thinkwork-cli 0.13.0 → 0.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/dist/{api-client-JUBCQQDF.js → api-client-JYZXYQJR.js} +3 -1
  2. package/dist/bowser-6Z6JFAIR.js +2822 -0
  3. package/dist/chunk-DV3MRBR5.js +43 -0
  4. package/dist/chunk-DVPBI5HM.js +1728 -0
  5. package/dist/chunk-EI5ZX7CN.js +522 -0
  6. package/dist/chunk-EUJ7X6YD.js +126 -0
  7. package/dist/chunk-GG5WKBOX.js +436 -0
  8. package/dist/chunk-I64EZSU5.js +358 -0
  9. package/dist/chunk-ILZKG5WV.js +6151 -0
  10. package/dist/chunk-KMIJ5KGT.js +128 -0
  11. package/dist/chunk-OCPHU34C.js +996 -0
  12. package/dist/chunk-QOERT44A.js +3286 -0
  13. package/dist/chunk-XYM7PE2A.js +2264 -0
  14. package/dist/{chunk-STY44FQO.js → chunk-Z2F6RJFR.js} +12 -18
  15. package/dist/cli.js +72564 -18884
  16. package/dist/dist-es-CRV46D5B.js +315 -0
  17. package/dist/dist-es-DCY4IC3Y.js +2000 -0
  18. package/dist/dist-es-ENBUC3JP.js +14726 -0
  19. package/dist/dist-es-G54LBMRN.js +48 -0
  20. package/dist/dist-es-G7PTG75M.js +176 -0
  21. package/dist/dist-es-GKDBE7E6.js +88 -0
  22. package/dist/dist-es-HY3535OI.js +70 -0
  23. package/dist/dist-es-KL7Y5NB2.js +377 -0
  24. package/dist/dist-es-LDE437AM.js +170 -0
  25. package/dist/dist-es-P26FWFLT.js +485 -0
  26. package/dist/dist-es-Q57SZXZ7.js +70 -0
  27. package/dist/dist-es-R7FGL2IB.js +88 -0
  28. package/dist/dist-es-USORBXZ5.js +318 -0
  29. package/dist/dist-es-ZKC2QKD2.js +377 -0
  30. package/dist/drizzle/0076_scheduled_jobs_marco_backfill.sql +2 -1
  31. package/dist/drizzle/0079_seed_tenant_customize_catalog.sql +1 -0
  32. package/dist/drizzle/0090_brain_schema_extraction.sql +10 -0
  33. package/dist/drizzle/0132_drop_computer_tables.sql +3 -0
  34. package/dist/drizzle/0160_compliance_event_types_plugins.sql +2 -0
  35. package/dist/drizzle/0214_think137_drop_judge_evidence_roi.sql +1 -0
  36. package/dist/drizzle/0216_drop_agent_loop_versions_goal_worker_policy.sql +1 -0
  37. package/dist/drizzle/0227_analyst_reader_role.sql +106 -38
  38. package/dist/drizzle/0230_analyst_rls.sql +192 -66
  39. package/dist/drizzle/0232_memory_source_thin_ledger.sql +176 -0
  40. package/dist/drizzle/0232_message_source_event_id.sql +9 -0
  41. package/dist/drizzle/0233_msteams_install_and_links.sql +151 -0
  42. package/dist/drizzle/0233_workflow_memory_stage_tokens.sql +20 -0
  43. package/dist/drizzle/0234_memory_claim_lifecycle.sql +160 -0
  44. package/dist/drizzle/0235_workflow_token_execution_claim.sql +34 -0
  45. package/dist/drizzle/0236_memory_retraction_fencing.sql +50 -0
  46. package/dist/drizzle/0237_memory_claims_active_value_uidx.sql +172 -0
  47. package/dist/drizzle/0238_memory_erase_generation_fence.sql +97 -0
  48. package/dist/drizzle/0239_canonical_entity_identity.sql +257 -0
  49. package/dist/drizzle/0240_memory_personal_scope.sql +45 -0
  50. package/dist/drizzle/0241_knowledge_base_document_manifest.sql +67 -0
  51. package/dist/drizzle/0242_search_queries.sql +28 -0
  52. package/dist/drizzle/0243_section_sources_thread_refs.sql +12 -0
  53. package/dist/drizzle/0244_system_automations_and_memory_stage_overrides.sql +45 -0
  54. package/dist/drizzle/0245_wiki_page_render.sql +21 -0
  55. package/dist/drizzle/0246_automation_workflow_ownership.sql +30 -0
  56. package/dist/drizzle/0247_capability_runtime.sql +402 -0
  57. package/dist/drizzle/0248_capability_private_interpreter.sql +13 -0
  58. package/dist/drizzle/0249_capability_headless_execution_evidence.sql +40 -0
  59. package/dist/drizzle/0250_kg_schema_extraction.sql +576 -0
  60. package/dist/drizzle/0251_agents_json_render_ui.sql +25 -0
  61. package/dist/drizzle/0251_brain_stragglers.sql +263 -0
  62. package/dist/drizzle/0252_drop_kg_and_straggler_compat_views.sql +100 -0
  63. package/dist/drizzle/0253_drop_brain_substrate.sql +93 -0
  64. package/dist/drizzle/0254_capability_autonomous_admission.sql +29 -0
  65. package/dist/drizzle/0255_capability_autonomous_approval_mode.sql +16 -0
  66. package/dist/drizzle/0256_agent_profile_manifest_authority.sql +18 -0
  67. package/dist/drizzle/0257_agents_runtime_allow_harness.sql +24 -0
  68. package/dist/drizzle/0257_capability_approvals.sql +53 -0
  69. package/dist/drizzle/0258_pending_tool_approvals.sql +84 -0
  70. package/dist/drizzle/0259_tenant_capability_registry_trust.sql +19 -0
  71. package/dist/drizzle/0260_scheduled_jobs_run_as_user.sql +20 -0
  72. package/dist/drizzle/0261_managed_harness_multiplayer.sql +313 -0
  73. package/dist/drizzle/0261_native_auth_control_plane.sql +328 -0
  74. package/dist/drizzle/0262_auth_subscription_tickets.sql +59 -0
  75. package/dist/drizzle/0262_parallel_harness_threads.sql +9 -0
  76. package/dist/drizzle/0263_agentcore_runtime_identifier.sql +69 -0
  77. package/dist/drizzle/0263_drop_workos_auth_runtime.sql +236 -0
  78. package/dist/drizzle/0264_auth_enrollment_attempt_limits.sql +15 -0
  79. package/dist/drizzle/0264_harness_tool_execution_ledger.sql +113 -0
  80. package/dist/drizzle/0265_eval_runs_requester_identity.sql +28 -0
  81. package/dist/drizzle/0265_require_fully_valid_auth_publication.sql +27 -0
  82. package/dist/drizzle/0266_auth_identity_recovery_grants.sql +9 -0
  83. package/dist/drizzle/0266_eval_profiles_runtime_type.sql +12 -0
  84. package/dist/drizzle/0267_ontology_candidate_rejections.sql +53 -0
  85. package/dist/drizzle/0268_identity_crosswalk_routing.sql +182 -0
  86. package/dist/drizzle/0269_ontology_system_map.sql +31 -0
  87. package/dist/event-streams-7JNLJEEL.js +894 -0
  88. package/dist/event-streams-KDF5RWR6.js +1515 -0
  89. package/dist/loadSso-DJVB7BXE.js +576 -0
  90. package/dist/loadSso-JAMNA3WY.js +583 -0
  91. package/dist/plugins/catalog/package.json +1 -2
  92. package/dist/plugins/catalog/src/__tests__/build-catalog.test.ts +0 -1
  93. package/dist/plugins/catalog/src/__tests__/contracts.test.ts +0 -69
  94. package/dist/plugins/catalog/src/__tests__/plugin-package.test.ts +0 -2
  95. package/dist/plugins/catalog/src/__tests__/plugin-registry.test.ts +0 -5
  96. package/dist/plugins/catalog/src/contracts.ts +2 -179
  97. package/dist/plugins/catalog/src/registry/generated-first-party.ts +0 -6
  98. package/dist/signin-UGDWTGS4.js +821 -0
  99. package/dist/sso-oidc-Q4VXANXY.js +837 -0
  100. package/dist/sts-CWLSE3GH.js +1241 -0
  101. package/dist/sts-DNZLNVOV.js +1245 -0
  102. package/dist/terraform/examples/greenfield/main.tf +247 -50
  103. package/dist/terraform/examples/greenfield/terraform.tfvars.example +15 -1
  104. package/dist/terraform/modules/app/agentcore-admin/main.tf +341 -0
  105. package/dist/terraform/modules/app/agentcore-admin/outputs.tf +20 -0
  106. package/dist/terraform/modules/app/agentcore-code-interpreter/main.tf +61 -0
  107. package/dist/terraform/modules/app/agentcore-gateway/main.tf +305 -0
  108. package/dist/terraform/modules/app/agentcore-gateway/outputs.tf +44 -0
  109. package/dist/terraform/modules/app/agentcore-gateway/scripts/delete_gateway.sh +93 -0
  110. package/dist/terraform/modules/app/agentcore-gateway/scripts/read_gateway.sh +33 -0
  111. package/dist/terraform/modules/app/agentcore-gateway/scripts/reconcile_gateway.sh +543 -0
  112. package/dist/terraform/modules/app/agentcore-gateway/variables.tf +70 -0
  113. package/dist/terraform/modules/app/agentcore-harness/main.tf +330 -0
  114. package/dist/terraform/modules/app/agentcore-harness/outputs.tf +72 -0
  115. package/dist/terraform/modules/app/agentcore-harness/scripts/delete_harness.sh +5 -0
  116. package/dist/terraform/modules/app/agentcore-harness/scripts/harness-lifecycle.mjs +430 -0
  117. package/dist/terraform/modules/app/agentcore-harness/scripts/harness-readback.mjs +180 -0
  118. package/dist/terraform/modules/app/agentcore-harness/scripts/harness-tool-contract.mjs +193 -0
  119. package/dist/terraform/modules/app/agentcore-harness/scripts/prune_harness.sh +5 -0
  120. package/dist/terraform/modules/app/agentcore-harness/scripts/read_harness.sh +5 -0
  121. package/dist/terraform/modules/app/agentcore-harness/scripts/reconcile_harness.sh +5 -0
  122. package/dist/terraform/modules/app/agentcore-harness/variables.tf +96 -0
  123. package/dist/terraform/modules/app/agentcore-identity/main.tf +173 -0
  124. package/dist/terraform/modules/app/agentcore-identity/outputs.tf +51 -0
  125. package/dist/terraform/modules/app/agentcore-identity/scripts/bootstrap_twenty_oauth_client.sh +122 -0
  126. package/dist/terraform/modules/app/agentcore-identity/scripts/delete_identity.sh +25 -0
  127. package/dist/terraform/modules/app/agentcore-identity/scripts/delete_twenty_identity.sh +16 -0
  128. package/dist/terraform/modules/app/agentcore-identity/scripts/read_identity.sh +40 -0
  129. package/dist/terraform/modules/app/agentcore-identity/scripts/reconcile_identity.sh +73 -0
  130. package/dist/terraform/modules/app/agentcore-identity/scripts/reconcile_twenty_identity.sh +74 -0
  131. package/dist/terraform/modules/app/agentcore-identity/scripts/reconcile_twenty_provider.mjs +61 -0
  132. package/dist/terraform/modules/app/agentcore-identity/variables.tf +54 -0
  133. package/dist/terraform/modules/app/agentcore-pi/main.tf +7 -0
  134. package/dist/terraform/modules/app/appsync-subscriptions/main.tf +127 -21
  135. package/dist/terraform/modules/app/appsync-subscriptions/outputs.tf +0 -6
  136. package/dist/terraform/modules/app/appsync-subscriptions/variables.tf +5 -0
  137. package/dist/terraform/modules/app/capability-broker/main.tf +393 -0
  138. package/dist/terraform/modules/app/capability-broker/outputs.tf +54 -0
  139. package/dist/terraform/modules/app/capability-broker/variables.tf +98 -0
  140. package/dist/terraform/modules/app/deployment-control-plane/runner.py +1011 -31
  141. package/dist/terraform/modules/app/deployment-control-plane/test_runner_bundle.py +655 -2
  142. package/dist/terraform/modules/app/lambda-api/agentcore-harness-alarms.tf +88 -0
  143. package/dist/terraform/modules/app/lambda-api/agentcore-identity-alarms.tf +91 -0
  144. package/dist/terraform/modules/app/lambda-api/capability-search-alarms.tf +60 -0
  145. package/dist/terraform/modules/app/lambda-api/handlers.tf +606 -37
  146. package/dist/terraform/modules/app/lambda-api/iam-grouped.tf +192 -21
  147. package/dist/terraform/modules/app/lambda-api/main.tf +35 -0
  148. package/dist/terraform/modules/app/lambda-api/mcp-oauth.tf +181 -4
  149. package/dist/terraform/modules/app/lambda-api/memory-alarms.tf +92 -0
  150. package/dist/terraform/modules/app/lambda-api/msteams-app-secrets.tf +46 -0
  151. package/dist/terraform/modules/app/lambda-api/outputs.tf +35 -0
  152. package/dist/terraform/modules/app/lambda-api/runtime-config.tf +3 -21
  153. package/dist/terraform/modules/app/lambda-api/variables.tf +188 -3
  154. package/dist/terraform/modules/app/workflow-interpreter-stepfunctions/alarms.tf +47 -0
  155. package/dist/terraform/modules/app/workflow-interpreter-stepfunctions/main.tf +43 -2
  156. package/dist/terraform/modules/foundation/cognito/main.tf +319 -73
  157. package/dist/terraform/modules/foundation/cognito/outputs.tf +42 -0
  158. package/dist/terraform/modules/foundation/cognito/variables.tf +124 -2
  159. package/dist/terraform/modules/foundation/vpc/outputs.tf +5 -0
  160. package/dist/terraform/modules/thinkwork/main.tf +396 -26
  161. package/dist/terraform/modules/thinkwork/outputs.tf +188 -5
  162. package/dist/terraform/modules/thinkwork/variables.tf +154 -5
  163. package/dist/terraform/schema.graphql +69 -36
  164. package/dist/workspace-defaults/files/TOOLS.md +39 -0
  165. package/dist/workspace-defaults/files/USER.md +1 -0
  166. package/dist/workspace-defaults/files/agents/analyst/INSTRUCTIONS.md +18 -0
  167. package/dist/workspace-defaults/files/agents/coding/INSTRUCTIONS.md +11 -0
  168. package/dist/workspace-defaults/files/agents/research/INSTRUCTIONS.md +12 -0
  169. package/dist/workspace-defaults/files/agents/reviewer/INSTRUCTIONS.md +15 -0
  170. package/dist/workspace-defaults/files/skills/document-composer/SKILL.md +6 -2
  171. package/package.json +6 -3
  172. package/dist/plugins/workos-auth/package.json +0 -22
  173. package/dist/plugins/workos-auth/src/index.ts +0 -30
  174. package/dist/plugins/workos-auth/src/manifest.ts +0 -40
  175. package/dist/plugins/workos-auth/src/provider-contract.ts +0 -39
  176. package/dist/plugins/workos-auth/test/manifest.test.ts +0 -94
  177. package/dist/plugins/workos-auth/tsconfig.json +0 -8
  178. /package/dist/workspace-defaults/files/{AGENTS.md → INSTRUCTIONS.md} +0 -0
