Add a system administrator role, and gate account management behind it #10
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user