1377d9005b
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.
393 lines
12 KiB
Go
393 lines
12 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
func handleBootstrap(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Username string `json:"username"`
|
|
Email string `json:"email"`
|
|
// Password is optional; without one the first user can only use the
|
|
// API key until somebody sets it.
|
|
Password string `json:"password"`
|
|
}
|
|
if err := decodeJSON(r, &req); err != nil {
|
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
|
return
|
|
}
|
|
if req.Username == "" || req.Email == "" {
|
|
respond(w, http.StatusBadRequest, errResp("username and email are required"))
|
|
return
|
|
}
|
|
var passwordHash *string
|
|
if req.Password != "" {
|
|
if msg := validatePassword(req.Password); msg != "" {
|
|
respond(w, http.StatusBadRequest, errResp(msg))
|
|
return
|
|
}
|
|
h, err := hashPassword(req.Password)
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
passwordHash = &h
|
|
}
|
|
|
|
var count int
|
|
if err := db.QueryRowContext(r.Context(), "SELECT COUNT(*) FROM users").Scan(&count); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
if count > 0 {
|
|
respond(w, http.StatusForbidden, errResp("bootstrap already completed"))
|
|
return
|
|
}
|
|
|
|
var userID int64
|
|
if err := db.QueryRowContext(r.Context(),
|
|
"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
|
|
}
|
|
|
|
raw, hash, err := randomToken()
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
var keyID int64
|
|
if err := db.QueryRowContext(r.Context(),
|
|
"INSERT INTO api_keys (user_id, key_hash, name) VALUES ($1, $2, $3) RETURNING id",
|
|
userID, hash, "bootstrap").Scan(&keyID); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
|
|
user, _ := fetchUser(r.Context(), db, userID)
|
|
key := models.APIKey{ID: keyID, UserID: userID, Name: "bootstrap", Key: raw, CreatedAt: user.CreatedAt}
|
|
respond(w, http.StatusCreated, map[string]any{"user": user, "api_key": key})
|
|
}
|
|
}
|
|
|
|
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, is_admin FROM users ORDER BY id")
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
users := []models.User{}
|
|
for rows.Next() {
|
|
var u models.User
|
|
var ts int64
|
|
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
|
|
}
|
|
u.CreatedAt = time.Unix(ts, 0).UTC()
|
|
users = append(users, u)
|
|
}
|
|
respond(w, http.StatusOK, users)
|
|
}
|
|
}
|
|
|
|
func handleCreateUser(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Username string `json:"username"`
|
|
Email string `json:"email"`
|
|
}
|
|
if err := decodeJSON(r, &req); err != nil {
|
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
|
return
|
|
}
|
|
if req.Username == "" || req.Email == "" {
|
|
respond(w, http.StatusBadRequest, errResp("username and email are required"))
|
|
return
|
|
}
|
|
|
|
var id int64
|
|
if err := db.QueryRowContext(r.Context(),
|
|
"INSERT INTO users (username, email) VALUES ($1, $2) RETURNING id",
|
|
req.Username, req.Email).Scan(&id); err != nil {
|
|
if isUniqueViolation(err) {
|
|
respond(w, http.StatusConflict, errResp("username or email already exists"))
|
|
return
|
|
}
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
user, _ := fetchUser(r.Context(), db, id)
|
|
respond(w, http.StatusCreated, user)
|
|
}
|
|
}
|
|
|
|
// handleSetNotifyTarget points a user's push notifications at an ntfy topic, or
|
|
// clears it with an empty string. The topic is a shared secret with the ntfy
|
|
// server — anyone who knows it can publish to it — so pick an unguessable one
|
|
// unless your ntfy enforces access control.
|
|
func handleSetNotifyTarget(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
|
|
}
|
|
if !requireSelfOrAdmin(w, r, id) {
|
|
return
|
|
}
|
|
var req struct {
|
|
NtfyTopic string `json:"ntfy_topic"`
|
|
}
|
|
if err := decodeJSON(r, &req); err != nil {
|
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
|
return
|
|
}
|
|
|
|
var topic *string
|
|
if t := strings.TrimSpace(req.NtfyTopic); t != "" {
|
|
topic = &t
|
|
}
|
|
|
|
res, err := db.ExecContext(r.Context(),
|
|
"UPDATE users SET ntfy_topic = $1 WHERE id = $2", topic, 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)
|
|
}
|
|
}
|
|
|
|
func handleDeleteUser(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
|
|
}
|
|
// 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"))
|
|
return
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
respond(w, http.StatusNotFound, errResp("user not found"))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
}
|
|
|
|
func handleCreateAPIKey(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
userID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
|
if err != nil {
|
|
respond(w, http.StatusBadRequest, errResp("invalid user id"))
|
|
return
|
|
}
|
|
if !requireSelfOrAdmin(w, r, userID) {
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
}
|
|
if err := decodeJSON(r, &req); err != nil {
|
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
|
return
|
|
}
|
|
if req.Name == "" {
|
|
respond(w, http.StatusBadRequest, errResp("name is required"))
|
|
return
|
|
}
|
|
|
|
var exists int
|
|
if err := db.QueryRowContext(r.Context(), "SELECT 1 FROM users WHERE id = $1", userID).Scan(&exists); err != nil {
|
|
respond(w, http.StatusNotFound, errResp("user not found"))
|
|
return
|
|
}
|
|
|
|
raw, hash, err := randomToken()
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
var keyID int64
|
|
if err := db.QueryRowContext(r.Context(),
|
|
"INSERT INTO api_keys (user_id, key_hash, name) VALUES ($1, $2, $3) RETURNING id",
|
|
userID, hash, req.Name).Scan(&keyID); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
key := models.APIKey{ID: keyID, UserID: userID, Name: req.Name, Key: raw, CreatedAt: time.Now().UTC()}
|
|
respond(w, http.StatusCreated, key)
|
|
}
|
|
}
|
|
|
|
func handleDeleteAPIKey(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
userID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
|
if err != nil {
|
|
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"))
|
|
return
|
|
}
|
|
|
|
res, err := db.ExecContext(r.Context(),
|
|
"DELETE FROM api_keys WHERE id = $1 AND user_id = $2", keyID, userID)
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
respond(w, http.StatusNotFound, errResp("api key not found"))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
}
|
|
|
|
// randomToken generates a random 32-byte secret encoded as hex, plus its SHA-256
|
|
// hash for storage. Used for API keys and for notification acknowledge tokens.
|
|
func randomToken() (raw, hash string, err error) {
|
|
b := make([]byte, 32)
|
|
if _, err = rand.Read(b); err != nil {
|
|
return
|
|
}
|
|
raw = hex.EncodeToString(b)
|
|
h := sha256.Sum256([]byte(raw))
|
|
hash = hex.EncodeToString(h[:])
|
|
return
|
|
}
|
|
|
|
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, 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
|
|
}
|