Skip to content

Add SCIM 2.0 provisioning for Organizations - #62

Merged
Adron merged 2 commits into
mainfrom
feature/scim
Oct 22, 2026
Merged

Adron merged 2 commits into
mainfrom
feature/scim

Conversation

@Adron

@Adron Adron commented Oct 22, 2026

Copy link
Copy Markdown
Member

Closes #34.

An Organization's identity provider (Okta, Entra ID, Google Workspace, …) can now invite and remove the Organization's members with SCIM 2.0. Spec: the new page API Reference → SCIM.

Setup (in openapi.yaml)

  • POST /v1/organizations/{id}/scim-token: org admin or organizations.manage, with a User token. Creates the token, or rotates it; the old one stops working at once. Returns the scim_… secret once, plus base_url.
  • GET: shows the token without the secret, including last_used_at. DELETE: turns SCIM off.
  • Storage: one token per Organization, with only its SHA-256 stored. Migration 0008_scim_tokens.
  • Audit: new actions organization.scim_token_created and organization.scim_token_revoked.

/scim/v2 (RFC 7644, not openapi.yaml, like /.well-known)

ServiceProviderConfig, ResourceTypes, and Users: list (with a userName eq "…" filter and startIndex/count paging), create, get, PUT, PATCH, and delete. Responses use application/scim+json and SCIM's own error shape. API errors are mapped to SCIM error types (uniqueness, mutability, invalidValue). API Gateway's $default route already sends /scim/v2 to the API Lambda, so no infra change is needed.

Design choices (written into the SCIM page)

  • Provisioning invites. POST /Users is the same as an org admin inviting by email: a pending membership plus an invitation. Organizations' consent model makes activating someone on their behalf a platform action, so an identity provider can't attach an existing account to an Organization without its owner agreeing.
    • SCIM active is true for both pending and active memberships.
    • A Membership extension reports status and role.
    • displayName appears only once the person has accepted. The member list already hides it the same way, so provisioning an address never reveals whether it already had an account.
  • Deprovisioning removes the membership. active: false (through PATCH or PUT, including the string "False" that Entra sends) or DELETE behaves like DELETE …/members/{userId}.
    • organization.member_removed and access.revoked fire.
    • The person's own account is never suspended. An Organization can't switch off someone's whole Substratal account.
    • The last-org_admin guard applies.
  • Audit attribution: SCIM writes are audited with actor type system and actor id the token's sct_… id. No new actor type is needed.
  • Out of scope: Groups, bulk operations, sorting, ETags, and setting roles. Groups is the obvious next step if an identity provider needs it.

Refactor

The invite and removal logic is extracted from the member handlers into inviteMember, insertPendingMember, and removeMember, so SCIM uses exactly the same code paths. Behavior is unchanged; the existing Organization tests pass as they were.

Tests

TestSCIMProvisioning:

  • Token lifecycle: the secret is shown only once, and a non-admin is refused.
  • SCIM-shaped 401; an unsupported resource returns 404.
  • Provisioning an existing account: the membership is pending and grants nothing.
  • A duplicate returns 409.
  • Provisioning a new address sends an account invitation.
  • Audit attribution to the token.
  • The filter, paging, and an unsupported filter.
  • Accepting brings access.
  • PATCH active "False" removes the membership: access.revoked fires once, GET returns 404, and the person's account still works.
  • DELETE removes a pending invitation.
  • Removing the last admin returns 409 mutability.
  • Revoking the token cuts SCIM off.

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

Merge order

🤖 Generated with Claude Code

An Organization's identity provider can invite and remove its members
(API Reference → SCIM):

- POST/GET/DELETE /v1/organizations/{id}/scim-token (org admin or
  organizations.manage): one bearer token per Organization, secret shown
  once, SHA-256 stored. Migration 0008_scim_tokens, with
  organization.scim_token_created / _revoked audit actions.
- /scim/v2: ServiceProviderConfig, ResourceTypes, and Users (list with
  userName eq filter and paging, create, get, put, patch, delete), in
  SCIM's own response and error shapes.
- Same membership rules as org admins: provisioning invites (pending
  until the person accepts, whether or not they had an account);
  deprovisioning removes the membership with access.revoked and never
  touches the person's account; the last-org_admin guard applies. Writes
  are audited as actor system, id the token's sct_ id.
