identity: Forgejo Actions as a Google WIF issuer; pin + back up the ID-token key #43

Merged
founder merged 4 commits from identity/forgejo-actions-wif-issuer into main 2026-08-16 10:45:38 +00:00
Owner

KSE-0182 Amendment A — phase 1, CI leg, part 1 of 2 (part 2 = cloud-tofu.yml switching to the job token once this is applied).

What

  • Nix: pin [actions] ID_TOKEN_SIGNING_PRIVATE_KEY_FILE to Forgejo's own default (no re-key), RS256, ID_TOKEN_EXPIRATION_TIME = 600; add forgejo-actions-id-token-key-backup.{service,timer} — daily age-encrypted copy of the CI signing key (founder + forge recipients) into the backups bucket. Nothing backed up /var/lib/forgejo file state before.
  • infra/identity/wif.tf: import the existing key-store-authentik pool (existence-only management: prevent_destroy, ignore_changes = all), add the forgejo-actions OIDC provider (issuer https://git.key.store/api/actions; mapping sub/repository/ref/workflow_ref/event_name; condition repository == key-store/key.store), and grant the repo's principalSet roles/iam.workloadIdentityUser on release-signing@key-d0t-store so cloud-tofu can impersonate it with a job token (parity with today's Authentik blast radius; narrow to workflow_ref in phase 3).

Verified

  • tofu fmt/validate clean (OpenTofu 1.11.8).
  • nixos-rebuild dry-activate on the forge builds; rendered app.ini has the three [actions] keys; backup unit + timer in the closure.
  • A cloud-tofu plan for identity is dispatched from this branch — link in comments.

To apply

cloud-tofu.yml → stack identity, mode apply (founder-gated). Expected: 1 import (pool), 2 creates (provider, SA IAM member).

🤖 Generated with Claude Code

KSE-0182 Amendment A — **phase 1, CI leg, part 1 of 2** (part 2 = `cloud-tofu.yml` switching to the job token once this is applied). ## What - **Nix**: pin `[actions] ID_TOKEN_SIGNING_PRIVATE_KEY_FILE` to Forgejo's own default (no re-key), `RS256`, `ID_TOKEN_EXPIRATION_TIME = 600`; add `forgejo-actions-id-token-key-backup.{service,timer}` — daily age-encrypted copy of the CI signing key (founder + forge recipients) into the backups bucket. Nothing backed up `/var/lib/forgejo` file state before. - **`infra/identity/wif.tf`**: import the existing `key-store-authentik` pool (existence-only management: `prevent_destroy`, `ignore_changes = all`), add the `forgejo-actions` OIDC provider (issuer `https://git.key.store/api/actions`; mapping `sub`/`repository`/`ref`/`workflow_ref`/`event_name`; condition `repository == key-store/key.store`), and grant the repo's principalSet `roles/iam.workloadIdentityUser` on `release-signing@key-d0t-store` so cloud-tofu can impersonate it with a job token (parity with today's Authentik blast radius; narrow to `workflow_ref` in phase 3). ## Verified - `tofu fmt`/`validate` clean (OpenTofu 1.11.8). - `nixos-rebuild dry-activate` on the forge builds; rendered `app.ini` has the three `[actions]` keys; backup unit + timer in the closure. - A cloud-tofu **plan** for `identity` is dispatched from this branch — link in comments. ## To apply `cloud-tofu.yml` → stack `identity`, mode `apply` (founder-gated). Expected: 1 import (pool), 2 creates (provider, SA IAM member). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
KSE-0182 Amendment A, phase 1 (CI leg), part 1 of 2.

Forgejo 15.0.2 already serves an Actions OIDC issuer at
https://git.key.store/api/actions (RS256, JWKS at
/api/actions/.well-known/keys, sub = repo:<owner/repo>:ref:<ref>, claims
repository/ref/workflow_ref/event_name/...). Make it a first-class identity:

- nixos/keystore-forge.nix: pin [actions] ID_TOKEN_SIGNING_PRIVATE_KEY_FILE
  to Forgejo's own default path (no re-key), RS256, ID_TOKEN_EXPIRATION_TIME
  600. Add forgejo-actions-id-token-key-backup.{service,timer}: daily
  age-encrypted copy of the signing key (founder + forge recipients,
  single-sourced from secrets/secrets.nix) into the backups bucket under
  forgejo/actions_id_token/. Nothing backed up /var/lib/forgejo file state
  before; losing this key would silently re-key every federation edge.
