a4dd60f6b8
Service accounts (SERVICE-ACCOUNTS.md) are a scoped, non-human credential: not a users row, so they never touch OIDC sync, login or the is_admin flag. Instance scope can create a team and mint a team-scoped account for it; team scope is owner-equivalent for that one team and nothing else. This is what unblocks terdut-operator's DESIGN.md §6 — no more impersonating a human admin, and a real rotation story instead of the unworkable delete-and-re-bootstrap /api/bootstrap can't actually do. - migration 014: service_accounts + service_account_keys - POST /api/service-accounts, POST/DELETE .../keys, GET ?name= self-lookup - AuthMiddleware resolves a tdsa_-prefixed key to a distinct principal; a team-scoped account gets a synthetic single membership so requireTeamMember/requireTeamOwner work on it unmodified - handleCreateTeam accepts an instance-scoped caller; the team it creates has no human owner, which is the expected shape for one an operator is about to hand a team-scoped credential to Operator mode (TERDUT_OPERATOR_MODE / values.operatorMode) declares an install gitops-managed: session and user-API-key writes to teams, escalation policies, dead man's switches and integrations get 403 reason=operator_managed, while a service account's writes still go through. Team membership/invites and the schedule are deliberately left out — never gitops-managed by design, and still human day-to-day work. /api/auth/config reports operator_mode so the web UI can grey these sections out from the start rather than only after a write fails. Also: GET /api/version (both terdut-tui and terdut-operator currently detect server capability by route-probing; this gives them a real answer), and a PUT for dead man's switches so a reconciler can update one in place instead of deleting and recreating it.
367 lines
13 KiB
Go
367 lines
13 KiB
Go
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))
|
|
}
|
|
}
|