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:
+122
-7
@@ -9,15 +9,17 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
ctxUser contextKey = "user"
|
||||
ctxSession contextKey = "session"
|
||||
ctxTeams contextKey = "teams"
|
||||
ctxUser contextKey = "user"
|
||||
ctxSession contextKey = "session"
|
||||
ctxTeams contextKey = "teams"
|
||||
ctxServiceAccount contextKey = "service_account"
|
||||
)
|
||||
|
||||
// AuthMiddleware accepts either of the two credentials the server issues: an
|
||||
@@ -39,12 +41,19 @@ func AuthMiddleware(db *sql.DB) func(http.Handler) http.Handler {
|
||||
respond(w, http.StatusUnauthorized, errResp("unauthorized"))
|
||||
return
|
||||
}
|
||||
userID, ok := apiKeyUser(r.Context(), db, token)
|
||||
if !ok {
|
||||
respond(w, http.StatusUnauthorized, errResp("unauthorized"))
|
||||
if userID, ok := apiKeyUser(r.Context(), db, token); ok {
|
||||
serveAs(w, r, next, db, userID, 0)
|
||||
return
|
||||
}
|
||||
serveAs(w, r, next, db, userID, 0)
|
||||
// Tried second, not first: a user API key is the common case,
|
||||
// and a service-account key is visibly prefixed (tdsa_) so this
|
||||
// second lookup is rarely reached on a request that was going
|
||||
// to fail anyway.
|
||||
if sa, ok := serviceAccountFor(r.Context(), db, token); ok {
|
||||
serveAsServiceAccount(w, r, next, sa)
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusUnauthorized, errResp("unauthorized"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -188,6 +197,112 @@ func userFromContext(ctx context.Context) (models.User, bool) {
|
||||
return u, ok
|
||||
}
|
||||
|
||||
// serviceAccountPrincipal is a service account as resolved from its key:
|
||||
// enough to authorize requests, never the key itself.
|
||||
type serviceAccountPrincipal struct {
|
||||
id int64
|
||||
name string
|
||||
scope string
|
||||
teamID int64 // meaningless (zero) for instance scope
|
||||
}
|
||||
|
||||
// serviceAccountFor resolves a service-account key to its account and stamps
|
||||
// its last use, the same shape apiKeyUser has for a user's own key.
|
||||
func serviceAccountFor(ctx context.Context, db *sql.DB, token string) (serviceAccountPrincipal, bool) {
|
||||
var sa serviceAccountPrincipal
|
||||
var keyID int64
|
||||
var teamID sql.NullInt64
|
||||
err := db.QueryRowContext(ctx, `
|
||||
SELECT k.id, a.id, a.name, a.scope, a.team_id
|
||||
FROM service_account_keys k
|
||||
JOIN service_accounts a ON a.id = k.service_account_id
|
||||
WHERE k.key_hash = $1`, hashToken(token),
|
||||
).Scan(&keyID, &sa.id, &sa.name, &sa.scope, &teamID)
|
||||
if err != nil {
|
||||
return serviceAccountPrincipal{}, false
|
||||
}
|
||||
if teamID.Valid {
|
||||
sa.teamID = teamID.Int64
|
||||
}
|
||||
|
||||
// best-effort; don't fail the request if this update fails
|
||||
db.ExecContext(ctx,
|
||||
"UPDATE service_account_keys SET last_used_at = $1 WHERE id = $2",
|
||||
time.Now().Unix(), keyID)
|
||||
return sa, true
|
||||
}
|
||||
|
||||
// serveAsServiceAccount hands the request on with a service account's
|
||||
// identity in context. A team-scoped account gets a single synthetic
|
||||
// membership — owner of its own team, nothing else — which is what makes it
|
||||
// satisfy requireTeamMember/requireTeamOwner exactly as a real owner would,
|
||||
// without teaching either function about a second kind of caller. An
|
||||
// instance-scoped account gets no memberships at all: it acts on teams by id,
|
||||
// not by belonging to one.
|
||||
//
|
||||
// No CSRF check, for the same reason an API key needs none: a service-account
|
||||
// key is only ever set by the client that holds it, never attached by a
|
||||
// browser to a request another site makes.
|
||||
func serveAsServiceAccount(w http.ResponseWriter, r *http.Request, next http.Handler, sa serviceAccountPrincipal) {
|
||||
ctx := r.Context()
|
||||
if sa.scope == models.ServiceAccountScopeTeam {
|
||||
ctx = context.WithValue(ctx, ctxTeams, []membership{{teamID: sa.teamID, role: models.RoleOwner}})
|
||||
}
|
||||
ctx = context.WithValue(ctx, ctxServiceAccount, sa)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
}
|
||||
|
||||
func serviceAccountFromContext(ctx context.Context) (serviceAccountPrincipal, bool) {
|
||||
sa, ok := ctx.Value(ctxServiceAccount).(serviceAccountPrincipal)
|
||||
return sa, ok
|
||||
}
|
||||
|
||||
// isInstanceServiceAccount reports whether the caller is an instance-scoped
|
||||
// service account — the one identity allowed to create a team and mint a
|
||||
// team-scoped account against any of them, the two things system
|
||||
// administration can already do that this extends to automation.
|
||||
func isInstanceServiceAccount(ctx context.Context) bool {
|
||||
sa, ok := serviceAccountFromContext(ctx)
|
||||
return ok && sa.scope == models.ServiceAccountScopeInstance
|
||||
}
|
||||
|
||||
// operatorReason marks a write that operator mode refused as such, distinct
|
||||
// from every other 403 this server returns, so a client — the web UI or
|
||||
// terdut-tui — can tell "you may not" from "this is managed elsewhere" and
|
||||
// show the right message instead of a bare "forbidden".
|
||||
const operatorReason = "operator_managed"
|
||||
|
||||
// OperatorModeBlock refuses a human write (session or a user's own API key)
|
||||
// on a route it wraps, while letting a service account through. That is the
|
||||
// whole point of operator mode: automation holding a service-account key
|
||||
// (terdut-operator, most likely) keeps reconciling these resources, and a
|
||||
// person in the web UI or terdut-tui gets a clear "edit this through your
|
||||
// GitOps source instead" rather than a write that the next resync would only
|
||||
// undo.
|
||||
//
|
||||
// Checked after AuthMiddleware, the same way AdminOnly is: by the time a
|
||||
// request reaches here the caller is already known to be a service account
|
||||
// or not. A router that never enables operator mode pays nothing for this —
|
||||
// it hands back next unchanged rather than wrapping it in a check that would
|
||||
// always pass.
|
||||
func OperatorModeBlock(cfg config.Config) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
if !cfg.OperatorMode {
|
||||
return next
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := serviceAccountFromContext(r.Context()); ok {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusForbidden, map[string]string{
|
||||
"error": "this server is in operator mode; edit this through your GitOps source instead of the web UI or API",
|
||||
"reason": operatorReason,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// membership is the caller's role in one team.
|
||||
type membership struct {
|
||||
teamID int64
|
||||
|
||||
Reference in New Issue
Block a user