- infra/identity/wif.tf: import the existing key-store-authentik pool
  (created by Scripts/release/provision-google-wif.sh; prevent_destroy +
  ignore_changes=all so it is managed for existence only), add the
  forgejo-actions OIDC provider (attribute mapping for sub/repository/ref/
  workflow_ref/event_name, attribute_condition repository ==
  key-store/key.store, default audience = provider resource name), and grant
  principalSet://.../attribute.repository/key-store/key.store
  roles/iam.workloadIdentityUser on release-signing@key-d0t-store so
  cloud-tofu.yml can impersonate the release SA with its job token (part 2
  switches the workflow over). Same blast radius as today's Authentik path;
  narrow to attribute.workflow_ref in phase 3.
- variables/outputs/tfvars example: manage_google_wif (default true, gated
  by manage_google), project number, pool/provider ids, issuer, repository,
  release SA email; outputs the provider name and the audience jobs request.

Verified: tofu fmt/validate clean (OpenTofu 1.11.8, import for_each ok);
nixos-rebuild dry-activate on the forge builds; rendered app.ini carries the
three new [actions] keys; backup unit + timer present in the closure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CI runs the identity stack with manage_google = false (KMS resources were
bootstrapped out of band), so the first plan from PR #43 planned nothing.
The pool pre-exists and the job always has Google credentials; gate the WIF
resources on manage_google_wif alone (default true).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author
Owner

Plan results and a bootstrap prerequisite.

  • Run 17103: clean, but planned nothing — CI's identity tfvars run with manage_google = false, and I had gated the WIF resources on it. Fixed in e6979a1e (WIF managed independently; the pool pre-exists and the job always has Google creds).
  • Run 17106: the import mechanics work (OpenTofu found and tried to import the pool), then Google returned 403 iam.workloadIdentityPools.get — cloud-tofu.yml impersonates release-signing@, which has KMS-scoped roles only.

Rather than widen the release SA, the third commit gives OpenTofu its own principal (KSE-0182 A.4): Scripts/release/provision-cloud-tofu-identity.sh creates cloud-tofu@key-d0t-store with the admin roles the stacks manage (WIF pool admin, SA admin, KMS admin, storage admin, service-usage admin — no project IAM admin) and binds workloadIdentityUser for the Authentik cloud-signing principalSet (temporary) and the Forgejo repository principalSet. cloud-tofu.yml impersonates vars.GOOGLE_TOFU_SERVICE_ACCOUNT_EMAIL when set.

Founder action needed (one-time, needs Google admin):

GOOGLE_CLOUD_BOOTSTRAP_ACCESS_TOKEN="$(gcloud auth print-access-token)" Scripts/release/provision-cloud-tofu-identity.sh

then set the Actions variable GOOGLE_TOFU_SERVICE_ACCOUNT_EMAIL=cloud-tofu@key-d0t-store.iam.gserviceaccount.com. After that: dispatch cloud-tofu.yml stack=identity mode=apply → 1 import + 2 creates, and part 2 (job-token consumer) follows.

