AdminOnly/requireSelfOrAdmin reject a service-account caller, so an operator-managed server has no API path to create its first human login #23

Closed
opened 2026-10-02 18:54:52 +00:00 by niklas · 0 comments
Owner

Summary

AdminOnly (guards POST /api/users, GET/PUT /api/admin/settings) and
requireSelfOrAdmin (guards PUT /api/users/{id}/password, among others)
both gate on userFromContext(ctx):

// internal/api/middleware.go
func AdminOnly(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		caller, ok := userFromContext(r.Context())
		if !ok || !caller.IsAdmin {
			respond(w, http.StatusForbidden, errResp("administrator access required"))
			return
		}
		...

userFromContext is only ever populated by serveAs (a human session
cookie or a per-user API key). A service-account caller goes through
serveAsServiceAccount instead, which stores its principal under a
different context key (ctxServiceAccount) that userFromContext never
reads. So any service-account Bearer token gets a flat 403 on these
routes
, regardless of scope — there's no "instance-scoped accounts count
as admin" branch the way isInstanceServiceAccount(ctx) already exists for
service_accounts.go/teams.go.

Why this is more than a theoretical gap

terdut-operator's bootstrap flow (DESIGN.md §6) is:

  1. POST /api/bootstrap once, to get the first human admin's raw API key.
  2. Immediately mint an instance-scoped service account from that key.
  3. Delete the bootstrap admin's checkpointed key as soon as the service
    account exists (terdut-operator/internal/controller/terdutserver_bootstrap.go,
    reconcileBootstrap's r.Delete(ctx, checkpoint)).

After step 3, the only credential that exists anywhere for a server the
operator created is that instance-scoped service-account token. Because of
the bug above, that token can never call /api/admin/settings to flip
signup_mode, nor POST /api/users / PUT /api/users/{id}/password to
create or provision a human login directly. There is no recovery path short
of writing to the settings/users tables by hand — on an
operator-managed install, nobody can ever sign in through the normal web UI
unless they do that.

This isn't hypothetical: terdut-operator/examples/demo/README.md's
documented "first login" step tells you to do exactly the broken thing
(curl -H "Authorization: Bearer $operatorToken" -X PUT .../api/admin/settings -d '{"signup_mode":"open"}') and it 403s as written.
A new terdut-operator/examples/demo/run-demo.sh automation script works
around it by kubectl exec-ing psql directly into the demo's own
Postgres to flip signup_mode, which is fine for a throwaway kind demo but
isn't something a real operator-managed install can reasonably do.

Suggested fix (not prescriptive — flagging the decision, not making it)

One of:

  • Let an instance-scoped service account satisfy AdminOnly /
    requireSelfOrAdmin the same way isInstanceServiceAccount(ctx) already
    lets one satisfy the team-shaped routes — this is probably the smallest
    change and matches the trust model DESIGN.md §6 already describes
    ("blast radius: a team-scoped key can only touch its own team_id...";
    the instance-scoped key is already meant to be able to act broadly).
  • Or: give the operator a narrower, purpose-built endpoint for "provision a
    human login" instead of reusing the general admin routes, if widening
    AdminOnly itself is considered too broad a privilege for a service
    account to hold.

Either way, terdut-operator/examples/demo/README.md's first-login section
should be corrected once there's a real fix, instead of recommending a call
that 403s.

Repro

Against any server an operator created (i.e. one that has an instance-scoped
service account and no surviving human admin credential):

curl -X PUT http://localhost:8080/api/admin/settings \
  -H "Authorization: Bearer $instanceServiceAccountToken" \
  -H 'Content-Type: application/json' \
  -d '{"signup_mode":"open"}'
# -> 403 {"error":"administrator access required"}

References

  • terdut-server/internal/api/middleware.go — AdminOnly, requireSelfOrAdmin,
    userFromContext, serveAsServiceAccount, isInstanceServiceAccount.
  • terdut-server/internal/api/router.go — the AdminOnly-wrapped group
    (POST /api/users, GET/PUT /api/admin/settings, etc.).
  • terdut-operator/DESIGN.md §6 — bootstrap/credential lifecycle.
  • terdut-operator/internal/controller/terdutserver_bootstrap.go —
    reconcileBootstrap deleting the checkpointed admin key.
  • terdut-operator/examples/demo/README.md — the first-login section that
    currently documents the broken call.
## Summary `AdminOnly` (guards `POST /api/users`, `GET/PUT /api/admin/settings`) and `requireSelfOrAdmin` (guards `PUT /api/users/{id}/password`, among others) both gate on `userFromContext(ctx)`: ```go // internal/api/middleware.go func AdminOnly(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { caller, ok := userFromContext(r.Context()) if !ok || !caller.IsAdmin { respond(w, http.StatusForbidden, errResp("administrator access required")) return } ... ``` `userFromContext` is only ever populated by `serveAs` (a human session cookie or a per-user API key). A service-account caller goes through `serveAsServiceAccount` instead, which stores its principal under a *different* context key (`ctxServiceAccount`) that `userFromContext` never reads. So **any service-account Bearer token gets a flat 403 on these routes**, regardless of scope — there's no "instance-scoped accounts count as admin" branch the way `isInstanceServiceAccount(ctx)` already exists for `service_accounts.go`/`teams.go`. ## Why this is more than a theoretical gap `terdut-operator`'s bootstrap flow (DESIGN.md §6) is: 1. `POST /api/bootstrap` once, to get the first human admin's raw API key. 2. Immediately mint an instance-scoped service account from that key. 3. **Delete the bootstrap admin's checkpointed key** as soon as the service account exists (`terdut-operator/internal/controller/terdutserver_bootstrap.go`, `reconcileBootstrap`'s `r.Delete(ctx, checkpoint)`). After step 3, the *only* credential that exists anywhere for a server the operator created is that instance-scoped service-account token. Because of the bug above, that token can never call `/api/admin/settings` to flip `signup_mode`, nor `POST /api/users` / `PUT /api/users/{id}/password` to create or provision a human login directly. There is no recovery path short of writing to the `settings`/`users` tables by hand — on an operator-managed install, nobody can ever sign in through the normal web UI unless they do that. This isn't hypothetical: `terdut-operator/examples/demo/README.md`'s documented "first login" step tells you to do exactly the broken thing (`curl -H "Authorization: Bearer $operatorToken" -X PUT .../api/admin/settings -d '{"signup_mode":"open"}'`) and it 403s as written. A new `terdut-operator/examples/demo/run-demo.sh` automation script works around it by `kubectl exec`-ing `psql` directly into the demo's own Postgres to flip `signup_mode`, which is fine for a throwaway kind demo but isn't something a real operator-managed install can reasonably do. ## Suggested fix (not prescriptive — flagging the decision, not making it) One of: - Let an **instance-scoped** service account satisfy `AdminOnly` / `requireSelfOrAdmin` the same way `isInstanceServiceAccount(ctx)` already lets one satisfy the team-shaped routes — this is probably the smallest change and matches the trust model DESIGN.md §6 already describes ("blast radius: a team-scoped key can only touch its own team_id..."; the instance-scoped key is already meant to be able to act broadly). - Or: give the operator a narrower, purpose-built endpoint for "provision a human login" instead of reusing the general admin routes, if widening `AdminOnly` itself is considered too broad a privilege for a service account to hold. Either way, `terdut-operator/examples/demo/README.md`'s first-login section should be corrected once there's a real fix, instead of recommending a call that 403s. ## Repro Against any server an operator created (i.e. one that has an instance-scoped service account and no surviving human admin credential): ```sh curl -X PUT http://localhost:8080/api/admin/settings \ -H "Authorization: Bearer $instanceServiceAccountToken" \ -H 'Content-Type: application/json' \ -d '{"signup_mode":"open"}' # -> 403 {"error":"administrator access required"} ``` ## References - `terdut-server/internal/api/middleware.go` — `AdminOnly`, `requireSelfOrAdmin`, `userFromContext`, `serveAsServiceAccount`, `isInstanceServiceAccount`. - `terdut-server/internal/api/router.go` — the `AdminOnly`-wrapped group (`POST /api/users`, `GET/PUT /api/admin/settings`, etc.). - `terdut-operator/DESIGN.md` §6 — bootstrap/credential lifecycle. - `terdut-operator/internal/controller/terdutserver_bootstrap.go` — `reconcileBootstrap` deleting the checkpointed admin key. - `terdut-operator/examples/demo/README.md` — the first-login section that currently documents the broken call.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: niklas/terdut-server#23