Skip to content

Add Audit Event exports (POST/GET /audit-exports) - #61

Open
Adron wants to merge 3 commits into
mainfrom
feature/audit-export
Open

Adron wants to merge 3 commits into
mainfrom
feature/audit-export

Conversation

@Adron

@Adron Adron commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

Closes #33. Closes #56 (the console's minimum export shape, which this follows).

What's new

  • POST /audit-exports:
    • Takes the same filters as GET /audit-events, as body fields (action is an array, target_user_id may be me), plus format: ndjson (the default) or csv.
    • The caller is confined to the same scope as on the list: an app-confined key gets only its own Application's events, and a User without audit.view only their own trail or a Tenant they own.
    • Idempotency-Key is required. Returns 202 with the export at once. Limited to 20 per caller per day.
  • GET /audit-exports/{id}: status, plus once it's ready a download link signed for 15 minutes, freshly signed on each call. Only the requester and audit.view can see an export; anyone else gets 404 audit_export_not_found. Records and files are deleted after 7 days.
  • Worker: the audit-exports job runs every minute; the devserver runs it every 15 seconds.
    • It claims one export at a time with for update skip locked, and retries a running export whose worker died once it's 20 minutes old.
    • It pages through the events oldest first, 1,000 per short transaction, into a temp file, then uploads it.
    • More than 1,000,000 rows fails with too_many_rows.
  • Storage: a new ExportStore interface: S3 in AWS (AUDIT_EXPORT_BUCKET), .dev/audit-exports locally, and in memory in tests.
  • Audit: audit.exported (target audit_export) is written when the file is ready. The requester is the actor, after holds the filters, format, and row count, and the event is written in the export's own scope, so it shows up in the trail that was exported.
  • Refactor: audit.go's scope and filter building is shared by the list and the worker. List behavior is unchanged.

Retention

#33 also asked for retention-policy enforcement. That's already built: the one-year hot window, and the nightly audit-archive job that moves older events, shape-only, to indefinite Glacier Deep Archive storage. The Changelog and roadmap now say so; no new code was needed.

Infrastructure (Terraform)

  • A new private bucket substratal-prod-audit-exports-<account>: SSE-S3, public access blocked, with a lifecycle rule that deletes objects after 7 days.
  • The jobs Lambda gets s3:PutObject on it.
  • The API Lambda gets s3:GetObject on it, to sign download links.
  • AUDIT_EXPORT_BUCKET is set on both Lambdas.
  • An audit-exports schedule, rate(1 minute).

terraform validate passes. Cost is negligible: one schedule a minute, and short-lived objects.

Not resolved here

Decision #37 (#7: audit immutability through a trigger, or a separate database role) is still your call. #33 listed it, but export doesn't depend on it: an export only reads.

Tests

TestAuditExport:

  • An NDJSON export by audit.view matches the list, row for row, oldest first.
  • audit.exported is written with the requester as actor.
  • An idempotent retry returns the same export.
  • A CSV export has the header plus one row per event.
  • An invalid format is rejected.
  • A User is refused without me, can export their own trail, and can't see someone else's export.
  • An app-confined key's export contains only its own Application's events.

The full integration and contract suite, gen-check, and terraform fmt and validate pass locally.

Merge order

Update (2026-10-22): rebased on the current API

🤖 Generated with Claude Code

