Proposes a non-human credential type — service_accounts + service_account_keys, instance- or team-scoped, distinct from both user API keys (always tied to a human's full rights) and integration keys (narrow, one-way webhook auth only). Directly unblocks terdut-operator's DESIGN.md §6, whose bootstrap/rotation plan doesn't work against /api/bootstrap's actual single-shot-per-install behavior. Design note only, no implementation yet.
8.8 KiB
Service accounts: a scoped, non-human credential type
This is a design note for a feature, not an implementation plan — it exists to
propose the shape before writing code. It's raised directly by terdut-operator
(a separate repo, no shared code — see its DESIGN.md §6, §9, §13), which needs
a credential for unattended, repeatable API access and currently has no good one
available. Anything automating terdut-server long-term (this operator, CI, future
integrations) hits the same gap, so this is written as a general primitive, not
operator-specific.
The problem
terdut-server has two credential types today, and neither fits "an unattended process that manages teams/schedules/policies on someone's behalf":
- User API keys (
api_keys,internal/api/users.go) are always tied to a realusersrow and carry that user's full rights — every team they're a member of, their admin flag if set. There's nokind/servicemarker distinguishing "a human's personal automation key" from "a login session," and no way to mint one scoped to less than the full user. - Integration keys (
integrations,internal/api/*teams*.go) are team-scoped, but narrowly: they authenticate exactly one inbound Alertmanager webhook call (POST /api/integrations/{key}/alertmanager) and nothing else. They're not a general management-API credential and shouldn't become one — overloading a narrow, one-way ingestion credential with broad read/write access would weaken the one property that makes it safe to embed in an Alertmanager config today.
The result: any automation that needs to create teams, set escalation policies,
manage dead-man switches, or rotate integration keys has to hold a real human
admin's or team owner's API key. That key is exactly as powerful as that person
logging in — full team access, and full instance access if they're an admin.
terdut-operator's design ran directly into this (its DESIGN.md §6): its
described bootstrap/rotation flow assumed a repeatable, identity-scoped way to
get a credential, and /api/bootstrap's actual behavior (single-shot per
install, gated on COUNT(*) FROM users, confirmed via internal/api/users.go
and charts/terdut-server/templates/bootstrap-job.yaml) doesn't provide one —
it mints exactly one founding admin, once, ever.
Goals
- A credential type that isn't a human: doesn't touch OIDC group sync, login,
session, or the
is_admin/account-management semantics that come with a realusersrow. - Two scopes matching the two shapes automation actually needs: instance-wide (create/list teams — what a server-owning controller needs) and team-scoped (manage one team's escalation policy, dead-man switches, integrations, schedule, OIDC group bindings — what a per-team controller or integration needs).
- Repeatable issuance and rotation — unlike
/api/bootstrap, callable more than once, by anything that already holds admin rights, without destroying and recreating state to get a fresh credential. - Visibly distinct from a human in every place identity shows up (audit trails, timeline entries, UI attribution) — a service account acting on a team should never be indistinguishable from a person.
Non-goals
- Not a general OAuth2/OIDC client-credentials flow — this is a bearer-token
primitive matching the shape
api_keysalready uses (SHA-256 hash stored, raw key shown once at creation), not a new auth protocol. - Not replacing integration keys — those stay as the narrow, one-way webhook credential they are today.
- Not modeling per-endpoint or per-verb permissions within a scope —
instanceandteamare the only two scopes for now; finer-grained scoping is future work if a real need shows up.
Proposed shape
Schema
CREATE TABLE service_accounts (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE, -- e.g. "terdut-operator"
scope TEXT NOT NULL CHECK (scope IN ('instance', 'team')),
team_id BIGINT REFERENCES teams(id) ON DELETE CASCADE,
-- team_id required iff scope = 'team'; NULL iff scope = 'instance'
created_by BIGINT REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE service_account_keys (
id BIGSERIAL PRIMARY KEY,
service_account_id BIGINT NOT NULL REFERENCES service_accounts(id) ON DELETE CASCADE,
key_hash TEXT NOT NULL UNIQUE,
name TEXT NOT NULL, -- e.g. "initial", "2026-Q4-rotation"
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_used_at TIMESTAMPTZ
);
Deliberately not a users row: no password_hash, no is_admin, no
user_identities linkage, so it's structurally impossible for a service account
to be pulled into OIDC group sync or password login. Multiple keys per account
(mirroring api_keys' existing one-user-many-keys shape) so rotation is "mint a
new key, revoke the old one," not "recreate the account."
Endpoints
POST /api/service-accounts— instance-scope/admin-only. Body:{"name": ..., "scope": "instance"|"team", "teamID": ... }(teamID required iff scope=team, and caller must be that team's owner or a system admin). Returns the account plus its first raw key (shown once, same pattern asPOST /api/users/{id}/api-keys). Safe to call again with the samename— see "idempotent lookup" below — unlike/api/bootstrap, which is inherently one-shot by design (it's answering "does any user exist yet," a question with no analogue once one already does).POST /api/service-accounts/{id}/keys— mint an additional key on an existing account (self-service-equivalent: instance admin forinstancescope, team owner or system admin forteamscope). Enables rotation without recreating the account or losing its identity/audit history.DELETE /api/service-accounts/{id}/keys/{keyID}— revoke one key, mirroringDELETE /api/users/{id}/api-keys/{keyID}.GET /api/service-accounts?name=— look up an existing account by name. This is what turns "I tried to create my account and got a conflict" into a normal flow instead of an error: a controller that expects to have already registered itself calls this first, and only falls through toPOSTif nothing comes back.
Auth middleware
internal/api/middleware.go's existing dual resolution (Authorization: Bearer
→ apiKeyUser(), or session cookie → sessionUser(), both landing on the same
models.User + team-membership context) gains a third path: a bearer token that
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.
What this unblocks
Directly resolves terdut-operator DESIGN.md §6's two broken assumptions:
- Bootstrap becomes single-purpose again.
/api/bootstrapmints exactly the founding human admin, once. The operator's actual first-reconcile flow: call/api/bootstraponly on a genuinely empty install; otherwise (or immediately after, if it won the bootstrap race) callGET /api/service-accounts?name=terdut-operator, andPOSTone if it doesn't exist yet. From then on the operator never touches/api/bootstrapagain. - 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. - Cross-namespace credential mirroring becomes unnecessary. Once team-scoped
accounts exist,
terdut-operator'sTerdutServercontroller can mint one key perTerdutTeamdirectly into thatTerdutTeam'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.
Suggested sequencing
Land this before terdut-operator implements any bootstrap/credential-handling
code — that code would otherwise be written against the current one-shot,
user-only credential model as a known-temporary workaround, which is wasted
effort on a repo that currently has zero implementation to begin with.