a4dd60f6b8
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.
328 lines
11 KiB
Go
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)
|
|
}
|
|
}
|