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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user