774fdfcaa8
ctxUser/ctxTeams (human) and ctxServiceAccount (+ a synthetic ctxTeams
entry, service account) used to be two parallel, un-unified context
representations -- every authz predicate had to remember which one(s) it
needed, and every place that forgot either wrongly 403'd a service account
(terdut-server#23, terdut-operator#3), crashed on an unchecked zero-value
user id, or silently no-op'd. New internal/api/caller.go collapses both
into one Caller, stored under one ctxCaller key by serveAs/serveAsServiceAccount;
every existing predicate (userFromContext, callerTeamIDs, callerRole,
callerIsAdmin, isInstanceServiceAccount, AdminOnly, requireSelfOrAdmin,
requireTeamOwner, OperatorModeBlock) now reads through it, with identical
behavior for every untouched call site (alerts.go, incidents.go,
schedule.go, stats.go, etc.) -- confirmed by the full existing suite
passing unchanged.
Four real fixes land alongside the refactor, not just the restructuring:
1. callerMayManageServiceAccount gains the one load-bearing branch this
exists for: an instance-scoped service account may now manage (mint or
revoke a key on) any team-scoped account, not only a human admin, that
team's human owner, or the account itself. handleCreateServiceAccount
already let an instance-scoped caller *create* a team-scoped account for
any team; adopting or rotating one it didn't just create in the same
call -- terdut-operator's own documented crash-window recovery -- had no
equivalent permission and 403'd forever. Closes terdut-operator#3.
2. handleCreateInvite wrote a service-account caller's zero-value user id
straight into invites.created_by (nullable, but never passed as nil),
which foreign-key-violates against users(id) -- a 500, not success, for
any team-scoped service account minting an invite. Fixed the same way
handleCreateServiceAccount already handles the analogous case. Found
live while verifying this change, not filed separately since it's fixed
in the same place it was found.
3. handleMe and handleTestNotification 500'd for a service-account caller
(fetchUser/the ntfy_topic lookup against a zero-value user id that
matches no row); handleDismissOnboarding silently no-op'd (UPDATE ...
WHERE id = 0). All three now call Caller.AsHuman() and return an
explicit 403 ("this endpoint is for human accounts only").
4. Ratifies, rather than further narrows, two capabilities a team-scoped
service account already had by construction and this document's own
text once called "a gap acknowledged rather than closed": owner-equivalent
reach over membership/invites, and minting another service account for
its own team. terdut-operator's new TerdutTeam invite-minting feature is
about to depend on the first one, so this makes it documented, tested,
intentional behavior instead of an accident nobody was supposed to rely
on.
AdminOnly/requireSelfOrAdmin are unchanged in effect: still human-only,
forever, for every scope of service account -- confirmed by
TestAdminOnly_RefusesEveryServiceAccountScope. terdut-server#23's named
routes (POST /api/users, PUT /api/admin/settings) were never the right
thing to widen; its real fix is the terdut-operator invite feature,
recorded in SERVICE-ACCOUNTS.md's "What this unblocks" and closing that
issue once it ships.
SERVICE-ACCOUNTS.md amended in place (not a new file, its own established
convention) to describe the as-built Caller model, correct its own
aspirational claim about AdminOnly that TEAM-LOOKUP.md had already flagged
as not matching shipped code, and record all of the above.
351 lines
13 KiB
Go
351 lines
13 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 {
|
|
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, 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 {
|
|
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)
|
|
}
|
|
}
|