From 1e2087676f8676c02046ff3b6f51a8d72416fd93 Mon Sep 17 00:00:00 2001 From: Matthias Hauber Date: Fri, 31 Jul 2026 18:13:57 +0200 Subject: [PATCH 1/3] feat(audit-logs): implement organization-wide audit logs configuration and resources --- src/config/hub-and-spoke-firewall.tfvars | 6 + src/config/hub-and-spoke.tfvars | 6 + src/main.tf | 1 + .../{3-bucket.tf => 3-object-storage.tf} | 40 ++++- src/modules/management/4-service-account.tf | 4 +- src/modules/management/6-audit-logs.tf | 143 ++++++++++++++++++ src/modules/management/README.md | 70 +++++---- src/modules/management/outputs.tf | 20 +++ src/modules/management/variables.tf | 27 ++++ src/modules/namespace-service-demo/main.tf | 2 +- src/outputs.tf | 15 ++ src/variables.tf | 14 ++ 12 files changed, 318 insertions(+), 30 deletions(-) rename src/modules/management/{3-bucket.tf => 3-object-storage.tf} (52%) create mode 100644 src/modules/management/6-audit-logs.tf diff --git a/src/config/hub-and-spoke-firewall.tfvars b/src/config/hub-and-spoke-firewall.tfvars index 6baf534..1f6dd99 100644 --- a/src/config/hub-and-spoke-firewall.tfvars +++ b/src/config/hub-and-spoke-firewall.tfvars @@ -35,6 +35,12 @@ labels = { # plan_name = "Observability-Starter-EU01" # } +# # Route organization-wide audit logs into a Logs instance in the management project. +# # Omit link_scopes for a single organization-wide link, or list folders/projects explicitly. +# audit_logs = { +# retention_days = 30 +# } + # # Federated identity providers for the management service account (e.g. GitHub Actions OIDC) # federated_identity_providers = [ # { diff --git a/src/config/hub-and-spoke.tfvars b/src/config/hub-and-spoke.tfvars index ed3d91b..998275b 100644 --- a/src/config/hub-and-spoke.tfvars +++ b/src/config/hub-and-spoke.tfvars @@ -35,6 +35,12 @@ labels = { # plan_name = "Observability-Starter-EU01" # } +# # Route organization-wide audit logs into a Logs instance in the management project. +# # Omit link_scopes for a single organization-wide link, or list folders/projects explicitly. +# audit_logs = { +# retention_days = 30 +# } + # # Federated identity providers for the management service account (e.g. GitHub Actions OIDC) # federated_identity_providers = [ # { diff --git a/src/main.tf b/src/main.tf index 71c8f47..4827c3d 100644 --- a/src/main.tf +++ b/src/main.tf @@ -28,6 +28,7 @@ module "management" { organization_id = var.organization_id labels = var.labels observability = var.observability + audit_logs = var.audit_logs federated_identity_providers = var.federated_identity_providers } diff --git a/src/modules/management/3-bucket.tf b/src/modules/management/3-object-storage.tf similarity index 52% rename from src/modules/management/3-bucket.tf rename to src/modules/management/3-object-storage.tf index c0a731e..956792f 100644 --- a/src/modules/management/3-bucket.tf +++ b/src/modules/management/3-object-storage.tf @@ -2,6 +2,16 @@ ## OBJECT STORAGE ## #################### +resource "stackit_objectstorage_compliance_lock" "this" { + count = try(var.audit_logs.s3_object_lock, false) ? 1 : 0 + + project_id = stackit_resourcemanager_project.this.project_id +} + +############# +## BUCKETS ## +############# + resource "stackit_objectstorage_bucket" "default" { name = "${var.naming_pattern}-default" project_id = stackit_resourcemanager_project.this.project_id @@ -16,13 +26,41 @@ resource "stackit_objectstorage_bucket" "tfstate" { ] } +resource "stackit_objectstorage_bucket" "audit_logs" { + name = "${var.naming_pattern}-audit-logs" + project_id = stackit_resourcemanager_project.this.project_id + + object_lock = local.audit_object_lock ? true : null + + depends_on = [ + stackit_objectstorage_bucket.tfstate, # "project.create_conflict","msg":"Two concurrent calls try to create the same project"}]} + stackit_objectstorage_compliance_lock.this, # object_lock requires the project lock to exist first + ] +} + +resource "stackit_objectstorage_default_retention" "audit_logs" { + count = local.audit_object_lock ? 1 : 0 + + project_id = stackit_resourcemanager_project.this.project_id + bucket_name = stackit_objectstorage_bucket.audit_logs.name + days = var.audit_logs.retention_days + + # GOVERNANCE, not COMPLIANCE: objects can still be removed early by a holder of s3:BypassGovernanceRetention, which keeps the bucket and project destroyable. + mode = "GOVERNANCE" +} + +################# +## CREDENTIALS ## +################# + resource "stackit_objectstorage_credentials_group" "this" { project_id = stackit_resourcemanager_project.this.project_id name = var.naming_pattern depends_on = [ stackit_objectstorage_bucket.default, - stackit_objectstorage_bucket.tfstate + stackit_objectstorage_bucket.tfstate, + stackit_objectstorage_bucket.audit_logs ] } diff --git a/src/modules/management/4-service-account.tf b/src/modules/management/4-service-account.tf index 4652731..01783e0 100644 --- a/src/modules/management/4-service-account.tf +++ b/src/modules/management/4-service-account.tf @@ -7,7 +7,7 @@ resource "stackit_service_account" "automation" { name = substr(replace("${var.naming_pattern}-automation", "-", ""), 0, 20) } -resource "time_rotating" "key_rotate" { +resource "time_rotating" "automation" { rotation_days = 60 } @@ -17,7 +17,7 @@ resource "stackit_service_account_key" "automation" { ttl_days = 90 rotate_when_changed = { - rotation = time_rotating.key_rotate.id + rotation = time_rotating.automation.id } } diff --git a/src/modules/management/6-audit-logs.tf b/src/modules/management/6-audit-logs.tf new file mode 100644 index 0000000..b481524 --- /dev/null +++ b/src/modules/management/6-audit-logs.tf @@ -0,0 +1,143 @@ +################### +## LOGS INSTANCE ## +################### + +resource "stackit_logs_instance" "audit" { + count = var.audit_logs != null ? 1 : 0 + + project_id = stackit_resourcemanager_project.this.project_id + display_name = "${var.naming_pattern}-audit" + retention_days = var.audit_logs.retention_days + acl = var.audit_logs.acl + description = "Audit log sink for ${var.naming_pattern}" +} + +resource "stackit_logs_access_token" "audit_write" { + count = var.audit_logs != null ? 1 : 0 + + project_id = stackit_resourcemanager_project.this.project_id + instance_id = stackit_logs_instance.audit[0].instance_id + display_name = "${var.naming_pattern}-audit-write" + permissions = ["write"] + description = "Bearer token the telemetry router uses to ingest audit logs" +} + +resource "stackit_logs_access_token" "audit_read" { + count = var.audit_logs != null ? 1 : 0 + + project_id = stackit_resourcemanager_project.this.project_id + instance_id = stackit_logs_instance.audit[0].instance_id + display_name = "${var.naming_pattern}-audit-read" + permissions = ["read"] + description = "Read token for querying audit logs, e.g. as a Grafana datasource" +} + +###################### +## TELEMETRY ROUTER ## +###################### + +resource "stackit_telemetryrouter_instance" "audit" { + count = var.audit_logs != null ? 1 : 0 + + project_id = stackit_resourcemanager_project.this.project_id + display_name = "${var.naming_pattern}-audit" + description = "Central ingestion point for STACKIT audit logs" +} + +resource "stackit_telemetryrouter_destination" "audit_logs" { + count = var.audit_logs != null ? 1 : 0 + + project_id = stackit_resourcemanager_project.this.project_id + instance_id = stackit_telemetryrouter_instance.audit[0].instance_id + display_name = "${var.naming_pattern}-audit-logs" + description = "Forwards the audit log stream into the Logs instance" + + config = { + config_type = "OpenTelemetry" + opentelemetry = { + uri = stackit_logs_instance.audit[0].ingest_otlp_url + bearer_token = stackit_logs_access_token.audit_write[0].access_token + } + } +} + +resource "stackit_telemetryrouter_destination" "audit_archive" { + count = var.audit_logs != null ? 1 : 0 + + project_id = stackit_resourcemanager_project.this.project_id + instance_id = stackit_telemetryrouter_instance.audit[0].instance_id + display_name = "${var.naming_pattern}-audit-archive" + description = "Archives the audit log stream to object storage for long-term retention" + + config = { + config_type = "S3" + s3 = { + bucket = stackit_objectstorage_bucket.audit_logs.name + endpoint = trimsuffix(stackit_objectstorage_bucket.audit_logs.url_path_style, "/${stackit_objectstorage_bucket.audit_logs.name}") + access_key = { + id = stackit_objectstorage_credential.this.access_key + secret = stackit_objectstorage_credential.this.secret_access_key + } + } + } +} + +resource "stackit_telemetryrouter_access_token" "audit_link" { + count = var.audit_logs != null ? 1 : 0 + + project_id = stackit_resourcemanager_project.this.project_id + instance_id = stackit_telemetryrouter_instance.audit[0].instance_id + display_name = "${var.naming_pattern}-audit-link" + description = "Used by telemetry links to push audit logs into the router" +} + +#################### +## TELEMETRY LINK ## +#################### + +locals { + audit_log_link_scopes = var.audit_logs != null ? coalesce( + var.audit_logs.link_scopes, + [{ resource_type = "organization", resource_id = var.organization_id }] + ) : [] +} + +# link(org/folder/project) --> telemetry router --+--> OTLP --> logs instance (query) +# `--> S3 --> audit bucket (archive) +resource "stackit_telemetrylink" "audit" { + for_each = { for scope in local.audit_log_link_scopes : "${scope.resource_type}-${scope.resource_id}" => scope } + + resource_type = each.value.resource_type + resource_id = each.value.resource_id + display_name = "${var.naming_pattern}-audit" + description = "Streams ${each.value.resource_type} audit logs to the platform telemetry router" + telemetry_router_id = stackit_telemetryrouter_instance.audit[0].instance_id + access_token = stackit_telemetryrouter_access_token.audit_link[0].access_token +} + +############ +## SECRET ## +############ + +resource "vault_kv_secret_v2" "audit_logs" { + count = var.audit_logs != null ? 1 : 0 + + mount = stackit_secretsmanager_instance.this.instance_id + name = "audit_logs_${replace(var.naming_pattern, "-", "_")}" + cas = 1 + delete_all_versions = true + data_json = jsonencode( + { + INGEST_OTLP_URL = stackit_logs_instance.audit[0].ingest_otlp_url + QUERY_URL = stackit_logs_instance.audit[0].query_url + DATASOURCE_URL = stackit_logs_instance.audit[0].datasource_url + WRITE_TOKEN = stackit_logs_access_token.audit_write[0].access_token + READ_TOKEN = stackit_logs_access_token.audit_read[0].access_token + ROUTER_TOKEN = stackit_telemetryrouter_access_token.audit_link[0].access_token + + # Credentials for this bucket are the project ones, already in object_storage_credentials_*. + ARCHIVE_BUCKET = stackit_objectstorage_bucket.audit_logs.name + ARCHIVE_ENDPOINT = trimsuffix(stackit_objectstorage_bucket.audit_logs.url_path_style, "/${stackit_objectstorage_bucket.audit_logs.name}") + } + ) +} diff --git a/src/modules/management/README.md b/src/modules/management/README.md index b5c9b49..8d84a8c 100644 --- a/src/modules/management/README.md +++ b/src/modules/management/README.md @@ -2,16 +2,16 @@ ## Requirements | Name | Version | -|------|---------| +| ---- | ------- | | [terraform](#requirement\_terraform) | >= 1.10 | -| [stackit](#requirement\_stackit) | >=0.93.0 | -| [time](#requirement\_time) | >=0.13.1 | -| [vault](#requirement\_vault) | >=5.7.0 | +| [stackit](#requirement\_stackit) | 0.106.0 | +| [time](#requirement\_time) | ~> 0.14.0 | +| [vault](#requirement\_vault) | 5.10.1 | ## Providers | Name | Version | -|------|---------| +| ---- | ------- | | [stackit](#provider\_stackit) | 0.93.0 | | [time](#provider\_time) | 0.13.1 | | [vault](#provider\_vault) | 5.9.0 | @@ -23,30 +23,43 @@ No modules. ## Resources | Name | Type | -|------|------| -| [stackit_authorization_organization_role_assignment.sa_owner](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/resources/authorization_organization_role_assignment) | resource | -| [stackit_authorization_project_role_assignment.this](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/resources/authorization_project_role_assignment) | resource | -| [stackit_objectstorage_bucket.default](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/resources/objectstorage_bucket) | resource | -| [stackit_objectstorage_bucket.tfstate](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/resources/objectstorage_bucket) | resource | -| [stackit_objectstorage_credential.this](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/resources/objectstorage_credential) | resource | -| [stackit_objectstorage_credentials_group.this](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/resources/objectstorage_credentials_group) | resource | -| [stackit_observability_credential.this](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/resources/observability_credential) | resource | -| [stackit_observability_instance.this](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/resources/observability_instance) | resource | -| [stackit_resourcemanager_project.this](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/resources/resourcemanager_project) | resource | -| [stackit_secretsmanager_instance.this](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/resources/secretsmanager_instance) | resource | -| [stackit_secretsmanager_user.default](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/resources/secretsmanager_user) | resource | -| [stackit_service_account.automation](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/resources/service_account) | resource | -| [stackit_service_account_federated_identity_provider.this](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/resources/service_account_federated_identity_provider) | resource | -| [stackit_service_account_key.automation](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/resources/service_account_key) | resource | -| [time_rotating.key_rotate](https://registry.terraform.io/providers/hashicorp/time/latest/docs/resources/rotating) | resource | -| [vault_kv_secret_v2.object_storage_credentials](https://registry.terraform.io/providers/hashicorp/vault/latest/docs/resources/kv_secret_v2) | resource | -| [vault_kv_secret_v2.observability](https://registry.terraform.io/providers/hashicorp/vault/latest/docs/resources/kv_secret_v2) | resource | -| [vault_kv_secret_v2.service_account_key_automation](https://registry.terraform.io/providers/hashicorp/vault/latest/docs/resources/kv_secret_v2) | resource | +| ---- | ---- | +| [stackit_authorization_organization_role_assignment.sa_owner](https://registry.terraform.io/providers/stackitcloud/stackit/0.106.0/docs/resources/authorization_organization_role_assignment) | resource | +| [stackit_authorization_project_role_assignment.this](https://registry.terraform.io/providers/stackitcloud/stackit/0.106.0/docs/resources/authorization_project_role_assignment) | resource | +| [stackit_logs_access_token.audit_read](https://registry.terraform.io/providers/stackitcloud/stackit/0.106.0/docs/resources/logs_access_token) | resource | +| [stackit_logs_access_token.audit_write](https://registry.terraform.io/providers/stackitcloud/stackit/0.106.0/docs/resources/logs_access_token) | resource | +| [stackit_logs_instance.audit](https://registry.terraform.io/providers/stackitcloud/stackit/0.106.0/docs/resources/logs_instance) | resource | +| [stackit_objectstorage_bucket.audit_logs](https://registry.terraform.io/providers/stackitcloud/stackit/0.106.0/docs/resources/objectstorage_bucket) | resource | +| [stackit_objectstorage_bucket.default](https://registry.terraform.io/providers/stackitcloud/stackit/0.106.0/docs/resources/objectstorage_bucket) | resource | +| [stackit_objectstorage_bucket.tfstate](https://registry.terraform.io/providers/stackitcloud/stackit/0.106.0/docs/resources/objectstorage_bucket) | resource | +| [stackit_objectstorage_compliance_lock.this](https://registry.terraform.io/providers/stackitcloud/stackit/0.106.0/docs/resources/objectstorage_compliance_lock) | resource | +| [stackit_objectstorage_credential.this](https://registry.terraform.io/providers/stackitcloud/stackit/0.106.0/docs/resources/objectstorage_credential) | resource | +| [stackit_objectstorage_credentials_group.this](https://registry.terraform.io/providers/stackitcloud/stackit/0.106.0/docs/resources/objectstorage_credentials_group) | resource | +| [stackit_objectstorage_default_retention.audit_logs](https://registry.terraform.io/providers/stackitcloud/stackit/0.106.0/docs/resources/objectstorage_default_retention) | resource | +| [stackit_observability_credential.this](https://registry.terraform.io/providers/stackitcloud/stackit/0.106.0/docs/resources/observability_credential) | resource | +| [stackit_observability_instance.this](https://registry.terraform.io/providers/stackitcloud/stackit/0.106.0/docs/resources/observability_instance) | resource | +| [stackit_resourcemanager_project.this](https://registry.terraform.io/providers/stackitcloud/stackit/0.106.0/docs/resources/resourcemanager_project) | resource | +| [stackit_secretsmanager_instance.this](https://registry.terraform.io/providers/stackitcloud/stackit/0.106.0/docs/resources/secretsmanager_instance) | resource | +| [stackit_secretsmanager_user.default](https://registry.terraform.io/providers/stackitcloud/stackit/0.106.0/docs/resources/secretsmanager_user) | resource | +| [stackit_service_account.automation](https://registry.terraform.io/providers/stackitcloud/stackit/0.106.0/docs/resources/service_account) | resource | +| [stackit_service_account_federated_identity_provider.this](https://registry.terraform.io/providers/stackitcloud/stackit/0.106.0/docs/resources/service_account_federated_identity_provider) | resource | +| [stackit_service_account_key.automation](https://registry.terraform.io/providers/stackitcloud/stackit/0.106.0/docs/resources/service_account_key) | resource | +| [stackit_telemetrylink.audit](https://registry.terraform.io/providers/stackitcloud/stackit/0.106.0/docs/resources/telemetrylink) | resource | +| [stackit_telemetryrouter_access_token.audit_link](https://registry.terraform.io/providers/stackitcloud/stackit/0.106.0/docs/resources/telemetryrouter_access_token) | resource | +| [stackit_telemetryrouter_destination.audit_archive](https://registry.terraform.io/providers/stackitcloud/stackit/0.106.0/docs/resources/telemetryrouter_destination) | resource | +| [stackit_telemetryrouter_destination.audit_logs](https://registry.terraform.io/providers/stackitcloud/stackit/0.106.0/docs/resources/telemetryrouter_destination) | resource | +| [stackit_telemetryrouter_instance.audit](https://registry.terraform.io/providers/stackitcloud/stackit/0.106.0/docs/resources/telemetryrouter_instance) | resource | +| [time_rotating.automation](https://registry.terraform.io/providers/hashicorp/time/latest/docs/resources/rotating) | resource | +| [vault_kv_secret_v2.audit_logs](https://registry.terraform.io/providers/hashicorp/vault/5.10.1/docs/resources/kv_secret_v2) | resource | +| [vault_kv_secret_v2.object_storage_credentials](https://registry.terraform.io/providers/hashicorp/vault/5.10.1/docs/resources/kv_secret_v2) | resource | +| [vault_kv_secret_v2.observability](https://registry.terraform.io/providers/hashicorp/vault/5.10.1/docs/resources/kv_secret_v2) | resource | +| [vault_kv_secret_v2.service_account_key_automation](https://registry.terraform.io/providers/hashicorp/vault/5.10.1/docs/resources/kv_secret_v2) | resource | ## Inputs | Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| +| ---- | ----------- | ---- | ------- | :------: | +| [audit\_logs](#input\_audit\_logs) | Audit logs configuration. The router forwards to two destinations: OTLP into the Logs instance for querying, and S3 into the audit bucket for long-term archive. retention\_days applies to both, driving the Logs instance retention and, when s3\_object\_lock is enabled, the archive bucket's default retention. link\_scopes defaults to a single organization-wide link; set it to attach individual folders or projects instead. |
object({
retention_days = optional(number, 30)
acl = optional(list(string), null)
s3_object_lock = optional(bool, false)
link_scopes = optional(list(object({
resource_type = string # organization, folder, project
resource_id = string
})), null)
})
| `null` | no | | [federated\_identity\_providers](#input\_federated\_identity\_providers) | List of federated identity providers to configure for the management service account. |
list(object({
name = string
issuer = string
assertions = list(object({
item = string
operator = string
value = string
}))
}))
| `[]` | no | | [labels](#input\_labels) | Additional labels to apply to all folders. | `map(string)` | `{}` | no | | [naming\_pattern](#input\_naming\_pattern) | Naming prefix for all resources in this module, e.g. "myco-pltfm-hub-prod". | `string` | n/a | yes | @@ -60,11 +73,16 @@ No modules. ## Outputs | Name | Description | -|------|-------------| +| ---- | ----------- | +| [audit\_logs\_bucket\_name](#output\_audit\_logs\_bucket\_name) | The name of the object storage bucket archiving audit logs. | +| [audit\_logs\_datasource\_url](#output\_audit\_logs\_datasource\_url) | Datasource URL of the audit Logs instance, usable as a Grafana datasource. | +| [audit\_logs\_instance\_id](#output\_audit\_logs\_instance\_id) | The ID of the Logs instance receiving audit logs. | +| [audit\_telemetry\_router\_id](#output\_audit\_telemetry\_router\_id) | The ID of the Telemetry Router collecting audit logs. | | [bucket\_name\_tfstate](#output\_bucket\_name\_tfstate) | The name of the tfstate object storage bucket. | | [project\_container\_id](#output\_project\_container\_id) | The container ID of the created STACKIT project. | | [project\_id](#output\_project\_id) | The project ID of the created STACKIT project. | | [project\_name](#output\_project\_name) | The name of the created STACKIT project. | +| [secretsmanager\_instance\_id](#output\_secretsmanager\_instance\_id) | The ID of the Secrets Manager instance, usable as the vault mount. | | [secretsmanager\_password](#output\_secretsmanager\_password) | The password of the default Secrets Manager user. | | [secretsmanager\_username](#output\_secretsmanager\_username) | The username of the default Secrets Manager user. | | [service\_account\_email](#output\_service\_account\_email) | The email of the created service account. | diff --git a/src/modules/management/outputs.tf b/src/modules/management/outputs.tf index 4675743..763545a 100644 --- a/src/modules/management/outputs.tf +++ b/src/modules/management/outputs.tf @@ -37,3 +37,23 @@ output "secretsmanager_instance_id" { description = "The ID of the Secrets Manager instance, usable as the vault mount." value = stackit_secretsmanager_instance.this.instance_id } + +output "audit_logs_instance_id" { + description = "The ID of the Logs instance receiving audit logs." + value = try(stackit_logs_instance.audit[0].instance_id, null) +} + +output "audit_logs_datasource_url" { + description = "Datasource URL of the audit Logs instance, usable as a Grafana datasource." + value = try(stackit_logs_instance.audit[0].datasource_url, null) +} + +output "audit_telemetry_router_id" { + description = "The ID of the Telemetry Router collecting audit logs." + value = try(stackit_telemetryrouter_instance.audit[0].instance_id, null) +} + +output "audit_logs_bucket_name" { + description = "The name of the object storage bucket archiving audit logs." + value = stackit_objectstorage_bucket.audit_logs.name +} diff --git a/src/modules/management/variables.tf b/src/modules/management/variables.tf index a5e5ed6..f1338c9 100644 --- a/src/modules/management/variables.tf +++ b/src/modules/management/variables.tf @@ -65,4 +65,31 @@ variable "federated_identity_providers" { })) description = "List of federated identity providers to configure for the management service account." default = [] +} + +variable "audit_logs" { + type = object({ + retention_days = optional(number, 30) + acl = optional(list(string), null) + s3_object_lock = optional(bool, true) + link_scopes = optional(list(object({ + resource_type = string # organization, folder, project + resource_id = string + })), null) + }) + description = "Audit logs configuration. The router forwards to two destinations: OTLP into the Logs instance for querying, and S3 into the audit bucket for long-term archive. retention_days applies to both, driving the Logs instance retention and, when s3_object_lock is enabled, the archive bucket's default retention. link_scopes defaults to a single organization-wide link; set it to attach individual folders or projects instead." + default = null + + validation { + condition = alltrue([ + for scope in try(var.audit_logs.link_scopes, []) == null ? [] : try(var.audit_logs.link_scopes, []) : + contains(["organization", "folder", "project"], scope.resource_type) + ]) + error_message = "audit_logs.link_scopes[*].resource_type must be one of: organization, folder, project." + } + + validation { + condition = try(!var.audit_logs.s3_object_lock || (var.audit_logs.retention_days >= 1 && var.audit_logs.retention_days <= 365), true) + error_message = "audit_logs.retention_days must be between 1 and 365 when s3_object_lock is enabled, since that is the STACKIT object storage maximum." + } } \ No newline at end of file diff --git a/src/modules/namespace-service-demo/main.tf b/src/modules/namespace-service-demo/main.tf index 14df412..9852b86 100644 --- a/src/modules/namespace-service-demo/main.tf +++ b/src/modules/namespace-service-demo/main.tf @@ -6,7 +6,7 @@ terraform { } grafana = { source = "grafana/grafana" - version = "4.41.0" + version = "4.42.0" } } } diff --git a/src/outputs.tf b/src/outputs.tf index 0355026..2d67061 100644 --- a/src/outputs.tf +++ b/src/outputs.tf @@ -22,6 +22,21 @@ output "management_bucket_name_tfstate" { value = module.management.bucket_name_tfstate } +output "audit_logs_instance_id" { + description = "The ID of the Logs instance receiving audit logs. Null when audit_logs is not configured." + value = module.management.audit_logs_instance_id +} + +output "audit_logs_datasource_url" { + description = "Datasource URL of the audit Logs instance, usable as a Grafana datasource." + value = module.management.audit_logs_datasource_url +} + +output "audit_telemetry_router_id" { + description = "The ID of the Telemetry Router collecting audit logs." + value = module.management.audit_telemetry_router_id +} + output "connectivity_network_area_id" { description = "The network area ID created by the regional module." value = try(module.connectivity[0].network_area_id, null) diff --git a/src/variables.tf b/src/variables.tf index 6359598..6ff02ab 100644 --- a/src/variables.tf +++ b/src/variables.tf @@ -182,6 +182,20 @@ variable "observability" { default = null } +variable "audit_logs" { + type = object({ + retention_days = optional(number, 30) + acl = optional(list(string), ["0.0.0.0/0"]) + s3_object_lock = optional(bool, true) + link_scopes = optional(list(object({ + resource_type = string # organization, folder, project + resource_id = string + })), null) + }) + description = "Audit log routing for the management module. A Telemetry Link streams audit events into a Telemetry Router, which forwards them to a Logs instance and archives them to object storage. link_scopes defaults to a single organization-wide link. retention_days applies to both the Logs instance and, when s3_object_lock is enabled, the archive bucket's default retention. s3_object_lock is off by default and turns on GOVERNANCE-mode WORM protection for the archive bucket, which is not cleanly reversible. Set to null to skip audit logs deployment." + default = null +} + variable "federated_identity_providers" { type = list(object({ name = string From 4f1dbefe536cdcd79bab2ccd2c0848c5dffd0f9e Mon Sep 17 00:00:00 2001 From: Matthias Hauber Date: Fri, 31 Jul 2026 18:31:38 +0200 Subject: [PATCH 2/3] fix(audit-logs): add HTTPS scheme to URLs and update compliance lock dependencies --- src/modules/management/3-object-storage.tf | 25 +++++++++++----------- src/modules/management/6-audit-logs.tf | 9 ++++---- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/src/modules/management/3-object-storage.tf b/src/modules/management/3-object-storage.tf index 956792f..305718c 100644 --- a/src/modules/management/3-object-storage.tf +++ b/src/modules/management/3-object-storage.tf @@ -1,13 +1,3 @@ -#################### -## OBJECT STORAGE ## -#################### - -resource "stackit_objectstorage_compliance_lock" "this" { - count = try(var.audit_logs.s3_object_lock, false) ? 1 : 0 - - project_id = stackit_resourcemanager_project.this.project_id -} - ############# ## BUCKETS ## ############# @@ -26,11 +16,22 @@ resource "stackit_objectstorage_bucket" "tfstate" { ] } +resource "stackit_objectstorage_compliance_lock" "this" { + count = try(var.audit_logs.s3_object_lock, false) ? 1 : 0 + + project_id = stackit_resourcemanager_project.this.project_id + + depends_on = [ + stackit_objectstorage_bucket.default, + stackit_objectstorage_bucket.tfstate, + ] +} + resource "stackit_objectstorage_bucket" "audit_logs" { name = "${var.naming_pattern}-audit-logs" project_id = stackit_resourcemanager_project.this.project_id - object_lock = local.audit_object_lock ? true : null + object_lock = try(var.audit_logs.s3_object_lock, false) ? true : null depends_on = [ stackit_objectstorage_bucket.tfstate, # "project.create_conflict","msg":"Two concurrent calls try to create the same project"}]} @@ -39,7 +40,7 @@ resource "stackit_objectstorage_bucket" "audit_logs" { } resource "stackit_objectstorage_default_retention" "audit_logs" { - count = local.audit_object_lock ? 1 : 0 + count = try(var.audit_logs.s3_object_lock, false) ? 1 : 0 project_id = stackit_resourcemanager_project.this.project_id bucket_name = stackit_objectstorage_bucket.audit_logs.name diff --git a/src/modules/management/6-audit-logs.tf b/src/modules/management/6-audit-logs.tf index b481524..f2c94ed 100644 --- a/src/modules/management/6-audit-logs.tf +++ b/src/modules/management/6-audit-logs.tf @@ -55,7 +55,7 @@ resource "stackit_telemetryrouter_destination" "audit_logs" { config = { config_type = "OpenTelemetry" opentelemetry = { - uri = stackit_logs_instance.audit[0].ingest_otlp_url + uri = "https://${stackit_logs_instance.audit[0].ingest_otlp_url}" bearer_token = stackit_logs_access_token.audit_write[0].access_token } } @@ -128,9 +128,10 @@ resource "vault_kv_secret_v2" "audit_logs" { delete_all_versions = true data_json = jsonencode( { - INGEST_OTLP_URL = stackit_logs_instance.audit[0].ingest_otlp_url - QUERY_URL = stackit_logs_instance.audit[0].query_url - DATASOURCE_URL = stackit_logs_instance.audit[0].datasource_url + # The API returns these without a scheme; store them ready to use. + INGEST_OTLP_URL = "https://${stackit_logs_instance.audit[0].ingest_otlp_url}" + QUERY_URL = "https://${stackit_logs_instance.audit[0].query_url}" + DATASOURCE_URL = "https://${stackit_logs_instance.audit[0].datasource_url}" WRITE_TOKEN = stackit_logs_access_token.audit_write[0].access_token READ_TOKEN = stackit_logs_access_token.audit_read[0].access_token ROUTER_TOKEN = stackit_telemetryrouter_access_token.audit_link[0].access_token From fa4a87ef6772aa270df10db871220094e947ad6f Mon Sep 17 00:00:00 2001 From: Matthias Hauber Date: Fri, 31 Jul 2026 18:35:32 +0200 Subject: [PATCH 3/3] feat(audit-logs): update retention_days for audit logs to 180 days --- src/config/hub-and-spoke-firewall.tfvars | 2 +- src/config/hub-and-spoke.tfvars | 2 +- src/modules/management/variables.tf | 2 +- src/variables.tf | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/config/hub-and-spoke-firewall.tfvars b/src/config/hub-and-spoke-firewall.tfvars index 1f6dd99..b89f0c9 100644 --- a/src/config/hub-and-spoke-firewall.tfvars +++ b/src/config/hub-and-spoke-firewall.tfvars @@ -38,7 +38,7 @@ labels = { # # Route organization-wide audit logs into a Logs instance in the management project. # # Omit link_scopes for a single organization-wide link, or list folders/projects explicitly. # audit_logs = { -# retention_days = 30 +# retention_days = 180 # } # # Federated identity providers for the management service account (e.g. GitHub Actions OIDC) diff --git a/src/config/hub-and-spoke.tfvars b/src/config/hub-and-spoke.tfvars index 998275b..a22e54a 100644 --- a/src/config/hub-and-spoke.tfvars +++ b/src/config/hub-and-spoke.tfvars @@ -38,7 +38,7 @@ labels = { # # Route organization-wide audit logs into a Logs instance in the management project. # # Omit link_scopes for a single organization-wide link, or list folders/projects explicitly. # audit_logs = { -# retention_days = 30 +# retention_days = 180 # } # # Federated identity providers for the management service account (e.g. GitHub Actions OIDC) diff --git a/src/modules/management/variables.tf b/src/modules/management/variables.tf index f1338c9..9d8361f 100644 --- a/src/modules/management/variables.tf +++ b/src/modules/management/variables.tf @@ -69,7 +69,7 @@ variable "federated_identity_providers" { variable "audit_logs" { type = object({ - retention_days = optional(number, 30) + retention_days = optional(number, 180) acl = optional(list(string), null) s3_object_lock = optional(bool, true) link_scopes = optional(list(object({ diff --git a/src/variables.tf b/src/variables.tf index 6ff02ab..3af977a 100644 --- a/src/variables.tf +++ b/src/variables.tf @@ -184,7 +184,7 @@ variable "observability" { variable "audit_logs" { type = object({ - retention_days = optional(number, 30) + retention_days = optional(number, 180) acl = optional(list(string), ["0.0.0.0/0"]) s3_object_lock = optional(bool, true) link_scopes = optional(list(object({