**Plan results and a bootstrap prerequisite.** - Run [17103](https://git.key.store/key-store/key.store/actions/runs/17103): clean, but planned nothing — CI's identity tfvars run with `manage_google = false`, and I had gated the WIF resources on it. Fixed in `e6979a1e` (WIF managed independently; the pool pre-exists and the job always has Google creds). - Run [17106](https://git.key.store/key-store/key.store/actions/runs/17106): the import mechanics work (OpenTofu found and tried to import the pool), then Google returned **403 `iam.workloadIdentityPools.get`** — cloud-tofu.yml impersonates `release-signing@`, which has KMS-scoped roles only. Rather than widen the release SA, the third commit gives OpenTofu its own principal (KSE-0182 A.4): `Scripts/release/provision-cloud-tofu-identity.sh` creates `cloud-tofu@key-d0t-store` with the admin roles the stacks manage (WIF pool admin, SA admin, KMS admin, storage admin, service-usage admin — no project IAM admin) and binds `workloadIdentityUser` for the Authentik cloud-signing principalSet (temporary) and the Forgejo repository principalSet. `cloud-tofu.yml` impersonates `vars.GOOGLE_TOFU_SERVICE_ACCOUNT_EMAIL` when set. **Founder action needed (one-time, needs Google admin):** ``` GOOGLE_CLOUD_BOOTSTRAP_ACCESS_TOKEN="$(gcloud auth print-access-token)" Scripts/release/provision-cloud-tofu-identity.sh ``` then set the Actions variable `GOOGLE_TOFU_SERVICE_ACCOUNT_EMAIL=cloud-tofu@key-d0t-store.iam.gserviceaccount.com`. After that: dispatch `cloud-tofu.yml` stack=identity mode=apply → 1 import + 2 creates, and part 2 (job-token consumer) follows.
identity: dedicated cloud-tofu service account; bootstrap script for the founder
All checks were successful
Build: Apple / Check (SwiftLint) (pull_request) Successful in 16s
Build: Apple / Build (visionOS) (pull_request) Successful in 23s
Build: Apple / Build (iOS) (pull_request) Successful in 29s
Build: Apple / Build (macOS) (pull_request) Successful in 1m49s
0aba903d61
The identity plan from PR #43 (run 17106) reached Google and stopped on
iam.workloadIdentityPools.get: cloud-tofu.yml impersonates the release
signing SA, which has KMS-scoped roles only. Rather than widen the release
SA, OpenTofu gets its own principal (KSE-0182 A.4):

- Scripts/release/provision-cloud-tofu-identity.sh: one-time founder
  bootstrap in the provision-google-wif.sh style (--dry-run, optional
  GOOGLE_CLOUD_BOOTSTRAP_ACCESS_TOKEN). Creates cloud-tofu@key-d0t-store
  with iam.workloadIdentityPoolAdmin, iam.serviceAccountAdmin, cloudkms.admin,
  storage.admin, serviceusage.serviceUsageAdmin (no project IAM admin), and
  roles/iam.workloadIdentityUser on it for the Authentik cloud-signing
  principalSet (temporary, until the workflow runs on the job token) and the
  Forgejo repository principalSet.
- infra/identity/wif.tf: the tofu-managed impersonation binding targets the
  cloud-tofu SA (google_tofu_service_account_email), not release-signing.
- cloud-tofu.yml: impersonate vars.GOOGLE_TOFU_SERVICE_ACCOUNT_EMAIL when
  set, else the release SA as today.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author
Owner

4th commit: aligned with KSE-0166 + folded in the consumer.

  • One Google provider per authority (forgejo-cloud-tofu-plan, forgejo-cloud-tofu-apply): allowed_audiences = the KSE-0166 audience, condition = repository + exact repo:key-store/key.store:ref:refs/heads/main subject, constant attribute.authority for IAM. Both main-only for now (a branch job could otherwise mint a plan token and impersonate an admin SA); follow-up is a viewer-only plan SA.
  • cloud-tofu.yml: enable-openid-connect: true on the job + a gated step (Actions var KEYSTORE_CLOUD_TOFU_FORGEJO_WIF=true) that runs forgejo-ci-trust-guard.sh --mode trusted-manual --allow-manual --ref refs/heads/main --audience key-store-cloud-tofu-<plan|apply> --write-oidc-token and exchanges it with google-wif-auth.sh at forgejo-cloud-tofu-<authority> for the cloud-tofu SA. Inert until the variable is set, so this merges before the apply.
  • provision-cloud-tofu-identity.sh binds attribute.authority/cloud-tofu-*; KSE-0182 A + Nix comment corrected to enable-openid-connect: true / KSE-0166 audiences.

Verified: tofu fmt/validate clean; workflow YAML parses.

Founder sequence (unchanged): run provision-cloud-tofu-identity.sh with admin creds → set GOOGLE_TOFU_SERVICE_ACCOUNT_EMAIL → merge → dispatch identity apply (1 import, 2 providers, 2 IAM members) → set KEYSTORE_CLOUD_TOFU_FORGEJO_WIF=true → dispatch identity plan on main to prove the job-token path → remove the temporary Authentik binding on the SA.

**4th commit: aligned with KSE-0166 + folded in the consumer.** - One Google provider per authority (`forgejo-cloud-tofu-plan`, `forgejo-cloud-tofu-apply`): `allowed_audiences` = the KSE-0166 audience, condition = repository + exact `repo:key-store/key.store:ref:refs/heads/main` subject, constant `attribute.authority` for IAM. Both main-only for now (a branch job could otherwise mint a plan token and impersonate an admin SA); follow-up is a viewer-only plan SA. - `cloud-tofu.yml`: `enable-openid-connect: true` on the job + a gated step (Actions var `KEYSTORE_CLOUD_TOFU_FORGEJO_WIF=true`) that runs `forgejo-ci-trust-guard.sh --mode trusted-manual --allow-manual --ref refs/heads/main --audience key-store-cloud-tofu-<plan|apply> --write-oidc-token` and exchanges it with `google-wif-auth.sh` at `forgejo-cloud-tofu-<authority>` for the cloud-tofu SA. Inert until the variable is set, so this merges before the apply. - `provision-cloud-tofu-identity.sh` binds `attribute.authority/cloud-tofu-*`; KSE-0182 A + Nix comment corrected to `enable-openid-connect: true` / KSE-0166 audiences. Verified: `tofu fmt`/`validate` clean; workflow YAML parses. **Founder sequence** (unchanged): run `provision-cloud-tofu-identity.sh` with admin creds → set `GOOGLE_TOFU_SERVICE_ACCOUNT_EMAIL` → merge → dispatch identity **apply** (1 import, 2 providers, 2 IAM members) → set `KEYSTORE_CLOUD_TOFU_FORGEJO_WIF=true` → dispatch identity **plan** on main to prove the job-token path → remove the temporary Authentik binding on the SA.
identity: one WIF provider per KSE-0166 authority; cloud-tofu job-token path
Some checks failed
Build: Apple / Check (SwiftLint) (pull_request) Successful in 16s
Build: Apple / Build (visionOS) (pull_request) Successful in 25s
Build: Apple / Build (iOS) (pull_request) Successful in 27s
Build: Apple / Build (macOS) (pull_request) Successful in 1m52s
Build: Linux / Build (Linux x86_64) (pull_request) Failing after 24m15s
Build: Linux / Build (keystore-forge x86_64) (pull_request) Has been skipped
80829e7627
Align phase 1 with the conventions KSE-0166 already set (Forgejo is the
branch-trust issuer; one dedicated audience per authority; PR subjects never
accepted by mutating providers; jobs opt in with enable-openid-connect: true;
Scripts/forgejo-ci-trust-guard.sh fetches and verifies the token):

- infra/identity/wif.tf: providers forgejo-cloud-tofu-plan and
  forgejo-cloud-tofu-apply (var.forgejo_actions_authorities), each with
  allowed_audiences = its KSE-0166 audience, condition = repository + exact
  main subject, and a constant attribute.authority; the cloud-tofu SA
  workloadIdentityUser bindings key on attribute.authority/<authority>.
  Both authorities are main-only for now: any branch job could otherwise
  mint a plan token and impersonate an admin principal (follow-up: split
  plan onto a viewer-only SA).
- cloud-tofu.yml: enable-openid-connect: true on the job; new step "Prepare
  Forgejo Job Identity For Google" (gated by the Actions variable
  KEYSTORE_CLOUD_TOFU_FORGEJO_WIF=true) runs the trust guard in
  trusted-manual mode on refs/heads/main with audience
  key-store-cloud-tofu-<plan|apply>, exchanges the JWT with
  google-wif-auth.sh at forgejo-cloud-tofu-<authority> for the cloud-tofu
  SA, and wins GOOGLE_APPLICATION_CREDENTIALS over the Authentik step
  (require-google drops to false in that mode). Inert until the variable is
  set, so this merges before the apply.
