v4.5 — Runtime Least-Privilege
No Cloud Run workload, job, or VM in the project runs as
roles/editoranymore — and a CI guard now fails the build before that regresses.
Summary
Every Cloud Run service, every Cloud Run job, and the enkidu-preview-db GCE VM ran as the project's default compute service account — and that account held roles/editor, a full-project write grant, plus roles/firebase.admin and roles/secretmanager.secretAccessor. The API is internet-facing and runs an LLM agent loop, the highest-exposure surface in the project, so a single compromised revision or job carried project-wide write across both staging and production, which shared the one identity with no IAM-layer isolation between them.
v4.5 provisioned nine dedicated, least-privilege, per-workload-per-environment service accounts (four Cloud Run workload classes × two environments, plus the preview-db VM), cut every workload over onto them one blast-radius tier at a time — staging, then production, then the VM — and only then stripped roles/editor, roles/firebase.admin, and roles/secretmanager.secretAccessor from the default compute SA. That strip is the milestone's entire security payoff; everything before it is preparation, and the ROADMAP says so explicitly to keep anyone from declaring victory early.
The milestone's defining trait wasn't the final IAM diff — it was how many times a plan-time assumption got overturned by a live GCP read or a look at the actual deploy code instead of a repo-grep or a status field. The preview-db VM's disposition flipped from delete to migrate after a live check found it backing 10 live preview PRs a name-grep couldn't see. Two legacy enkidu-agent-runner services flipped from "provision new SAs" to "just delete them" after reading the deploy workflows found the real workload already ran elsewhere, least-privileged, as a sidecar. A pre-strip live sweep (DEPRIV-0) caught four workloads no phase plan had ever counted before the strip could touch them. Each catch happened before it shipped, not after.
Goal
Move every runtime workload in the
enkidu-488723project off the default compute service account (confirmed holdingroles/editor— a full-project write grant on every workload) and onto dedicated, least-privilege, per-workload-per-environment service accounts, then striproles/editorfrom the default compute SA so no runtime identity carries project-wide write by default.
Scope — What Shipped
gcp_setup/08_runtime_service_accounts.sh— provisions all 9 dedicated SAs (enkidu-run-{api,worker,jobs,litellm}-{production,staging}+enkidu-preview-db-vm) with grants derived from the real deploy manifests and workflow--set-secretsflags, not hand-written, with a trailing assertion that fails the script if any generated grant list is empty or a manifest-referenced secret is missingdeploy/service.{staging,production}.yamlanddeploy/worker.{staging,production}.yaml— each pins its dedicatedserviceAccountName.github/workflows/{staging,production,preview,preview_teardown,rollback}.yml— everygcloud run jobs create/updateandgcloud run deployinvocation passes--service-accountenkidu-preview-dbGCE VM — migrated ontoenkidu-preview-db-vm@…in Phase 4 on 2026-07-16 (no PR), then deleted on 2026-07-17, alongside the ~20 orphanedenkidu-api-pr-*/enkidu-db-setup-pr-*preview resources cleaned up manually that same day (see.github/workflows/ci.yml'slint-workflowsguard comment). The branch-based preview architecture it backed had already been retired on 2026-04-05 (39415e33d), so Phase 4's "10 live preview PRs" sighting was that leaked cohort, not real consumers.gcp_setup/04_preview_db_vm.shstill carries--service-account+--no-scopesfor any future re-provision, and itsenkidu-preview-db-vm@service account — a confirmed zero-blast-radius orphan (full five-surface check indocs/operations/deployment.md§ Runtime service accounts) — was deleted on 2026-09-05; recoverable for 30 days (until ~2026-10-05) viagcloud iam service-accounts undelete 105943331296695590995 --project=enkidu-488723, after which a re-run ofgcp_setup/08_runtime_service_accounts.shwould provision a brand-new SA with a different uniqueId, not restore this one- Two dormant Cloud Run jobs (
enkidu-clerk-migrate,enkidu-make-admin) and two legacy standalone Cloud Run services (enkidu-agent-runner-{production,staging}) deleted — the live agent-runner workload already runs as a least-privileged sidecar insideenkidu-worker-{env} roles/editor,roles/firebase.admin, androles/secretmanager.secretAccessorremoved from the default compute SA (github-actions-deployer's separatefirebase.adminbinding, needed for Firebase Hosting deploys, was left untouched)- New
scripts/check-runtime-service-account.mjs(+.test.mjs) — CI guard that fails the build if anydeploy/*.yamllacksserviceAccountNameor any workflow job/service deploy lacks--service-account docs/operations/deployment.md— new runtime-IAM section: the workload-class role table, thegcloud run services replacereset trap, the two-firebase.admin-bindings distinction
Phases
| Phase | Name | Status | Plans | Highlights |
|---|---|---|---|---|
| 1 | Provision least-privilege SAs (additive) | Shipped | 1 | 9 SAs live with derived, resource-scoped grants; Roy caught a production-bucket leak and a tautological coverage assertion pre-live-run |
| 2 | Staging Cloud Run cutover | Shipped | 1 | Both manifests + all jobs + LiteLLM wired; image upload+delete proven against a staging bucket the milestone discovered had never actually existed |
| 3 | Production Cloud Run cutover | Shipped | 1 | Gate corrected mid-phase — the real cutover was the v4.1.1 tag, not the inert master merge; 24h clean soak before Phase 5 |
| 4 | Preview DB VM SA migration | Shipped | 1 | Delete rescope overturned live — the VM backs 10 live preview PRs by raw IP; migrated instead |
| 5 | De-privilege default compute SA + CI guard + docs | Shipped | 1 | Pre-strip sweep found 4 uncounted workloads; agent-runner pair deleted, not migrated; Editor/firebase.admin/secretAccessor stripped; CI guard live |
Key Decisions
- Strip last, not first. Phases 1-4 create identities and move workloads onto them, but the default compute SA keeps
roles/editorthe entire time — the strip is Phase 5 and only Phase 5. The ROADMAP calls this out explicitly so no earlier phase gets mistaken for the finish line. - Gate on the artifact that actually deploys, not the PR that merges. Phase 3's PR merged to
masterinertly —production.ymlonly reads those files on av*tag. The real cutover event, and the thing the 24h soak actually gates, is the tag cut. - A repo-grep "nothing references this" is not the same as "nothing depends on this." Both major disposition reversals this milestone — the preview-db VM (delete → migrate) and the agent-runner pair (provision-new-SA → delete) — came from finding something a static search couldn't see: a raw-IP consumer in one case, a documented-but-never-run cleanup step in the other. Both times, gating the action on a live GCP or deploy-code read caught it before it shipped.
- A merged script is not applied infrastructure. Phase 1 hit this twice — the 9 SAs didn't exist until someone actually ran the script against live GCP, and a staging GCS bucket the manifests depended on had been declared in a setup script for two days but never (re-)created. Both were caught by a live run, not by CI.
- Resource-scoped grants are invisible to a project-level audit. The GDPR-exports bucket grant (
07_create_gdpr_exports_buckets.sh) targeted the default compute SA specifically and would have silently broken GDPR export post-strip if Phase 1's follow-on PR hadn't redirected it — agcloud projects get-iam-policyread alone would never have shown it. - The CI guard shipped before the risky phase, deliberately. Landing
preview_teardown.yml/rollback.yml's wiring separately and early (OBJ-1392) meant Phase 5's guard authored against an already-clean tree, instead of going red for the first time inside the milestone's least-reversible step — where the pressure would have been to narrow the guard rather than fix the gap it found.
Requirements Coverage
27 / 27 requirements satisfied — SA 6/6, STAGE 3/3, PROD 2/2, VM 3/3, DEPRIV 7/7, GUARD 2/2, DOCS 4/4.
| Category | Count | Status |
|---|---|---|
| SA-1 – SA-6 | 6 | All satisfied |
| STAGE-1 – STAGE-3 | 3 | All satisfied |
| PROD-1, PROD-2 | 2 | All satisfied |
| VM-1 – VM-3 | 3 | All satisfied |
| DEPRIV-0 – DEPRIV-6 | 7 | All satisfied |
| GUARD-1, GUARD-2 | 2 | All satisfied |
| DOCS-1 – DOCS-4 | 4 | All satisfied |
Full requirements list and per-phase detail: v4.5-runtime-least-privilege-ROADMAP.md on GitHub. No separate REQUIREMENTS.md file exists for this milestone — requirements are defined inline per phase in the ROADMAP (Phase 5's expanded DEPRIV-5/6 and DOCS-3/4 are defined in the dispatched OBJ-1358 issue body).
Outcomes
The default compute service account holds zero project-level primitive roles — confirmed by a live gcloud projects get-iam-policy read (etags BwZW48BbxkM= / BwZW48EPpyU= / BwZW48Gj6vw= on the three removed bindings). Every Cloud Run workload runs under a dedicated identity scoped to exactly what that workload needs, generated from the real deploy manifests rather than hand-maintained. (The preview-db VM was migrated too, then deleted on 2026-07-17 — see Scope above.) Staging and production no longer share one identity — a compromise in one environment no longer implies write access to the other. A regression that omits a service account from a manifest or workflow now fails CI instead of silently defaulting back onto the compute SA.
Tech Debt
- (Phase 5) No independent second-identity verification of the IAM strip — the crew's own runtime service account lacks permission to re-run
get-iam-policyitself, so two of the ten Phase 5 acceptance criteria rest on Josh's first-party report rather than a cross-checked second read. Non-blocking given Josh executed as project Owner, but worth a lightweight read-only IAM-audit role if this class of milestone recurs. - (Milestone-wide) No automated drift check between what
gcp_setup/*.shdeclares and what actually exists inenkidu-488723— this bit Phase 1 twice (the SAs, a missing staging bucket) before a live run or luck caught it either time. Flagged as a candidate CI check, explicitly not yet scoped. - (Phase 5)
docs/operations/deployment.md's pre-existing Cloud Run Services/Jobs inventory lists (2 services / 3 jobs) are stale against the full 6-service/22-job tier this milestone actually migrated — out of scope for the DOCS-1..4 pass, flagged for a future docs-lint sweep.
Related Artifacts
- Roadmap: v4.5-runtime-least-privilege-ROADMAP.md
- Milestone Audit: v4.5-runtime-least-privilege-MILESTONE-AUDIT.md
- PRD: none — this milestone originated from a live IAM audit finding (OBJ-1343), not a product PRD
- Git tag: v4.1.1 cut the production Cloud Run cutover (Phase 3); the de-privilege step itself (Phase 5) is an IAM-only change with no version bump, riding the existing weekly release train
- Merge PRs: #1495, #1535, #1501, #1579, #1580
Related Commits
0d1f038aa— Phase 1: provision 9 least-privilege runtime service accounts (#1495)2cc25e311— Phase 1 follow-on: GDPR-exports bucket grants for dedicated SAs (#1535)6c5d45d18— Phase 3: wire production Cloud Run workloads to least-privilege service accounts (#1501)5871cf744— feat(ci): add runtime service-account guard for Cloud Run manifests + deploys (#1579)471ab9bd6— [Dori] docs(v4.5): document runtime least-privilege IAM model (#1580)
Last updated: 2026-07-18 Version: v4.1.1