teleo-infrastructure/ops/plan_gcp_iam_split.py
twentyOne2x 8f96b44539
Some checks are pending
CI / lint-and-test (push) Waiting to run
Add GCP IAM split plan
2026-07-07 12:54:53 +02:00

260 lines
8.2 KiB
Python

#!/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())