From 44870bc9412220a7b28e0a519cdb3d95a8df96cf Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Tue, 11 Aug 2026 15:24:51 +0200 Subject: [PATCH 1/2] [feat](trx-frontend-http): add restricted Guest role Assisted-By: Codex (GPT-5) Signed-off-by: Stan Grams --- 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 -- 2.55.0 From e978cf8a840a3ef45d8781ecfd5b925d7e128f02 Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Tue, 11 Aug 2026 18:36:33 +0200 Subject: [PATCH 2/2] [feat](trx-frontend-http): separate transmit permission Assisted-By: Codex (GPT-5) Signed-off-by: Stan Grams --- docs/Architecture.md | 3 +- docs/Planned-Features.md | 4 +- docs/UX_Guidelines.md | 9 +-- docs/User-Manual.md | 8 ++- .../assets/web/generated/app.js | 39 ++++++---- .../assets/web/generated/background-decode.js | 2 +- .../assets/web/generated/bookmarks.js | 2 +- .../{chunk-FT2RH7BL.js => chunk-PISLBJGN.js} | 5 +- .../assets/web/generated/logbook.js | 2 +- .../assets/web/generated/scheduler.js | 2 +- .../trx-frontend-http/frontend/package.json | 2 +- .../frontend/src/api/auth.ts | 5 +- .../frontend/src/api/generated.ts | 2 +- .../trx-frontend-http/frontend/src/app.ts | 43 ++++++----- .../frontend/tests/account-management.mjs | 2 +- .../frontend/tests/auth.test.mjs | 6 +- .../frontend/tests/bookmarks.test.mjs | 2 +- .../frontend/tests/host-fixture.mjs | 2 +- .../frontend/tests/transmit-role.mjs | 53 ++++++++++++++ .../frontend/tests/web-fixture.mjs | 2 +- .../trx-frontend-http/src/audio.rs | 12 ++++ .../trx-frontend-http/src/auth.rs | 72 +++++++++++++++++-- 22 files changed, 217 insertions(+), 62 deletions(-) rename src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/{chunk-FT2RH7BL.js => chunk-PISLBJGN.js} (96%) create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/transmit-role.mjs diff --git a/docs/Architecture.md b/docs/Architecture.md index 60d061b0..707e1efc 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -926,8 +926,9 @@ main ### HTTP Frontend Auth - Optional Argon2id-backed managed accounts with HttpOnly session cookies -- An exclusive Guest role plus composable Read, Control, Write, and Administrator roles, with policy shared by middleware and handlers +- An exclusive Guest role plus composable Read, Control, Transmit, 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 +- Transmit separately gates PTT, TX audio frames, and TX power-limit changes - 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/Planned-Features.md b/docs/Planned-Features.md index a6fc3e6f..30d9bf08 100644 --- a/docs/Planned-Features.md +++ b/docs/Planned-Features.md @@ -541,8 +541,8 @@ setting, which is also what LoTW's station locations expect. rotate operators through one station callsign, which is why contest loggers record it per QSO. It is stored per QSO, defaulted from the configured callsign so a single operator never touches it, and changed on the station line at the top of the panel where it sticks for the session. -It cannot be taken from the session's identity: the auth roles are `admin` and `user`, with no -notion of who is logged in. +It cannot be inferred from the session's identity: an account username need not be an operator +callsign, and operational accounts may be shared. **Server clock, and the log says so.** The server is the machine at the radio; the browser may be on a phone in another timezone with a clock nobody has checked. QSO times are UTC from the diff --git a/docs/UX_Guidelines.md b/docs/UX_Guidelines.md index ddadd664..7f5eed35 100644 --- a/docs/UX_Guidelines.md +++ b/docs/UX_Guidelines.md @@ -127,7 +127,7 @@ When auth is enabled, an **auth gate** blocks the UI with: - Role badge display **Guest** provides read-only station access and is exclusive. Non-Guest accounts -may combine **Read**, **Control**, **Write**, and **Administrator** roles. +may combine **Read**, **Control**, **Transmit**, **Write**, and **Administrator** roles. Administrator implies all permissions. Session cookie: `trx_http_sid`, HttpOnly, configurable Secure and SameSite attributes. @@ -337,13 +337,14 @@ Logo and favicon are embedded at compile time via `include_bytes!`. The logo ima ### 7.1 Route Access Classification -Routes are classified into three tiers: +Routes are classified into access tiers: | Tier | Examples | Requirement | |---|---|---| | **Public** | `/`, `/index.html`, `/map`, login/session endpoints, static assets | None | -| **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 | +| **Read** | `/status`, `/events`, `/audio`, `/decode`, `/spectrum`, `/bookmarks` | Guest, Read, Control, Transmit, or Administrator role | +| **Control** | `/set_freq`, `/set_mode`, `/toggle_power`, receive-side radio-control POST routes | Control or Administrator role | +| **Transmit** | `/set_ptt`, `/set_tx_limit`, outbound `/audio` frames | Transmit or Administrator role | | **Write** | Logbook access and bookmark mutations | Write or Administrator role | ### 7.2 Session Management diff --git a/docs/User-Manual.md b/docs/User-Manual.md index a46c6052..3350bbc2 100644 --- a/docs/User-Manual.md +++ b/docs/User-Manual.md @@ -593,7 +593,8 @@ 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 +- **Control** — tuning, mode, power, and receive-side radio controls +- **Transmit** — PTT, transmitted audio, and TX power-limit controls - **Write** — logbook access and bookmark changes - **Administrator** — user management and all other permissions @@ -644,8 +645,9 @@ credentials in configuration before first startup on an exposed deployment. | `/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 accept Guest or require Read. Radio mutations require Control. Logbook access and -bookmark mutations require Write. Administrator grants every permission. +Read routes accept Guest or require Read. Tuning and receive-side radio mutations +require Control. PTT, transmitted audio, and TX limit changes require Transmit. +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 7116c2be..351d3412 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 @@ -13,7 +13,7 @@ import { logout, normalizeAuthRoles, updateUser -} from "./chunk-FT2RH7BL.js"; +} from "./chunk-PISLBJGN.js"; // src/webgl-renderer.ts (function initTrxWebGl(global) { @@ -1972,30 +1972,33 @@ function updateAuthUI() { } function applyAuthRestrictions() { if (authRoles.length === 0) return; - if (!hasAuthRole2("control")) { + if (!hasAuthRole2("transmit")) { const pttBtn2 = document.getElementById("ptt-btn"); + const txLimitInput2 = document.getElementById("tx-limit"); + const txLimitBtn2 = document.getElementById("tx-limit-btn"); + const txAudioBtn2 = document.getElementById("tx-audio-btn"); + const txLimitRow2 = document.getElementById("tx-limit-row"); + if (pttBtn2) pttBtn2.disabled = true; + if (txAudioBtn2) txAudioBtn2.disabled = true; + if (txLimitBtn2) txLimitBtn2.disabled = true; + if (txLimitInput2) txLimitInput2.disabled = true; + if (txLimitRow2) txLimitRow2.style.opacity = "0.5"; + } + if (!hasAuthRole2("control")) { const powerBtn2 = document.getElementById("power-btn"); const lockBtn2 = document.getElementById("lock-btn"); const freqInput = document.getElementById("freq"); const centerFreqInput = document.getElementById("center-freq"); const modeSelect = document.getElementById("mode"); - const txLimitInput2 = document.getElementById("tx-limit"); - const txLimitBtn2 = document.getElementById("tx-limit-btn"); - const txAudioBtn2 = document.getElementById("tx-audio-btn"); - const txLimitRow2 = document.getElementById("tx-limit-row"); const jogUp = document.getElementById("jog-up"); const jogDown = document.getElementById("jog-down"); const jogButtons = document.querySelectorAll(".jog-step button"); const vfoButtons = document.querySelectorAll("#vfo-picker button"); - if (pttBtn2) pttBtn2.disabled = true; if (powerBtn2) powerBtn2.disabled = true; if (lockBtn2) lockBtn2.disabled = true; - if (txAudioBtn2) txAudioBtn2.disabled = true; - if (txLimitBtn2) txLimitBtn2.disabled = true; if (freqInput) freqInput.disabled = true; if (centerFreqInput) centerFreqInput.disabled = true; if (modeSelect) modeSelect.disabled = true; - if (txLimitInput2) txLimitInput2.disabled = true; vfoButtons.forEach((btn) => btn.disabled = true); const jogWheel2 = document.getElementById("jog-wheel"); if (jogUp) jogUp.disabled = true; @@ -2032,7 +2035,6 @@ function applyAuthRestrictions() { btn.disabled = true; } }); - if (txLimitRow2) txLimitRow2.style.opacity = "0.5"; } } function applyCapabilities(caps) { @@ -4250,9 +4252,16 @@ function formatSignal(sUnits) { return overDb === 0 ? `${sigUnit("S")}9` : `${sigUnit("S")}9+${overDb}${sigUnit("dB")}`; } function setDisabled(disabled) { - [freqEl, centerFreqEl, modeEl, pttBtn, powerBtn, txLimitInput, txLimitBtn, lockBtn].forEach((el) => { - if (el) el.disabled = disabled; + const controlDisabled = disabled || authEnabled && !hasAuthRole2("control"); + const transmitDisabled = disabled || authEnabled && !hasAuthRole2("transmit"); + [freqEl, centerFreqEl, modeEl, powerBtn, lockBtn].forEach((el) => { + if (el) el.disabled = controlDisabled; }); + [pttBtn, txLimitInput, txLimitBtn].forEach((el) => { + if (el) el.disabled = transmitDisabled; + }); + const transmitAudio = document.getElementById("tx-audio-btn"); + if (transmitAudio) transmitAudio.disabled = transmitDisabled || !hasWebCodecs; syncModePicker(); } var serverVersion = null; @@ -7329,6 +7338,10 @@ function startTxAudio() { void stopTxAudio(); return; } + if (authEnabled && !hasAuthRole2("transmit")) { + audioStatus.textContent = "Transmit role required"; + return; + } if (!hasWebCodecs) { audioStatus.textContent = "Audio requires Chrome/Edge"; return; 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 d8bd4adc..1c7d77c4 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-FT2RH7BL.js"; +} from "./chunk-PISLBJGN.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 05764581..c18690db 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-FT2RH7BL.js"; +} from "./chunk-PISLBJGN.js"; import { hostCore, hostState diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-FT2RH7BL.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-PISLBJGN.js similarity index 96% rename from src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-FT2RH7BL.js rename to src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-PISLBJGN.js index 283aaea6..005ee4c1 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-FT2RH7BL.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-PISLBJGN.js @@ -1,10 +1,11 @@ // src/api/auth.ts -var AUTH_ROLES = ["guest", "read", "control", "write", "administrator"]; +var AUTH_ROLES = ["guest", "read", "control", "transmit", "write", "administrator"]; var AUTH_ADMIN_ROLES = AUTH_ROLES.filter((role) => role !== "guest"); var AUTH_ROLE_LABELS = { guest: "Guest", read: "Read", control: "Control", + transmit: "Transmit", write: "Write", administrator: "Administrator" }; @@ -15,7 +16,7 @@ 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("guest") || required === "read" && roles.includes("control"); + return roles.includes("administrator") || roles.includes(required) || required === "read" && roles.includes("guest") || required === "read" && (roles.includes("control") || roles.includes("transmit")); } function hasAccountControls(roles) { return roles.length > 0 && !roles.includes("guest"); 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 85dd2806..348f836e 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-FT2RH7BL.js"; +} from "./chunk-PISLBJGN.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 e932b67d..2131772e 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-FT2RH7BL.js"; +} from "./chunk-PISLBJGN.js"; import { hostState } from "./chunk-KL66PICH.js"; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json b/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json index b008f062..b7fbc068 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json @@ -12,7 +12,7 @@ "typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json", "lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern", "test": "node --test tests/*.test.mjs", - "test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.mjs && node tests/mobile-layout.mjs && node tests/satellite-predictions.mjs && node tests/background-decode.mjs && node tests/logbook.mjs && node tests/account-management.mjs", + "test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.mjs && node tests/mobile-layout.mjs && node tests/satellite-predictions.mjs && node tests/background-decode.mjs && node tests/logbook.mjs && node tests/account-management.mjs && node tests/transmit-role.mjs", "verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts" }, "devDependencies": { 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 49fd9fc9..bc682318 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,12 +6,13 @@ import type { AuthRole } from "./generated.js"; export type { AuthRole }; -export const AUTH_ROLES: readonly AuthRole[] = ["guest", "read", "control", "write", "administrator"]; +export const AUTH_ROLES: readonly AuthRole[] = ["guest", "read", "control", "transmit", "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", + transmit: "Transmit", write: "Write", administrator: "Administrator", }; @@ -28,7 +29,7 @@ export function hasAuthRole(roles: readonly AuthRole[], required: AuthRole): boo return roles.includes("administrator") || roles.includes(required) || required === "read" && roles.includes("guest") - || required === "read" && roles.includes("control"); + || required === "read" && (roles.includes("control") || roles.includes("transmit")); } export function hasAccountControls(roles: readonly AuthRole[]): boolean { 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 8821145f..13a84a2c 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 = "guest" | "read" | "control" | "write" | "administrator"; +export type AuthRole = "guest" | "read" | "control" | "transmit" | "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 f2979505..830be597 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 @@ -584,35 +584,38 @@ function updateAuthUI() { function applyAuthRestrictions() { if (authRoles.length === 0) return; - // Disable TX/PTT/frequency/mode/VFO controls for user role - if (!hasAuthRole("control")) { + if (!hasAuthRole("transmit")) { const pttBtn = document.getElementById("ptt-btn") as HTMLButtonElement | null; + const txLimitInput = document.getElementById("tx-limit") as HTMLInputElement | null; + const txLimitBtn = document.getElementById("tx-limit-btn") as HTMLButtonElement | null; + const txAudioBtn = document.getElementById("tx-audio-btn") as HTMLButtonElement | null; + const txLimitRow = document.getElementById("tx-limit-row"); + if (pttBtn) pttBtn.disabled = true; + if (txAudioBtn) txAudioBtn.disabled = true; + if (txLimitBtn) txLimitBtn.disabled = true; + if (txLimitInput) txLimitInput.disabled = true; + if (txLimitRow) txLimitRow.style.opacity = "0.5"; + } + + // General tuning and receive-side controls require Control. + if (!hasAuthRole("control")) { const powerBtn = document.getElementById("power-btn") as HTMLButtonElement | null; const lockBtn = document.getElementById("lock-btn") as HTMLButtonElement | null; const freqInput = document.getElementById("freq") as HTMLInputElement | null; const centerFreqInput = document.getElementById("center-freq") as HTMLInputElement | null; const modeSelect = document.getElementById("mode") as HTMLSelectElement | null; - const txLimitInput = document.getElementById("tx-limit") as HTMLInputElement | null; - const txLimitBtn = document.getElementById("tx-limit-btn") as HTMLButtonElement | null; - const txAudioBtn = document.getElementById("tx-audio-btn") as HTMLButtonElement | null; - const txLimitRow = document.getElementById("tx-limit-row"); const jogUp = document.getElementById("jog-up") as HTMLButtonElement | null; const jogDown = document.getElementById("jog-down") as HTMLButtonElement | null; const jogButtons = document.querySelectorAll(".jog-step button"); const vfoButtons = document.querySelectorAll("#vfo-picker button"); - // Disable TX buttons - if (pttBtn) pttBtn.disabled = true; if (powerBtn) powerBtn.disabled = true; if (lockBtn) lockBtn.disabled = true; - if (txAudioBtn) txAudioBtn.disabled = true; - if (txLimitBtn) txLimitBtn.disabled = true; // Disable frequency/mode inputs if (freqInput) freqInput.disabled = true; if (centerFreqInput) centerFreqInput.disabled = true; if (modeSelect) modeSelect.disabled = true; - if (txLimitInput) txLimitInput.disabled = true; // Disable VFO selector vfoButtons.forEach(btn => btn.disabled = true); @@ -656,9 +659,6 @@ function applyAuthRestrictions() { btn.disabled = true; } }); - - // Hide TX-specific UI but keep controls visible (disabled) - if (txLimitRow) txLimitRow.style.opacity = "0.5"; } } @@ -3080,9 +3080,16 @@ function formatSignal(sUnits: number) { } function setDisabled(disabled: boolean) { - [freqEl, centerFreqEl, modeEl, pttBtn, powerBtn, txLimitInput, txLimitBtn, lockBtn].forEach((el) => { - if (el) el.disabled = disabled; + const controlDisabled = disabled || (authEnabled && !hasAuthRole("control")); + const transmitDisabled = disabled || (authEnabled && !hasAuthRole("transmit")); + [freqEl, centerFreqEl, modeEl, powerBtn, lockBtn].forEach((el) => { + if (el) el.disabled = controlDisabled; }); + [pttBtn, txLimitInput, txLimitBtn].forEach((el) => { + if (el) el.disabled = transmitDisabled; + }); + const transmitAudio = document.getElementById("tx-audio-btn") as HTMLButtonElement | null; + if (transmitAudio) transmitAudio.disabled = transmitDisabled || !hasWebCodecs; syncModePicker(); } @@ -6370,6 +6377,10 @@ function stopRxAudio() { function startTxAudio() { if (txActive) { void stopTxAudio(); return; } + if (authEnabled && !hasAuthRole("transmit")) { + audioStatus.textContent = "Transmit role required"; + return; + } if (!hasWebCodecs) { audioStatus.textContent = "Audio requires Chrome/Edge"; return; 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 8a5e4ba5..bd7b052a 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 @@ -8,7 +8,7 @@ import { startBrowser, startWebFixture } from "./web-fixture.mjs"; /* global document */ -const ALL_ROLES = ["read", "control", "write", "administrator"]; +const ALL_ROLES = ["read", "control", "transmit", "write", "administrator"]; const fixture = await startWebFixture({ authSession: { authenticated: true, 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 3c3a759d..0204bc32 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,13 +15,15 @@ function loadAuth(fetch) { return context.AuthApi; } -test("role policy centralizes Guest and implied read access", () => { +test("role policy centralizes Guest and separates Control from Transmit", () => { const auth = loadAuth(async () => { throw new Error("unused"); }); - assert.deepEqual(Array.from(auth.AUTH_ROLES), ["guest", "read", "control", "write", "administrator"]); + assert.deepEqual(Array.from(auth.AUTH_ROLES), ["guest", "read", "control", "transmit", "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(["transmit"], "read"), true); + assert.equal(auth.hasAuthRole(["control"], "transmit"), false); 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/bookmarks.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/bookmarks.test.mjs index 72145ef3..d476c61e 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/bookmarks.test.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/bookmarks.test.mjs @@ -32,7 +32,7 @@ function hostFixture(overrides = {}) { const calls = { postPath: [], setRigFrequency: [], armOptimisticFrequency: [], applyLocalTunedFrequency: [], syncBandwidthInput: [], scheduleSpectrumDraw: 0, syncModePicker: 0 }; const state = { authEnabled: false, - authRoles: ["read", "control", "write", "administrator"], + authRoles: ["read", "control", "transmit", "write", "administrator"], lastActiveRigId: null, lastRigIds: [], lastRigDisplayNames: {}, diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/host-fixture.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/host-fixture.mjs index 14778877..b2beee1a 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/host-fixture.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/host-fixture.mjs @@ -19,7 +19,7 @@ export function createHost({ state = {}, core = {}, modules = {} } = {}) { serverLat: null, serverLon: null, authEnabled: false, - authRoles: ["read", "control", "write", "administrator"], + authRoles: ["read", "control", "transmit", "write", "administrator"], lastActiveRigId: null, lastRigIds: [], lastRigDisplayNames: {}, diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/transmit-role.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/transmit-role.mjs new file mode 100644 index 00000000..f2c0cba1 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/transmit-role.mjs @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +import assert from "node:assert/strict"; +import { chromium } from "playwright-core"; +import { startBrowser, startWebFixture } from "./web-fixture.mjs"; + +async function controlState(page) { + return { + frequency: await page.locator("#freq").isDisabled(), + ptt: await page.locator("#ptt-btn").isDisabled(), + txAudio: await page.locator("#tx-audio-btn").isDisabled(), + txLimit: await page.locator("#tx-limit-btn").isDisabled(), + }; +} + +async function inspectRole(roles) { + const fixture = await startWebFixture({ + tx: true, + authSession: { + authenticated: true, + username: "operator", + roles, + auth_disabled: false, + }, + }); + const session = await startBrowser(chromium); + try { + await session.page.goto(`${fixture.origin}/`, { waitUntil: "domcontentloaded" }); + await session.page.locator("#content").waitFor({ state: "visible" }); + await session.page.waitForTimeout(1200); + assert.deepEqual(session.runtimeErrors, []); + return await controlState(session.page); + } finally { + await session.browser.close(); + await fixture.close(); + } +} + +assert.deepEqual(await inspectRole(["control"]), { + frequency: false, + ptt: true, + txAudio: true, + txLimit: true, +}); + +assert.deepEqual(await inspectRole(["transmit"]), { + frequency: true, + ptt: false, + txAudio: false, + txLimit: false, +}); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/web-fixture.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/web-fixture.mjs index 6b7a6edd..01f6c254 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/web-fixture.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/web-fixture.mjs @@ -145,7 +145,7 @@ export async function startWebFixture({ bandplanEnabled = false, bandplanUnauthorizedFirst = false, satPasses = null, - authSession = { authenticated: true, roles: ["read", "control", "write", "administrator"], auth_disabled: true }, + authSession = { authenticated: true, roles: ["read", "control", "transmit", "write", "administrator"], auth_disabled: true }, users = [], } = {}) { const rigItems = ["rig-a", "rig-b"].map((remote) => ({ diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/audio.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/audio.rs index b3f3da87..bb7797b0 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/audio.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/audio.rs @@ -737,6 +737,7 @@ pub async fn audio_ws( body: web::Payload, query: web::Query, context: web::Data>, + auth_state: web::Data, ) -> Result { let Some(tx_sender) = context.audio.tx.as_ref().cloned() else { return Ok(HttpResponse::NotFound().body("audio not enabled")); @@ -746,6 +747,7 @@ pub async fn audio_ws( if !req.headers().contains_key("upgrade") { return Ok(HttpResponse::NoContent().finish()); } + let tx_session_id = crate::server::auth::extract_session_id(&req); // If a channel_id is specified, subscribe to the per-channel broadcaster. // The entry is created asynchronously when AUDIO_MSG_VCHAN_ALLOCATED arrives @@ -880,6 +882,16 @@ pub async fn audio_ws( msg = msg_stream.recv() => { match msg { Some(Ok(Message::Binary(data))) => { + let can_transmit = !auth_state.config.enabled + || crate::server::auth::session_id_grants( + tx_session_id.as_ref(), + &auth_state, + crate::server::auth::AuthRole::Transmit, + ); + if !can_transmit { + warn!("Audio WS: closing after unauthorized TX frame"); + break; + } let _ = tx_sender.send(Bytes::from(data.to_vec())).await; } Some(Ok(Message::Close(_))) => break, 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 1848a3af..ce291e95 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 @@ -38,6 +38,7 @@ pub enum AuthRole { Guest, Read, Control, + Transmit, Write, Administrator, } @@ -48,22 +49,29 @@ impl AuthRole { Self::Guest => "guest", Self::Read => "read", Self::Control => "control", + Self::Transmit => "transmit", Self::Write => "write", Self::Administrator => "administrator", } } pub fn full_access() -> BTreeSet { - [Self::Read, Self::Control, Self::Write, Self::Administrator] - .into_iter() - .collect() + [ + Self::Read, + Self::Control, + Self::Transmit, + Self::Write, + Self::Administrator, + ] + .into_iter() + .collect() } fn grants(self, required: Self) -> bool { self == Self::Administrator || self == required || self == Self::Guest && required == Self::Read - || self == Self::Control && required == Self::Read + || matches!(self, Self::Control | Self::Transmit) && required == Self::Read } } @@ -776,7 +784,7 @@ pub struct ChangePasswordRequest { } /// Extract session from cookie -fn extract_session_id(req: &HttpRequest) -> Option { +pub fn extract_session_id(req: &HttpRequest) -> Option { req.cookie("trx_http_sid") .map(|cookie| cookie.value().to_string()) } @@ -789,7 +797,18 @@ pub fn get_session_roles(req: &HttpRequest, auth_state: &AuthState) -> Option bool { - get_session_roles(req, auth_state).is_some_and(|roles| roles_grant(&roles, role)) + let session_id = extract_session_id(req); + session_id_grants(session_id.as_ref(), auth_state, role) +} + +pub fn session_id_grants( + session_id: Option<&SessionId>, + auth_state: &AuthState, + role: AuthRole, +) -> bool { + session_id + .and_then(|id| auth_state.store.get(id)) + .is_some_and(|session| roles_grant(&session.roles, role)) } fn require_session( @@ -1113,12 +1132,14 @@ enum RouteAccess { Account, /// Read-only resources (Guest, Read, Control, or Administrator required) Read, - /// Bookmarks (Guest, Read, Control, Write, or Administrator required) + /// Bookmarks (Guest, Read, Control, Transmit, Write, or Administrator required) ReadWrite, /// Logbook access (Write or Administrator required) Write, /// Radio control (Control or Administrator required) Control, + /// Transmit actions (Transmit or Administrator required) + Transmit, /// Managed-account administration. Administrator, } @@ -1146,6 +1167,9 @@ impl RouteAccess { if path == "/auth/users" || path.starts_with("/auth/users/") { return Self::Administrator; } + if path == "/set_ptt" || path == "/set_tx_limit" { + return Self::Transmit; + } // Static assets. The band plan is one of them: it is compiled into the // binary and identical for every user, but ".json" is not an asset @@ -1206,6 +1230,7 @@ impl RouteAccess { .is_some_and(|roles| roles_grant_any(roles, &[AuthRole::Read, AuthRole::Write])), Self::Write => roles.is_some_and(|roles| roles_grant(roles, AuthRole::Write)), Self::Control => roles.is_some_and(|roles| roles_grant(roles, AuthRole::Control)), + Self::Transmit => roles.is_some_and(|roles| roles_grant(roles, AuthRole::Transmit)), Self::Administrator => { roles.is_some_and(|roles| roles_grant(roles, AuthRole::Administrator)) } @@ -1379,6 +1404,11 @@ mod tests { RouteAccess::Control ); assert_eq!(RouteAccess::from_path("/set_mode"), RouteAccess::Control); + assert_eq!(RouteAccess::from_path("/set_ptt"), RouteAccess::Transmit); + assert_eq!( + RouteAccess::from_path("/set_tx_limit"), + RouteAccess::Transmit + ); } #[test] @@ -1386,6 +1416,7 @@ mod tests { let guest = roles(&[AuthRole::Guest]); let read = roles(&[AuthRole::Read]); let control = roles(&[AuthRole::Control]); + let transmit = roles(&[AuthRole::Transmit]); let write = roles(&[AuthRole::Write]); let administrator = roles(&[AuthRole::Administrator]); assert!(RouteAccess::Public.allows(None)); @@ -1399,6 +1430,7 @@ mod tests { assert!(RouteAccess::Read.allows(Some(&guest))); assert!(RouteAccess::Read.allows(Some(&read))); assert!(RouteAccess::Read.allows(Some(&control))); + assert!(RouteAccess::Read.allows(Some(&transmit))); assert!(!RouteAccess::Read.allows(Some(&write))); assert!(RouteAccess::Read.allows(Some(&administrator))); @@ -1413,7 +1445,11 @@ mod tests { assert!(!RouteAccess::Control.allows(None)); assert!(!RouteAccess::Control.allows(Some(&read))); assert!(RouteAccess::Control.allows(Some(&control))); + assert!(!RouteAccess::Control.allows(Some(&transmit))); assert!(RouteAccess::Control.allows(Some(&administrator))); + assert!(!RouteAccess::Transmit.allows(Some(&control))); + assert!(RouteAccess::Transmit.allows(Some(&transmit))); + assert!(RouteAccess::Transmit.allows(Some(&administrator))); assert!(!RouteAccess::Administrator.allows(Some(&control))); assert!(RouteAccess::Administrator.allows(Some(&administrator))); } @@ -1442,6 +1478,28 @@ mod tests { assert!(store.get(&session_id).is_none()); } + #[test] + fn transmit_grant_tracks_live_session_revocation() { + let directory = tempfile::tempdir().unwrap(); + let state = AuthState::new(test_auth_config(directory.path().join("users.json"))).unwrap(); + let session_id = state.store.create( + "operator".to_string(), + roles(&[AuthRole::Transmit]), + Duration::from_secs(3600), + ); + assert!(session_id_grants( + Some(&session_id), + &state, + AuthRole::Transmit + )); + state.store.remove(&session_id); + assert!(!session_id_grants( + Some(&session_id), + &state, + AuthRole::Transmit + )); + } + #[test] fn user_store_bootstraps_and_manages_users() { let directory = tempfile::tempdir().unwrap(); -- 2.55.0