From a4dd60f6b8d852d3c3e2b8a476c9d336ab69892c Mon Sep 17 00:00:00 2001 From: Niklas Ye Date: Tue, 29 Sep 2026 21:25:17 +0200 Subject: [PATCH] Add service accounts and operator mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- SERVICE-ACCOUNTS.md | 37 +- .../terdut-server/templates/deployment.yaml | 2 + charts/terdut-server/values.yaml | 8 + cmd/terdut/main.go | 2 +- internal/api/api_test.go | 2 +- internal/api/auth_test.go | 2 +- internal/api/middleware.go | 129 +++++- internal/api/oidc.go | 11 +- internal/api/router.go | 51 ++- internal/api/service_accounts.go | 327 ++++++++++++++++ internal/api/service_accounts_test.go | 366 ++++++++++++++++++ internal/api/teams.go | 129 +++++- internal/config/config.go | 10 + .../db/migrations/014_service_accounts.sql | 43 ++ internal/models/service_account.go | 37 ++ 15 files changed, 1111 insertions(+), 45 deletions(-) create mode 100644 internal/api/service_accounts.go create mode 100644 internal/api/service_accounts_test.go create mode 100644 internal/db/migrations/014_service_accounts.sql create mode 100644 internal/models/service_account.go diff --git a/SERVICE-ACCOUNTS.md b/SERVICE-ACCOUNTS.md index 92592c2..7d7404e 100644 --- a/SERVICE-ACCOUNTS.md +++ b/SERVICE-ACCOUNTS.md @@ -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 diff --git a/charts/terdut-server/templates/deployment.yaml b/charts/terdut-server/templates/deployment.yaml index 1d49ad6..0ecf1b3 100644 --- a/charts/terdut-server/templates/deployment.yaml +++ b/charts/terdut-server/templates/deployment.yaml @@ -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 }} diff --git a/charts/terdut-server/values.yaml b/charts/terdut-server/values.yaml index e9b0e68..df83661 100644 --- a/charts/terdut-server/values.yaml +++ b/charts/terdut-server/values.yaml @@ -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 diff --git a/cmd/terdut/main.go b/cmd/terdut/main.go index 3430b6a..2997f5c 100644 --- a/cmd/terdut/main.go +++ b/cmd/terdut/main.go @@ -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, diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 0eb4274..ec26d42 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -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"}) diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go index 27b9c58..8b21e09 100644 --- a/internal/api/auth_test.go +++ b/internal/api/auth_test.go @@ -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 + `"}` diff --git a/internal/api/middleware.go b/internal/api/middleware.go index fe53310..fc2204f 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -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 diff --git a/internal/api/oidc.go b/internal/api/oidc.go index 0e5b306..cc15bf0 100644 --- a/internal/api/oidc.go +++ b/internal/api/oidc.go @@ -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 diff --git a/internal/api/router.go b/internal/api/router.go index 4a482b6..a484bb9 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -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 diff --git a/internal/api/service_accounts.go b/internal/api/service_accounts.go new file mode 100644 index 0000000..90f7afd --- /dev/null +++ b/internal/api/service_accounts.go @@ -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) + } +} diff --git a/internal/api/service_accounts_test.go b/internal/api/service_accounts_test.go new file mode 100644 index 0000000..559b25e --- /dev/null +++ b/internal/api/service_accounts_test.go @@ -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)) + } +} diff --git a/internal/api/teams.go b/internal/api/teams.go index 59cadba..e2c9b45 100644 --- a/internal/api/teams.go +++ b/internal/api/teams.go @@ -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". diff --git a/internal/config/config.go b/internal/config/config.go index cfffdd7..565638c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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), } } diff --git a/internal/db/migrations/014_service_accounts.sql b/internal/db/migrations/014_service_accounts.sql new file mode 100644 index 0000000..bd93644 --- /dev/null +++ b/internal/db/migrations/014_service_accounts.sql @@ -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); diff --git a/internal/models/service_account.go b/internal/models/service_account.go new file mode 100644 index 0000000..413a043 --- /dev/null +++ b/internal/models/service_account.go @@ -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 +}