Operator-token gate on the destructive write routes (rc#2369)¶
Slice 1 of #1999 and infra#448.
Why¶
Nothing in-cluster restricts who reaches the conductor:
- there is no NetworkPolicy in rig-conductor, dev-e or review-e;
- NodePort 30080 serves the tailnet;
- Cloudflare Access fronts only rig-conductor.dashecorp.com.
The rig's threat model treats agent pods as untrusted, because a hostile issue body steers them. Before this, any of them could start the GPU pod (spend), force-done an issue, create a tenant, or delete agents and repo learnings, with no credential. Merging was already closed by #2350, and tenant erasure and POST /api/admin/overrides by their empty allowlist. That left these.
The gated routes¶
| Route | Scope |
|---|---|
POST /api/pods/{name}/start |
wake: the wake token or the operator token |
POST /api/pods/{name}/stop |
operator |
POST /api/admin/issues/force-done |
operator |
POST /api/admin/tenants, DELETE /api/admin/tenants/{tenantId} |
operator (the DELETE keeps its own allowlist and confirm-header guards) |
POST /api/admin/overrides |
operator (it keeps its own allowlist guard) |
DELETE /api/agents/{agentId}, DELETE /api/agents/offline, DELETE /api/repo-learnings |
operator |
Not gated, deliberately:
- GET /api/pods/{name}, which Rig-E, the SPA and the worker's liveness monitor poll;
- GET /api/admin/tenants and GET /api/admin/overrides, read-only lists behind the cockpit and the conductor's static dashboard;
- GET/POST /api/repo-learnings, which the runtime uses.
OperatorRouteGateE2eTests derives the gated set from the app's own endpoint table. Every DELETE, and every non-GET route under /api/admin/ or /api/pods/, must carry the gate, and nothing else may. A new destructive route without it fails the build.
How it decides (OperatorRouteGatePolicy, Core)¶
- Header:
X-Rig-Operator-Token. It is compared in constant time withRIG_OPERATOR_TOKEN, and, on the wake route only,RIG_WAKE_TOKEN. Rig-E gets the wake token, so it can wake its GPU without holding a credential that could stop it, force-done work or touch tenants. - Configured tokens: trimmed, because a k8s secret often ends in a newline. One shorter than 32 characters is ignored.
OPERATOR_ROUTE_GATE:reportby default, and for anything that is not exactlyenforce.- Report never refuses. Its outcome says what enforce would have done:
report:no-token,report:bad-tokenorreport:unconfigured. - Enforce refuses a missing or wrong token, and refuses everything when no usable token is configured. Enforcement without a secret fails closed.
- A refusal is
403, never401. The cockpit SPA and the iOS app treat a 401 as a logout. - Every gated call is logged, allowed or not:
It is an endpoint filter attached per endpoint (.RequireOperatorToken(scope)), not path-prefix middleware. Two reasons:
- some gated routes share a path with open ones;
- routing matches /API/Admin/… case-insensitively, which a prefix check would miss. The e2e asserts the mixed-case path is refused.
Query and body parameters bind before endpoint filters run. A malformed request is therefore a 400 that reaches neither the gate nor the handler.
Rollout¶
- This change: nothing is configured in
deploy/, so production runs in report mode and refuses nothing. Read the callers from the log:kubectl logs -n rig-conductor deploy/rig-conductor-api | grep '\[OperatorGate\]' - Callers:
- cockpit worker: inject the operator token on
stop, after its header-copy loop, and add the header name toSTRIPPED_INBOUND_HEADERS; - Rig-E: send the wake token on
start, and log a 401/403 instead of swallowing it (rarendpoint-resolver.js:107-110); - runbooks: add the header (
gpu-brain.md,runpod-pod-control.md,api.md, the onboarding and erasure runbooks). - Operator only (credential policy): mint both tokens. They go into a new Secret,
rig-conductor-gate-secrets(rig-gitops#846), which is separate fromrig-conductor-secretsso it could be created from the public age recipient alone. The same values go to Rig-E (rig-e-wake-secrets) and to the worker (itsRIG_OPERATOR_TOKENrepo secret).deploy/k8s/deployment.yamlreads both tokens as optionalsecretKeyRefs (#2372). Order: the Secret has to exist before the deployment change rolls out, because env fromsecretKeyRefis read only at container start. The conductor then logs[OperatorGate] ok … credential=operatoron a Stop GPU andcredential=wakeon a Rig-E wake. - When the report log shows only the expected callers, all presenting a token, set
OPERATOR_ROUTE_GATE=enforce. Done 2026-09-25 (#2435). - The 24 h report log (2026-09-24 12:26Z to 2026-09-25 12:28Z) held three calls, each with a token: two Stop GPU
calls from the cockpit (
credential=operator) and one wake from Rig-E (credential=wake). - A code search found no other caller of any gated route.
To roll back to report mode, remove the OPERATOR_ROUTE_GATE env from deploy/k8s/deployment.yaml.
Out of scope, because each needs a per-agent identity: forged /api/events, and Valkey's unauthenticated assignments:*, which is NodePort 30079.