From 029617635595d836b6aabe0128ec8aa37c20f360 Mon Sep 17 00:00:00 2001 From: twentyOne2x Date: Wed, 15 Jul 2026 22:45:24 +0200 Subject: [PATCH] Fail closed on cross-provider WIF mappings --- docs/observatory-read-adapter-gcp-staging.md | 4 +- ..._observatory_read_adapter_gcp_preflight.py | 50 +++++++++++ ops/plan_observatory_read_adapter_gcp.py | 11 ++- .../test_observatory_read_adapter_gcp_plan.py | 88 +++++++++++++++++++ 4 files changed, 151 insertions(+), 2 deletions(-) diff --git a/docs/observatory-read-adapter-gcp-staging.md b/docs/observatory-read-adapter-gcp-staging.md index b4ace9a..3eba8f7 100644 --- a/docs/observatory-read-adapter-gcp-staging.md +++ b/docs/observatory-read-adapter-gcp-staging.md @@ -87,7 +87,9 @@ they do not route production traffic. The deployer binding uses the provider-unique `attribute.observatory_workflow` principal set. A pool-wide subject binding or the general `living-ip-github` provider is diagnostic-only and must not - authenticate the preflight or deploy jobs. + authenticate the preflight or deploy jobs. The preflight lists every + provider in `github-actions`, including soft-deleted providers, and blocks + if any non-dedicated provider maps that attribute. 3. Add the dedicated runtime service account as a Cloud SQL IAM service-account user on `teleo-pgvector-standby`. 4. From the existing private GCP VM database-admin path, run diff --git a/ops/check_observatory_read_adapter_gcp_preflight.py b/ops/check_observatory_read_adapter_gcp_preflight.py index 0aeb39b..6711bf8 100644 --- a/ops/check_observatory_read_adapter_gcp_preflight.py +++ b/ops/check_observatory_read_adapter_gcp_preflight.py @@ -54,6 +54,7 @@ PREFLIGHT_PERMISSIONS = { "iam.serviceAccounts.getIamPolicy", "iam.roles.get", "iam.workloadIdentityPoolProviders.get", + "iam.workloadIdentityPoolProviders.list", "resourcemanager.projects.get", "resourcemanager.projects.getIamPolicy", "run.operations.get", @@ -307,6 +308,35 @@ def wif_provider_is_exact(value: str) -> bool: ) +def wif_provider_inventory_is_isolated(value: str) -> bool: + providers = json.loads(value) + if not isinstance(providers, list) or not providers: + return False + + dedicated: list[dict[str, object]] = [] + provider_names: set[str] = set() + for provider in providers: + if not isinstance(provider, dict): + return False + name = provider.get("name") + if not isinstance(name, str) or not name: + return False + provider_id = name.rsplit("/", 1)[-1] + if provider_id in provider_names: + return False + provider_names.add(provider_id) + + mapping = provider.get("attributeMapping", {}) + if not isinstance(mapping, dict): + return False + if provider_id == DEPLOY_WIF_PROVIDER: + dedicated.append(provider) + elif "attribute.observatory_workflow" in mapping: + return False + + return len(dedicated) == 1 and wif_provider_is_exact(json.dumps(dedicated[0])) + + def deployer_service_account_policy_is_exact(value: str) -> bool: policy = json.loads(value) return role_has_exact_unconditional_members( @@ -482,6 +512,26 @@ def build_checks(image_ref: str) -> list[Check]: wif_provider_is_exact, "main_and_exact_workflow_only", ), + command_check( + "wif_pool_provider_attribute_isolation", + [ + "gcloud", + "iam", + "workload-identity-pools", + "providers", + "list", + "--project", + PROJECT, + "--location", + "global", + "--workload-identity-pool", + WIF_POOL, + "--show-deleted", + "--format=json", + ], + wif_provider_inventory_is_isolated, + "dedicated_provider_is_sole_observatory_workflow_mapper", + ), command_check( "preflight_custom_role", [ diff --git a/ops/plan_observatory_read_adapter_gcp.py b/ops/plan_observatory_read_adapter_gcp.py index e13183d..c24172f 100644 --- a/ops/plan_observatory_read_adapter_gcp.py +++ b/ops/plan_observatory_read_adapter_gcp.py @@ -55,6 +55,7 @@ PREFLIGHT_PERMISSIONS = ( "iam.serviceAccounts.getIamPolicy", "iam.roles.get", "iam.workloadIdentityPoolProviders.get", + "iam.workloadIdentityPoolProviders.list", "resourcemanager.projects.get", "resourcemanager.projects.getIamPolicy", "run.operations.get", @@ -448,6 +449,13 @@ def build_plan() -> dict[str, object]: "fallback_provider_allowed": False, "required_condition": DEPLOY_WIF_ATTRIBUTE_CONDITION, }, + "provider_isolation": { + "pool": WIF_POOL, + "dedicated_provider": DEPLOY_WIF_PROVIDER, + "exclusive_attribute": "attribute.observatory_workflow", + "all_providers_must_be_enumerated": True, + "non_dedicated_provider_mapping_allowed": False, + }, "required_apis": list(REQUIRED_APIS), "preflight_custom_role": { "name": PREFLIGHT_ROLE, @@ -482,7 +490,8 @@ def build_plan() -> dict[str, object]: "post_apply_checks": [ f"gcloud projects describe {PROJECT} --format=value(projectId)", "python3 ops/check_gcp_infra_readiness.py", - "Require the adapter preflight to verify the dedicated WIF provider, custom roles, IAM bindings, private Cloud SQL, secret, and immutable image.", + "Require the adapter preflight to enumerate every provider in the pool and prove only the dedicated provider maps attribute.observatory_workflow.", + "Require the adapter preflight to verify custom roles, IAM bindings, private Cloud SQL, secret, and immutable image.", ( "gh workflow run gcp-observatory-read-adapter.yml --repo " f"{GITHUB_REPOSITORY} --ref main -f action=preflight_staging" diff --git a/tests/test_observatory_read_adapter_gcp_plan.py b/tests/test_observatory_read_adapter_gcp_plan.py index 94f4c73..4294964 100644 --- a/tests/test_observatory_read_adapter_gcp_plan.py +++ b/tests/test_observatory_read_adapter_gcp_plan.py @@ -54,6 +54,14 @@ def test_plan_splits_build_deploy_and_runtime_identities() -> None: "fallback_provider_allowed": False, "required_condition": plan["conditions"]["deployer_wif"], } + assert plan["provider_isolation"] == { + "pool": "github-actions", + "dedicated_provider": "observatory-read-adapter-main", + "exclusive_attribute": "attribute.observatory_workflow", + "all_providers_must_be_enumerated": True, + "non_dedicated_provider_mapping_allowed": False, + } + assert "iam.workloadIdentityPoolProviders.list" in plan["preflight_custom_role"]["permissions"] assert "remove-iam-policy-binding" in commands assert plan["legacy_pool_wide_github_wif_member_removed"] in commands assert "roles/run.serviceAgent" in commands @@ -269,6 +277,10 @@ def test_preflight_end_to_end_snapshot_retains_no_secret(monkeypatch) -> None: ] } provider = { + "name": ( + "projects/785938879453/locations/global/workloadIdentityPools/github-actions/providers/" + "observatory-read-adapter-main" + ), "state": "ACTIVE", "attributeCondition": preflight.DEPLOY_WIF_ATTRIBUTE_CONDITION, "attributeMapping": { @@ -293,6 +305,24 @@ def test_preflight_end_to_end_snapshot_retains_no_secret(monkeypatch) -> None: stdout = "ENABLED\n" elif "workload-identity-pools providers describe" in joined: stdout = json.dumps(provider) + elif "workload-identity-pools providers list" in joined: + stdout = json.dumps( + [ + provider, + { + "name": ( + "projects/785938879453/locations/global/workloadIdentityPools/" + "github-actions/providers/living-ip-github" + ), + "state": "ACTIVE", + "attributeMapping": { + "google.subject": "assertion.sub", + "attribute.repository": "assertion.repository", + "attribute.ref": "assertion.ref", + }, + }, + ] + ) elif "iam roles describe observatoryStagingPreflight" in joined: stdout = json.dumps({"includedPermissions": sorted(preflight.PREFLIGHT_PERMISSIONS)}) elif "iam roles describe observatoryStagingDeployer" in joined: @@ -375,6 +405,10 @@ def test_deployer_wif_binding_rejects_pool_wide_or_additional_impersonators() -> def test_dedicated_provider_requires_provider_unique_workflow_mapping() -> None: provider = { + "name": ( + "projects/785938879453/locations/global/workloadIdentityPools/github-actions/providers/" + "observatory-read-adapter-main" + ), "state": "ACTIVE", "attributeCondition": preflight.DEPLOY_WIF_ATTRIBUTE_CONDITION, "attributeMapping": { @@ -389,6 +423,60 @@ def test_dedicated_provider_requires_provider_unique_workflow_mapping() -> None: assert preflight.wif_provider_is_exact(json.dumps(provider)) is True +@pytest.mark.parametrize( + "malicious_mapping", + [ + "assertion.workflow_ref", + "'living-ip/teleo-infrastructure/.github/workflows/" + "gcp-observatory-read-adapter.yml@refs/heads/main'", + ], +) +def test_provider_inventory_rejects_any_second_observatory_mapper(malicious_mapping: str) -> None: + dedicated = { + "name": ( + "projects/785938879453/locations/global/workloadIdentityPools/github-actions/providers/" + "observatory-read-adapter-main" + ), + "state": "ACTIVE", + "attributeCondition": preflight.DEPLOY_WIF_ATTRIBUTE_CONDITION, + "attributeMapping": { + "google.subject": "assertion.sub", + "attribute.observatory_workflow": "assertion.workflow_ref", + "attribute.repository": "assertion.repository", + "attribute.ref": "assertion.ref", + "attribute.workflow_ref": "assertion.workflow_ref", + }, + } + general = { + "name": ( + "projects/785938879453/locations/global/workloadIdentityPools/github-actions/providers/" + "living-ip-github" + ), + "state": "ACTIVE", + "attributeMapping": { + "google.subject": "assertion.sub", + "attribute.repository": "assertion.repository", + }, + } + assert preflight.wif_provider_inventory_is_isolated(json.dumps([dedicated, general])) is True + + general["attributeMapping"]["attribute.observatory_workflow"] = malicious_mapping + assert preflight.wif_provider_inventory_is_isolated(json.dumps([dedicated, general])) is False + + +def test_provider_inventory_fails_closed_on_missing_or_duplicate_dedicated_provider() -> None: + general = { + "name": ( + "projects/785938879453/locations/global/workloadIdentityPools/github-actions/providers/" + "living-ip-github" + ), + "state": "ACTIVE", + "attributeMapping": {"google.subject": "assertion.sub"}, + } + assert preflight.wif_provider_inventory_is_isolated(json.dumps([general])) is False + assert preflight.wif_provider_inventory_is_isolated(json.dumps([general, general])) is False + + def test_malformed_successful_readback_becomes_blocked_check(monkeypatch) -> None: monkeypatch.setattr( preflight,