teleo-infrastructure/ops/plan_observatory_read_adapter_gcp.py
twentyOne2x 36fe0d3cc2
Some checks are pending
CI / lint-and-test (push) Waiting to run
Harden Observatory private staging deploy gate (#163)
Merge the T2 readiness and least-privilege staging gate. This does not deploy Cloud Run, mutate Cloud SQL, or change production routing.
2026-07-15 09:55:49 +02:00

475 lines
17 KiB
Python

#!/usr/bin/env python3
"""Emit the unexecuted least-privilege GCP staging packet for Observatory."""
from __future__ import annotations
import argparse
import json
import shlex
PROJECT = "teleo-501523"
PROJECT_NUMBER = "785938879453"
REGION = "europe-west6"
GITHUB_REPOSITORY = "living-ip/teleo-infrastructure"
WIF_POOL = "github-actions"
DEPLOY_WIF_PROVIDER = "observatory-read-adapter-main"
DEPLOY_WORKFLOW_REF = (
f"{GITHUB_REPOSITORY}/.github/workflows/gcp-observatory-read-adapter.yml@refs/heads/main"
)
DEPLOY_WIF_ATTRIBUTE_MAPPING = (
"google.subject=assertion.sub,attribute.repository=assertion.repository,"
"attribute.ref=assertion.ref,attribute.workflow_ref=assertion.workflow_ref"
)
DEPLOY_WIF_ATTRIBUTE_CONDITION = (
"assertion.repository=='living-ip/teleo-infrastructure' && "
"assertion.ref=='refs/heads/main' && "
f"assertion.workflow_ref=='{DEPLOY_WORKFLOW_REF}'"
)
BUILD_SERVICE_ACCOUNT = f"sa-artifact-builder@{PROJECT}.iam.gserviceaccount.com"
DEPLOY_SERVICE_ACCOUNT_ID = "sa-observatory-deployer"
DEPLOY_SERVICE_ACCOUNT = f"{DEPLOY_SERVICE_ACCOUNT_ID}@{PROJECT}.iam.gserviceaccount.com"
RUNTIME_SERVICE_ACCOUNT_ID = "sa-observatory-read-adapter"
RUNTIME_SERVICE_ACCOUNT = f"{RUNTIME_SERVICE_ACCOUNT_ID}@{PROJECT}.iam.gserviceaccount.com"
DB_IAM_USER = f"{RUNTIME_SERVICE_ACCOUNT_ID}@{PROJECT}.iam"
SERVICE = "observatory-read-adapter-staging"
INSTANCE = "teleo-pgvector-standby"
NETWORK = "teleo-staging-net"
SUBNET = "teleo-staging-europe-west6"
SECRET = "observatory-read-api-key-staging"
REPOSITORY = "teleo"
PREFLIGHT_ROLE_ID = "observatoryStagingPreflight"
PREFLIGHT_ROLE = f"projects/{PROJECT}/roles/{PREFLIGHT_ROLE_ID}"
DEPLOY_ROLE_ID = "observatoryStagingDeployer"
DEPLOY_ROLE = f"projects/{PROJECT}/roles/{DEPLOY_ROLE_ID}"
PREFLIGHT_PERMISSIONS = (
"artifactregistry.repositories.getIamPolicy",
"cloudsql.instances.get",
"cloudsql.users.list",
"compute.networks.get",
"compute.subnetworks.get",
"iam.serviceAccounts.get",
"iam.serviceAccounts.getIamPolicy",
"iam.roles.get",
"iam.workloadIdentityPoolProviders.get",
"resourcemanager.projects.get",
"resourcemanager.projects.getIamPolicy",
"run.operations.get",
"run.services.get",
"run.services.getIamPolicy",
"secretmanager.secrets.getIamPolicy",
"serviceusage.services.get",
"serviceusage.services.use",
)
DEPLOY_PERMISSIONS = (
"run.operations.get",
"run.services.create",
"run.services.get",
"run.services.setIamPolicy",
"run.services.update",
)
REQUIRED_APIS = (
"artifactregistry.googleapis.com",
"compute.googleapis.com",
"iamcredentials.googleapis.com",
"run.googleapis.com",
"secretmanager.googleapis.com",
"sqladmin.googleapis.com",
"sts.googleapis.com",
)
FORBIDDEN_DEPLOYER_ROLES = (
"roles/artifactregistry.writer",
"roles/cloudsql.admin",
"roles/compute.admin",
"roles/editor",
"roles/owner",
"roles/run.admin",
"roles/run.developer",
"roles/secretmanager.admin",
)
def shell_join(parts: list[str]) -> str:
return " ".join(shlex.quote(part) for part in parts)
def github_wif_member() -> str:
return (
f"principal://iam.googleapis.com/projects/{PROJECT_NUMBER}/locations/global/"
f"workloadIdentityPools/{WIF_POOL}/subject/repo:{GITHUB_REPOSITORY}:ref:refs/heads/main"
)
def service_account_member(account: str) -> str:
return f"serviceAccount:{account}"
def project_binding(member: str, role: str, condition: str | None = None) -> str:
command = [
"gcloud",
"projects",
"add-iam-policy-binding",
PROJECT,
"--member",
member,
"--role",
role,
]
if condition:
command.extend(["--condition", condition])
return shell_join(command)
def custom_role_commands(
role_id: str,
title: str,
description: str,
permissions: tuple[str, ...],
) -> list[str]:
common = [
"--project",
PROJECT,
"--title",
title,
"--description",
description,
"--permissions",
",".join(permissions),
"--stage",
"GA",
]
return [
(
f"gcloud iam roles describe {shlex.quote(role_id)} --project {shlex.quote(PROJECT)} "
"|| "
+ shell_join(["gcloud", "iam", "roles", "create", role_id, *common])
),
shell_join(["gcloud", "iam", "roles", "update", role_id, *common]),
]
def build_plan() -> dict[str, object]:
deployer_member = service_account_member(DEPLOY_SERVICE_ACCOUNT)
runtime_member = service_account_member(RUNTIME_SERVICE_ACCOUNT)
cloud_sql_condition = (
"expression=resource.name == "
f"'projects/{PROJECT}/instances/{INSTANCE}' && resource.service == 'sqladmin.googleapis.com',"
"title=observatory-exact-cloudsql-instance"
)
create_accounts = [
(
DEPLOY_SERVICE_ACCOUNT,
DEPLOY_SERVICE_ACCOUNT_ID,
"Observatory staging deploy and canary",
),
(
RUNTIME_SERVICE_ACCOUNT,
RUNTIME_SERVICE_ACCOUNT_ID,
"Observatory private Cloud SQL reader",
),
]
commands = [
shell_join(["gcloud", "projects", "describe", PROJECT, "--format=value(projectId)"]),
shell_join(["gcloud", "services", "enable", *REQUIRED_APIS, "--project", PROJECT]),
project_binding(
f"serviceAccount:service-{PROJECT_NUMBER}@serverless-robot-prod.iam.gserviceaccount.com",
"roles/run.serviceAgent",
),
]
for email, account_id, display_name in create_accounts:
commands.append(
f"gcloud iam service-accounts describe {shlex.quote(email)} --project {shlex.quote(PROJECT)} "
f"|| {shell_join(['gcloud', 'iam', 'service-accounts', 'create', account_id, '--project', PROJECT, '--display-name', display_name])}"
)
commands.extend(
[
(
"gcloud iam workload-identity-pools providers describe "
+ shlex.quote(DEPLOY_WIF_PROVIDER)
+ " --project "
+ shlex.quote(PROJECT)
+ " --location global --workload-identity-pool "
+ shlex.quote(WIF_POOL)
+ " || "
+ shell_join(
[
"gcloud",
"iam",
"workload-identity-pools",
"providers",
"create-oidc",
DEPLOY_WIF_PROVIDER,
"--project",
PROJECT,
"--location",
"global",
"--workload-identity-pool",
WIF_POOL,
"--issuer-uri",
"https://token.actions.githubusercontent.com/",
"--attribute-mapping",
DEPLOY_WIF_ATTRIBUTE_MAPPING,
"--attribute-condition",
DEPLOY_WIF_ATTRIBUTE_CONDITION,
]
)
),
shell_join(
[
"gcloud",
"iam",
"workload-identity-pools",
"providers",
"update-oidc",
DEPLOY_WIF_PROVIDER,
"--project",
PROJECT,
"--location",
"global",
"--workload-identity-pool",
WIF_POOL,
"--attribute-mapping",
DEPLOY_WIF_ATTRIBUTE_MAPPING,
"--attribute-condition",
DEPLOY_WIF_ATTRIBUTE_CONDITION,
]
),
shell_join(
[
"gcloud",
"iam",
"service-accounts",
"add-iam-policy-binding",
DEPLOY_SERVICE_ACCOUNT,
"--project",
PROJECT,
"--member",
github_wif_member(),
"--role",
"roles/iam.workloadIdentityUser",
]
),
]
)
commands.extend(
custom_role_commands(
PREFLIGHT_ROLE_ID,
"Observatory staging preflight",
"Read only the exact resource classes required before the private staging deploy",
PREFLIGHT_PERMISSIONS,
)
)
commands.extend(
custom_role_commands(
DEPLOY_ROLE_ID,
"Observatory staging deployer",
"Create or update Cloud Run without delete or broad infrastructure permissions",
DEPLOY_PERMISSIONS,
)
)
commands.extend(
[
project_binding(deployer_member, PREFLIGHT_ROLE),
project_binding(deployer_member, DEPLOY_ROLE),
shell_join(
[
"gcloud",
"artifacts",
"repositories",
"add-iam-policy-binding",
REPOSITORY,
"--project",
PROJECT,
"--location",
REGION,
"--member",
deployer_member,
"--role",
"roles/artifactregistry.reader",
]
),
shell_join(
[
"gcloud",
"iam",
"service-accounts",
"add-iam-policy-binding",
RUNTIME_SERVICE_ACCOUNT,
"--project",
PROJECT,
"--member",
deployer_member,
"--role",
"roles/iam.serviceAccountUser",
]
),
project_binding(runtime_member, "roles/cloudsql.client", cloud_sql_condition),
project_binding(runtime_member, "roles/cloudsql.instanceUser", cloud_sql_condition),
f"gcloud secrets describe {shlex.quote(SECRET)} --project {shlex.quote(PROJECT)} "
f"|| {shell_join(['gcloud', 'secrets', 'create', SECRET, '--project', PROJECT, '--replication-policy', 'automatic'])}",
]
)
for member in (runtime_member, deployer_member):
commands.append(
shell_join(
[
"gcloud",
"secrets",
"add-iam-policy-binding",
SECRET,
"--project",
PROJECT,
"--member",
member,
"--role",
"roles/secretmanager.secretAccessor",
]
)
)
commands.extend(
[
shell_join(
[
"gcloud",
"secrets",
"add-iam-policy-binding",
SECRET,
"--project",
PROJECT,
"--member",
deployer_member,
"--role",
"roles/secretmanager.viewer",
]
),
(
"if ! gcloud secrets versions list "
+ shlex.quote(SECRET)
+ " --project "
+ shlex.quote(PROJECT)
+ " --filter='state=ENABLED' --limit=1 --format='value(name)' | grep -q .; then "
+ "openssl rand -hex 32 | tr -d '\\n' | gcloud secrets versions add "
+ shlex.quote(SECRET)
+ " --project "
+ shlex.quote(PROJECT)
+ " --data-file=-; fi"
),
(
"if ! gcloud sql users list --instance "
+ shlex.quote(INSTANCE)
+ " --project "
+ shlex.quote(PROJECT)
+ " --filter='type=CLOUD_IAM_SERVICE_ACCOUNT' --format='value(name)' | grep -Fxq "
+ shlex.quote(DB_IAM_USER)
+ "; then "
+ shell_join(
[
"gcloud",
"sql",
"users",
"create",
DB_IAM_USER,
"--instance",
INSTANCE,
"--project",
PROJECT,
"--type",
"CLOUD_IAM_SERVICE_ACCOUNT",
]
)
+ "; fi"
),
]
)
return {
"schema": "livingip.observatory-read-adapter-gcp-plan.v1",
"project": PROJECT,
"required_tier": "T3_live_readonly",
"current_tier": "T2_unexecuted_least_privilege_packet",
"claim_ceiling": "Plan only; no IAM, API, secret, Cloud SQL, Cloud Run, or routing mutation has run.",
"identities": {
"build": BUILD_SERVICE_ACCOUNT,
"deploy_and_canary": DEPLOY_SERVICE_ACCOUNT,
"runtime": RUNTIME_SERVICE_ACCOUNT,
"database_principal": DB_IAM_USER,
},
"kept_narrow": {
"artifact_builder": "image build and push only; never deploy or receive broad infra roles",
"deployer": "workflow-specific main-only WIF plus a five-permission Cloud Run role, repository read, exact runtime actAs, exact secret access",
"runtime": "exact Cloud SQL instance connect/login plus exact secret access",
},
"github_wif_provider": (
f"projects/{PROJECT_NUMBER}/locations/global/workloadIdentityPools/{WIF_POOL}/providers/{DEPLOY_WIF_PROVIDER}"
),
"github_wif_member": github_wif_member(),
"required_apis": list(REQUIRED_APIS),
"preflight_custom_role": {
"name": PREFLIGHT_ROLE,
"permissions": list(PREFLIGHT_PERMISSIONS),
},
"deploy_custom_role": {
"name": DEPLOY_ROLE,
"permissions": list(DEPLOY_PERMISSIONS),
},
"conditions": {
"cloud_sql": cloud_sql_condition,
"deployer_wif": DEPLOY_WIF_ATTRIBUTE_CONDITION,
},
"commands": commands,
"private_database_action": {
"location": "existing authenticated private GCP VM database-admin path",
"command": (
f"OBSERVATORY_DB_IAM_USER={DB_IAM_USER} psql --dbname=teleo_canonical "
"--set=ON_ERROR_STOP=1 --file=ops/observatory_read_role.sql"
),
"required_receipt": (
"database=teleo_canonical, authorization_role=kb_observatory_read, exact database principal, "
"required_reads=true, effective_table_writes_denied=true"
),
},
"cloud_run_service_agent_check": {
"principal": f"service-{PROJECT_NUMBER}@serverless-robot-prod.iam.gserviceaccount.com",
"required_role": "roles/run.serviceAgent",
"reason": "Default Cloud Run service-agent role carries Direct VPC permissions; do not grant them to the runtime identity.",
"empty_readback_action": "Stop. Repair the Google-managed service-agent grant as an administrator before preflight.",
},
"post_apply_checks": [
f"gcloud projects describe {PROJECT} --format=value(projectId)",
"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.",
(
"gh workflow run gcp-observatory-read-adapter.yml --repo "
f"{GITHUB_REPOSITORY} --ref main -f action=preflight_staging"
),
"Require a passing observatory-read-adapter-preflight artifact before action=deploy_staging.",
],
"forbidden_deployer_roles": list(FORBIDDEN_DEPLOYER_ROLES),
"forbidden_actions": [
"grant deploy or infra roles to sa-artifact-builder",
"reuse a pre-existing image tag instead of building the current workflow revision",
"add a public Cloud SQL address",
"expose a secret value in Git, logs, or a receipt",
"add a write route",
"change Vercel or production routing",
],
"production_repoint_executed": False,
}
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("# Unexecuted staging packet. Review and run only as an authenticated teleo-501523 administrator.")
print("set -euo pipefail")
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())