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:
+23
-14
@@ -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 }}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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".
|
||||
|
||||
@@ -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);
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user