Add GCP IAM split plan
Some checks are pending
CI / lint-and-test (push) Waiting to run

This commit is contained in:
twentyOne2x 2026-07-07 12:54:53 +02:00 committed by GitHub
parent 04e586c23e
commit 8f96b44539
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 357 additions and 4 deletions

View file

@ -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 \

View file

@ -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"

View file

@ -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:

View file

@ -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",

260
ops/plan_gcp_iam_split.py Normal file
View file

@ -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())

View file

@ -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

View file

@ -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