thinkwork-cli 0.13.1 → 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-4DZ266J5.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-Y2WHJECR.js → chunk-Z2F6RJFR.js} +1 -39
- package/dist/cli.js +43080 -13150
- 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 +6 -0
- package/dist/drizzle/0230_analyst_rls.sql +9 -0
- package/dist/drizzle/0250_kg_schema_extraction.sql +27 -0
- package/dist/drizzle/0251_brain_stragglers.sql +21 -0
- package/dist/drizzle/0253_drop_brain_substrate.sql +2 -0
- package/dist/drizzle/0257_agents_runtime_allow_harness.sql +24 -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 +223 -66
- package/dist/terraform/examples/greenfield/terraform.tfvars.example +15 -1
- 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 +237 -8
- package/dist/terraform/modules/app/agentcore-harness/outputs.tf +63 -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 +72 -1
- 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/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/deployment-control-plane/runner.py +966 -27
- package/dist/terraform/modules/app/deployment-control-plane/test_runner_bundle.py +581 -22
- 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/handlers.tf +298 -74
- package/dist/terraform/modules/app/lambda-api/iam-grouped.tf +99 -54
- package/dist/terraform/modules/app/lambda-api/main.tf +24 -0
- package/dist/terraform/modules/app/lambda-api/mcp-oauth.tf +181 -4
- 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 +109 -16
- package/dist/terraform/modules/foundation/cognito/main.tf +294 -72
- package/dist/terraform/modules/foundation/cognito/outputs.tf +42 -0
- package/dist/terraform/modules/foundation/cognito/variables.tf +114 -4
- package/dist/terraform/modules/thinkwork/main.tf +269 -46
- package/dist/terraform/modules/thinkwork/outputs.tf +136 -8
- package/dist/terraform/modules/thinkwork/variables.tf +124 -17
- package/dist/terraform/schema.graphql +69 -36
- package/dist/workspace-defaults/files/USER.md +1 -0
- package/package.json +3 -2
- package/dist/drizzle/0261_thread_turns_origin_turn_index.sql +0 -11
- 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
|
@@ -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
|
|
@@ -2081,7 +2080,9 @@ def resolve_agentcore_pi_source_image_uri(payload):
|
|
|
2081
2080
|
safe_get(operation, "action", default=safe_get(payload, "action", "phase", default=""))
|
|
2082
2081
|
).lower()
|
|
2083
2082
|
kind = str(safe_get(operation, "kind", default="")).lower()
|
|
2084
|
-
is_customer_update =
|
|
2083
|
+
is_customer_update = (
|
|
2084
|
+
action == "update" or kind == "foundation"
|
|
2085
|
+
) and kind != "identity_provider"
|
|
2085
2086
|
explicit = safe_get(payload, "agentcorePiSourceImageUri", default="")
|
|
2086
2087
|
if explicit:
|
|
2087
2088
|
if is_customer_update:
|
|
@@ -2298,9 +2299,24 @@ def ensure_compliance_roles(database_url, outputs, vars_json):
|
|
|
2298
2299
|
)
|
|
2299
2300
|
|
|
2300
2301
|
|
|
2301
|
-
def migration_files():
|
|
2302
|
+
def migration_files(vars_json=None):
|
|
2302
2303
|
migrations = SOURCE / "packages/database-pg/drizzle"
|
|
2303
|
-
|
|
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)
|
|
2304
2320
|
|
|
2305
2321
|
|
|
2306
2322
|
def apply_migration_file(database_url, outputs, vars_json, path):
|
|
@@ -2334,7 +2350,7 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
|
|
2334
2350
|
CREATE EXTENSION IF NOT EXISTS vector;
|
|
2335
2351
|
""",
|
|
2336
2352
|
)
|
|
2337
|
-
for path in migration_files():
|
|
2353
|
+
for path in migration_files(vars_json):
|
|
2338
2354
|
apply_migration_file(database_url, outputs, vars_json, path)
|
|
2339
2355
|
|
|
2340
2356
|
|
|
@@ -2454,7 +2470,7 @@ def platform_migration_object_present(database_url, kind, name):
|
|
|
2454
2470
|
return None
|
|
2455
2471
|
|
|
2456
2472
|
|
|
2457
|
-
def backfill_platform_migration_ledger(database_url):
|
|
2473
|
+
def backfill_platform_migration_ledger(database_url, vars_json):
|
|
2458
2474
|
"""One-time ledger bootstrap for environments installed before the ledger
|
|
2459
2475
|
existed. Every file shipping with this release is recorded as assumed
|
|
2460
2476
|
applied — auto-re-running old files is unsafe (markers can name objects
|
|
@@ -2463,11 +2479,29 @@ def backfill_platform_migration_ledger(database_url):
|
|
|
2463
2479
|
transition apply through the exact pending path."""
|
|
2464
2480
|
verified = []
|
|
2465
2481
|
assumed = []
|
|
2466
|
-
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
|
|
2467
2485
|
verdicts = [
|
|
2468
2486
|
platform_migration_object_present(database_url, kind, name)
|
|
2469
2487
|
for kind, name in declared_migration_objects(path)
|
|
2470
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
|
|
2471
2505
|
if any(verdict is False for verdict in verdicts):
|
|
2472
2506
|
print(
|
|
2473
2507
|
f"[migrations] transition WARNING: {path.name} declares objects missing from "
|
|
@@ -2484,7 +2518,7 @@ def backfill_platform_migration_ledger(database_url):
|
|
|
2484
2518
|
|
|
2485
2519
|
def apply_pending_platform_migrations(database_url, outputs, vars_json):
|
|
2486
2520
|
recorded = recorded_platform_migrations(database_url)
|
|
2487
|
-
for path in migration_files():
|
|
2521
|
+
for path in migration_files(vars_json):
|
|
2488
2522
|
if path.name in recorded:
|
|
2489
2523
|
continue
|
|
2490
2524
|
print(f"[migrations] applying {path.name}")
|
|
@@ -2966,10 +3000,10 @@ def push_database_schema(outputs_path, vars_json):
|
|
|
2966
3000
|
ensure_migration_ledger(database_url)
|
|
2967
3001
|
if fresh:
|
|
2968
3002
|
record_platform_migrations(
|
|
2969
|
-
database_url, [path.name for path in migration_files()], "greenfield"
|
|
3003
|
+
database_url, [path.name for path in migration_files(vars_json)], "greenfield"
|
|
2970
3004
|
)
|
|
2971
3005
|
elif not ledger_present:
|
|
2972
|
-
backfill_platform_migration_ledger(database_url)
|
|
3006
|
+
backfill_platform_migration_ledger(database_url, vars_json)
|
|
2973
3007
|
apply_pending_platform_migrations(database_url, outputs, vars_json)
|
|
2974
3008
|
seed_platform_bootstrap_defaults(database_url)
|
|
2975
3009
|
migrations = SOURCE / "packages/database-pg/drizzle"
|
|
@@ -2980,6 +3014,55 @@ def push_database_schema(outputs_path, vars_json):
|
|
|
2980
3014
|
apply_migration_file(database_url, outputs, vars_json, path)
|
|
2981
3015
|
|
|
2982
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
|
+
|
|
2983
3066
|
def write_runner_files(payload, runner_secrets):
|
|
2984
3067
|
preserved_config = payload.get("preservedConfig")
|
|
2985
3068
|
if not isinstance(preserved_config, dict):
|
|
@@ -3065,12 +3148,70 @@ def write_runner_files(payload, runner_secrets):
|
|
|
3065
3148
|
if not api_auth_secret:
|
|
3066
3149
|
api_auth_secret = secrets.token_urlsafe(48)
|
|
3067
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
|
|
3068
3206
|
vars_json = {
|
|
3069
3207
|
"stage": stage,
|
|
3070
3208
|
"region": region,
|
|
3071
3209
|
"account_id": account_id,
|
|
3072
3210
|
"db_password": db_password,
|
|
3073
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,
|
|
3074
3215
|
"database_engine": safe_get(
|
|
3075
3216
|
reviewed_payload,
|
|
3076
3217
|
"databaseEngine",
|
|
@@ -3131,6 +3272,38 @@ def write_runner_files(payload, runner_secrets):
|
|
|
3131
3272
|
default="",
|
|
3132
3273
|
),
|
|
3133
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,
|
|
3134
3307
|
"cognito_email_source_arn": safe_get(
|
|
3135
3308
|
runner_secrets,
|
|
3136
3309
|
"cognitoEmailSourceArn",
|
|
@@ -3289,6 +3462,16 @@ variable "api_auth_secret" {{
|
|
|
3289
3462
|
sensitive = true
|
|
3290
3463
|
}}
|
|
3291
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
|
+
|
|
3292
3475
|
variable "database_engine" {{
|
|
3293
3476
|
type = string
|
|
3294
3477
|
}}
|
|
@@ -3352,6 +3535,35 @@ variable "google_oauth_client_secret" {{
|
|
|
3352
3535
|
sensitive = true
|
|
3353
3536
|
}}
|
|
3354
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
|
+
|
|
3355
3567
|
variable "lambda_artifact_bucket" {{
|
|
3356
3568
|
type = string
|
|
3357
3569
|
}}
|
|
@@ -3672,10 +3884,16 @@ module "thinkwork" {{
|
|
|
3672
3884
|
|
|
3673
3885
|
db_password = var.db_password
|
|
3674
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
|
|
3675
3889
|
database_engine = var.database_engine
|
|
3676
3890
|
|
|
3677
3891
|
google_oauth_client_id = var.google_oauth_client_id
|
|
3678
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
|
|
3679
3897
|
platform_operator_emails = var.platform_operator_emails
|
|
3680
3898
|
|
|
3681
3899
|
cognito_email_source_arn = var.cognito_email_source_arn
|
|
@@ -3778,10 +3996,6 @@ output "app_distribution_id" {{ value = module.thinkwork.app_distribution_id }}
|
|
|
3778
3996
|
output "api_endpoint" {{ value = module.thinkwork.api_endpoint }}
|
|
3779
3997
|
output "appsync_api_url" {{ value = module.thinkwork.appsync_api_url }}
|
|
3780
3998
|
output "appsync_realtime_url" {{ value = module.thinkwork.appsync_realtime_url }}
|
|
3781
|
-
output "appsync_api_key" {{
|
|
3782
|
-
value = module.thinkwork.appsync_api_key
|
|
3783
|
-
sensitive = true
|
|
3784
|
-
}}
|
|
3785
3999
|
output "auth_domain" {{ value = module.thinkwork.auth_domain }}
|
|
3786
4000
|
output "customer_domain" {{ value = module.thinkwork.customer_domain }}
|
|
3787
4001
|
output "customer_domain_name_servers" {{ value = module.thinkwork.customer_domain_name_servers }}
|
|
@@ -3790,6 +4004,9 @@ output "db_secret_arn" {{ value = module.thinkwork.db_secret_arn }}
|
|
|
3790
4004
|
output "database_name" {{ value = module.thinkwork.database_name }}
|
|
3791
4005
|
output "user_pool_id" {{ value = module.thinkwork.user_pool_id }}
|
|
3792
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 }}
|
|
3793
4010
|
output "docs_bucket_name" {{ value = module.thinkwork.docs_bucket_name }}
|
|
3794
4011
|
output "docs_distribution_id" {{ value = module.thinkwork.docs_distribution_id }}
|
|
3795
4012
|
output "docs_distribution_domain" {{ value = module.thinkwork.docs_distribution_domain }}
|
|
@@ -4013,7 +4230,6 @@ def write_outputs_to_ssm(outputs_path, vars_json):
|
|
|
4013
4230
|
"profile/graphql-http-url": None,
|
|
4014
4231
|
"profile/appsync-url": "appsync_api_url",
|
|
4015
4232
|
"profile/appsync-realtime-url": "appsync_realtime_url",
|
|
4016
|
-
"profile/appsync-api-key": "appsync_api_key",
|
|
4017
4233
|
"profile/cognito-domain": None,
|
|
4018
4234
|
"profile/cognito-user-pool-id": "user_pool_id",
|
|
4019
4235
|
"profile/cognito-client-id": "admin_client_id",
|
|
@@ -4154,7 +4370,6 @@ def runtime_profile(outputs, vars_json):
|
|
|
4154
4370
|
"graphqlHttpUrl": graphql_http_url,
|
|
4155
4371
|
"appsyncUrl": output_value("appsync_api_url"),
|
|
4156
4372
|
"appsyncRealtimeUrl": output_value("appsync_realtime_url"),
|
|
4157
|
-
"appsyncApiKey": output_value("appsync_api_key"),
|
|
4158
4373
|
"cognitoDomain": cognito_domain,
|
|
4159
4374
|
"cognitoUserPoolId": output_value("user_pool_id"),
|
|
4160
4375
|
"cognitoClientId": output_value("admin_client_id"),
|
|
@@ -4185,7 +4400,6 @@ def runtime_profile(outputs, vars_json):
|
|
|
4185
4400
|
"VITE_GRAPHQL_HTTP_URL": profile["graphqlHttpUrl"],
|
|
4186
4401
|
"VITE_GRAPHQL_URL": profile["appsyncUrl"],
|
|
4187
4402
|
"VITE_GRAPHQL_WS_URL": profile["appsyncRealtimeUrl"],
|
|
4188
|
-
"VITE_GRAPHQL_API_KEY": profile["appsyncApiKey"],
|
|
4189
4403
|
"VITE_COGNITO_DOMAIN": profile["cognitoDomain"],
|
|
4190
4404
|
"VITE_COGNITO_USER_POOL_ID": profile["cognitoUserPoolId"],
|
|
4191
4405
|
"VITE_COGNITO_CLIENT_ID": profile["cognitoClientId"],
|
|
@@ -4211,6 +4425,700 @@ def runtime_profile(outputs, vars_json):
|
|
|
4211
4425
|
return profile, web_env + "\n"
|
|
4212
4426
|
|
|
4213
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
|
+
|
|
4214
5122
|
def sync_static(outputs_path, static_files, vars_json, artifact_names=None):
|
|
4215
5123
|
outputs = json.loads(outputs_path.read_text(encoding="utf-8"))
|
|
4216
5124
|
artifact_names = set(artifact_names or [])
|
|
@@ -4508,8 +5416,13 @@ def main():
|
|
|
4508
5416
|
|
|
4509
5417
|
runner_secrets = secret_payload(payload)
|
|
4510
5418
|
web_only = is_web_only_operation(payload, action)
|
|
5419
|
+
identity_operation = identity_provider_operation(payload)
|
|
4511
5420
|
static_files = {}
|
|
4512
|
-
if
|
|
5421
|
+
if (
|
|
5422
|
+
action in {"deploy", "update", "web"}
|
|
5423
|
+
and not is_managed_app_operation(payload)
|
|
5424
|
+
and identity_operation is None
|
|
5425
|
+
):
|
|
4513
5426
|
static_files = sync_release_artifacts(
|
|
4514
5427
|
artifact_types={"static-site"} if web_only else None,
|
|
4515
5428
|
artifact_names={"web"} if web_only else None,
|
|
@@ -4551,6 +5464,11 @@ def main():
|
|
|
4551
5464
|
write_evidence("succeeded", vars_json, 0)
|
|
4552
5465
|
return 0
|
|
4553
5466
|
|
|
5467
|
+
if identity_operation is not None:
|
|
5468
|
+
CONTROLLER_EVIDENCE["identityProvider"] = reconcile_identity_provider_resource(
|
|
5469
|
+
payload, vars_json
|
|
5470
|
+
)
|
|
5471
|
+
|
|
4554
5472
|
configure_cloudflare_provider_auth(vars_json["stage"])
|
|
4555
5473
|
configure_terraform_provider_mirror()
|
|
4556
5474
|
run(["terraform", "init", "-backend-config=backend.hcl", "-no-color"], cwd=TF)
|
|
@@ -4565,7 +5483,10 @@ def main():
|
|
|
4565
5483
|
if selected.returncode != 0:
|
|
4566
5484
|
run(["terraform", "workspace", "new", workspace, "-no-color"], cwd=TF)
|
|
4567
5485
|
target_args = managed_app_terraform_target_args(payload)
|
|
4568
|
-
|
|
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":
|
|
4569
5490
|
plan = subprocess.run(
|
|
4570
5491
|
["terraform", "plan", "-destroy", *target_args, "-out=tfplan", "-no-color"],
|
|
4571
5492
|
cwd=TF,
|
|
@@ -4599,6 +5520,10 @@ def main():
|
|
|
4599
5520
|
|
|
4600
5521
|
outputs_path = TF / "outputs.json"
|
|
4601
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
|
|
4602
5527
|
write_outputs_after_apply(payload, vars_json, outputs_path)
|
|
4603
5528
|
if is_managed_app_operation(payload):
|
|
4604
5529
|
sync_twenty_thinkwork_app(
|
|
@@ -4608,7 +5533,21 @@ def main():
|
|
|
4608
5533
|
runner_secrets,
|
|
4609
5534
|
managed_app_artifacts,
|
|
4610
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)
|
|
4611
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.
|
|
4612
5551
|
push_database_schema(outputs_path, vars_json)
|
|
4613
5552
|
ensure_first_admin(outputs_path, vars_json, payload, runner_secrets)
|
|
4614
5553
|
write_outputs_to_ssm(outputs_path, vars_json)
|