- provision-cloud-tofu-identity.sh: bind attribute.authority/cloud-tofu-*.
- KSE-0182 Amendment A + the Nix comment: enable-openid-connect: true (not
  GitHub's permissions: id-token), KSE-0166 audiences, per-authority
  providers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
founder force-pushed identity/forgejo-actions-wif-issuer from 80829e7627
Some checks failed
Build: Apple / Check (SwiftLint) (pull_request) Successful in 16s
Build: Apple / Build (visionOS) (pull_request) Successful in 25s
Build: Apple / Build (iOS) (pull_request) Successful in 27s
Build: Apple / Build (macOS) (pull_request) Successful in 1m52s
Build: Linux / Build (Linux x86_64) (pull_request) Failing after 24m15s
Build: Linux / Build (keystore-forge x86_64) (pull_request) Has been skipped
to 5b8b7e6944
Some checks failed
Release: Signing Preflight / Release (Signing Preflight) (push) Successful in 1m3s
Infra: Cloud OpenTofu / Infra (Cloud OpenTofu) (push) Successful in 1m4s
Deploy: Web / Deploy (Web) (push) Successful in 2m19s
Release: If Needed / Check (Release Needed) (push) Has been cancelled
Deploy: API / Deploy (API) (push) Has been cancelled
2026-08-16 10:45:35 +00:00
Compare
founder deleted branch identity/forgejo-actions-wif-issuer 2026-08-16 10:45:38 +00:00
Author
Owner

Phase 1 CI leg — done end to end (2026-08-16).

  • Bootstrap run by the founder (cloud-tofu@ created); GOOGLE_TOFU_SERVICE_ACCOUNT_EMAIL set.
  • #43 merged (main 5b8b7e69); #50 fixed Google's 32-char provider display-name cap; #53 made the SA impersonation binding authoritative.
  • Runs on main: identity plan 17161 1 to import, 4 to add → apply 17162 (partial: display-name cap) → apply 17170 Apply complete! 3 added, 1 changed.
  • KEYSTORE_CLOUD_TOFU_FORGEJO_WIF=true → plan 17176: ok: Forgejo CI trust guard accepted mode=trusted-manual … ref=refs/heads/mainGoogle identity for OpenTofu: Forgejo job token (authority cloud-tofu-plan) -> cloud-tofu@No changes. Your infrastructure matches the configuration.
  • Apply 17204 on the cloud-tofu-apply authority: authoritative workloadIdentityUser binding on cloud-tofu@ (only attribute.authority/cloud-tofu-{plan,apply}) — the temporary Authentik binding is gone; OpenTofu is the sole authority over its own identity.

Pending: the forge switch for the Nix half (Forgejo [actions] ID-token settings + daily key backup) — needs the founder-gated deploy-forge-host dispatch (restarts forgejo.service briefly). Next: KSE-0182 A.5 phase 1 host leg (Authentik key-store-forge-host + google-wif-credentials.service + seal/archive IAM), then phase 2 (OpenBao up; wire infra/openbao into cloud-tofu.yml — it isn't a supported stack there yet).

**Phase 1 CI leg — done end to end (2026-08-16).** - Bootstrap run by the founder (`cloud-tofu@` created); `GOOGLE_TOFU_SERVICE_ACCOUNT_EMAIL` set. - #43 merged (main `5b8b7e69`); #50 fixed Google's 32-char provider display-name cap; #53 made the SA impersonation binding authoritative. - Runs on `main`: identity plan [17161](https://git.key.store/key-store/key.store/actions/runs/17161) `1 to import, 4 to add` → apply 17162 (partial: display-name cap) → apply [17170](https://git.key.store/key-store/key.store/actions/runs/17170) `Apply complete! 3 added, 1 changed`. - `KEYSTORE_CLOUD_TOFU_FORGEJO_WIF=true` → plan [17176](https://git.key.store/key-store/key.store/actions/runs/17176): `ok: Forgejo CI trust guard accepted mode=trusted-manual … ref=refs/heads/main` → `Google identity for OpenTofu: Forgejo job token (authority cloud-tofu-plan) -> cloud-tofu@` → **`No changes. Your infrastructure matches the configuration.`** - Apply [17204](https://git.key.store/key-store/key.store/actions/runs/17204) on the **cloud-tofu-apply** authority: authoritative `workloadIdentityUser` binding on `cloud-tofu@` (only `attribute.authority/cloud-tofu-{plan,apply}`) — the temporary Authentik binding is gone; OpenTofu is the sole authority over its own identity. Pending: the **forge switch** for the Nix half (Forgejo `[actions]` ID-token settings + daily key backup) — needs the founder-gated `deploy-forge-host` dispatch (restarts forgejo.service briefly). Next: KSE-0182 A.5 phase 1 host leg (Authentik `key-store-forge-host` + `google-wif-credentials.service` + seal/archive IAM), then phase 2 (OpenBao up; wire `infra/openbao` into cloud-tofu.yml — it isn't a supported stack there yet).
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
key-store/key.store!43
No description provided.