318 lines
9.3 KiB
Python
318 lines
9.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Apply the Teleo GCP IAM split with retained proof.
|
|
|
|
Default mode is dry-run. Execute mode is intentionally idempotent where GCP
|
|
supports it: existing service accounts are skipped, IAM bindings are added with
|
|
Google's policy-binding commands, and every command result is retained without
|
|
printing secret values.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import subprocess
|
|
from dataclasses import asdict, dataclass
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
|
|
from plan_gcp_iam_split import (
|
|
BACKUP_BUCKET,
|
|
CLOUDSQL_INSTANCE,
|
|
PROJECT,
|
|
READINESS_PROJECT_ROLES,
|
|
READINESS_SERVICE_ACCOUNT,
|
|
READINESS_SERVICE_ACCOUNT_ID,
|
|
RESTORE_BUCKET_ROLES,
|
|
RESTORE_PROJECT_ROLES,
|
|
RESTORE_SERVICE_ACCOUNT,
|
|
RESTORE_SERVICE_ACCOUNT_ID,
|
|
build_plan,
|
|
github_wif_member,
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class OperationResult:
|
|
name: str
|
|
command: list[str]
|
|
action: str
|
|
exitcode: int | None = None
|
|
stdout_tail: str = ""
|
|
stderr_tail: str = ""
|
|
|
|
|
|
def tail(value: str, limit: int = 2000) -> str:
|
|
return value[-limit:]
|
|
|
|
|
|
def run_command(command: list[str], *, execute: bool, name: str, action: str) -> OperationResult:
|
|
if not execute:
|
|
return OperationResult(name=name, command=command, action=action)
|
|
completed = subprocess.run(command, text=True, capture_output=True, check=False)
|
|
return OperationResult(
|
|
name=name,
|
|
command=command,
|
|
action=action,
|
|
exitcode=completed.returncode,
|
|
stdout_tail=tail(completed.stdout),
|
|
stderr_tail=tail(completed.stderr),
|
|
)
|
|
|
|
|
|
def service_account_exists(email: str) -> OperationResult:
|
|
return run_command(
|
|
[
|
|
"gcloud",
|
|
"iam",
|
|
"service-accounts",
|
|
"describe",
|
|
email,
|
|
"--project",
|
|
PROJECT,
|
|
"--format=json",
|
|
],
|
|
execute=True,
|
|
name=f"describe_service_account:{email}",
|
|
action="probe",
|
|
)
|
|
|
|
|
|
def create_service_account(account_id: str, email: str, display_name: str, *, execute: bool) -> list[OperationResult]:
|
|
if execute:
|
|
probe = service_account_exists(email)
|
|
if probe.exitcode == 0:
|
|
probe.action = "already_exists"
|
|
return [probe]
|
|
if "Reauthentication failed" in probe.stderr_tail:
|
|
return [probe]
|
|
return [
|
|
run_command(
|
|
[
|
|
"gcloud",
|
|
"iam",
|
|
"service-accounts",
|
|
"create",
|
|
account_id,
|
|
"--project",
|
|
PROJECT,
|
|
"--display-name",
|
|
display_name,
|
|
],
|
|
execute=execute,
|
|
name=f"create_service_account:{email}",
|
|
action="create_if_missing",
|
|
)
|
|
]
|
|
|
|
|
|
def add_service_account_iam_binding(
|
|
email: str,
|
|
member: str,
|
|
role: str,
|
|
*,
|
|
execute: bool,
|
|
name: str,
|
|
) -> OperationResult:
|
|
return run_command(
|
|
[
|
|
"gcloud",
|
|
"iam",
|
|
"service-accounts",
|
|
"add-iam-policy-binding",
|
|
email,
|
|
"--project",
|
|
PROJECT,
|
|
"--member",
|
|
member,
|
|
"--role",
|
|
role,
|
|
"--quiet",
|
|
],
|
|
execute=execute,
|
|
name=name,
|
|
action="ensure_binding",
|
|
)
|
|
|
|
|
|
def add_project_binding(email: str, role: str, *, execute: bool, prefix: str) -> OperationResult:
|
|
return run_command(
|
|
[
|
|
"gcloud",
|
|
"projects",
|
|
"add-iam-policy-binding",
|
|
PROJECT,
|
|
"--member",
|
|
f"serviceAccount:{email}",
|
|
"--role",
|
|
role,
|
|
"--quiet",
|
|
],
|
|
execute=execute,
|
|
name=f"{prefix}:{role}",
|
|
action="ensure_binding",
|
|
)
|
|
|
|
|
|
def add_bucket_binding(member: str, role: str, *, execute: bool, prefix: str) -> OperationResult:
|
|
return run_command(
|
|
[
|
|
"gcloud",
|
|
"storage",
|
|
"buckets",
|
|
"add-iam-policy-binding",
|
|
f"gs://{BACKUP_BUCKET}",
|
|
"--member",
|
|
member,
|
|
"--role",
|
|
role,
|
|
"--quiet",
|
|
],
|
|
execute=execute,
|
|
name=f"{prefix}:{role}",
|
|
action="ensure_binding",
|
|
)
|
|
|
|
|
|
def read_cloudsql_instance_service_account(*, execute: bool) -> tuple[str | None, OperationResult]:
|
|
command = [
|
|
"gcloud",
|
|
"sql",
|
|
"instances",
|
|
"describe",
|
|
CLOUDSQL_INSTANCE,
|
|
"--project",
|
|
PROJECT,
|
|
"--format=value(serviceAccountEmailAddress)",
|
|
]
|
|
result = run_command(
|
|
command,
|
|
execute=execute,
|
|
name="describe_cloudsql_instance_service_account",
|
|
action="probe",
|
|
)
|
|
if not execute or result.exitcode != 0:
|
|
return None, result
|
|
value = result.stdout_tail.strip()
|
|
return value or None, result
|
|
|
|
|
|
def build_operations(*, execute: bool) -> list[OperationResult]:
|
|
operations: list[OperationResult] = []
|
|
operations.extend(
|
|
create_service_account(
|
|
READINESS_SERVICE_ACCOUNT_ID,
|
|
READINESS_SERVICE_ACCOUNT,
|
|
"Teleo read-only infra readiness",
|
|
execute=execute,
|
|
)
|
|
)
|
|
if operations[-1].exitcode not in (None, 0):
|
|
return operations
|
|
|
|
operations.append(
|
|
add_service_account_iam_binding(
|
|
READINESS_SERVICE_ACCOUNT,
|
|
github_wif_member(),
|
|
"roles/iam.workloadIdentityUser",
|
|
execute=execute,
|
|
name="bind_github_wif_to_readiness_service_account",
|
|
)
|
|
)
|
|
if operations[-1].exitcode not in (None, 0):
|
|
return operations
|
|
|
|
for role in READINESS_PROJECT_ROLES:
|
|
operations.append(add_project_binding(READINESS_SERVICE_ACCOUNT, role, execute=execute, prefix="readiness_project_role"))
|
|
if operations[-1].exitcode not in (None, 0):
|
|
return operations
|
|
|
|
operations.extend(
|
|
create_service_account(
|
|
RESTORE_SERVICE_ACCOUNT_ID,
|
|
RESTORE_SERVICE_ACCOUNT,
|
|
"Teleo Cloud SQL restore drill",
|
|
execute=execute,
|
|
)
|
|
)
|
|
if operations[-1].exitcode not in (None, 0):
|
|
return operations
|
|
|
|
for role in RESTORE_PROJECT_ROLES:
|
|
operations.append(add_project_binding(RESTORE_SERVICE_ACCOUNT, role, execute=execute, prefix="restore_project_role"))
|
|
if operations[-1].exitcode not in (None, 0):
|
|
return operations
|
|
|
|
for role in RESTORE_BUCKET_ROLES:
|
|
operations.append(
|
|
add_bucket_binding(f"serviceAccount:{RESTORE_SERVICE_ACCOUNT}", role, execute=execute, prefix="restore_bucket_role")
|
|
)
|
|
if operations[-1].exitcode not in (None, 0):
|
|
return operations
|
|
|
|
instance_service_account, probe = read_cloudsql_instance_service_account(execute=execute)
|
|
operations.append(probe)
|
|
if probe.exitcode not in (None, 0):
|
|
return operations
|
|
|
|
if execute and not instance_service_account:
|
|
operations.append(
|
|
OperationResult(
|
|
name="cloudsql_instance_service_account_missing",
|
|
command=[],
|
|
action="fail",
|
|
exitcode=1,
|
|
stderr_tail=f"Cloud SQL instance {CLOUDSQL_INSTANCE} did not return serviceAccountEmailAddress",
|
|
)
|
|
)
|
|
return operations
|
|
|
|
cloudsql_member = (
|
|
f"serviceAccount:{instance_service_account}"
|
|
if instance_service_account
|
|
else f"serviceAccount:$(gcloud sql instances describe {CLOUDSQL_INSTANCE} --project {PROJECT} --format=value(serviceAccountEmailAddress))"
|
|
)
|
|
operations.append(
|
|
add_bucket_binding(cloudsql_member, "roles/storage.objectAdmin", execute=execute, prefix="cloudsql_instance_bucket_role")
|
|
)
|
|
return operations
|
|
|
|
|
|
def write_output(path: Path, payload: dict[str, object]) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--execute", action="store_true", help="apply IAM bindings; default is dry-run")
|
|
parser.add_argument(
|
|
"--output",
|
|
default="outputs/gcp-infra-hardening-20260707/proofs/gcp-iam-split-apply.json",
|
|
help="proof JSON path",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
operations = build_operations(execute=args.execute)
|
|
failed = [operation for operation in operations if operation.exitcode not in (None, 0)]
|
|
payload = {
|
|
"artifact": "teleo_gcp_iam_split_apply",
|
|
"generated_at_utc": datetime.now(UTC).isoformat(),
|
|
"mode": "execute" if args.execute else "dry_run",
|
|
"project": PROJECT,
|
|
"plan": build_plan(),
|
|
"operations": [asdict(operation) for operation in operations],
|
|
"status": "failed" if failed else ("applied" if args.execute else "planned"),
|
|
"failed_operation_count": len(failed),
|
|
"next_checks": [
|
|
f"gh workflow run gcp-readiness.yml --repo living-ip/teleo-infrastructure --ref main -f service_account={READINESS_SERVICE_ACCOUNT}",
|
|
"EXECUTE=1 ops/run_gcp_cloudsql_restore_drill.sh",
|
|
],
|
|
}
|
|
write_output(Path(args.output), payload)
|
|
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
return 1 if failed else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|