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.
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user