diff --git a/api/localauth/fake_test.go b/api/localauth/fake_test.go index e0de852..137ee21 100644 --- a/api/localauth/fake_test.go +++ b/api/localauth/fake_test.go @@ -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 } diff --git a/api/localauth/handler.go b/api/localauth/handler.go index 2d66a65..6fd0b7a 100644 --- a/api/localauth/handler.go +++ b/api/localauth/handler.go @@ -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, diff --git a/api/localauth/handler_test.go b/api/localauth/handler_test.go index 0df5893..3c60e54 100644 --- a/api/localauth/handler_test.go +++ b/api/localauth/handler_test.go @@ -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) + } +} diff --git a/api/localauth/store.go b/api/localauth/store.go index 2189036..3aa6157 100644 --- a/api/localauth/store.go +++ b/api/localauth/store.go @@ -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. diff --git a/api/localauth/store_integration_test.go b/api/localauth/store_integration_test.go index 33e2652..388a1d5 100644 --- a/api/localauth/store_integration_test.go +++ b/api/localauth/store_integration_test.go @@ -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) + } + } +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 855cab3..498bda7 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -474,6 +474,14 @@ export function resetPassword(id: string, newPassword?: string): Promise<{ passw }); } +export function setUserRole(id: string, role: string): Promise { + return request(`/auth/users/${id}/role`, { + method: 'PUT', + credentials: 'include', + body: JSON.stringify({ role }) + }); +} + // --- alerting --------------------------------------------------------- export type ConditionType = 'threshold' | 'absence'; diff --git a/web/src/lib/components/NavSidebar.svelte b/web/src/lib/components/NavSidebar.svelte index 9c477ee..1aeacc2 100644 --- a/web/src/lib/components/NavSidebar.svelte +++ b/web/src/lib/components/NavSidebar.svelte @@ -18,15 +18,16 @@ onCloseMobile }: { onOpenPalette: () => void; mobileOpen?: boolean; onCloseMobile?: () => void } = $props(); - const navItems = [ + const baseNavItems = [ { href: '/', label: 'Search', icon: '◇' }, { href: '/dashboards', label: 'Dashboards', icon: '▤' }, { href: '/alerts', label: 'Alerts', icon: '▲' }, { href: '/data-sources', label: 'Data Sources', icon: '◈' }, { href: '/agents', label: 'Agents', icon: '●' }, - { href: '/hosts', label: 'Hosts', icon: '▣' }, - { href: '/settings', label: 'Settings', icon: '⚙' } + { href: '/hosts', label: 'Hosts', icon: '▣' } ]; + const usersNavItem = { href: '/users', label: 'Users', icon: '◐' }; + const settingsNavItem = { href: '/settings', label: 'Settings', icon: '⚙' }; function isActive(href: string): boolean { if (href === '/') return page.url.pathname === '/'; @@ -44,6 +45,18 @@ getLocalSession().then((s) => (localSession = s === 'disabled' ? null : s)); }); + // The Users nav item only ever makes sense for local-auth mode's + // owner-only user manager (see routes/users/+page.svelte) -- an + // enterprise-SSO deployment or a non-owner local session never sees + // it, same gating that page enforces itself if reached directly. + const isLocalOwner = $derived.by(() => { + const s = localSession; + return s !== null && s.role === 'owner'; + }); + const navItems = $derived( + isLocalOwner ? [...baseNavItems, usersNavItem, settingsNavItem] : [...baseNavItems, settingsNavItem] + ); + let loggingOut = $state(false); async function handleLogout() { loggingOut = true; diff --git a/web/src/routes/settings/+page.svelte b/web/src/routes/settings/+page.svelte index 996a442..1380d17 100644 --- a/web/src/routes/settings/+page.svelte +++ b/web/src/routes/settings/+page.svelte @@ -1,17 +1,5 @@ + +
+

Users

+ + {#if !checked} +

Loading…

+ {:else if localSession === 'disabled'} +

+ This deployment doesn't have local user accounts enabled -- see the "Single sign-on" section on + Settings if it's using enterprise SSO instead. +

+ {:else if localSession === null} +

Sign in to manage users.

+ {:else if localSession.role !== 'owner'} +

Only an owner can manage users. Signed in as {localSession.username} ({localSession.role}).

+ {:else} +

Local accounts for this deployment: passwords, and roles.

+ + {#if usersError}

{usersError}

{/if} + + {#if usersLoading} +

Loading…

+ {:else} + + + + + + + + + + + {#each users as u (u.id)} + + + + + + + {#if passwordTarget === u.id} + + + + {/if} + {/each} + +
UserRoleCreated
+
+ + {u.username} + {#if u.id === localSession.user_id}you{/if} +
+
+ + {formatDate(u.created_at)} + + +
+
+ {#if passwordShown?.userId === u.id} +

+ New password (shown once, save it now): {passwordShown.password} +

+ {:else} + +
+ + +
+ + {/if} +
+
+ {/if} + + {#if showCreate} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ {:else} + + {/if} + {/if} +
+ + diff --git a/web/src/routes/users/+page.ts b/web/src/routes/users/+page.ts new file mode 100644 index 0000000..c902fb0 --- /dev/null +++ b/web/src/routes/users/+page.ts @@ -0,0 +1,3 @@ +// Same shape as settings/+page.ts: no route params, data comes from a +// client-side fetch. +export const prerender = true;