Files
terdut-server/internal/api/service_accounts.go
T
Niklas Ye a4dd60f6b8 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.
2026-09-29 21:25:17 +02:00

328 lines
11 KiB
Go

package api
import (
"context"
"database/sql"
"errors"
"net/http"
"strconv"
"strings"
"time"
"git.ryuvia.com/niklas/terdut-server/internal/models"
"github.com/go-chi/chi/v5"
)
// serviceAccountKeyPrefix marks a service-account key visibly, in logs and at
// a glance, distinct from a user's own personal API key. It carries no
// meaning to the server itself — the hash is looked up the same way either
// kind of key is — it exists entirely for whoever is reading a log line or an
// audit trail.
const serviceAccountKeyPrefix = "tdsa_"
// randomServiceAccountToken is randomToken with serviceAccountKeyPrefix on the
// raw value, hashed as a whole: the prefix is not a fixed header stripped
// before hashing, it is part of the secret, the same as if it had been
// generated that long to begin with.
func randomServiceAccountToken() (raw, hash string, err error) {
body, _, err := randomToken()
if err != nil {
return "", "", err
}
raw = serviceAccountKeyPrefix + body
return raw, hashToken(raw), nil
}
// callerIsAdmin reports whether the caller is a signed-in human system
// administrator. A service account never is, by design (SERVICE-ACCOUNTS.md):
// account and user management stays human-only, service accounts included.
func callerIsAdmin(ctx context.Context) bool {
u, ok := userFromContext(ctx)
return ok && u.IsAdmin
}
// callerOwnsTeam reports whether the caller is a human owner of teamID. Built
// on callerRole/ctxTeams like requireTeamOwner, but without writing a
// response: callers here need to combine it with other ways of being
// allowed, not stop at the first no.
func callerOwnsTeam(ctx context.Context, teamID int64) bool {
role, ok := callerRole(ctx, teamID)
return ok && role == models.RoleOwner
}
// handleCreateServiceAccount creates a service account and mints its first
// key. Who may do this depends on scope: an instance-scoped account (which
// can in turn create a team and a team-scoped account for it) is system
// administration's own reach extended to automation, so only a human admin
// grants one. A team-scoped account is that team's owner's reach, so a human
// admin, the target team's own human owner, or an existing instance-scoped
// service account (minting itself a narrower credential for a team it just
// created) may create one.
func handleCreateServiceAccount(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
Scope string `json:"scope"`
TeamID int64 `json:"team_id"`
}
if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid request body"))
return
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
respond(w, http.StatusBadRequest, errResp("name is required"))
return
}
if req.Scope != models.ServiceAccountScopeInstance && req.Scope != models.ServiceAccountScopeTeam {
respond(w, http.StatusBadRequest, errResp("scope must be instance or team"))
return
}
if req.Scope == models.ServiceAccountScopeTeam && req.TeamID == 0 {
respond(w, http.StatusBadRequest, errResp("team_id is required for a team-scoped account"))
return
}
if req.Scope == models.ServiceAccountScopeInstance && req.TeamID != 0 {
respond(w, http.StatusBadRequest, errResp("team_id must not be set for an instance-scoped account"))
return
}
allowed := callerIsAdmin(r.Context())
if !allowed && req.Scope == models.ServiceAccountScopeTeam {
allowed = callerOwnsTeam(r.Context(), req.TeamID) || isInstanceServiceAccount(r.Context())
}
if !allowed {
respond(w, http.StatusForbidden, errResp("team owner, system administrator, or instance-scoped service account access required"))
return
}
var callerUserID *int64
if u, ok := userFromContext(r.Context()); ok {
id := u.ID
callerUserID = &id
}
var teamID *int64
if req.Scope == models.ServiceAccountScopeTeam {
teamID = &req.TeamID
}
var sa models.ServiceAccount
var created int64
if err := db.QueryRowContext(r.Context(), `
INSERT INTO service_accounts (name, scope, team_id, created_by)
VALUES ($1, $2, $3, $4)
RETURNING id, name, scope, team_id, created_by, created_at`,
req.Name, req.Scope, teamID, callerUserID,
).Scan(&sa.ID, &sa.Name, &sa.Scope, &sa.TeamID, &sa.CreatedBy, &created); err != nil {
if isUniqueViolation(err) {
respond(w, http.StatusConflict, errResp("a service account with that name already exists"))
return
}
// The only foreign key that can fail here is team_id: an
// instance-scoped caller is not otherwise checked against it
// (callerOwnsTeam already proved it exists for a human owner).
respond(w, http.StatusBadRequest, errResp("unknown team_id"))
return
}
sa.CreatedAt = time.Unix(created, 0).UTC()
key, err := mintServiceAccountKey(r.Context(), db, sa.ID, "initial")
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusCreated, map[string]any{"service_account": sa, "key": key})
}
}
// mintServiceAccountKey inserts one key for an existing account and returns
// it with its raw value populated — the one moment that value exists outside
// the request that generated it.
func mintServiceAccountKey(ctx context.Context, db *sql.DB, serviceAccountID int64, name string) (models.ServiceAccountKey, error) {
raw, hash, err := randomServiceAccountToken()
if err != nil {
return models.ServiceAccountKey{}, err
}
var key models.ServiceAccountKey
var created int64
if err := db.QueryRowContext(ctx, `
INSERT INTO service_account_keys (service_account_id, key_hash, name)
VALUES ($1, $2, $3)
RETURNING id, service_account_id, name, created_at`,
serviceAccountID, hash, name,
).Scan(&key.ID, &key.ServiceAccountID, &key.Name, &created); err != nil {
return models.ServiceAccountKey{}, err
}
key.CreatedAt = time.Unix(created, 0).UTC()
key.Key = raw
return key, nil
}
func fetchServiceAccount(ctx context.Context, db *sql.DB, id int64) (models.ServiceAccount, error) {
var sa models.ServiceAccount
var created int64
err := db.QueryRowContext(ctx,
"SELECT id, name, scope, team_id, created_by, created_at FROM service_accounts WHERE id = $1", id,
).Scan(&sa.ID, &sa.Name, &sa.Scope, &sa.TeamID, &sa.CreatedBy, &created)
if err != nil {
return sa, err
}
sa.CreatedAt = time.Unix(created, 0).UTC()
return sa, nil
}
// callerMayManageServiceAccount reports whether the caller may mint or revoke
// a key on sa: a system administrator, that team-scoped account's own human
// owner, or the account rotating its own credential — which is not a
// privilege escalation, the same reasoning requireSelfOrAdmin already rests
// on for a user's own API keys.
func callerMayManageServiceAccount(ctx context.Context, sa models.ServiceAccount) bool {
if callerIsAdmin(ctx) {
return true
}
if sa.TeamID != nil && callerOwnsTeam(ctx, *sa.TeamID) {
return true
}
if self, ok := serviceAccountFromContext(ctx); ok && self.id == sa.ID {
return true
}
return false
}
func serviceAccountParam(w http.ResponseWriter, r *http.Request) (int64, bool) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
respond(w, http.StatusBadRequest, errResp("invalid service account id"))
return 0, false
}
return id, true
}
func handleCreateServiceAccountKey(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, ok := serviceAccountParam(w, r)
if !ok {
return
}
sa, err := fetchServiceAccount(r.Context(), db, id)
if errors.Is(err, sql.ErrNoRows) {
respond(w, http.StatusNotFound, errResp("service account not found"))
return
}
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if !callerMayManageServiceAccount(r.Context(), sa) {
respond(w, http.StatusForbidden, errResp("team owner, system administrator, or the account itself may rotate its key"))
return
}
var req struct {
Name string `json:"name"`
}
if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid request body"))
return
}
if req.Name == "" {
respond(w, http.StatusBadRequest, errResp("name is required"))
return
}
key, err := mintServiceAccountKey(r.Context(), db, sa.ID, req.Name)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusCreated, key)
}
}
func handleDeleteServiceAccountKey(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, ok := serviceAccountParam(w, r)
if !ok {
return
}
sa, err := fetchServiceAccount(r.Context(), db, id)
if errors.Is(err, sql.ErrNoRows) {
respond(w, http.StatusNotFound, errResp("service account not found"))
return
}
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if !callerMayManageServiceAccount(r.Context(), sa) {
respond(w, http.StatusForbidden, errResp("team owner, system administrator, or the account itself may revoke its key"))
return
}
keyID, err := strconv.ParseInt(chi.URLParam(r, "keyID"), 10, 64)
if err != nil {
respond(w, http.StatusBadRequest, errResp("invalid key id"))
return
}
res, err := db.ExecContext(r.Context(),
"DELETE FROM service_account_keys WHERE id = $1 AND service_account_id = $2", keyID, sa.ID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if n, _ := res.RowsAffected(); n == 0 {
respond(w, http.StatusNotFound, errResp("key not found"))
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// handleListServiceAccounts lists every service account, or looks one up by
// its exact name with ?name=. The name lookup is open to any authenticated
// caller, human or service account: it returns no key material, and it is
// what lets a service account find its own account on the 403 that follows a
// second POST — the self-registration pattern SERVICE-ACCOUNTS.md describes.
// Listing everything, with no filter, stays administrator-only.
func handleListServiceAccounts(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
name := strings.TrimSpace(r.URL.Query().Get("name"))
if name == "" && !callerIsAdmin(r.Context()) {
respond(w, http.StatusForbidden, errResp("administrator access required to list every service account; pass ?name= to look up one by name"))
return
}
query := "SELECT id, name, scope, team_id, created_by, created_at FROM service_accounts"
var args []any
if name != "" {
query += " WHERE name = $1"
args = append(args, name)
}
query += " ORDER BY id"
rows, err := db.QueryContext(r.Context(), query, args...)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
defer rows.Close()
accounts := []models.ServiceAccount{}
for rows.Next() {
var sa models.ServiceAccount
var created int64
if err := rows.Scan(&sa.ID, &sa.Name, &sa.Scope, &sa.TeamID, &sa.CreatedBy, &created); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
sa.CreatedAt = time.Unix(created, 0).UTC()
accounts = append(accounts, sa)
}
if err := rows.Err(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusOK, accounts)
}
}