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
+14
View File
@@ -100,6 +100,20 @@ func (f *fakeStore) SetPasswordHash(_ context.Context, userID, hash string) erro
return nil
}
func (f *fakeStore) SetRole(_ context.Context, userID string, role authz.Role) error {
u, ok := f.users[userID]
if !ok {
return ErrNotFound
}
u.Role = role
for h, sess := range f.sessions {
if sess.UserID == userID {
delete(f.sessions, h)
}
}
return nil
}
func (f *fakeStore) CountLocalUsers(_ context.Context) (int, error) {
return len(f.users), nil
}
+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,
+168
View File
@@ -259,3 +259,171 @@ func TestResetPasswordRevokesExistingSessions(t *testing.T) {
t.Fatalf("bob's pre-reset session status = %d, want 401 (reset must revoke existing sessions)", stale.Code)
}
}
func TestResetPasswordAcceptsCallerSuppliedPassword(t *testing.T) {
fs := newFakeStore()
mustCreateUser(t, fs, "admin", "adminpass1", authz.RoleOwner)
bob := mustCreateUser(t, fs, "bob", "bobspassword", authz.RoleViewer)
_, mux := newTestHandler(t, fs)
adminLogin := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"admin","password":"adminpass1"}`, nil)
adminCookie := sessionCookieFrom(adminLogin)
reset := doRequest(t, mux, http.MethodPost, "/auth/users/"+bob.ID+"/reset-password", `{"password":"bobs-new-password"}`, adminCookie)
if reset.Code != http.StatusOK {
t.Fatalf("reset status = %d, want 200; body=%s", reset.Code, reset.Body.String())
}
var resp resetPasswordResponse
if err := json.Unmarshal(reset.Body.Bytes(), &resp); err != nil {
t.Fatalf("decoding response: %v", err)
}
if resp.Password != "" {
t.Errorf("expected no password echoed back when the caller supplied one, got %q", resp.Password)
}
login := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"bob","password":"bobs-new-password"}`, nil)
if login.Code != http.StatusOK {
t.Fatalf("login with caller-supplied password: status = %d, want 200", login.Code)
}
}
func TestOwnerCanReassignRole(t *testing.T) {
fs := newFakeStore()
mustCreateUser(t, fs, "admin", "adminpass1", authz.RoleOwner)
bob := mustCreateUser(t, fs, "bob", "bobspassword", authz.RoleViewer)
_, mux := newTestHandler(t, fs)
adminLogin := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"admin","password":"adminpass1"}`, nil)
adminCookie := sessionCookieFrom(adminLogin)
bobLogin := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"bob","password":"bobspassword"}`, nil)
bobCookie := sessionCookieFrom(bobLogin)
set := doRequest(t, mux, http.MethodPut, "/auth/users/"+bob.ID+"/role", `{"role":"admin"}`, adminCookie)
if set.Code != http.StatusOK {
t.Fatalf("set role status = %d, want 200; body=%s", set.Code, set.Body.String())
}
var updated userResponse
if err := json.Unmarshal(set.Body.Bytes(), &updated); err != nil {
t.Fatalf("decoding response: %v", err)
}
if updated.Role != "admin" {
t.Errorf("role = %q, want admin", updated.Role)
}
stale := doRequest(t, mux, http.MethodGet, "/auth/session", "", bobCookie)
if stale.Code != http.StatusUnauthorized {
t.Fatalf("bob's pre-reassignment session status = %d, want 401 (role change must revoke existing sessions)", stale.Code)
}
}
// TestOwnerCanReassignEveryRoleTransition exercises every ordered pair
// of the four roles (viewer/editor/admin/owner), including a role's
// no-op transition to itself -- "an owner can reassign a role" must
// hold universally, not just for the one viewer->admin pair
// TestOwnerCanReassignRole already covers, and in particular must not
// silently special-case promotion to/from owner.
func TestOwnerCanReassignEveryRoleTransition(t *testing.T) {
allRoles := []authz.Role{authz.RoleViewer, authz.RoleEditor, authz.RoleAdmin, authz.RoleOwner}
for _, from := range allRoles {
for _, to := range allRoles {
t.Run(string(from)+"_to_"+string(to), func(t *testing.T) {
fs := newFakeStore()
mustCreateUser(t, fs, "admin", "adminpass1", authz.RoleOwner)
target := mustCreateUser(t, fs, "target", "targetspassword", from)
_, mux := newTestHandler(t, fs)
adminLogin := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"admin","password":"adminpass1"}`, nil)
adminCookie := sessionCookieFrom(adminLogin)
set := doRequest(t, mux, http.MethodPut, "/auth/users/"+target.ID+"/role", `{"role":"`+string(to)+`"}`, adminCookie)
if set.Code != http.StatusOK {
t.Fatalf("set role %s -> %s: status = %d, want 200; body=%s", from, to, set.Code, set.Body.String())
}
var updated userResponse
if err := json.Unmarshal(set.Body.Bytes(), &updated); err != nil {
t.Fatalf("decoding response: %v", err)
}
if updated.Role != string(to) {
t.Fatalf("role in response = %q, want %q", updated.Role, to)
}
// Confirm it actually took, not just that the handler said
// so -- log back in as target and check the session's role.
login := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"target","password":"targetspassword"}`, nil)
if login.Code != http.StatusOK {
t.Fatalf("login as target after reassignment: status = %d", login.Code)
}
var loginResp sessionResponse
if err := json.Unmarshal(login.Body.Bytes(), &loginResp); err != nil {
t.Fatalf("decoding login response: %v", err)
}
if loginResp.Role != string(to) {
t.Fatalf("role after fresh login = %q, want %q", loginResp.Role, to)
}
})
}
}
}
// TestOwnerCanReassignOwnRole confirms self-reassignment isn't
// special-cased away -- consistent with handleDeleteUser's documented
// "single-operator deployment knows what it's doing" trust posture, an
// owner can demote (or re-promote) themselves same as anyone else.
func TestOwnerCanReassignOwnRole(t *testing.T) {
fs := newFakeStore()
admin := mustCreateUser(t, fs, "admin", "adminpass1", authz.RoleOwner)
_, mux := newTestHandler(t, fs)
login := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"admin","password":"adminpass1"}`, nil)
cookie := sessionCookieFrom(login)
set := doRequest(t, mux, http.MethodPut, "/auth/users/"+admin.ID+"/role", `{"role":"viewer"}`, cookie)
if set.Code != http.StatusOK {
t.Fatalf("self role change status = %d, want 200; body=%s", set.Code, set.Body.String())
}
var updated userResponse
if err := json.Unmarshal(set.Body.Bytes(), &updated); err != nil {
t.Fatalf("decoding response: %v", err)
}
if updated.Role != "viewer" {
t.Errorf("role = %q, want viewer", updated.Role)
}
// The role change revokes sessions same as any other target -- the
// admin's own now-stale cookie must stop working too.
stale := doRequest(t, mux, http.MethodGet, "/auth/session", "", cookie)
if stale.Code != http.StatusUnauthorized {
t.Fatalf("own session after self-reassignment status = %d, want 401", stale.Code)
}
}
func TestSetRoleRejectsInvalidRole(t *testing.T) {
fs := newFakeStore()
mustCreateUser(t, fs, "admin", "adminpass1", authz.RoleOwner)
bob := mustCreateUser(t, fs, "bob", "bobspassword", authz.RoleViewer)
_, mux := newTestHandler(t, fs)
adminLogin := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"admin","password":"adminpass1"}`, nil)
adminCookie := sessionCookieFrom(adminLogin)
rec := doRequest(t, mux, http.MethodPut, "/auth/users/"+bob.ID+"/role", `{"role":"superuser"}`, adminCookie)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 for an invalid role", rec.Code)
}
}
func TestNonOwnerCannotReassignRole(t *testing.T) {
fs := newFakeStore()
mustCreateUser(t, fs, "alice", "hunter22", authz.RoleEditor)
bob := mustCreateUser(t, fs, "bob", "bobspassword", authz.RoleViewer)
_, mux := newTestHandler(t, fs)
login := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"alice","password":"hunter22"}`, nil)
cookie := sessionCookieFrom(login)
rec := doRequest(t, mux, http.MethodPut, "/auth/users/"+bob.ID+"/role", `{"role":"admin"}`, cookie)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403 for a non-owner reassigning a role", rec.Code)
}
}
+29
View File
@@ -229,6 +229,35 @@ func (s *Store) SetPasswordHash(ctx context.Context, userID, hash string) error
return tx.Commit(ctx)
}
// SetRole also revokes every existing session for userID, in the same
// transaction -- Session.Role is a snapshot taken at login (see
// SetPasswordHash's doc comment above for why), so without this a
// demoted user would keep acting under their old, higher-privileged
// role for the rest of an already-issued session's lifetime.
func (s *Store) SetRole(ctx context.Context, userID string, role authz.Role) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
tag, err := tx.Exec(ctx, `
UPDATE tenant_memberships SET role = $1
WHERE user_id = $2 AND tenant_id = $3
AND EXISTS (SELECT 1 FROM users WHERE id = $2 AND username IS NOT NULL)`,
string(role), userID, defaultTenantID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
if _, err := tx.Exec(ctx, `DELETE FROM local_sessions WHERE user_id = $1`, userID); err != nil {
return err
}
return tx.Commit(ctx)
}
// CountLocalUsers backs -seed-admin's idempotency check (see
// cmd/api/main.go's runSeedAdmin): a deployment that already has at
// least one local user never gets a second auto-created admin account.
+66
View File
@@ -155,3 +155,69 @@ func TestIntegrationSetPasswordHashRevokesSessions(t *testing.T) {
t.Errorf("GetSession after password reset: err = %v, want ErrNotFound (reset must revoke existing sessions)", err)
}
}
func TestIntegrationSetRoleRevokesSessions(t *testing.T) {
store := integrationStore(t)
ctx := context.Background()
username := testUsername(t)
hash, _ := HashPassword("password1")
user, err := store.CreateUser(ctx, username, hash, authz.RoleViewer)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
t.Cleanup(func() { _ = store.DeleteUser(ctx, user.ID) })
raw, err := store.CreateSession(ctx, user.ID, "default", authz.RoleViewer, time.Hour)
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
if err := store.SetRole(ctx, user.ID, authz.RoleAdmin); err != nil {
t.Fatalf("SetRole: %v", err)
}
got, _, err := store.GetUserForLogin(ctx, username)
if err != nil {
t.Fatalf("GetUserForLogin: %v", err)
}
if got.Role != authz.RoleAdmin {
t.Errorf("role after SetRole = %q, want admin", got.Role)
}
if _, err := store.GetSession(ctx, hashToken(raw)); !errors.Is(err, ErrNotFound) {
t.Errorf("GetSession after role change: err = %v, want ErrNotFound (role change must revoke existing sessions)", err)
}
}
// TestIntegrationSetRoleAcceptsEveryRole confirms tenant_memberships'
// role CHECK constraint (0020_create_tenant_memberships.sql) accepts
// all four roles via SetRole's UPDATE, not just CreateUser's INSERT --
// the handler-level fake-store test already covers all sixteen ordered
// transitions, but only a real Postgres run proves the constraint
// itself doesn't reject any of them.
func TestIntegrationSetRoleAcceptsEveryRole(t *testing.T) {
store := integrationStore(t)
ctx := context.Background()
username := testUsername(t)
hash, _ := HashPassword("password1")
user, err := store.CreateUser(ctx, username, hash, authz.RoleViewer)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
t.Cleanup(func() { _ = store.DeleteUser(ctx, user.ID) })
for _, role := range []authz.Role{authz.RoleEditor, authz.RoleAdmin, authz.RoleOwner, authz.RoleViewer} {
if err := store.SetRole(ctx, user.ID, role); err != nil {
t.Fatalf("SetRole(%s): %v", role, err)
}
got, _, err := store.GetUserForLogin(ctx, username)
if err != nil {
t.Fatalf("GetUserForLogin after SetRole(%s): %v", role, err)
}
if got.Role != role {
t.Fatalf("role after SetRole(%s) = %q, want %q", role, got.Role, role)
}
}
}