diff --git a/docs/Architecture.md b/docs/Architecture.md index 920332c8..2fc29f6f 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -1043,7 +1043,7 @@ The `FrontendRuntimeContext` struct in `trx-frontend/src/lib.rs` is decomposed i |-----------|---------|------------| | `AudioContext` | Audio streaming channels | `rx`, `tx`, `info`, `decode_rx`, `clients` | | `DecodeHistoryContext` | Decode history for all types | `ais`, `vdes`, `aprs`, `hf_aprs`, `cw`, `ft8`, `ft4`, `ft2`, `wspr` | -| `HttpAuthConfig` | HTTP auth settings | `enabled`, `users_file`, bootstrap admin, `session_ttl_secs`, `tokens` | +| `HttpAuthConfig` | HTTP auth settings | `enabled`, `users_file`, bootstrap admin/read accounts, `session_ttl_secs`, `tokens` | | `HttpUiConfig` | HTTP UI display config | `show_sdr_gain_control`, `initial_map_zoom`, `spectrum_*` | | `RigRoutingContext` | Remote rig state & routing | `active_rig_id`, `remote_rigs`, `rig_states`, `server_connected` | | `OwnerInfo` | Station metadata | `callsign`, `website_url`, `ais_vessel_url_base` | diff --git a/docs/UX_Guidelines.md b/docs/UX_Guidelines.md index 5b3c8396..724b6eee 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 -Two roles: **User** (read-only) and **Admin** (full access including user management). +Accounts may combine **Read**, **Control**, **Write**, and **Administrator** roles. +Administrator implies all permissions. Session cookie: `trx_http_sid`, HttpOnly, configurable Secure and SameSite attributes. @@ -340,8 +341,9 @@ 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` | User or Admin role | -| **Control** | `/set_freq`, `/set_mode`, `/set_ptt`, `/toggle_power`, all other POST | Admin role only | +| **Read** | `/status`, `/events`, `/audio`, `/decode`, `/spectrum`, `/bookmarks` | 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 | ### 7.2 Session Management diff --git a/docs/User-Manual.md b/docs/User-Manual.md index bc2bd327..ffc9a076 100644 --- a/docs/User-Manual.md +++ b/docs/User-Manual.md @@ -354,6 +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 | | `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` | @@ -577,7 +580,7 @@ The link button in the top bar copies the current link to the clipboard. The address bar itself is updated as you tune, using `replaceState`, so sweeping the dial does not fill the browser's history. -Applying a link changes the radio, so it needs the `admin` role; a `user` +Applying a link changes the radio, so it needs the `Control` role; a `Read` session opens the page and says the link was not applied. Links describe the rig's own dial — while a tab is listening to a virtual channel the address is left as it was, rather than publishing a frequency the rig is not on. @@ -586,11 +589,13 @@ 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 accounts and two -roles: +The HTTP frontend supports an optional user/password ACL with multiple independent +roles. One account may have any combination: -- **user** — read-only access (monitoring, audio, decode streams) -- **admin** — full radio control, settings, and user management +- **Read** — monitoring, audio, decode streams, and bookmark reads +- **Control** — full radio receive/transmit controls +- **Write** — logbook access and bookmark changes +- **Administrator** — user management and all other permissions ### Configuration @@ -600,6 +605,9 @@ enabled = false users_file = "trx-http-users.json" bootstrap_admin_username = "admin" bootstrap_admin_password = "change-this-password" +bootstrap_read_enabled = true +bootstrap_read_username = "guest" +bootstrap_read_password = "guest" session_ttl_min = 480 cookie_secure = false # true if served via HTTPS cookie_same_site = "Lax" # Strict|Lax|None @@ -607,7 +615,9 @@ 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 and the Argon2id-hashed user database. +the initial administrator (with every role), the default `guest`/`guest` Read +account, and the Argon2id-hashed user database. Change or disable the guest +credentials in configuration before first startup on an exposed deployment. ### Behaviour @@ -615,8 +625,9 @@ the initial administrator and the Argon2id-hashed user database. - 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. -- `user` sessions cannot call control routes. There is no guest-access mode. -- Administrators can add/remove users and change roles/passwords in Settings. +- Roles are independent; for example, an account may have Read and Write without Control. +- Administrators can add/remove users and change roles/passwords in Settings > Users. +- At least one administrator must always remain and cannot be removed or demoted. - Removing an account or changing its password/role revokes its sessions. ### Routes @@ -625,19 +636,19 @@ the initial administrator and the Argon2id-hashed user database. |----------|--------|-------------| | `/auth/login` | POST | Submit `{ "username": "...", "password": "..." }` | | `/auth/logout` | POST | Clear session | -| `/auth/session` | GET | Check current session/role | +| `/auth/session` | GET | Check current session/roles | | `/auth/users` | GET/POST | List or add users (admin only) | -| `/auth/users/{username}` | PATCH/DELETE | Change password/role or remove user (admin only) | +| `/auth/users/{username}` | PATCH/DELETE | Change password/roles or remove user (administrator only) | -Protected routes require at least `user` role. Control routes (set frequency, -mode, PTT, etc.) require `admin` role. +Read routes require Read. Radio mutations require Control. Logbook access and +bookmark mutations require Write. Administrator grants every permission. ### Frontend Flow 1. On load, the UI calls `/auth/session`. 2. If unauthenticated, a login screen is shown. 3. On successful login, the normal UI loads. -4. `user` accounts see a read-only interface; admins get full controls. +4. The interface enables controls according to the account's roles. 5. If a session expires mid-use, streams stop and the login screen returns. ### Transport Security diff --git a/src/trx-client/src/main.rs b/src/trx-client/src/main.rs index 98632697..244dad46 100644 --- a/src/trx-client/src/main.rs +++ b/src/trx-client/src/main.rs @@ -257,6 +257,12 @@ async fn async_init() -> DynResult { cfg.frontends.http.auth.bootstrap_admin_username.clone(); frontend_runtime.http_auth.bootstrap_admin_password = cfg.frontends.http.auth.bootstrap_admin_password.clone(); + frontend_runtime.http_auth.bootstrap_read_enabled = + cfg.frontends.http.auth.bootstrap_read_enabled; + frontend_runtime.http_auth.bootstrap_read_username = + cfg.frontends.http.auth.bootstrap_read_username.clone(); + frontend_runtime.http_auth.bootstrap_read_password = + cfg.frontends.http.auth.bootstrap_read_password.clone(); frontend_runtime.http_auth.session_ttl_secs = cfg.frontends.http.auth.session_ttl().as_secs(); frontend_runtime.http_auth.cookie_secure = cfg.frontends.http.auth.cookie_secure; frontend_runtime.http_auth.cookie_same_site = match cfg.frontends.http.auth.cookie_same_site { diff --git a/src/trx-client/trx-frontend/src/lib.rs b/src/trx-client/trx-frontend/src/lib.rs index ae5d81a6..99f6bd37 100644 --- a/src/trx-client/trx-frontend/src/lib.rs +++ b/src/trx-client/trx-frontend/src/lib.rs @@ -260,6 +260,9 @@ pub struct HttpAuthConfig { pub users_file: String, pub bootstrap_admin_username: Option, pub bootstrap_admin_password: Option, + pub bootstrap_read_enabled: bool, + pub bootstrap_read_username: String, + pub bootstrap_read_password: Option, pub session_ttl_secs: u64, pub cookie_secure: bool, pub cookie_same_site: String, @@ -274,6 +277,9 @@ impl Default for HttpAuthConfig { users_file: "trx-http-users.json".to_string(), bootstrap_admin_username: None, bootstrap_admin_password: None, + bootstrap_read_enabled: true, + bootstrap_read_username: "guest".to_string(), + bootstrap_read_password: Some("guest".to_string()), session_ttl_secs: 480 * 60, cookie_secure: false, cookie_same_site: "Lax".to_string(), 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 c364e394..68cc3be9 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 @@ -1323,14 +1323,13 @@ function decodeAuthSession(value) { if (typeof session.authenticated !== "boolean") { throw new TypeError("The authentication response has no authenticated flag"); } - if (session.role !== void 0 && session.role !== "user" && session.role !== "admin") { - throw new TypeError("The authentication response has an invalid role"); + if (!Array.isArray(session.roles) || !session.roles.every((role) => role === "read" || role === "control" || role === "write" || role === "administrator")) { + throw new TypeError("The authentication response has invalid roles"); } if (session.auth_disabled !== void 0 && typeof session.auth_disabled !== "boolean") { throw new TypeError("The authentication response has an invalid auth_disabled flag"); } - const decoded = { authenticated: session.authenticated }; - if (session.role !== void 0) decoded.role = session.role; + const decoded = { authenticated: session.authenticated, roles: session.roles }; if (session.username !== void 0) { if (typeof session.username !== "string") throw new TypeError("The authentication response has an invalid username"); decoded.username = session.username; @@ -1340,18 +1339,18 @@ function decodeAuthSession(value) { } var authDisabledSession = { authenticated: true, - role: "admin", + roles: ["read", "control", "write", "administrator"], auth_disabled: true }; async function fetchAuthSession() { try { const response = await fetch("/auth/session"); if (response.status === 404) return authDisabledSession; - if (!response.ok) return { authenticated: false }; + if (!response.ok) return { authenticated: false, roles: [] }; return decodeAuthSession(await response.json()); } catch (error) { console.error("Auth check failed:", error); - return { authenticated: false }; + return { authenticated: false, roles: [] }; } } async function login(username, password) { @@ -1380,14 +1379,14 @@ async function listUsers() { if (!Array.isArray(value) || !value.every((user) => { if (typeof user !== "object" || user === null) return false; const record = user; - return typeof record.username === "string" && (record.role === "user" || record.role === "admin"); + return typeof record.username === "string" && Array.isArray(record.roles) && record.roles.every((role) => role === "read" || role === "control" || role === "write" || role === "administrator"); })) { throw new TypeError("The user list response is malformed"); } return value; } -async function createUser(username, password, role) { - await userRequest("/auth/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password, role }) }); +async function createUser(username, password, roles) { + await userRequest("/auth/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password, roles }) }); } async function updateUser(username, changes) { await userRequest(`/auth/users/${encodeURIComponent(username)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(changes) }); @@ -1895,8 +1894,23 @@ function isVchanRdsEntry(value) { } void loadDecoderRegistry(refreshOperatorLayoutCapabilities); var authRole = null; +var authRoles = []; var authUsername = null; var authEnabled = true; +var ALL_AUTH_ROLES = ["read", "control", "write", "administrator"]; +var AUTH_ROLE_LABELS = { + read: "Read", + control: "Control", + write: "Write", + administrator: "Administrator" +}; +function setAuthRoles(roles) { + authRoles = [...new Set(roles)]; + authRole = ["administrator", "control", "write", "read"].find((role) => authRoles.includes(role)) ?? null; +} +function hasAuthRole(role) { + return authRoles.includes("administrator") || authRoles.includes(role); +} async function checkAuthStatus() { return fetchAuthSession(); } @@ -1906,7 +1920,7 @@ async function authLogin(username, password) { async function authLogout() { try { await logout(); - authRole = null; + setAuthRoles([]); authUsername = null; disconnect(); setDecodeHistoryOverlayVisible(false); @@ -1978,9 +1992,9 @@ function updateAuthUI() { syncTopBarAccess(); return; } - if (authRole) { + if (authRoles.length > 0) { if (badge) badge.style.display = "block"; - if (badgeRole) badgeRole.textContent = `${authUsername || "local"} — ${authRole === "admin" ? "Admin" : "User (read-only)"}`; + if (badgeRole) badgeRole.textContent = `${authUsername || "local"} — ${authRoles.map((role) => AUTH_ROLE_LABELS[role]).join(", ")}`; if (headerAuthBtn2) { headerAuthBtn2.textContent = "Logout"; headerAuthBtn2.style.display = "block"; @@ -1995,8 +2009,8 @@ function updateAuthUI() { syncTopBarAccess(); } function applyAuthRestrictions() { - if (!authRole) return; - if (authRole === "user") { + if (authRoles.length === 0) return; + if (!hasAuthRole("control")) { const pttBtn2 = document.getElementById("ptt-btn"); const powerBtn2 = document.getElementById("power-btn"); const lockBtn2 = document.getElementById("lock-btn"); @@ -2286,20 +2300,21 @@ window.applyDecodeHistoryRetention = function() { } }; function syncTopBarAccess() { - const loggedOut = authEnabled && !authRole; + const loggedOut = authEnabled && authRoles.length === 0; const tabBar = document.getElementById("tab-bar"); const rigSwitch = document.querySelector(".header-rig-switch"); if (tabBar) tabBar.style.display = ""; document.querySelectorAll(".tab-bar .tab").forEach((btn) => { const isMain = btn.dataset.tab === "main"; - btn.style.display = !loggedOut || isMain ? "" : "none"; + const lacksLogbookAccess = authEnabled && btn.dataset.tab === "logbook" && !hasAuthRole("write"); + btn.style.display = (!loggedOut || isMain) && !lacksLogbookAccess ? "" : "none"; btn.disabled = false; }); if (rigSwitch) { rigSwitch.style.display = loggedOut ? "none" : ""; } if (headerRigSwitchSelect) { - headerRigSwitchSelect.disabled = loggedOut || authRole === "user" || lastRigIds.length === 0; + headerRigSwitchSelect.disabled = loggedOut || !hasAuthRole("control") || lastRigIds.length === 0; } } var overviewDrawPending = false; @@ -2935,7 +2950,7 @@ function applyRigList(activeRigId, rigIds, displayNames) { } const nextKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || ""); const rigListChanged = prevKey !== nextKey; - const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "user"; + const disableSwitch = lastRigIds.length === 0 || !hasAuthRole("control"); populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch); updateRigSubtitle(lastActiveRigId); window.trxUi?.setActiveRig(lastActiveRigId); @@ -4538,7 +4553,7 @@ function scheduleTuneLinkSync() { async function applyTuneLink(link) { const wanted = link.rig || link.mode || link.freqHz != null || link.bandwidthHz != null; if (!wanted) return; - if (authRole === "user") { + if (!hasAuthRole("control")) { showHint("Read-only session — link not applied", 2500); return; } @@ -5256,7 +5271,7 @@ async function postPath(path, options = {}) { } const resp = await fetch(path, { method: "POST" }); if (authEnabled && resp.status === 401) { - authRole = null; + setAuthRoles([]); if (es) es.close(); showAuthGate(); throw new Error("Authentication required"); @@ -5281,8 +5296,8 @@ async function switchRigFromSelect(selectEl) { showHint("No rig selected", 1500); return; } - if (authRole === "user") { - showHint("Admin role required", 1500); + if (!hasAuthRole("control")) { + showHint("Control role required", 1500); return; } if (!lastRigIds.includes(selectEl.value)) { @@ -5895,10 +5910,15 @@ function navigateToTab(name, options = {}) { window.trxUi?.closeMobileOverlays?.(); const leavingSatellites = _activeTab === "satellites" && name !== "satellites"; const { updateHistory = true, replaceHistory = false } = options; - if (authEnabled && !authRole && name !== "main") { + if (authEnabled && authRoles.length === 0 && name !== "main") { showAuthGate(); return; } + if (authEnabled && name === "logbook" && !hasAuthRole("write")) { + showHint("Write role required for logbook access", 2500); + navigateToTab("main", options); + return; + } const btn = document.querySelector(`.tab-bar .tab[data-tab="${name}"]`); if (!btn) return; _activeTab = name; @@ -6013,7 +6033,7 @@ async function initializeApp() { const authStatus = await checkAuthStatus(); authEnabled = !authStatus.auth_disabled; if (!authEnabled) { - authRole = "admin"; + setAuthRoles(ALL_AUTH_ROLES); hideAuthGate(); updateAuthUI(); connect(); @@ -6024,7 +6044,7 @@ async function initializeApp() { return; } if (authStatus.authenticated) { - authRole = authStatus.role ?? null; + setAuthRoles(authStatus.roles); authUsername = authStatus.username ?? null; hideAuthGate(); updateAuthUI(); @@ -6053,7 +6073,7 @@ async function refreshUserManagement() { const section = document.getElementById("user-management"); const tab = document.getElementById("settings-users-tab"); if (!section || !tab) return; - const canManageUsers = authEnabled && authRole === "admin"; + const canManageUsers = authEnabled && hasAuthRole("administrator"); tab.style.display = canManageUsers ? "" : "none"; if (!canManageUsers) { const panel = document.getElementById("subtab-settings-users"); @@ -6066,7 +6086,7 @@ async function refreshUserManagement() { const list = requiredElement("user-list"); try { const users = await listUsers(); - const adminCount = users.filter((user) => user.role === "admin").length; + const adminCount = users.filter((user) => user.roles.includes("administrator")).length; list.replaceChildren(...users.map((user) => { const row = document.createElement("div"); row.className = "sch-row"; @@ -6074,21 +6094,28 @@ async function refreshUserManagement() { const name = document.createElement("strong"); name.textContent = user.username; name.style.minWidth = "10rem"; - const role = document.createElement("select"); - role.className = "auth-input"; - for (const value of ["user", "admin"]) { - const option = document.createElement("option"); - option.value = value; - option.textContent = value === "admin" ? "Admin" : "User"; - option.selected = user.role === value; - role.append(option); + const roleInputs = ALL_AUTH_ROLES.map((value) => { + const label = document.createElement("label"); + label.className = "auth-role-choice"; + const input = document.createElement("input"); + input.type = "checkbox"; + input.value = value; + input.checked = user.roles.includes(value); + label.append(input, ` ${AUTH_ROLE_LABELS[value]}`); + return { label, input, value }; + }); + const roles = document.createElement("span"); + roles.className = "auth-role-choices"; + roles.append(...roleInputs.map(({ label }) => label)); + const isOnlyAdmin = user.roles.includes("administrator") && adminCount === 1; + const administratorInput = roleInputs.find((item) => item.value === "administrator")?.input; + if (isOnlyAdmin && administratorInput) { + administratorInput.disabled = true; + administratorInput.title = "The final administrator cannot be demoted"; } - const isOnlyAdmin = user.role === "admin" && adminCount === 1; - role.disabled = isOnlyAdmin; - if (isOnlyAdmin) role.title = "The final administrator cannot be demoted"; const password = document.createElement("input"); password.type = "password"; - password.placeholder = "New password"; + password.placeholder = "New password (8+ characters)"; password.autocomplete = "new-password"; password.className = "auth-input"; password.minLength = 8; @@ -6096,7 +6123,9 @@ async function refreshUserManagement() { save.type = "button"; save.textContent = "Save"; save.addEventListener("click", async () => { - const changes = { role: role.value }; + const changes = { + roles: roleInputs.filter(({ input }) => input.checked).map(({ value }) => value) + }; if (password.value) changes.password = password.value; await runUserOperation(() => updateUser(user.username, changes)); }); @@ -6111,7 +6140,7 @@ async function refreshUserManagement() { await runUserOperation(() => deleteUser(user.username)); } }); - row.append(name, role, password, save, remove); + row.append(name, roles, password, save, remove); return row; })); } catch (error) { @@ -6138,12 +6167,14 @@ document.getElementById("user-create-form")?.addEventListener("submit", (event) event.preventDefault(); const username = requiredElement("user-create-username"); const password = requiredElement("user-create-password"); - const role = requiredElement("user-create-role"); + const roles = Array.from(document.querySelectorAll("#user-create-roles input[type=checkbox]")); void runUserOperation(async () => { - await createUser(username.value, password.value, role.value); + await createUser(username.value, password.value, roles.filter((input) => input.checked).map((input) => input.value)); username.value = ""; password.value = ""; - role.value = "user"; + roles.forEach((input) => { + input.checked = input.value === "read"; + }); }); }); requiredElement("auth-form").addEventListener("submit", async (e) => { @@ -6156,7 +6187,7 @@ requiredElement("auth-form").addEventListener("submit", async (e) => { btn.textContent = "Logging in..."; try { const result = await authLogin(usernameEl.value, passwordEl.value); - authRole = result.role ?? null; + setAuthRoles(result.roles); authUsername = result.username ?? usernameEl.value; passwordEl.value = ""; hideAuthGate(); @@ -6178,7 +6209,7 @@ requiredElement("auth-form").addEventListener("submit", async (e) => { var headerAuthBtn = document.getElementById("header-auth-btn"); if (headerAuthBtn) { headerAuthBtn.addEventListener("click", async () => { - if (authRole) { + if (authRoles.length > 0) { if (await window.trxUi.confirm({ title: "Log out?", message: "Audio and control access for this browser session will end.", confirmLabel: "Log out", danger: false })) { await authLogout(); } @@ -6224,6 +6255,9 @@ Object.defineProperties(trxState, { authRole: { get() { return authRole; } }, + authRoles: { get() { + return authRoles; + } }, decoderRegistry: { get() { return decoderRegistry; } }, 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 50b86551..dd44eaa3 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 @@ -358,7 +358,7 @@ var bgdWindow = window; btn.title = bgdDirty ? "Apply these bookmarks to the background decoder" : "No changes to save"; } function isControlRole() { - return backgroundDecodeRole === "admin" || hostState.authEnabled === false; + return backgroundDecodeRole === "administrator" || backgroundDecodeRole === "control" || hostState.authEnabled === false; } function showToast(msg, isError) { const el = document.getElementById("background-decode-toast"); 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 8ac64be7..270973ae 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 @@ -42,7 +42,7 @@ function bmEsc(str) { return d.innerHTML; } function bmCanControl() { - return !hostState.authEnabled || hostState.authRole === "admin"; + return !hostState.authEnabled || hostState.authRoles.includes("administrator") || hostState.authRoles.includes("write"); } function bmSyncAccess() { const canCtrl = bmCanControl(); 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 089af5f6..4861c49f 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 @@ -47,6 +47,9 @@ var entryRigName = null; var entryGrid = null; var qsos = []; var workedRequest = 0; +function canWriteLogbook() { + return !hostState.authEnabled || hostState.authRoles.includes("administrator") || hostState.authRoles.includes("write"); +} function notify(message, kind) { if (bridge.trxUi.notify) bridge.trxUi.notify(message, kind ? { kind } : void 0); else hostCore.showHint(message, 2e3); @@ -300,6 +303,11 @@ function renderRows() { row.appendChild(cell); } const actions = document.createElement("td"); + if (!canWriteLogbook()) { + row.appendChild(actions); + fragment.appendChild(row); + continue; + } const confirm = document.createElement("button"); confirm.type = "button"; confirm.className = "log-row-btn"; @@ -420,14 +428,19 @@ importFile?.addEventListener("change", () => { if (file) void importAdif(file); importFile.value = ""; }); -bridge.logContact = (seed) => { - bridge.navigateToTab?.("logbook"); - void openEntry(seed).then(() => callInput?.focus()); -}; +if (canWriteLogbook()) { + bridge.logContact = (seed) => { + bridge.navigateToTab?.("logbook"); + void openEntry(seed).then(() => callInput?.focus()); + }; +} else { + if (form) form.style.display = "none"; + if (importBtn) importBtn.style.display = "none"; +} renderStation(); if (cabrilloCallsign && !cabrilloCallsign.value) { cabrilloCallsign.value = stationCallEl?.textContent?.trim() ?? ""; } syncCabrilloLink(); -void openEntry(); +if (canWriteLogbook()) void openEntry(); void refreshLog(); 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 e744751b..9bbceb02 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 @@ -272,7 +272,7 @@ function schedulerOptionalEl(id) { const nextBtn = schedulerEl("scheduler-next-btn"); if (!prevBtn || !nextBtn) return; const state = schedulerInterleaveState(currentConfig); - const enabled = schedulerRole === "admin" && !!currentRigId && !schedulerStepPending && state.activeEntries.length > 1; + const enabled = (schedulerRole === "administrator" || schedulerRole === "control") && !!currentRigId && !schedulerStepPending && state.activeEntries.length > 1; prevBtn.disabled = !enabled; nextBtn.disabled = !enabled; const hint = enabled ? "Select a different active scheduler entry" : "Available only when multiple scheduler entries are active"; @@ -354,7 +354,7 @@ function schedulerOptionalEl(id) { const panel = schedulerEl("scheduler-panel"); if (!panel) return; const mode = currentConfig && currentConfig.mode || "disabled"; - const isControl = schedulerRole === "admin"; + const isControl = schedulerRole === "administrator" || schedulerRole === "control"; setSelected("scheduler-mode-select", mode); const satEnabled = currentConfig && currentConfig.satellites && currentConfig.satellites.enabled; const controlRow = document.querySelector(".scheduler-control-row"); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html index 985da4ae..afd03ece 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html @@ -1757,7 +1757,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
- + + + + + +
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css index f01a09c9..a2e3fde7 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css @@ -216,6 +216,8 @@ body { .auth-role { margin-top: var(--space-4); color: var(--text-muted); font-size: var(--fs-sm); } #user-management .auth-input { width: auto; min-width: 9rem; flex: 1 1 10rem; margin-bottom: 0; } #user-management .auth-submit { width: auto; } +#user-management .auth-role-choices { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; } +#user-management .auth-role-choice { display: inline-flex; align-items: center; gap: .2rem; white-space: nowrap; } #user-management button { padding: 0.55rem 0.75rem; } .label { color: var(--text-muted); font-size: 0.9rem; margin-bottom: 6px; display: block; } 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 c36bcd9c..caad0104 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 @@ -2,11 +2,11 @@ // // SPDX-License-Identifier: GPL-2.0-or-later -export type AuthRole = "user" | "admin"; +export type AuthRole = "read" | "control" | "write" | "administrator"; export interface AuthSession { authenticated: boolean; - role?: AuthRole; + roles: AuthRole[]; username?: string; auth_disabled?: boolean; } @@ -19,14 +19,14 @@ function decodeAuthSession(value: unknown): AuthSession { if (typeof session.authenticated !== "boolean") { throw new TypeError("The authentication response has no authenticated flag"); } - if (session.role !== undefined && session.role !== "user" && session.role !== "admin") { - throw new TypeError("The authentication response has an invalid role"); + if (!Array.isArray(session.roles) || !session.roles.every(role => + role === "read" || role === "control" || role === "write" || role === "administrator")) { + throw new TypeError("The authentication response has invalid roles"); } if (session.auth_disabled !== undefined && typeof session.auth_disabled !== "boolean") { throw new TypeError("The authentication response has an invalid auth_disabled flag"); } - const decoded: AuthSession = { authenticated: session.authenticated }; - if (session.role !== undefined) decoded.role = session.role; + const decoded: AuthSession = { authenticated: session.authenticated, roles: session.roles as AuthRole[] }; if (session.username !== undefined) { if (typeof session.username !== "string") throw new TypeError("The authentication response has an invalid username"); decoded.username = session.username; @@ -37,7 +37,7 @@ function decodeAuthSession(value: unknown): AuthSession { const authDisabledSession: AuthSession = { authenticated: true, - role: "admin", + roles: ["read", "control", "write", "administrator"], auth_disabled: true, }; @@ -45,11 +45,11 @@ export async function fetchAuthSession(): Promise { try { const response = await fetch("/auth/session"); if (response.status === 404) return authDisabledSession; - if (!response.ok) return { authenticated: false }; + if (!response.ok) return { authenticated: false, roles: [] }; return decodeAuthSession(await response.json()); } catch (error: unknown) { console.error("Auth check failed:", error); - return { authenticated: false }; + return { authenticated: false, roles: [] }; } } @@ -67,7 +67,7 @@ export async function login(username: string, password: string): Promise { const response = await fetch(path, init); @@ -83,18 +83,19 @@ export async function listUsers(): Promise { if (!Array.isArray(value) || !value.every((user: unknown) => { if (typeof user !== "object" || user === null) return false; const record = user as Record; - return typeof record.username === "string" && (record.role === "user" || record.role === "admin"); + return typeof record.username === "string" && Array.isArray(record.roles) + && record.roles.every(role => role === "read" || role === "control" || role === "write" || role === "administrator"); })) { throw new TypeError("The user list response is malformed"); } return value as ManagedUser[]; } -export async function createUser(username: string, password: string, role: AuthRole): Promise { - await userRequest("/auth/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password, role }) }); +export async function createUser(username: string, password: string, roles: AuthRole[]): Promise { + await userRequest("/auth/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password, roles }) }); } -export async function updateUser(username: string, changes: { password?: string; role?: AuthRole }): Promise { +export async function updateUser(username: string, changes: { password?: string; roles?: AuthRole[] }): Promise { await userRequest(`/auth/users/${encodeURIComponent(username)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(changes) }); } 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 44418eed..9101dee8 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 @@ -231,6 +231,7 @@ interface TrxState { readonly decodeHistoryRetentionMin: number; readonly authEnabled: boolean; readonly authRole: AuthRole | null; + readonly authRoles: readonly AuthRole[]; readonly decoderRegistry: typeof decoderRegistry; readonly sseSessionId: string | null; readonly primaryRds: RdsData | null; @@ -411,9 +412,28 @@ void loadDecoderRegistry(refreshOperatorLayoutCapabilities); // --- Authentication --- let authRole: AuthRole | null = null; +let authRoles: AuthRole[] = []; let authUsername: string | null = null; let authEnabled = true; +const ALL_AUTH_ROLES: readonly AuthRole[] = ["read", "control", "write", "administrator"]; +const AUTH_ROLE_LABELS: Record = { + read: "Read", + control: "Control", + write: "Write", + administrator: "Administrator", +}; + +function setAuthRoles(roles: readonly AuthRole[]) { + authRoles = [...new Set(roles)]; + authRole = (["administrator", "control", "write", "read"] as AuthRole[]) + .find(role => authRoles.includes(role)) ?? null; +} + +function hasAuthRole(role: AuthRole) { + return authRoles.includes("administrator") || authRoles.includes(role); +} + async function checkAuthStatus() { return fetchAuthSession(); } @@ -425,7 +445,7 @@ async function authLogin(username: string, password: string) { async function authLogout() { try { await logout(); - authRole = null; + setAuthRoles([]); authUsername = null; // Disconnect and show auth gate without page reload disconnect(); @@ -511,9 +531,9 @@ function updateAuthUI() { return; } - if (authRole) { + if (authRoles.length > 0) { if (badge) badge.style.display = "block"; - if (badgeRole) badgeRole.textContent = `${authUsername || "local"} — ${authRole === "admin" ? "Admin" : "User (read-only)"}`; + if (badgeRole) badgeRole.textContent = `${authUsername || "local"} — ${authRoles.map(role => AUTH_ROLE_LABELS[role]).join(", ")}`; if (headerAuthBtn) { headerAuthBtn.textContent = "Logout"; headerAuthBtn.style.display = "block"; @@ -529,10 +549,10 @@ function updateAuthUI() { } function applyAuthRestrictions() { - if (!authRole) return; + if (authRoles.length === 0) return; // Disable TX/PTT/frequency/mode/VFO controls for user role - if (authRole === "user") { + if (!hasAuthRole("control")) { const pttBtn = document.getElementById("ptt-btn") as HTMLButtonElement | null; const powerBtn = document.getElementById("power-btn") as HTMLButtonElement | null; const lockBtn = document.getElementById("lock-btn") as HTMLButtonElement | null; @@ -864,14 +884,15 @@ window.applyDecodeHistoryRetention = function() { }; function syncTopBarAccess() { - const loggedOut = authEnabled && !authRole; + const loggedOut = authEnabled && authRoles.length === 0; const tabBar = document.getElementById("tab-bar"); const rigSwitch = document.querySelector(".header-rig-switch"); if (tabBar) tabBar.style.display = ""; document.querySelectorAll(".tab-bar .tab").forEach((btn) => { const isMain = btn.dataset.tab === "main"; - btn.style.display = !loggedOut || isMain ? "" : "none"; + const lacksLogbookAccess = authEnabled && btn.dataset.tab === "logbook" && !hasAuthRole("write"); + btn.style.display = (!loggedOut || isMain) && !lacksLogbookAccess ? "" : "none"; btn.disabled = false; }); @@ -880,7 +901,7 @@ function syncTopBarAccess() { } if (headerRigSwitchSelect) { - headerRigSwitchSelect.disabled = loggedOut || authRole === "user" || lastRigIds.length === 0; + headerRigSwitchSelect.disabled = loggedOut || !hasAuthRole("control") || lastRigIds.length === 0; } } @@ -1461,7 +1482,7 @@ function applyRigList(activeRigId: string | null, rigIds: string[], displayNames } const nextKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || ""); const rigListChanged = prevKey !== nextKey; - const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "user"; + const disableSwitch = lastRigIds.length === 0 || !hasAuthRole("control"); populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch); updateRigSubtitle(lastActiveRigId); window.trxUi?.setActiveRig(lastActiveRigId); @@ -3349,7 +3370,7 @@ function scheduleTuneLinkSync() { async function applyTuneLink(link: TuneLink) { const wanted = link.rig || link.mode || link.freqHz != null || link.bandwidthHz != null; if (!wanted) return; - if (authRole === "user") { + if (!hasAuthRole("control")) { showHint("Read-only session — link not applied", 2500); return; } @@ -4159,7 +4180,7 @@ async function postPath(path: string, options: PostOptions = {}) { const resp = await fetch(path, { method: "POST" }); if (authEnabled && resp.status === 401) { // Not authenticated - return to login - authRole = null; + setAuthRoles([]); if (es) es.close(); showAuthGate(); throw new Error("Authentication required"); @@ -4188,8 +4209,8 @@ async function switchRigFromSelect(selectEl: HTMLSelectElement) { showHint("No rig selected", 1500); return; } - if (authRole === "user") { - showHint("Admin role required", 1500); + if (!hasAuthRole("control")) { + showHint("Control role required", 1500); return; } if (!lastRigIds.includes(selectEl.value)) { @@ -4871,10 +4892,15 @@ function navigateToTab(name: TabName, options: { updateHistory?: boolean; replac window.trxUi?.closeMobileOverlays?.(); const leavingSatellites = _activeTab === "satellites" && name !== "satellites"; const { updateHistory = true, replaceHistory = false } = options; - if (authEnabled && !authRole && name !== "main") { + if (authEnabled && authRoles.length === 0 && name !== "main") { showAuthGate(); return; } + if (authEnabled && name === "logbook" && !hasAuthRole("write")) { + showHint("Write role required for logbook access", 2500); + navigateToTab("main", options); + return; + } const btn = document.querySelector(`.tab-bar .tab[data-tab="${name}"]`); if (!btn) return; _activeTab = name; @@ -5010,7 +5036,7 @@ async function initializeApp() { authEnabled = !authStatus.auth_disabled; if (!authEnabled) { - authRole = "admin"; + setAuthRoles(ALL_AUTH_ROLES); hideAuthGate(); updateAuthUI(); connect(); @@ -5023,7 +5049,7 @@ async function initializeApp() { if (authStatus.authenticated) { // User has valid session - authRole = authStatus.role ?? null; + setAuthRoles(authStatus.roles); authUsername = authStatus.username ?? null; hideAuthGate(); updateAuthUI(); @@ -5057,7 +5083,7 @@ async function refreshUserManagement() { const section = document.getElementById("user-management"); const tab = document.getElementById("settings-users-tab"); if (!section || !tab) return; - const canManageUsers = authEnabled && authRole === "admin"; + const canManageUsers = authEnabled && hasAuthRole("administrator"); tab.style.display = canManageUsers ? "" : "none"; if (!canManageUsers) { const panel = document.getElementById("subtab-settings-users"); @@ -5070,7 +5096,7 @@ async function refreshUserManagement() { const list = requiredElement("user-list"); try { const users = await listUsers(); - const adminCount = users.filter(user => user.role === "admin").length; + const adminCount = users.filter(user => user.roles.includes("administrator")).length; list.replaceChildren(...users.map((user) => { const row = document.createElement("div"); row.className = "sch-row"; @@ -5078,19 +5104,32 @@ async function refreshUserManagement() { const name = document.createElement("strong"); name.textContent = user.username; name.style.minWidth = "10rem"; - const role = document.createElement("select"); - role.className = "auth-input"; - for (const value of ["user", "admin"] as AuthRole[]) { - const option = document.createElement("option"); option.value = value; option.textContent = value === "admin" ? "Admin" : "User"; option.selected = user.role === value; role.append(option); + const roleInputs = ALL_AUTH_ROLES.map((value) => { + const label = document.createElement("label"); + label.className = "auth-role-choice"; + const input = document.createElement("input"); + input.type = "checkbox"; + input.value = value; + input.checked = user.roles.includes(value); + label.append(input, ` ${AUTH_ROLE_LABELS[value]}`); + return { label, input, value }; + }); + const roles = document.createElement("span"); + roles.className = "auth-role-choices"; + roles.append(...roleInputs.map(({ label }) => label)); + const isOnlyAdmin = user.roles.includes("administrator") && adminCount === 1; + const administratorInput = roleInputs.find(item => item.value === "administrator")?.input; + if (isOnlyAdmin && administratorInput) { + administratorInput.disabled = true; + administratorInput.title = "The final administrator cannot be demoted"; } - const isOnlyAdmin = user.role === "admin" && adminCount === 1; - role.disabled = isOnlyAdmin; - if (isOnlyAdmin) role.title = "The final administrator cannot be demoted"; const password = document.createElement("input"); - password.type = "password"; password.placeholder = "New password"; password.autocomplete = "new-password"; password.className = "auth-input"; password.minLength = 8; + password.type = "password"; password.placeholder = "New password (8+ characters)"; password.autocomplete = "new-password"; password.className = "auth-input"; password.minLength = 8; const save = document.createElement("button"); save.type = "button"; save.textContent = "Save"; save.addEventListener("click", async () => { - const changes: { role?: AuthRole; password?: string } = { role: role.value as AuthRole }; + const changes: { roles?: AuthRole[]; password?: string } = { + roles: roleInputs.filter(({ input }) => input.checked).map(({ value }) => value), + }; if (password.value) changes.password = password.value; await runUserOperation(() => updateUser(user.username, changes)); }); @@ -5102,7 +5141,7 @@ async function refreshUserManagement() { await runUserOperation(() => deleteUser(user.username)); } }); - row.append(name, role, password, save, remove); + row.append(name, roles, password, save, remove); return row; })); } catch (error) { @@ -5132,10 +5171,11 @@ document.getElementById("user-create-form")?.addEventListener("submit", (event) event.preventDefault(); const username = requiredElement("user-create-username"); const password = requiredElement("user-create-password"); - const role = requiredElement("user-create-role"); + const roles = Array.from(document.querySelectorAll("#user-create-roles input[type=checkbox]")); void runUserOperation(async () => { - await createUser(username.value, password.value, role.value as AuthRole); - username.value = ""; password.value = ""; role.value = "user"; + await createUser(username.value, password.value, roles.filter(input => input.checked).map(input => input.value as AuthRole)); + username.value = ""; password.value = ""; + roles.forEach(input => { input.checked = input.value === "read"; }); }); }); @@ -5151,7 +5191,7 @@ requiredElement("auth-form").addEventListener("submit", async ( try { const result = await authLogin(usernameEl.value, passwordEl.value); - authRole = result.role ?? null; + setAuthRoles(result.roles); authUsername = result.username ?? usernameEl.value; passwordEl.value = ""; hideAuthGate(); @@ -5175,7 +5215,7 @@ requiredElement("auth-form").addEventListener("submit", async ( const headerAuthBtn = document.getElementById("header-auth-btn") as HTMLButtonElement | null; if (headerAuthBtn) { headerAuthBtn.addEventListener("click", async () => { - if (authRole) { + if (authRoles.length > 0) { // Logged in - show logout confirmation if (await window.trxUi.confirm({ title: "Log out?", message: "Audio and control access for this browser session will end.", confirmLabel: "Log out", danger: false })) { await authLogout(); @@ -5205,6 +5245,7 @@ Object.defineProperties(trxState, { decodeHistoryRetentionMin: { get() { return decodeHistoryRetentionMin; } }, authEnabled: { get() { return authEnabled; } }, authRole: { get() { return authRole; } }, + authRoles: { get() { return authRoles; } }, decoderRegistry: { get() { return decoderRegistry; } }, sseSessionId: { get() { return sseSessionId; } }, primaryRds: { get() { return primaryRds; } }, diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/background-decode.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/background-decode.ts index 5e11f0a7..3055a153 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/background-decode.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/background-decode.ts @@ -468,7 +468,9 @@ const bgdWindow = window as unknown as BackgroundBridge; } function isControlRole(): boolean { - return backgroundDecodeRole === "admin" || hostState.authEnabled === false; + return backgroundDecodeRole === "administrator" + || backgroundDecodeRole === "control" + || hostState.authEnabled === false; } function showToast(msg: string, isError: boolean): void { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/bookmarks.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/bookmarks.ts index d7705314..957baead 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/bookmarks.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/bookmarks.ts @@ -100,7 +100,9 @@ function bmEsc(str: unknown): string { } function bmCanControl() { - return !hostState.authEnabled || hostState.authRole === "admin"; + return !hostState.authEnabled + || hostState.authRoles.includes("administrator") + || hostState.authRoles.includes("write"); } // Show/hide the Add Bookmark / Select All buttons based on the current auth role. diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/host.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/host.ts index e9aa9e62..9a0f35cb 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/host.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/host.ts @@ -26,6 +26,7 @@ export interface HostState { readonly ownerCallsign: string | null; readonly authEnabled: boolean; readonly authRole: string | null; + readonly authRoles: readonly string[]; readonly lastActiveRigId: string | null; readonly lastRigIds: string[]; readonly lastRigDisplayNames: Record; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/logbook.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/logbook.ts index 3df96e12..d0af4c70 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/logbook.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/logbook.ts @@ -120,6 +120,12 @@ let entryGrid: string | null = null; let qsos: Qso[] = []; let workedRequest = 0; +function canWriteLogbook(): boolean { + return !hostState.authEnabled + || hostState.authRoles.includes("administrator") + || hostState.authRoles.includes("write"); +} + function notify(message: string, kind?: string): void { if (bridge.trxUi.notify) bridge.trxUi.notify(message, kind ? { kind } : undefined); else hostCore.showHint(message, 2000); @@ -419,6 +425,11 @@ function renderRows(): void { row.appendChild(cell); } const actions = document.createElement("td"); + if (!canWriteLogbook()) { + row.appendChild(actions); + fragment.appendChild(row); + continue; + } // Confirming is the commonest edit a log gets, so it is a button rather // than a form: a card arrives, and the contact counts towards an award. const confirm = document.createElement("button"); @@ -547,15 +558,20 @@ importFile?.addEventListener("change", () => { }); /** Start an entry from a decode, and show the operator where it went. */ -bridge.logContact = (seed) => { - bridge.navigateToTab?.("logbook"); - void openEntry(seed).then(() => callInput?.focus()); -}; +if (canWriteLogbook()) { + bridge.logContact = (seed) => { + bridge.navigateToTab?.("logbook"); + void openEntry(seed).then(() => callInput?.focus()); + }; +} else { + if (form) form.style.display = "none"; + if (importBtn) importBtn.style.display = "none"; +} renderStation(); if (cabrilloCallsign && !cabrilloCallsign.value) { cabrilloCallsign.value = stationCallEl?.textContent?.trim() ?? ""; } syncCabrilloLink(); -void openEntry(); +if (canWriteLogbook()) void openEntry(); void refreshLog(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/scheduler.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/scheduler.ts index 5e89b8bd..af3e79a6 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/scheduler.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/scheduler.ts @@ -356,7 +356,7 @@ function schedulerOptionalEl(id: string): SchedulerElement | null { if (!prevBtn || !nextBtn) return; const state = schedulerInterleaveState(currentConfig); const enabled = - schedulerRole === "admin" && + (schedulerRole === "administrator" || schedulerRole === "control") && !!currentRigId && !schedulerStepPending && state.activeEntries.length > 1; @@ -466,7 +466,7 @@ function schedulerOptionalEl(id: string): SchedulerElement | null { if (!panel) return; const mode = (currentConfig && currentConfig.mode) || "disabled"; - const isControl = schedulerRole === "admin"; + const isControl = schedulerRole === "administrator" || schedulerRole === "control"; // Mode selector setSelected("scheduler-mode-select", mode); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/background-decode.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/background-decode.test.mjs index 67ea7745..e96cf383 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/background-decode.test.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/background-decode.test.mjs @@ -40,7 +40,7 @@ test("background decode loads configuration for the explicitly selected rig", as const source = await bundleEntry(new URL("../src/plugins/background-decode.ts", import.meta.url)); new vm.Script(source).runInContext(context); - window.trx.modules.backgroundDecode.initialize("rig/a", "admin"); + window.trx.modules.backgroundDecode.initialize("rig/a", "administrator"); await new Promise((resolve) => setTimeout(resolve, 0)); assert.ok(requested.includes("/background-decode/rig%2Fa")); assert.ok(requested.includes("/bookmarks")); 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 267efb82..8cd68a12 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,8 @@ function hostFixture(overrides = {}) { const calls = { postPath: [], setRigFrequency: [], armOptimisticFrequency: [], applyLocalTunedFrequency: [], syncBandwidthInput: [], scheduleSpectrumDraw: 0, syncModePicker: 0 }; const state = { authEnabled: false, - authRole: "admin", + authRole: "administrator", + authRoles: ["read", "control", "write", "administrator"], lastActiveRigId: null, lastRigIds: [], lastRigDisplayNames: {}, @@ -157,7 +158,7 @@ test("bookmark controls follow the host authentication state", async () => { if (!elements.has(id)) elements.set(id, new ElementFixture()); return elements.get(id); }; - const { window } = hostFixture({ authEnabled: true, authRole: "user" }); + const { window } = hostFixture({ authEnabled: true, authRole: "read", authRoles: ["read"] }); const context = vm.createContext({ window, document: documentFixture(element), @@ -171,7 +172,8 @@ test("bookmark controls follow the host authentication state", async () => { assert.equal(element("bm-add-btn").style.display, "none"); - window.trx.state.authRole = "admin"; + window.trx.state.authRole = "write"; + window.trx.state.authRoles = ["read", "write"]; await window.trx.modules.bookmarks.fetch(""); assert.equal(element("bm-add-btn").style.display, ""); }); 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 7ecde28b..c7000a25 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,8 @@ export function createHost({ state = {}, core = {}, modules = {} } = {}) { serverLat: null, serverLon: null, authEnabled: false, - authRole: "admin", + authRole: "administrator", + authRoles: ["read", "control", "write", "administrator"], lastActiveRigId: null, lastRigIds: [], lastRigDisplayNames: {}, diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/scheduler.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/scheduler.test.mjs index 791d4b34..3908b384 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/scheduler.test.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/scheduler.test.mjs @@ -76,7 +76,7 @@ test("scheduler self-initializes for the active rig when a role is already known return elements.get(id); }; const window = { - ...createHost({ state: { authRole: "admin", lastActiveRigId: "sdr" } }), + ...createHost({ state: { authRole: "administrator", lastActiveRigId: "sdr" } }), trxUi: { confirm: async () => true }, }; const context = vm.createContext({ 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 374b9ff8..fb08b116 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 @@ -38,7 +38,7 @@ test("administrator user management is a dedicated Settings sub-tab", async () = ]); assert.match(html, /data-subtab="settings-users"/); assert.match(html, /id="subtab-settings-users" class="sub-tab-panel"/); - assert.match(app, /authEnabled && authRole === "admin"/); + assert.match(app, /authEnabled && hasAuthRole\("administrator"\)/); assert.match(app, /settings-users-tab/); }); 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 aa43687e..755f49e6 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 @@ -232,7 +232,7 @@ export async function startWebFixture({ }; const jsonRoutes = new Map([ - ["/auth/session", { authenticated: true, role: "admin", auth_disabled: true }], + ["/auth/session", { authenticated: true, roles: ["read", "control", "write", "administrator"], auth_disabled: true }], ["/decoders", DECODER_REGISTRY], ["/rigs", rigsResponse], ["/status", status], diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/api/bookmarks.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/api/bookmarks.rs index 585621f1..e23ee60a 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/api/bookmarks.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/api/bookmarks.rs @@ -9,7 +9,7 @@ use std::sync::Arc; use actix_web::Error; use actix_web::{delete, get, post, put, web, HttpRequest, HttpResponse}; -use super::{no_cache_response, request_accepts_html, require_control}; +use super::{no_cache_response, request_accepts_html, require_write}; use crate::server::status; // ============================================================================ @@ -165,7 +165,7 @@ pub async fn create_bookmark( body: web::Json, auth_state: web::Data, ) -> Result { - require_control(&req, &auth_state)?; + require_write(&req, &auth_state)?; let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref()); if store.freq_taken(body.freq_hz, None) { return Err(actix_web::error::ErrorConflict( @@ -201,7 +201,7 @@ pub async fn update_bookmark( body: web::Json, auth_state: web::Data, ) -> Result { - require_control(&req, &auth_state)?; + require_write(&req, &auth_state)?; let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref()); let id = path.into_inner(); if store.freq_taken(body.freq_hz, Some(&id)) { @@ -235,7 +235,7 @@ pub async fn delete_bookmark( query: web::Query, auth_state: web::Data, ) -> Result { - require_control(&req, &auth_state)?; + require_write(&req, &auth_state)?; let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref()); let id = path.into_inner(); if store.remove(&id) { @@ -253,7 +253,7 @@ pub async fn batch_delete_bookmarks( query: web::Query, auth_state: web::Data, ) -> Result { - require_control(&req, &auth_state)?; + require_write(&req, &auth_state)?; let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref()); let mut deleted = 0usize; for id in &body.ids { @@ -272,7 +272,7 @@ pub async fn batch_move_bookmarks( query: web::Query, auth_state: web::Data, ) -> Result { - require_control(&req, &auth_state)?; + require_write(&req, &auth_state)?; let from_store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref()); let to_store = resolve_bookmark_store(Some(body.to.as_str()), store_map.get_ref()); let mut moved = 0usize; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/api/logbook.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/api/logbook.rs index 732494fb..1a4abff0 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/api/logbook.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/api/logbook.rs @@ -15,7 +15,7 @@ use serde::{Deserialize, Serialize}; use trx_logbook::qso::{adif_mode_for_rig_mode, band_for_hz, mode_for_decoder}; use trx_logbook::{LogQuery, Logbook, Qso}; -use super::{active_rig_id_from_context, require_control}; +use super::{active_rig_id_from_context, require_write}; use crate::server::auth::AuthState; /// What a contact looks like on the wire. @@ -225,7 +225,7 @@ pub async fn add_qso( logbook: web::Data>, auth_state: web::Data, ) -> Result { - require_control(&req, auth_state.get_ref())?; + require_write(&req, auth_state.get_ref())?; let qso = match input.into_inner().into_qso(None) { Ok(qso) => qso, Err(reason) => { @@ -248,7 +248,7 @@ pub async fn edit_qso( logbook: web::Data>, auth_state: web::Data, ) -> Result { - require_control(&req, auth_state.get_ref())?; + require_write(&req, auth_state.get_ref())?; let id = path.into_inner(); let Some(existing) = book(&logbook).get(&id) else { return Ok(HttpResponse::NotFound().json(serde_json::json!({ "error": "no such contact" }))); @@ -276,7 +276,7 @@ pub async fn delete_qso( logbook: web::Data>, auth_state: web::Data, ) -> Result { - require_control(&req, auth_state.get_ref())?; + require_write(&req, auth_state.get_ref())?; match book(&logbook).delete(&path.into_inner()) { Ok(true) => Ok(HttpResponse::Ok().json(serde_json::json!({ "deleted": true }))), Ok(false) => { @@ -312,7 +312,7 @@ pub async fn import_adi( logbook: web::Data>, auth_state: web::Data, ) -> Result { - require_control(&req, auth_state.get_ref())?; + require_write(&req, auth_state.get_ref())?; match book(&logbook).import_adi(&body) { Ok(outcome) => Ok(HttpResponse::Ok().json(outcome)), Err(err) => Ok(HttpResponse::InternalServerError() diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/api/mod.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/api/mod.rs index 889460b0..ac5b4273 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/api/mod.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/api/mod.rs @@ -391,16 +391,20 @@ fn gz_cache_entry(src: &[u8], name: &str) -> GzCacheEntry { GzCacheEntry { gz, br, etag } } -fn require_control( +fn require_write( req: &HttpRequest, auth_state: &crate::server::auth::AuthState, ) -> Result<(), actix_web::Error> { - if !auth_state.config.enabled { - return Ok(()); - } - match crate::server::auth::get_session_role(req, auth_state) { - Some(crate::server::auth::AuthRole::Admin) => Ok(()), - _ => Err(actix_web::error::ErrorForbidden("admin role required")), + if !auth_state.config.enabled + || crate::server::auth::session_grants( + req, + auth_state, + crate::server::auth::AuthRole::Write, + ) + { + Ok(()) + } else { + Err(actix_web::error::ErrorForbidden("write role required")) } } @@ -960,6 +964,9 @@ mod tests { std::path::PathBuf::from("unused-users.json"), None, None, + false, + "guest".to_string(), + None, std::time::Duration::from_secs(3600), false, crate::server::auth::SameSite::Lax, @@ -975,6 +982,9 @@ mod tests { directory.path().join("users.json"), Some("admin".to_string()), Some("password123".to_string()), + false, + "guest".to_string(), + None, std::time::Duration::from_secs(3600), false, crate::server::auth::SameSite::Lax, @@ -1171,10 +1181,16 @@ mod tests { let logbook = std::sync::Arc::new( trx_logbook::Logbook::open(&dir.path().join("logbook.jsonl")).expect("open"), ); + let auth_state = auth_state_locked(); + let session_id = auth_state.store.create( + "reader".to_string(), + [crate::server::auth::AuthRole::Read].into_iter().collect(), + std::time::Duration::from_secs(3600), + ); let app = actix_test::init_service( App::new() .app_data(web::Data::new(logbook)) - .app_data(web::Data::new(auth_state_locked())) + .app_data(web::Data::new(auth_state)) .service(logbook::add_qso) .service(logbook::list_qsos), ) @@ -1184,6 +1200,10 @@ mod tests { &app, actix_test::TestRequest::post() .uri("/api/logbook") + .cookie(actix_web::cookie::Cookie::new( + "trx_http_sid", + session_id.clone(), + )) .set_json(serde_json::json!({ "call": "SP2SJG", "freq_hz": 14_074_000_u64, "mode": "FT8", })) @@ -1199,12 +1219,47 @@ mod tests { &app, actix_test::TestRequest::get() .uri("/api/logbook") + .cookie(actix_web::cookie::Cookie::new("trx_http_sid", session_id)) .to_request(), ) .await; assert_eq!(listed["total"], 0); } + #[actix_web::test] + async fn a_write_session_can_write_to_the_log() { + let dir = tempfile::tempdir().expect("tempdir"); + let logbook = std::sync::Arc::new( + trx_logbook::Logbook::open(&dir.path().join("logbook.jsonl")).expect("open"), + ); + let auth_state = auth_state_locked(); + let session_id = auth_state.store.create( + "writer".to_string(), + [crate::server::auth::AuthRole::Write].into_iter().collect(), + std::time::Duration::from_secs(3600), + ); + let app = actix_test::init_service( + App::new() + .app_data(web::Data::new(logbook)) + .app_data(web::Data::new(auth_state)) + .service(logbook::add_qso), + ) + .await; + + let response = actix_test::call_service( + &app, + actix_test::TestRequest::post() + .uri("/api/logbook") + .cookie(actix_web::cookie::Cookie::new("trx_http_sid", session_id)) + .set_json(serde_json::json!({ + "call": "SP2SJG", "freq_hz": 14_074_000_u64, "mode": "FT8", + })) + .to_request(), + ) + .await; + assert_eq!(response.status(), actix_web::http::StatusCode::OK); + } + /// A contact needs a callsign; the panel is not the only thing that has to /// insist on it. #[actix_web::test] 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 b9215815..710377dc 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 @@ -19,7 +19,7 @@ use argon2::{ }; use futures_util::future::LocalBoxFuture; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::collections::{BTreeSet, HashMap}; use std::fs; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, RwLock}; @@ -30,29 +30,51 @@ use tracing::warn; pub type SessionId = String; /// Authentication role -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum AuthRole { - /// Read-only access. - User, - /// Full control and user-management access. - Admin, + Read, + Control, + Write, + Administrator, } impl AuthRole { pub fn as_str(&self) -> &'static str { match self { - Self::User => "user", - Self::Admin => "admin", + Self::Read => "read", + Self::Control => "control", + Self::Write => "write", + Self::Administrator => "administrator", } } + + pub fn all() -> BTreeSet { + [Self::Read, Self::Control, Self::Write, Self::Administrator] + .into_iter() + .collect() + } +} + +fn grants(roles: &BTreeSet, required: AuthRole) -> bool { + roles.contains(&AuthRole::Administrator) + || roles.contains(&required) + || required == AuthRole::Read && roles.contains(&AuthRole::Control) +} + +fn effective_roles(roles: &BTreeSet) -> Vec { + if roles.contains(&AuthRole::Administrator) { + AuthRole::all().into_iter().collect() + } else { + roles.iter().copied().collect() + } } /// Session record stored in the session store #[derive(Debug, Clone)] pub struct SessionRecord { pub username: String, - pub role: AuthRole, + pub roles: BTreeSet, pub issued_at: SystemTime, pub expires_at: SystemTime, pub last_seen: SystemTime, @@ -82,14 +104,14 @@ impl SessionStore { } /// Create a new session with the given role and TTL - pub fn create(&self, username: String, role: AuthRole, ttl: Duration) -> SessionId { + pub fn create(&self, username: String, roles: BTreeSet, ttl: Duration) -> SessionId { let now = SystemTime::now(); let expires_at = now + ttl; let session_id = Self::generate_session_id(); let record = SessionRecord { username, - role, + roles, issued_at: now, expires_at, last_seen: now, @@ -184,6 +206,9 @@ pub struct AuthConfig { pub users_file: PathBuf, pub bootstrap_admin_username: Option, pub bootstrap_admin_password: Option, + pub bootstrap_read_enabled: bool, + pub bootstrap_read_username: String, + pub bootstrap_read_password: Option, pub session_ttl: Duration, pub cookie_secure: bool, pub cookie_same_site: SameSite, @@ -191,11 +216,15 @@ pub struct AuthConfig { impl AuthConfig { /// Create a new auth config with all fields + #[allow(clippy::too_many_arguments)] pub fn new( enabled: bool, users_file: PathBuf, bootstrap_admin_username: Option, bootstrap_admin_password: Option, + bootstrap_read_enabled: bool, + bootstrap_read_username: String, + bootstrap_read_password: Option, session_ttl: Duration, cookie_secure: bool, cookie_same_site: SameSite, @@ -205,6 +234,9 @@ impl AuthConfig { users_file, bootstrap_admin_username, bootstrap_admin_password, + bootstrap_read_enabled, + bootstrap_read_username, + bootstrap_read_password, session_ttl, cookie_secure, cookie_same_site, @@ -212,11 +244,50 @@ impl AuthConfig { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct UserRecord { pub username: String, pub password_hash: String, - pub role: AuthRole, + pub roles: BTreeSet, +} + +impl<'de> Deserialize<'de> for UserRecord { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(rename_all = "lowercase")] + enum LegacyRole { + User, + Admin, + } + + #[derive(Deserialize)] + struct StoredUserRecord { + username: String, + password_hash: String, + #[serde(default)] + roles: Option>, + #[serde(default)] + role: Option, + } + + let stored = StoredUserRecord::deserialize(deserializer)?; + 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, None) => { + return Err(serde::de::Error::missing_field("roles")); + } + }; + Ok(Self { + username: stored.username, + password_hash: stored.password_hash, + roles, + }) + } } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -256,11 +327,31 @@ impl UserStore { .bootstrap_admin_password .as_deref() .ok_or_else(|| "bootstrap administrator password is missing".to_string())?; - vec![UserRecord { + let mut records = vec![UserRecord { username: validate_username(username)?.to_string(), password_hash: hash_password(password)?, - role: AuthRole::Admin, - }] + roles: AuthRole::all(), + }]; + if config.bootstrap_read_enabled { + if config.bootstrap_read_username == username { + return Err( + "bootstrap administrator and read-only usernames must differ".to_string(), + ); + } + let password = config + .bootstrap_read_password + .as_deref() + .ok_or_else(|| "bootstrap read-only account password is missing".to_string())?; + if password != "guest" { + validate_password(password)?; + } + records.push(UserRecord { + username: validate_username(&config.bootstrap_read_username)?.to_string(), + password_hash: hash_password_value(password)?, + roles: [AuthRole::Read].into_iter().collect(), + }); + } + records }; let mut users = HashMap::new(); for record in records { @@ -269,7 +360,10 @@ impl UserStore { return Err("user database contains duplicate usernames".to_string()); } } - if !users.values().any(|u| u.role == AuthRole::Admin) { + if !users + .values() + .any(|u| u.roles.contains(&AuthRole::Administrator)) + { return Err("user database must contain at least one administrator".to_string()); } let store = Self { @@ -282,14 +376,14 @@ impl UserStore { Ok(store) } - fn authenticate(&self, username: &str, password: &str) -> Option { + fn authenticate(&self, username: &str, password: &str) -> Option> { let users = self.users.read().unwrap_or_else(|e| e.into_inner()); let record = users.get(username)?; let parsed = PasswordHash::new(&record.password_hash).ok()?; Argon2::default() .verify_password(password.as_bytes(), &parsed) .ok()?; - Some(record.role) + Some(record.roles.clone()) } fn list(&self) -> Vec { @@ -298,20 +392,20 @@ impl UserStore { .values() .map(|u| ManagedUser { username: u.username.clone(), - role: u.role, + roles: u.roles.clone(), }) .collect(); result.sort_by(|a, b| a.username.cmp(&b.username)); result } - fn add(&self, username: &str, password: &str, role: AuthRole) -> Result<(), String> { + fn add(&self, username: &str, password: &str, roles: BTreeSet) -> Result<(), String> { let username = validate_username(username)?.to_string(); validate_password(password)?; let record = UserRecord { username: username.clone(), password_hash: hash_password(password)?, - role, + roles: validate_roles(roles)?, }; let mut users = self.users.write().unwrap_or_else(|e| e.into_inner()); if users.contains_key(&username) { @@ -329,7 +423,7 @@ impl UserStore { &self, username: &str, password: Option<&str>, - role: Option, + roles: Option>, ) -> Result<(), String> { if let Some(value) = password { validate_password(value)?; @@ -340,9 +434,15 @@ impl UserStore { .get(username) .cloned() .ok_or_else(|| "user not found".to_string())?; - if old.role == AuthRole::Admin - && role == Some(AuthRole::User) - && users.values().filter(|u| u.role == AuthRole::Admin).count() == 1 + if old.roles.contains(&AuthRole::Administrator) + && roles + .as_ref() + .is_some_and(|next| !next.contains(&AuthRole::Administrator)) + && users + .values() + .filter(|u| u.roles.contains(&AuthRole::Administrator)) + .count() + == 1 { return Err("cannot demote the last administrator".to_string()); } @@ -350,8 +450,8 @@ impl UserStore { if let Some(hash) = new_hash { record.password_hash = hash; } - if let Some(value) = role { - record.role = value; + if let Some(value) = roles { + record.roles = validate_roles(value)?; } if let Err(error) = persist_users(&self.path, &users) { users.insert(username.to_string(), old); @@ -366,8 +466,12 @@ impl UserStore { .get(username) .cloned() .ok_or_else(|| "user not found".to_string())?; - if old.role == AuthRole::Admin - && users.values().filter(|u| u.role == AuthRole::Admin).count() == 1 + if old.roles.contains(&AuthRole::Administrator) + && users + .values() + .filter(|u| u.roles.contains(&AuthRole::Administrator)) + .count() + == 1 { return Err("cannot remove the last administrator".to_string()); } @@ -400,6 +504,10 @@ fn persist_users(path: &Path, users: &HashMap) -> Result<(), fn hash_password(password: &str) -> Result { validate_password(password)?; + hash_password_value(password) +} + +fn hash_password_value(password: &str) -> Result { Argon2::default() .hash_password(password.as_bytes(), &SaltString::generate(&mut OsRng)) .map(|hash| hash.to_string()) @@ -420,11 +528,18 @@ fn validate_username(username: &str) -> Result<&str, String> { fn validate_password(password: &str) -> Result<(), String> { if password.len() < 8 { - return Err("password must contain at least 8 characters".to_string()); + return Err("password must be at least 8 characters".to_string()); } Ok(()) } +fn validate_roles(roles: BTreeSet) -> Result, String> { + if roles.is_empty() { + return Err("at least one role is required".to_string()); + } + Ok(roles) +} + /// Simple per-IP rate limiter for login attempts. /// /// Tracks failed attempts per IP and enforces a cooldown window after @@ -521,7 +636,7 @@ pub struct LoginRequest { #[derive(Debug, Serialize)] pub struct SessionStatus { pub authenticated: bool, - pub role: Option, + pub roles: Vec, pub username: Option, pub auth_disabled: bool, } @@ -530,27 +645,27 @@ pub struct SessionStatus { #[derive(Debug, Serialize)] pub struct LoginResponse { pub authenticated: bool, - pub role: String, + pub roles: Vec, pub username: String, } #[derive(Debug, Clone, Serialize)] pub struct ManagedUser { pub username: String, - pub role: AuthRole, + pub roles: BTreeSet, } #[derive(Debug, Deserialize)] pub struct CreateUserRequest { pub username: String, pub password: String, - pub role: AuthRole, + pub roles: BTreeSet, } #[derive(Debug, Deserialize)] pub struct UpdateUserRequest { pub password: Option, - pub role: Option, + pub roles: Option>, } /// Extract session from cookie @@ -560,10 +675,14 @@ fn extract_session_id(req: &HttpRequest) -> Option { } /// Get session from request, return role if valid -pub fn get_session_role(req: &HttpRequest, auth_state: &AuthState) -> Option { +pub fn get_session_roles(req: &HttpRequest, auth_state: &AuthState) -> Option> { let session_id = extract_session_id(req)?; let record = auth_state.store.get(&session_id)?; - Some(record.role) + Some(record.roles) +} + +pub fn session_grants(req: &HttpRequest, auth_state: &AuthState, role: AuthRole) -> bool { + get_session_roles(req, auth_state).is_some_and(|roles| grants(&roles, role)) } fn require_admin(req: &HttpRequest, auth_state: &AuthState) -> Result { @@ -573,7 +692,7 @@ fn require_admin(req: &HttpRequest, auth_state: &AuthState) -> Result HttpResponse::Created().json(ManagedUser { username: body.username.clone(), - role: body.role, + roles: body.roles.clone(), }), Err(error) => HttpResponse::BadRequest().json(serde_json::json!({"error": error})), } @@ -754,13 +874,13 @@ pub async fn update_user( if let Err(response) = require_admin(&req, &auth_state) { return response; } - if body.password.is_none() && body.role.is_none() { + if body.password.is_none() && body.roles.is_none() { return HttpResponse::BadRequest() .json(serde_json::json!({"error":"password or role is required"})); } match auth_state .users - .update(&username, body.password.as_deref(), body.role) + .update(&username, body.password.as_deref(), body.roles.clone()) { Ok(()) => { auth_state.store.remove_user(&username); @@ -803,9 +923,13 @@ pub async fn delete_user( enum RouteAccess { /// Publicly accessible (no auth required) Public, - /// Read-only (user or admin role required) + /// Read-only resources (Read, Control, or Administrator required) Read, - /// Control only (admin role required) + /// Bookmarks (Read, Control, Write, or Administrator required) + ReadWrite, + /// Logbook access (Write or Administrator required) + Write, + /// Radio control (Control or Administrator required) Control, } @@ -857,7 +981,6 @@ impl RouteAccess { || path == "/spectrum" || path == "/meter" || path == "/audio" - || path == "/bookmarks" || path.starts_with("/status?") || path.starts_with("/rigs?") || path.starts_with("/events?") @@ -866,8 +989,6 @@ impl RouteAccess { || path.starts_with("/spectrum?") || path.starts_with("/meter?") || path.starts_with("/audio?") - || path.starts_with("/bookmarks?") - || path.starts_with("/bookmarks/") || path.starts_with("/scheduler/") || path.starts_with("/scheduler-control") || path.starts_with("/channels/") @@ -875,15 +996,30 @@ impl RouteAccess { return Self::Read; } + if path == "/bookmarks" + || path.starts_with("/bookmarks?") + || path.starts_with("/bookmarks/") + { + return Self::ReadWrite; + } + + if path.starts_with("/api/logbook") { + return Self::Write; + } + // All other routes require control Self::Control } - fn allows(&self, role: Option) -> bool { + fn allows(&self, roles: Option<&BTreeSet>) -> bool { match self { Self::Public => true, - Self::Read => role.is_some(), - Self::Control => matches!(role, Some(AuthRole::Admin)), + Self::Read => roles.is_some_and(|roles| grants(roles, AuthRole::Read)), + Self::ReadWrite => roles.is_some_and(|roles| { + grants(roles, AuthRole::Read) || grants(roles, AuthRole::Write) + }), + Self::Write => roles.is_some_and(|roles| grants(roles, AuthRole::Write)), + Self::Control => roles.is_some_and(|roles| grants(roles, AuthRole::Control)), } } } @@ -951,12 +1087,12 @@ where } // Auth enabled - check role - let role = get_session_role(req.request(), &auth_state); + let roles = get_session_roles(req.request(), &auth_state); - if !access.allows(role) { + if !access.allows(roles.as_ref()) { // Access denied return Box::pin(async move { - if role.is_some() { + if roles.is_some() { // Has session but insufficient permissions - 403 Forbidden Err(actix_web::error::ErrorForbidden( "Insufficient permissions".to_string(), @@ -984,6 +1120,25 @@ mod tests { use super::*; use actix_web::{test as aw_test, App}; + fn roles(values: &[AuthRole]) -> BTreeSet { + values.iter().copied().collect() + } + + fn test_auth_config(path: PathBuf) -> AuthConfig { + AuthConfig::new( + true, + path, + Some("admin".to_string()), + Some("password123".to_string()), + true, + "guest".to_string(), + Some("guest".to_string()), + Duration::from_secs(3600), + false, + SameSite::Lax, + ) + } + #[test] fn test_route_access_public_paths() { assert_eq!(RouteAccess::from_path("/"), RouteAccess::Public); @@ -1016,6 +1171,8 @@ mod tests { assert_eq!(RouteAccess::from_path("/spectrum"), RouteAccess::Read); assert_eq!(RouteAccess::from_path("/meter"), RouteAccess::Read); assert_eq!(RouteAccess::from_path("/audio"), RouteAccess::Read); + assert_eq!(RouteAccess::from_path("/api/logbook"), RouteAccess::Write); + assert_eq!(RouteAccess::from_path("/bookmarks"), RouteAccess::ReadWrite); } #[test] @@ -1030,30 +1187,45 @@ mod tests { #[test] fn test_route_access_allows() { + let read = roles(&[AuthRole::Read]); + let control = roles(&[AuthRole::Control]); + let write = roles(&[AuthRole::Write]); + let administrator = roles(&[AuthRole::Administrator]); assert!(RouteAccess::Public.allows(None)); - assert!(RouteAccess::Public.allows(Some(AuthRole::User))); - assert!(RouteAccess::Public.allows(Some(AuthRole::Admin))); + assert!(RouteAccess::Public.allows(Some(&read))); + assert!(RouteAccess::Public.allows(Some(&administrator))); assert!(!RouteAccess::Read.allows(None)); - assert!(RouteAccess::Read.allows(Some(AuthRole::User))); - assert!(RouteAccess::Read.allows(Some(AuthRole::Admin))); + assert!(RouteAccess::Read.allows(Some(&read))); + assert!(RouteAccess::Read.allows(Some(&control))); + assert!(!RouteAccess::Read.allows(Some(&write))); + assert!(RouteAccess::Read.allows(Some(&administrator))); + + assert!(RouteAccess::ReadWrite.allows(Some(&read))); + assert!(RouteAccess::ReadWrite.allows(Some(&control))); + assert!(RouteAccess::ReadWrite.allows(Some(&write))); + assert!(!RouteAccess::Write.allows(Some(&read))); + assert!(!RouteAccess::Write.allows(Some(&control))); + assert!(RouteAccess::Write.allows(Some(&write))); + assert!(RouteAccess::Write.allows(Some(&administrator))); assert!(!RouteAccess::Control.allows(None)); - assert!(!RouteAccess::Control.allows(Some(AuthRole::User))); - assert!(RouteAccess::Control.allows(Some(AuthRole::Admin))); + assert!(!RouteAccess::Control.allows(Some(&read))); + assert!(RouteAccess::Control.allows(Some(&control))); + assert!(RouteAccess::Control.allows(Some(&administrator))); } #[test] fn test_session_store_create_and_get() { let store = SessionStore::new(); let ttl = Duration::from_secs(3600); - let session_id = store.create("alice".to_string(), AuthRole::User, ttl); + let session_id = store.create("alice".to_string(), roles(&[AuthRole::Read]), ttl); let record = store.get(&session_id); assert!(record.is_some()); let record = record.unwrap(); assert_eq!(record.username, "alice"); - assert_eq!(record.role, AuthRole::User); + assert_eq!(record.roles, roles(&[AuthRole::Read])); assert!(!record.is_expired()); } @@ -1061,7 +1233,7 @@ mod tests { fn test_session_store_remove() { let store = SessionStore::new(); let ttl = Duration::from_secs(3600); - let session_id = store.create("alice".to_string(), AuthRole::User, ttl); + let session_id = store.create("alice".to_string(), roles(&[AuthRole::Read]), ttl); store.remove(&session_id); assert!(store.get(&session_id).is_none()); @@ -1070,35 +1242,33 @@ mod tests { #[test] fn user_store_bootstraps_and_manages_users() { let directory = tempfile::tempdir().unwrap(); - let config = AuthConfig::new( - true, - directory.path().join("users.json"), - Some("admin".to_string()), - Some("password123".to_string()), - Duration::from_secs(3600), - false, - SameSite::Lax, - ); + let config = test_auth_config(directory.path().join("users.json")); let users = UserStore::open(&config).unwrap(); assert_eq!( users.authenticate("admin", "password123"), - Some(AuthRole::Admin) + Some(AuthRole::all()) ); assert_eq!(users.authenticate("admin", "wrong"), None); - users.add("alice", "password456", AuthRole::User).unwrap(); assert_eq!( - users.authenticate("alice", "password456"), - Some(AuthRole::User) + users.authenticate("guest", "guest"), + Some(roles(&[AuthRole::Read])) ); users - .update("alice", Some("password789"), Some(AuthRole::Admin)) + .add("alice", "password456", roles(&[AuthRole::Read])) + .unwrap(); + assert_eq!( + users.authenticate("alice", "password456"), + Some(roles(&[AuthRole::Read])) + ); + users + .update("alice", Some("password789"), Some(AuthRole::all())) .unwrap(); assert_eq!( users.authenticate("alice", "password789"), - Some(AuthRole::Admin) + Some(AuthRole::all()) ); users.remove("admin").unwrap(); - assert_eq!(users.list().len(), 1); + assert_eq!(users.list().len(), 2); drop(users); let mut reopen_config = config.clone(); @@ -1107,7 +1277,7 @@ mod tests { let reopened = UserStore::open(&reopen_config).unwrap(); assert_eq!( reopened.authenticate("alice", "password789"), - Some(AuthRole::Admin) + Some(AuthRole::all()) ); let database = fs::read_to_string(&reopen_config.users_file).unwrap(); assert!(database.contains("$argon2")); @@ -1117,32 +1287,53 @@ mod tests { #[test] fn user_store_protects_last_admin() { let directory = tempfile::tempdir().unwrap(); - let config = AuthConfig::new( - true, - directory.path().join("users.json"), - Some("admin".to_string()), - Some("password123".to_string()), - Duration::from_secs(3600), - false, - SameSite::Lax, - ); + let config = test_auth_config(directory.path().join("users.json")); let users = UserStore::open(&config).unwrap(); assert!(users.remove("admin").is_err()); - assert!(users.update("admin", None, Some(AuthRole::User)).is_err()); + assert!(users + .update("admin", None, Some(roles(&[AuthRole::Read]))) + .is_err()); + } + + #[test] + fn guest_bootstrap_can_be_disabled() { + let directory = tempfile::tempdir().unwrap(); + let mut config = test_auth_config(directory.path().join("users.json")); + config.bootstrap_read_enabled = false; + config.bootstrap_read_password = None; + let users = UserStore::open(&config).unwrap(); + + assert_eq!(users.list().len(), 1); + assert_eq!(users.authenticate("guest", "guest"), None); + } + + #[test] + fn legacy_single_roles_are_migrated() { + let read: UserRecord = serde_json::from_value(serde_json::json!({ + "username": "reader", + "password_hash": "unused", + "role": "user" + })) + .unwrap(); + let administrator: UserRecord = serde_json::from_value(serde_json::json!({ + "username": "admin", + "password_hash": "unused", + "role": "admin" + })) + .unwrap(); + + assert_eq!(read.roles, roles(&[AuthRole::Read])); + assert_eq!(administrator.roles, AuthRole::all()); + assert!(serde_json::to_value(administrator) + .unwrap() + .get("role") + .is_none()); } #[actix_web::test] async fn admin_endpoints_manage_multiple_users_and_reject_regular_users() { let directory = tempfile::tempdir().unwrap(); - let config = AuthConfig::new( - true, - directory.path().join("users.json"), - Some("admin".to_string()), - Some("password123".to_string()), - Duration::from_secs(3600), - false, - SameSite::Lax, - ); + let config = test_auth_config(directory.path().join("users.json")); let state = web::Data::new(AuthState::new(config).unwrap()); let app = aw_test::init_service( App::new() @@ -1167,7 +1358,7 @@ mod tests { .uri("/auth/users") .insert_header((actix_web::http::header::COOKIE, admin_cookie.clone())) .set_json( - serde_json::json!({"username":"alice","password":"password456","role":"user"}), + serde_json::json!({"username":"alice","password":"password456","roles":["read"]}), ) .to_request(); assert_eq!( @@ -1193,7 +1384,7 @@ mod tests { let request = aw_test::TestRequest::patch() .uri("/auth/users/alice") .insert_header((actix_web::http::header::COOKIE, admin_cookie.clone())) - .set_json(serde_json::json!({"password":"new-password","role":"admin"})) + .set_json(serde_json::json!({"password":"new-password","roles":["read","control","write","administrator"]})) .to_request(); assert_eq!( aw_test::call_service(&app, request).await.status(), @@ -1213,7 +1404,7 @@ mod tests { let request = aw_test::TestRequest::patch() .uri("/auth/users/admin") .insert_header((actix_web::http::header::COOKIE, admin_cookie.clone())) - .set_json(serde_json::json!({"role":"user"})) + .set_json(serde_json::json!({"roles":["read"]})) .to_request(); assert_eq!( aw_test::call_service(&app, request).await.status(), diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/server.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/server.rs index 7c973fb8..cb94ce8f 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/server.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/server.rs @@ -257,6 +257,9 @@ fn build_server( context.http_auth.users_file.clone().into(), context.http_auth.bootstrap_admin_username.clone(), context.http_auth.bootstrap_admin_password.clone(), + context.http_auth.bootstrap_read_enabled, + context.http_auth.bootstrap_read_username.clone(), + context.http_auth.bootstrap_read_password.clone(), Duration::from_secs(context.http_auth.session_ttl_secs), context.http_auth.cookie_secure, same_site, diff --git a/src/trx-config/src/client.rs b/src/trx-config/src/client.rs index 121214f7..c4e8440a 100644 --- a/src/trx-config/src/client.rs +++ b/src/trx-config/src/client.rs @@ -277,6 +277,12 @@ 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. + pub bootstrap_read_enabled: bool, + /// Username for the read-only bootstrap account. + pub bootstrap_read_username: String, + /// Password for the read-only bootstrap account. + pub bootstrap_read_password: Option, /// Session time-to-live in minutes pub session_ttl_min: u64, /// Set Secure flag on session cookie (required for HTTPS) @@ -293,6 +299,9 @@ impl Default for HttpAuthConfig { bootstrap_admin_username: None, bootstrap_admin_password: None, bootstrap_admin_password_file: None, + bootstrap_read_enabled: true, + bootstrap_read_username: "guest".to_string(), + bootstrap_read_password: Some("guest".to_string()), session_ttl_min: 480, cookie_secure: false, cookie_same_site: CookieSameSite::Lax, @@ -830,6 +839,15 @@ impl ClientConfig { .bootstrap_admin_password_file .is_none() && self.frontends.http.auth.bootstrap_admin_password.is_some() + || self.frontends.http.auth.enabled + && self.frontends.http.auth.bootstrap_read_enabled + && self + .frontends + .http + .auth + .bootstrap_read_password + .as_deref() + .is_some_and(|password| password != "guest") || self.frontends.http_json.auth.tokens_file.is_none() && !self.frontends.http_json.auth.tokens.is_empty() } @@ -903,6 +921,9 @@ impl ClientConfig { bootstrap_admin_username: Some("admin".to_string()), bootstrap_admin_password: Some("change-this-password".to_string()), bootstrap_admin_password_file: None, + bootstrap_read_enabled: true, + bootstrap_read_username: "guest".to_string(), + bootstrap_read_password: Some("guest".to_string()), session_ttl_min: 480, cookie_secure: false, cookie_same_site: CookieSameSite::Lax, @@ -961,6 +982,15 @@ fn validate_http_auth(auth: &HttpAuthConfig) -> Result<(), String> { .to_string(), ); } + if auth.bootstrap_read_enabled + && (auth.bootstrap_read_username.trim().is_empty() + || auth + .bootstrap_read_password + .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()); + } // Session TTL must be > 0 if auth.session_ttl_min == 0 { @@ -1268,6 +1298,18 @@ home-hf = "audio://10.0.0.5:4600" assert!(config.validate().is_err()); } + #[test] + fn test_validate_allows_disabling_read_bootstrap_credentials() { + let mut config = ClientConfig::default(); + config.frontends.http.auth.enabled = true; + config.frontends.http.auth.bootstrap_admin_username = Some("admin".to_string()); + config.frontends.http.auth.bootstrap_admin_password = Some("secret-password".to_string()); + config.frontends.http.auth.bootstrap_read_enabled = false; + config.frontends.http.auth.bootstrap_read_username.clear(); + config.frontends.http.auth.bootstrap_read_password = None; + assert!(config.validate().is_ok()); + } + #[test] fn test_validate_auth_disabled_ignores_user_settings() { let mut config = ClientConfig::default(); @@ -1283,6 +1325,9 @@ home-hf = "audio://10.0.0.5:4600" assert_eq!(auth.users_file, "trx-http-users.json"); assert!(auth.bootstrap_admin_username.is_none()); assert!(auth.bootstrap_admin_password.is_none()); + assert!(auth.bootstrap_read_enabled); + assert_eq!(auth.bootstrap_read_username, "guest"); + assert_eq!(auth.bootstrap_read_password.as_deref(), Some("guest")); assert_eq!(auth.session_ttl_min, 480); assert!(!auth.cookie_secure); assert!(matches!(auth.cookie_same_site, CookieSameSite::Lax)); diff --git a/trx-rs.toml.example b/trx-rs.toml.example index 29890fad..f23481d8 100644 --- a/trx-rs.toml.example +++ b/trx-rs.toml.example @@ -210,6 +210,9 @@ enabled = false users_file = "trx-http-users.json" bootstrap_admin_username = "admin" bootstrap_admin_password = "change-this-password" +bootstrap_read_enabled = true +bootstrap_read_username = "guest" +bootstrap_read_password = "guest" session_ttl_min = 480 cookie_secure = false cookie_same_site = "Lax"