Enforce the full user-management RBAC matrix, add self-service password change
api/localauth now enforces every rule of the requested matrix, each
checked inside the handler beyond RegisterRoutes' floor:
- At least one owner must always exist -- handleDeleteUser and
handleSetRole both refuse an operation that would leave zero
owners (wouldRemoveLastOwner, backed by new store method
CountUsersWithRole), whether the caller is admin or owner.
- Owner can create/delete any role, including another owner (subject
to the above). Admin can only create/delete viewer or editor --
GET/POST /auth/users and DELETE .../{id} moved from RoleOwner to
RoleAdmin floor, with an inner check narrowing what an admin
caller specifically may target.
- Only a user can change their own password -- new POST
/auth/password (RoleViewer floor, i.e. every role) requires the
caller's current password (verified via new store method
GetPasswordHashByID) and is now the only path to changing your
own, including for an owner. The existing admin-reset endpoint
(POST /auth/users/{id}/reset-password, also moved to RoleAdmin
floor) now refuses id == the caller's own ID, and refuses an
owner target unless the caller is themselves an owner -- "admin
can change any password except an owner's; owner can change any
password, even another owner's."
- Role reassignment (PUT .../{id}/role) stays owner-only, unchanged
beyond the last-owner guard above.
New web/src/routes/account page (linked from NavSidebar next to "Log
out", visible to every local-auth role) is the self-service password
change UI. /users now mirrors the server's per-row restrictions
client-side (disabled role selects/delete/reset buttons with an
explanatory title, a restricted role list on the create form) so an
admin never sees an action that would just 403 -- the server remains
the actual authority.
Verified live against real Postgres and in the browser: the full
matrix via curl (owner creating a second owner, admin blocked from
creating/deleting/resetting admin or owner accounts, last-owner delete
and demote both blocked, admin resetting non-owner passwords,
self-target reset rejected, self-service change with wrong/right
current password), plus the actual /users page rendering correctly
restricted for an admin session and a full change-password round trip
through the real UI ending in a forced re-login with the new password.
This commit is contained in:
@@ -482,6 +482,20 @@ export function setUserRole(id: string, role: string): Promise<LocalUser> {
|
||||
});
|
||||
}
|
||||
|
||||
// changeOwnPassword is the only way to change your own password (any
|
||||
// role, including owner) -- distinct from resetPassword above, which
|
||||
// is exclusively for an owner/admin acting on someone ELSE's account
|
||||
// and will reject a request that targets the caller's own id. Revokes
|
||||
// the caller's own session on success (see api/localauth/handler.go's
|
||||
// handleChangeOwnPassword), so the caller must sign in again afterward.
|
||||
export function changeOwnPassword(currentPassword: string, newPassword: string): Promise<void> {
|
||||
return request('/auth/password', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword })
|
||||
});
|
||||
}
|
||||
|
||||
// --- log retention (owner/admin only, see api/logretention) -----------
|
||||
// Deletion is scoped to specific (host, service) targets, not wholesale
|
||||
// -- a caller must name which agents' *and* which log types' logs to
|
||||
|
||||
@@ -46,15 +46,17 @@
|
||||
});
|
||||
|
||||
// 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(() => {
|
||||
// owner/admin user manager (see routes/users/+page.svelte, which
|
||||
// admin can now partially use too -- viewer/editor accounts only) --
|
||||
// an enterprise-SSO deployment or a plain viewer/editor local
|
||||
// session never sees it, same gating that page enforces itself if
|
||||
// reached directly.
|
||||
const canManageUsers = $derived.by(() => {
|
||||
const s = localSession;
|
||||
return s !== null && s.role === 'owner';
|
||||
return s !== null && (s.role === 'owner' || s.role === 'admin');
|
||||
});
|
||||
const navItems = $derived(
|
||||
isLocalOwner ? [...baseNavItems, usersNavItem, settingsNavItem] : [...baseNavItems, settingsNavItem]
|
||||
canManageUsers ? [...baseNavItems, usersNavItem, settingsNavItem] : [...baseNavItems, settingsNavItem]
|
||||
);
|
||||
|
||||
let loggingOut = $state(false);
|
||||
@@ -105,6 +107,7 @@
|
||||
<span class="tenant-name">{localSession.username}</span>
|
||||
<span class="role">{localSession.role}</span>
|
||||
</div>
|
||||
<a class="switch" href="/account">Change password</a>
|
||||
<button type="button" class="switch logout-btn" onclick={handleLogout} disabled={loggingOut}>
|
||||
{loggingOut ? 'Signing out…' : 'Log out'}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
<script lang="ts">
|
||||
import { localAuthEnabled, getLocalSession, changeOwnPassword, type LocalSession } from '$lib/api';
|
||||
|
||||
// Deliberately no role gating anywhere on this page -- changing your
|
||||
// own password is available to every role (viewer through owner),
|
||||
// unlike everything on /users which is owner/admin only. See
|
||||
// api/localauth/handler.go's handleChangeOwnPassword doc comment.
|
||||
let localSession = $state<LocalSession | 'disabled' | null>(null);
|
||||
let checked = $state(false);
|
||||
$effect(() => {
|
||||
if (!localAuthEnabled) {
|
||||
checked = true;
|
||||
return;
|
||||
}
|
||||
getLocalSession().then((s) => {
|
||||
localSession = s;
|
||||
checked = true;
|
||||
});
|
||||
});
|
||||
|
||||
let currentPassword = $state('');
|
||||
let newPassword = $state('');
|
||||
let confirmPassword = $state('');
|
||||
let saving = $state(false);
|
||||
let error = $state('');
|
||||
let done = $state(false);
|
||||
|
||||
async function submit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
error = '';
|
||||
if (newPassword.length < 8) {
|
||||
error = 'New password must be at least 8 characters.';
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
error = 'New password and confirmation do not match.';
|
||||
return;
|
||||
}
|
||||
saving = true;
|
||||
try {
|
||||
await changeOwnPassword(currentPassword, newPassword);
|
||||
done = true;
|
||||
// The server just revoked this session as part of the change
|
||||
// (see handleChangeOwnPassword) -- the cookie is already dead,
|
||||
// so send the user to sign back in rather than leaving them on
|
||||
// a page that looks live but whose next request will 401.
|
||||
setTimeout(() => (window.location.href = '/login'), 1500);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<h1>Change password</h1>
|
||||
|
||||
{#if !checked}
|
||||
<p class="muted">Loading…</p>
|
||||
{:else if localSession === 'disabled'}
|
||||
<p class="note">This deployment doesn't have local accounts enabled.</p>
|
||||
{:else if localSession === null}
|
||||
<p class="note">Sign in to change your password.</p>
|
||||
{:else if done}
|
||||
<p class="note">Password changed. Signing you out so you can sign back in…</p>
|
||||
{:else}
|
||||
<p class="subtitle">Signed in as {localSession.username} ({localSession.role}).</p>
|
||||
|
||||
<form onsubmit={submit} class="password-form">
|
||||
<div class="field">
|
||||
<label for="current-password">Current password</label>
|
||||
<input
|
||||
id="current-password"
|
||||
type="password"
|
||||
bind:value={currentPassword}
|
||||
disabled={saving}
|
||||
autocomplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="new-password">New password</label>
|
||||
<input
|
||||
id="new-password"
|
||||
type="password"
|
||||
placeholder="Min. 8 characters"
|
||||
bind:value={newPassword}
|
||||
disabled={saving}
|
||||
autocomplete="new-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="confirm-password">Confirm new password</label>
|
||||
<input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
bind:value={confirmPassword}
|
||||
disabled={saving}
|
||||
autocomplete="new-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if error}<p class="error">{error}</p>{/if}
|
||||
|
||||
<button type="submit" disabled={saving}>{saving ? 'Saving…' : 'Change password'}</button>
|
||||
</form>
|
||||
<p class="note">Changing your password signs you out everywhere -- you'll need to sign back in.</p>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
max-width: 26rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: var(--text-xl);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.subtitle {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-sm);
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
.muted {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.note {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-sm);
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
.error {
|
||||
color: var(--color-danger);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.password-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.field label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.field input {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
}
|
||||
.password-form button {
|
||||
align-self: flex-start;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-bg);
|
||||
background: var(--color-accent);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.password-form button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,3 @@
|
||||
// Same shape as settings/+page.ts: no route params, data comes from a
|
||||
// client-side fetch.
|
||||
export const prerender = true;
|
||||
@@ -12,6 +12,11 @@
|
||||
} from '$lib/api';
|
||||
|
||||
const roleOptions = ['viewer', 'editor', 'admin', 'owner'] as const;
|
||||
// An admin caller can only ever create/pick viewer or editor -- the
|
||||
// server rejects admin/owner from an admin caller (see
|
||||
// api/localauth/handler.go's handleCreateUser), so this is here to
|
||||
// keep the create form from ever offering a choice that would 403.
|
||||
const adminCreatableRoles = ['viewer', 'editor'] as const;
|
||||
|
||||
let localSession = $state<LocalSession | 'disabled' | null>(null);
|
||||
let checked = $state(false);
|
||||
@@ -22,7 +27,8 @@
|
||||
async function loadUsers() {
|
||||
localSession = localAuthEnabled ? await getLocalSession() : 'disabled';
|
||||
checked = true;
|
||||
if (localSession === 'disabled' || localSession === null || localSession.role !== 'owner') return;
|
||||
if (localSession === 'disabled' || localSession === null) return;
|
||||
if (localSession.role !== 'owner' && localSession.role !== 'admin') return;
|
||||
usersLoading = true;
|
||||
usersError = '';
|
||||
try {
|
||||
@@ -35,6 +41,39 @@
|
||||
}
|
||||
loadUsers();
|
||||
|
||||
// isOwner gates what this page offers beyond the owner-or-admin floor
|
||||
// that gets you onto the page at all: role reassignment, and
|
||||
// creating/deleting/resetting an admin or owner account, all stay
|
||||
// owner-only (see api/localauth/handler.go's RBAC matrix doc
|
||||
// comment). $derived.by + a local const alias sidesteps a
|
||||
// svelte-check narrowing quirk with reading a mutable $state
|
||||
// directly inside a bare $derived(...) expression.
|
||||
const isOwner = $derived.by(() => {
|
||||
const s = localSession;
|
||||
return s !== null && s !== 'disabled' && s.role === 'owner';
|
||||
});
|
||||
const ownerCount = $derived(users.filter((u) => u.role === 'owner').length);
|
||||
|
||||
// canDeleteUser/canResetPassword mirror the server's own checks so
|
||||
// the UI never offers an action that would just come back as a 403
|
||||
// or (for the last owner) a 409 -- the server remains the actual
|
||||
// authority, this is purely a "don't show a doomed button" nicety.
|
||||
function canDeleteUser(u: LocalUser): boolean {
|
||||
if ((u.role === 'admin' || u.role === 'owner') && !isOwner) return false;
|
||||
if (u.role === 'owner' && ownerCount <= 1) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function deleteDisabledReason(u: LocalUser): string {
|
||||
if ((u.role === 'admin' || u.role === 'owner') && !isOwner) return 'Only an owner can delete an admin or owner account';
|
||||
if (u.role === 'owner' && ownerCount <= 1) return 'There must always be at least one owner';
|
||||
return '';
|
||||
}
|
||||
|
||||
function canResetPassword(u: LocalUser): boolean {
|
||||
return !(u.role === 'owner' && !isOwner);
|
||||
}
|
||||
|
||||
// --- create user ---
|
||||
let newUsername = $state('');
|
||||
let newPassword = $state('');
|
||||
@@ -159,10 +198,16 @@
|
||||
</p>
|
||||
{:else if localSession === null}
|
||||
<p class="note">Sign in to manage users.</p>
|
||||
{:else if localSession.role !== 'owner'}
|
||||
<p class="note">Only an owner can manage users. Signed in as {localSession.username} ({localSession.role}).</p>
|
||||
{:else if localSession.role !== 'owner' && localSession.role !== 'admin'}
|
||||
<p class="note">
|
||||
Only an owner or admin can manage users. Signed in as {localSession.username} ({localSession.role}). To
|
||||
change your own password, use "Change password" in the sidebar.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="subtitle">Local accounts for this deployment: passwords, and roles.</p>
|
||||
<p class="subtitle">
|
||||
Local accounts for this deployment.
|
||||
{#if !isOwner}As an admin, you can manage viewer and editor accounts.{/if}
|
||||
</p>
|
||||
|
||||
{#if usersError}<p class="error">{usersError}</p>{/if}
|
||||
|
||||
@@ -192,7 +237,8 @@
|
||||
<select
|
||||
class="role-select"
|
||||
value={u.role}
|
||||
disabled={roleSaving === u.id}
|
||||
disabled={roleSaving === u.id || !isOwner}
|
||||
title={isOwner ? '' : 'Only an owner can reassign roles'}
|
||||
onchange={(e) => handleRoleChange(u, e.currentTarget.value)}
|
||||
aria-label="Role for {u.username}"
|
||||
>
|
||||
@@ -203,8 +249,27 @@
|
||||
</td>
|
||||
<td class="muted">{formatDate(u.created_at)}</td>
|
||||
<td class="actions">
|
||||
<button type="button" onclick={() => togglePasswordPanel(u.id)}>Change password</button>
|
||||
<button type="button" class="danger" onclick={() => handleDelete(u)}>Delete</button>
|
||||
{#if u.id === localSession.user_id}
|
||||
<span class="muted self-note">Use "Change password" in the sidebar</span>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => togglePasswordPanel(u.id)}
|
||||
disabled={!canResetPassword(u)}
|
||||
title={canResetPassword(u) ? '' : "Only an owner can reset an owner's password"}
|
||||
>
|
||||
Change password
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="danger"
|
||||
onclick={() => handleDelete(u)}
|
||||
disabled={!canDeleteUser(u)}
|
||||
title={deleteDisabledReason(u)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{#if passwordTarget === u.id}
|
||||
@@ -271,7 +336,7 @@
|
||||
<div class="field">
|
||||
<label for="new-role">Role</label>
|
||||
<select id="new-role" bind:value={newRole} disabled={creating}>
|
||||
{#each roleOptions as r (r)}
|
||||
{#each (isOwner ? roleOptions : adminCreatableRoles) as r (r)}
|
||||
<option value={r}>{r}</option>
|
||||
{/each}
|
||||
</select>
|
||||
@@ -404,6 +469,13 @@
|
||||
color: var(--color-danger);
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
.actions button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.self-note {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.password-row td {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
|
||||
Reference in New Issue
Block a user