From 721ff049088804b12094a0fd6472bc8eb2af0a35 Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Tue, 11 Aug 2026 15:24:51 +0200 Subject: [PATCH] Add restricted guest role --- docs/Architecture.md | 3 +- docs/UX_Guidelines.md | 10 +- docs/User-Manual.md | 21 +-- .../assets/web/generated/app.js | 32 +++- .../assets/web/generated/background-decode.js | 2 +- .../assets/web/generated/bookmarks.js | 2 +- .../{chunk-BB2X7SND.js => chunk-FT2RH7BL.js} | 13 +- .../assets/web/generated/logbook.js | 2 +- .../assets/web/generated/scheduler.js | 2 +- .../frontend/src/api/auth.ts | 11 +- .../frontend/src/api/generated.ts | 2 +- .../trx-frontend-http/frontend/src/app.ts | 28 +++- .../frontend/tests/account-management.mjs | 24 ++- .../frontend/tests/auth.test.mjs | 7 +- .../frontend/tests/smoke.test.mjs | 1 + .../trx-frontend-http/src/auth.rs | 138 ++++++++++++++---- src/trx-config/src/client.rs | 8 +- 17 files changed, 240 insertions(+), 66 deletions(-) rename src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/{chunk-BB2X7SND.js => chunk-FT2RH7BL.js} (91%) diff --git a/docs/Architecture.md b/docs/Architecture.md index 845bf0ed..60d061b0 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -926,7 +926,8 @@ main ### HTTP Frontend Auth - Optional Argon2id-backed managed accounts with HttpOnly session cookies -- Composable Read, Control, Write, and Administrator roles, with policy shared by middleware and handlers +- An exclusive Guest role plus composable Read, Control, Write, and Administrator roles, with policy shared by middleware and handlers +- Guest sessions receive read-only station access but no account-control endpoints or panels - Atomic JSON persistence with migration from the legacy single-role schema - Account enable/disable, administrator CRUD, self-service password changes, and session revocation on security changes - A database invariant always preserves at least one enabled administrator diff --git a/docs/UX_Guidelines.md b/docs/UX_Guidelines.md index a4f0f966..ddadd664 100644 --- a/docs/UX_Guidelines.md +++ b/docs/UX_Guidelines.md @@ -126,7 +126,8 @@ When auth is enabled, an **auth gate** blocks the UI with: - Error message area (red `#ff6b6b`) - Role badge display -Accounts may combine **Read**, **Control**, **Write**, and **Administrator** roles. +**Guest** provides read-only station access and is exclusive. Non-Guest accounts +may combine **Read**, **Control**, **Write**, and **Administrator** roles. Administrator implies all permissions. Session cookie: `trx_http_sid`, HttpOnly, configurable Secure and SameSite attributes. @@ -341,7 +342,7 @@ Routes are classified into three tiers: | Tier | Examples | Requirement | |---|---|---| | **Public** | `/`, `/index.html`, `/map`, login/session endpoints, static assets | None | -| **Read** | `/status`, `/events`, `/audio`, `/decode`, `/spectrum`, `/bookmarks` | Read, Control, or Administrator role | +| **Read** | `/status`, `/events`, `/audio`, `/decode`, `/spectrum`, `/bookmarks` | Guest, Read, Control, or Administrator role | | **Control** | `/set_freq`, `/set_mode`, `/set_ptt`, `/toggle_power`, radio-control POST routes | Control or Administrator role | | **Write** | Logbook access and bookmark mutations | Write or Administrator role | @@ -354,8 +355,9 @@ Routes are classified into three tiers: ### 7.3 User Management -Every authenticated account gets a Settings > Account tab for changing its own -password. Only administrators get Settings > Users, where accounts can be +Every authenticated non-Guest account gets a Settings > Account tab for changing +its own password. Guest sees neither Account nor Users and both account-control +APIs deny Guest sessions. Only administrators get Settings > Users, where accounts can be created, enabled/disabled, assigned multiple roles, given a new password, or removed. The final enabled administrator cannot be disabled, removed, or demoted. Account security changes revoke every active session for that account. diff --git a/docs/User-Manual.md b/docs/User-Manual.md index b4c65650..a46c6052 100644 --- a/docs/User-Manual.md +++ b/docs/User-Manual.md @@ -354,9 +354,9 @@ A name in any of those maps that no remote answers to is a config error. | `bootstrap_admin_username` | string | — | First administrator, used only if the database is absent | | `bootstrap_admin_password` | string | — | First administrator password | | `bootstrap_admin_password_file` | string | — | Read the bootstrap password from this file instead | -| `bootstrap_read_enabled` | bool | `true` | Create the default read-only account when the database is absent | -| `bootstrap_read_username` | string | `"guest"` | Initial read-only username | -| `bootstrap_read_password` | string | `"guest"` | Initial read-only password | +| `bootstrap_read_enabled` | bool | `true` | Create the default Guest account when the database is absent | +| `bootstrap_read_username` | string | `"guest"` | Initial Guest username | +| `bootstrap_read_password` | string | `"guest"` | Initial Guest password | | `session_ttl_min` | u64 | `480` | Session lifetime | | `cookie_secure` | bool | `false` | Set Secure on the session cookie (needs HTTPS) | | `cookie_same_site` | string | `"Lax"` | `Strict`, `Lax`, or `None` | @@ -589,9 +589,9 @@ left as it was, rather than publishing a frequency the rig is not on. ## Authentication -The HTTP frontend supports an optional user/password ACL with multiple independent -roles. One account may have any combination: +The HTTP frontend supports an optional user/password ACL: +- **Guest** — read-only station access with no Account or Users controls; Guest cannot be combined with another role - **Read** — monitoring, audio, decode streams, and bookmark reads - **Control** — full radio receive/transmit controls - **Write** — logbook access and bookmark changes @@ -615,7 +615,7 @@ cookie_same_site = "Lax" # Strict|Lax|None When `enabled = false` (the default), all auth is bypassed and the UI behaves as before. When enabling it for the first time, bootstrap credentials create -the initial administrator (with every role), the default `guest`/`guest` Read +the initial administrator (with every non-Guest role), the default `guest`/`guest` Guest account, and the Argon2id-hashed user database. Change or disable the guest credentials in configuration before first startup on an exposed deployment. @@ -625,8 +625,9 @@ credentials in configuration before first startup on an exposed deployment. - Sessions are in-memory; a server restart invalidates all sessions. - Rate limiting is applied per IP to mitigate brute-force attempts. - User records persist in `users_file`; passwords are stored as salted Argon2id hashes. -- Roles are independent; for example, an account may have Read and Write without Control. -- Every signed-in user can change their own password in Settings > Account. This signs out all of their sessions. +- Non-Guest roles are independent; for example, an account may have Read and Write without Control. +- Guest accounts have no account-control panels and cannot call account-control endpoints. +- Every non-Guest signed-in user can change their own password in Settings > Account. This signs out all of their sessions. - Administrators can add, enable/disable, or remove users and change roles/passwords in Settings > Users. - At least one enabled administrator must always remain and cannot be disabled, removed, or demoted. - Disabling/removing an account or changing its password/roles revokes all of its sessions. @@ -639,11 +640,11 @@ credentials in configuration before first startup on an exposed deployment. | `/auth/login` | POST | Submit `{ "username": "...", "password": "..." }` | | `/auth/logout` | POST | Clear session | | `/auth/session` | GET | Check current session/roles | -| `/auth/account/password` | PATCH | Change the signed-in user's password after verifying the current password | +| `/auth/account/password` | PATCH | Change a non-Guest user's password after verifying the current password | | `/auth/users` | GET/POST | List or add users (admin only) | | `/auth/users/{username}` | PATCH/DELETE | Change enabled state/password/roles or remove user (administrator only) | -Read routes require Read. Radio mutations require Control. Logbook access and +Read routes accept Guest or require Read. Radio mutations require Control. Logbook access and bookmark mutations require Write. Administrator grants every permission. ### Frontend Flow diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js index 46e71af0..7116c2be 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js @@ -1,17 +1,19 @@ import { + AUTH_ADMIN_ROLES, AUTH_ROLES, AUTH_ROLE_LABELS, changeOwnPassword, createUser, deleteUser, fetchAuthSession, + hasAccountControls, hasAuthRole, listUsers, login, logout, normalizeAuthRoles, updateUser -} from "./chunk-BB2X7SND.js"; +} from "./chunk-FT2RH7BL.js"; // src/webgl-renderer.ts (function initTrxWebGl(global) { @@ -1846,6 +1848,19 @@ function buildRoleChoices(selected) { element.append(label); return { input, value }; }); + inputs.forEach(({ input, value }) => { + input.addEventListener("change", () => { + if (!input.checked) return; + if (value === "guest") { + inputs.forEach((choice) => { + if (choice.value !== "guest") choice.input.checked = false; + }); + } else { + const guest = inputs.find((choice) => choice.value === "guest"); + if (guest) guest.input.checked = false; + } + }); + }); return { element, inputs }; } async function checkAuthStatus() { @@ -1932,7 +1947,13 @@ function updateAuthUI() { return; } if (authRoles.length > 0) { - if (accountTab) accountTab.style.display = ""; + const canManageAccount = hasAccountControls(authRoles); + if (accountTab) accountTab.style.display = canManageAccount ? "" : "none"; + if (!canManageAccount && accountTab?.classList.contains("active")) { + const panel = document.getElementById("subtab-settings-account"); + if (panel) panel.style.display = "none"; + document.querySelector('[data-subtab="settings-scheduler"]')?.click(); + } if (badge) badge.style.display = "block"; if (badgeRole) badgeRole.textContent = `${authUsername || "local"} — ${authRoles.map((role) => AUTH_ROLE_LABELS[role]).join(", ")}`; if (headerAuthBtn2) { @@ -5974,7 +5995,7 @@ async function initializeApp() { const authStatus = await checkAuthStatus(); authEnabled = !authStatus.auth_disabled; if (!authEnabled) { - setAuthRoles(AUTH_ROLES); + setAuthRoles(AUTH_ADMIN_ROLES); hideAuthGate(); updateAuthUI(); connect(); @@ -6047,10 +6068,15 @@ async function refreshUserManagement() { enabledLabel.append(enabled, " Enabled"); const isOnlyAdmin = user.enabled && hasAuthRole(user.roles, "administrator") && enabledAdminCount === 1; const administratorInput = roleInputs.find((item) => item.value === "administrator")?.input; + const guestInput = roleInputs.find((item) => item.value === "guest")?.input; if (isOnlyAdmin && administratorInput) { administratorInput.disabled = true; administratorInput.title = "The final administrator cannot be demoted"; } + if (isOnlyAdmin && guestInput) { + guestInput.disabled = true; + guestInput.title = "The final administrator cannot become a Guest"; + } if (isOnlyAdmin) { enabled.disabled = true; enabled.title = "The final enabled administrator cannot be disabled"; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/background-decode.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/background-decode.js index c30098cf..d8bd4adc 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/background-decode.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/background-decode.js @@ -1,6 +1,6 @@ import { hasAuthRole -} from "./chunk-BB2X7SND.js"; +} from "./chunk-FT2RH7BL.js"; import { hostState } from "./chunk-KL66PICH.js"; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/bookmarks.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/bookmarks.js index 173a4eaa..05764581 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/bookmarks.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/bookmarks.js @@ -1,6 +1,6 @@ import { hasAuthRole -} from "./chunk-BB2X7SND.js"; +} from "./chunk-FT2RH7BL.js"; import { hostCore, hostState diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-BB2X7SND.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-FT2RH7BL.js similarity index 91% rename from src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-BB2X7SND.js rename to src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-FT2RH7BL.js index 4de8b509..283aaea6 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-BB2X7SND.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-FT2RH7BL.js @@ -1,6 +1,8 @@ // src/api/auth.ts -var AUTH_ROLES = ["read", "control", "write", "administrator"]; +var AUTH_ROLES = ["guest", "read", "control", "write", "administrator"]; +var AUTH_ADMIN_ROLES = AUTH_ROLES.filter((role) => role !== "guest"); var AUTH_ROLE_LABELS = { + guest: "Guest", read: "Read", control: "Control", write: "Write", @@ -13,7 +15,10 @@ function normalizeAuthRoles(roles) { return AUTH_ROLES.filter((role) => roles.includes(role)); } function hasAuthRole(roles, required) { - return roles.includes("administrator") || roles.includes(required) || required === "read" && roles.includes("control"); + return roles.includes("administrator") || roles.includes(required) || required === "read" && roles.includes("guest") || required === "read" && roles.includes("control"); +} +function hasAccountControls(roles) { + return roles.length > 0 && !roles.includes("guest"); } function decodeRoles(value, context) { if (!Array.isArray(value) || !value.every(isAuthRole)) { @@ -45,7 +50,7 @@ function decodeAuthSession(value) { } var authDisabledSession = { authenticated: true, - roles: ["read", "control", "write", "administrator"], + roles: [...AUTH_ADMIN_ROLES], auth_disabled: true }; async function fetchAuthSession() { @@ -114,9 +119,11 @@ async function logout() { export { AUTH_ROLES, + AUTH_ADMIN_ROLES, AUTH_ROLE_LABELS, normalizeAuthRoles, hasAuthRole, + hasAccountControls, fetchAuthSession, login, listUsers, diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/logbook.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/logbook.js index 89a1b8f8..85dd2806 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/logbook.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/logbook.js @@ -1,6 +1,6 @@ import { hasAuthRole -} from "./chunk-BB2X7SND.js"; +} from "./chunk-FT2RH7BL.js"; import { hostCore, hostState diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/scheduler.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/scheduler.js index 255e46b0..e932b67d 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/scheduler.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/scheduler.js @@ -1,6 +1,6 @@ import { hasAuthRole -} from "./chunk-BB2X7SND.js"; +} from "./chunk-FT2RH7BL.js"; import { hostState } from "./chunk-KL66PICH.js"; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/api/auth.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/api/auth.ts index 8534f8f4..49fd9fc9 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/api/auth.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/api/auth.ts @@ -6,8 +6,10 @@ import type { AuthRole } from "./generated.js"; export type { AuthRole }; -export const AUTH_ROLES: readonly AuthRole[] = ["read", "control", "write", "administrator"]; +export const AUTH_ROLES: readonly AuthRole[] = ["guest", "read", "control", "write", "administrator"]; +export const AUTH_ADMIN_ROLES: readonly AuthRole[] = AUTH_ROLES.filter(role => role !== "guest"); export const AUTH_ROLE_LABELS: Readonly> = { + guest: "Guest", read: "Read", control: "Control", write: "Write", @@ -25,9 +27,14 @@ export function normalizeAuthRoles(roles: readonly AuthRole[]): AuthRole[] { export function hasAuthRole(roles: readonly AuthRole[], required: AuthRole): boolean { return roles.includes("administrator") || roles.includes(required) + || required === "read" && roles.includes("guest") || required === "read" && roles.includes("control"); } +export function hasAccountControls(roles: readonly AuthRole[]): boolean { + return roles.length > 0 && !roles.includes("guest"); +} + function decodeRoles(value: unknown, context: string): AuthRole[] { if (!Array.isArray(value) || !value.every(isAuthRole)) { throw new TypeError(`${context} has invalid roles`); @@ -67,7 +74,7 @@ function decodeAuthSession(value: unknown): AuthSession { const authDisabledSession: AuthSession = { authenticated: true, - roles: ["read", "control", "write", "administrator"], + roles: [...AUTH_ADMIN_ROLES], auth_disabled: true, }; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/api/generated.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/api/generated.ts index 31191c98..8821145f 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/api/generated.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/api/generated.ts @@ -123,7 +123,7 @@ export type RigListResponse = { active_remote: string | null, rigs: Array, owner_callsign: string | null, owner_website_url: string | null, owner_website_name: string | null, ais_vessel_url_base: string | null, show_sdr_gain_control: boolean, initial_map_zoom: number, spectrum_coverage_margin_hz: number, spectrum_usable_span_ratio: number, bandplan_enabled: boolean, bandplan_region: string, decode_history_retention_min: bigint, server_connected: boolean, }; -export type AuthRole = "read" | "control" | "write" | "administrator"; +export type AuthRole = "guest" | "read" | "control" | "write" | "administrator"; export type DecoderActivation = "mode_bound" | "toggle"; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts index 0a0d4b53..f2979505 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts @@ -26,8 +26,10 @@ import { updateUser, deleteUser, changeOwnPassword, + AUTH_ADMIN_ROLES, AUTH_ROLES, AUTH_ROLE_LABELS, + hasAccountControls, hasAuthRole as rolesInclude, normalizeAuthRoles, } from "./api/auth.js"; @@ -441,6 +443,17 @@ function buildRoleChoices(selected: readonly AuthRole[]) { element.append(label); return { input, value }; }); + inputs.forEach(({ input, value }) => { + input.addEventListener("change", () => { + if (!input.checked) return; + if (value === "guest") { + inputs.forEach(choice => { if (choice.value !== "guest") choice.input.checked = false; }); + } else { + const guest = inputs.find(choice => choice.value === "guest"); + if (guest) guest.input.checked = false; + } + }); + }); return { element, inputs }; } @@ -544,7 +557,13 @@ function updateAuthUI() { } if (authRoles.length > 0) { - if (accountTab) accountTab.style.display = ""; + const canManageAccount = hasAccountControls(authRoles); + if (accountTab) accountTab.style.display = canManageAccount ? "" : "none"; + if (!canManageAccount && accountTab?.classList.contains("active")) { + const panel = document.getElementById("subtab-settings-account"); + if (panel) panel.style.display = "none"; + document.querySelector('[data-subtab="settings-scheduler"]')?.click(); + } if (badge) badge.style.display = "block"; if (badgeRole) badgeRole.textContent = `${authUsername || "local"} — ${authRoles.map(role => AUTH_ROLE_LABELS[role]).join(", ")}`; if (headerAuthBtn) { @@ -5050,7 +5069,7 @@ async function initializeApp() { authEnabled = !authStatus.auth_disabled; if (!authEnabled) { - setAuthRoles(AUTH_ROLES); + setAuthRoles(AUTH_ADMIN_ROLES); hideAuthGate(); updateAuthUI(); connect(); @@ -5132,10 +5151,15 @@ async function refreshUserManagement() { && rolesInclude(user.roles, "administrator") && enabledAdminCount === 1; const administratorInput = roleInputs.find(item => item.value === "administrator")?.input; + const guestInput = roleInputs.find(item => item.value === "guest")?.input; if (isOnlyAdmin && administratorInput) { administratorInput.disabled = true; administratorInput.title = "The final administrator cannot be demoted"; } + if (isOnlyAdmin && guestInput) { + guestInput.disabled = true; + guestInput.title = "The final administrator cannot become a Guest"; + } if (isOnlyAdmin) { enabled.disabled = true; enabled.title = "The final enabled administrator cannot be disabled"; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/account-management.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/account-management.mjs index 7bdb8959..8a5e4ba5 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/account-management.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/account-management.mjs @@ -47,14 +47,16 @@ try { createRoles: [...document.querySelectorAll("#user-create-roles input")].map((input) => input.value), adminEnabledLocked: admin?.querySelector('input[type="checkbox"]')?.disabled, adminRoleLocked: role(admin, "administrator")?.disabled, + adminGuestLocked: role(admin, "guest")?.disabled, adminRemoveLocked: admin?.querySelector("button.danger")?.disabled, listenerEnabled: listener?.querySelector('input[type="checkbox"]')?.checked, listenerRead: role(listener, "read")?.checked, }; }); - assert.deepEqual(state.createRoles, ALL_ROLES); + assert.deepEqual(state.createRoles, ["guest", ...ALL_ROLES]); assert.equal(state.adminEnabledLocked, true); assert.equal(state.adminRoleLocked, true); + assert.equal(state.adminGuestLocked, true); assert.equal(state.adminRemoveLocked, true); assert.equal(state.listenerEnabled, false); assert.equal(state.listenerRead, true); @@ -63,3 +65,23 @@ try { await browser.close(); await fixture.close(); } + +const guestFixture = await startWebFixture({ + authSession: { + authenticated: true, + username: "guest", + roles: ["guest"], + auth_disabled: false, + }, +}); +const guestBrowser = await startBrowser(chromium); +try { + await guestBrowser.page.goto(`${guestFixture.origin}/settings`, { waitUntil: "domcontentloaded" }); + await guestBrowser.page.locator("#tab-settings").waitFor({ state: "visible" }); + assert.equal(await guestBrowser.page.locator("#settings-account-tab").isVisible(), false); + assert.equal(await guestBrowser.page.locator("#settings-users-tab").isVisible(), false); + assert.deepEqual(guestBrowser.runtimeErrors, []); +} finally { + await guestBrowser.browser.close(); + await guestFixture.close(); +} diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/auth.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/auth.test.mjs index f79d36f6..3c3a759d 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/auth.test.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/auth.test.mjs @@ -15,9 +15,12 @@ function loadAuth(fetch) { return context.AuthApi; } -test("role policy is centralized and preserves the Control-to-Read implication", () => { +test("role policy centralizes Guest and implied read access", () => { const auth = loadAuth(async () => { throw new Error("unused"); }); - assert.deepEqual(Array.from(auth.AUTH_ROLES), ["read", "control", "write", "administrator"]); + assert.deepEqual(Array.from(auth.AUTH_ROLES), ["guest", "read", "control", "write", "administrator"]); + assert.equal(auth.hasAuthRole(["guest"], "read"), true); + assert.equal(auth.hasAccountControls(["guest"]), false); + assert.equal(auth.hasAccountControls(["read"]), true); assert.equal(auth.hasAuthRole(["control"], "read"), true); assert.equal(auth.hasAuthRole(["control"], "write"), false); assert.equal(auth.hasAuthRole(["administrator"], "write"), true); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/smoke.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/smoke.test.mjs index 1d2db445..98e503ba 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/smoke.test.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/smoke.test.mjs @@ -51,6 +51,7 @@ test("account lifecycle controls include self-service passwords and enable state assert.match(html, /id="account-password-form"/); assert.match(html, /id="user-create-enabled"/); assert.match(app, /changeOwnPassword/); + assert.match(app, /hasAccountControls\(authRoles\)/); assert.match(app, /enabledAdminCount/); }); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/auth.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/auth.rs index 0b87695c..1848a3af 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/auth.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/auth.rs @@ -35,6 +35,7 @@ pub type SessionId = String; )] #[serde(rename_all = "lowercase")] pub enum AuthRole { + Guest, Read, Control, Write, @@ -44,6 +45,7 @@ pub enum AuthRole { impl AuthRole { pub fn as_str(&self) -> &'static str { match self { + Self::Guest => "guest", Self::Read => "read", Self::Control => "control", Self::Write => "write", @@ -51,7 +53,7 @@ impl AuthRole { } } - pub fn all() -> BTreeSet { + pub fn full_access() -> BTreeSet { [Self::Read, Self::Control, Self::Write, Self::Administrator] .into_iter() .collect() @@ -60,6 +62,7 @@ impl AuthRole { fn grants(self, required: Self) -> bool { self == Self::Administrator || self == required + || self == Self::Guest && required == Self::Read || self == Self::Control && required == Self::Read } } @@ -74,9 +77,13 @@ fn roles_grant_any(roles: &AuthRoles, required: &[AuthRole]) -> bool { required.iter().any(|role| roles_grant(roles, *role)) } +fn roles_allow_account_controls(roles: &AuthRoles) -> bool { + !roles.contains(&AuthRole::Guest) +} + fn effective_roles(roles: &AuthRoles) -> Vec { if roles.contains(&AuthRole::Administrator) { - AuthRole::all().into_iter().collect() + AuthRole::full_access().into_iter().collect() } else { roles.iter().copied().collect() } @@ -311,7 +318,7 @@ impl<'de> Deserialize<'de> for UserRecord { let roles = match (stored.roles, stored.role) { (Some(roles), _) => roles, (None, Some(LegacyRole::User)) => [AuthRole::Read].into_iter().collect(), - (None, Some(LegacyRole::Admin)) => AuthRole::all(), + (None, Some(LegacyRole::Admin)) => AuthRole::full_access(), (None, None) => { return Err(serde::de::Error::missing_field("roles")); } @@ -390,7 +397,7 @@ impl UserStore { let mut records = vec![UserRecord { username: validate_username(&administrator.username)?.to_string(), password_hash: hash_password(&administrator.password)?, - roles: AuthRole::all(), + roles: AuthRole::full_access(), enabled: true, }]; if let Some(reader) = &config.bootstrap_read { @@ -405,7 +412,7 @@ impl UserStore { records.push(UserRecord { username: validate_username(&reader.username)?.to_string(), password_hash: hash_password_value(&reader.password)?, - roles: [AuthRole::Read].into_iter().collect(), + roles: [AuthRole::Guest].into_iter().collect(), enabled: true, }); } @@ -617,6 +624,9 @@ fn validate_roles(roles: AuthRoles) -> Result { if roles.is_empty() { return Err("at least one role is required".to_string()); } + if roles.contains(&AuthRole::Guest) && roles.len() != 1 { + return Err("guest cannot be combined with other roles".to_string()); + } Ok(roles) } @@ -812,6 +822,19 @@ fn require_admin(req: &HttpRequest, auth_state: &AuthState) -> Result Result { + let session = require_session(req, auth_state)?; + if !roles_allow_account_controls(&session.roles) { + return Err(HttpResponse::Forbidden().json(serde_json::json!({ + "error":"guest accounts cannot access account controls" + }))); + } + Ok(session) +} + fn session_cookie(value: String, config: &AuthConfig, max_age: Duration) -> Cookie<'static> { let mut cookie = Cookie::new("trx_http_sid", value); cookie.set_path("/"); @@ -925,7 +948,7 @@ pub async fn session_status( if !auth_state.config.enabled { return Ok(HttpResponse::Ok().json(SessionStatus { authenticated: true, - roles: effective_roles(&AuthRole::all()), + roles: effective_roles(&AuthRole::full_access()), username: None, auth_disabled: true, })); @@ -958,7 +981,7 @@ pub async fn change_own_password( body: web::Json, auth_state: web::Data, ) -> impl Responder { - let session = match require_session(&req, &auth_state) { + let session = match require_account_controls(&req, &auth_state) { Ok(value) => value, Err(response) => return response, }; @@ -1086,11 +1109,11 @@ pub async fn delete_user( enum RouteAccess { /// Publicly accessible (no auth required) Public, - /// Any valid account session. - Authenticated, - /// Read-only resources (Read, Control, or Administrator required) + /// Account controls are available to non-guest sessions. + Account, + /// Read-only resources (Guest, Read, Control, or Administrator required) Read, - /// Bookmarks (Read, Control, Write, or Administrator required) + /// Bookmarks (Guest, Read, Control, Write, or Administrator required) ReadWrite, /// Logbook access (Write or Administrator required) Write, @@ -1118,7 +1141,7 @@ impl RouteAccess { } if path == "/auth/account/password" { - return Self::Authenticated; + return Self::Account; } if path == "/auth/users" || path.starts_with("/auth/users/") { return Self::Administrator; @@ -1177,7 +1200,7 @@ impl RouteAccess { fn allows(&self, roles: Option<&AuthRoles>) -> bool { match self { Self::Public => true, - Self::Authenticated => roles.is_some(), + Self::Account => roles.is_some_and(roles_allow_account_controls), Self::Read => roles.is_some_and(|roles| roles_grant(roles, AuthRole::Read)), Self::ReadWrite => roles .is_some_and(|roles| roles_grant_any(roles, &[AuthRole::Read, AuthRole::Write])), @@ -1319,7 +1342,7 @@ mod tests { assert_eq!(RouteAccess::from_path("/auth/logout"), RouteAccess::Public); assert_eq!( RouteAccess::from_path("/auth/account/password"), - RouteAccess::Authenticated + RouteAccess::Account ); assert_eq!( RouteAccess::from_path("/auth/users"), @@ -1360,6 +1383,7 @@ mod tests { #[test] fn test_route_access_allows() { + let guest = roles(&[AuthRole::Guest]); let read = roles(&[AuthRole::Read]); let control = roles(&[AuthRole::Control]); let write = roles(&[AuthRole::Write]); @@ -1367,10 +1391,12 @@ mod tests { assert!(RouteAccess::Public.allows(None)); assert!(RouteAccess::Public.allows(Some(&read))); assert!(RouteAccess::Public.allows(Some(&administrator))); - assert!(!RouteAccess::Authenticated.allows(None)); - assert!(RouteAccess::Authenticated.allows(Some(&write))); + assert!(!RouteAccess::Account.allows(None)); + assert!(!RouteAccess::Account.allows(Some(&guest))); + assert!(RouteAccess::Account.allows(Some(&write))); assert!(!RouteAccess::Read.allows(None)); + assert!(RouteAccess::Read.allows(Some(&guest))); assert!(RouteAccess::Read.allows(Some(&read))); assert!(RouteAccess::Read.allows(Some(&control))); assert!(!RouteAccess::Read.allows(Some(&write))); @@ -1423,12 +1449,12 @@ mod tests { let users = UserStore::open(&config).unwrap(); assert_eq!( users.authenticate("admin", "password123"), - Some(AuthRole::all()) + Some(AuthRole::full_access()) ); assert_eq!(users.authenticate("admin", "wrong"), None); assert_eq!( users.authenticate("guest", "guest"), - Some(roles(&[AuthRole::Read])) + Some(roles(&[AuthRole::Guest])) ); users .add("alice", "password456", roles(&[AuthRole::Read]), true) @@ -1438,11 +1464,16 @@ mod tests { Some(roles(&[AuthRole::Read])) ); users - .update("alice", Some("password789"), Some(AuthRole::all()), None) + .update( + "alice", + Some("password789"), + Some(AuthRole::full_access()), + None, + ) .unwrap(); assert_eq!( users.authenticate("alice", "password789"), - Some(AuthRole::all()) + Some(AuthRole::full_access()) ); users.remove("admin").unwrap(); assert_eq!(users.list().len(), 2); @@ -1453,7 +1484,7 @@ mod tests { let reopened = UserStore::open(&reopen_config).unwrap(); assert_eq!( reopened.authenticate("alice", "password789"), - Some(AuthRole::all()) + Some(AuthRole::full_access()) ); let database = fs::read_to_string(&reopen_config.users_file).unwrap(); assert!(database.contains("$argon2")); @@ -1500,6 +1531,15 @@ mod tests { assert_eq!(users.authenticate("guest", "guest"), None); } + #[test] + fn guest_is_an_exclusive_role() { + assert_eq!( + validate_roles(roles(&[AuthRole::Guest])).unwrap(), + roles(&[AuthRole::Guest]) + ); + assert!(validate_roles(roles(&[AuthRole::Guest, AuthRole::Read])).is_err()); + } + #[test] fn legacy_single_roles_are_migrated() { let read: UserRecord = serde_json::from_value(serde_json::json!({ @@ -1517,7 +1557,7 @@ mod tests { assert_eq!(read.roles, roles(&[AuthRole::Read])); assert!(read.enabled); - assert_eq!(administrator.roles, AuthRole::all()); + assert_eq!(administrator.roles, AuthRole::full_access()); assert!(administrator.enabled); assert!(serde_json::to_value(administrator) .unwrap() @@ -1743,11 +1783,15 @@ mod tests { } #[actix_web::test] - async fn users_can_change_their_own_password() { + async fn non_guest_users_can_change_their_own_password() { let directory = tempfile::tempdir().unwrap(); let state = web::Data::new( AuthState::new(test_auth_config(directory.path().join("users.json"))).unwrap(), ); + state + .users + .add("reader", "reader-password", roles(&[AuthRole::Read]), true) + .unwrap(); let app = aw_test::init_service( App::new() .app_data(state) @@ -1760,16 +1804,16 @@ mod tests { &app, aw_test::TestRequest::post() .uri("/auth/login") - .set_json(serde_json::json!({"username":"guest","password":"guest"})) + .set_json(serde_json::json!({"username":"reader","password":"reader-password"})) .to_request(), ) .await; - let guest_cookie = response.response().cookies().next().unwrap().to_string(); + let reader_cookie = response.response().cookies().next().unwrap().to_string(); let change = |current: &str, new: &str| { aw_test::TestRequest::patch() .uri("/auth/account/password") - .insert_header((actix_web::http::header::COOKIE, guest_cookie.clone())) + .insert_header((actix_web::http::header::COOKIE, reader_cookie.clone())) .set_json(serde_json::json!({ "current_password":current,"new_password":new })) @@ -1782,7 +1826,7 @@ mod tests { actix_web::http::StatusCode::FORBIDDEN ); assert_eq!( - aw_test::call_service(&app, change("guest", "new-password")) + aw_test::call_service(&app, change("reader-password", "new-password")) .await .status(), actix_web::http::StatusCode::OK @@ -1791,7 +1835,7 @@ mod tests { &app, aw_test::TestRequest::get() .uri("/auth/session") - .insert_header((actix_web::http::header::COOKIE, guest_cookie)) + .insert_header((actix_web::http::header::COOKIE, reader_cookie)) .to_request(), ) .await; @@ -1801,7 +1845,7 @@ mod tests { &app, aw_test::TestRequest::post() .uri("/auth/login") - .set_json(serde_json::json!({"username":"guest","password":"new-password"})) + .set_json(serde_json::json!({"username":"reader","password":"new-password"})) .to_request(), ) .await @@ -1809,4 +1853,40 @@ mod tests { actix_web::http::StatusCode::OK ); } + + #[actix_web::test] + async fn guest_cannot_change_account_password() { + let directory = tempfile::tempdir().unwrap(); + let state = web::Data::new( + AuthState::new(test_auth_config(directory.path().join("users.json"))).unwrap(), + ); + let app = aw_test::init_service( + App::new() + .app_data(state) + .service(login) + .service(change_own_password), + ) + .await; + let response = aw_test::call_service( + &app, + aw_test::TestRequest::post() + .uri("/auth/login") + .set_json(serde_json::json!({"username":"guest","password":"guest"})) + .to_request(), + ) + .await; + let guest_cookie = response.response().cookies().next().unwrap().to_string(); + let response = aw_test::call_service( + &app, + aw_test::TestRequest::patch() + .uri("/auth/account/password") + .insert_header((actix_web::http::header::COOKIE, guest_cookie)) + .set_json(serde_json::json!({ + "current_password":"guest","new_password":"new-password" + })) + .to_request(), + ) + .await; + assert_eq!(response.status(), actix_web::http::StatusCode::FORBIDDEN); + } } diff --git a/src/trx-config/src/client.rs b/src/trx-config/src/client.rs index c4e8440a..92c9fa02 100644 --- a/src/trx-config/src/client.rs +++ b/src/trx-config/src/client.rs @@ -277,11 +277,11 @@ pub struct HttpAuthConfig { /// Read the bootstrap administrator password from this file instead. #[serde(default, skip_serializing_if = "Option::is_none")] pub bootstrap_admin_password_file: Option, - /// Create a read-only account when bootstrapping a new database. + /// Create a Guest account when bootstrapping a new database. pub bootstrap_read_enabled: bool, - /// Username for the read-only bootstrap account. + /// Username for the Guest bootstrap account. pub bootstrap_read_username: String, - /// Password for the read-only bootstrap account. + /// Password for the Guest bootstrap account. pub bootstrap_read_password: Option, /// Session time-to-live in minutes pub session_ttl_min: u64, @@ -989,7 +989,7 @@ fn validate_http_auth(auth: &HttpAuthConfig) -> Result<(), String> { .as_deref() .is_none_or(str::is_empty)) { - return Err("[frontends.http.auth] enabled bootstrap read account requires a non-empty username and password".to_string()); + return Err("[frontends.http.auth] enabled bootstrap Guest account requires a non-empty username and password".to_string()); } // Session TTL must be > 0