Add a system administrator role, and gate account management behind it
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:
+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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user