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:
Niklas Ye
2026-09-29 21:25:17 +02:00
parent b5573fbca2
commit a4dd60f6b8
15 changed files with 1111 additions and 45 deletions
+23 -14
View File
@@ -126,12 +126,17 @@ hashes to a `service_account_keys.key_hash` resolves to a distinct principal
type, not a synthesized `models.User`. `requireTeamMember`/`requireTeamOwner`
treat a matching team-scoped service account as owner-equivalent for that one
team (satisfies the same checks a real team owner would), and an instance-scoped
one as satisfying `AdminOnly` for team-creation/listing purposes only — never
for user-management endpoints (`POST /api/users`, `PUT /api/users/{id}/admin`,
etc.), which stay human-admin-only. Anywhere identity is recorded for a human
(incident timeline `acknowledged_by`/`assigned_to`, audit-relevant fields), a
service-account principal is stored and displayed distinctly, e.g.
`service-account:terdut-operator`, never coerced into a `user_id` FK.
one as satisfying `AdminOnly` for team-creation/listing purposes **and** for
minting a `team`-scoped service account against any team (`POST
/api/service-accounts {"scope":"team","teamID":...}`) — this second permission
is what lets an operator-style caller create a team, then immediately mint that
team its own narrower credential, without a human in the loop for every team.
Neither permission extends to user-management endpoints (`POST /api/users`,
`PUT /api/users/{id}/admin`, etc.), which stay human-admin-only. Anywhere
identity is recorded for a human (incident timeline
`acknowledged_by`/`assigned_to`, audit-relevant fields), a service-account
principal is stored and displayed distinctly, e.g. `service-account:terdut-operator`,
never coerced into a `user_id` FK.
## What this unblocks
@@ -146,14 +151,18 @@ Directly resolves `terdut-operator` DESIGN.md §6's two broken assumptions:
2. **Rotation becomes real.** `POST /api/service-accounts/{id}/keys` + revoke the
old one — no destructive DB-level workaround, no re-triggering a single-shot
endpoint that can't fire twice.
3. **Cross-namespace credential mirroring becomes unnecessary.** Once team-scoped
accounts exist, `terdut-operator`'s `TerdutServer` controller can mint one
key per `TerdutTeam` directly into that `TerdutTeam`'s own namespace
(owner-referenced to the CR) instead of mirroring one shared,
server-admin-equivalent credential into every consenting namespace. This
also closes the blast-radius gap that mirroring left open: a leaked Secret
today would expose every team on the server; a leaked team-scoped key
exposes exactly one team.
3. **Cross-namespace credential mirroring is no longer needed at all.**
`terdut-operator`'s current design holds every credential — instance- and
team-scoped alike — privately in the operator's own namespace, never in
the namespace of the CR each one authenticates for; reconciliation happens
entirely inside the operator's controller loop, so no CR owner ever needs
read access to a terdut-server credential regardless of same- or
cross-namespace `serverRef`. Team scoping is still what bounds the blast
radius of any individual credential: a leaked team-scoped key exposes
exactly one team's resources, never the whole server, which is what makes
holding many credentials in one place (the operator's namespace) an
acceptable trade rather than reintroducing the mirrored design's
server-admin-equivalent-everywhere problem.
## Suggested sequencing
@@ -75,6 +75,8 @@ spec:
value: "{{ .Values.notify.publicUrl | default (printf "https://%s" .Values.networking.hostname) }}"
- name: TERDUT_PASSWORD_LOGIN
value: {{ .Values.passwordLogin | quote }}
- name: TERDUT_OPERATOR_MODE
value: {{ .Values.operatorMode | quote }}
{{- if .Values.oidc.enabled }}
- name: TERDUT_OIDC_ISSUER
value: {{ required "oidc.issuer is required when oidc.enabled" .Values.oidc.issuer | quote }}
+8
View File
@@ -112,6 +112,14 @@ notify:
# redeploy) if the identity provider is down and somebody has to get in.
passwordLogin: true
# Declares this install gitops-managed: writes to teams, escalation policies,
# dead man's switches and integrations from a session or a user's own API key
# are refused, while a service account's (see SERVICE-ACCOUNTS.md) are not.
# Off by default — turning it on is a statement that something like
# terdut-operator, not a person in the web UI, owns this install's
# configuration from here on.
operatorMode: false
# Single sign-on through an OpenID Connect provider such as Authentik.
#
# At the provider, create an OAuth2/OpenID application whose redirect URI is
+1 -1
View File
@@ -54,7 +54,7 @@ func main() {
log.Fatalf("seed settings: %v", err)
}
router := api.NewRouter(database, notify, cfg)
router := api.NewRouter(database, notify, cfg, version)
srv := &http.Server{
Addr: cfg.Addr,
+1 -1
View File
@@ -60,7 +60,7 @@ func newTSWith(t *testing.T, deadman api.DeadmanConfig, cfg api.NotifyConfig, co
t.Helper()
database := newTestDB(t)
srv := httptest.NewServer(api.NewRouter(database, cfg, conf))
srv := httptest.NewServer(api.NewRouter(database, cfg, conf, "test"))
t.Cleanup(srv.Close)
body, _ := json.Marshal(map[string]string{"username": "admin", "email": "admin@test.com"})
+1 -1
View File
@@ -301,7 +301,7 @@ func TestSetPassword_EndsOtherSessionsButNotThisOne(t *testing.T) {
func TestBootstrap_WithPassword(t *testing.T) {
database := newTestDB(t)
srv := httptest.NewServer(api.NewRouter(database, api.NotifyConfig{}, testConfig()))
srv := httptest.NewServer(api.NewRouter(database, api.NotifyConfig{}, testConfig(), "test"))
t.Cleanup(srv.Close)
body := `{"username":"admin","email":"a@test.com","password":"` + adminPassword + `"}`
+122 -7
View File
@@ -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
+10 -1
View File
@@ -66,9 +66,18 @@ func handleAuthConfig(cfg config.Config) http.HandlerFunc {
// DeviceLogin is whether a client that cannot open a browser (the TUI)
// can sign in by showing a code, through /api/oidc/device.
DeviceLogin bool `json:"device_login"`
// OperatorMode is whether this install is gitops-managed: writes to
// teams, escalation policies, dead man's switches and integrations
// from a session or a user's own API key are refused (OperatorModeBlock),
// though a service account's are not. The web UI reads this before
// anybody signs in, the same way it reads PasswordLogin/OIDC, so it can
// show those sections read-only from the start rather than only after
// a write fails.
OperatorMode bool `json:"operator_mode"`
}
return func(w http.ResponseWriter, r *http.Request) {
resp := response{PasswordLogin: !cfg.DisablePasswordLogin}
resp := response{PasswordLogin: !cfg.DisablePasswordLogin, OperatorMode: cfg.OperatorMode}
if cfg.OIDC.Enabled() {
resp.OIDC = oidcInfo{Enabled: true, Name: cfg.OIDC.Name}
resp.DeviceLogin = true
+38 -13
View File
@@ -14,8 +14,12 @@ import (
// NewRouter builds the HTTP surface. notify is passed through to the webhook,
// the only handler that has to decide where a new incident's page goes; a zero
// notify disables notifications. Dead man's switches are per team and read from
// the database, so nothing about them is wired in here.
func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler {
// the database, so nothing about them is wired in here. version is reported
// verbatim by GET /api/version, unauthenticated like /healthz: a client
// deciding whether it can talk to this server — terdut-tui, terdut-operator —
// needs to ask before it holds a credential for it, and the version is not a
// secret.
func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config, version string) http.Handler {
// One limiter each, both process-wide for the life of the router: login
// counts failed passwords, sign-up counts account creation, and mixing the
// two would let a burst of sign-ups lock somebody out of logging in.
@@ -30,6 +34,9 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
respond(w, http.StatusOK, map[string]string{"status": "ok"})
})
r.Get("/api/version", func(w http.ResponseWriter, r *http.Request) {
respond(w, http.StatusOK, map[string]string{"version": version})
})
// Unauthenticated: bootstrap, the Alertmanager webhook receiver, and the
// Acknowledge button in a push notification. The last one is authorised by
@@ -151,11 +158,26 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler
r.Post("/api/incidents/{id}/notes", handleCreateNote(db))
r.Delete("/api/incidents/{id}/notes/{eventID}", handleDeleteNote(db))
// Service accounts: a scoped, non-human credential for automation
// (terdut-operator, most likely) that needs to manage the resources
// below without impersonating a human user. See SERVICE-ACCOUNTS.md.
r.Get("/api/service-accounts", handleListServiceAccounts(db))
r.Post("/api/service-accounts", handleCreateServiceAccount(db))
r.Post("/api/service-accounts/{id}/keys", handleCreateServiceAccountKey(db))
r.Delete("/api/service-accounts/{id}/keys/{keyID}", handleDeleteServiceAccountKey(db))
// Operator mode (TERDUT_OPERATOR_MODE) makes every write below refuse a
// human caller (a session or a user's own API key) while still letting
// a service account through — see OperatorModeBlock. opMode is a no-op
// wrapper when the flag is off, so this costs nothing on a server that
// never sets it.
opMode := OperatorModeBlock(cfg)
// Teams. A user sees the teams they belong to; an owner configures one.
r.Get("/api/teams", handleListTeams(db))
r.Post("/api/teams", handleCreateTeam(db))
r.Put("/api/teams/{teamID}", handleRenameTeam(db))
r.Delete("/api/teams/{teamID}", handleDeleteTeam(db))
r.With(opMode).Post("/api/teams", handleCreateTeam(db))
r.With(opMode).Put("/api/teams/{teamID}", handleRenameTeam(db))
r.With(opMode).Delete("/api/teams/{teamID}", handleDeleteTeam(db))
r.Get("/api/teams/{teamID}/members", handleListTeamMembers(db))
r.Post("/api/teams/{teamID}/members", handleAddTeamMember(db))
r.Delete("/api/teams/{teamID}/members/{userID}", handleRemoveTeamMember(db))
@@ -163,28 +185,31 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler
// A team's own OIDC group binding: which provider groups grant member
// and owner access to it.
r.Get("/api/teams/{teamID}/oidc-groups", handleGetTeamOIDCGroups(db))
r.Put("/api/teams/{teamID}/oidc-groups", handleSetTeamOIDCGroups(db))
r.With(opMode).Put("/api/teams/{teamID}/oidc-groups", handleSetTeamOIDCGroups(db))
// Invite links into this team.
// Invite links into this team. Not operator-mode-gated: membership is
// deliberately never gitops-managed (see terdut-operator's DESIGN.md
// §4.2), so it stays editable regardless of this flag.
r.Get("/api/teams/{teamID}/invites", handleListInvites(db))
r.Post("/api/teams/{teamID}/invites", handleCreateInvite(db, notify.PublicURL))
r.Delete("/api/teams/{teamID}/invites/{inviteID}", handleRevokeInvite(db))
// A team's escalation ladder: who is paged when nobody answers.
r.Get("/api/teams/{teamID}/escalation", handleGetEscalation(db))
r.Put("/api/teams/{teamID}/escalation", handleSetEscalation(db))
r.With(opMode).Put("/api/teams/{teamID}/escalation", handleSetEscalation(db))
// A team's own dead man's switches: which of its alerts are heartbeats,
// and how long a silence has to last before somebody is paged.
r.Get("/api/teams/{teamID}/deadman/switches", handleListTeamDeadman(db))
r.Post("/api/teams/{teamID}/deadman/switches", handleCreateTeamDeadman(db))
r.Delete("/api/teams/{teamID}/deadman/switches/{switchID}", handleDeleteTeamDeadman(db))
r.With(opMode).Post("/api/teams/{teamID}/deadman/switches", handleCreateTeamDeadman(db))
r.With(opMode).Put("/api/teams/{teamID}/deadman/switches/{switchID}", handleUpdateTeamDeadman(db))
r.With(opMode).Delete("/api/teams/{teamID}/deadman/switches/{switchID}", handleDeleteTeamDeadman(db))
// Integrations: where a team's alerts come in, and the key that says so.
r.Get("/api/teams/{teamID}/integrations", handleListIntegrations(db))
r.Post("/api/teams/{teamID}/integrations", handleCreateIntegration(db, notify.PublicURL))
r.Patch("/api/teams/{teamID}/integrations/{integrationID}", handleRenameIntegration(db))
r.Delete("/api/teams/{teamID}/integrations/{integrationID}", handleDeleteIntegration(db))
r.With(opMode).Post("/api/teams/{teamID}/integrations", handleCreateIntegration(db, notify.PublicURL))
r.With(opMode).Patch("/api/teams/{teamID}/integrations/{integrationID}", handleRenameIntegration(db))
r.With(opMode).Delete("/api/teams/{teamID}/integrations/{integrationID}", handleDeleteIntegration(db))
// The rota is per team. /api/schedule/current is the exception: it
// answers across every team the caller is in, which is what somebody on
+327
View File
@@ -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)
}
}
+366
View File
@@ -0,0 +1,366 @@
package api_test
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.ryuvia.com/niklas/terdut-server/internal/api"
"git.ryuvia.com/niklas/terdut-server/internal/models"
)
// reqAs is s.req with an arbitrary bearer credential in place of the admin's
// own key, for exercising a service account's or another user's key.
func (s *ts) reqAs(t *testing.T, key, method, path string, body any) *http.Response {
t.Helper()
var r io.Reader
if body != nil {
data, _ := json.Marshal(body)
r = bytes.NewReader(data)
}
req, _ := http.NewRequest(method, s.URL+path, r)
req.Header.Set("Authorization", "Bearer "+key)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
return resp
}
// createServiceAccount creates a service account as callerKey and returns its
// freshly minted raw key.
func createServiceAccount(t *testing.T, s *ts, callerKey, name, scope string, teamID int64) string {
t.Helper()
body := map[string]any{"name": name, "scope": scope}
if teamID != 0 {
body["team_id"] = teamID
}
resp := s.reqAs(t, callerKey, http.MethodPost, "/api/service-accounts", body)
if resp.StatusCode != http.StatusCreated {
resp.Body.Close()
t.Fatalf("create service account %s: %d", name, resp.StatusCode)
}
var result struct {
Key struct {
Key string `json:"key"`
} `json:"key"`
}
decode(t, resp, &result)
if result.Key.Key == "" {
t.Fatalf("create service account %s: no key returned", name)
}
return result.Key.Key
}
// createTeamAs creates a team as callerKey and returns its id.
func createTeamAs(t *testing.T, s *ts, callerKey, name string) int64 {
t.Helper()
resp := s.reqAs(t, callerKey, http.MethodPost, "/api/teams", map[string]string{"name": name})
if resp.StatusCode != http.StatusCreated {
resp.Body.Close()
t.Fatalf("create team %s: %d", name, resp.StatusCode)
}
var team struct {
ID int64 `json:"id"`
}
decode(t, resp, &team)
return team.ID
}
// ---------------------------------------------------------------------------
// Instance scope
// ---------------------------------------------------------------------------
func TestServiceAccount_InstanceScopeCreatesTeamWithNoHumanOwner(t *testing.T) {
s := newTS(t)
instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0)
if !strings.HasPrefix(instanceKey, "tdsa_") {
t.Errorf("expected a service-account key to carry the tdsa_ prefix, got %q", instanceKey)
}
resp := s.reqAs(t, instanceKey, http.MethodPost, "/api/teams", map[string]string{"name": "provisioned"})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("instance-scoped account creating a team: %d", resp.StatusCode)
}
var team struct {
ID int64 `json:"id"`
Role string `json:"role"`
}
decode(t, resp, &team)
if team.Role != "" {
t.Errorf("expected no role on a team a service account created (no human owner), got %q", team.Role)
}
// It still exists, visible to an administrator, even with no member.
var admin []map[string]any
decode(t, s.req(t, http.MethodGet, "/api/admin/teams", nil), &admin)
found := false
for _, tm := range admin {
if int64(tm["id"].(float64)) == team.ID {
found = true
}
}
if !found {
t.Errorf("expected the service-account-created team to appear in /api/admin/teams")
}
}
func TestServiceAccount_TeamScopeCannotCreateTeam(t *testing.T) {
s := newTS(t)
instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0)
teamA := createTeamAs(t, s, instanceKey, "team-a")
keyA := createServiceAccount(t, s, instanceKey, "team-a-sa", models.ServiceAccountScopeTeam, teamA)
resp := s.reqAs(t, keyA, http.MethodPost, "/api/teams", map[string]string{"name": "should-fail"})
if resp.StatusCode != http.StatusForbidden {
t.Errorf("expected 403, a team-scoped account creating a team, got %d", resp.StatusCode)
}
resp.Body.Close()
}
// ---------------------------------------------------------------------------
// Team scope
// ---------------------------------------------------------------------------
// The whole point of team scope: bound to its own team, refused everywhere
// else, the same as an instance-scoped account minting a key per TerdutTeam
// rather than sharing one server-admin-equivalent credential would need.
func TestServiceAccount_TeamScopeIsBoundToItsOwnTeam(t *testing.T) {
s := newTS(t)
instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0)
teamA := createTeamAs(t, s, instanceKey, "team-a")
teamB := createTeamAs(t, s, instanceKey, "team-b")
keyA := createServiceAccount(t, s, instanceKey, "team-a-sa", models.ServiceAccountScopeTeam, teamA)
policy := map[string]any{"repeat_count": 0, "fallback_topic": "", "levels": []any{}}
resp := s.reqAs(t, keyA, http.MethodPut, "/api/teams/"+id64(teamA)+"/escalation", policy)
if resp.StatusCode != http.StatusOK {
t.Fatalf("team-a's own key setting its escalation: %d", resp.StatusCode)
}
resp.Body.Close()
// 404, not 403: the same "does this exist" refusal a human non-member
// gets from requireTeamMember, not a distinguishable "you may not".
resp2 := s.reqAs(t, keyA, http.MethodPut, "/api/teams/"+id64(teamB)+"/escalation", policy)
if resp2.StatusCode != http.StatusNotFound {
t.Errorf("expected 404 reaching into another team, got %d", resp2.StatusCode)
}
resp2.Body.Close()
}
// Team scope is owner-equivalent broadly (SERVICE-ACCOUNTS.md), not limited to
// one endpoint: escalation, dead man's switches and integrations all work.
func TestServiceAccount_TeamScopeManagesItsResources(t *testing.T) {
s := newTS(t)
instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0)
teamA := createTeamAs(t, s, instanceKey, "team-a")
keyA := createServiceAccount(t, s, instanceKey, "team-a-sa", models.ServiceAccountScopeTeam, teamA)
resp := s.reqAs(t, keyA, http.MethodPost, "/api/teams/"+id64(teamA)+"/deadman/switches",
map[string]any{"matcher": "alertname=Watchdog", "timeout_seconds": 900, "severity": "critical"})
if resp.StatusCode != http.StatusCreated {
t.Errorf("team-scoped account creating a dead man's switch: %d", resp.StatusCode)
}
resp.Body.Close()
resp2 := s.reqAs(t, keyA, http.MethodPost, "/api/teams/"+id64(teamA)+"/integrations",
map[string]string{"name": "prod"})
if resp2.StatusCode != http.StatusCreated {
t.Errorf("team-scoped account creating an integration: %d", resp2.StatusCode)
}
resp2.Body.Close()
}
// ---------------------------------------------------------------------------
// Key rotation
// ---------------------------------------------------------------------------
func TestServiceAccount_SelfRotatesItsOwnKey(t *testing.T) {
s := newTS(t)
instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0)
// Self-lookup by name, the pattern that turns /api/bootstrap's 403 into a
// normal flow instead of an unhandled error.
var accounts []map[string]any
decode(t, s.reqAs(t, instanceKey, http.MethodGet, "/api/service-accounts?name=terdut-operator", nil), &accounts)
if len(accounts) != 1 {
t.Fatalf("expected exactly one match for ?name=terdut-operator, got %d", len(accounts))
}
id := int64(accounts[0]["id"].(float64))
resp := s.reqAs(t, instanceKey, http.MethodPost, "/api/service-accounts/"+id64(id)+"/keys",
map[string]string{"name": "rotated"})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("self-rotation: %d", resp.StatusCode)
}
var newKey struct {
Key string `json:"key"`
}
decode(t, resp, &newKey)
if resp := s.reqAs(t, newKey.Key, http.MethodPost, "/api/teams", map[string]string{"name": "after-rotation"}); resp.StatusCode != http.StatusCreated {
t.Errorf("expected the newly rotated key to work, got %d", resp.StatusCode)
} else {
resp.Body.Close()
}
// Rotation adds a key, it does not itself revoke the old one.
if resp := s.reqAs(t, instanceKey, http.MethodGet, "/api/service-accounts?name=terdut-operator", nil); resp.StatusCode != http.StatusOK {
t.Errorf("expected the original key to still work until explicitly revoked, got %d", resp.StatusCode)
} else {
resp.Body.Close()
}
}
// ---------------------------------------------------------------------------
// Operator mode
// ---------------------------------------------------------------------------
// Operator mode is exercised against a second router over an
// already-configured database, rather than turning it on for newTSWith's own
// setup: that setup creates the default integration with the admin's (human)
// key, which is precisely the write operator mode exists to refuse, and in
// the real deployment this flag targets that setup was never done by a human
// to begin with — the operator itself would have provisioned it.
func TestOperatorMode_BlocksHumanWritesButAllowsServiceAccounts(t *testing.T) {
s := newTS(t)
conf := testConfig()
conf.OperatorMode = true
opSrv := httptest.NewServer(api.NewRouter(s.db, s.notify, conf, "test"))
t.Cleanup(opSrv.Close)
do := func(key, method, path string, body any) *http.Response {
t.Helper()
var r io.Reader
if body != nil {
data, _ := json.Marshal(body)
r = bytes.NewReader(data)
}
req, _ := http.NewRequest(method, opSrv.URL+path, r)
req.Header.Set("Authorization", "Bearer "+key)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
return resp
}
// The bootstrap admin's own key is a human credential: refused.
resp := do(s.key, http.MethodPost, "/api/teams", map[string]string{"name": "human-team"})
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("expected 403 for a human write under operator mode, got %d", resp.StatusCode)
}
var refusal map[string]string
decode(t, resp, &refusal)
if refusal["reason"] != "operator_managed" {
t.Errorf("expected reason=operator_managed, got %q", refusal["reason"])
}
// Creating the service account itself is not gated by operator mode —
// it is how an operator identifies itself, not one of the resources it
// manages.
resp2 := do(s.key, http.MethodPost, "/api/service-accounts",
map[string]any{"name": "terdut-operator", "scope": models.ServiceAccountScopeInstance})
if resp2.StatusCode != http.StatusCreated {
t.Fatalf("create service account under operator mode: %d", resp2.StatusCode)
}
var result struct {
Key struct {
Key string `json:"key"`
} `json:"key"`
}
decode(t, resp2, &result)
resp3 := do(result.Key.Key, http.MethodPost, "/api/teams", map[string]string{"name": "operator-team"})
if resp3.StatusCode != http.StatusCreated {
t.Fatalf("expected 201 for a service-account write under operator mode, got %d", resp3.StatusCode)
}
resp3.Body.Close()
// Reads are unaffected regardless of caller.
if resp := do(s.key, http.MethodGet, "/api/teams", nil); resp.StatusCode != http.StatusOK {
t.Errorf("expected reads to stay open under operator mode, got %d", resp.StatusCode)
} else {
resp.Body.Close()
}
}
func TestOperatorMode_OffLeavesHumanWritesAlone(t *testing.T) {
s := newTS(t) // testConfig(): OperatorMode false
resp := s.req(t, http.MethodPost, "/api/teams", map[string]string{"name": "still-fine"})
if resp.StatusCode != http.StatusCreated {
t.Errorf("expected a human write to succeed with operator mode off, got %d", resp.StatusCode)
}
resp.Body.Close()
}
// ---------------------------------------------------------------------------
// Version
// ---------------------------------------------------------------------------
func TestVersion(t *testing.T) {
s := newTS(t)
resp, err := http.Get(s.URL + "/api/version")
if err != nil {
t.Fatalf("get version: %v", err)
}
var v struct {
Version string `json:"version"`
}
decode(t, resp, &v)
if v.Version != "test" {
t.Errorf("expected version %q, got %q", "test", v.Version)
}
}
// ---------------------------------------------------------------------------
// Dead man's switch update-in-place
// ---------------------------------------------------------------------------
func TestDeadman_UpdateInPlacePreservesID(t *testing.T) {
s := newTS(t)
var created struct {
ID int64 `json:"id"`
}
decode(t, s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/deadman/switches",
map[string]any{"matcher": "alertname=Watchdog", "timeout_seconds": 900, "severity": "critical"}), &created)
resp := s.req(t, http.MethodPut, "/api/teams/"+defaultTeam+"/deadman/switches/"+id64(created.ID),
map[string]any{"name": "renamed", "matcher": "alertname=Watchdog", "timeout_seconds": 1200, "severity": "warning"})
if resp.StatusCode != http.StatusOK {
t.Fatalf("update switch: %d", resp.StatusCode)
}
var updated struct {
ID int64 `json:"id"`
Name string `json:"name"`
TimeoutSeconds int64 `json:"timeout_seconds"`
Severity string `json:"severity"`
}
decode(t, resp, &updated)
if updated.ID != created.ID {
t.Errorf("expected id to stay %d, got %d", created.ID, updated.ID)
}
if updated.Name != "renamed" || updated.TimeoutSeconds != 1200 || updated.Severity != "warning" {
t.Errorf("expected the update to apply, got %+v", updated)
}
var list []map[string]any
decode(t, s.req(t, http.MethodGet, "/api/teams/"+defaultTeam+"/deadman/switches", nil), &list)
if len(list) != 1 {
t.Errorf("expected the update to replace in place, not add a row, got %d switches", len(list))
}
}
+122 -7
View File
@@ -116,6 +116,15 @@ func handleUserTeams(db *sql.DB) http.HandlerFunc {
// handleCreateTeam creates a team and makes its creator the first owner. A team
// with no owner would need an administrator to repair before anybody could use
// it, so the two happen in one transaction.
//
// An instance-scoped service account may also create a team (SERVICE-ACCOUNTS.md:
// it acts with the same reach system administration has over teams), but it
// is not a users row and cannot become an owner the way a person does. The
// team it creates starts with no human owner at all — not a bug, the expected
// shape for one terdut-operator is about to provision: a system administrator
// can always act as owner to repair or hand it off (requireTeamOwner), and
// the account that created it mints itself a team-scoped credential for it
// next, via POST /api/service-accounts.
func handleCreateTeam(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
@@ -131,7 +140,14 @@ func handleCreateTeam(db *sql.DB) http.HandlerFunc {
return
}
caller, _ := userFromContext(r.Context())
caller, isUser := userFromContext(r.Context())
if !isUser && !isInstanceServiceAccount(r.Context()) {
// A team-scoped service account authenticates as owner of exactly
// one team already (see serveAsServiceAccount); letting it create
// another would reach outside that boundary.
respond(w, http.StatusForbidden, errResp("instance-scoped service account or user access required"))
return
}
tx, err := db.BeginTx(r.Context(), nil)
if err != nil {
@@ -152,11 +168,13 @@ func handleCreateTeam(db *sql.DB) http.HandlerFunc {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if _, err := tx.ExecContext(r.Context(),
"INSERT INTO team_members (team_id, user_id, role) VALUES ($1, $2, $3)",
team.ID, caller.ID, models.RoleOwner); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
if isUser {
if _, err := tx.ExecContext(r.Context(),
"INSERT INTO team_members (team_id, user_id, role) VALUES ($1, $2, $3)",
team.ID, caller.ID, models.RoleOwner); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
}
if err := tx.Commit(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
@@ -164,7 +182,9 @@ func handleCreateTeam(db *sql.DB) http.HandlerFunc {
}
team.CreatedAt = time.Unix(created, 0).UTC()
team.Role = models.RoleOwner
if isUser {
team.Role = models.RoleOwner
}
respond(w, http.StatusCreated, team)
}
}
@@ -861,6 +881,101 @@ func handleCreateTeamDeadman(db *sql.DB) http.HandlerFunc {
}
}
// handleUpdateTeamDeadman replaces one switch's configuration in place.
// Added alongside create/delete so an automated caller (terdut-operator) can
// reconcile a spec change without deleting and recreating the switch, which
// would otherwise be the only option and would needlessly rotate its id for
// no reason a reconciler's diff should ever manufacture.
func handleUpdateTeamDeadman(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
teamID, ok := teamParam(w, r)
if !ok {
return
}
if !requireTeamOwner(w, r, teamID) {
return
}
switchID, err := strconv.ParseInt(chi.URLParam(r, "switchID"), 10, 64)
if err != nil {
respond(w, http.StatusBadRequest, errResp("invalid switch id"))
return
}
var req deadmanSwitchRequest
if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid request body"))
return
}
req.Matcher = strings.TrimSpace(req.Matcher)
req.Name = strings.TrimSpace(req.Name)
if req.Severity == "" {
req.Severity = "critical"
}
if !deadmanSeverities[req.Severity] {
respond(w, http.StatusBadRequest, errResp("severity must be critical, error, warning or info"))
return
}
if req.TimeoutSeconds <= 0 {
respond(w, http.StatusBadRequest, errResp("timeout_seconds must be positive"))
return
}
if strings.Contains(req.Matcher, ";") {
respond(w, http.StatusBadRequest, errResp("one matcher per switch: add another switch instead of separating with ;"))
return
}
m, err := parseDeadmanMatcher(req.Matcher)
if err != nil {
respond(w, http.StatusBadRequest, errResp(
"unusable matcher ("+err.Error()+"): each must name an alertname, as in alertname=Watchdog,cluster=prod"))
return
}
if req.Name == "" {
req.Name = m.config()
}
if len(req.Name) > 100 {
respond(w, http.StatusBadRequest, errResp("name is too long"))
return
}
res, err := db.ExecContext(r.Context(), `
UPDATE deadman_switches
SET name = $1, matcher = $2, timeout_seconds = $3, severity = $4
WHERE id = $5 AND team_id = $6`,
req.Name, m.config(), req.TimeoutSeconds, req.Severity, switchID, teamID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if n, _ := res.RowsAffected(); n == 0 {
respond(w, http.StatusNotFound, errResp("switch not found"))
return
}
// The full status, not the bare request echoed back: an update can
// change whether the switch is dormant, alive or dead (a longer
// timeout can revive one that just went dead), and a caller
// reconciling against status deserves the same view
// handleListTeamDeadman would give it.
set, err := deadmanSetForTeam(r.Context(), db, teamID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
statuses, err := deadmanStatuses(r.Context(), db, teamID, set, time.Now())
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
for _, s := range statuses {
if s.ID == switchID {
respond(w, http.StatusOK, s)
return
}
}
respond(w, http.StatusInternalServerError, errResp("internal error"))
}
}
// handleDeleteTeamDeadman removes a switch. An incident it already opened stays
// open until somebody resolves it: deleting the switch says "stop watching", not
// "the problem is gone".
+10
View File
@@ -72,6 +72,14 @@ type Config struct {
// OIDC configures single sign-on. The zero value, with no Issuer, is off.
OIDC OIDC
// OperatorMode declares this install gitops-managed: writes to teams,
// escalation policies, dead man's switches and integrations from a human
// (a session or a user's own API key) are refused, while a service
// account's are not. Deploy-time and restart-required, like the rest of
// "where this server is plugged in" — it is a statement about who owns
// this install's configuration, not a per-request toggle.
OperatorMode bool
}
// OIDC is the single sign-on configuration. Groups from the provider decide
@@ -151,6 +159,8 @@ func Load() Config {
DisablePasswordLogin: !boolean("TERDUT_PASSWORD_LOGIN", true),
OIDC: loadOIDC(),
OperatorMode: boolean("TERDUT_OPERATOR_MODE", false),
}
}
@@ -0,0 +1,43 @@
-- Service accounts: a scoped, non-human credential for automation (e.g.
-- terdut-operator) that needs to manage teams, escalation policies, dead
-- man's switches, integrations and OIDC group bindings without impersonating
-- a human user. See SERVICE-ACCOUNTS.md for the design this implements.
--
-- Deliberately not a users row: no password_hash, no is_admin, no
-- user_identities linkage, so a service account can never be pulled into
-- OIDC group sync or password login, and is never mistaken for a human in an
-- audit trail.
--
-- scope is 'instance' (acts with the same reach system administration has
-- over teams: create one, list them, mint a 'team'-scoped account against
-- any of them) or 'team' (acts as that one team's owner, and nothing else).
-- The CHECK ties team_id's presence to scope directly, rather than leaving it
-- to application code to keep the two consistent.
CREATE TABLE service_accounts (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
scope TEXT NOT NULL CHECK (scope IN ('instance', 'team')),
team_id BIGINT REFERENCES teams(id) ON DELETE CASCADE,
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
created_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint,
CONSTRAINT service_accounts_scope_team_id_chk CHECK (
(scope = 'team' AND team_id IS NOT NULL) OR
(scope = 'instance' AND team_id IS NULL)
)
);
CREATE INDEX service_accounts_team_id_idx ON service_accounts(team_id);
-- One account, many keys: rotation is minting a new one and revoking the
-- old, the same shape api_keys already has, so an account's identity and
-- audit history survive a rotation instead of being recreated by it.
CREATE TABLE service_account_keys (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
service_account_id BIGINT NOT NULL REFERENCES service_accounts(id) ON DELETE CASCADE,
key_hash TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
created_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint,
last_used_at BIGINT
);
CREATE INDEX service_account_keys_service_account_id_idx ON service_account_keys(service_account_id);
+37
View File
@@ -0,0 +1,37 @@
package models
import "time"
// Service account scopes. Instance acts with the same reach system
// administration has over teams: create one, list them, mint a team-scoped
// account against any of them. Team acts as that one team's owner, and
// nothing else.
const (
ServiceAccountScopeInstance = "instance"
ServiceAccountScopeTeam = "team"
)
// ServiceAccount is a non-human credential: not a users row, so it never
// touches OIDC group sync, login, or the is_admin flag, and is never mistaken
// for a human in an audit trail (see api_keys' user_id, which every service
// account key deliberately does not have).
type ServiceAccount struct {
ID int64 `json:"id"`
Name string `json:"name"`
Scope string `json:"scope"`
TeamID *int64 `json:"team_id,omitempty"`
CreatedBy *int64 `json:"created_by,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// ServiceAccountKey is one bearer credential on a ServiceAccount. Multiple
// keys per account, the same shape as APIKey, are what let rotation mint a
// new one and revoke the old without recreating the account.
type ServiceAccountKey struct {
ID int64 `json:"id"`
ServiceAccountID int64 `json:"service_account_id"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
Key string `json:"key,omitempty"` // populated only on creation, never stored
}