Add a system administrator role, and gate account management behind it
CI / chart (pull_request) Successful in 2s
CI / security (pull_request) Successful in 16s
CI / test (pull_request) Successful in 1m37s

Until now every authenticated caller could create and delete users, set
anybody's password and mint anybody's API keys -- auth.go said so in a
comment. Defensible with one operator and a hand-made account; not once
people sign themselves up (#7), and not in a multi-tenant install (#4),
where the user list is no longer everybody who works here.

users.is_admin is the flag. AdminOnly gates creating and deleting users
and granting the flag itself. The endpoints that are self-service for
your own account and administration for somebody else's -- password,
ntfy topic, API keys -- go through requireSelfOrAdmin instead, because
which rule applies depends on the {id} in the path rather than on the
route.

Minting your own API key stays self-service. A key carries exactly the
rights of the user it belongs to, so issuing one is no more than signing
in again; requiring an admin for it would mean a responder cannot set up
the TUI without somebody else in the room.

/api/users stays readable by everybody. The queue's assignment control
and the on-call schedule both have to name people, and hiding the roster
from the people on it buys nothing.

THE MIGRATION MAKES EVERY EXISTING USER AN ADMINISTRATOR. They already
hold these powers, so nobody's access changes on upgrade: it names what
is already true and leaves demotion as a deliberate act. Promoting only
user 1 would silently strip the others, and could leave an install whose
only administrator is an account nobody has a password for.

Two guards keep an install administrable: the last administrator can be
neither deleted nor demoted, and nobody can delete or demote themselves
-- the likelier accident, where the only admin clears their own flag
while tidying up and locks the door behind them.

No UI changes: there are no account-management screens yet. models.User
carries is_admin (not omitempty, so a client can tell false from an old
server), which is what #5's admin page will render from.
This commit is contained in:
Niklas Ye
2026-09-20 13:20:04 +02:00
parent e3ad19c110
commit 1377d9005b
8 changed files with 491 additions and 19 deletions
+51 -8
View File
@@ -445,6 +445,20 @@ Authorization: Bearer <api-key>
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
+255
View File
@@ -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")
}
}
+6 -2
View File
@@ -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"`
+35 -2
View File
@@ -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
}
+18 -2
View File
@@ -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))
+95 -5
View File
@@ -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
}
+25
View File
@@ -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;
+6
View File
@@ -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 {