The audit log can be exported to a file for compliance evidence
(API Reference → Audit → Exporting), in the shape the console needs
(#56):

- POST /v1/audit-exports: the same filters and caller scope as
  GET /v1/audit-events, plus format ndjson|csv; Idempotency-Key required;
  202 with the export. 20 per caller per day.
- GET /v1/audit-exports/{id}: status, and once ready a download link
  signed for 15 minutes. Requester and audit.view only. Kept 7 days.
- A jobs worker (audit-exports, every minute; every 15 s in the
  devserver) pages through the events into a temp file and uploads it
  through a new ExportStore: a private S3 bucket with a 7-day lifecycle
  rule in AWS, .dev/audit-exports locally, in memory in tests.
- audit.exported is written when the file is ready, with the requester
  as actor, in the export's own scope.
- audit.go's scope and filters are shared by the list and the worker.
- Migration 0007_audit_exports (after 0005 and 0006, keeping
  user.expired in the action check). Terraform: the exports bucket,
  env, IAM, and schedule.

Retention needed no code: the hot window and archival already enforce
it; the docs now say so.

Closes #33
Closes #56

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

terraform plan (production)

This is exactly what merging will change in AWS.

Plan
terraform_data.app_secret_init: Refreshing state... [id=5474e6c3-4843-79d8-9346-9fc3d3f8fa87]
random_password.mcp_session: Refreshing state... [id=none]
data.aws_caller_identity.me: Reading...
module.jobs.aws_cloudwatch_log_group.this: Refreshing state... [id=/aws/lambda/substratal-prod-jobs]
aws_apigatewayv2_api.main: Refreshing state... [id=rv9n7e3ww4]
aws_sqs_queue.webhooks_dlq: Refreshing state... [id=https://sqs.us-east-1.amazonaws.com/196936048600/substratal-prod-webhooks-dlq]
aws_sns_topic.alarms: Refreshing state... [id=arn:aws:sns:us-east-1:196936048600:substratal-prod-alarms]
module.jobs.aws_iam_role.this: Refreshing state... [id=substratal-prod-jobs]
module.webhook_worker.aws_cloudwatch_log_group.this: Refreshing state... [id=/aws/lambda/substratal-prod-webhook-worker]
aws_rds_cluster.main: Refreshing state... [id=substratal-prod-db]
module.mcp.aws_cloudwatch_log_group.this: Refreshing state... [id=/aws/lambda/substratal-prod-mcp]
module.stripe_webhook.aws_iam_role.this: Refreshing state... [id=substratal-prod-stripe-webhook]
data.aws_caller_identity.me: Read complete after 0s [id=196936048600]
aws_sesv2_email_identity.mail: Refreshing state... [id=mail.substratalapps.com]
aws_acm_certificate.api: Refreshing state... [id=arn:aws:acm:us-east-1:196936048600:certificate/a8e2136f-64d3-456b-904a-05ea74eba9b3]
aws_cloudwatch_log_group.api_access: Refreshing state... [id=/aws/apigateway/substratal-prod]
module.mcp.aws_iam_role.this: Refreshing state... [id=substratal-prod-mcp]
aws_iam_role.scheduler: Refreshing state... [id=substratal-prod-scheduler]
aws_kms_key.signing: Refreshing state... [id=0d2770cd-bbc8-4904-a432-2e9c9304a3ca]
data.aws_servicequotas_service_quota.lambda_concurrency: Reading...
aws_secretsmanager_secret.app: Refreshing state... [id=arn:aws:secretsmanager:us-east-1:196936048600:secret:substratal-prod/app-1hXTGg]
module.webhook_worker.aws_iam_role.this: Refreshing state... [id=substratal-prod-webhook-worker]
module.api.aws_iam_role.this: Refreshing state... [id=substratal-prod-api]
aws_kms_key.data: Refreshing state... [id=b0cda5a8-651e-419f-b8ab-61772bce79da]
data.aws_route53_zone.main: Reading...
data.aws_servicequotas_service_quota.lambda_concurrency: Read complete after 0s [id=arn:aws:servicequotas:us-east-1::lambda/L-B99A9384]
module.stripe_webhook.aws_cloudwatch_log_group.this: Refreshing state... [id=/aws/lambda/substratal-prod-stripe-webhook]
module.api.aws_cloudwatch_log_group.this: Refreshing state... [id=/aws/lambda/substratal-prod-api]
aws_sqs_queue.webhooks: Refreshing state... [id=https://sqs.us-east-1.amazonaws.com/196936048600/substratal-prod-webhooks]
aws_s3_bucket.audit_archive: Refreshing state... [id=substratal-prod-audit-archive-196936048600]
aws_cloudwatch_metric_alarm.webhook_dlq: Refreshing state... [id=substratal-prod-webhooks-dlq]
aws_cloudwatch_metric_alarm.api_5xx: Refreshing state... [id=substratal-prod-api-5xx]
aws_cloudwatch_metric_alarm.security["failed-logins"]: Refreshing state... [id=substratal-prod-security-failed-logins]
aws_cloudwatch_metric_alarm.security["refresh-reuse"]: Refreshing state... [id=substratal-prod-security-refresh-reuse]
aws_cloudwatch_metric_alarm.security["destructive-blocked"]: Refreshing state... [id=substratal-prod-security-destructive-blocked]
aws_apigatewayv2_stage.default: Refreshing state... [id=$default]
aws_rds_cluster_instance.main: Refreshing state... [id=substratal-prod-db-1]
aws_cloudwatch_metric_alarm.aurora_near_max: Refreshing state... [id=substratal-prod-aurora-acu-near-max]
module.mcp.aws_iam_role_policy.logs: Refreshing state... [id=substratal-prod-mcp:logs]
module.jobs.aws_iam_role_policy.logs: Refreshing state... [id=substratal-prod-jobs:logs]
aws_kms_alias.signing: Refreshing state... [id=alias/substratal-prod-jwt-signing]
module.stripe_webhook.aws_iam_role_policy.logs: Refreshing state... [id=substratal-prod-stripe-webhook:logs]
aws_kms_alias.data: Refreshing state... [id=alias/substratal-prod-data]
data.aws_route53_zone.main: Read complete after 1s [id=Z0783627X8ON1GH6I0XB]
module.api.aws_iam_role_policy.logs: Refreshing state... [id=substratal-prod-api:logs]
module.webhook_worker.aws_iam_role_policy.logs: Refreshing state... [id=substratal-prod-webhook-worker:logs]
module.mcp.aws_lambda_function.this: Refreshing state... [id=substratal-prod-mcp]
aws_cloudwatch_log_metric_filter.security["refresh-reuse"]: Refreshing state... [id=substratal-prod-refresh-reuse]
aws_cloudwatch_log_metric_filter.security["failed-logins"]: Refreshing state... [id=substratal-prod-failed-logins]
aws_cloudwatch_log_metric_filter.security["destructive-blocked"]: Refreshing state... [id=substratal-prod-destructive-blocked]
aws_route53_record.dkim[2]: Refreshing state... [id=Z0783627X8ON1GH6I0XB_rp4dt5rjjkpnhdyxud6l2spetqo5nom4._domainkey.mail.substratalapps.com_CNAME]
aws_route53_record.dkim[0]: Refreshing state... [id=Z0783627X8ON1GH6I0XB_fmy2g7whqti56v5vn56es6izxo4yzayc._domainkey.mail.substratalapps.com_CNAME]
aws_route53_record.dkim[1]: Refreshing state... [id=Z0783627X8ON1GH6I0XB_fhl63kaowzjfd2splohuyaifnnansx5d._domainkey.mail.substratalapps.com_CNAME]
aws_route53_record.dmarc: Refreshing state... [id=Z0783627X8ON1GH6I0XB__dmarc.mail.substratalapps.com_TXT]
aws_route53_record.cert_validation["api.substratalapps.com"]: Refreshing state... [id=Z0783627X8ON1GH6I0XB__45a2e898c02bdc2297dde6c5e5a798a8.api.substratalapps.com._CNAME]
module.stripe_webhook.aws_iam_role_policy.this[0]: Refreshing state... [id=substratal-prod-stripe-webhook:function]
module.api.aws_iam_role_policy.this[0]: Refreshing state... [id=substratal-prod-api:function]
module.webhook_worker.aws_iam_role_policy.this[0]: Refreshing state... [id=substratal-prod-webhook-worker:function]
module.stripe_webhook.aws_lambda_function.this: Refreshing state... [id=substratal-prod-stripe-webhook]
module.api.aws_lambda_function.this: Refreshing state... [id=substratal-prod-api]
module.webhook_worker.aws_lambda_function.this: Refreshing state... [id=substratal-prod-webhook-worker]
aws_acm_certificate_validation.api: Refreshing state... [id=2026-10-20 20:44:42.323 +0000 UTC]
aws_apigatewayv2_domain_name.api: Refreshing state... [id=api.substratalapps.com]
module.mcp.aws_lambda_alias.live: Refreshing state... [id=arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-mcp:live]
module.stripe_webhook.aws_lambda_alias.live: Refreshing state... [id=arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-stripe-webhook:live]
module.api.aws_lambda_alias.live: Refreshing state... [id=arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-api:live]
module.webhook_worker.aws_lambda_alias.live: Refreshing state... [id=arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-webhook-worker:live]
aws_route53_record.api: Refreshing state... [id=Z0783627X8ON1GH6I0XB_api.substratalapps.com_A]
aws_apigatewayv2_api_mapping.api: Refreshing state... [id=zdmjg2]
aws_lambda_permission.apigw["api"]: Refreshing state... [id=apigateway-api]
aws_lambda_permission.apigw["mcp"]: Refreshing state... [id=apigateway-mcp]
aws_lambda_permission.apigw["stripe"]: Refreshing state... [id=apigateway-stripe]
aws_apigatewayv2_integration.fn["stripe"]: Refreshing state... [id=34l24l9]
aws_apigatewayv2_integration.fn["api"]: Refreshing state... [id=3h3bk4c]
aws_apigatewayv2_integration.fn["mcp"]: Refreshing state... [id=8xk20ia]
aws_lambda_event_source_mapping.webhooks: Refreshing state... [id=bed6416f-6a72-474a-83e5-559c995cf8f3]
aws_cloudwatch_metric_alarm.lambda_throttles["worker"]: Refreshing state... [id=substratal-prod-worker-throttles]
aws_cloudwatch_metric_alarm.lambda_throttles["api"]: Refreshing state... [id=substratal-prod-api-throttles]
aws_s3_bucket_server_side_encryption_configuration.audit_archive: Refreshing state... [id=substratal-prod-audit-archive-196936048600]
aws_s3_bucket_public_access_block.audit_archive: Refreshing state... [id=substratal-prod-audit-archive-196936048600]
aws_s3_bucket_versioning.audit_archive: Refreshing state... [id=substratal-prod-audit-archive-196936048600]
module.jobs.aws_iam_role_policy.this[0]: Refreshing state... [id=substratal-prod-jobs:function]
module.jobs.aws_lambda_function.this: Refreshing state... [id=substratal-prod-jobs]
aws_apigatewayv2_route.r["GET /.well-known/{proxy+}"]: Refreshing state... [id=kb35wrh]
aws_apigatewayv2_route.r["POST /mcp"]: Refreshing state... [id=a197cl8]
aws_apigatewayv2_route.r["GET /mcp"]: Refreshing state... [id=s6ez97n]
aws_apigatewayv2_route.r["POST /internal/stripe/webhook"]: Refreshing state... [id=bczysio]
aws_apigatewayv2_route.r["ANY /v1/{proxy+}"]: Refreshing state... [id=jvtq728]
aws_apigatewayv2_route.r["$default"]: Refreshing state... [id=cr82zzs]
aws_apigatewayv2_route.r["DELETE /mcp"]: Refreshing state... [id=peme2oj]
module.jobs.aws_lambda_alias.live: Refreshing state... [id=arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-jobs:live]
aws_iam_role_policy.scheduler: Refreshing state... [id=substratal-prod-scheduler:terraform-339ca2ff2a164d299b046f72e0]
aws_scheduler_schedule.job["webhook-dispatch"]: Refreshing state... [id=default/substratal-prod-webhook-dispatch]
aws_scheduler_schedule.job["entitlement-sweep"]: Refreshing state... [id=default/substratal-prod-entitlement-sweep]
aws_scheduler_schedule.job["audit-archive"]: Refreshing state... [id=default/substratal-prod-audit-archive]
aws_scheduler_schedule.job["settings-projections"]: Refreshing state... [id=default/substratal-prod-settings-projections]
aws_scheduler_schedule.job["cleanup"]: Refreshing state... [id=default/substratal-prod-cleanup]
aws_cloudwatch_metric_alarm.lambda_errors["stripe"]: Refreshing state... [id=substratal-prod-stripe-errors]
aws_scheduler_schedule.job["seat-sync"]: Refreshing state... [id=default/substratal-prod-seat-sync]
aws_cloudwatch_metric_alarm.lambda_errors["worker"]: Refreshing state... [id=substratal-prod-worker-errors]
aws_cloudwatch_metric_alarm.lambda_errors["api"]: Refreshing state... [id=substratal-prod-api-errors]
aws_cloudwatch_metric_alarm.lambda_errors["jobs"]: Refreshing state... [id=substratal-prod-jobs-errors]
aws_cloudwatch_metric_alarm.lambda_errors["mcp"]: Refreshing state... [id=substratal-prod-mcp-errors]
aws_scheduler_schedule.job["push-maintenance"]: Refreshing state... [id=default/substratal-prod-push-maintenance]
aws_scheduler_schedule.job["subscription-access"]: Refreshing state... [id=default/substratal-prod-subscription-access]
aws_scheduler_schedule.job["erasure-cascade"]: Refreshing state... [id=default/substratal-prod-erasure-cascade]
aws_scheduler_schedule.job["webhook-health"]: Refreshing state... [id=default/substratal-prod-webhook-health]
aws_scheduler_schedule.job["stripe-events"]: Refreshing state... [id=default/substratal-prod-stripe-events]

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create
  ~ update in-place
  - destroy

Terraform will perform the following actions:

  # aws_s3_bucket.audit_exports will be created
  + resource "aws_s3_bucket" "audit_exports" {
      + acceleration_status         = (known after apply)
      + acl                         = (known after apply)
      + arn                         = (known after apply)
      + bucket                      = "substratal-prod-audit-exports-196936048600"
      + bucket_domain_name          = (known after apply)
      + bucket_namespace            = (known after apply)
      + bucket_prefix               = (known after apply)
      + bucket_region               = (known after apply)
      + bucket_regional_domain_name = (known after apply)
      + force_destroy               = false
      + hosted_zone_id              = (known after apply)
      + id                          = (known after apply)
      + object_lock_enabled         = (known after apply)
      + policy                      = (known after apply)
      + region                      = "us-east-1"
      + request_payer               = (known after apply)
      + tags_all                    = {
          + "managed_by" = "terraform"
          + "project"    = "substratal"
          + "stack"      = "prod"
        }
      + website_domain              = (known after apply)
      + website_endpoint            = (known after apply)

      + cors_rule (known after apply)

      + grant (known after apply)

      + lifecycle_rule (known after apply)

      + logging (known after apply)

      + object_lock_configuration (known after apply)

      + replication_configuration (known after apply)

      + server_side_encryption_configuration (known after apply)

      + versioning (known after apply)

      + website (known after apply)
    }

  # aws_s3_bucket_lifecycle_configuration.audit_exports will be created
  + resource "aws_s3_bucket_lifecycle_configuration" "audit_exports" {
      + bucket                                 = (known after apply)
      + expected_bucket_owner                  = (known after apply)
      + id                                     = (known after apply)
      + region                                 = "us-east-1"
      + transition_default_minimum_object_size = "all_storage_classes_128K"

      + rule {
          + id     = "expire-exports"
          + status = "Enabled"
            # (1 unchanged attribute hidden)

          + expiration {
              + days                         = 7
              + expired_object_delete_marker = false
            }

          + filter {
                # (1 unchanged attribute hidden)
            }
        }
    }

  # aws_s3_bucket_public_access_block.audit_exports will be created
  + resource "aws_s3_bucket_public_access_block" "audit_exports" {
      + block_public_acls       = true
      + block_public_policy     = true
      + bucket                  = (known after apply)
      + id                      = (known after apply)
      + ignore_public_acls      = true
      + region                  = "us-east-1"
      + restrict_public_buckets = true
    }

  # aws_s3_bucket_server_side_encryption_configuration.audit_exports will be created
  + resource "aws_s3_bucket_server_side_encryption_configuration" "audit_exports" {
      + bucket = (known after apply)
      + id     = (known after apply)
      + region = "us-east-1"

      + rule {
          + blocked_encryption_types = (known after apply)
          + bucket_key_enabled       = (known after apply)

          + apply_server_side_encryption_by_default {
              + kms_master_key_id = (known after apply)
              + sse_algorithm     = "AES256"
            }
        }
    }

  # aws_scheduler_schedule.job["audit-exports"] will be created
  + resource "aws_scheduler_schedule" "job" {
      + action_after_completion      = (known after apply)
      + arn                          = (known after apply)
      + group_name                   = (known after apply)
      + id                           = (known after apply)
      + name                         = "substratal-prod-audit-exports"
      + name_prefix                  = (known after apply)
      + region                       = "us-east-1"
      + schedule_expression          = "rate(1 minute)"
      + schedule_expression_timezone = "UTC"
      + state                        = "ENABLED"

      + flexible_time_window {
          + mode = "OFF"
        }

      + target {
          + arn      = "arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-jobs:live"
          + input    = jsonencode(
                {
                  + job = "audit-exports"
                }
            )
          + role_arn = "arn:aws:iam::196936048600:role/substratal-prod-scheduler"

          + retry_policy {
              + maximum_event_age_in_seconds = 86400
              + maximum_retry_attempts       = 2
            }
        }
    }

  # aws_secretsmanager_secret.app will be updated in-place
  ~ resource "aws_secretsmanager_secret" "app" {
      ~ description                    = "cursor_secret, stripe_secret_key, stripe_webhook_secret. Set the Stripe keys by hand; Terraform never reads or writes the value." -> "cursor_secret, stripe_secret_key, stripe_webhook_secret. Set the Stripe keys by hand; Terraform never overwrites them."
        id                             = "arn:aws:secretsmanager:us-east-1:196936048600:secret:substratal-prod/app-1hXTGg"
        name                           = "substratal-prod/app"
        tags                           = {}
        # (9 unchanged attributes hidden)
    }

  # aws_secretsmanager_secret_version.app will be created
  + resource "aws_secretsmanager_secret_version" "app" {
      + arn                  = (known after apply)
      + has_secret_string_wo = (known after apply)
      + id                   = (known after apply)
      + region               = "us-east-1"
      + secret_arn           = (known after apply)
      + secret_id            = "arn:aws:secretsmanager:us-east-1:196936048600:secret:substratal-prod/app-1hXTGg"
      + secret_string        = (sensitive value)
      + secret_string_wo     = (write-only attribute)
      + version_id           = (known after apply)
      + version_stages       = (known after apply)
    }

  # random_password.cursor will be created
  + resource "random_password" "cursor" {
      + bcrypt_hash = (sensitive value)
      + id          = (known after apply)
      + length      = 48
      + lower       = true
      + min_lower   = 0
      + min_numeric = 0
      + min_special = 0
      + min_upper   = 0
      + number      = true
      + numeric     = true
      + result      = (sensitive value)
      + special     = false
      + upper       = true
    }

  # terraform_data.app_secret_init will be destroyed
  # (because terraform_data.app_secret_init is not in configuration)
  - resource "terraform_data" "app_secret_init" {
      - id               = "5474e6c3-4843-79d8-9346-9fc3d3f8fa87" -> null
      - triggers_replace = [
          - "arn:aws:secretsmanager:us-east-1:196936048600:secret:substratal-prod/app-1hXTGg",
        ] -> null
    }

  # module.api.aws_iam_role_policy.this[0] will be updated in-place
  ~ resource "aws_iam_role_policy" "this" {
        id          = "substratal-prod-api:function"
        name        = "function"
      ~ policy      = jsonencode(
            {
              - Statement = [
                  - {
                      - Action   = [
                          - "rds-data:ExecuteStatement",
                          - "rds-data:BatchExecuteStatement",
                          - "rds-data:BeginTransaction",
                          - "rds-data:CommitTransaction",
                          - "rds-data:RollbackTransaction",
                        ]
                      - Effect   = "Allow"
                      - Resource = "arn:aws:rds:us-east-1:196936048600:cluster:substratal-prod-db"
                    },
                  - {
                      - Action   = [
                          - "secretsmanager:GetSecretValue",
                        ]
                      - Effect   = "Allow"
                      - Resource = [
                          - "arn:aws:secretsmanager:us-east-1:196936048600:secret:rds!cluster-9c5f3e8e-7c3b-49d6-92f5-77ae0ca64c1d-E5LGIA",
                          - "arn:aws:secretsmanager:us-east-1:196936048600:secret:substratal-prod/app-1hXTGg",
                        ]
                    },
                  - {
                      - Action   = [
                          - "kms:Sign",
                          - "kms:GetPublicKey",
                        ]
                      - Effect   = "Allow"
                      - Resource = "arn:aws:kms:us-east-1:196936048600:key/0d2770cd-bbc8-4904-a432-2e9c9304a3ca"
                    },
                  - {
                      - Action   = [
                          - "kms:Encrypt",
                          - "kms:Decrypt",
                        ]
                      - Effect   = "Allow"
                      - Resource = "arn:aws:kms:us-east-1:196936048600:key/b0cda5a8-651e-419f-b8ab-61772bce79da"
                    },
                  - {
                      - Action   = [
                          - "ses:SendEmail",
                        ]
                      - Effect   = "Allow"
                      - Resource = "arn:aws:ses:us-east-1:196936048600:identity/mail.substratalapps.com"
                    },
                  - {
                      - Action   = [
                          - "sqs:SendMessage",
                        ]
                      - Effect   = "Allow"
                      - Resource = "arn:aws:sqs:us-east-1:196936048600:substratal-prod-webhooks"
                    },
                ]
              - Version   = "2012-10-17"
            }
        ) -> (known after apply)
        # (2 unchanged attributes hidden)
    }

  # module.api.aws_lambda_alias.live will be updated in-place
  ~ resource "aws_lambda_alias" "live" {
      ~ function_version = "5" -> (known after apply)
        id               = "arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-api:live"
        name             = "live"
        # (5 unchanged attributes hidden)
    }

  # module.api.aws_lambda_function.this will be updated in-place
  ~ resource "aws_lambda_function" "this" {
        id                             = "substratal-prod-api"
      ~ last_modified                  = "2026-10-22T03:19:59.000+0000" -> (known after apply)
      ~ qualified_arn                  = "arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-api:5" -> (known after apply)
      ~ qualified_invoke_arn           = "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-api:5/invocations" -> (known after apply)
      ~ s3_key                         = "643b5cf05d2c7acfd449e05d99a886163f7f9ccb/api.zip" -> "d72b196a94b615b0296045d4571bb7089132becc/api.zip"
        tags                           = {}
      ~ version                        = "5" -> (known after apply)
        # (28 unchanged attributes hidden)

      ~ environment {
          ~ variables = {
              + "AUDIT_EXPORT_BUCKET"  = "substratal-prod-audit-exports-196936048600"
                # (16 unchanged elements hidden)
            }
        }

        # (3 unchanged blocks hidden)
    }

  # module.jobs.aws_iam_role_policy.this[0] will be updated in-place
  ~ resource "aws_iam_role_policy" "this" {
        id          = "substratal-prod-jobs:function"
        name        = "function"
      ~ policy      = jsonencode(
            {
              - Statement = [
                  - {
                      - Action   = [
                          - "rds-data:ExecuteStatement",
                          - "rds-data:BatchExecuteStatement",
                          - "rds-data:BeginTransaction",
                          - "rds-data:CommitTransaction",
                          - "rds-data:RollbackTransaction",
                        ]
                      - Effect   = "Allow"
                      - Resource = "arn:aws:rds:us-east-1:196936048600:cluster:substratal-prod-db"
                    },
                  - {
                      - Action   = [
                          - "secretsmanager:GetSecretValue",
                        ]
                      - Effect   = "Allow"
                      - Resource = [
                          - "arn:aws:secretsmanager:us-east-1:196936048600:secret:rds!cluster-9c5f3e8e-7c3b-49d6-92f5-77ae0ca64c1d-E5LGIA",
                          - "arn:aws:secretsmanager:us-east-1:196936048600:secret:substratal-prod/app-1hXTGg",
                        ]
                    },
                  - {
                      - Action   = [
                          - "kms:Sign",
                          - "kms:GetPublicKey",
                        ]
                      - Effect   = "Allow"
                      - Resource = "arn:aws:kms:us-east-1:196936048600:key/0d2770cd-bbc8-4904-a432-2e9c9304a3ca"
                    },
                  - {
                      - Action   = [
                          - "kms:Encrypt",
                          - "kms:Decrypt",
                        ]
                      - Effect   = "Allow"
                      - Resource = "arn:aws:kms:us-east-1:196936048600:key/b0cda5a8-651e-419f-b8ab-61772bce79da"
                    },
                  - {
                      - Action   = [
                          - "ses:SendEmail",
                        ]
                      - Effect   = "Allow"
                      - Resource = "arn:aws:ses:us-east-1:196936048600:identity/mail.substratalapps.com"
                    },
                  - {
                      - Action   = [
                          - "s3:PutObject",
                        ]
                      - Effect   = "Allow"
                      - Resource = "arn:aws:s3:::substratal-prod-audit-archive-196936048600/*"
                    },
                ]
              - Version   = "2012-10-17"
            }
        ) -> (known after apply)
        # (2 unchanged attributes hidden)
    }

  # module.jobs.aws_lambda_alias.live will be updated in-place
  ~ resource "aws_lambda_alias" "live" {
      ~ function_version = "5" -> (known after apply)
        id               = "arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-jobs:live"
        name             = "live"
        # (5 unchanged attributes hidden)
    }

  # module.jobs.aws_lambda_function.this will be updated in-place
  ~ resource "aws_lambda_function" "this" {
        id                             = "substratal-prod-jobs"
      ~ last_modified                  = "2026-10-22T03:19:59.000+0000" -> (known after apply)
      ~ qualified_arn                  = "arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-jobs:5" -> (known after apply)
      ~ qualified_invoke_arn           = "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-jobs:5/invocations" -> (known after apply)
      ~ s3_key                         = "643b5cf05d2c7acfd449e05d99a886163f7f9ccb/jobs.zip" -> "d72b196a94b615b0296045d4571bb7089132becc/jobs.zip"
        tags                           = {}
      ~ version                        = "5" -> (known after apply)
        # (28 unchanged attributes hidden)

      ~ environment {
          ~ variables = {
              + "AUDIT_EXPORT_BUCKET"  = "substratal-prod-audit-exports-196936048600"
                # (15 unchanged elements hidden)
            }
        }

        # (3 unchanged blocks hidden)
    }

  # module.mcp.aws_lambda_alias.live will be updated in-place
  ~ resource "aws_lambda_alias" "live" {
      ~ function_version = "6" -> (known after apply)
        id               = "arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-mcp:live"
        name             = "live"
        # (5 unchanged attributes hidden)
    }

  # module.mcp.aws_lambda_function.this will be updated in-place
  ~ resource "aws_lambda_function" "this" {
        id                             = "substratal-prod-mcp"
      ~ last_modified                  = "2026-10-22T03:19:59.000+0000" -> (known after apply)
      ~ qualified_arn                  = "arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-mcp:6" -> (known after apply)
      ~ qualified_invoke_arn           = "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-mcp:6/invocations" -> (known after apply)
      ~ s3_key                         = "643b5cf05d2c7acfd449e05d99a886163f7f9ccb/mcp.zip" -> "d72b196a94b615b0296045d4571bb7089132becc/mcp.zip"
        tags                           = {}
      ~ version                        = "6" -> (known after apply)
        # (28 unchanged attributes hidden)

        # (4 unchanged blocks hidden)
    }

  # module.stripe_webhook.aws_lambda_alias.live will be updated in-place
  ~ resource "aws_lambda_alias" "live" {
      ~ function_version = "5" -> (known after apply)
        id               = "arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-stripe-webhook:live"
        name             = "live"
        # (5 unchanged attributes hidden)
    }

  # module.stripe_webhook.aws_lambda_function.this will be updated in-place
  ~ resource "aws_lambda_function" "this" {
        id                             = "substratal-prod-stripe-webhook"
      ~ last_modified                  = "2026-10-22T03:19:59.000+0000" -> (known after apply)
      ~ qualified_arn                  = "arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-stripe-webhook:5" -> (known after apply)
      ~ qualified_invoke_arn           = "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-stripe-webhook:5/invocations" -> (known after apply)
      ~ s3_key                         = "643b5cf05d2c7acfd449e05d99a886163f7f9ccb/stripe-webhook.zip" -> "d72b196a94b615b0296045d4571bb7089132becc/stripe-webhook.zip"
        tags                           = {}
      ~ version                        = "5" -> (known after apply)
        # (28 unchanged attributes hidden)

        # (4 unchanged blocks hidden)
    }

  # module.webhook_worker.aws_lambda_alias.live will be updated in-place
  ~ resource "aws_lambda_alias" "live" {
      ~ function_version = "5" -> (known after apply)
        id               = "arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-webhook-worker:live"
        name             = "live"
        # (5 unchanged attributes hidden)
    }

  # module.webhook_worker.aws_lambda_function.this will be updated in-place
  ~ resource "aws_lambda_function" "this" {
        id                             = "substratal-prod-webhook-worker"
      ~ last_modified                  = "2026-10-22T03:19:59.000+0000" -> (known after apply)
      ~ qualified_arn                  = "arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-webhook-worker:5" -> (known after apply)
      ~ qualified_invoke_arn           = "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-webhook-worker:5/invocations" -> (known after apply)
      ~ s3_key                         = "643b5cf05d2c7acfd449e05d99a886163f7f9ccb/webhook-worker.zip" -> "d72b196a94b615b0296045d4571bb7089132becc/webhook-worker.zip"
        tags                           = {}
      ~ version                        = "5" -> (known after apply)
        # (28 unchanged attributes hidden)

        # (4 unchanged blocks hidden)
    }

Plan: 7 to add, 13 to change, 1 to destroy.

─────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.

Brings in the Substratal-Version header and root paths (#37), the
plan-job secret fix (#59), and the versioning docs (#63).

- Conflicts in Audit, Orders & Audit, and NFR: this branch's rows, with
  root paths; main's side only differed by the removed /v1 prefix. The
  Changelog puts this branch's entry above main's two 2026-10-21 entries
- openapi.yaml 1.0.0-draft.5 (main took draft.4 for the header change)
- Root paths in audit_exports_test.go and in comments, descriptions,
  and docs this branch added; tools.json regenerated

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

terraform plan (production)

This is exactly what merging will change in AWS.

Plan
random_password.mcp_session: Refreshing state... [id=none]
aws_kms_key.data: Refreshing state... [id=b0cda5a8-651e-419f-b8ab-61772bce79da]
aws_sesv2_email_identity.mail: Refreshing state... [id=mail.substratalapps.com]
module.stripe_webhook.aws_cloudwatch_log_group.this: Refreshing state... [id=/aws/lambda/substratal-prod-stripe-webhook]
module.api.aws_cloudwatch_log_group.this: Refreshing state... [id=/aws/lambda/substratal-prod-api]
module.jobs.aws_iam_role.this: Refreshing state... [id=substratal-prod-jobs]
aws_cloudwatch_log_group.api_access: Refreshing state... [id=/aws/apigateway/substratal-prod]
module.mcp.aws_iam_role.this: Refreshing state... [id=substratal-prod-mcp]
module.mcp.aws_cloudwatch_log_group.this: Refreshing state... [id=/aws/lambda/substratal-prod-mcp]
aws_apigatewayv2_api.main: Refreshing state... [id=rv9n7e3ww4]
aws_acm_certificate.api: Refreshing state... [id=arn:aws:acm:us-east-1:196936048600:certificate/a8e2136f-64d3-456b-904a-05ea74eba9b3]
aws_sns_topic.alarms: Refreshing state... [id=arn:aws:sns:us-east-1:196936048600:substratal-prod-alarms]
aws_secretsmanager_secret.app: Refreshing state... [id=arn:aws:secretsmanager:us-east-1:196936048600:secret:substratal-prod/app-1hXTGg]
aws_sqs_queue.webhooks_dlq: Refreshing state... [id=https://sqs.us-east-1.amazonaws.com/196936048600/substratal-prod-webhooks-dlq]
data.aws_route53_zone.main: Reading...
module.jobs.aws_cloudwatch_log_group.this: Refreshing state... [id=/aws/lambda/substratal-prod-jobs]
aws_iam_role.scheduler: Refreshing state... [id=substratal-prod-scheduler]
module.api.aws_iam_role.this: Refreshing state... [id=substratal-prod-api]
data.aws_servicequotas_service_quota.lambda_concurrency: Reading...
module.webhook_worker.aws_cloudwatch_log_group.this: Refreshing state... [id=/aws/lambda/substratal-prod-webhook-worker]
aws_kms_key.signing: Refreshing state... [id=0d2770cd-bbc8-4904-a432-2e9c9304a3ca]
aws_rds_cluster.main: Refreshing state... [id=substratal-prod-db]
module.webhook_worker.aws_iam_role.this: Refreshing state... [id=substratal-prod-webhook-worker]
data.aws_caller_identity.me: Reading...
module.stripe_webhook.aws_iam_role.this: Refreshing state... [id=substratal-prod-stripe-webhook]
data.aws_caller_identity.me: Read complete after 0s [id=196936048600]
aws_kms_alias.data: Refreshing state... [id=alias/substratal-prod-data]
aws_apigatewayv2_stage.default: Refreshing state... [id=$default]
module.mcp.aws_iam_role_policy.logs: Refreshing state... [id=substratal-prod-mcp:logs]
terraform_data.app_secret_init: Refreshing state... [id=5474e6c3-4843-79d8-9346-9fc3d3f8fa87]
module.jobs.aws_iam_role_policy.logs: Refreshing state... [id=substratal-prod-jobs:logs]
aws_sqs_queue.webhooks: Refreshing state... [id=https://sqs.us-east-1.amazonaws.com/196936048600/substratal-prod-webhooks]
aws_s3_bucket.audit_archive: Refreshing state... [id=substratal-prod-audit-archive-196936048600]
data.aws_servicequotas_service_quota.lambda_concurrency: Read complete after 0s [id=arn:aws:servicequotas:us-east-1::lambda/L-B99A9384]
aws_kms_alias.signing: Refreshing state... [id=alias/substratal-prod-jwt-signing]
aws_cloudwatch_log_metric_filter.security["refresh-reuse"]: Refreshing state... [id=substratal-prod-refresh-reuse]
data.aws_route53_zone.main: Read complete after 0s [id=Z0783627X8ON1GH6I0XB]
aws_cloudwatch_log_metric_filter.security["failed-logins"]: Refreshing state... [id=substratal-prod-failed-logins]
aws_cloudwatch_log_metric_filter.security["destructive-blocked"]: Refreshing state... [id=substratal-prod-destructive-blocked]
aws_cloudwatch_metric_alarm.webhook_dlq: Refreshing state... [id=substratal-prod-webhooks-dlq]
aws_cloudwatch_metric_alarm.api_5xx: Refreshing state... [id=substratal-prod-api-5xx]
aws_cloudwatch_metric_alarm.security["failed-logins"]: Refreshing state... [id=substratal-prod-security-failed-logins]
aws_cloudwatch_metric_alarm.security["refresh-reuse"]: Refreshing state... [id=substratal-prod-security-refresh-reuse]
aws_cloudwatch_metric_alarm.security["destructive-blocked"]: Refreshing state... [id=substratal-prod-security-destructive-blocked]
module.mcp.aws_lambda_function.this: Refreshing state... [id=substratal-prod-mcp]
aws_route53_record.dmarc: Refreshing state... [id=Z0783627X8ON1GH6I0XB__dmarc.mail.substratalapps.com_TXT]
aws_route53_record.dkim[2]: Refreshing state... [id=Z0783627X8ON1GH6I0XB_rp4dt5rjjkpnhdyxud6l2spetqo5nom4._domainkey.mail.substratalapps.com_CNAME]
aws_route53_record.dkim[0]: Refreshing state... [id=Z0783627X8ON1GH6I0XB_fmy2g7whqti56v5vn56es6izxo4yzayc._domainkey.mail.substratalapps.com_CNAME]
aws_route53_record.dkim[1]: Refreshing state... [id=Z0783627X8ON1GH6I0XB_fhl63kaowzjfd2splohuyaifnnansx5d._domainkey.mail.substratalapps.com_CNAME]
aws_route53_record.cert_validation["api.substratalapps.com"]: Refreshing state... [id=Z0783627X8ON1GH6I0XB__45a2e898c02bdc2297dde6c5e5a798a8.api.substratalapps.com._CNAME]
module.api.aws_iam_role_policy.logs: Refreshing state... [id=substratal-prod-api:logs]
module.webhook_worker.aws_iam_role_policy.logs: Refreshing state... [id=substratal-prod-webhook-worker:logs]
module.stripe_webhook.aws_iam_role_policy.logs: Refreshing state... [id=substratal-prod-stripe-webhook:logs]
aws_cloudwatch_metric_alarm.aurora_near_max: Refreshing state... [id=substratal-prod-aurora-acu-near-max]
aws_rds_cluster_instance.main: Refreshing state... [id=substratal-prod-db-1]
module.stripe_webhook.aws_iam_role_policy.this[0]: Refreshing state... [id=substratal-prod-stripe-webhook:function]
module.api.aws_iam_role_policy.this[0]: Refreshing state... [id=substratal-prod-api:function]
module.webhook_worker.aws_lambda_function.this: Refreshing state... [id=substratal-prod-webhook-worker]
module.webhook_worker.aws_iam_role_policy.this[0]: Refreshing state... [id=substratal-prod-webhook-worker:function]
module.api.aws_lambda_function.this: Refreshing state... [id=substratal-prod-api]
module.stripe_webhook.aws_lambda_function.this: Refreshing state... [id=substratal-prod-stripe-webhook]
aws_acm_certificate_validation.api: Refreshing state... [id=2026-10-20 20:44:42.323 +0000 UTC]
aws_apigatewayv2_domain_name.api: Refreshing state... [id=api.substratalapps.com]
module.mcp.aws_lambda_alias.live: Refreshing state... [id=arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-mcp:live]
aws_apigatewayv2_api_mapping.api: Refreshing state... [id=zdmjg2]
aws_route53_record.api: Refreshing state... [id=Z0783627X8ON1GH6I0XB_api.substratalapps.com_A]
module.webhook_worker.aws_lambda_alias.live: Refreshing state... [id=arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-webhook-worker:live]
module.api.aws_lambda_alias.live: Refreshing state... [id=arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-api:live]
aws_lambda_event_source_mapping.webhooks: Refreshing state... [id=bed6416f-6a72-474a-83e5-559c995cf8f3]
module.stripe_webhook.aws_lambda_alias.live: Refreshing state... [id=arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-stripe-webhook:live]
aws_cloudwatch_metric_alarm.lambda_throttles["api"]: Refreshing state... [id=substratal-prod-api-throttles]
aws_cloudwatch_metric_alarm.lambda_throttles["worker"]: Refreshing state... [id=substratal-prod-worker-throttles]
aws_lambda_permission.apigw["stripe"]: Refreshing state... [id=apigateway-stripe]
aws_apigatewayv2_integration.fn["mcp"]: Refreshing state... [id=8xk20ia]
aws_lambda_permission.apigw["api"]: Refreshing state... [id=apigateway-api]
aws_apigatewayv2_integration.fn["stripe"]: Refreshing state... [id=34l24l9]
aws_apigatewayv2_integration.fn["api"]: Refreshing state... [id=3h3bk4c]
aws_lambda_permission.apigw["mcp"]: Refreshing state... [id=apigateway-mcp]
aws_s3_bucket_server_side_encryption_configuration.audit_archive: Refreshing state... [id=substratal-prod-audit-archive-196936048600]
aws_s3_bucket_public_access_block.audit_archive: Refreshing state... [id=substratal-prod-audit-archive-196936048600]
aws_s3_bucket_versioning.audit_archive: Refreshing state... [id=substratal-prod-audit-archive-196936048600]
module.jobs.aws_lambda_function.this: Refreshing state... [id=substratal-prod-jobs]
module.jobs.aws_iam_role_policy.this[0]: Refreshing state... [id=substratal-prod-jobs:function]
aws_apigatewayv2_route.r["DELETE /mcp"]: Refreshing state... [id=peme2oj]
aws_apigatewayv2_route.r["$default"]: Refreshing state... [id=cr82zzs]
aws_apigatewayv2_route.r["GET /mcp"]: Refreshing state... [id=s6ez97n]
aws_apigatewayv2_route.r["POST /internal/stripe/webhook"]: Refreshing state... [id=bczysio]
aws_apigatewayv2_route.r["GET /.well-known/{proxy+}"]: Refreshing state... [id=kb35wrh]
aws_apigatewayv2_route.r["POST /mcp"]: Refreshing state... [id=a197cl8]
module.jobs.aws_lambda_alias.live: Refreshing state... [id=arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-jobs:live]
aws_iam_role_policy.scheduler: Refreshing state... [id=substratal-prod-scheduler:terraform-339ca2ff2a164d299b046f72e0]
aws_scheduler_schedule.job["cleanup"]: Refreshing state... [id=default/substratal-prod-cleanup]
aws_scheduler_schedule.job["seat-sync"]: Refreshing state... [id=default/substratal-prod-seat-sync]
aws_cloudwatch_metric_alarm.lambda_errors["stripe"]: Refreshing state... [id=substratal-prod-stripe-errors]
aws_scheduler_schedule.job["push-maintenance"]: Refreshing state... [id=default/substratal-prod-push-maintenance]
aws_cloudwatch_metric_alarm.lambda_errors["worker"]: Refreshing state... [id=substratal-prod-worker-errors]
aws_scheduler_schedule.job["erasure-cascade"]: Refreshing state... [id=default/substratal-prod-erasure-cascade]
aws_cloudwatch_metric_alarm.lambda_errors["api"]: Refreshing state... [id=substratal-prod-api-errors]
aws_scheduler_schedule.job["settings-projections"]: Refreshing state... [id=default/substratal-prod-settings-projections]
aws_scheduler_schedule.job["subscription-access"]: Refreshing state... [id=default/substratal-prod-subscription-access]
aws_scheduler_schedule.job["webhook-health"]: Refreshing state... [id=default/substratal-prod-webhook-health]
aws_scheduler_schedule.job["audit-archive"]: Refreshing state... [id=default/substratal-prod-audit-archive]
aws_scheduler_schedule.job["webhook-dispatch"]: Refreshing state... [id=default/substratal-prod-webhook-dispatch]
aws_cloudwatch_metric_alarm.lambda_errors["jobs"]: Refreshing state... [id=substratal-prod-jobs-errors]
aws_cloudwatch_metric_alarm.lambda_errors["mcp"]: Refreshing state... [id=substratal-prod-mcp-errors]
aws_scheduler_schedule.job["entitlement-sweep"]: Refreshing state... [id=default/substratal-prod-entitlement-sweep]
aws_scheduler_schedule.job["stripe-events"]: Refreshing state... [id=default/substratal-prod-stripe-events]

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create
  ~ update in-place

Terraform will perform the following actions:

  # aws_s3_bucket.audit_exports will be created
  + resource "aws_s3_bucket" "audit_exports" {
      + acceleration_status         = (known after apply)
      + acl                         = (known after apply)
      + arn                         = (known after apply)
      + bucket                      = "substratal-prod-audit-exports-196936048600"
      + bucket_domain_name          = (known after apply)
      + bucket_namespace            = (known after apply)
      + bucket_prefix               = (known after apply)
      + bucket_region               = (known after apply)
      + bucket_regional_domain_name = (known after apply)
      + force_destroy               = false
      + hosted_zone_id              = (known after apply)
      + id                          = (known after apply)
      + object_lock_enabled         = (known after apply)
      + policy                      = (known after apply)
      + region                      = "us-east-1"
      + request_payer               = (known after apply)
      + tags_all                    = {
          + "managed_by" = "terraform"
          + "project"    = "substratal"
          + "stack"      = "prod"
        }
      + website_domain              = (known after apply)
      + website_endpoint            = (known after apply)

      + cors_rule (known after apply)

      + grant (known after apply)

      + lifecycle_rule (known after apply)

      + logging (known after apply)

      + object_lock_configuration (known after apply)

      + replication_configuration (known after apply)

      + server_side_encryption_configuration (known after apply)

      + versioning (known after apply)

      + website (known after apply)
    }

  # aws_s3_bucket_lifecycle_configuration.audit_exports will be created
  + resource "aws_s3_bucket_lifecycle_configuration" "audit_exports" {
      + bucket                                 = (known after apply)
      + expected_bucket_owner                  = (known after apply)
      + id                                     = (known after apply)
      + region                                 = "us-east-1"
      + transition_default_minimum_object_size = "all_storage_classes_128K"

      + rule {
          + id     = "expire-exports"
          + status = "Enabled"
            # (1 unchanged attribute hidden)

          + expiration {
              + days                         = 7
              + expired_object_delete_marker = false
            }

          + filter {
                # (1 unchanged attribute hidden)
            }
        }
    }

  # aws_s3_bucket_public_access_block.audit_exports will be created
  + resource "aws_s3_bucket_public_access_block" "audit_exports" {
      + block_public_acls       = true
      + block_public_policy     = true
      + bucket                  = (known after apply)
      + id                      = (known after apply)
      + ignore_public_acls      = true
      + region                  = "us-east-1"
      + restrict_public_buckets = true
    }

  # aws_s3_bucket_server_side_encryption_configuration.audit_exports will be created
  + resource "aws_s3_bucket_server_side_encryption_configuration" "audit_exports" {
      + bucket = (known after apply)
      + id     = (known after apply)
      + region = "us-east-1"

      + rule {
          + blocked_encryption_types = (known after apply)
          + bucket_key_enabled       = (known after apply)

          + apply_server_side_encryption_by_default {
              + kms_master_key_id = (known after apply)
              + sse_algorithm     = "AES256"
            }
        }
    }

  # aws_scheduler_schedule.job["audit-exports"] will be created
  + resource "aws_scheduler_schedule" "job" {
      + action_after_completion      = (known after apply)
      + arn                          = (known after apply)
      + group_name                   = (known after apply)
      + id                           = (known after apply)
      + name                         = "substratal-prod-audit-exports"
      + name_prefix                  = (known after apply)
      + region                       = "us-east-1"
      + schedule_expression          = "rate(1 minute)"
      + schedule_expression_timezone = "UTC"
      + state                        = "ENABLED"

      + flexible_time_window {
          + mode = "OFF"
        }

      + target {
          + arn      = "arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-jobs:live"
          + input    = jsonencode(
                {
                  + job = "audit-exports"
                }
            )
          + role_arn = "arn:aws:iam::196936048600:role/substratal-prod-scheduler"

          + retry_policy {
              + maximum_event_age_in_seconds = 86400
              + maximum_retry_attempts       = 2
            }
        }
    }

  # module.api.aws_iam_role_policy.this[0] will be updated in-place
  ~ resource "aws_iam_role_policy" "this" {
        id          = "substratal-prod-api:function"
        name        = "function"
      ~ policy      = jsonencode(
            {
              - Statement = [
                  - {
                      - Action   = [
                          - "rds-data:ExecuteStatement",
                          - "rds-data:BatchExecuteStatement",
                          - "rds-data:BeginTransaction",
                          - "rds-data:CommitTransaction",
                          - "rds-data:RollbackTransaction",
                        ]
                      - Effect   = "Allow"
                      - Resource = "arn:aws:rds:us-east-1:196936048600:cluster:substratal-prod-db"
                    },
                  - {
                      - Action   = [
                          - "secretsmanager:GetSecretValue",
                        ]
                      - Effect   = "Allow"
                      - Resource = [
                          - "arn:aws:secretsmanager:us-east-1:196936048600:secret:rds!cluster-9c5f3e8e-7c3b-49d6-92f5-77ae0ca64c1d-E5LGIA",
                          - "arn:aws:secretsmanager:us-east-1:196936048600:secret:substratal-prod/app-1hXTGg",
                        ]
                    },
                  - {
                      - Action   = [
                          - "kms:Sign",
                          - "kms:GetPublicKey",
                        ]
                      - Effect   = "Allow"
                      - Resource = "arn:aws:kms:us-east-1:196936048600:key/0d2770cd-bbc8-4904-a432-2e9c9304a3ca"
                    },
                  - {
                      - Action   = [
                          - "kms:Encrypt",
                          - "kms:Decrypt",
                        ]
                      - Effect   = "Allow"
                      - Resource = "arn:aws:kms:us-east-1:196936048600:key/b0cda5a8-651e-419f-b8ab-61772bce79da"
                    },
                  - {
                      - Action   = [
                          - "ses:SendEmail",
                        ]
                      - Effect   = "Allow"
                      - Resource = "arn:aws:ses:us-east-1:196936048600:identity/mail.substratalapps.com"
                    },
                  - {
                      - Action   = [
                          - "sqs:SendMessage",
                        ]
                      - Effect   = "Allow"
                      - Resource = "arn:aws:sqs:us-east-1:196936048600:substratal-prod-webhooks"
                    },
                ]
              - Version   = "2012-10-17"
            }
        ) -> (known after apply)
        # (2 unchanged attributes hidden)
    }

  # module.api.aws_lambda_alias.live will be updated in-place
  ~ resource "aws_lambda_alias" "live" {
      ~ function_version = "7" -> (known after apply)
        id               = "arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-api:live"
        name             = "live"
        # (5 unchanged attributes hidden)
    }

  # module.api.aws_lambda_function.this will be updated in-place
  ~ resource "aws_lambda_function" "this" {
        id                             = "substratal-prod-api"
      ~ last_modified                  = "2026-10-22T03:40:45.000+0000" -> (known after apply)
      ~ qualified_arn                  = "arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-api:7" -> (known after apply)
      ~ qualified_invoke_arn           = "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-api:7/invocations" -> (known after apply)
      ~ s3_key                         = "364ce5bb0458b14bd90d076a972d99d7e3fecfcf/api.zip" -> "983dde9623c02cb457adbcf9f9de89eaa56db46b/api.zip"
        tags                           = {}
      ~ version                        = "7" -> (known after apply)
        # (28 unchanged attributes hidden)

      ~ environment {
          ~ variables = {
              + "AUDIT_EXPORT_BUCKET"  = "substratal-prod-audit-exports-196936048600"
                # (16 unchanged elements hidden)
            }
        }

        # (3 unchanged blocks hidden)
    }

  # module.jobs.aws_iam_role_policy.this[0] will be updated in-place
  ~ resource "aws_iam_role_policy" "this" {
        id          = "substratal-prod-jobs:function"
        name        = "function"
      ~ policy      = jsonencode(
            {
              - Statement = [
                  - {
                      - Action   = [
                          - "rds-data:ExecuteStatement",
                          - "rds-data:BatchExecuteStatement",
                          - "rds-data:BeginTransaction",
                          - "rds-data:CommitTransaction",
                          - "rds-data:RollbackTransaction",
                        ]
                      - Effect   = "Allow"
                      - Resource = "arn:aws:rds:us-east-1:196936048600:cluster:substratal-prod-db"
                    },
                  - {
                      - Action   = [
                          - "secretsmanager:GetSecretValue",
                        ]
                      - Effect   = "Allow"
                      - Resource = [
                          - "arn:aws:secretsmanager:us-east-1:196936048600:secret:rds!cluster-9c5f3e8e-7c3b-49d6-92f5-77ae0ca64c1d-E5LGIA",
                          - "arn:aws:secretsmanager:us-east-1:196936048600:secret:substratal-prod/app-1hXTGg",
                        ]
                    },
                  - {
                      - Action   = [
                          - "kms:Sign",
                          - "kms:GetPublicKey",
                        ]
                      - Effect   = "Allow"
                      - Resource = "arn:aws:kms:us-east-1:196936048600:key/0d2770cd-bbc8-4904-a432-2e9c9304a3ca"
                    },
                  - {
                      - Action   = [
                          - "kms:Encrypt",
                          - "kms:Decrypt",
                        ]
                      - Effect   = "Allow"
                      - Resource = "arn:aws:kms:us-east-1:196936048600:key/b0cda5a8-651e-419f-b8ab-61772bce79da"
                    },
                  - {
                      - Action   = [
                          - "ses:SendEmail",
                        ]
                      - Effect   = "Allow"
                      - Resource = "arn:aws:ses:us-east-1:196936048600:identity/mail.substratalapps.com"
                    },
                  - {
                      - Action   = [
                          - "s3:PutObject",
                        ]
                      - Effect   = "Allow"
                      - Resource = "arn:aws:s3:::substratal-prod-audit-archive-196936048600/*"
                    },
                ]
              - Version   = "2012-10-17"
            }
        ) -> (known after apply)
        # (2 unchanged attributes hidden)
    }

  # module.jobs.aws_lambda_alias.live will be updated in-place
  ~ resource "aws_lambda_alias" "live" {
      ~ function_version = "7" -> (known after apply)
        id               = "arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-jobs:live"
        name             = "live"
        # (5 unchanged attributes hidden)
    }

  # module.jobs.aws_lambda_function.this will be updated in-place
  ~ resource "aws_lambda_function" "this" {
        id                             = "substratal-prod-jobs"
      ~ last_modified                  = "2026-10-22T03:40:45.000+0000" -> (known after apply)
      ~ qualified_arn                  = "arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-jobs:7" -> (known after apply)
      ~ qualified_invoke_arn           = "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-jobs:7/invocations" -> (known after apply)
      ~ s3_key                         = "364ce5bb0458b14bd90d076a972d99d7e3fecfcf/jobs.zip" -> "983dde9623c02cb457adbcf9f9de89eaa56db46b/jobs.zip"
        tags                           = {}
      ~ version                        = "7" -> (known after apply)
        # (28 unchanged attributes hidden)

      ~ environment {
          ~ variables = {
              + "AUDIT_EXPORT_BUCKET"  = "substratal-prod-audit-exports-196936048600"
                # (15 unchanged elements hidden)
            }
        }

        # (3 unchanged blocks hidden)
    }

  # module.mcp.aws_lambda_alias.live will be updated in-place
  ~ resource "aws_lambda_alias" "live" {
      ~ function_version = "8" -> (known after apply)
        id               = "arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-mcp:live"
        name             = "live"
        # (5 unchanged attributes hidden)
    }

  # module.mcp.aws_lambda_function.this will be updated in-place
  ~ resource "aws_lambda_function" "this" {
        id                             = "substratal-prod-mcp"
      ~ last_modified                  = "2026-10-22T03:40:45.000+0000" -> (known after apply)
      ~ qualified_arn                  = "arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-mcp:8" -> (known after apply)
      ~ qualified_invoke_arn           = "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-mcp:8/invocations" -> (known after apply)
      ~ s3_key                         = "364ce5bb0458b14bd90d076a972d99d7e3fecfcf/mcp.zip" -> "983dde9623c02cb457adbcf9f9de89eaa56db46b/mcp.zip"
        tags                           = {}
      ~ version                        = "8" -> (known after apply)
        # (28 unchanged attributes hidden)

        # (4 unchanged blocks hidden)
    }

  # module.stripe_webhook.aws_lambda_alias.live will be updated in-place
  ~ resource "aws_lambda_alias" "live" {
      ~ function_version = "7" -> (known after apply)
        id               = "arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-stripe-webhook:live"
        name             = "live"
        # (5 unchanged attributes hidden)
    }

  # module.stripe_webhook.aws_lambda_function.this will be updated in-place
  ~ resource "aws_lambda_function" "this" {
        id                             = "substratal-prod-stripe-webhook"
      ~ last_modified                  = "2026-10-22T03:40:45.000+0000" -> (known after apply)
      ~ qualified_arn                  = "arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-stripe-webhook:7" -> (known after apply)
      ~ qualified_invoke_arn           = "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-stripe-webhook:7/invocations" -> (known after apply)
      ~ s3_key                         = "364ce5bb0458b14bd90d076a972d99d7e3fecfcf/stripe-webhook.zip" -> "983dde9623c02cb457adbcf9f9de89eaa56db46b/stripe-webhook.zip"
        tags                           = {}
      ~ version                        = "7" -> (known after apply)
        # (28 unchanged attributes hidden)

        # (4 unchanged blocks hidden)
    }

  # module.webhook_worker.aws_lambda_alias.live will be updated in-place
  ~ resource "aws_lambda_alias" "live" {
      ~ function_version = "7" -> (known after apply)
        id               = "arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-webhook-worker:live"
        name             = "live"
        # (5 unchanged attributes hidden)
    }

  # module.webhook_worker.aws_lambda_function.this will be updated in-place
  ~ resource "aws_lambda_function" "this" {
        id                             = "substratal-prod-webhook-worker"
      ~ last_modified                  = "2026-10-22T03:40:45.000+0000" -> (known after apply)
      ~ qualified_arn                  = "arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-webhook-worker:7" -> (known after apply)
      ~ qualified_invoke_arn           = "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-webhook-worker:7/invocations" -> (known after apply)
      ~ s3_key                         = "364ce5bb0458b14bd90d076a972d99d7e3fecfcf/webhook-worker.zip" -> "983dde9623c02cb457adbcf9f9de89eaa56db46b/webhook-worker.zip"
        tags                           = {}
      ~ version                        = "7" -> (known after apply)
        # (28 unchanged attributes hidden)

        # (4 unchanged blocks hidden)
    }

Plan: 5 to add, 12 to change, 0 to destroy.

─────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.

- Migration 0007_audit_exports → 0011_audit_exports: 0008_scim_tokens
  reached production first; #58 and #60 are now 0009 and 0010. Its audit
  action check is the full set, SCIM's actions included.
- openapi.yaml 1.0.0-draft.8 (after #58 draft.6 and #60 draft.7), 123
  operations; Changelog, OpenAPI index, roadmap, schema, action catalog,
  ids, and plan-remaining.md merged with SCIM's additions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Adron Adron changed the title Add Audit Event exports (POST/GET /v1/audit-exports) Add Audit Event exports (POST/GET /audit-exports) Oct 22, 2026
@github-actions

Copy link
Copy Markdown

terraform plan (production)

This is exactly what merging will change in AWS.

Plan
random_password.mcp_session: Refreshing state... [id=none]
data.aws_caller_identity.me: Reading...
module.jobs.aws_cloudwatch_log_group.this: Refreshing state... [id=/aws/lambda/substratal-prod-jobs]
module.api.aws_cloudwatch_log_group.this: Refreshing state... [id=/aws/lambda/substratal-prod-api]
aws_sns_topic.alarms: Refreshing state... [id=arn:aws:sns:us-east-1:196936048600:substratal-prod-alarms]
data.aws_servicequotas_service_quota.lambda_concurrency: Reading...
data.aws_route53_zone.main: Reading...
aws_cloudwatch_log_group.api_access: Refreshing state... [id=/aws/apigateway/substratal-prod]
module.jobs.aws_iam_role.this: Refreshing state... [id=substratal-prod-jobs]
aws_kms_key.data: Refreshing state... [id=b0cda5a8-651e-419f-b8ab-61772bce79da]
aws_sesv2_email_identity.mail: Refreshing state... [id=mail.substratalapps.com]
data.aws_caller_identity.me: Read complete after 0s [id=196936048600]
aws_sqs_queue.webhooks_dlq: Refreshing state... [id=https://sqs.us-east-1.amazonaws.com/196936048600/substratal-prod-webhooks-dlq]
aws_apigatewayv2_api.main: Refreshing state... [id=rv9n7e3ww4]
aws_secretsmanager_secret.app: Refreshing state... [id=arn:aws:secretsmanager:us-east-1:196936048600:secret:substratal-prod/app-1hXTGg]
aws_iam_role.scheduler: Refreshing state... [id=substratal-prod-scheduler]
aws_rds_cluster.main: Refreshing state... [id=substratal-prod-db]
data.aws_servicequotas_service_quota.lambda_concurrency: Read complete after 0s [id=arn:aws:servicequotas:us-east-1::lambda/L-B99A9384]
module.webhook_worker.aws_iam_role.this: Refreshing state... [id=substratal-prod-webhook-worker]
aws_acm_certificate.api: Refreshing state... [id=arn:aws:acm:us-east-1:196936048600:certificate/a8e2136f-64d3-456b-904a-05ea74eba9b3]
module.stripe_webhook.aws_cloudwatch_log_group.this: Refreshing state... [id=/aws/lambda/substratal-prod-stripe-webhook]
module.api.aws_iam_role.this: Refreshing state... [id=substratal-prod-api]
data.aws_route53_zone.main: Read complete after 0s [id=Z0783627X8ON1GH6I0XB]
module.stripe_webhook.aws_iam_role.this: Refreshing state... [id=substratal-prod-stripe-webhook]
aws_kms_key.signing: Refreshing state... [id=0d2770cd-bbc8-4904-a432-2e9c9304a3ca]
module.mcp.aws_iam_role.this: Refreshing state... [id=substratal-prod-mcp]
module.mcp.aws_cloudwatch_log_group.this: Refreshing state... [id=/aws/lambda/substratal-prod-mcp]
module.webhook_worker.aws_cloudwatch_log_group.this: Refreshing state... [id=/aws/lambda/substratal-prod-webhook-worker]
aws_s3_bucket.audit_archive: Refreshing state... [id=substratal-prod-audit-archive-196936048600]
aws_kms_alias.data: Refreshing state... [id=alias/substratal-prod-data]
aws_sqs_queue.webhooks: Refreshing state... [id=https://sqs.us-east-1.amazonaws.com/196936048600/substratal-prod-webhooks]
aws_cloudwatch_metric_alarm.webhook_dlq: Refreshing state... [id=substratal-prod-webhooks-dlq]
aws_sns_topic_subscription.alarms[0]: Refreshing state... [id=arn:aws:sns:us-east-1:196936048600:substratal-prod-alarms:fffb7317-8153-4e66-9212-eded800c9c05]
aws_cloudwatch_metric_alarm.security["failed-logins"]: Refreshing state... [id=substratal-prod-security-failed-logins]
aws_cloudwatch_metric_alarm.security["refresh-reuse"]: Refreshing state... [id=substratal-prod-security-refresh-reuse]
aws_cloudwatch_metric_alarm.security["destructive-blocked"]: Refreshing state... [id=substratal-prod-security-destructive-blocked]
aws_route53_record.dmarc: Refreshing state... [id=Z0783627X8ON1GH6I0XB__dmarc.mail.substratalapps.com_TXT]
aws_route53_record.dkim[0]: Refreshing state... [id=Z0783627X8ON1GH6I0XB_fmy2g7whqti56v5vn56es6izxo4yzayc._domainkey.mail.substratalapps.com_CNAME]
aws_route53_record.dkim[1]: Refreshing state... [id=Z0783627X8ON1GH6I0XB_fhl63kaowzjfd2splohuyaifnnansx5d._domainkey.mail.substratalapps.com_CNAME]
aws_route53_record.dkim[2]: Refreshing state... [id=Z0783627X8ON1GH6I0XB_rp4dt5rjjkpnhdyxud6l2spetqo5nom4._domainkey.mail.substratalapps.com_CNAME]
terraform_data.app_secret_init: Refreshing state... [id=5474e6c3-4843-79d8-9346-9fc3d3f8fa87]
module.jobs.aws_iam_role_policy.logs: Refreshing state... [id=substratal-prod-jobs:logs]
aws_apigatewayv2_stage.default: Refreshing state... [id=$default]
aws_cloudwatch_metric_alarm.api_5xx: Refreshing state... [id=substratal-prod-api-5xx]
aws_kms_alias.signing: Refreshing state... [id=alias/substratal-prod-jwt-signing]
aws_cloudwatch_log_metric_filter.security["refresh-reuse"]: Refreshing state... [id=substratal-prod-refresh-reuse]
aws_cloudwatch_log_metric_filter.security["destructive-blocked"]: Refreshing state... [id=substratal-prod-destructive-blocked]
aws_cloudwatch_log_metric_filter.security["failed-logins"]: Refreshing state... [id=substratal-prod-failed-logins]
aws_route53_record.cert_validation["api.substratalapps.com"]: Refreshing state... [id=Z0783627X8ON1GH6I0XB__45a2e898c02bdc2297dde6c5e5a798a8.api.substratalapps.com._CNAME]
aws_cloudwatch_metric_alarm.aurora_near_max: Refreshing state... [id=substratal-prod-aurora-acu-near-max]
aws_rds_cluster_instance.main: Refreshing state... [id=substratal-prod-db-1]
module.webhook_worker.aws_iam_role_policy.logs: Refreshing state... [id=substratal-prod-webhook-worker:logs]
module.api.aws_iam_role_policy.logs: Refreshing state... [id=substratal-prod-api:logs]
module.mcp.aws_iam_role_policy.logs: Refreshing state... [id=substratal-prod-mcp:logs]
module.stripe_webhook.aws_iam_role_policy.logs: Refreshing state... [id=substratal-prod-stripe-webhook:logs]
module.stripe_webhook.aws_iam_role_policy.this[0]: Refreshing state... [id=substratal-prod-stripe-webhook:function]
module.webhook_worker.aws_iam_role_policy.this[0]: Refreshing state... [id=substratal-prod-webhook-worker:function]
module.api.aws_iam_role_policy.this[0]: Refreshing state... [id=substratal-prod-api:function]
module.api.aws_lambda_function.this: Refreshing state... [id=substratal-prod-api]
module.webhook_worker.aws_lambda_function.this: Refreshing state... [id=substratal-prod-webhook-worker]
module.mcp.aws_lambda_function.this: Refreshing state... [id=substratal-prod-mcp]
module.stripe_webhook.aws_lambda_function.this: Refreshing state... [id=substratal-prod-stripe-webhook]
aws_acm_certificate_validation.api: Refreshing state... [id=2026-10-20 20:44:42.323 +0000 UTC]
aws_apigatewayv2_domain_name.api: Refreshing state... [id=api.substratalapps.com]
aws_apigatewayv2_api_mapping.api: Refreshing state... [id=zdmjg2]
aws_route53_record.api: Refreshing state... [id=Z0783627X8ON1GH6I0XB_api.substratalapps.com_A]
aws_s3_bucket_versioning.audit_archive: Refreshing state... [id=substratal-prod-audit-archive-196936048600]
aws_s3_bucket_public_access_block.audit_archive: Refreshing state... [id=substratal-prod-audit-archive-196936048600]
aws_s3_bucket_server_side_encryption_configuration.audit_archive: Refreshing state... [id=substratal-prod-audit-archive-196936048600]
module.jobs.aws_iam_role_policy.this[0]: Refreshing state... [id=substratal-prod-jobs:function]
module.jobs.aws_lambda_function.this: Refreshing state... [id=substratal-prod-jobs]
module.api.aws_lambda_alias.live: Refreshing state... [id=arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-api:live]
module.mcp.aws_lambda_alias.live: Refreshing state... [id=arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-mcp:live]
module.webhook_worker.aws_lambda_alias.live: Refreshing state... [id=arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-webhook-worker:live]
module.stripe_webhook.aws_lambda_alias.live: Refreshing state... [id=arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-stripe-webhook:live]
aws_cloudwatch_metric_alarm.lambda_throttles["worker"]: Refreshing state... [id=substratal-prod-worker-throttles]
aws_lambda_event_source_mapping.webhooks: Refreshing state... [id=bed6416f-6a72-474a-83e5-559c995cf8f3]
aws_cloudwatch_metric_alarm.lambda_throttles["api"]: Refreshing state... [id=substratal-prod-api-throttles]
aws_lambda_permission.apigw["api"]: Refreshing state... [id=apigateway-api]
aws_apigatewayv2_integration.fn["api"]: Refreshing state... [id=3h3bk4c]
aws_apigatewayv2_integration.fn["mcp"]: Refreshing state... [id=8xk20ia]
aws_apigatewayv2_integration.fn["stripe"]: Refreshing state... [id=34l24l9]
aws_lambda_permission.apigw["mcp"]: Refreshing state... [id=apigateway-mcp]
aws_lambda_permission.apigw["stripe"]: Refreshing state... [id=apigateway-stripe]
aws_apigatewayv2_route.r["POST /mcp"]: Refreshing state... [id=a197cl8]
aws_apigatewayv2_route.r["DELETE /mcp"]: Refreshing state... [id=peme2oj]
aws_apigatewayv2_route.r["POST /internal/stripe/webhook"]: Refreshing state... [id=bczysio]
aws_apigatewayv2_route.r["GET /mcp"]: Refreshing state... [id=s6ez97n]
aws_apigatewayv2_route.r["GET /.well-known/{proxy+}"]: Refreshing state... [id=kb35wrh]
aws_apigatewayv2_route.r["$default"]: Refreshing state... [id=cr82zzs]
module.jobs.aws_lambda_alias.live: Refreshing state... [id=arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-jobs:live]
aws_iam_role_policy.scheduler: Refreshing state... [id=substratal-prod-scheduler:terraform-339ca2ff2a164d299b046f72e0]
aws_scheduler_schedule.job["subscription-access"]: Refreshing state... [id=default/substratal-prod-subscription-access]
aws_scheduler_schedule.job["entitlement-sweep"]: Refreshing state... [id=default/substratal-prod-entitlement-sweep]
aws_scheduler_schedule.job["webhook-dispatch"]: Refreshing state... [id=default/substratal-prod-webhook-dispatch]
aws_scheduler_schedule.job["webhook-health"]: Refreshing state... [id=default/substratal-prod-webhook-health]
aws_scheduler_schedule.job["cleanup"]: Refreshing state... [id=default/substratal-prod-cleanup]
aws_scheduler_schedule.job["seat-sync"]: Refreshing state... [id=default/substratal-prod-seat-sync]
aws_scheduler_schedule.job["stripe-events"]: Refreshing state... [id=default/substratal-prod-stripe-events]
aws_scheduler_schedule.job["audit-archive"]: Refreshing state... [id=default/substratal-prod-audit-archive]
aws_cloudwatch_metric_alarm.lambda_errors["stripe"]: Refreshing state... [id=substratal-prod-stripe-errors]
aws_scheduler_schedule.job["push-maintenance"]: Refreshing state... [id=default/substratal-prod-push-maintenance]
aws_scheduler_schedule.job["erasure-cascade"]: Refreshing state... [id=default/substratal-prod-erasure-cascade]
aws_scheduler_schedule.job["settings-projections"]: Refreshing state... [id=default/substratal-prod-settings-projections]
aws_cloudwatch_metric_alarm.lambda_errors["worker"]: Refreshing state... [id=substratal-prod-worker-errors]
aws_cloudwatch_metric_alarm.lambda_errors["jobs"]: Refreshing state... [id=substratal-prod-jobs-errors]
aws_cloudwatch_metric_alarm.lambda_errors["mcp"]: Refreshing state... [id=substratal-prod-mcp-errors]
aws_cloudwatch_metric_alarm.lambda_errors["api"]: Refreshing state... [id=substratal-prod-api-errors]

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create
  ~ update in-place

Terraform will perform the following actions:

  # aws_s3_bucket.audit_exports will be created
  + resource "aws_s3_bucket" "audit_exports" {
      + acceleration_status         = (known after apply)
      + acl                         = (known after apply)
      + arn                         = (known after apply)
      + bucket                      = "substratal-prod-audit-exports-196936048600"
      + bucket_domain_name          = (known after apply)
      + bucket_namespace            = (known after apply)
      + bucket_prefix               = (known after apply)
      + bucket_region               = (known after apply)
      + bucket_regional_domain_name = (known after apply)
      + force_destroy               = false
      + hosted_zone_id              = (known after apply)
      + id                          = (known after apply)
      + object_lock_enabled         = (known after apply)
      + policy                      = (known after apply)
      + region                      = "us-east-1"
      + request_payer               = (known after apply)
      + tags_all                    = {
          + "managed_by" = "terraform"
          + "project"    = "substratal"
          + "stack"      = "prod"
        }
      + website_domain              = (known after apply)
      + website_endpoint            = (known after apply)

      + cors_rule (known after apply)

      + grant (known after apply)

      + lifecycle_rule (known after apply)

      + logging (known after apply)

      + object_lock_configuration (known after apply)

      + replication_configuration (known after apply)

      + server_side_encryption_configuration (known after apply)

      + versioning (known after apply)

      + website (known after apply)
    }

  # aws_s3_bucket_lifecycle_configuration.audit_exports will be created
  + resource "aws_s3_bucket_lifecycle_configuration" "audit_exports" {
      + bucket                                 = (known after apply)
      + expected_bucket_owner                  = (known after apply)
      + id                                     = (known after apply)
      + region                                 = "us-east-1"
      + transition_default_minimum_object_size = "all_storage_classes_128K"

      + rule {
          + id     = "expire-exports"
          + status = "Enabled"
            # (1 unchanged attribute hidden)

          + expiration {
              + days                         = 7
              + expired_object_delete_marker = false
            }

          + filter {
                # (1 unchanged attribute hidden)
            }
        }
    }

  # aws_s3_bucket_public_access_block.audit_exports will be created
  + resource "aws_s3_bucket_public_access_block" "audit_exports" {
      + block_public_acls       = true
      + block_public_policy     = true
      + bucket                  = (known after apply)
      + id                      = (known after apply)
      + ignore_public_acls      = true
      + region                  = "us-east-1"
      + restrict_public_buckets = true
    }

  # aws_s3_bucket_server_side_encryption_configuration.audit_exports will be created
  + resource "aws_s3_bucket_server_side_encryption_configuration" "audit_exports" {
      + bucket = (known after apply)
      + id     = (known after apply)
      + region = "us-east-1"

      + rule {
          + blocked_encryption_types = (known after apply)
          + bucket_key_enabled       = (known after apply)

          + apply_server_side_encryption_by_default {
              + kms_master_key_id = (known after apply)
              + sse_algorithm     = "AES256"
            }
        }
    }

  # aws_scheduler_schedule.job["audit-exports"] will be created
  + resource "aws_scheduler_schedule" "job" {
      + action_after_completion      = (known after apply)
      + arn                          = (known after apply)
      + group_name                   = (known after apply)
      + id                           = (known after apply)
      + name                         = "substratal-prod-audit-exports"
      + name_prefix                  = (known after apply)
      + region                       = "us-east-1"
      + schedule_expression          = "rate(1 minute)"
      + schedule_expression_timezone = "UTC"
      + state                        = "ENABLED"

      + flexible_time_window {
          + mode = "OFF"
        }

      + target {
          + arn      = "arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-jobs:live"
          + input    = jsonencode(
                {
                  + job = "audit-exports"
                }
            )
          + role_arn = "arn:aws:iam::196936048600:role/substratal-prod-scheduler"

          + retry_policy {
              + maximum_event_age_in_seconds = 86400
              + maximum_retry_attempts       = 2
            }
        }
    }

  # module.api.aws_iam_role_policy.this[0] will be updated in-place
  ~ resource "aws_iam_role_policy" "this" {
        id          = "substratal-prod-api:function"
        name        = "function"
      ~ policy      = jsonencode(
            {
              - Statement = [
                  - {
                      - Action   = [
                          - "rds-data:ExecuteStatement",
                          - "rds-data:BatchExecuteStatement",
                          - "rds-data:BeginTransaction",
                          - "rds-data:CommitTransaction",
                          - "rds-data:RollbackTransaction",
                        ]
                      - Effect   = "Allow"
                      - Resource = "arn:aws:rds:us-east-1:196936048600:cluster:substratal-prod-db"
                    },
                  - {
                      - Action   = [
                          - "secretsmanager:GetSecretValue",
                        ]
                      - Effect   = "Allow"
                      - Resource = [
                          - "arn:aws:secretsmanager:us-east-1:196936048600:secret:rds!cluster-9c5f3e8e-7c3b-49d6-92f5-77ae0ca64c1d-E5LGIA",
                          - "arn:aws:secretsmanager:us-east-1:196936048600:secret:substratal-prod/app-1hXTGg",
                        ]
                    },
                  - {
                      - Action   = [
                          - "kms:Sign",
                          - "kms:GetPublicKey",
                        ]
                      - Effect   = "Allow"
                      - Resource = "arn:aws:kms:us-east-1:196936048600:key/0d2770cd-bbc8-4904-a432-2e9c9304a3ca"
                    },
                  - {
                      - Action   = [
                          - "kms:Encrypt",
                          - "kms:Decrypt",
                        ]
                      - Effect   = "Allow"
                      - Resource = "arn:aws:kms:us-east-1:196936048600:key/b0cda5a8-651e-419f-b8ab-61772bce79da"
                    },
                  - {
                      - Action   = [
                          - "ses:SendEmail",
                        ]
                      - Effect   = "Allow"
                      - Resource = "arn:aws:ses:us-east-1:196936048600:identity/mail.substratalapps.com"
                    },
                  - {
                      - Action   = [
                          - "sqs:SendMessage",
                        ]
                      - Effect   = "Allow"
                      - Resource = "arn:aws:sqs:us-east-1:196936048600:substratal-prod-webhooks"
                    },
                ]
              - Version   = "2012-10-17"
            }
        ) -> (known after apply)
        # (2 unchanged attributes hidden)
    }

  # module.api.aws_lambda_alias.live will be updated in-place
  ~ resource "aws_lambda_alias" "live" {
      ~ function_version = "9" -> (known after apply)
        id               = "arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-api:live"
        name             = "live"
        # (5 unchanged attributes hidden)
    }

  # module.api.aws_lambda_function.this will be updated in-place
  ~ resource "aws_lambda_function" "this" {
        id                             = "substratal-prod-api"
      ~ last_modified                  = "2026-10-22T06:34:56.000+0000" -> (known after apply)
      ~ qualified_arn                  = "arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-api:9" -> (known after apply)
      ~ qualified_invoke_arn           = "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-api:9/invocations" -> (known after apply)
      ~ s3_key                         = "8d1a3f6ed856747a33e07b8fb48c66ce607337d9/api.zip" -> "00fa83bfecf1e637ac4965e9db33b7815ce75a34/api.zip"
        tags                           = {}
      ~ version                        = "9" -> (known after apply)
        # (28 unchanged attributes hidden)

      ~ environment {
          ~ variables = {
              + "AUDIT_EXPORT_BUCKET"  = "substratal-prod-audit-exports-196936048600"
                # (16 unchanged elements hidden)
            }
        }

        # (3 unchanged blocks hidden)
    }

  # module.jobs.aws_iam_role_policy.this[0] will be updated in-place
  ~ resource "aws_iam_role_policy" "this" {
        id          = "substratal-prod-jobs:function"
        name        = "function"
      ~ policy      = jsonencode(
            {
              - Statement = [
                  - {
                      - Action   = [
                          - "rds-data:ExecuteStatement",
                          - "rds-data:BatchExecuteStatement",
                          - "rds-data:BeginTransaction",
                          - "rds-data:CommitTransaction",
                          - "rds-data:RollbackTransaction",
                        ]
                      - Effect   = "Allow"
                      - Resource = "arn:aws:rds:us-east-1:196936048600:cluster:substratal-prod-db"
                    },
                  - {
                      - Action   = [
                          - "secretsmanager:GetSecretValue",
                        ]
                      - Effect   = "Allow"
                      - Resource = [
                          - "arn:aws:secretsmanager:us-east-1:196936048600:secret:rds!cluster-9c5f3e8e-7c3b-49d6-92f5-77ae0ca64c1d-E5LGIA",
                          - "arn:aws:secretsmanager:us-east-1:196936048600:secret:substratal-prod/app-1hXTGg",
                        ]
                    },
                  - {
                      - Action   = [
                          - "kms:Sign",
                          - "kms:GetPublicKey",
                        ]
                      - Effect   = "Allow"
                      - Resource = "arn:aws:kms:us-east-1:196936048600:key/0d2770cd-bbc8-4904-a432-2e9c9304a3ca"
                    },
                  - {
                      - Action   = [
                          - "kms:Encrypt",
                          - "kms:Decrypt",
                        ]
                      - Effect   = "Allow"
                      - Resource = "arn:aws:kms:us-east-1:196936048600:key/b0cda5a8-651e-419f-b8ab-61772bce79da"
                    },
                  - {
                      - Action   = [
                          - "ses:SendEmail",
                        ]
                      - Effect   = "Allow"
                      - Resource = "arn:aws:ses:us-east-1:196936048600:identity/mail.substratalapps.com"
                    },
                  - {
                      - Action   = [
                          - "s3:PutObject",
                        ]
                      - Effect   = "Allow"
                      - Resource = "arn:aws:s3:::substratal-prod-audit-archive-196936048600/*"
                    },
                ]
              - Version   = "2012-10-17"
            }
        ) -> (known after apply)
        # (2 unchanged attributes hidden)
    }

  # module.jobs.aws_lambda_alias.live will be updated in-place
  ~ resource "aws_lambda_alias" "live" {
      ~ function_version = "9" -> (known after apply)
        id               = "arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-jobs:live"
        name             = "live"
        # (5 unchanged attributes hidden)
    }

  # module.jobs.aws_lambda_function.this will be updated in-place
  ~ resource "aws_lambda_function" "this" {
        id                             = "substratal-prod-jobs"
      ~ last_modified                  = "2026-10-22T06:34:56.000+0000" -> (known after apply)
      ~ qualified_arn                  = "arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-jobs:9" -> (known after apply)
      ~ qualified_invoke_arn           = "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-jobs:9/invocations" -> (known after apply)
      ~ s3_key                         = "8d1a3f6ed856747a33e07b8fb48c66ce607337d9/jobs.zip" -> "00fa83bfecf1e637ac4965e9db33b7815ce75a34/jobs.zip"
        tags                           = {}
      ~ version                        = "9" -> (known after apply)
        # (28 unchanged attributes hidden)

      ~ environment {
          ~ variables = {
              + "AUDIT_EXPORT_BUCKET"  = "substratal-prod-audit-exports-196936048600"
                # (15 unchanged elements hidden)
            }
        }

        # (3 unchanged blocks hidden)
    }

  # module.mcp.aws_lambda_alias.live will be updated in-place
  ~ resource "aws_lambda_alias" "live" {
      ~ function_version = "10" -> (known after apply)
        id               = "arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-mcp:live"
        name             = "live"
        # (5 unchanged attributes hidden)
    }

  # module.mcp.aws_lambda_function.this will be updated in-place
  ~ resource "aws_lambda_function" "this" {
        id                             = "substratal-prod-mcp"
      ~ last_modified                  = "2026-10-22T06:34:56.000+0000" -> (known after apply)
      ~ qualified_arn                  = "arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-mcp:10" -> (known after apply)
      ~ qualified_invoke_arn           = "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-mcp:10/invocations" -> (known after apply)
      ~ s3_key                         = "8d1a3f6ed856747a33e07b8fb48c66ce607337d9/mcp.zip" -> "00fa83bfecf1e637ac4965e9db33b7815ce75a34/mcp.zip"
        tags                           = {}
      ~ version                        = "10" -> (known after apply)
        # (28 unchanged attributes hidden)

        # (4 unchanged blocks hidden)
    }

  # module.stripe_webhook.aws_lambda_alias.live will be updated in-place
  ~ resource "aws_lambda_alias" "live" {
      ~ function_version = "9" -> (known after apply)
        id               = "arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-stripe-webhook:live"
        name             = "live"
        # (5 unchanged attributes hidden)
    }

  # module.stripe_webhook.aws_lambda_function.this will be updated in-place
  ~ resource "aws_lambda_function" "this" {
        id                             = "substratal-prod-stripe-webhook"
      ~ last_modified                  = "2026-10-22T06:34:56.000+0000" -> (known after apply)
      ~ qualified_arn                  = "arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-stripe-webhook:9" -> (known after apply)
      ~ qualified_invoke_arn           = "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-stripe-webhook:9/invocations" -> (known after apply)
      ~ s3_key                         = "8d1a3f6ed856747a33e07b8fb48c66ce607337d9/stripe-webhook.zip" -> "00fa83bfecf1e637ac4965e9db33b7815ce75a34/stripe-webhook.zip"
        tags                           = {}
      ~ version                        = "9" -> (known after apply)
        # (28 unchanged attributes hidden)

        # (4 unchanged blocks hidden)
    }

  # module.webhook_worker.aws_lambda_alias.live will be updated in-place
  ~ resource "aws_lambda_alias" "live" {
      ~ function_version = "9" -> (known after apply)
        id               = "arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-webhook-worker:live"
        name             = "live"
        # (5 unchanged attributes hidden)
    }

  # module.webhook_worker.aws_lambda_function.this will be updated in-place
  ~ resource "aws_lambda_function" "this" {
        id                             = "substratal-prod-webhook-worker"
      ~ last_modified                  = "2026-10-22T06:34:56.000+0000" -> (known after apply)
      ~ qualified_arn                  = "arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-webhook-worker:9" -> (known after apply)
      ~ qualified_invoke_arn           = "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-webhook-worker:9/invocations" -> (known after apply)
      ~ s3_key                         = "8d1a3f6ed856747a33e07b8fb48c66ce607337d9/webhook-worker.zip" -> "00fa83bfecf1e637ac4965e9db33b7815ce75a34/webhook-worker.zip"
        tags                           = {}
      ~ version                        = "9" -> (known after apply)
        # (28 unchanged attributes hidden)

        # (4 unchanged blocks hidden)
    }

Plan: 5 to add, 12 to change, 0 to destroy.

─────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[api-gaps] G19. Audit export: the console's minimum shape (tracked by #33) Audit-event export and retention enforcement

1 participant