- The invite and removal logic is factored out of the member handlers
  (inviteMember, insertPendingMember, removeMember) and shared, with no
  behavior change.

Users only; no Groups, bulk, sort, or ETags.

Closes #34

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Adron Adron mentioned this pull request Oct 22, 2026
4 tasks
@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_secretsmanager_secret.app: Refreshing state... [id=arn:aws:secretsmanager:us-east-1:196936048600:secret:substratal-prod/app-1hXTGg]
data.aws_caller_identity.me: Reading...
module.mcp.aws_cloudwatch_log_group.this: Refreshing state... [id=/aws/lambda/substratal-prod-mcp]
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.stripe_webhook.aws_cloudwatch_log_group.this: Refreshing state... [id=/aws/lambda/substratal-prod-stripe-webhook]
data.aws_route53_zone.main: Reading...
aws_kms_key.data: Refreshing state... [id=b0cda5a8-651e-419f-b8ab-61772bce79da]
module.webhook_worker.aws_cloudwatch_log_group.this: Refreshing state... [id=/aws/lambda/substratal-prod-webhook-worker]
aws_sns_topic.alarms: Refreshing state... [id=arn:aws:sns:us-east-1:196936048600:substratal-prod-alarms]
data.aws_caller_identity.me: Read complete after 0s [id=196936048600]
aws_acm_certificate.api: Refreshing state... [id=arn:aws:acm:us-east-1:196936048600:certificate/a8e2136f-64d3-456b-904a-05ea74eba9b3]
aws_kms_key.signing: Refreshing state... [id=0d2770cd-bbc8-4904-a432-2e9c9304a3ca]
aws_sesv2_email_identity.mail: Refreshing state... [id=mail.substratalapps.com]
module.stripe_webhook.aws_iam_role.this: Refreshing state... [id=substratal-prod-stripe-webhook]
data.aws_servicequotas_service_quota.lambda_concurrency: Reading...
aws_cloudwatch_log_group.api_access: Refreshing state... [id=/aws/apigateway/substratal-prod]
aws_apigatewayv2_api.main: Refreshing state... [id=rv9n7e3ww4]
module.jobs.aws_iam_role.this: Refreshing state... [id=substratal-prod-jobs]
aws_sqs_queue.webhooks_dlq: Refreshing state... [id=https://sqs.us-east-1.amazonaws.com/196936048600/substratal-prod-webhooks-dlq]
aws_rds_cluster.main: Refreshing state... [id=substratal-prod-db]
module.api.aws_cloudwatch_log_group.this: Refreshing state... [id=/aws/lambda/substratal-prod-api]
module.api.aws_iam_role.this: Refreshing state... [id=substratal-prod-api]
module.mcp.aws_iam_role.this: Refreshing state... [id=substratal-prod-mcp]
data.aws_route53_zone.main: Read complete after 1s [id=Z0783627X8ON1GH6I0XB]
module.webhook_worker.aws_iam_role.this: Refreshing state... [id=substratal-prod-webhook-worker]
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]
terraform_data.app_secret_init: Refreshing state... [id=5474e6c3-4843-79d8-9346-9fc3d3f8fa87]
aws_cloudwatch_metric_alarm.security["refresh-reuse"]: Refreshing state... [id=substratal-prod-security-refresh-reuse]
aws_cloudwatch_metric_alarm.security["failed-logins"]: Refreshing state... [id=substratal-prod-security-failed-logins]
aws_cloudwatch_metric_alarm.security["destructive-blocked"]: Refreshing state... [id=substratal-prod-security-destructive-blocked]
aws_kms_alias.data: Refreshing state... [id=alias/substratal-prod-data]
aws_kms_alias.signing: Refreshing state... [id=alias/substratal-prod-jwt-signing]
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]
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]
aws_route53_record.dkim[0]: Refreshing state... [id=Z0783627X8ON1GH6I0XB_fmy2g7whqti56v5vn56es6izxo4yzayc._domainkey.mail.substratalapps.com_CNAME]
aws_cloudwatch_metric_alarm.api_5xx: Refreshing state... [id=substratal-prod-api-5xx]
aws_apigatewayv2_stage.default: Refreshing state... [id=$default]
aws_cloudwatch_metric_alarm.webhook_dlq: Refreshing state... [id=substratal-prod-webhooks-dlq]
aws_sqs_queue.webhooks: Refreshing state... [id=https://sqs.us-east-1.amazonaws.com/196936048600/substratal-prod-webhooks]
module.stripe_webhook.aws_iam_role_policy.logs: Refreshing state... [id=substratal-prod-stripe-webhook:logs]
module.jobs.aws_iam_role_policy.logs: Refreshing state... [id=substratal-prod-jobs:logs]
aws_cloudwatch_log_metric_filter.security["destructive-blocked"]: Refreshing state... [id=substratal-prod-destructive-blocked]
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]
module.mcp.aws_iam_role_policy.logs: Refreshing state... [id=substratal-prod-mcp:logs]
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]
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]
aws_acm_certificate_validation.api: Refreshing state... [id=2026-10-20 20:44:42.323 +0000 UTC]
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_lambda_function.this: Refreshing state... [id=substratal-prod-api]
module.api.aws_iam_role_policy.this[0]: Refreshing state... [id=substratal-prod-api:function]
module.stripe_webhook.aws_lambda_function.this: Refreshing state... [id=substratal-prod-stripe-webhook]
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]
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_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]
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]
module.mcp.aws_lambda_alias.live: Refreshing state... [id=arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-mcp: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["worker"]: Refreshing state... [id=substratal-prod-worker-throttles]
aws_cloudwatch_metric_alarm.lambda_throttles["api"]: Refreshing state... [id=substratal-prod-api-throttles]
aws_apigatewayv2_integration.fn["api"]: Refreshing state... [id=3h3bk4c]
aws_apigatewayv2_integration.fn["stripe"]: Refreshing state... [id=34l24l9]
aws_lambda_permission.apigw["api"]: Refreshing state... [id=apigateway-api]
aws_apigatewayv2_integration.fn["mcp"]: Refreshing state... [id=8xk20ia]
aws_lambda_permission.apigw["stripe"]: Refreshing state... [id=apigateway-stripe]
aws_lambda_permission.apigw["mcp"]: Refreshing state... [id=apigateway-mcp]
aws_apigatewayv2_route.r["GET /.well-known/{proxy+}"]: Refreshing state... [id=kb35wrh]
aws_apigatewayv2_route.r["DELETE /mcp"]: Refreshing state... [id=peme2oj]
aws_apigatewayv2_route.r["POST /mcp"]: Refreshing state... [id=a197cl8]
aws_apigatewayv2_route.r["$default"]: Refreshing state... [id=cr82zzs]
aws_apigatewayv2_route.r["POST /internal/stripe/webhook"]: Refreshing state... [id=bczysio]
aws_apigatewayv2_route.r["GET /mcp"]: Refreshing state... [id=s6ez97n]
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_cloudwatch_metric_alarm.lambda_errors["mcp"]: Refreshing state... [id=substratal-prod-mcp-errors]
aws_scheduler_schedule.job["audit-archive"]: Refreshing state... [id=default/substratal-prod-audit-archive]
aws_scheduler_schedule.job["stripe-events"]: Refreshing state... [id=default/substratal-prod-stripe-events]
aws_scheduler_schedule.job["webhook-dispatch"]: Refreshing state... [id=default/substratal-prod-webhook-dispatch]
aws_scheduler_schedule.job["cleanup"]: Refreshing state... [id=default/substratal-prod-cleanup]
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["webhook-health"]: Refreshing state... [id=default/substratal-prod-webhook-health]
aws_scheduler_schedule.job["erasure-cascade"]: Refreshing state... [id=default/substratal-prod-erasure-cascade]
aws_scheduler_schedule.job["seat-sync"]: Refreshing state... [id=default/substratal-prod-seat-sync]
aws_scheduler_schedule.job["entitlement-sweep"]: Refreshing state... [id=default/substratal-prod-entitlement-sweep]
aws_cloudwatch_metric_alarm.lambda_errors["stripe"]: Refreshing state... [id=substratal-prod-stripe-errors]
aws_scheduler_schedule.job["settings-projections"]: Refreshing state... [id=default/substratal-prod-settings-projections]
aws_cloudwatch_metric_alarm.lambda_errors["api"]: Refreshing state... [id=substratal-prod-api-errors]
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]

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_apigatewayv2_route.r["ANY /v1/{proxy+}"] will be created
  + resource "aws_apigatewayv2_route" "r" {
      + api_id             = "rv9n7e3ww4"
      + api_key_required   = false
      + authorization_type = "NONE"
      + id                 = (known after apply)
      + region             = "us-east-1"
      + route_key          = "ANY /v1/{proxy+}"
      + target             = "integrations/3h3bk4c"
    }

  # module.api.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-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:28:14.000+0000" -> (known after apply)
      ~ qualified_arn                  = "arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-api: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-api:6/invocations" -> (known after apply)
      ~ s3_key                         = "1565cd9446d963529a59855cadff7010f8939e28/api.zip" -> "a58991336ba408b8d3f1d5165a95a26c1f2cb0b8/api.zip"
        tags                           = {}
      ~ version                        = "6" -> (known after apply)
        # (28 unchanged attributes hidden)

        # (4 unchanged blocks hidden)
    }

  # module.jobs.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-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:28:14.000+0000" -> (known after apply)
      ~ qualified_arn                  = "arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-jobs: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-jobs:6/invocations" -> (known after apply)
      ~ s3_key                         = "1565cd9446d963529a59855cadff7010f8939e28/jobs.zip" -> "a58991336ba408b8d3f1d5165a95a26c1f2cb0b8/jobs.zip"
        tags                           = {}
      ~ version                        = "6" -> (known after apply)
        # (28 unchanged attributes hidden)

        # (4 unchanged blocks hidden)
    }

  # module.mcp.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-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:28:14.000+0000" -> (known after apply)
      ~ qualified_arn                  = "arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-mcp: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-mcp:7/invocations" -> (known after apply)
      ~ s3_key                         = "1565cd9446d963529a59855cadff7010f8939e28/mcp.zip" -> "a58991336ba408b8d3f1d5165a95a26c1f2cb0b8/mcp.zip"
        tags                           = {}
      ~ version                        = "7" -> (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 = "6" -> (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:28:14.000+0000" -> (known after apply)
      ~ qualified_arn                  = "arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-stripe-webhook: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-stripe-webhook:6/invocations" -> (known after apply)
      ~ s3_key                         = "1565cd9446d963529a59855cadff7010f8939e28/stripe-webhook.zip" -> "a58991336ba408b8d3f1d5165a95a26c1f2cb0b8/stripe-webhook.zip"
        tags                           = {}
      ~ version                        = "6" -> (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 = "6" -> (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:28:14.000+0000" -> (known after apply)
      ~ qualified_arn                  = "arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-webhook-worker: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-webhook-worker:6/invocations" -> (known after apply)
      ~ s3_key                         = "1565cd9446d963529a59855cadff7010f8939e28/webhook-worker.zip" -> "a58991336ba408b8d3f1d5165a95a26c1f2cb0b8/webhook-worker.zip"
        tags                           = {}
      ~ version                        = "6" -> (known after apply)
        # (28 unchanged attributes hidden)

        # (4 unchanged blocks hidden)
    }

Plan: 1 to add, 10 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.

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

- /scim/v2 is exempt from Substratal-Version: identity providers (Okta,
  Entra ID, Google Workspace) can't add custom headers, and SCIM versions
  itself in the path. Without this every IdP request would be
  400 api_version_required. The scim-token endpoints still need it.
  Conventions, SCIM, openapi.yaml's description, and the Changelog say so
- scimCall sends no Substratal-Version, the way an IdP does, so the
  whole SCIM suite exercises the exemption; testenv skips contract
  validation for /scim/ (it follows RFC 7644, not openapi.yaml)
- Conflicts in Organizations and Orders & Audit: this branch's rows, with
  root paths; plan-remaining.md is main's (this branch didn't change it).
  The Changelog puts this branch's entry above main's two entries
- openapi.yaml 1.0.0-draft.5 (main took draft.4 for the header change)
- Root paths in scim.md, scim.go, and scim_test.go; 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_sesv2_email_identity.mail: Refreshing state... [id=mail.substratalapps.com]
module.api.aws_iam_role.this: Refreshing state... [id=substratal-prod-api]
aws_kms_key.data: Refreshing state... [id=b0cda5a8-651e-419f-b8ab-61772bce79da]
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]
data.aws_servicequotas_service_quota.lambda_concurrency: Reading...
module.jobs.aws_iam_role.this: Refreshing state... [id=substratal-prod-jobs]
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.webhook_worker.aws_iam_role.this: Refreshing state... [id=substratal-prod-webhook-worker]
data.aws_servicequotas_service_quota.lambda_concurrency: Read complete after 0s [id=arn:aws:servicequotas:us-east-1::lambda/L-B99A9384]
aws_iam_role.scheduler: Refreshing state... [id=substratal-prod-scheduler]
data.aws_caller_identity.me: Reading...
module.stripe_webhook.aws_iam_role.this: Refreshing state... [id=substratal-prod-stripe-webhook]
aws_rds_cluster.main: Refreshing state... [id=substratal-prod-db]
aws_sqs_queue.webhooks_dlq: Refreshing state... [id=https://sqs.us-east-1.amazonaws.com/196936048600/substratal-prod-webhooks-dlq]
module.webhook_worker.aws_cloudwatch_log_group.this: Refreshing state... [id=/aws/lambda/substratal-prod-webhook-worker]
data.aws_caller_identity.me: Read complete after 0s [id=196936048600]
aws_cloudwatch_log_group.api_access: Refreshing state... [id=/aws/apigateway/substratal-prod]
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]
data.aws_route53_zone.main: Reading...
module.mcp.aws_cloudwatch_log_group.this: Refreshing state... [id=/aws/lambda/substratal-prod-mcp]
module.jobs.aws_cloudwatch_log_group.this: Refreshing state... [id=/aws/lambda/substratal-prod-jobs]
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_kms_alias.signing: Refreshing state... [id=alias/substratal-prod-jwt-signing]
aws_kms_alias.data: Refreshing state... [id=alias/substratal-prod-data]
aws_s3_bucket.audit_archive: Refreshing state... [id=substratal-prod-audit-archive-196936048600]
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]
aws_sqs_queue.webhooks: Refreshing state... [id=https://sqs.us-east-1.amazonaws.com/196936048600/substratal-prod-webhooks]
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]
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.jobs.aws_iam_role_policy.logs: Refreshing state... [id=substratal-prod-jobs:logs]
aws_rds_cluster_instance.main: Refreshing state... [id=substratal-prod-db-1]
aws_cloudwatch_metric_alarm.webhook_dlq: Refreshing state... [id=substratal-prod-webhooks-dlq]
aws_cloudwatch_metric_alarm.aurora_near_max: Refreshing state... [id=substratal-prod-aurora-acu-near-max]
aws_cloudwatch_metric_alarm.security["refresh-reuse"]: Refreshing state... [id=substratal-prod-security-refresh-reuse]
aws_cloudwatch_metric_alarm.security["failed-logins"]: Refreshing state... [id=substratal-prod-security-failed-logins]
aws_cloudwatch_metric_alarm.security["destructive-blocked"]: Refreshing state... [id=substratal-prod-security-destructive-blocked]
terraform_data.app_secret_init: Refreshing state... [id=5474e6c3-4843-79d8-9346-9fc3d3f8fa87]
aws_cloudwatch_metric_alarm.api_5xx: Refreshing state... [id=substratal-prod-api-5xx]
aws_apigatewayv2_stage.default: Refreshing state... [id=$default]
data.aws_route53_zone.main: Read complete after 1s [id=Z0783627X8ON1GH6I0XB]
module.mcp.aws_lambda_function.this: Refreshing state... [id=substratal-prod-mcp]
aws_route53_record.dkim[0]: Refreshing state... [id=Z0783627X8ON1GH6I0XB_fmy2g7whqti56v5vn56es6izxo4yzayc._domainkey.mail.substratalapps.com_CNAME]
aws_route53_record.dmarc: Refreshing state... [id=Z0783627X8ON1GH6I0XB__dmarc.mail.substratalapps.com_TXT]
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]
aws_route53_record.cert_validation["api.substratalapps.com"]: Refreshing state... [id=Z0783627X8ON1GH6I0XB__45a2e898c02bdc2297dde6c5e5a798a8.api.substratalapps.com._CNAME]
module.api.aws_iam_role_policy.this[0]: Refreshing state... [id=substratal-prod-api:function]
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.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]
module.mcp.aws_lambda_alias.live: Refreshing state... [id=arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-mcp:live]
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.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_apigatewayv2_api_mapping.api: Refreshing state... [id=zdmjg2]
aws_route53_record.api: Refreshing state... [id=Z0783627X8ON1GH6I0XB_api.substratalapps.com_A]
module.stripe_webhook.aws_lambda_alias.live: Refreshing state... [id=arn:aws:lambda:us-east-1:196936048600:function:substratal-prod-stripe-webhook:live]
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_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["api"]: Refreshing state... [id=3h3bk4c]
aws_apigatewayv2_integration.fn["stripe"]: Refreshing state... [id=34l24l9]
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_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["POST /mcp"]: Refreshing state... [id=a197cl8]
aws_apigatewayv2_route.r["GET /.well-known/{proxy+}"]: Refreshing state... [id=kb35wrh]
aws_apigatewayv2_route.r["$default"]: Refreshing state... [id=cr82zzs]
aws_apigatewayv2_route.r["POST /internal/stripe/webhook"]: Refreshing state... [id=bczysio]
aws_apigatewayv2_route.r["DELETE /mcp"]: Refreshing state... [id=peme2oj]
aws_apigatewayv2_route.r["GET /mcp"]: Refreshing state... [id=s6ez97n]
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["stripe-events"]: Refreshing state... [id=default/substratal-prod-stripe-events]
aws_scheduler_schedule.job["push-maintenance"]: Refreshing state... [id=default/substratal-prod-push-maintenance]
aws_cloudwatch_metric_alarm.lambda_errors["mcp"]: Refreshing state... [id=substratal-prod-mcp-errors]
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_scheduler_schedule.job["cleanup"]: Refreshing state... [id=default/substratal-prod-cleanup]
aws_scheduler_schedule.job["webhook-health"]: Refreshing state... [id=default/substratal-prod-webhook-health]
aws_scheduler_schedule.job["seat-sync"]: Refreshing state... [id=default/substratal-prod-seat-sync]
aws_scheduler_schedule.job["entitlement-sweep"]: Refreshing state... [id=default/substratal-prod-entitlement-sweep]
aws_scheduler_schedule.job["subscription-access"]: Refreshing state... [id=default/substratal-prod-subscription-access]
aws_scheduler_schedule.job["webhook-dispatch"]: Refreshing state... [id=default/substratal-prod-webhook-dispatch]
aws_scheduler_schedule.job["audit-archive"]: Refreshing state... [id=default/substratal-prod-audit-archive]
aws_cloudwatch_metric_alarm.lambda_errors["api"]: Refreshing state... [id=substratal-prod-api-errors]
aws_cloudwatch_metric_alarm.lambda_errors["stripe"]: Refreshing state... [id=substratal-prod-stripe-errors]
aws_cloudwatch_metric_alarm.lambda_errors["jobs"]: Refreshing state... [id=substratal-prod-jobs-errors]
aws_cloudwatch_metric_alarm.lambda_errors["worker"]: Refreshing state... [id=substratal-prod-worker-errors]

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

Terraform will perform the following actions:

  # 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" -> "18b83d771fa7fc14dc711eec05cb42540b36a3b0/api.zip"
        tags                           = {}
      ~ version                        = "7" -> (known after apply)
        # (28 unchanged attributes hidden)

        # (4 unchanged blocks 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" -> "18b83d771fa7fc14dc711eec05cb42540b36a3b0/jobs.zip"
        tags                           = {}
      ~ version                        = "7" -> (known after apply)
        # (28 unchanged attributes hidden)

        # (4 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" -> "18b83d771fa7fc14dc711eec05cb42540b36a3b0/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" -> "18b83d771fa7fc14dc711eec05cb42540b36a3b0/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" -> "18b83d771fa7fc14dc711eec05cb42540b36a3b0/webhook-worker.zip"
        tags                           = {}
      ~ version                        = "7" -> (known after apply)
        # (28 unchanged attributes hidden)

        # (4 unchanged blocks hidden)
    }

Plan: 0 to add, 10 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.

@Adron
Adron merged commit 8d1a3f6 into main Oct 22, 2026
8 checks passed
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.

SCIM provisioning

1 participant