diff --git a/README.md b/README.md index 91daf23..ee4d561 100644 --- a/README.md +++ b/README.md @@ -445,6 +445,20 @@ Authorization: Bearer or the web UI's session cookie. A request that carries an `Authorization` header is judged on that header alone. +Two kinds of user exist. An **administrator** manages accounts: creating and +deleting users, setting anybody's password, minting keys for anybody, and +granting the flag itself. Everybody else works incidents — acknowledging, +assigning, snoozing, resolving, noting — and manages their own account and +nobody else's. An API key carries exactly the rights of the user it belongs to. + +The first user, from `/api/bootstrap`, is an administrator. Users created +afterwards are not, until an administrator says so. An install always keeps at +least one: the last administrator can be neither deleted nor demoted, and +nobody can delete or demote themselves. + +Endpoints that require the flag answer `403` with +`{"error":"administrator access required"}`. + | Method | Path | Description | |---|---|---| | `POST` | `/api/login` | `{"username","password"}` → sets the session cookie, returns `{user, has_password}`. `429` after too many failures | @@ -455,14 +469,21 @@ is judged on that header alone. | Method | Path | Description | |---|---|---| -| `POST` | `/api/bootstrap` | Create first user + API key `{"username","email","password"?}` (only works on empty DB) | -| `GET` | `/api/users` | List users | -| `POST` | `/api/users` | Create user `{"username","email"}` | -| `DELETE` | `/api/users/{id}` | Delete user (cascades to keys) | -| `PUT` | `/api/users/{id}/notify` | Set push notification target `{"ntfy_topic"}` — empty string clears it | -| `PUT` | `/api/users/{id}/password` | Set web UI password `{"password","current_password"}`. `current_password` is required only when changing your own existing password. Ends the user's other sessions | -| `POST` | `/api/users/{id}/api-keys` | Issue API key `{"name"}` — key shown once | -| `DELETE` | `/api/users/{id}/api-keys/{keyID}` | Revoke API key | +**admin** marks an endpoint that requires the administrator flag; **self or +admin** marks one you may use on your own account and an administrator may use +on anybody's. + +| Method | Path | Who | Description | +|---|---|---|---| +| `POST` | `/api/bootstrap` | — | Create first user + API key `{"username","email","password"?}` (only works on empty DB). The user is an administrator | +| `GET` | `/api/users` | any | List users. Open to everybody: the queue's assignment control and the schedule both have to name people | +| `POST` | `/api/users` | **admin** | Create user `{"username","email"}`. Not an administrator | +| `DELETE` | `/api/users/{id}` | **admin** | Delete user (cascades to keys). `409` for yourself or the last administrator | +| `PUT` | `/api/users/{id}/admin` | **admin** | Grant or revoke the administrator flag `{"is_admin"}`. `409` for yourself or the last administrator | +| `PUT` | `/api/users/{id}/notify` | self or admin | Set push notification target `{"ntfy_topic"}` — empty string clears it | +| `PUT` | `/api/users/{id}/password` | self or admin | Set web UI password `{"password","current_password"}`. `current_password` is required only when changing your own existing password. Ends the user's other sessions | +| `POST` | `/api/users/{id}/api-keys` | self or admin | Issue API key `{"name"}` — key shown once | +| `DELETE` | `/api/users/{id}/api-keys/{keyID}` | self or admin | Revoke API key | ### Alert ingestion @@ -678,6 +699,28 @@ averages over incidents that have actually been acknowledged or resolved, and ar --- +## Upgrading to roles + +Before this release every authenticated caller could create and delete users, +set anybody's password and mint anybody's API keys. That is now the +administrator flag, and the migration **makes every existing user an +administrator** — they already held those powers, so nobody's access changes on +upgrade and demotion is a deliberate act afterwards. Promoting only the first +user would have silently stripped the rest, and could leave an install whose +only administrator is an account nobody has a password for. + +Users created after the upgrade are not administrators. Hand the flag out with: + +```bash +curl -X PUT https://terdut.example.com/api/users/7/admin \ + -H "Authorization: Bearer $TERDUT_API_KEY" \ + -H 'Content-Type: application/json' \ + -d '{"is_admin": true}' +``` + +Nothing in the API changed shape, so terdut-tui needs no new version — but a +non-administrator now gets `403` where a `200` used to come back. + ## Upgrading from SQLite Versions up to v0.10.2 stored everything in a SQLite file. From the Postgres release onwards diff --git a/internal/api/admin_test.go b/internal/api/admin_test.go new file mode 100644 index 0000000..2ac083a --- /dev/null +++ b/internal/api/admin_test.go @@ -0,0 +1,255 @@ +package api_test + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "strconv" + "testing" +) + +// The bootstrap user is an administrator; everybody it creates afterwards is +// not. These tests are about the line between them. + +// id64 spells an id into a path segment. +func id64(n int64) string { return strconv.FormatInt(n, 10) } + +// member creates an ordinary user and an API key for it, and returns a caller +// that authenticates as them. Minting the key goes through the admin's own +// credentials, which is how a real install hands one out. +func member(t *testing.T, s *ts, username string) (id int64, call func(method, path string, body any) *http.Response) { + t.Helper() + + resp := s.req(t, http.MethodPost, "/api/users", + map[string]string{"username": username, "email": username + "@test.com"}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create %s: %d", username, resp.StatusCode) + } + var user struct { + ID int64 `json:"id"` + IsAdmin bool `json:"is_admin"` + } + decode(t, resp, &user) + if user.IsAdmin { + t.Fatalf("a created user must not be an administrator") + } + + resp = s.req(t, http.MethodPost, "/api/users/"+id64(user.ID)+"/api-keys", + map[string]string{"name": "test"}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("mint key for %s: %d", username, resp.StatusCode) + } + var key struct { + Key string `json:"key"` + } + decode(t, resp, &key) + + return user.ID, func(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.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 whole point of the release: a user who is not an administrator cannot +// manage other people's accounts. Every one of these was open to any +// authenticated caller before. +func TestAdmin_MemberIsRefusedAdministration(t *testing.T) { + s := newTS(t) + memberID, call := member(t, s, "member") + + cases := []struct { + name string + method string + path string + body any + }{ + {"create a user", http.MethodPost, "/api/users", + map[string]string{"username": "sneaky", "email": "sneaky@test.com"}}, + {"delete the admin", http.MethodDelete, "/api/users/1", nil}, + {"grant themselves admin", http.MethodPut, "/api/users/" + id64(memberID) + "/admin", + map[string]bool{"is_admin": true}}, + {"set the admin's password", http.MethodPut, "/api/users/1/password", + map[string]string{"password": "hunter2-hunter2"}}, + {"mint a key for the admin", http.MethodPost, "/api/users/1/api-keys", + map[string]string{"name": "borrowed"}}, + {"retarget the admin's notifications", http.MethodPut, "/api/users/1/notify", + map[string]string{"ntfy_topic": "attacker-topic"}}, + } + + for _, c := range cases { + resp := call(c.method, c.path, c.body) + resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Errorf("%s: expected 403, got %d", c.name, resp.StatusCode) + } + } +} + +// Being refused other people's accounts must not cost a user their own. +func TestAdmin_MemberKeepsTheirOwnAccount(t *testing.T) { + s := newTS(t) + memberID, call := member(t, s, "member") + self := "/api/users/" + id64(memberID) + + resp := call(http.MethodPut, self+"/notify", map[string]string{"ntfy_topic": "terdut-member"}) + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Errorf("own notify target: %d", resp.StatusCode) + } + + resp = call(http.MethodPut, self+"/password", map[string]string{"password": "correct-horse-battery"}) + resp.Body.Close() + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { + t.Errorf("own password: %d", resp.StatusCode) + } + + // An API key carries exactly the rights of its owner, so minting your own + // is no more than signing in again. + resp = call(http.MethodPost, self+"/api-keys", map[string]string{"name": "laptop"}) + resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + t.Errorf("own API key: %d", resp.StatusCode) + } + + // And the queue still has to be able to name people. + resp = call(http.MethodGet, "/api/users", nil) + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Errorf("list users: %d", resp.StatusCode) + } +} + +// Incident work is everybody's job; none of it is administration. +func TestAdmin_MemberCanWorkIncidents(t *testing.T) { + s := newTS(t) + _, call := member(t, s, "responder") + postWebhook(t, s, []map[string]any{ + amAlert("fp-admin", "DiskFull", "firing", "2026-09-20T10:00:00Z", zeroTime, nil), + }) + + for _, c := range []struct { + name string + method string + path string + }{ + {"list", http.MethodGet, "/api/incidents"}, + {"acknowledge", http.MethodPost, "/api/incidents/1/acknowledge"}, + {"resolve", http.MethodPost, "/api/incidents/1/resolve"}, + } { + resp := call(c.method, c.path, nil) + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Errorf("%s: expected 200, got %d", c.name, resp.StatusCode) + } + } +} + +// An install must never be left with nobody who can administer it. +func TestAdmin_LastAdministratorIsProtected(t *testing.T) { + s := newTS(t) + + resp := s.req(t, http.MethodPut, "/api/users/1/admin", map[string]bool{"is_admin": false}) + resp.Body.Close() + if resp.StatusCode != http.StatusConflict { + t.Errorf("self-demotion: expected 409, got %d", resp.StatusCode) + } + + resp = s.req(t, http.MethodDelete, "/api/users/1", nil) + resp.Body.Close() + if resp.StatusCode != http.StatusConflict { + t.Errorf("deleting yourself: expected 409, got %d", resp.StatusCode) + } + + // With a second administrator the first may stand down, but not while they + // are the only one — which is the same rule from the other side. + otherID, _ := member(t, s, "second") + resp = s.req(t, http.MethodPut, "/api/users/"+id64(otherID)+"/admin", map[string]bool{"is_admin": true}) + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("granting admin: %d", resp.StatusCode) + } + + resp = s.req(t, http.MethodDelete, "/api/users/"+id64(otherID), nil) + resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + t.Errorf("deleting the second admin: expected 204, got %d", resp.StatusCode) + } +} + +// A promoted user gets the powers with the flag, and loses them with it. +func TestAdmin_GrantAndRevokeChangeWhatIsAllowed(t *testing.T) { + s := newTS(t) + memberID, call := member(t, s, "promotee") + admin := "/api/users/" + id64(memberID) + "/admin" + + resp := call(http.MethodPost, "/api/users", map[string]string{"username": "a", "email": "a@test.com"}) + resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("before the grant: %d", resp.StatusCode) + } + + resp = s.req(t, http.MethodPut, admin, map[string]bool{"is_admin": true}) + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("grant: %d", resp.StatusCode) + } + + resp = call(http.MethodPost, "/api/users", map[string]string{"username": "b", "email": "b@test.com"}) + resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + t.Errorf("after the grant: expected 201, got %d", resp.StatusCode) + } + + resp = s.req(t, http.MethodPut, admin, map[string]bool{"is_admin": false}) + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("revoke: %d", resp.StatusCode) + } + + resp = call(http.MethodPost, "/api/users", map[string]string{"username": "c", "email": "c@test.com"}) + resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Errorf("after the revoke: expected 403, got %d", resp.StatusCode) + } +} + +// The flag has to reach the client, or the web UI cannot decide what to show. +func TestAdmin_MeReportsTheFlag(t *testing.T) { + s := newTS(t) + + var me struct { + User struct { + IsAdmin bool `json:"is_admin"` + } `json:"user"` + } + decode(t, s.req(t, http.MethodGet, "/api/me", nil), &me) + if !me.User.IsAdmin { + t.Error("the bootstrap user should be an administrator") + } + + _, call := member(t, s, "plain") + var theirs struct { + User struct { + IsAdmin bool `json:"is_admin"` + } `json:"user"` + } + decode(t, call(http.MethodGet, "/api/me", nil), &theirs) + if theirs.User.IsAdmin { + t.Error("a created user should not be an administrator") + } +} diff --git a/internal/api/auth.go b/internal/api/auth.go index 47e3733..ab5c037 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -266,8 +266,9 @@ func handleMe(db *sql.DB) http.HandlerFunc { // // Changing your own password takes the current one, when there is one, so an // unattended signed-in browser cannot be used to take the account over. Setting -// somebody else's is how an admin gives a user their first password, and like -// the other user endpoints it is open to any authenticated caller. +// somebody else's is how an admin gives a user their first password, and is +// restricted to administrators: it hands over an account outright, without +// knowing the password it replaces. // // Every other session of the target is ended: a password change is what you // do when you think someone else is signed in. @@ -278,6 +279,9 @@ func handleSetPassword(db *sql.DB) http.HandlerFunc { respond(w, http.StatusBadRequest, errResp("invalid user id")) return } + if !requireSelfOrAdmin(w, r, id) { + return + } var req struct { Password string `json:"password"` CurrentPassword string `json:"current_password"` diff --git a/internal/api/middleware.go b/internal/api/middleware.go index e9e90af..7058ca2 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -66,6 +66,39 @@ func AuthMiddleware(db *sql.DB) func(http.Handler) http.Handler { } } +// AdminOnly rejects a caller who is not a system administrator. It runs inside +// AuthMiddleware's group, so by the time it sees a request the caller is known. +// +// 403 and not 404: the route exists and the caller is authenticated, they are +// simply not allowed. Hiding the endpoint would buy nothing — every one of them +// is in the README. +func AdminOnly(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + caller, ok := userFromContext(r.Context()) + if !ok || !caller.IsAdmin { + respond(w, http.StatusForbidden, errResp("administrator access required")) + return + } + next.ServeHTTP(w, r) + }) +} + +// requireSelfOrAdmin guards the endpoints that are self-service for your own +// account and administration for anybody else's: your password, your ntfy +// topic, your API keys. Reports whether the request may proceed, and answers it +// if not. +// +// An API key is not an escalation: it carries exactly the rights of the user it +// belongs to, so minting your own is no more than signing in again. +func requireSelfOrAdmin(w http.ResponseWriter, r *http.Request, targetID int64) bool { + caller, ok := userFromContext(r.Context()) + if !ok || (caller.ID != targetID && !caller.IsAdmin) { + respond(w, http.StatusForbidden, errResp("administrator access required")) + return false + } + return true +} + // apiKeyUser resolves an API key to its user and stamps its last use. func apiKeyUser(ctx context.Context, db *sql.DB, token string) (int64, bool) { var keyID, userID int64 @@ -111,8 +144,8 @@ func serveAs(w http.ResponseWriter, r *http.Request, next http.Handler, db *sql. var u models.User var createdUnix int64 if err := db.QueryRowContext(r.Context(), - "SELECT id, username, email, created_at FROM users WHERE id = $1", userID, - ).Scan(&u.ID, &u.Username, &u.Email, &createdUnix); err != nil { + "SELECT id, username, email, created_at, is_admin FROM users WHERE id = $1", userID, + ).Scan(&u.ID, &u.Username, &u.Email, &createdUnix, &u.IsAdmin); err != nil { respond(w, http.StatusUnauthorized, errResp("unauthorized")) return } diff --git a/internal/api/router.go b/internal/api/router.go index f1450f4..2f0a2da 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -40,14 +40,30 @@ func NewRouter(db *sql.DB, notify NotifyConfig, deadman DeadmanConfig) http.Hand r.Use(AuthMiddleware(db)) r.Get("/api/me", handleMe(db)) + + // Readable by anyone signed in: the queue's assignment control and the + // on-call schedule both need to name people. r.Get("/api/users", handleListUsers(db)) - r.Post("/api/users", handleCreateUser(db)) - r.Delete("/api/users/{id}", handleDeleteUser(db)) + + // Your own account, or anybody's if you are an admin. The handlers call + // requireSelfOrAdmin rather than sitting behind AdminOnly, because + // which rule applies depends on the {id} in the path. r.Put("/api/users/{id}/notify", handleSetNotifyTarget(db)) r.Put("/api/users/{id}/password", handleSetPassword(db)) r.Post("/api/users/{id}/api-keys", handleCreateAPIKey(db)) r.Delete("/api/users/{id}/api-keys/{keyID}", handleDeleteAPIKey(db)) + // Administration: who exists, and who is an administrator. Until #3 + // these were open to any authenticated caller, which meant every user + // could delete every other one. + r.Group(func(r chi.Router) { + r.Use(AdminOnly) + + r.Post("/api/users", handleCreateUser(db)) + r.Delete("/api/users/{id}", handleDeleteUser(db)) + r.Put("/api/users/{id}/admin", handleSetAdmin(db)) + }) + // Alerts are read-only: they are Alertmanager's record, not a work // queue. Everything a person does happens on the incident instead. r.Get("/api/alerts", handleListAlerts(db)) diff --git a/internal/api/users.go b/internal/api/users.go index e685589..f1955f9 100644 --- a/internal/api/users.go +++ b/internal/api/users.go @@ -58,7 +58,7 @@ func handleBootstrap(db *sql.DB) http.HandlerFunc { var userID int64 if err := db.QueryRowContext(r.Context(), - "INSERT INTO users (username, email, password_hash) VALUES ($1, $2, $3) RETURNING id", + "INSERT INTO users (username, email, password_hash, is_admin) VALUES ($1, $2, $3, true) RETURNING id", req.Username, req.Email, passwordHash).Scan(&userID); err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return @@ -86,7 +86,7 @@ func handleBootstrap(db *sql.DB) http.HandlerFunc { func handleListUsers(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { rows, err := db.QueryContext(r.Context(), - "SELECT id, username, email, created_at, ntfy_topic FROM users ORDER BY id") + "SELECT id, username, email, created_at, ntfy_topic, is_admin FROM users ORDER BY id") if err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return @@ -97,7 +97,7 @@ func handleListUsers(db *sql.DB) http.HandlerFunc { for rows.Next() { var u models.User var ts int64 - if err := rows.Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic); err != nil { + if err := rows.Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin); err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return } @@ -150,6 +150,9 @@ func handleSetNotifyTarget(db *sql.DB) http.HandlerFunc { respond(w, http.StatusBadRequest, errResp("invalid user id")) return } + if !requireSelfOrAdmin(w, r, id) { + return + } var req struct { NtfyTopic string `json:"ntfy_topic"` } @@ -190,6 +193,21 @@ func handleDeleteUser(db *sql.DB) http.HandlerFunc { respond(w, http.StatusBadRequest, errResp("invalid user id")) return } + // Deleting yourself is how an install ends up with no administrator at + // all, and it is never what somebody meant to do. + caller, _ := userFromContext(r.Context()) + if caller.ID == id { + respond(w, http.StatusConflict, errResp("cannot delete your own account")) + return + } + if last, err := isLastAdmin(r.Context(), db, id); err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } else if last { + respond(w, http.StatusConflict, errResp("cannot delete the last administrator")) + return + } + res, err := db.ExecContext(r.Context(), "DELETE FROM users WHERE id = $1", id) if err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) @@ -211,6 +229,9 @@ func handleCreateAPIKey(db *sql.DB) http.HandlerFunc { respond(w, http.StatusBadRequest, errResp("invalid user id")) return } + if !requireSelfOrAdmin(w, r, userID) { + return + } var req struct { Name string `json:"name"` @@ -254,6 +275,9 @@ func handleDeleteAPIKey(db *sql.DB) http.HandlerFunc { respond(w, http.StatusBadRequest, errResp("invalid user id")) return } + if !requireSelfOrAdmin(w, r, userID) { + return + } keyID, err := strconv.ParseInt(chi.URLParam(r, "keyID"), 10, 64) if err != nil { respond(w, http.StatusBadRequest, errResp("invalid key id")) @@ -292,11 +316,77 @@ func fetchUser(ctx context.Context, db *sql.DB, id int64) (models.User, error) { var u models.User var ts int64 err := db.QueryRowContext(ctx, - "SELECT id, username, email, created_at, ntfy_topic FROM users WHERE id = $1", id). - Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic) + "SELECT id, username, email, created_at, ntfy_topic, is_admin FROM users WHERE id = $1", id). + Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin) if err != nil { return u, err } u.CreatedAt = time.Unix(ts, 0).UTC() return u, nil } + +// handleSetAdmin grants or revokes the system administrator flag. +// +// Revoking is guarded twice: an install must keep at least one administrator, +// and you cannot demote yourself. The first stops the flag being lost +// altogether; the second stops the likelier accident, where the only admin +// clears their own flag while tidying up and locks the door behind them. +func handleSetAdmin(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + respond(w, http.StatusBadRequest, errResp("invalid user id")) + return + } + var req struct { + IsAdmin *bool `json:"is_admin"` + } + if err := decodeJSON(r, &req); err != nil || req.IsAdmin == nil { + respond(w, http.StatusBadRequest, errResp("is_admin is required")) + return + } + + if !*req.IsAdmin { + caller, _ := userFromContext(r.Context()) + if caller.ID == id { + respond(w, http.StatusConflict, errResp("cannot revoke your own administrator access")) + return + } + if last, err := isLastAdmin(r.Context(), db, id); err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } else if last { + respond(w, http.StatusConflict, errResp("cannot revoke the last administrator")) + return + } + } + + res, err := db.ExecContext(r.Context(), + "UPDATE users SET is_admin = $1 WHERE id = $2", *req.IsAdmin, id) + if err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + if n, _ := res.RowsAffected(); n == 0 { + respond(w, http.StatusNotFound, errResp("user not found")) + return + } + + user, err := fetchUser(r.Context(), db, id) + if err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + respond(w, http.StatusOK, user) + } +} + +// isLastAdmin reports whether id is an administrator and no other user is one. +// A non-admin id is never the last one, so removing them is always allowed. +func isLastAdmin(ctx context.Context, db *sql.DB, id int64) (bool, error) { + var last bool + err := db.QueryRowContext(ctx, ` + SELECT EXISTS (SELECT 1 FROM users WHERE id = $1 AND is_admin) + AND NOT EXISTS (SELECT 1 FROM users WHERE id <> $1 AND is_admin)`, id).Scan(&last) + return last, err +} diff --git a/internal/db/migrations/002_admin_role.sql b/internal/db/migrations/002_admin_role.sql new file mode 100644 index 0000000..99cc095 --- /dev/null +++ b/internal/db/migrations/002_admin_role.sql @@ -0,0 +1,25 @@ +-- A system administrator role, and the first thing in this server that one user +-- can do and another cannot. +-- +-- Until now every authenticated caller could create and delete users, set +-- anybody's password and mint API keys for anybody — auth.go said so in a +-- comment. That was defensible with one operator and a hand-made account; it is +-- not once people sign themselves up (see #7). +-- +-- EVERY EXISTING USER BECOMES AN ADMIN. They already hold these powers, so +-- this migration changes nobody's access: it names what is already true, and +-- leaves demotion as a deliberate act somebody performs afterwards. The +-- alternative — promoting only user 1 — would silently strip the others, and +-- could leave an install whose only admin is an account nobody has a password +-- for. +-- +-- New users are not admins: the column defaults to false, and the only ways to +-- become one are this backfill, the bootstrap endpoint, or an existing admin +-- granting it. +ALTER TABLE users ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT false; + +UPDATE users SET is_admin = true; + +-- The queue's assignment dropdown and the on-call schedule read every user, and +-- the admin screens in #5 will filter on this. +CREATE INDEX users_is_admin_idx ON users(is_admin) WHERE is_admin; diff --git a/internal/models/user.go b/internal/models/user.go index 39428e1..7985ba8 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -12,6 +12,12 @@ type User struct { // none of their own; incidents assigned to them fall back to the configured // fallback topic instead. NtfyTopic *string `json:"ntfy_topic,omitempty"` + + // IsAdmin is the system administrator flag: managing users and API keys. + // Not omitempty — a client has to be able to tell "false" from "this server + // is too old to have the field", and the web UI decides what to show from + // it. + IsAdmin bool `json:"is_admin"` } type APIKey struct {