Files
terdut-server/internal/api/service_accounts.go
T
Niklas Ye 9029d48584
CI / chart (pull_request) Successful in 2s
CI / security (pull_request) Failing after 19s
CI / test (pull_request) Successful in 5m34s
Let an operator authenticate with a seeded key, and reset the schema
- TERDUT_OPERATOR_KEY creates or re-keys the instance-scoped service account
  "terdut-operator" at every start, so terdut-operator needs no bootstrap
  handshake. An instance-scoped account now acts as owner of every team's
  configuration, but is not a member of any team.
- POST /api/teams takes an external_id (instance service accounts only) and
  is idempotent on it, so automation finds its own team again after a crash
  instead of adopting by display name. GET /api/teams?name= is removed.
- Integration and dead man's switch names are unique per team (409). The
  escalation PUT accepts usernames and resolves them itself.
- The 18 migrations are squashed into 001_schema.sql, with no Default team.
  TERDUT_DEADMAN_* and the env seeding of switches are removed: teams carry
  their own. Existing development databases must be recreated.

Security and robustness:
- GET /api/users no longer returns other people's email or ntfy topic to
  non-admins.
- The access log records the route pattern, so integration keys and ack
  tokens in the path are not written to the log. Server errors are logged.
- Rate limits take the client address TERDUT_TRUSTED_PROXIES hops from the
  right of X-Forwarded-For instead of trusting the first, forgeable entry.
- /api/bootstrap runs in a transaction under an advisory lock, so two
  concurrent calls cannot both create an administrator.
- API key last_used_at is written at most every five minutes.

Cleanup: remove GET /api/incidents/{id}/alerts, unused exports, SQLite
remnants in comments and config.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
2026-10-09 14:56:13 +02:00

354 lines
12 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 owner-equivalent for teamID:
// a human owner, or that team's own team-scoped service account (its single
// synthetic membership, serveAsServiceAccount — ratified in
// SERVICE-ACCOUNTS.md as intentional, not an accident: a team-scoped
// credential is that team's owner's reach, full stop, membership and
// invites included). Built on callerRole 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 {
if c, _ := callerFromContext(ctx); c.IsInstanceServiceAccount() {
return true
}
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 {
serverError(w, r, err)
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, the account rotating its own credential (not a privilege
// escalation, the same reasoning requireSelfOrAdmin already rests on for a
// user's own API keys) — or, new, an instance-scoped service account
// managing any team-scoped account.
//
// That last branch closes terdut-operator#3: handleCreateServiceAccount
// already lets an instance-scoped caller *create* a team-scoped account for
// any team (the branch below it, isInstanceServiceAccount(ctx)) — this
// account didn't have an equivalent reach to *adopt or rotate* one it
// didn't just create in the same call, which is exactly the recovery path
// terdut-operator's own documented crash-window handling depends on
// (DESIGN.md §5's general adopt-on-conflict rule): a reconcile that creates
// the account successfully but crashes before persisting its credential
// locally retries into a 409, and without this branch the only available
// recovery — minting a fresh key on the now-existing account — 403'd
// forever, with no way out. Granting it here is not a new power: it
// mirrors the create-time reach this scope already has, just extended to
// the retry path DESIGN.md's own crash-window reasoning requires.
func callerMayManageServiceAccount(ctx context.Context, sa models.ServiceAccount) bool {
if callerIsAdmin(ctx) {
return true
}
if sa.TeamID != nil && callerOwnsTeam(ctx, *sa.TeamID) {
return true
}
caller, _ := callerFromContext(ctx)
if id, ok := caller.ServiceAccountID(); ok && id == sa.ID {
return true
}
if sa.TeamID != nil && caller.IsInstanceServiceAccount() {
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 {
serverError(w, r, err)
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 {
serverError(w, r, err)
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 {
serverError(w, r, err)
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 {
serverError(w, r, err)
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 {
serverError(w, r, err)
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 {
serverError(w, r, err)
return
}
sa.CreatedAt = time.Unix(created, 0).UTC()
accounts = append(accounts, sa)
}
if err := rows.Err(); err != nil {
serverError(w, r, err)
return
}
respond(w, http.StatusOK, accounts)
}
}