9029d48584
- 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
354 lines
12 KiB
Go
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)
|
|
}
|
|
}
|