Some checks are pending
CI / lint-and-test (push) Waiting to run
Merge the T2 readiness and least-privilege staging gate. This does not deploy Cloud Run, mutate Cloud SQL, or change production routing.
475 lines
17 KiB
Python
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())
|