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.
- package/dist/{api-client-JUBCQQDF.js → api-client-JYZXYQJR.js} +3 -1
- package/dist/bowser-6Z6JFAIR.js +2822 -0
- package/dist/chunk-DV3MRBR5.js +43 -0
- package/dist/chunk-DVPBI5HM.js +1728 -0
- package/dist/chunk-EI5ZX7CN.js +522 -0
- package/dist/chunk-EUJ7X6YD.js +126 -0
- package/dist/chunk-GG5WKBOX.js +436 -0
- package/dist/chunk-I64EZSU5.js +358 -0
- package/dist/chunk-ILZKG5WV.js +6151 -0
- package/dist/chunk-KMIJ5KGT.js +128 -0
- package/dist/chunk-OCPHU34C.js +996 -0
- package/dist/chunk-QOERT44A.js +3286 -0
- package/dist/chunk-XYM7PE2A.js +2264 -0
- package/dist/{chunk-STY44FQO.js → chunk-Z2F6RJFR.js} +12 -18
- package/dist/cli.js +72564 -18884
- package/dist/dist-es-CRV46D5B.js +315 -0
- package/dist/dist-es-DCY4IC3Y.js +2000 -0
- package/dist/dist-es-ENBUC3JP.js +14726 -0
- package/dist/dist-es-G54LBMRN.js +48 -0
- package/dist/dist-es-G7PTG75M.js +176 -0
- package/dist/dist-es-GKDBE7E6.js +88 -0
- package/dist/dist-es-HY3535OI.js +70 -0
- package/dist/dist-es-KL7Y5NB2.js +377 -0
- package/dist/dist-es-LDE437AM.js +170 -0
- package/dist/dist-es-P26FWFLT.js +485 -0
- package/dist/dist-es-Q57SZXZ7.js +70 -0
- package/dist/dist-es-R7FGL2IB.js +88 -0
- package/dist/dist-es-USORBXZ5.js +318 -0
- package/dist/dist-es-ZKC2QKD2.js +377 -0
- package/dist/drizzle/0076_scheduled_jobs_marco_backfill.sql +2 -1
- package/dist/drizzle/0079_seed_tenant_customize_catalog.sql +1 -0
- package/dist/drizzle/0090_brain_schema_extraction.sql +10 -0
- package/dist/drizzle/0132_drop_computer_tables.sql +3 -0
- package/dist/drizzle/0160_compliance_event_types_plugins.sql +2 -0
- package/dist/drizzle/0214_think137_drop_judge_evidence_roi.sql +1 -0
- package/dist/drizzle/0216_drop_agent_loop_versions_goal_worker_policy.sql +1 -0
- package/dist/drizzle/0227_analyst_reader_role.sql +106 -38
- package/dist/drizzle/0230_analyst_rls.sql +192 -66
- package/dist/drizzle/0232_memory_source_thin_ledger.sql +176 -0
- package/dist/drizzle/0232_message_source_event_id.sql +9 -0
- package/dist/drizzle/0233_msteams_install_and_links.sql +151 -0
- package/dist/drizzle/0233_workflow_memory_stage_tokens.sql +20 -0
- package/dist/drizzle/0234_memory_claim_lifecycle.sql +160 -0
- package/dist/drizzle/0235_workflow_token_execution_claim.sql +34 -0
- package/dist/drizzle/0236_memory_retraction_fencing.sql +50 -0
- package/dist/drizzle/0237_memory_claims_active_value_uidx.sql +172 -0
- package/dist/drizzle/0238_memory_erase_generation_fence.sql +97 -0
- package/dist/drizzle/0239_canonical_entity_identity.sql +257 -0
- package/dist/drizzle/0240_memory_personal_scope.sql +45 -0
- package/dist/drizzle/0241_knowledge_base_document_manifest.sql +67 -0
- package/dist/drizzle/0242_search_queries.sql +28 -0
- package/dist/drizzle/0243_section_sources_thread_refs.sql +12 -0
- package/dist/drizzle/0244_system_automations_and_memory_stage_overrides.sql +45 -0
- package/dist/drizzle/0245_wiki_page_render.sql +21 -0
- package/dist/drizzle/0246_automation_workflow_ownership.sql +30 -0
- package/dist/drizzle/0247_capability_runtime.sql +402 -0
- package/dist/drizzle/0248_capability_private_interpreter.sql +13 -0
- package/dist/drizzle/0249_capability_headless_execution_evidence.sql +40 -0
- package/dist/drizzle/0250_kg_schema_extraction.sql +576 -0
- package/dist/drizzle/0251_agents_json_render_ui.sql +25 -0
- package/dist/drizzle/0251_brain_stragglers.sql +263 -0
- package/dist/drizzle/0252_drop_kg_and_straggler_compat_views.sql +100 -0
- package/dist/drizzle/0253_drop_brain_substrate.sql +93 -0
- package/dist/drizzle/0254_capability_autonomous_admission.sql +29 -0
- package/dist/drizzle/0255_capability_autonomous_approval_mode.sql +16 -0
- package/dist/drizzle/0256_agent_profile_manifest_authority.sql +18 -0
- package/dist/drizzle/0257_agents_runtime_allow_harness.sql +24 -0
- package/dist/drizzle/0257_capability_approvals.sql +53 -0
- package/dist/drizzle/0258_pending_tool_approvals.sql +84 -0
- package/dist/drizzle/0259_tenant_capability_registry_trust.sql +19 -0
- package/dist/drizzle/0260_scheduled_jobs_run_as_user.sql +20 -0
- package/dist/drizzle/0261_managed_harness_multiplayer.sql +313 -0
- package/dist/drizzle/0261_native_auth_control_plane.sql +328 -0
- package/dist/drizzle/0262_auth_subscription_tickets.sql +59 -0
- package/dist/drizzle/0262_parallel_harness_threads.sql +9 -0
- package/dist/drizzle/0263_agentcore_runtime_identifier.sql +69 -0
- package/dist/drizzle/0263_drop_workos_auth_runtime.sql +236 -0
- package/dist/drizzle/0264_auth_enrollment_attempt_limits.sql +15 -0
- package/dist/drizzle/0264_harness_tool_execution_ledger.sql +113 -0
- package/dist/drizzle/0265_eval_runs_requester_identity.sql +28 -0
- package/dist/drizzle/0265_require_fully_valid_auth_publication.sql +27 -0
- package/dist/drizzle/0266_auth_identity_recovery_grants.sql +9 -0
- package/dist/drizzle/0266_eval_profiles_runtime_type.sql +12 -0
- package/dist/drizzle/0267_ontology_candidate_rejections.sql +53 -0
- package/dist/drizzle/0268_identity_crosswalk_routing.sql +182 -0
- package/dist/drizzle/0269_ontology_system_map.sql +31 -0
- package/dist/event-streams-7JNLJEEL.js +894 -0
- package/dist/event-streams-KDF5RWR6.js +1515 -0
- package/dist/loadSso-DJVB7BXE.js +576 -0
- package/dist/loadSso-JAMNA3WY.js +583 -0
- package/dist/plugins/catalog/package.json +1 -2
- package/dist/plugins/catalog/src/__tests__/build-catalog.test.ts +0 -1
- package/dist/plugins/catalog/src/__tests__/contracts.test.ts +0 -69
- package/dist/plugins/catalog/src/__tests__/plugin-package.test.ts +0 -2
- package/dist/plugins/catalog/src/__tests__/plugin-registry.test.ts +0 -5
- package/dist/plugins/catalog/src/contracts.ts +2 -179
- package/dist/plugins/catalog/src/registry/generated-first-party.ts +0 -6
- package/dist/signin-UGDWTGS4.js +821 -0
- package/dist/sso-oidc-Q4VXANXY.js +837 -0
- package/dist/sts-CWLSE3GH.js +1241 -0
- package/dist/sts-DNZLNVOV.js +1245 -0
- package/dist/terraform/examples/greenfield/main.tf +247 -50
- package/dist/terraform/examples/greenfield/terraform.tfvars.example +15 -1
- package/dist/terraform/modules/app/agentcore-admin/main.tf +341 -0
- package/dist/terraform/modules/app/agentcore-admin/outputs.tf +20 -0
- package/dist/terraform/modules/app/agentcore-code-interpreter/main.tf +61 -0
- package/dist/terraform/modules/app/agentcore-gateway/main.tf +305 -0
- package/dist/terraform/modules/app/agentcore-gateway/outputs.tf +44 -0
- package/dist/terraform/modules/app/agentcore-gateway/scripts/delete_gateway.sh +93 -0
- package/dist/terraform/modules/app/agentcore-gateway/scripts/read_gateway.sh +33 -0
- package/dist/terraform/modules/app/agentcore-gateway/scripts/reconcile_gateway.sh +543 -0
- package/dist/terraform/modules/app/agentcore-gateway/variables.tf +70 -0
- package/dist/terraform/modules/app/agentcore-harness/main.tf +330 -0
- package/dist/terraform/modules/app/agentcore-harness/outputs.tf +72 -0
- package/dist/terraform/modules/app/agentcore-harness/scripts/delete_harness.sh +5 -0
- package/dist/terraform/modules/app/agentcore-harness/scripts/harness-lifecycle.mjs +430 -0
- package/dist/terraform/modules/app/agentcore-harness/scripts/harness-readback.mjs +180 -0
- package/dist/terraform/modules/app/agentcore-harness/scripts/harness-tool-contract.mjs +193 -0
- package/dist/terraform/modules/app/agentcore-harness/scripts/prune_harness.sh +5 -0
- package/dist/terraform/modules/app/agentcore-harness/scripts/read_harness.sh +5 -0
- package/dist/terraform/modules/app/agentcore-harness/scripts/reconcile_harness.sh +5 -0
- package/dist/terraform/modules/app/agentcore-harness/variables.tf +96 -0
- package/dist/terraform/modules/app/agentcore-identity/main.tf +173 -0
- package/dist/terraform/modules/app/agentcore-identity/outputs.tf +51 -0
- package/dist/terraform/modules/app/agentcore-identity/scripts/bootstrap_twenty_oauth_client.sh +122 -0
- package/dist/terraform/modules/app/agentcore-identity/scripts/delete_identity.sh +25 -0
- package/dist/terraform/modules/app/agentcore-identity/scripts/delete_twenty_identity.sh +16 -0
- package/dist/terraform/modules/app/agentcore-identity/scripts/read_identity.sh +40 -0
- package/dist/terraform/modules/app/agentcore-identity/scripts/reconcile_identity.sh +73 -0
- package/dist/terraform/modules/app/agentcore-identity/scripts/reconcile_twenty_identity.sh +74 -0
- package/dist/terraform/modules/app/agentcore-identity/scripts/reconcile_twenty_provider.mjs +61 -0
- package/dist/terraform/modules/app/agentcore-identity/variables.tf +54 -0
- package/dist/terraform/modules/app/agentcore-pi/main.tf +7 -0
- package/dist/terraform/modules/app/appsync-subscriptions/main.tf +127 -21
- package/dist/terraform/modules/app/appsync-subscriptions/outputs.tf +0 -6
- package/dist/terraform/modules/app/appsync-subscriptions/variables.tf +5 -0
- package/dist/terraform/modules/app/capability-broker/main.tf +393 -0
- package/dist/terraform/modules/app/capability-broker/outputs.tf +54 -0
- package/dist/terraform/modules/app/capability-broker/variables.tf +98 -0
- package/dist/terraform/modules/app/deployment-control-plane/runner.py +1011 -31
- package/dist/terraform/modules/app/deployment-control-plane/test_runner_bundle.py +655 -2
- package/dist/terraform/modules/app/lambda-api/agentcore-harness-alarms.tf +88 -0
- package/dist/terraform/modules/app/lambda-api/agentcore-identity-alarms.tf +91 -0
- package/dist/terraform/modules/app/lambda-api/capability-search-alarms.tf +60 -0
- package/dist/terraform/modules/app/lambda-api/handlers.tf +606 -37
- package/dist/terraform/modules/app/lambda-api/iam-grouped.tf +192 -21
- package/dist/terraform/modules/app/lambda-api/main.tf +35 -0
- package/dist/terraform/modules/app/lambda-api/mcp-oauth.tf +181 -4
- package/dist/terraform/modules/app/lambda-api/memory-alarms.tf +92 -0
- package/dist/terraform/modules/app/lambda-api/msteams-app-secrets.tf +46 -0
- package/dist/terraform/modules/app/lambda-api/outputs.tf +35 -0
- package/dist/terraform/modules/app/lambda-api/runtime-config.tf +3 -21
- package/dist/terraform/modules/app/lambda-api/variables.tf +188 -3
- package/dist/terraform/modules/app/workflow-interpreter-stepfunctions/alarms.tf +47 -0
- package/dist/terraform/modules/app/workflow-interpreter-stepfunctions/main.tf +43 -2
- package/dist/terraform/modules/foundation/cognito/main.tf +319 -73
- package/dist/terraform/modules/foundation/cognito/outputs.tf +42 -0
- package/dist/terraform/modules/foundation/cognito/variables.tf +124 -2
- package/dist/terraform/modules/foundation/vpc/outputs.tf +5 -0
- package/dist/terraform/modules/thinkwork/main.tf +396 -26
- package/dist/terraform/modules/thinkwork/outputs.tf +188 -5
- package/dist/terraform/modules/thinkwork/variables.tf +154 -5
- package/dist/terraform/schema.graphql +69 -36
- package/dist/workspace-defaults/files/TOOLS.md +39 -0
- package/dist/workspace-defaults/files/USER.md +1 -0
- package/dist/workspace-defaults/files/agents/analyst/INSTRUCTIONS.md +18 -0
- package/dist/workspace-defaults/files/agents/coding/INSTRUCTIONS.md +11 -0
- package/dist/workspace-defaults/files/agents/research/INSTRUCTIONS.md +12 -0
- package/dist/workspace-defaults/files/agents/reviewer/INSTRUCTIONS.md +15 -0
- package/dist/workspace-defaults/files/skills/document-composer/SKILL.md +6 -2
- package/package.json +6 -3
- package/dist/plugins/workos-auth/package.json +0 -22
- package/dist/plugins/workos-auth/src/index.ts +0 -30
- package/dist/plugins/workos-auth/src/manifest.ts +0 -40
- package/dist/plugins/workos-auth/src/provider-contract.ts +0 -39
- package/dist/plugins/workos-auth/test/manifest.test.ts +0 -94
- package/dist/plugins/workos-auth/tsconfig.json +0 -8
- /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
|
-
|
|
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":
|
|
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
|
|
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
|
-
|
|
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)
|