Fail closed on cross-provider WIF mappings
This commit is contained in:
parent
1a3c7fa9f4
commit
0296176355
4 changed files with 151 additions and 2 deletions
|
|
@ -87,7 +87,9 @@ they do not route production traffic.
|
||||||
The deployer binding uses the provider-unique
|
The deployer binding uses the provider-unique
|
||||||
`attribute.observatory_workflow` principal set. A pool-wide subject binding
|
`attribute.observatory_workflow` principal set. A pool-wide subject binding
|
||||||
or the general `living-ip-github` provider is diagnostic-only and must not
|
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
|
3. Add the dedicated runtime service account as a Cloud SQL IAM
|
||||||
service-account user on `teleo-pgvector-standby`.
|
service-account user on `teleo-pgvector-standby`.
|
||||||
4. From the existing private GCP VM database-admin path, run
|
4. From the existing private GCP VM database-admin path, run
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ PREFLIGHT_PERMISSIONS = {
|
||||||
"iam.serviceAccounts.getIamPolicy",
|
"iam.serviceAccounts.getIamPolicy",
|
||||||
"iam.roles.get",
|
"iam.roles.get",
|
||||||
"iam.workloadIdentityPoolProviders.get",
|
"iam.workloadIdentityPoolProviders.get",
|
||||||
|
"iam.workloadIdentityPoolProviders.list",
|
||||||
"resourcemanager.projects.get",
|
"resourcemanager.projects.get",
|
||||||
"resourcemanager.projects.getIamPolicy",
|
"resourcemanager.projects.getIamPolicy",
|
||||||
"run.operations.get",
|
"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:
|
def deployer_service_account_policy_is_exact(value: str) -> bool:
|
||||||
policy = json.loads(value)
|
policy = json.loads(value)
|
||||||
return role_has_exact_unconditional_members(
|
return role_has_exact_unconditional_members(
|
||||||
|
|
@ -482,6 +512,26 @@ def build_checks(image_ref: str) -> list[Check]:
|
||||||
wif_provider_is_exact,
|
wif_provider_is_exact,
|
||||||
"main_and_exact_workflow_only",
|
"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(
|
command_check(
|
||||||
"preflight_custom_role",
|
"preflight_custom_role",
|
||||||
[
|
[
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ PREFLIGHT_PERMISSIONS = (
|
||||||
"iam.serviceAccounts.getIamPolicy",
|
"iam.serviceAccounts.getIamPolicy",
|
||||||
"iam.roles.get",
|
"iam.roles.get",
|
||||||
"iam.workloadIdentityPoolProviders.get",
|
"iam.workloadIdentityPoolProviders.get",
|
||||||
|
"iam.workloadIdentityPoolProviders.list",
|
||||||
"resourcemanager.projects.get",
|
"resourcemanager.projects.get",
|
||||||
"resourcemanager.projects.getIamPolicy",
|
"resourcemanager.projects.getIamPolicy",
|
||||||
"run.operations.get",
|
"run.operations.get",
|
||||||
|
|
@ -448,6 +449,13 @@ def build_plan() -> dict[str, object]:
|
||||||
"fallback_provider_allowed": False,
|
"fallback_provider_allowed": False,
|
||||||
"required_condition": DEPLOY_WIF_ATTRIBUTE_CONDITION,
|
"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),
|
"required_apis": list(REQUIRED_APIS),
|
||||||
"preflight_custom_role": {
|
"preflight_custom_role": {
|
||||||
"name": PREFLIGHT_ROLE,
|
"name": PREFLIGHT_ROLE,
|
||||||
|
|
@ -482,7 +490,8 @@ def build_plan() -> dict[str, object]:
|
||||||
"post_apply_checks": [
|
"post_apply_checks": [
|
||||||
f"gcloud projects describe {PROJECT} --format=value(projectId)",
|
f"gcloud projects describe {PROJECT} --format=value(projectId)",
|
||||||
"python3 ops/check_gcp_infra_readiness.py",
|
"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 "
|
"gh workflow run gcp-observatory-read-adapter.yml --repo "
|
||||||
f"{GITHUB_REPOSITORY} --ref main -f action=preflight_staging"
|
f"{GITHUB_REPOSITORY} --ref main -f action=preflight_staging"
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,14 @@ def test_plan_splits_build_deploy_and_runtime_identities() -> None:
|
||||||
"fallback_provider_allowed": False,
|
"fallback_provider_allowed": False,
|
||||||
"required_condition": plan["conditions"]["deployer_wif"],
|
"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 "remove-iam-policy-binding" in commands
|
||||||
assert plan["legacy_pool_wide_github_wif_member_removed"] in commands
|
assert plan["legacy_pool_wide_github_wif_member_removed"] in commands
|
||||||
assert "roles/run.serviceAgent" 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 = {
|
provider = {
|
||||||
|
"name": (
|
||||||
|
"projects/785938879453/locations/global/workloadIdentityPools/github-actions/providers/"
|
||||||
|
"observatory-read-adapter-main"
|
||||||
|
),
|
||||||
"state": "ACTIVE",
|
"state": "ACTIVE",
|
||||||
"attributeCondition": preflight.DEPLOY_WIF_ATTRIBUTE_CONDITION,
|
"attributeCondition": preflight.DEPLOY_WIF_ATTRIBUTE_CONDITION,
|
||||||
"attributeMapping": {
|
"attributeMapping": {
|
||||||
|
|
@ -293,6 +305,24 @@ def test_preflight_end_to_end_snapshot_retains_no_secret(monkeypatch) -> None:
|
||||||
stdout = "ENABLED\n"
|
stdout = "ENABLED\n"
|
||||||
elif "workload-identity-pools providers describe" in joined:
|
elif "workload-identity-pools providers describe" in joined:
|
||||||
stdout = json.dumps(provider)
|
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:
|
elif "iam roles describe observatoryStagingPreflight" in joined:
|
||||||
stdout = json.dumps({"includedPermissions": sorted(preflight.PREFLIGHT_PERMISSIONS)})
|
stdout = json.dumps({"includedPermissions": sorted(preflight.PREFLIGHT_PERMISSIONS)})
|
||||||
elif "iam roles describe observatoryStagingDeployer" in joined:
|
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:
|
def test_dedicated_provider_requires_provider_unique_workflow_mapping() -> None:
|
||||||
provider = {
|
provider = {
|
||||||
|
"name": (
|
||||||
|
"projects/785938879453/locations/global/workloadIdentityPools/github-actions/providers/"
|
||||||
|
"observatory-read-adapter-main"
|
||||||
|
),
|
||||||
"state": "ACTIVE",
|
"state": "ACTIVE",
|
||||||
"attributeCondition": preflight.DEPLOY_WIF_ATTRIBUTE_CONDITION,
|
"attributeCondition": preflight.DEPLOY_WIF_ATTRIBUTE_CONDITION,
|
||||||
"attributeMapping": {
|
"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
|
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:
|
def test_malformed_successful_readback_becomes_blocked_check(monkeypatch) -> None:
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
preflight,
|
preflight,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue