Add service accounts and operator mode

Service accounts (SERVICE-ACCOUNTS.md) are a scoped, non-human credential:
not a users row, so they never touch OIDC sync, login or the is_admin flag.
Instance scope can create a team and mint a team-scoped account for it;
team scope is owner-equivalent for that one team and nothing else. This is
what unblocks terdut-operator's DESIGN.md §6 — no more impersonating a
human admin, and a real rotation story instead of the unworkable
delete-and-re-bootstrap /api/bootstrap can't actually do.

- migration 014: service_accounts + service_account_keys
- POST /api/service-accounts, POST/DELETE .../keys, GET ?name= self-lookup
- AuthMiddleware resolves a tdsa_-prefixed key to a distinct principal;
  a team-scoped account gets a synthetic single membership so
  requireTeamMember/requireTeamOwner work on it unmodified
- handleCreateTeam accepts an instance-scoped caller; the team it creates
  has no human owner, which is the expected shape for one an operator is
  about to hand a team-scoped credential to

Operator mode (TERDUT_OPERATOR_MODE / values.operatorMode) declares an
install gitops-managed: session and user-API-key writes to teams,
escalation policies, dead man's switches and integrations get 403
reason=operator_managed, while a service account's writes still go
through. Team membership/invites and the schedule are deliberately left
out — never gitops-managed by design, and still human day-to-day work.
/api/auth/config reports operator_mode so the web UI can grey these
sections out from the start rather than only after a write fails.

Also: GET /api/version (both terdut-tui and terdut-operator currently
detect server capability by route-probing; this gives them a real answer),
and a PUT for dead man's switches so a reconciler can update one in place
instead of deleting and recreating it.
This commit is contained in:
Niklas Ye
2026-09-29 21:25:17 +02:00
parent b5573fbca2
commit a4dd60f6b8
15 changed files with 1111 additions and 45 deletions
+23 -14
View File
@@ -126,12 +126,17 @@ hashes to a `service_account_keys.key_hash` resolves to a distinct principal
type, not a synthesized `models.User`. `requireTeamMember`/`requireTeamOwner`
treat a matching team-scoped service account as owner-equivalent for that one
team (satisfies the same checks a real team owner would), and an instance-scoped
one as satisfying `AdminOnly` for team-creation/listing purposes only — never
for user-management endpoints (`POST /api/users`, `PUT /api/users/{id}/admin`,
etc.), which stay human-admin-only. Anywhere identity is recorded for a human
(incident timeline `acknowledged_by`/`assigned_to`, audit-relevant fields), a
service-account principal is stored and displayed distinctly, e.g.
`service-account:terdut-operator`, never coerced into a `user_id` FK.
one as satisfying `AdminOnly` for team-creation/listing purposes **and** for
minting a `team`-scoped service account against any team (`POST
/api/service-accounts {"scope":"team","teamID":...}`) — this second permission
is what lets an operator-style caller create a team, then immediately mint that
team its own narrower credential, without a human in the loop for every team.
Neither permission extends to user-management endpoints (`POST /api/users`,
`PUT /api/users/{id}/admin`, etc.), which stay human-admin-only. Anywhere
identity is recorded for a human (incident timeline
`acknowledged_by`/`assigned_to`, audit-relevant fields), a service-account
principal is stored and displayed distinctly, e.g. `service-account:terdut-operator`,
never coerced into a `user_id` FK.
## What this unblocks
@@ -146,14 +151,18 @@ Directly resolves `terdut-operator` DESIGN.md §6's two broken assumptions:
2. **Rotation becomes real.** `POST /api/service-accounts/{id}/keys` + revoke the
old one — no destructive DB-level workaround, no re-triggering a single-shot
endpoint that can't fire twice.
3. **Cross-namespace credential mirroring becomes unnecessary.** Once team-scoped
accounts exist, `terdut-operator`'s `TerdutServer` controller can mint one
key per `TerdutTeam` directly into that `TerdutTeam`'s own namespace
(owner-referenced to the CR) instead of mirroring one shared,
server-admin-equivalent credential into every consenting namespace. This
also closes the blast-radius gap that mirroring left open: a leaked Secret
today would expose every team on the server; a leaked team-scoped key
exposes exactly one team.
3. **Cross-namespace credential mirroring is no longer needed at all.**
`terdut-operator`'s current design holds every credential — instance- and
team-scoped alike — privately in the operator's own namespace, never in
the namespace of the CR each one authenticates for; reconciliation happens
entirely inside the operator's controller loop, so no CR owner ever needs
read access to a terdut-server credential regardless of same- or
cross-namespace `serverRef`. Team scoping is still what bounds the blast
radius of any individual credential: a leaked team-scoped key exposes
exactly one team's resources, never the whole server, which is what makes
holding many credentials in one place (the operator's namespace) an
acceptable trade rather than reintroducing the mirrored design's
server-admin-equivalent-everywhere problem.
## Suggested sequencing