diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8cc3be3..dbebfbd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,7 @@ jobs: lib/llm.py \ lib/post_extract.py \ ops/check_gcp_infra_readiness.py \ + ops/plan_gcp_iam_split.py \ ops/sqlite_to_postgres_dump.py \ telegram/approvals.py \ scripts/check_crabbox_ci_contract.py \ @@ -52,6 +53,8 @@ jobs: tests/test_decision_engine_replay.py \ tests/test_evaluate_agent_routing.py \ tests/test_gcp_cloudsql_restore_drill.py \ + tests/test_gcp_iam_split_plan.py \ + tests/test_gcp_readiness_workflow.py \ tests/test_phase1b_end_to_end.py \ tests/test_sqlite_to_postgres_dump.py \ tests/test_eval_parse.py \ diff --git a/.github/workflows/gcp-readiness.yml b/.github/workflows/gcp-readiness.yml index fbbc5ba..ac71aff 100644 --- a/.github/workflows/gcp-readiness.yml +++ b/.github/workflows/gcp-readiness.yml @@ -2,6 +2,11 @@ name: gcp-readiness on: workflow_dispatch: + inputs: + service_account: + description: GCP service account to impersonate for the read-only readiness probe + required: false + default: sa-artifact-builder@teleo-501523.iam.gserviceaccount.com permissions: contents: read @@ -14,7 +19,7 @@ concurrency: env: PROJECT_ID: teleo-501523 WORKLOAD_IDENTITY_PROVIDER: projects/785938879453/locations/global/workloadIdentityPools/github-actions/providers/living-ip-github - ARTIFACT_SERVICE_ACCOUNT: sa-artifact-builder@teleo-501523.iam.gserviceaccount.com + READINESS_SERVICE_ACCOUNT: ${{ inputs.service_account || 'sa-artifact-builder@teleo-501523.iam.gserviceaccount.com' }} READINESS_ARTIFACT_DIR: gcp-readiness-artifacts jobs: @@ -29,7 +34,7 @@ jobs: uses: google-github-actions/auth@v2 with: workload_identity_provider: ${{ env.WORKLOAD_IDENTITY_PROVIDER }} - service_account: ${{ env.ARTIFACT_SERVICE_ACCOUNT }} + service_account: ${{ env.READINESS_SERVICE_ACCOUNT }} - uses: google-github-actions/setup-gcloud@v2 @@ -40,7 +45,7 @@ jobs: mkdir -p "${READINESS_ARTIFACT_DIR}" { echo "project=${PROJECT_ID}" - echo "service_account=${ARTIFACT_SERVICE_ACCOUNT}" + echo "service_account=${READINESS_SERVICE_ACCOUNT}" echo "workflow_ref=${GITHUB_REF}" echo "revision=${GITHUB_SHA}" } > "${READINESS_ARTIFACT_DIR}/context.txt" diff --git a/docs/gcp-infra-hardening-20260707.md b/docs/gcp-infra-hardening-20260707.md index b313164..923d985 100644 --- a/docs/gcp-infra-hardening-20260707.md +++ b/docs/gcp-infra-hardening-20260707.md @@ -65,6 +65,16 @@ uploads stdout, stderr, exit code, and a summary as the `gcp-readiness` artifact It is intentionally non-mutating. If the Artifact Registry service account lacks broader read permissions, the artifact is still the proof of the exact IAM gap. +After applying the dedicated readiness service account from the IAM split plan, +run the same workflow against that account: + +```bash +gh workflow run gcp-readiness.yml \ + --repo living-ip/teleo-infrastructure \ + --ref main \ + -f service_account=sa-teleo-readiness@teleo-501523.iam.gserviceaccount.com +``` + For a local/manual Cloud Build proof: ```bash @@ -127,6 +137,39 @@ be converted and restored into PostgreSQL with table/row-count parity, but the same import still needs to run against the GCP Cloud SQL standby through an approved GCP auth and network path. +## IAM Split Plan + +Do not make `sa-artifact-builder@teleo-501523.iam.gserviceaccount.com` the broad +infra account. Keep it scoped to Docker image publishing. + +Use the generated plan for the next privilege boundary: + +```bash +python3 ops/plan_gcp_iam_split.py --format json +python3 ops/plan_gcp_iam_split.py --format shell +``` + +The plan creates two separate accounts: + +- `sa-teleo-readiness@teleo-501523.iam.gserviceaccount.com` + - GitHub WIF account for read-only readiness checks. + - Needs read-only roles for Artifact Registry, IAM/service-account/WIF + metadata, Compute/network posture, Cloud SQL metadata, backup buckets, and + Secret Manager metadata. +- `sa-teleo-restore-drill@teleo-501523.iam.gserviceaccount.com` + - Operator account for explicit Cloud SQL restore drills. + - Needs Cloud SQL edit rights for the import operation and object-admin access + to the restore bucket path. + +Cloud SQL imports also require the Cloud SQL instance service account to read the +GCS object. The plan includes a command that discovers that instance service +account and grants it `roles/storage.objectAdmin` on +`gs://teleo-501523-prod-backups`. + +This plan is not itself a completed redundancy proof. DB redundancy is complete +only after the restore drill imports source data into Cloud SQL and the retained +`target-counts.sql` readback matches the source/local restore proof. + ## Communication Posture Current GCP communication hardening is limited to the surfaces already read back: diff --git a/ops/check_gcp_infra_readiness.py b/ops/check_gcp_infra_readiness.py index 5c6df1f..986af6c 100755 --- a/ops/check_gcp_infra_readiness.py +++ b/ops/check_gcp_infra_readiness.py @@ -267,9 +267,10 @@ def check_github_actions_readiness_ci() -> Check: text = workflow.read_text() required = [ "workflow_dispatch", + "service_account:", "google-github-actions/auth@v2", "ops/check_gcp_infra_readiness.py", - GITHUB_ARTIFACT_SERVICE_ACCOUNT, + "READINESS_SERVICE_ACCOUNT", "READINESS_ARTIFACT_DIR: gcp-readiness-artifacts", "gcp-readiness-summary.json", "actions/upload-artifact@v4", diff --git a/ops/plan_gcp_iam_split.py b/ops/plan_gcp_iam_split.py new file mode 100644 index 0000000..c576540 --- /dev/null +++ b/ops/plan_gcp_iam_split.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +"""Emit the least-privilege GCP IAM split for Teleo CI/CD and DB drills. + +This script is intentionally dry-run by default. It does not read or print +secret values; it only emits the service accounts, roles, and commands needed +to separate Docker publishing from infra readiness and Cloud SQL restore drills. +""" + +from __future__ import annotations + +import argparse +import json +import shlex +from dataclasses import asdict, dataclass + +PROJECT = "teleo-501523" +PROJECT_NUMBER = "785938879453" +GITHUB_REPOSITORY = "living-ip/teleo-infrastructure" +WIF_POOL = "github-actions" +WIF_PROVIDER = "living-ip-github" +BACKUP_BUCKET = "teleo-501523-prod-backups" +CLOUDSQL_INSTANCE = "teleo-pgvector-standby" + +ARTIFACT_SERVICE_ACCOUNT = f"sa-artifact-builder@{PROJECT}.iam.gserviceaccount.com" +READINESS_SERVICE_ACCOUNT_ID = "sa-teleo-readiness" +READINESS_SERVICE_ACCOUNT = f"{READINESS_SERVICE_ACCOUNT_ID}@{PROJECT}.iam.gserviceaccount.com" +RESTORE_SERVICE_ACCOUNT_ID = "sa-teleo-restore-drill" +RESTORE_SERVICE_ACCOUNT = f"{RESTORE_SERVICE_ACCOUNT_ID}@{PROJECT}.iam.gserviceaccount.com" + +READINESS_PROJECT_ROLES = [ + "roles/artifactregistry.reader", + "roles/iam.serviceAccountViewer", + "roles/iam.workloadIdentityPoolViewer", + "roles/iam.securityReviewer", + "roles/compute.viewer", + "roles/cloudsql.viewer", + "roles/storage.viewer", + "roles/secretmanager.viewer", +] + +RESTORE_PROJECT_ROLES = [ + "roles/cloudsql.editor", +] + +RESTORE_BUCKET_ROLES = [ + "roles/storage.objectAdmin", +] + + +@dataclass(frozen=True) +class Binding: + scope: str + member: str + role: str + reason: str + + +def shell_join(parts: list[str]) -> str: + return " ".join(shlex.quote(part) for part in parts) + + +def github_wif_member() -> str: + return ( + f"principalSet://iam.googleapis.com/projects/{PROJECT_NUMBER}/locations/global/" + f"workloadIdentityPools/{WIF_POOL}/attribute.repository/{GITHUB_REPOSITORY}" + ) + + +def build_plan() -> dict[str, object]: + readiness_bindings = [ + Binding( + scope=f"projects/{PROJECT}", + member=f"serviceAccount:{READINESS_SERVICE_ACCOUNT}", + role=role, + reason="read-only GitHub readiness probe", + ) + for role in READINESS_PROJECT_ROLES + ] + restore_bindings = [ + Binding( + scope=f"projects/{PROJECT}", + member=f"serviceAccount:{RESTORE_SERVICE_ACCOUNT}", + role=role, + reason="operator-triggered Cloud SQL import operation", + ) + for role in RESTORE_PROJECT_ROLES + ] + restore_bucket_bindings = [ + Binding( + scope=f"gs://{BACKUP_BUCKET}", + member=f"serviceAccount:{RESTORE_SERVICE_ACCOUNT}", + role=role, + reason="upload and verify generated restore SQL in the backup bucket", + ) + for role in RESTORE_BUCKET_ROLES + ] + + commands = [ + shell_join( + [ + "gcloud", + "iam", + "service-accounts", + "create", + READINESS_SERVICE_ACCOUNT_ID, + "--project", + PROJECT, + "--display-name", + "Teleo read-only infra readiness", + ] + ), + shell_join( + [ + "gcloud", + "iam", + "service-accounts", + "add-iam-policy-binding", + READINESS_SERVICE_ACCOUNT, + "--project", + PROJECT, + "--member", + github_wif_member(), + "--role", + "roles/iam.workloadIdentityUser", + ] + ), + *[ + shell_join( + [ + "gcloud", + "projects", + "add-iam-policy-binding", + PROJECT, + "--member", + binding.member, + "--role", + binding.role, + ] + ) + for binding in readiness_bindings + ], + shell_join( + [ + "gcloud", + "iam", + "service-accounts", + "create", + RESTORE_SERVICE_ACCOUNT_ID, + "--project", + PROJECT, + "--display-name", + "Teleo Cloud SQL restore drill", + ] + ), + *[ + shell_join( + [ + "gcloud", + "projects", + "add-iam-policy-binding", + PROJECT, + "--member", + binding.member, + "--role", + binding.role, + ] + ) + for binding in restore_bindings + ], + *[ + shell_join( + [ + "gcloud", + "storage", + "buckets", + "add-iam-policy-binding", + binding.scope, + "--member", + binding.member, + "--role", + binding.role, + ] + ) + for binding in restore_bucket_bindings + ], + "CLOUDSQL_INSTANCE_SA=$(gcloud sql instances describe " + + shlex.quote(CLOUDSQL_INSTANCE) + + " --project " + + shlex.quote(PROJECT) + + " --format='value(serviceAccountEmailAddress)')", + "gcloud storage buckets add-iam-policy-binding " + + shlex.quote(f"gs://{BACKUP_BUCKET}") + + ' --member "serviceAccount:${CLOUDSQL_INSTANCE_SA}" --role roles/storage.objectAdmin', + shell_join( + [ + "gh", + "workflow", + "run", + "gcp-readiness.yml", + "--repo", + GITHUB_REPOSITORY, + "--ref", + "main", + "-f", + f"service_account={READINESS_SERVICE_ACCOUNT}", + ] + ), + ] + + return { + "artifact": "teleo_gcp_iam_split_plan", + "project": PROJECT, + "claim_ceiling": "dry_run_plan_only_until_applied_by_authenticated_gcp_admin", + "kept_narrow": { + "artifact_service_account": ARTIFACT_SERVICE_ACCOUNT, + "reason": "Docker image publishing only; do not add broad infra-read or restore roles here.", + }, + "readiness_service_account": READINESS_SERVICE_ACCOUNT, + "restore_service_account": RESTORE_SERVICE_ACCOUNT, + "github_wif_provider": f"projects/{PROJECT_NUMBER}/locations/global/workloadIdentityPools/{WIF_POOL}/providers/{WIF_PROVIDER}", + "github_wif_member": github_wif_member(), + "readiness_project_roles": READINESS_PROJECT_ROLES, + "restore_project_roles": RESTORE_PROJECT_ROLES, + "restore_bucket_roles": RESTORE_BUCKET_ROLES, + "cloudsql_instance_service_account_binding": { + "bucket": f"gs://{BACKUP_BUCKET}", + "role": "roles/storage.objectAdmin", + "reason": "Cloud SQL imports read the SQL object through the Cloud SQL instance service account.", + }, + "bindings": { + "readiness": [asdict(binding) for binding in readiness_bindings], + "restore_operator": [asdict(binding) for binding in restore_bindings], + "restore_bucket": [asdict(binding) for binding in restore_bucket_bindings], + }, + "commands": commands, + "post_apply_checks": [ + f"gh workflow run gcp-readiness.yml --repo {GITHUB_REPOSITORY} --ref main -f service_account={READINESS_SERVICE_ACCOUNT}", + "EXECUTE=1 ops/run_gcp_cloudsql_restore_drill.sh", + "retain target-counts.sql query readback before claiming GCP DB redundancy", + ], + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--format", choices=("json", "shell"), default="json") + args = parser.parse_args() + + plan = build_plan() + if args.format == "shell": + print("# Dry-run command plan. Review before executing with an authenticated GCP admin.") + for command in plan["commands"]: + print(command) + else: + print(json.dumps(plan, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_gcp_iam_split_plan.py b/tests/test_gcp_iam_split_plan.py new file mode 100644 index 0000000..8c43308 --- /dev/null +++ b/tests/test_gcp_iam_split_plan.py @@ -0,0 +1,39 @@ +import json +import subprocess +from pathlib import Path + + +def run_plan(*args: str) -> str: + return subprocess.check_output( + ["python3", "ops/plan_gcp_iam_split.py", *args], + cwd=Path(__file__).resolve().parents[1], + text=True, + ) + + +def test_gcp_iam_split_keeps_artifact_builder_narrow() -> None: + plan = json.loads(run_plan()) + + assert plan["kept_narrow"]["artifact_service_account"] == ( + "sa-artifact-builder@teleo-501523.iam.gserviceaccount.com" + ) + assert plan["readiness_service_account"] == "sa-teleo-readiness@teleo-501523.iam.gserviceaccount.com" + assert plan["restore_service_account"] == "sa-teleo-restore-drill@teleo-501523.iam.gserviceaccount.com" + assert "roles/artifactregistry.writer" not in plan["readiness_project_roles"] + assert "roles/cloudsql.editor" not in plan["readiness_project_roles"] + assert "roles/compute.viewer" in plan["readiness_project_roles"] + assert "roles/cloudsql.viewer" in plan["readiness_project_roles"] + assert "roles/secretmanager.viewer" in plan["readiness_project_roles"] + assert plan["restore_project_roles"] == ["roles/cloudsql.editor"] + assert plan["restore_bucket_roles"] == ["roles/storage.objectAdmin"] + + +def test_gcp_iam_split_shell_plan_has_post_apply_checks() -> None: + shell = run_plan("--format", "shell") + + assert "sa-teleo-readiness" in shell + assert "sa-teleo-restore-drill" in shell + assert "roles/iam.workloadIdentityUser" in shell + assert "CLOUDSQL_INSTANCE_SA=$(gcloud sql instances describe" in shell + assert '--member "serviceAccount:${CLOUDSQL_INSTANCE_SA}"' in shell + assert "gh workflow run gcp-readiness.yml" in shell diff --git a/tests/test_gcp_readiness_workflow.py b/tests/test_gcp_readiness_workflow.py index 68112cd..0454440 100644 --- a/tests/test_gcp_readiness_workflow.py +++ b/tests/test_gcp_readiness_workflow.py @@ -4,6 +4,8 @@ from pathlib import Path def test_gcp_readiness_workflow_uses_uploadable_artifact_dir() -> None: workflow = Path(".github/workflows/gcp-readiness.yml").read_text() + assert "service_account:" in workflow + assert "READINESS_SERVICE_ACCOUNT:" in workflow assert "READINESS_ARTIFACT_DIR: gcp-readiness-artifacts" in workflow assert 'mkdir -p "${READINESS_ARTIFACT_DIR}"' in workflow assert "path: ${{ env.READINESS_ARTIFACT_DIR }}/" in workflow