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
|
or the web UI's session cookie. A request that carries an `Authorization` header
|
||||||
is judged on that header alone.
|
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 |
|
| Method | Path | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `POST` | `/api/login` | `{"username","password"}` → sets the session cookie, returns `{user, has_password}`. `429` after too many failures |
|
| `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 |
|
| Method | Path | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `POST` | `/api/bootstrap` | Create first user + API key `{"username","email","password"?}` (only works on empty DB) |
|
**admin** marks an endpoint that requires the administrator flag; **self or
|
||||||
| `GET` | `/api/users` | List users |
|
admin** marks one you may use on your own account and an administrator may use
|
||||||
| `POST` | `/api/users` | Create user `{"username","email"}` |
|
on anybody's.
|
||||||
| `DELETE` | `/api/users/{id}` | Delete user (cascades to keys) |
|
|
||||||
| `PUT` | `/api/users/{id}/notify` | Set push notification target `{"ntfy_topic"}` — empty string clears it |
|
| Method | Path | Who | Description |
|
||||||
| `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 |
|
| `POST` | `/api/bootstrap` | — | Create first user + API key `{"username","email","password"?}` (only works on empty DB). The user is an administrator |
|
||||||
| `DELETE` | `/api/users/{id}/api-keys/{keyID}` | Revoke API key |
|
| `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
|
### 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
|
## Upgrading from SQLite
|
||||||
|
|
||||||
Versions up to v0.10.2 stored everything in a SQLite file. From the Postgres release onwards
|
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
|
// 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
|
// 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
|
// somebody else's is how an admin gives a user their first password, and is
|
||||||
// the other user endpoints it is open to any authenticated caller.
|
// 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
|
// Every other session of the target is ended: a password change is what you
|
||||||
// do when you think someone else is signed in.
|
// 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"))
|
respond(w, http.StatusBadRequest, errResp("invalid user id"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireSelfOrAdmin(w, r, id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var req struct {
|
var req struct {
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
CurrentPassword string `json:"current_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.
|
// 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) {
|
func apiKeyUser(ctx context.Context, db *sql.DB, token string) (int64, bool) {
|
||||||
var keyID, userID int64
|
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 u models.User
|
||||||
var createdUnix int64
|
var createdUnix int64
|
||||||
if err := db.QueryRowContext(r.Context(),
|
if err := db.QueryRowContext(r.Context(),
|
||||||
"SELECT id, username, email, created_at FROM users WHERE id = $1", userID,
|
"SELECT id, username, email, created_at, is_admin FROM users WHERE id = $1", userID,
|
||||||
).Scan(&u.ID, &u.Username, &u.Email, &createdUnix); err != nil {
|
).Scan(&u.ID, &u.Username, &u.Email, &createdUnix, &u.IsAdmin); err != nil {
|
||||||
respond(w, http.StatusUnauthorized, errResp("unauthorized"))
|
respond(w, http.StatusUnauthorized, errResp("unauthorized"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-2
@@ -40,14 +40,30 @@ func NewRouter(db *sql.DB, notify NotifyConfig, deadman DeadmanConfig) http.Hand
|
|||||||
r.Use(AuthMiddleware(db))
|
r.Use(AuthMiddleware(db))
|
||||||
|
|
||||||
r.Get("/api/me", handleMe(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.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}/notify", handleSetNotifyTarget(db))
|
||||||
r.Put("/api/users/{id}/password", handleSetPassword(db))
|
r.Put("/api/users/{id}/password", handleSetPassword(db))
|
||||||
r.Post("/api/users/{id}/api-keys", handleCreateAPIKey(db))
|
r.Post("/api/users/{id}/api-keys", handleCreateAPIKey(db))
|
||||||
r.Delete("/api/users/{id}/api-keys/{keyID}", handleDeleteAPIKey(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
|
// Alerts are read-only: they are Alertmanager's record, not a work
|
||||||
// queue. Everything a person does happens on the incident instead.
|
// queue. Everything a person does happens on the incident instead.
|
||||||
r.Get("/api/alerts", handleListAlerts(db))
|
r.Get("/api/alerts", handleListAlerts(db))
|
||||||
|
|||||||
+95
-5
@@ -58,7 +58,7 @@ func handleBootstrap(db *sql.DB) http.HandlerFunc {
|
|||||||
|
|
||||||
var userID int64
|
var userID int64
|
||||||
if err := db.QueryRowContext(r.Context(),
|
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 {
|
req.Username, req.Email, passwordHash).Scan(&userID); err != nil {
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
@@ -86,7 +86,7 @@ func handleBootstrap(db *sql.DB) http.HandlerFunc {
|
|||||||
func handleListUsers(db *sql.DB) http.HandlerFunc {
|
func handleListUsers(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
rows, err := db.QueryContext(r.Context(),
|
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 {
|
if err != nil {
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
@@ -97,7 +97,7 @@ func handleListUsers(db *sql.DB) http.HandlerFunc {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var u models.User
|
var u models.User
|
||||||
var ts int64
|
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"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -150,6 +150,9 @@ func handleSetNotifyTarget(db *sql.DB) http.HandlerFunc {
|
|||||||
respond(w, http.StatusBadRequest, errResp("invalid user id"))
|
respond(w, http.StatusBadRequest, errResp("invalid user id"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireSelfOrAdmin(w, r, id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var req struct {
|
var req struct {
|
||||||
NtfyTopic string `json:"ntfy_topic"`
|
NtfyTopic string `json:"ntfy_topic"`
|
||||||
}
|
}
|
||||||
@@ -190,6 +193,21 @@ func handleDeleteUser(db *sql.DB) http.HandlerFunc {
|
|||||||
respond(w, http.StatusBadRequest, errResp("invalid user id"))
|
respond(w, http.StatusBadRequest, errResp("invalid user id"))
|
||||||
return
|
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)
|
res, err := db.ExecContext(r.Context(), "DELETE FROM users WHERE id = $1", id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
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"))
|
respond(w, http.StatusBadRequest, errResp("invalid user id"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireSelfOrAdmin(w, r, userID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -254,6 +275,9 @@ func handleDeleteAPIKey(db *sql.DB) http.HandlerFunc {
|
|||||||
respond(w, http.StatusBadRequest, errResp("invalid user id"))
|
respond(w, http.StatusBadRequest, errResp("invalid user id"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireSelfOrAdmin(w, r, userID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
keyID, err := strconv.ParseInt(chi.URLParam(r, "keyID"), 10, 64)
|
keyID, err := strconv.ParseInt(chi.URLParam(r, "keyID"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respond(w, http.StatusBadRequest, errResp("invalid key id"))
|
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 u models.User
|
||||||
var ts int64
|
var ts int64
|
||||||
err := db.QueryRowContext(ctx,
|
err := db.QueryRowContext(ctx,
|
||||||
"SELECT id, username, email, created_at, ntfy_topic FROM users WHERE id = $1", id).
|
"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)
|
Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return u, err
|
return u, err
|
||||||
}
|
}
|
||||||
u.CreatedAt = time.Unix(ts, 0).UTC()
|
u.CreatedAt = time.Unix(ts, 0).UTC()
|
||||||
return u, nil
|
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
|
// none of their own; incidents assigned to them fall back to the configured
|
||||||
// fallback topic instead.
|
// fallback topic instead.
|
||||||
NtfyTopic *string `json:"ntfy_topic,omitempty"`
|
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 {
|
type APIKey struct {
|
||||||
|
|||||||
Reference in New Issue
Block a user