Give local users their own manager: custom passwords and role reassignment

Move user management out of Settings into its own /users page (nav-gated
to owners), let an owner type a specific password on reset instead of
always generating a random one, and add role reassignment via a new
PUT /auth/users/{id}/role endpoint. Role changes revoke the target's
existing sessions, same as a password reset, so a demoted user can't
keep acting under a stale, higher-privileged session.
This commit is contained in:
2026-08-21 14:23:52 -07:00
parent 4b5dae5879
commit 864e68253a
10 changed files with 887 additions and 225 deletions
+34
View File
@@ -23,6 +23,7 @@ type store interface {
GetUserByID(ctx context.Context, id string) (*User, error)
DeleteUser(ctx context.Context, id string) error
SetPasswordHash(ctx context.Context, userID, hash string) error
SetRole(ctx context.Context, userID string, role authz.Role) error
CreateSession(ctx context.Context, userID, tenantID string, role authz.Role, ttl time.Duration) (string, error)
DeleteSessionByHash(ctx context.Context, tokenHash string) error
}
@@ -93,6 +94,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /auth/users", authz.RequireRole(h.authorizer, authz.RoleOwner, h.handleCreateUser))
mux.HandleFunc("DELETE /auth/users/{id}", authz.RequireRole(h.authorizer, authz.RoleOwner, h.handleDeleteUser))
mux.HandleFunc("POST /auth/users/{id}/reset-password", authz.RequireRole(h.authorizer, authz.RoleOwner, h.handleResetPassword))
mux.HandleFunc("PUT /auth/users/{id}/role", authz.RequireRole(h.authorizer, authz.RoleOwner, h.handleSetRole))
}
type loginRequest struct {
@@ -284,6 +286,38 @@ func (h *Handler) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
type setRoleRequest struct {
Role string `json:"role"`
}
// handleSetRole deliberately does not stop an owner from demoting or
// re-promoting their own account -- same "single-operator prototype
// deployment knows what it's doing" trust level handleDeleteUser's doc
// comment already establishes for this package's owner-only endpoints.
func (h *Handler) handleSetRole(w http.ResponseWriter, r *http.Request) {
var req setRoleRequest
if !decodeJSON(w, r, &req) {
return
}
role := authz.Role(req.Role)
if !validRole(role) {
writeError(w, http.StatusBadRequest, `role must be "viewer", "editor", "admin", or "owner"`)
return
}
if err := h.store.SetRole(r.Context(), r.PathValue("id"), role); err != nil {
h.writeStoreErr(w, err, "updating role")
return
}
user, err := h.store.GetUserByID(r.Context(), r.PathValue("id"))
if err != nil {
h.writeStoreErr(w, err, "fetching updated user")
return
}
writeJSON(w, http.StatusOK, userResponse{ID: user.ID, Username: user.Username, Role: string(user.Role), CreatedAt: user.CreatedAt})
}
type resetPasswordRequest struct {
// Password is optional -- omitted, a random one is generated and
// returned in the response body exactly once, same "shown once,