@@ -7,8 +7,10 @@ import re
7
7
  import secrets
8
8
  import subprocess
9
9
  import tarfile
10
+ import urllib.error
10
11
  import urllib.parse
11
12
  import urllib.request
13
+ import uuid
12
14
  from datetime import UTC, datetime
13
15
  from pathlib import Path
14
16
  from tempfile import TemporaryDirectory
@@ -42,6 +44,8 @@ RELEASE_MANIFEST_TRUST_POLICIES = {
42
44
  POST_SEED_MIGRATIONS = [
43
45
  "0155_tenant_model_catalog.sql",
44
46
  ]
47
+ AUTH_RETIREMENT_MIGRATION_MARKER = "-- deployment-phase: auth-retired"
48
+ PLATFORM_MIGRATION_LEDGER_CUTOFF = 260
45
49
  MIGRATION_MARKER_KINDS = [
46
50
  ("-- creates-column:", "column"),
47
51
  ("-- creates-constraint:", "constraint"),
@@ -506,6 +510,7 @@ def redacted_tfvars(vars_json):
506
510
  "api_auth_secret",
507
511
  "db_password",
508
512
  "google_oauth_client_secret",
513
+ "microsoft_oauth_client_secret",
509
514
  ]:
510
515
  if key in redacted:
511
516
  redacted[key] = "[redacted]"
@@ -1951,19 +1956,13 @@ def managed_app_terraform_overrides(payload, stage, account_id, current_outputs,
1951
1956
  # plans a destroy of a cert still attached to the n8n ALB (observed:
1952
1957
  # McPherson update to v0.1.0-canary.314). When state shows the root
1953
1958
  # managed cert, reproduce the install-time inputs instead.
1954
- managed_n8n_cert = state_root_resource_attributes(
1955
- current_state, "aws_acm_certificate", "n8n"
1956
- )
1959
+ managed_n8n_cert = state_root_resource_attributes(current_state, "aws_acm_certificate", "n8n")
1957
1960
  if managed_n8n_cert:
1958
1961
  overrides["n8n_domain"] = (
1959
- managed_n8n_cert.get("domain_name")
1960
- or url_hostname(overrides["n8n_public_url"])
1961
- or ""
1962
+ managed_n8n_cert.get("domain_name") or url_hostname(overrides["n8n_public_url"]) or ""
1962
1963
  )
1963
1964
  overrides["n8n_certificate_arn"] = ""
1964
- foundation_n8n_dns_name = overrides["n8n_domain"] or url_hostname(
1965
- overrides["n8n_public_url"]
1966
- )
1965
+ foundation_n8n_dns_name = overrides["n8n_domain"] or url_hostname(overrides["n8n_public_url"])
1967
1966
  if overrides["n8n_provisioned"] and foundation_n8n_dns_name:
1968
1967
  # State-derived zone lookup goes blank once the cloudflare_record
1969
1968
  # resources are absent (e.g. a prior partial destroy) — fall back
@@ -2075,6 +2074,37 @@ def release_runtime_image(name):
2075
2074
  return ""
2076
2075
 
2077
2076
 
2077
+ def resolve_agentcore_pi_source_image_uri(payload):
2078
+ operation = payload.get("operation") if isinstance(payload.get("operation"), dict) else {}
2079
+ action = str(
2080
+ safe_get(operation, "action", default=safe_get(payload, "action", "phase", default=""))
2081
+ ).lower()
2082
+ kind = str(safe_get(operation, "kind", default="")).lower()
2083
+ is_customer_update = (
2084
+ action == "update" or kind == "foundation"
2085
+ ) and kind != "identity_provider"
2086
+ explicit = safe_get(payload, "agentcorePiSourceImageUri", default="")
2087
+ if explicit:
2088
+ if is_customer_update:
2089
+ account_id = safe_get(payload, "awsAccountId", default="")
2090
+ region = safe_get(payload, "awsRegion", default=os.environ.get("AWS_REGION", ""))
2091
+ customer_registry = f"{account_id}.dkr.ecr.{region}.amazonaws.com/"
2092
+ if not account_id or not region or not explicit.startswith(customer_registry):
2093
+ raise RuntimeError(
2094
+ "Customer foundation updates require agentcorePiSourceImageUri "
2095
+ "to reference the customer-owned ECR registry."
2096
+ )
2097
+ return explicit
2098
+
2099
+ if is_customer_update:
2100
+ raise RuntimeError(
2101
+ "Customer foundation updates require an explicit customer-ECR "
2102
+ "agentcorePiSourceImageUri; refusing to fall back to the release registry."
2103
+ )
2104
+
2105
+ return release_runtime_image("agentcore-pi-amd64")
2106
+
2107
+
2078
2108
  def release_git_sha():
2079
2109
  if not MANIFEST.exists():
2080
2110
  return ""
@@ -2269,9 +2299,24 @@ def ensure_compliance_roles(database_url, outputs, vars_json):
2269
2299
  )
2270
2300
 
2271
2301
 
2272
- def migration_files():
2302
+ def migration_files(vars_json=None):
2273
2303
  migrations = SOURCE / "packages/database-pg/drizzle"
2274
- return sorted(path for path in migrations.glob("*.sql") if "rollback" not in path.name)
2304
+ allow_auth_retirement = bool(
2305
+ isinstance(vars_json, dict)
2306
+ and vars_json.get("auth_retirement_phase") == "retired"
2307
+ and vars_json.get("finalize_auth_retirement") is True
2308
+ )
2309
+ paths = []
2310
+ for path in migrations.glob("*.sql"):
2311
+ if "rollback" in path.name:
2312
+ continue
2313
+ if (
2314
+ AUTH_RETIREMENT_MIGRATION_MARKER in path.read_text(encoding="utf-8")
2315
+ and not allow_auth_retirement
2316
+ ):
2317
+ continue
2318
+ paths.append(path)
2319
+ return sorted(paths)
2275
2320
 
2276
2321
 
2277
2322
  def apply_migration_file(database_url, outputs, vars_json, path):
@@ -2305,7 +2350,7 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto;
2305
2350
  CREATE EXTENSION IF NOT EXISTS vector;
2306
2351
  """,
2307
2352
  )
2308
- for path in migration_files():
2353
+ for path in migration_files(vars_json):
2309
2354
  apply_migration_file(database_url, outputs, vars_json, path)
2310
2355
 
2311
2356
 
@@ -2425,7 +2470,7 @@ def platform_migration_object_present(database_url, kind, name):
2425
2470
  return None
2426
2471
 
2427
2472
 
2428
- def backfill_platform_migration_ledger(database_url):
2473
+ def backfill_platform_migration_ledger(database_url, vars_json):
2429
2474
  """One-time ledger bootstrap for environments installed before the ledger
2430
2475
  existed. Every file shipping with this release is recorded as assumed
2431
2476
  applied — auto-re-running old files is unsafe (markers can name objects
@@ -2434,11 +2479,29 @@ def backfill_platform_migration_ledger(database_url):
2434
2479
  transition apply through the exact pending path."""
2435
2480
  verified = []
2436
2481
  assumed = []
2437
- for path in migration_files():
2482
+ for path in migration_files(vars_json):
2483
+ match = re.match(r"^(\d{4})_", path.name)
2484
+ migration_number = int(match.group(1)) if match else None
2438
2485
  verdicts = [
2439
2486
  platform_migration_object_present(database_url, kind, name)
2440
2487
  for kind, name in declared_migration_objects(path)
2441
2488
  ]
2489
+ # The ledger ships with the native-auth release. Files through 0260 may
2490
+ # already have been applied by the old deploy paths and cannot safely be
2491
+ # replayed. Files introduced with this release must never be silently
2492
+ # assumed: record them only when every declared object is present;
2493
+ # otherwise leave them pending so apply_pending_platform_migrations runs
2494
+ # them before the app is published.
2495
+ if (
2496
+ migration_number is not None
2497
+ and migration_number > PLATFORM_MIGRATION_LEDGER_CUTOFF
2498
+ and (not verdicts or any(verdict is not True for verdict in verdicts))
2499
+ ):
2500
+ print(
2501
+ f"[migrations] transition: {path.name} is newer than the pre-ledger cutoff "
2502
+ "and remains pending"
2503
+ )
2504
+ continue
2442
2505
  if any(verdict is False for verdict in verdicts):
2443
2506
  print(
2444
2507
  f"[migrations] transition WARNING: {path.name} declares objects missing from "
@@ -2455,7 +2518,7 @@ def backfill_platform_migration_ledger(database_url):
2455
2518
 
2456
2519
  def apply_pending_platform_migrations(database_url, outputs, vars_json):
2457
2520
  recorded = recorded_platform_migrations(database_url)
2458
- for path in migration_files():
2521
+ for path in migration_files(vars_json):
2459
2522
  if path.name in recorded:
2460
2523
  continue
2461
2524
  print(f"[migrations] applying {path.name}")
@@ -2937,10 +3000,10 @@ def push_database_schema(outputs_path, vars_json):
2937
3000
  ensure_migration_ledger(database_url)
2938
3001
  if fresh:
2939
3002
  record_platform_migrations(
2940
- database_url, [path.name for path in migration_files()], "greenfield"
3003
+ database_url, [path.name for path in migration_files(vars_json)], "greenfield"
2941
3004
  )
2942
3005
  elif not ledger_present:
2943
- backfill_platform_migration_ledger(database_url)
3006
+ backfill_platform_migration_ledger(database_url, vars_json)
2944
3007
  apply_pending_platform_migrations(database_url, outputs, vars_json)
2945
3008
  seed_platform_bootstrap_defaults(database_url)
2946
3009
  migrations = SOURCE / "packages/database-pg/drizzle"
@@ -2951,6 +3014,55 @@ def push_database_schema(outputs_path, vars_json):
2951
3014
  apply_migration_file(database_url, outputs, vars_json, path)
2952
3015
 
2953
3016
 
3017
+ def prepare_additive_schema_before_app(payload, vars_json):
3018
+ """Materialize foundation/data dependencies and apply additive schema before
3019
+ the full app plan can publish Lambdas that consume it. Retirement stays
3020
+ disabled here and, when explicitly requested, runs only after the app
3021
+ retirement phase has deployed."""
3022
+ action = str(payload.get("action") or "").lower()
3023
+ if (
3024
+ action not in {"deploy", "update"}
3025
+ or is_managed_app_operation(payload)
3026
+ or identity_provider_operation(payload) is not None
3027
+ ):
3028
+ return None
3029
+ target = "module.thinkwork.module.database"
3030
+ plan = subprocess.run(
3031
+ [
3032
+ "terraform",
3033
+ "plan",
3034
+ f"-target={target}",
3035
+ "-out=tfplan-data",
3036
+ "-no-color",
3037
+ ],
3038
+ cwd=TF,
3039
+ text=True,
3040
+ )
3041
+ if plan.returncode != 0:
3042
+ return plan
3043
+ applied = subprocess.run(
3044
+ ["terraform", "apply", "-auto-approve", "-no-color", "tfplan-data"],
3045
+ cwd=TF,
3046
+ text=True,
3047
+ )
3048
+ if applied.returncode != 0:
3049
+ return applied
3050
+ outputs_path = TF / "pre-app-outputs.json"
3051
+ outputs_path.write_text(
3052
+ output(["terraform", "output", "-json"], cwd=TF),
3053
+ encoding="utf-8",
3054
+ )
3055
+ additive_vars = dict(vars_json)
3056
+ additive_vars["finalize_auth_retirement"] = False
3057
+ push_database_schema(outputs_path, additive_vars)
3058
+ TERRAFORM_EVIDENCE["schemaOrdering"] = {
3059
+ "status": "succeeded",
3060
+ "target": target,
3061
+ "phase": "foundation-data-additive-before-app",
3062
+ }
3063
+ return applied
3064
+
3065
+
2954
3066
  def write_runner_files(payload, runner_secrets):
2955
3067
  preserved_config = payload.get("preservedConfig")
2956
3068
  if not isinstance(preserved_config, dict):
@@ -3036,12 +3148,70 @@ def write_runner_files(payload, runner_secrets):
3036
3148
  if not api_auth_secret:
3037
3149
  api_auth_secret = secrets.token_urlsafe(48)
3038
3150
 
3151
+ auth_state = auth_reconciliation_state(stage)
3152
+ tenant_auth_metadata = identity_provider_desired_connections(
3153
+ payload,
3154
+ stage=stage,
3155
+ account_id=account_id,
3156
+ region=region,
3157
+ previous_state=auth_state,
3158
+ current_outputs=current_outputs,
3159
+ )
3160
+ prior_phase_output = current_outputs.get("auth_retirement_phase")
3161
+ prior_phase = (
3162
+ prior_phase_output.get("value")
3163
+ if isinstance(prior_phase_output, dict)
3164
+ else None
3165
+ )
3166
+ if prior_phase not in {"coexistence", "cutover", "retired"}:
3167
+ # An existing pre-native-auth stack must enter through coexistence.
3168
+ # A truly empty state is greenfield and has no WorkOS runtime to keep.
3169
+ prior_phase = "coexistence" if current_state else "retired"
3170
+ auth_retirement_phase = safe_get(
3171
+ runner_secrets,
3172
+ "authRetirementPhase",
3173
+ default=safe_get(
3174
+ reviewed_payload,
3175
+ "authRetirementPhase",
3176
+ default=prior_phase,
3177
+ ),
3178
+ )
3179
+ if auth_retirement_phase not in {"coexistence", "cutover", "retired"}:
3180
+ raise RuntimeError("authRetirementPhase must be coexistence, cutover, or retired")
3181
+ auth_migration_recovery_deadline = safe_get(
3182
+ runner_secrets,
3183
+ "authMigrationRecoveryDeadline",
3184
+ default=safe_get(
3185
+ reviewed_payload,
3186
+ "authMigrationRecoveryDeadline",
3187
+ default=os.environ.get("AUTH_MIGRATION_RECOVERY_DEADLINE", ""),
3188
+ ),
3189
+ ).strip()
3190
+ if auth_migration_recovery_deadline:
3191
+ try:
3192
+ parsed_recovery_deadline = datetime.fromisoformat(
3193
+ auth_migration_recovery_deadline.replace("Z", "+00:00")
3194
+ )
3195
+ if parsed_recovery_deadline.tzinfo is None:
3196
+ raise ValueError("timezone required")
3197
+ except ValueError as exc:
3198
+ raise RuntimeError(
3199
+ "authMigrationRecoveryDeadline must be an RFC3339 timestamp"
3200
+ ) from exc
3201
+ if auth_retirement_phase == "coexistence" and not auth_migration_recovery_deadline:
3202
+ raise RuntimeError(
3203
+ "authMigrationRecoveryDeadline is required while authRetirementPhase is coexistence"
3204
+ )
3205
+ finalize_auth_retirement = payload.get("finalizeAuthRetirement") is True
3039
3206
  vars_json = {
3040
3207
  "stage": stage,
3041
3208
  "region": region,
3042
3209
  "account_id": account_id,
3043
3210
  "db_password": db_password,
3044
3211
  "api_auth_secret": api_auth_secret,
3212
+ "auth_retirement_phase": auth_retirement_phase,
3213
+ "auth_migration_recovery_deadline": auth_migration_recovery_deadline,
3214
+ "finalize_auth_retirement": finalize_auth_retirement,
3045
3215
  "database_engine": safe_get(
3046
3216
  reviewed_payload,
3047
3217
  "databaseEngine",
@@ -3053,6 +3223,16 @@ def write_runner_files(payload, runner_secrets):
3053
3223
  "enableHindsight",
3054
3224
  default=False,
3055
3225
  ),
3226
+ # Analyst data-path Lambda VPC egress (stable NAT EIP for external
3227
+ # database IP allowlists). Per-environment infra posture, so the
3228
+ # durable home is the runner-secrets document; a payload boolean
3229
+ # can override per-deploy.
3230
+ "analyst_lambda_vpc_egress": safe_get_bool(
3231
+ runner_secrets,
3232
+ reviewed_payload,
3233
+ "analystLambdaVpcEgress",
3234
+ default=False,
3235
+ ),
3056
3236
  # THINK-220: dedicated Hindsight database on the stage cluster.
3057
3237
  # Empty keeps the legacy hindsight-schema layout; set (e.g.
3058
3238
  # "thinkwork_hindsight") points the Hindsight service and platform
@@ -3092,6 +3272,38 @@ def write_runner_files(payload, runner_secrets):
3092
3272
  default="",
3093
3273
  ),
3094
3274
  ),
3275
+ "microsoft_oauth_client_id": safe_get(
3276
+ runner_secrets,
3277
+ "microsoftOauthClientId",
3278
+ default=safe_get(
3279
+ reviewed_payload,
3280
+ "microsoftOauthClientId",
3281
+ default="",
3282
+ ),
3283
+ ),
3284
+ "microsoft_oauth_client_secret": safe_get(
3285
+ runner_secrets,
3286
+ "microsoftOauthClientSecret",
3287
+ default=safe_get(
3288
+ reviewed_payload,
3289
+ "microsoftOauthClientSecret",
3290
+ default="",
3291
+ ),
3292
+ ),
3293
+ "microsoft_oauth_tenant": safe_get(
3294
+ runner_secrets,
3295
+ "microsoftOauthTenant",
3296
+ default=safe_get(
3297
+ reviewed_payload,
3298
+ "microsoftOauthTenant",
3299
+ default="",
3300
+ ),
3301
+ ),
3302
+ # Secret-free desired metadata. The compact Terraform projection only
3303
+ # creates provider-specific public app clients; the runner reconciles
3304
+ # the secret-bearing Cognito IdP before Terraform plans those clients.
3305
+ "tenant_entra_connections": tenant_entra_terraform_projection(tenant_auth_metadata),
3306
+ "auth_tenant_connection_metadata": tenant_auth_metadata,
3095
3307
  "cognito_email_source_arn": safe_get(
3096
3308
  runner_secrets,
3097
3309
  "cognitoEmailSourceArn",
@@ -3161,11 +3373,7 @@ def write_runner_files(payload, runner_secrets):
3161
3373
  "deployment_evidence_bucket": os.environ.get("THINKWORK_EVIDENCE_BUCKET", ""),
3162
3374
  "deployment_terraform_module_source": terraform_module_source,
3163
3375
  "deployment_terraform_module_version": terraform_module_version,
3164
- "agentcore_pi_source_image_uri": safe_get(
3165
- payload,
3166
- "agentcorePiSourceImageUri",
3167
- default=release_runtime_image("agentcore-pi-amd64"),
3168
- ),
3376
+ "agentcore_pi_source_image_uri": resolve_agentcore_pi_source_image_uri(payload),
3169
3377
  }
3170
3378
  enforce_customer_domain_preservation(
3171
3379
  current_outputs,
@@ -3254,6 +3462,16 @@ variable "api_auth_secret" {{
3254
3462
  sensitive = true
3255
3463
  }}
3256
3464
 
3465
+ variable "auth_retirement_phase" {{
3466
+ type = string
3467
+ default = "coexistence"
3468
+ }}
3469
+
3470
+ variable "auth_migration_recovery_deadline" {{
3471
+ type = string
3472
+ default = ""
3473
+ }}
3474
+
3257
3475
  variable "database_engine" {{
3258
3476
  type = string
3259
3477
  }}
@@ -3262,6 +3480,11 @@ variable "enable_hindsight" {{
3262
3480
  type = bool
3263
3481
  }}
3264
3482
 
3483
+ variable "analyst_lambda_vpc_egress" {{
3484
+ type = bool
3485
+ default = false
3486
+ }}
3487
+
3265
3488
  variable "hindsight_database_name" {{
3266
3489
  type = string
3267
3490
  default = ""
@@ -3312,6 +3535,35 @@ variable "google_oauth_client_secret" {{
3312
3535
  sensitive = true
3313
3536
  }}
3314
3537
 
3538
+ variable "microsoft_oauth_client_id" {{
3539
+ type = string
3540
+ }}
3541
+
3542
+ variable "microsoft_oauth_client_secret" {{
3543
+ type = string
3544
+ sensitive = true
3545
+ }}
3546
+
3547
+ variable "microsoft_oauth_tenant" {{
3548
+ type = string
3549
+ default = ""
3550
+ }}
3551
+
3552
+ variable "tenant_entra_connections" {{
3553
+ type = list(object({{
3554
+ connection_key = string
3555
+ tenant_id = string
3556
+ provider_name = string
3557
+ display_name = string
3558
+ }}))
3559
+ default = []
3560
+ }}
3561
+
3562
+ variable "auth_tenant_connection_metadata" {{
3563
+ type = list(any)
3564
+ default = []
3565
+ }}
3566
+
3315
3567
  variable "lambda_artifact_bucket" {{
3316
3568
  type = string
3317
3569
  }}
@@ -3632,10 +3884,16 @@ module "thinkwork" {{
3632
3884
 
3633
3885
  db_password = var.db_password
3634
3886
  api_auth_secret = var.api_auth_secret
3887
+ auth_retirement_phase = var.auth_retirement_phase
3888
+ auth_migration_recovery_deadline = var.auth_migration_recovery_deadline
3635
3889
  database_engine = var.database_engine
3636
3890
 
3637
3891
  google_oauth_client_id = var.google_oauth_client_id
3638
3892
  google_oauth_client_secret = var.google_oauth_client_secret
3893
+ microsoft_oauth_client_id = var.microsoft_oauth_client_id
3894
+ microsoft_oauth_client_secret = var.microsoft_oauth_client_secret
3895
+ microsoft_oauth_tenant = var.microsoft_oauth_tenant
3896
+ tenant_entra_connections = var.tenant_entra_connections
3639
3897
  platform_operator_emails = var.platform_operator_emails
3640
3898
 
3641
3899
  cognito_email_source_arn = var.cognito_email_source_arn
@@ -3655,6 +3913,7 @@ module "thinkwork" {{
3655
3913
  agentcore_pi_source_image_uri = var.agentcore_pi_source_image_uri
3656
3914
 
3657
3915
  enable_hindsight = var.enable_hindsight
3916
+ analyst_lambda_vpc_egress = var.analyst_lambda_vpc_egress
3658
3917
  hindsight_database_name = var.hindsight_database_name
3659
3918
  enable_workspace_orchestration = true
3660
3919
 
@@ -3737,10 +3996,6 @@ output "app_distribution_id" {{ value = module.thinkwork.app_distribution_id }}
3737
3996
  output "api_endpoint" {{ value = module.thinkwork.api_endpoint }}
3738
3997
  output "appsync_api_url" {{ value = module.thinkwork.appsync_api_url }}
3739
3998
  output "appsync_realtime_url" {{ value = module.thinkwork.appsync_realtime_url }}
3740
- output "appsync_api_key" {{
3741
- value = module.thinkwork.appsync_api_key
3742
- sensitive = true
3743
- }}
3744
3999
  output "auth_domain" {{ value = module.thinkwork.auth_domain }}
3745
4000
  output "customer_domain" {{ value = module.thinkwork.customer_domain }}
3746
4001
  output "customer_domain_name_servers" {{ value = module.thinkwork.customer_domain_name_servers }}
@@ -3749,6 +4004,9 @@ output "db_secret_arn" {{ value = module.thinkwork.db_secret_arn }}
3749
4004
  output "database_name" {{ value = module.thinkwork.database_name }}
3750
4005
  output "user_pool_id" {{ value = module.thinkwork.user_pool_id }}
3751
4006
  output "admin_client_id" {{ value = module.thinkwork.admin_client_id }}
4007
+ output "web_local_client_id" {{ value = module.thinkwork.web_local_client_id }}
4008
+ output "auth_route_clients" {{ value = module.thinkwork.auth_route_clients }}
4009
+ output "auth_retirement_phase" {{ value = module.thinkwork.auth_retirement_phase }}
3752
4010
  output "docs_bucket_name" {{ value = module.thinkwork.docs_bucket_name }}
3753
4011
  output "docs_distribution_id" {{ value = module.thinkwork.docs_distribution_id }}
3754
4012
  output "docs_distribution_domain" {{ value = module.thinkwork.docs_distribution_domain }}
@@ -3972,7 +4230,6 @@ def write_outputs_to_ssm(outputs_path, vars_json):
3972
4230
  "profile/graphql-http-url": None,
3973
4231
  "profile/appsync-url": "appsync_api_url",
3974
4232
  "profile/appsync-realtime-url": "appsync_realtime_url",
3975
- "profile/appsync-api-key": "appsync_api_key",
3976
4233
  "profile/cognito-domain": None,
3977
4234
  "profile/cognito-user-pool-id": "user_pool_id",
3978
4235
  "profile/cognito-client-id": "admin_client_id",
@@ -4113,7 +4370,6 @@ def runtime_profile(outputs, vars_json):
4113
4370
  "graphqlHttpUrl": graphql_http_url,
4114
4371
  "appsyncUrl": output_value("appsync_api_url"),
4115
4372
  "appsyncRealtimeUrl": output_value("appsync_realtime_url"),
4116
- "appsyncApiKey": output_value("appsync_api_key"),
4117
4373
  "cognitoDomain": cognito_domain,
4118
4374
  "cognitoUserPoolId": output_value("user_pool_id"),
4119
4375
  "cognitoClientId": output_value("admin_client_id"),
@@ -4144,7 +4400,6 @@ def runtime_profile(outputs, vars_json):
4144
4400
  "VITE_GRAPHQL_HTTP_URL": profile["graphqlHttpUrl"],
4145
4401
  "VITE_GRAPHQL_URL": profile["appsyncUrl"],
4146
4402
  "VITE_GRAPHQL_WS_URL": profile["appsyncRealtimeUrl"],
4147
- "VITE_GRAPHQL_API_KEY": profile["appsyncApiKey"],
4148
4403
  "VITE_COGNITO_DOMAIN": profile["cognitoDomain"],
4149
4404
  "VITE_COGNITO_USER_POOL_ID": profile["cognitoUserPoolId"],
4150
4405
  "VITE_COGNITO_CLIENT_ID": profile["cognitoClientId"],
@@ -4170,6 +4425,700 @@ def runtime_profile(outputs, vars_json):
4170
4425
  return profile, web_env + "\n"
4171
4426
 
4172
4427
 
4428
+ IDENTITY_PROVIDER_ACTIONS = {"create", "validate", "rotate", "disable"}
4429
+ ENTRA_TENANT_GUID_RE = re.compile(
4430
+ r"^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
4431
+ re.IGNORECASE,
4432
+ )
4433
+ ENTRA_PROVIDER_NAME_RE = re.compile(r"^Entra_[a-f0-9]{16}_[a-f0-9]{8}$")
4434
+
4435
+
4436
+ def identity_provider_operation(payload):
4437
+ operation = payload.get("operation")
4438
+ if not isinstance(operation, dict) or operation.get("kind") != "identity_provider":
4439
+ return None
4440
+ return operation
4441
+
4442
+
4443
+ def _identity_string(value, label):
4444
+ if not isinstance(value, str) or not value.strip():
4445
+ raise RuntimeError(f"Identity-provider operation requires {label}")
4446
+ return value.strip()
4447
+
4448
+
4449
+ def _current_output_value(current_outputs, key):
4450
+ value = current_outputs.get(key) if isinstance(current_outputs, dict) else None
4451
+ if isinstance(value, dict) and "value" in value:
4452
+ return value.get("value")
4453
+ return value
4454
+
4455
+
4456
+ def validate_identity_provider_secret_arn(secret_arn, stage, account_id, region):
4457
+ expected = f"arn:aws:secretsmanager:{region}:{account_id}:secret:thinkwork/{stage}/auth/entra/"
4458
+ if not isinstance(secret_arn, str) or not secret_arn.startswith(expected):
4459
+ raise RuntimeError(
4460
+ "Tenant Entra secret ARN must belong to this account, region, stage, "
4461
+ "and thinkwork/<stage>/auth/entra namespace"
4462
+ )
4463
+ return secret_arn
4464
+
4465
+
4466
+ def validate_identity_provider_connection(raw, stage, account_id, region, user_pool_id):
4467
+ if not isinstance(raw, dict):
4468
+ raise RuntimeError("Identity-provider operation requires safe connection metadata")
4469
+ tenant_id = _identity_string(raw.get("tenantId"), "connection.tenantId").lower()
4470
+ if not ENTRA_TENANT_GUID_RE.fullmatch(tenant_id):
4471
+ raise RuntimeError("Tenant Entra directory ID must be a GUID")
4472
+ provider_name = _identity_string(raw.get("providerName"), "connection.providerName")
4473
+ if not ENTRA_PROVIDER_NAME_RE.fullmatch(provider_name):
4474
+ raise RuntimeError("Tenant Entra provider name is not deterministic")
4475
+ connection_key = f"microsoft:tenant:{tenant_id}"
4476
+ if raw.get("connectionKey") != connection_key:
4477
+ raise RuntimeError("Tenant Entra connection key does not match its directory ID")
4478
+ client_id = _identity_string(raw.get("clientId"), "connection.clientId")
4479
+ display_name = _identity_string(raw.get("displayName"), "connection.displayName")
4480
+ secret_arn = validate_identity_provider_secret_arn(
4481
+ raw.get("clientSecretRef"), stage, account_id, region
4482
+ )
4483
+ client_secret_version_stage = raw.get("clientSecretVersionStage")
4484
+ if client_secret_version_stage is not None and client_secret_version_stage != "AWSPENDING":
4485
+ raise RuntimeError("Tenant Entra clientSecretVersionStage must be AWSPENDING when supplied")
4486
+ bindings = raw.get("tenantBindings")
4487
+ if not isinstance(bindings, list) or len(bindings) != 1:
4488
+ raise RuntimeError("Tenant Entra connection requires exactly one tenant binding")
4489
+ binding = bindings[0]
4490
+ if not isinstance(binding, dict):
4491
+ raise RuntimeError("Tenant Entra binding must be an object")
4492
+ thinkwork_tenant_id = _identity_string(binding.get("tenantId"), "tenant binding ID")
4493
+ try:
4494
+ uuid.UUID(thinkwork_tenant_id)
4495
+ except (ValueError, AttributeError) as error:
4496
+ raise RuntimeError("ThinkWork tenant binding ID must be a UUID") from error
4497
+ hostnames = binding.get("hostnames")
4498
+ if not isinstance(hostnames, list) or not hostnames:
4499
+ raise RuntimeError("Tenant Entra binding requires at least one hostname")
4500
+ normalized_hostnames = []
4501
+ for value in hostnames:
4502
+ hostname = _identity_string(value, "tenant binding hostname").lower().rstrip(".")
4503
+ if not re.fullmatch(r"[a-z0-9](?:[a-z0-9.-]{0,251}[a-z0-9])?", hostname):
4504
+ raise RuntimeError(f"Invalid tenant auth hostname: {hostname}")
4505
+ normalized_hostnames.append(hostname)
4506
+ issuer_url = f"https://login.microsoftonline.com/{tenant_id}/v2.0"
4507
+ if raw.get("issuerUrl") != issuer_url:
4508
+ raise RuntimeError("Tenant Entra issuer must use its tenant GUID v2.0 authority")
4509
+ return {
4510
+ "connectionKey": connection_key,
4511
+ "providerKey": "microsoft",
4512
+ "providerKind": "microsoft_tenant",
4513
+ "displayName": display_name,
4514
+ "lifecycleState": "native",
4515
+ "cognitoUserPoolId": _identity_string(user_pool_id, "Cognito user pool ID"),
4516
+ "cognitoIdentityProviderName": provider_name,
4517
+ "issuerUrl": issuer_url,
4518
+ "clientId": client_id,
4519
+ "clientSecretRef": secret_arn,
4520
+ **(
4521
+ {"clientSecretVersionStage": client_secret_version_stage}
4522
+ if client_secret_version_stage is not None
4523
+ else {}
4524
+ ),
4525
+ "authorizeScopes": "openid email profile",
4526
+ "tenantBindings": [
4527
+ {
4528
+ "tenantId": thinkwork_tenant_id,
4529
+ "label": _identity_string(binding.get("label"), "tenant binding label"),
4530
+ "hostnames": sorted(set(normalized_hostnames)),
4531
+ "status": "enabled",
4532
+ }
4533
+ ],
4534
+ # Kept in runner/SSM safe state for the Terraform projection only.
4535
+ "tenantDirectoryId": tenant_id,
4536
+ }
4537
+
4538
+
4539
+ def identity_provider_desired_connections(
4540
+ payload, *, stage, account_id, region, previous_state, current_outputs
4541
+ ):
4542
+ previous = previous_state.get("tenantConnections") or []
4543
+ if not isinstance(previous, list):
4544
+ raise RuntimeError("Auth reconciliation tenantConnections state must be an array")
4545
+ desired = [dict(value) for value in previous if isinstance(value, dict)]
4546
+ operation = identity_provider_operation(payload)
4547
+ if operation is None:
4548
+ return desired
4549
+
4550
+ action = str(operation.get("action") or "").lower()
4551
+ if action not in IDENTITY_PROVIDER_ACTIONS:
4552
+ raise RuntimeError(f"Unsupported identity-provider action: {action}")
4553
+ expected_previous = int(operation.get("expectedPreviousRevision", -1))
4554
+ revision = int(operation.get("revision", -1))
4555
+ current_revision = int(previous_state.get("desiredRevision") or 0)
4556
+ if expected_previous != current_revision or revision != current_revision + 1:
4557
+ raise RuntimeError(f"Identity-provider revision conflict: expected {current_revision + 1}")
4558
+
4559
+ user_pool_id = _current_output_value(current_outputs, "user_pool_id")
4560
+ connection = validate_identity_provider_connection(
4561
+ operation.get("connection"), stage, account_id, region, user_pool_id
4562
+ )
4563
+ client_secret_version_stage = connection.get("clientSecretVersionStage")
4564
+ if action == "rotate" and client_secret_version_stage != "AWSPENDING":
4565
+ raise RuntimeError("Tenant Entra rotation requires clientSecretVersionStage AWSPENDING")
4566
+ if action != "rotate" and client_secret_version_stage is not None:
4567
+ raise RuntimeError(
4568
+ "clientSecretVersionStage AWSPENDING is valid only for Tenant Entra rotation"
4569
+ )
4570
+ existing = next(
4571
+ (value for value in desired if value.get("connectionKey") == connection["connectionKey"]),
4572
+ None,
4573
+ )
4574
+ if action == "create" and existing and existing.get("lifecycleState") != "denied":
4575
+ raise RuntimeError("Tenant Entra connection already exists; use rotate or validate")
4576
+ if action in {"validate", "rotate", "disable"} and not existing:
4577
+ raise RuntimeError(f"Tenant Entra connection does not exist for {action}")
4578
+ if action in {"validate", "rotate", "disable"} and existing != connection:
4579
+ # Disable may change lifecycle; rotate may add only its runner-owned
4580
+ # pending-version label. Safe connection metadata must remain exact.
4581
+ comparable = dict(existing)
4582
+ comparable["lifecycleState"] = "native"
4583
+ submitted_comparable = dict(connection)
4584
+ comparable.pop("clientSecretVersionStage", None)
4585
+ submitted_comparable.pop("clientSecretVersionStage", None)
4586
+ if comparable != submitted_comparable:
4587
+ raise RuntimeError(
4588
+ "Identity-provider metadata differs from the active desired connection"
4589
+ )
4590
+
4591
+ desired = [
4592
+ value for value in desired if value.get("connectionKey") != connection["connectionKey"]
4593
+ ]
4594
+ if action == "disable":
4595
+ connection["lifecycleState"] = "denied"
4596
+ connection["tenantBindings"][0]["status"] = "disabled"
4597
+ desired.append(connection)
4598
+ return sorted(desired, key=lambda value: value["connectionKey"])
4599
+
4600
+
4601
+ def tenant_entra_terraform_projection(connections):
4602
+ projected = []
4603
+ for connection in connections:
4604
+ if connection.get("lifecycleState") != "native":
4605
+ continue
4606
+ projected.append(
4607
+ {
4608
+ "connection_key": connection["connectionKey"],
4609
+ "tenant_id": connection["tenantDirectoryId"],
4610
+ "provider_name": connection["cognitoIdentityProviderName"],
4611
+ "display_name": connection["displayName"],
4612
+ }
4613
+ )
4614
+ return projected
4615
+
4616
+
4617
+ def native_auth_reconciliation_payload(outputs, vars_json, previous_state=None):
4618
+ """Build the complete secret-free Cognito desired set from Terraform outputs."""
4619
+ previous_state = previous_state if isinstance(previous_state, dict) else {}
4620
+ route_manifest = outputs.get("auth_route_clients", {}).get("value") or {}
4621
+ if not isinstance(route_manifest, dict):
4622
+ raise RuntimeError("auth_route_clients Terraform output must be an object")
4623
+ user_pool_id = str(outputs.get("user_pool_id", {}).get("value") or "")
4624
+ if not user_pool_id:
4625
+ raise RuntimeError("Native auth reconciliation requires user_pool_id output")
4626
+
4627
+ routes = []
4628
+ provider_client_ids = {}
4629
+ for manifest_key, raw in sorted(route_manifest.items()):
4630
+ if not isinstance(raw, dict):
4631
+ raise RuntimeError(f"Invalid auth route manifest entry: {manifest_key}")
4632
+ provider_names = [str(value) for value in raw.get("provider_names") or []]
4633
+ client_id = str(raw.get("client_id") or "")
4634
+ for provider_name in provider_names:
4635
+ provider_client_ids.setdefault(provider_name, []).append(client_id)
4636
+ routes.append(
4637
+ {
4638
+ "routeKey": str(raw.get("route_key") or ""),
4639
+ "clientFamily": str(raw.get("client_family") or ""),
4640
+ "cognitoUserPoolId": user_pool_id,
4641
+ "cognitoAppClientId": client_id,
4642
+ "providerNames": provider_names,
4643
+ "explicitAuthFlows": [str(value) for value in raw.get("explicit_auth_flows") or []],
4644
+ "redirectUris": [str(value) for value in raw.get("callback_urls") or []],
4645
+ "logoutUris": [str(value) for value in raw.get("logout_urls") or []],
4646
+ "lifecycleState": str(raw.get("lifecycle_state") or "native"),
4647
+ }
4648
+ )
4649
+
4650
+ connections = [
4651
+ {
4652
+ "connectionKey": "local",
4653
+ "providerKey": "cognito",
4654
+ "providerKind": "local",
4655
+ "displayName": "Email and password",
4656
+ "lifecycleState": "native",
4657
+ "cognitoUserPoolId": user_pool_id,
4658
+ "cognitoIdentityProviderName": "COGNITO",
4659
+ "authorizeScopes": "openid email profile",
4660
+ "tenantBindings": [],
4661
+ }
4662
+ ]
4663
+ if provider_client_ids.get("Google"):
4664
+ connections.append(
4665
+ {
4666
+ "connectionKey": "google",
4667
+ "providerKey": "google",
4668
+ "providerKind": "google",
4669
+ "displayName": "Google",
4670
+ "lifecycleState": "native",
4671
+ "cognitoUserPoolId": user_pool_id,
4672
+ "cognitoIdentityProviderName": "Google",
4673
+ "authorizeScopes": "openid email profile",
4674
+ "tenantBindings": [],
4675
+ }
4676
+ )
4677
+ if provider_client_ids.get("MicrosoftOrganizations"):
4678
+ microsoft_tenant = str(vars_json.get("microsoft_oauth_tenant") or "").lower()
4679
+ if not microsoft_tenant:
4680
+ raise RuntimeError(
4681
+ "Microsoft native auth reconciliation requires microsoft_oauth_tenant"
4682
+ )
4683
+ connections.append(
4684
+ {
4685
+ "connectionKey": "microsoft:organizations",
4686
+ "providerKey": "microsoft",
4687
+ "providerKind": "microsoft_organizations",
4688
+ "displayName": "Microsoft",
4689
+ "lifecycleState": "native",
4690
+ "cognitoUserPoolId": user_pool_id,
4691
+ "cognitoIdentityProviderName": "MicrosoftOrganizations",
4692
+ "issuerUrl": (f"https://login.microsoftonline.com/{microsoft_tenant}/v2.0"),
4693
+ "authorizeScopes": "openid email profile",
4694
+ "tenantBindings": [],
4695
+ }
4696
+ )
4697
+
4698
+ # Identity-provider operations store the complete tenant connection set in
4699
+ # safe SSM state. Standard releases carry it forward; omission is not
4700
+ # interpreted as deletion.
4701
+ tenant_connections = vars_json.get("auth_tenant_connection_metadata")
4702
+ if tenant_connections is None:
4703
+ tenant_connections = previous_state.get("tenantConnections") or []
4704
+ for connection in tenant_connections:
4705
+ if isinstance(connection, dict):
4706
+ # tenantDirectoryId is runner-owned projection metadata, not part
4707
+ # of the public/API reconciliation contract.
4708
+ connections.append(
4709
+ {
4710
+ key: value
4711
+ for key, value in connection.items()
4712
+ if key not in {"tenantDirectoryId", "clientSecretVersionStage"}
4713
+ }
4714
+ )
4715
+
4716
+ desired = {
4717
+ "connections": sorted(connections, key=lambda item: item["connectionKey"]),
4718
+ "routeClients": sorted(
4719
+ routes,
4720
+ key=lambda item: f"{item['routeKey']}:{item['clientFamily']}",
4721
+ ),
4722
+ }
4723
+ desired_fingerprint = hashlib.sha256(canonical_json(desired).encode("utf-8")).hexdigest()
4724
+ if desired_fingerprint == previous_state.get("desiredFingerprint"):
4725
+ return None, previous_state
4726
+
4727
+ previous_revision = int(previous_state.get("revision") or 0)
4728
+ payload = {
4729
+ "stage": vars_json["stage"],
4730
+ "awsAccountId": vars_json["account_id"],
4731
+ "awsRegion": vars_json["region"],
4732
+ "revision": previous_revision + 1,
4733
+ "expectedPreviousRevision": previous_revision,
4734
+ "idempotencyKey": str(
4735
+ uuid.uuid5(
4736
+ uuid.NAMESPACE_URL,
4737
+ f"thinkwork-auth:{vars_json['stage']}:{previous_revision + 1}:{desired_fingerprint}",
4738
+ )
4739
+ ),
4740
+ "connections": desired["connections"],
4741
+ "routeClients": desired["routeClients"],
4742
+ }
4743
+ payload["manifestFingerprint"] = hashlib.sha256(
4744
+ json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
4745
+ ).hexdigest()
4746
+ next_state = {
4747
+ "revision": payload["revision"],
4748
+ "desiredFingerprint": desired_fingerprint,
4749
+ "tenantConnections": tenant_connections,
4750
+ "desiredRevision": previous_state.get("desiredRevision", 0),
4751
+ }
4752
+ return payload, next_state
4753
+
4754
+
4755
+ def auth_reconciliation_state(stage):
4756
+ name = f"/thinkwork/{stage}/auth/reconciliation/state"
4757
+ try:
4758
+ encoded = output(
4759
+ [
4760
+ "aws",
4761
+ "ssm",
4762
+ "get-parameter",
4763
+ "--name",
4764
+ name,
4765
+ "--query",
4766
+ "Parameter.Value",
4767
+ "--output",
4768
+ "text",
4769
+ ]
4770
+ )
4771
+ return json.loads(encoded)
4772
+ except Exception:
4773
+ return {}
4774
+
4775
+
4776
+ def _describe_cognito_identity_provider(user_pool_id, provider_name):
4777
+ try:
4778
+ raw = output(
4779
+ [
4780
+ "aws",
4781
+ "cognito-idp",
4782
+ "describe-identity-provider",
4783
+ "--user-pool-id",
4784
+ user_pool_id,
4785
+ "--provider-name",
4786
+ provider_name,
4787
+ "--output",
4788
+ "json",
4789
+ ],
4790
+ stderr=subprocess.PIPE,
4791
+ )
4792
+ value = json.loads(raw or "{}")
4793
+ return value.get("IdentityProvider") if isinstance(value, dict) else None
4794
+ except subprocess.CalledProcessError as error:
4795
+ stderr = str(getattr(error, "stderr", "") or "")
4796
+ if "ResourceNotFoundException" in stderr:
4797
+ return None
4798
+ raise RuntimeError(
4799
+ "Tenant Entra Cognito identity provider could not be described"
4800
+ ) from error
4801
+ except json.JSONDecodeError as error:
4802
+ raise RuntimeError(
4803
+ "Tenant Entra Cognito identity provider returned malformed metadata"
4804
+ ) from error
4805
+
4806
+
4807
+ def _read_tenant_entra_secret(connection):
4808
+ try:
4809
+ args = [
4810
+ "aws",
4811
+ "secretsmanager",
4812
+ "get-secret-value",
4813
+ "--secret-id",
4814
+ connection["clientSecretRef"],
4815
+ ]
4816
+ version_stage = connection.get("clientSecretVersionStage")
4817
+ if version_stage:
4818
+ args.extend(["--version-stage", version_stage])
4819
+ args.extend(
4820
+ [
4821
+ "--query",
4822
+ "SecretString",
4823
+ "--output",
4824
+ "text",
4825
+ ]
4826
+ )
4827
+ body = output(
4828
+ args,
4829
+ stderr=subprocess.DEVNULL,
4830
+ )
4831
+ secret = json.loads(body or "{}")
4832
+ except (subprocess.CalledProcessError, json.JSONDecodeError) as error:
4833
+ raise RuntimeError("Tenant Entra secret could not be read") from error
4834
+ client_id = secret.get("clientId") if isinstance(secret, dict) else None
4835
+ client_secret = secret.get("clientSecret") if isinstance(secret, dict) else None
4836
+ if (
4837
+ client_id != connection["clientId"]
4838
+ or not isinstance(client_secret, str)
4839
+ or not client_secret
4840
+ ):
4841
+ raise RuntimeError("Tenant Entra secret document is missing matching credentials")
4842
+ return client_secret
4843
+
4844
+
4845
+ def promote_tenant_entra_pending_secret(payload, vars_json):
4846
+ operation = identity_provider_operation(payload)
4847
+ if operation is None or str(operation.get("action") or "").lower() != "rotate":
4848
+ return None
4849
+ connection_key = operation["connection"]["connectionKey"]
4850
+ connection = next(
4851
+ value
4852
+ for value in vars_json["auth_tenant_connection_metadata"]
4853
+ if value["connectionKey"] == connection_key
4854
+ )
4855
+ if connection.get("clientSecretVersionStage") != "AWSPENDING":
4856
+ raise RuntimeError("Tenant Entra rotation is missing its pending secret version")
4857
+ raw = output(
4858
+ [
4859
+ "aws",
4860
+ "secretsmanager",
4861
+ "describe-secret",
4862
+ "--secret-id",
4863
+ connection["clientSecretRef"],
4864
+ "--query",
4865
+ "VersionIdsToStages",
4866
+ "--output",
4867
+ "json",
4868
+ ],
4869
+ stderr=subprocess.DEVNULL,
4870
+ )
4871
+ versions = json.loads(raw or "{}")
4872
+ pending = next(
4873
+ (version for version, stages in versions.items() if "AWSPENDING" in stages),
4874
+ None,
4875
+ )
4876
+ current = next(
4877
+ (version for version, stages in versions.items() if "AWSCURRENT" in stages),
4878
+ None,
4879
+ )
4880
+ if not pending:
4881
+ raise RuntimeError("Tenant Entra pending secret version was not found")
4882
+ if pending != current:
4883
+ args = [
4884
+ "aws",
4885
+ "secretsmanager",
4886
+ "update-secret-version-stage",
4887
+ "--secret-id",
4888
+ connection["clientSecretRef"],
4889
+ "--version-stage",
4890
+ "AWSCURRENT",
4891
+ "--move-to-version-id",
4892
+ pending,
4893
+ ]
4894
+ if current:
4895
+ args.extend(["--remove-from-version-id", current])
4896
+ output(args, stderr=subprocess.DEVNULL)
4897
+ return {"status": "promoted", "connectionKey": connection_key}
4898
+
4899
+
4900
+ def _assert_tenant_entra_provider_matches(existing, connection):
4901
+ details = existing.get("ProviderDetails") if isinstance(existing, dict) else None
4902
+ mapping = existing.get("AttributeMapping") if isinstance(existing, dict) else None
4903
+ expected_mapping = {
4904
+ "email": "preferred_username",
4905
+ "name": "name",
4906
+ "username": "sub",
4907
+ "custom:entra_tenant_id": "tid",
4908
+ "custom:entra_object_id": "oid",
4909
+ }
4910
+ if (
4911
+ not isinstance(existing, dict)
4912
+ or existing.get("ProviderName") != connection["cognitoIdentityProviderName"]
4913
+ or existing.get("ProviderType") != "OIDC"
4914
+ or not isinstance(details, dict)
4915
+ or details.get("client_id") != connection["clientId"]
4916
+ or str(details.get("oidc_issuer") or "").rstrip("/") != connection["issuerUrl"].rstrip("/")
4917
+ or details.get("authorize_scopes") != "openid email profile"
4918
+ or mapping != expected_mapping
4919
+ ):
4920
+ raise RuntimeError("Tenant Entra Cognito identity provider has drifted")
4921
+
4922
+
4923
+ def reconcile_identity_provider_resource(payload, vars_json):
4924
+ operation = identity_provider_operation(payload)
4925
+ if operation is None:
4926
+ return None
4927
+ action = str(operation["action"]).lower()
4928
+ connection_key = operation["connection"]["connectionKey"]
4929
+ connection = next(
4930
+ value
4931
+ for value in vars_json["auth_tenant_connection_metadata"]
4932
+ if value["connectionKey"] == connection_key
4933
+ )
4934
+ user_pool_id = connection["cognitoUserPoolId"]
4935
+ provider_name = connection["cognitoIdentityProviderName"]
4936
+ existing = _describe_cognito_identity_provider(user_pool_id, provider_name)
4937
+
4938
+ if action == "disable":
4939
+ return {
4940
+ "action": action,
4941
+ "connectionKey": connection_key,
4942
+ "providerName": provider_name,
4943
+ "status": "pending_route_removal",
4944
+ }
4945
+
4946
+ # Validation intentionally reads the secret as well: a provider whose
4947
+ # referenced credential is inaccessible or malformed is not valid.
4948
+ client_secret = _read_tenant_entra_secret(connection)
4949
+ if action == "validate":
4950
+ if existing is None:
4951
+ raise RuntimeError("Tenant Entra Cognito identity provider does not exist")
4952
+ _assert_tenant_entra_provider_matches(existing, connection)
4953
+ return {
4954
+ "action": action,
4955
+ "connectionKey": connection_key,
4956
+ "providerName": provider_name,
4957
+ "status": "valid",
4958
+ }
4959
+
4960
+ cli_input = {
4961
+ "UserPoolId": user_pool_id,
4962
+ "ProviderName": provider_name,
4963
+ "ProviderDetails": {
4964
+ "client_id": connection["clientId"],
4965
+ "client_secret": client_secret,
4966
+ "attributes_request_method": "GET",
4967
+ "oidc_issuer": connection["issuerUrl"],
4968
+ "authorize_scopes": "openid email profile",
4969
+ },
4970
+ "AttributeMapping": {
4971
+ "email": "preferred_username",
4972
+ "name": "name",
4973
+ "username": "sub",
4974
+ "custom:entra_tenant_id": "tid",
4975
+ "custom:entra_object_id": "oid",
4976
+ },
4977
+ "IdpIdentifiers": [],
4978
+ }
4979
+ aws_action = "update-identity-provider" if existing else "create-identity-provider"
4980
+ try:
4981
+ subprocess.run(
4982
+ [
4983
+ "aws",
4984
+ "cognito-idp",
4985
+ aws_action,
4986
+ "--cli-input-json",
4987
+ "file:///dev/stdin",
4988
+ "--output",
4989
+ "json",
4990
+ ],
4991
+ input=json.dumps(cli_input, separators=(",", ":")),
4992
+ text=True,
4993
+ check=True,
4994
+ stdout=subprocess.DEVNULL,
4995
+ stderr=subprocess.DEVNULL,
4996
+ )
4997
+ except subprocess.CalledProcessError as error:
4998
+ raise RuntimeError("Tenant Entra Cognito reconciliation failed") from error
4999
+ described = _describe_cognito_identity_provider(user_pool_id, provider_name)
5000
+ _assert_tenant_entra_provider_matches(described, connection)
5001
+ return {
5002
+ "action": action,
5003
+ "connectionKey": connection_key,
5004
+ "providerName": provider_name,
5005
+ "status": "reconciled",
5006
+ }
5007
+
5008
+
5009
+ def finalize_disabled_identity_provider(payload, vars_json):
5010
+ operation = identity_provider_operation(payload)
5011
+ if operation is None or str(operation.get("action") or "").lower() != "disable":
5012
+ return None
5013
+ connection = operation["connection"]
5014
+ user_pool_id = next(
5015
+ value["cognitoUserPoolId"]
5016
+ for value in vars_json["auth_tenant_connection_metadata"]
5017
+ if value["connectionKey"] == connection["connectionKey"]
5018
+ )
5019
+ provider_name = connection["providerName"]
5020
+ if _describe_cognito_identity_provider(user_pool_id, provider_name) is not None:
5021
+ try:
5022
+ output(
5023
+ [
5024
+ "aws",
5025
+ "cognito-idp",
5026
+ "delete-identity-provider",
5027
+ "--user-pool-id",
5028
+ user_pool_id,
5029
+ "--provider-name",
5030
+ provider_name,
5031
+ ],
5032
+ stderr=subprocess.DEVNULL,
5033
+ )
5034
+ except subprocess.CalledProcessError as error:
5035
+ raise RuntimeError("Tenant Entra Cognito provider could not be disabled") from error
5036
+ return {
5037
+ "action": "disable",
5038
+ "connectionKey": connection["connectionKey"],
5039
+ "providerName": provider_name,
5040
+ "status": "disabled",
5041
+ }
5042
+
5043
+
5044
+ def record_identity_provider_operation_state(payload, vars_json):
5045
+ operation = identity_provider_operation(payload)
5046
+ if operation is None:
5047
+ return
5048
+ current = auth_reconciliation_state(vars_json["stage"])
5049
+ expected = int(operation["expectedPreviousRevision"])
5050
+ if int(current.get("desiredRevision") or 0) != expected:
5051
+ raise RuntimeError("Identity-provider desired revision changed during reconciliation")
5052
+ current["desiredRevision"] = int(operation["revision"])
5053
+ current["tenantConnections"] = [
5054
+ {key: value for key, value in connection.items() if key != "clientSecretVersionStage"}
5055
+ for connection in vars_json["auth_tenant_connection_metadata"]
5056
+ ]
5057
+ run(
5058
+ [
5059
+ "aws",
5060
+ "ssm",
5061
+ "put-parameter",
5062
+ "--overwrite",
5063
+ "--type",
5064
+ "String",
5065
+ "--name",
5066
+ f"/thinkwork/{vars_json['stage']}/auth/reconciliation/state",
5067
+ "--value",
5068
+ json.dumps(current, separators=(",", ":")),
5069
+ ]
5070
+ )
5071
+
5072
+
5073
+ def reconcile_native_auth_metadata(outputs_path, vars_json):
5074
+ outputs = json.loads(outputs_path.read_text(encoding="utf-8"))
5075
+ previous = auth_reconciliation_state(vars_json["stage"])
5076
+ payload, next_state = native_auth_reconciliation_payload(outputs, vars_json, previous)
5077
+ if payload is None:
5078
+ return {"status": "unchanged", "revision": previous.get("revision", 0)}
5079
+ api_endpoint = str(outputs.get("api_endpoint", {}).get("value") or "").rstrip("/")
5080
+ api_auth_secret = str(vars_json.get("api_auth_secret") or "")
5081
+ if not api_endpoint or not api_auth_secret:
5082
+ raise RuntimeError("Native auth reconciliation requires API endpoint and service auth")
5083
+ request = urllib.request.Request(
5084
+ f"{api_endpoint}/api/auth/providers/reconcile",
5085
+ data=json.dumps(payload, separators=(",", ":")).encode("utf-8"),
5086
+ method="POST",
5087
+ headers={
5088
+ "Authorization": f"Bearer {api_auth_secret}",
5089
+ "Content-Type": "application/json",
5090
+ "Accept": "application/json",
5091
+ },
5092
+ )
5093
+ try:
5094
+ with urllib.request.urlopen(request, timeout=30) as response:
5095
+ result = json.loads(response.read().decode("utf-8") or "{}")
5096
+ except urllib.error.HTTPError as error:
5097
+ safe_body = error.read().decode("utf-8", errors="replace")[:1000]
5098
+ raise RuntimeError(
5099
+ f"Native auth metadata reconciliation failed with HTTP {error.code}: {safe_body}"
5100
+ ) from error
5101
+ run(
5102
+ [
5103
+ "aws",
5104
+ "ssm",
5105
+ "put-parameter",
5106
+ "--overwrite",
5107
+ "--type",
5108
+ "String",
5109
+ "--name",
5110
+ f"/thinkwork/{vars_json['stage']}/auth/reconciliation/state",
5111
+ "--value",
5112
+ json.dumps(next_state, separators=(",", ":")),
5113
+ ]
5114
+ )
5115
+ return {
5116
+ "status": str(result.get("status") or "applied"),
5117
+ "revision": int(result.get("revision") or payload["revision"]),
5118
+ "manifestFingerprint": payload["manifestFingerprint"],
5119
+ }
5120
+
5121
+
4173
5122
  def sync_static(outputs_path, static_files, vars_json, artifact_names=None):
4174
5123
  outputs = json.loads(outputs_path.read_text(encoding="utf-8"))
4175
5124
  artifact_names = set(artifact_names or [])
@@ -4467,8 +5416,13 @@ def main():
4467
5416
 
4468
5417
  runner_secrets = secret_payload(payload)
4469
5418
  web_only = is_web_only_operation(payload, action)
5419
+ identity_operation = identity_provider_operation(payload)
4470
5420
  static_files = {}
4471
- if action in {"deploy", "update", "web"} and not is_managed_app_operation(payload):
5421
+ if (
5422
+ action in {"deploy", "update", "web"}
5423
+ and not is_managed_app_operation(payload)
5424
+ and identity_operation is None
5425
+ ):
4472
5426
  static_files = sync_release_artifacts(
4473
5427
  artifact_types={"static-site"} if web_only else None,
4474
5428
  artifact_names={"web"} if web_only else None,
@@ -4510,6 +5464,11 @@ def main():
4510
5464
  write_evidence("succeeded", vars_json, 0)
4511
5465
  return 0
4512
5466
 
5467
+ if identity_operation is not None:
5468
+ CONTROLLER_EVIDENCE["identityProvider"] = reconcile_identity_provider_resource(
5469
+ payload, vars_json
5470
+ )
5471
+
4513
5472
  configure_cloudflare_provider_auth(vars_json["stage"])
4514
5473
  configure_terraform_provider_mirror()
4515
5474
  run(["terraform", "init", "-backend-config=backend.hcl", "-no-color"], cwd=TF)
@@ -4524,7 +5483,10 @@ def main():
4524
5483
  if selected.returncode != 0:
4525
5484
  run(["terraform", "workspace", "new", workspace, "-no-color"], cwd=TF)
4526
5485
  target_args = managed_app_terraform_target_args(payload)
4527
- if action == "destroy":
5486
+ pre_app_schema = prepare_additive_schema_before_app(payload, vars_json)
5487
+ if pre_app_schema is not None and pre_app_schema.returncode != 0:
5488
+ result = pre_app_schema
5489
+ elif action == "destroy":
4528
5490
  plan = subprocess.run(
4529
5491
  ["terraform", "plan", "-destroy", *target_args, "-out=tfplan", "-no-color"],
4530
5492
  cwd=TF,
@@ -4558,6 +5520,10 @@ def main():
4558
5520
 
4559
5521
  outputs_path = TF / "outputs.json"
4560
5522
  if result.returncode == 0 and action in {"deploy", "update"}:
5523
+ if identity_operation is not None:
5524
+ finalized = finalize_disabled_identity_provider(payload, vars_json)
5525
+ if finalized is not None:
5526
+ CONTROLLER_EVIDENCE["identityProvider"] = finalized
4561
5527
  write_outputs_after_apply(payload, vars_json, outputs_path)
4562
5528
  if is_managed_app_operation(payload):
4563
5529
  sync_twenty_thinkwork_app(
@@ -4567,7 +5533,21 @@ def main():
4567
5533
  runner_secrets,
4568
5534
  managed_app_artifacts,
4569
5535
  )
5536
+ elif identity_operation is not None:
5537
+ CONTROLLER_EVIDENCE["authReconciliation"] = reconcile_native_auth_metadata(
5538
+ outputs_path, vars_json
5539
+ )
5540
+ promoted = promote_tenant_entra_pending_secret(payload, vars_json)
5541
+ if promoted is not None:
5542
+ CONTROLLER_EVIDENCE["identityProviderSecret"] = promoted
5543
+ write_outputs_to_ssm(outputs_path, vars_json)
5544
+ record_identity_provider_operation_state(payload, vars_json)
4570
5545
  else:
5546
+ CONTROLLER_EVIDENCE["authReconciliation"] = reconcile_native_auth_metadata(
5547
+ outputs_path, vars_json
5548
+ )
5549
+ # Destructive retirement migrations are permitted only after the
5550
+ # deployed native routes have reconciled successfully.
4571
5551
  push_database_schema(outputs_path, vars_json)
4572
5552
  ensure_first_admin(outputs_path, vars_json, payload, runner_secrets)
4573
5553
  write_outputs_to_ssm(outputs_path, vars_json)