Rotate the talos Forgejo bot PAT
How to rotate the Forgejo PAT that the talos pod and the blumeops mise tasks use
to act as the talos bot on the forge (origin eblume/blumeops#1656; the
#1166 transcript incident is why the credential is arranged the way it is
below). The secret lives at op://agents/talos-forgejo-token/api-token — a
PAT on the Forgejo user talos, scopes write:repository + write:issue,
minted as the talos user so it can’t exceed the bot’s own repo access.
Consumers:
- the talos pod entrypoint (reads the token at pod boot, exports
FORGEJO_TOKEN, seeds tea — pod sessions resume off the PVC); - the blumeops mise tasks
request-run,verify-runs,lint-auto-apply,pending-deploys(FORGEJO_TOKENenv withopread fallback).
The same talos Forgejo user also holds a separate token the rotation
leaves alone: horkos-forge-read, a read-only PAT the horkos cv
release-asset fetch uses (eblume/horkos#17), stored in the blumeops vault
and ESO-mounted into the horkos publisher (not a consumer of this token).
The rotation neither hands it over nor revokes it — a rotation run
that did would break the publisher. The script names it and the pre-mint
check excludes it from the “exactly one incumbent” rule.
Minting the successor needs the bot’s login password — a Forgejo PAT
cannot mint its own successor (Forgejo mints over basic auth as the user),
so the mint rides the stored password copy. That bootstrap credential lives at
op://blumeops-ci/talos-forgejo-bot/password.
When to rotate
- Every ~90 days (a talos cron files a
chore:issue ~75 days out — established chore surface, see eblume/blumeops#1656) - Immediately on any suspected exposure — e.g. the #1166 transcript incident
Warrant path (automated)
Once the 1Password service account is created (the “Create the new 1Password
service account” human step on eblume/blumeops#1656) and the
forge-reconcile values half has pushed its token as the
FORGE_PAT_ROTATE_OP_TOKEN Actions secret, the rotation is a
warrant-gated run of .forgejo/workflows/forge-bot-pat-rotate.yaml:
- a talos session files the request:
mise run request-run forge-bot-pat-rotate.yaml <full-sha> --pr N -i revision=<full-sha> --why "…", Erich approves it in Horkos, andhorkos-forgedispatches it on the priv runner. The workflow checks out the bound SHA (freezing the reviewed script body) and runs./mise-tasks/forge-bot-pat-rotate --ci. - Credentials:
FORGE_PAT_ROTATE_OP_TOKEN(the new service account, read+write on theagentsvault only) drives the vault read/write asOP_SERVICE_ACCOUNT_TOKEN; the existing read-onlyBLUMEOPS_CI_OP_TOKENfeeds the script’sblumeops-cireads (the login password, thek3s-run-scriptkubeconfig). The pod roll runs as therun-scriptServiceAccount via therun-script-talos-rolloutRole (argocd/manifests/talos/rbac.yaml). - The script never prints key material — its run output is reported to the warrant, so it reports token names and ids only.
One-time password migration
The step that makes the automated path possible. The login password used to
live in the agents vault at op://agents/talos-forgejo-bot, which every
talos session can read — that copy is exactly what leaked in #1166. Human
steps, on gilbert or via the 1Password UI:
-
Create the item with a 1Password-generated password (the password is generated by
opitself, so it never touches argv or shell history; the username is not a secret, so it is fine on the command line):op item create --vault blumeops-ci --category login \ --title talos-forgejo-bot --generate-password 'username=talos'(The 1Password UI equivalent works too.) The generated value lands in the item’s built-in
passwordfield — 1Password CLI forces that label for--generate-passwordon a login item — which is the field the rotation script reads (PASSWORD_REF). Do not stash it under a differently-named custom field; the script would not find it. -
Set it on the Forgejo
talosaccount: read the new password once (op read op://blumeops-ci/talos-forgejo-bot/password— terminal output only), then set it on the account. Preferred: the admin edit page/admin/users/<id>/edit(needs no password — logged in as an admin, type the new password into “Password” and save). Alternative: log in astaloswith the old password → Settings → Account → Change Password. -
Delete the old item:
op item delete --vault agents talos-forgejo-bot
Nothing in the pod reads the agents-vault copy, so deleting it is safe once the new copy verifies (a dry-run + one live rotation proves it).
Procedure
The automated path, from a machine with biometric op (gilbert) and a
kubectl that can reach the cluster (the operator’s own):
mise run forge-bot-pat-rotateThe script refuses non-TTY runs without --dry-run (the warrant-gated CI
path, below, passes --ci). What it does, numbered:
- Verify the current PAT — repo GET of
eblume/research(a repo the bot pushes to) through it, 200 and reporting the bot’s push permission; a failure dies before anything is touched. - Read the login password from
op://blumeops-ci/talos-forgejo-bot/password— the credential over which the mint rides. - List the bot’s tokens via basic auth and print each as
- name (id=N)— names and ids only, never values. If the bot holds more than one unknown non-forge-bot-pat-*token (the known external ones —horkos-forge-read— are named and excluded), the script dies here (before minting) — resolve the rest by hand first. The run also proves that the vault’s current value matches some live token bytoken_last_eightbefore the mint, which makes the single unprefixed token provably revocable as a predecessor: it is either the vault’s incumbent (the first run’s hand-over) or the pending delayed revoke of an earlier--skip-roll/ failed-roll run. - Mint
forge-bot-pat-<ts>(scopes unchanged). - Prove the new PAT BEFORE any write: repo GET through it (200, push
permission) and it appears in the user’s token list via basic auth
(the ownership check — mirrored from
horkos-forge-provision’sverify_new_pat). If that proof or the vault write fails, the script re-reads the vault (a failedop item editmay have landed) and revokes the fresh token before dying — a live PAT that no consumer knows about is never left behind. The cleanup catches every exception (a missed biometric prompt included) and re-raises the original error. - Write the vault:
op://agents/talos-forgejo-token/api-token+ set the item’sexpiresfield to the next-due date (--days, default 90). - Roll the talos pods:
kubectl -n talos rollout restart deployment/talosthenrollout status deployment/talos --timeout=300s— kubectl’s own config as-is outside--ci($KUBECONFIGor~/.kube/config); only the CI path falls back to theblumeops-ci/k3s-run-scriptbound token (loopback-only, a 0600 temp file). This roll is what picks up the new token. On the--cipath the roll runs as therun-scriptServiceAccount, whose deployment access is the namespacedrun-script-talos-rolloutRole inargocd/manifests/talos/rbac.yaml—get/list/patch/watchon thetalosdeployment. - Revoke every predecessor: all other
forge-bot-pat-*tokens, plus the single unprefixed incumbent the first run takes over (the vault’s current token predates the prefix). The retained external tokens (horkos-forge-read) are never revoked. - Post-check: the new token plus the retained external ones are the only live tokens for the bot, and the old PAT now 401s.
Fail-safe ordering: a failure anywhere before the revoke step leaves every
consumer working — the old token is only revoked after the new one is verified
and the pods have been rolled; --skip-roll defers the revoke instead
(the live pods still need the old token — see below).
—skip-roll (when the script can’t roll)
The roll step needs a kubectl that can reach the cluster API: either
kubectl’s own config ($KUBECONFIG or ~/.kube/config — on gilbert the
operator’s own kubectl works as-is) or, in the warrant CI path, the
k3s-run-script bound token in blumeops-ci (loopback-only, ringtail’s
priv runner). When no kubectl can reach the cluster, use --skip-roll.
The tradeoff, stated plainly: the run stops after the vault write and
revokes nothing — the predecessors stay live (delayed revoke; the next
run cleans them up) precisely because the live talos pods still need the
old token until they are rolled. Roll immediately after the run
completes:
kubectl -n talos rollout restart deployment/talosUntil the roll, new sessions read the new token from the vault while live sessions run the old one — both work. Until the next run, the bot holds two or more live tokens (one per skipped run; harmless: both scopes are the bot’s own, and the run’s post-check after a full rotation still asserts the one-live-token invariant).
Known limitation
The pre-mint guard can’t tell a hand-minted token it doesn’t know from the
pending delayed revoke an earlier run left: once the bot has been through a
full rotation, a token minted by hand on the talos user that is not in
RETAINED_EXTERNAL_TOKENS passes the guard as the one unprefixed
predecessor and is revoked — it takes a second such token to stop the run.
When adding the next external token to the talos user, add its name to
RETAINED_EXTERNAL_TOKENS in mise-tasks/forge-bot-pat-rotate in the same
PR.
Debugging
The current token does not authenticate
Check the vault item, and whether a rotation half-finished: if the vault has a
token that 401s while the pods work, the pods are running a still-valid old
token and the chain is fine — roll them. If nothing authenticates, the chain
is broken: worst case, mint by hand in the Forgejo UI as the talos account
and op item edit the vault item, then roll the pods.
login password not readable
The migration isn’t done (the password is still only in the agents vault), or
you’re in the warrant path without BLUMEOPS_CI_OP_TOKEN, which the script
needs to read blumeops-ci items. See One-time password migration.
rollout fails
kubectlnot on PATH → re-run with--skip-rolland roll the pods yourself.- in the CI path, the
k3s-run-scriptbound token is loopback-only (ringtail’s priv runner) — the manual path never hits this; on the manual path kubectl uses your own config as-is. - k3s token expired → re-mint the
k3s-run-scriptbound token per docs/reference/services/horkos.md.
Related
- rotate-gandi-pat — sibling rotation ceremony (Gandi PAT)
- rotate-fly-deploy-token — sibling rotation ceremony (Fly.io token)
- principals — the bot’s reference card
- eblume/blumeops#1656 — the chore that files the rotation