Playbook: Programmatic Google Cloud Management (p24-infra-admin SA)
Goal: let a p24-infra admin agent manage Google Cloud resources programmatically — service accounts, API keys, IAM roles, API enablement, and OAuth redirect URIs — without manual GCP Console work. Backed by a single dedicated service account,
p24-infra-admin, whose JSON key lives encrypted in SOPS.Tracking issue: radieu/p24-infra#2002 Related:
docs/secrets-management.md,docs/playbooks/dev-r-services-insert.md,docs/playbooks/credential-rotation-180d.md
Security — read first
- Never print the SA JSON key, any generated API key, or any SA key file content in chat,
commits, or logs. Reference key NAMES only (
GCP_SA_KEY_JSON). - The SA key is a highly privileged credential — it grants IAM admin, key admin, and project
IAM admin across the target project(s). Treat a leak as a P1 incident
(
docs/playbooks/static-api-key-incident-rotation.md). - All generated
/tmp/*.jsonkey files MUST beshred -u’d immediately after use — never left on disk, never committed. - Secrets live in SOPS+age only. On workers the age key is
/home/claude-runner/.age/p24-infra-keys.txt(SOPS_AGE_KEY_FILE).
Deployment target — none (developer/admin-only) — #2944
GCP_SA_KEY_JSON has no server or Vercel deployment target. It is consumed on-demand by
Claude admin agents, which decrypt it straight from SOPS into a temp file for
gcloud auth activate-service-account (§2) and shred -u it immediately after use. No deployed
service, container, or Vercel app reads it, so it is excluded from secrets-sync.yml
(!secrets/gcp.env.sops in the push paths-ignore list — do not remove) and is documented as
developer-only in .sops.yaml. Treat it like administration.env.sops / bms-servers.env.sops:
never synced, always read fresh from SOPS.
Storage note: the key currently lives in the standalone
secrets/gcp.env.sopsfile (notsecrets/administration.env.sops). Thesecrets/gcp.env.sopsreferences throughout this playbook reflect that.gcp.env.sopskeeps the full 6-recipient encryption rule so the bms-4 and vps-i1 runner agents can decrypt it at runtime.
1. One-time human setup (≈15 min) — required before any automation
These steps need the GCP Console and a human with Owner/IAM-admin on the GCP project. Claude cannot do them — they bootstrap the very credential the automation depends on.
1.1 Create the service account
GCP project: p24-infra (or whichever GCP project holds shared infra).
Name: p24-infra-admin
ID: p24-infra-admin
1.2 Assign roles
| Role | Why |
|---|---|
roles/iam.serviceAccountAdmin | create/delete service accounts |
roles/iam.serviceAccountKeyAdmin | create/rotate SA JSON keys |
roles/serviceusage.serviceUsageAdmin | enable/disable APIs per project |
roles/apikeys.admin | create/rotate API keys |
roles/resourcemanager.projectIamAdmin | bind IAM roles on the project |
roles/iam.securityAdmin | manage IAM policy / audit roles |
1.3 Generate the JSON key
Console → IAM & Admin → Service Accounts → p24-infra-admin → Keys → Add Key → JSON → download.
1.4 Add the key to SOPS
The key is added as GCP_SA_KEY_JSON — the entire JSON compacted to a single-line,
JSON-escaped string — into secrets/gcp.env.sops (developer/admin-only; never deployed by CI).
On a Linux box with the developer age key (compacting + escaping the downloaded file in one step, without ever printing it):
export SOPS_AGE_KEY_FILE="$HOME/.age/p24-infra-keys.txt"
# Build the single-line value silently (jq -c compacts; the value never hits the terminal).
VAL=$(jq -c . /path/to/downloaded-key.json)
# Append the line inside sops edit (opens $EDITOR on the decrypted buffer):
sops secrets/gcp.env.sops
# add a line: GCP_SA_KEY_JSON=<paste $VAL>
# save + exit — sops re-encrypts automatically.
unset VAL
shred -u /path/to/downloaded-key.jsonWindows note: if editing SOPS on Windows, follow the CRLF-safe procedure in CLAUDE.md (
[System.IO.File]::WriteAllTextwithUTF8Encoding($false)— never>,Out-File, orSet-Content). Seedocs/playbooks/sops-windows-crlf.md.
Verify the key is present (NAME only, never the value):
sops -d --input-type dotenv --output-type dotenv secrets/gcp.env.sops \
| grep -q '^GCP_SA_KEY_JSON=' && echo "GCP_SA_KEY_JSON: exists" || echo "missing"Commit and push:
git add secrets/gcp.env.sops
git commit -m "chore: add GCP_SA_KEY_JSON to gcp secrets"
git push
# NOTE: gcp.env.sops is developer/admin-only and is EXCLUDED from secrets-sync.yml — CI does NOT
# distribute this value to any server. Admin agents read it on-demand from SOPS (§2). See #2944.1.5 Signal completion
Post a comment on the tracking issue (radieu/p24-infra#2002) when done. A Claude session then
runs §3 (verification), §4 (compliance row), and the automation in §5 on demand.
2. Authenticate as p24-infra-admin (Claude, after §1)
Load the key from SOPS into a temp file, activate it for gcloud, then shred the file. The key
value is never echoed.
export SOPS_AGE_KEY_FILE="$HOME/.age/p24-infra-keys.txt"
KEYFILE=$(mktemp /tmp/gcp-sa-XXXXXX.json)
chmod 600 "$KEYFILE"
# Decrypt only this one key into the temp file (value never printed):
sops -d --input-type dotenv --output-type dotenv secrets/gcp.env.sops \
| grep '^GCP_SA_KEY_JSON=' | cut -d= -f2- > "$KEYFILE"
gcloud auth activate-service-account --key-file="$KEYFILE"
# ... run gcloud commands ...
gcloud auth revoke --all >/dev/null 2>&1 || true
shred -u "$KEYFILE"If
gcloudis not installed on the worker, install the Google Cloud CLI first (docs/playbooks/— orcurl https://sdk.cloud.google.com | bash) and re-auth.
3. Verify (AC: gcloud projects list works)
After §2 activation:
gcloud projects list # must return the accessible projects without an auth errorA non-empty list with no PERMISSION_DENIED confirms the SA + roles are correct. Record the
outcome on the tracking issue (“verified — gcloud projects list returns N projects”); never
paste project IDs that are sensitive.
4. Compliance row (AC: dev_r_services)
Once GCP_SA_KEY_JSON exists, register the credential in dev_r_services. Follow the
trigger-enforced-column rules in docs/playbooks/dev-r-services-insert.md (note: no unique
constraint on service_name, so use the WHERE NOT EXISTS guard, not ON CONFLICT):
Always set the rotation-tracking columns (criticality, rotation_freq, last_rotated,
next_due, auto_rotate). Omitting them leaves next_due/criticality NULL, which makes the
credential-exporter emit its 999 “no due date” sentinel and criticality=unknown — a false
CredentialRotationCritical alert (#2171). This is a manually-rotated key (no self-rotate API),
so auto_rotate=false on a 180-day cadence, same as CF_GLOBAL_API_KEY.
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM dev_r_services WHERE service_name = 'gcp-sa-p24-infra-admin') THEN
INSERT INTO dev_r_services (
service_name,
project_id, project_name,
office_id, ws_id, app_id,
service_type,
element_type,
status,
owner,
criticality,
rotation_freq,
last_rotated,
next_due,
auto_rotate,
compliance_workbook,
workbook_url,
compliance_notes
) VALUES (
'gcp-sa-p24-infra-admin',
'p24-infra', 'p24-infra',
'p24-devops', '99', 'et-app',
'secret',
'credential',
'active',
'radieu',
'high',
'180 days',
CURRENT_DATE,
CURRENT_DATE + interval '180 days',
false,
'yes',
'docs/playbooks/gcp-programmatic-management.md',
'GCP service account for programmatic IAM/API-key/SA/OAuth management; JSON key in secrets/gcp.env.sops as GCP_SA_KEY_JSON (developer/admin-only, not CI-deployed)'
);
ELSE
UPDATE dev_r_services SET
service_type = 'secret',
element_type = 'credential',
status = 'active',
criticality = COALESCE(criticality, 'high'),
rotation_freq = COALESCE(rotation_freq, '180 days'),
last_rotated = COALESCE(last_rotated, CURRENT_DATE),
next_due = COALESCE(next_due, CURRENT_DATE + interval '180 days'),
auto_rotate = false,
compliance_workbook = 'yes',
workbook_url = 'docs/playbooks/gcp-programmatic-management.md'
WHERE service_name = 'gcp-sa-p24-infra-admin';
END IF;
END $$;Verify exactly one row:
SELECT service_name, service_type, element_type, compliance_workbook,
criticality, rotation_freq, next_due, auto_rotate
FROM dev_r_services WHERE service_name = 'gcp-sa-p24-infra-admin';criticality, rotation_freq, and next_due must all be non-NULL — otherwise the
credential-exporter will flag a false CredentialRotationCritical (999d / unknown).
5. Ongoing automation (after §1)
All commands assume the SA is active (§2). Run inside the auth/shred wrapper.
5.1 OAuth redirect URIs — single shared OAuth app
Architecture: one OAuth app (p24-platform, created once by a human) serves all projects.
The redirect URI is always:
https://p24-auth.radieu.workers.dev/callback
New CF Workers / projects never need a new OAuth client — they reuse the shared
GOOGLE_CLIENT_ID (add it to the project’s secrets/*.env.sops, no new GCP OAuth app). Add a
redirect URI to the existing app only if a genuinely new callback host is introduced:
gcloud alpha iap oauth-clients update <oauth-client> \
--add-redirect-uris="https://p24-auth.radieu.workers.dev/callback"5.2 New-project bootstrap (enable API + dedicated SA + key)
Example: a new project needs the Google Sheets API and its own worker SA.
PROJECT=<project>
NAME=<name> # short slug, e.g. "sheets-sync"
gcloud services enable sheets.googleapis.com --project="$PROJECT"
gcloud iam service-accounts create "p24-${NAME}-worker" --project="$PROJECT" \
--display-name="p24 ${NAME} worker"
KEYFILE=$(mktemp /tmp/gcp-newsa-XXXXXX.json); chmod 600 "$KEYFILE"
gcloud iam service-accounts keys create "$KEYFILE" \
--iam-account="p24-${NAME}-worker@${PROJECT}.iam.gserviceaccount.com"
# Add the new key to the appropriate secrets/*.env.sops (NOT administration unless infra-shared),
# compacting with jq -c as in §1.4. Then shred:
shred -u "$KEYFILE"Open a PR with the SOPS update and a dev_r_services row for the new SA (§4 pattern) plus an
ops note. Never commit the plaintext key.
5.3 SA key + API key rotation
On schedule (see docs/playbooks/credential-rotation-180d.md) or on demand:
# List existing keys for an SA (key IDs only — no private material printed):
gcloud iam service-accounts keys list --iam-account="<sa-email>"
# Create a new key, update SOPS (§1.4 / §5.2), confirm the new value is live, THEN delete the old:
gcloud iam service-accounts keys delete <OLD_KEY_ID> --iam-account="<sa-email>"Always: create new → distribute via SOPS+CI → verify live → revoke old. Log the rotation in
docs/secrets-rotation-log.md.
5.4 IAM audit (detect over-privileged roles)
gcloud projects get-iam-policy <project> --format=json \
| jq '.bindings[] | {role, members}'Flag any member with broad roles (roles/owner, roles/editor) that should be scoped down.
File a GH issue for any finding.
5.5 Enable / disable APIs per project
gcloud services list --enabled --project=<project> # audit
gcloud services enable <api>.googleapis.com --project=<project>
gcloud services disable <api>.googleapis.com --project=<project> # confirm no dependents first6. Error reporting (mandatory)
Any automation script built on this playbook must, on failure, send a Discord embed via
P24_DISCORD_INFRA_SCRIPTS_ERRORS_WEBHOOK_URL and create a GH issue in radieu/p24-infra
with label bug (see CLAUDE.md §Error Notification Standard). Never silently swallow a failure.
7. Checklist
- §1 human setup done;
GCP_SA_KEY_JSONpresent insecrets/gcp.env.sops - §3
gcloud projects listverified - §4
dev_r_servicesrowgcp-sa-p24-infra-adminpresent with non-NULLcriticality/rotation_freq/next_due - Every SA key file
shred -u’d after use; no key value ever printed - Rotations logged in
docs/secrets-rotation-log.md