Add composable HTTP access roles #62

Merged
sjg merged 3 commits from agent/refactor-account-system into main 2026-08-11 01:20:39 +02:00
32 changed files with 772 additions and 268 deletions
+1 -1
View File
@@ -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` | | `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` | | `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_*` | | `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` | | `RigRoutingContext` | Remote rig state & routing | `active_rig_id`, `remote_rigs`, `rig_states`, `server_connected` |
| `OwnerInfo` | Station metadata | `callsign`, `website_url`, `ais_vessel_url_base` | | `OwnerInfo` | Station metadata | `callsign`, `website_url`, `ais_vessel_url_base` |
+5 -3
View File
@@ -126,7 +126,8 @@ When auth is enabled, an **auth gate** blocks the UI with:
- Error message area (red `#ff6b6b`) - Error message area (red `#ff6b6b`)
- Role badge display - 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. Session cookie: `trx_http_sid`, HttpOnly, configurable Secure and SameSite attributes.
@@ -340,8 +341,9 @@ Routes are classified into three tiers:
| Tier | Examples | Requirement | | Tier | Examples | Requirement |
|---|---|---| |---|---|---|
| **Public** | `/`, `/index.html`, `/map`, login/session endpoints, static assets | None | | **Public** | `/`, `/index.html`, `/map`, login/session endpoints, static assets | None |
| **Read** | `/status`, `/events`, `/audio`, `/decode`, `/spectrum`, `/bookmarks` | User or Admin role | | **Read** | `/status`, `/events`, `/audio`, `/decode`, `/spectrum`, `/bookmarks` | Read, Control, or Administrator role |
| **Control** | `/set_freq`, `/set_mode`, `/set_ptt`, `/toggle_power`, all other POST | Admin role only | | **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 ### 7.2 Session Management
+24 -13
View File
@@ -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_username` | string | — | First administrator, used only if the database is absent |
| `bootstrap_admin_password` | string | — | First administrator password | | `bootstrap_admin_password` | string | — | First administrator password |
| `bootstrap_admin_password_file` | string | — | Read the bootstrap password from this file instead | | `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 | | `session_ttl_min` | u64 | `480` | Session lifetime |
| `cookie_secure` | bool | `false` | Set Secure on the session cookie (needs HTTPS) | | `cookie_secure` | bool | `false` | Set Secure on the session cookie (needs HTTPS) |
| `cookie_same_site` | string | `"Lax"` | `Strict`, `Lax`, or `None` | | `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 address bar itself is updated as you tune, using `replaceState`, so sweeping
the dial does not fill the browser's history. 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 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 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. 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 ## Authentication
The HTTP frontend supports an optional user/password ACL with multiple accounts and two The HTTP frontend supports an optional user/password ACL with multiple independent
roles: roles. One account may have any combination:
- **user** — read-only access (monitoring, audio, decode streams) - **Read** — monitoring, audio, decode streams, and bookmark reads
- **admin** — full radio control, settings, and user management - **Control** — full radio receive/transmit controls
- **Write** — logbook access and bookmark changes
- **Administrator** — user management and all other permissions
### Configuration ### Configuration
@@ -600,6 +605,9 @@ enabled = false
users_file = "trx-http-users.json" users_file = "trx-http-users.json"
bootstrap_admin_username = "admin" bootstrap_admin_username = "admin"
bootstrap_admin_password = "change-this-password" bootstrap_admin_password = "change-this-password"
bootstrap_read_enabled = true
bootstrap_read_username = "guest"
bootstrap_read_password = "guest"
session_ttl_min = 480 session_ttl_min = 480
cookie_secure = false # true if served via HTTPS cookie_secure = false # true if served via HTTPS
cookie_same_site = "Lax" # Strict|Lax|None 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 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 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 ### Behaviour
@@ -615,8 +625,9 @@ the initial administrator and the Argon2id-hashed user database.
- Sessions are in-memory; a server restart invalidates all sessions. - Sessions are in-memory; a server restart invalidates all sessions.
- Rate limiting is applied per IP to mitigate brute-force attempts. - 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 records persist in `users_file`; passwords are stored as salted Argon2id hashes.
- `user` sessions cannot call control routes. There is no guest-access mode. - 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. - 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. - Removing an account or changing its password/role revokes its sessions.
### Routes ### Routes
@@ -625,19 +636,19 @@ the initial administrator and the Argon2id-hashed user database.
|----------|--------|-------------| |----------|--------|-------------|
| `/auth/login` | POST | Submit `{ "username": "...", "password": "..." }` | | `/auth/login` | POST | Submit `{ "username": "...", "password": "..." }` |
| `/auth/logout` | POST | Clear session | | `/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` | 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, Read routes require Read. Radio mutations require Control. Logbook access and
mode, PTT, etc.) require `admin` role. bookmark mutations require Write. Administrator grants every permission.
### Frontend Flow ### Frontend Flow
1. On load, the UI calls `/auth/session`. 1. On load, the UI calls `/auth/session`.
2. If unauthenticated, a login screen is shown. 2. If unauthenticated, a login screen is shown.
3. On successful login, the normal UI loads. 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. 5. If a session expires mid-use, streams stop and the login screen returns.
### Transport Security ### Transport Security
+6
View File
@@ -257,6 +257,12 @@ async fn async_init() -> DynResult<AppState> {
cfg.frontends.http.auth.bootstrap_admin_username.clone(); cfg.frontends.http.auth.bootstrap_admin_username.clone();
frontend_runtime.http_auth.bootstrap_admin_password = frontend_runtime.http_auth.bootstrap_admin_password =
cfg.frontends.http.auth.bootstrap_admin_password.clone(); 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.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_secure = cfg.frontends.http.auth.cookie_secure;
frontend_runtime.http_auth.cookie_same_site = match cfg.frontends.http.auth.cookie_same_site { frontend_runtime.http_auth.cookie_same_site = match cfg.frontends.http.auth.cookie_same_site {
+6
View File
@@ -260,6 +260,9 @@ pub struct HttpAuthConfig {
pub users_file: String, pub users_file: String,
pub bootstrap_admin_username: Option<String>, pub bootstrap_admin_username: Option<String>,
pub bootstrap_admin_password: Option<String>, pub bootstrap_admin_password: Option<String>,
pub bootstrap_read_enabled: bool,
pub bootstrap_read_username: String,
pub bootstrap_read_password: Option<String>,
pub session_ttl_secs: u64, pub session_ttl_secs: u64,
pub cookie_secure: bool, pub cookie_secure: bool,
pub cookie_same_site: String, pub cookie_same_site: String,
@@ -274,6 +277,9 @@ impl Default for HttpAuthConfig {
users_file: "trx-http-users.json".to_string(), users_file: "trx-http-users.json".to_string(),
bootstrap_admin_username: None, bootstrap_admin_username: None,
bootstrap_admin_password: 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, session_ttl_secs: 480 * 60,
cookie_secure: false, cookie_secure: false,
cookie_same_site: "Lax".to_string(), cookie_same_site: "Lax".to_string(),
@@ -1323,14 +1323,13 @@ function decodeAuthSession(value) {
if (typeof session.authenticated !== "boolean") { if (typeof session.authenticated !== "boolean") {
throw new TypeError("The authentication response has no authenticated flag"); throw new TypeError("The authentication response has no authenticated flag");
} }
if (session.role !== void 0 && session.role !== "user" && session.role !== "admin") { if (!Array.isArray(session.roles) || !session.roles.every((role) => role === "read" || role === "control" || role === "write" || role === "administrator")) {
throw new TypeError("The authentication response has an invalid role"); throw new TypeError("The authentication response has invalid roles");
} }
if (session.auth_disabled !== void 0 && typeof session.auth_disabled !== "boolean") { if (session.auth_disabled !== void 0 && typeof session.auth_disabled !== "boolean") {
throw new TypeError("The authentication response has an invalid auth_disabled flag"); throw new TypeError("The authentication response has an invalid auth_disabled flag");
} }
const decoded = { authenticated: session.authenticated }; const decoded = { authenticated: session.authenticated, roles: session.roles };
if (session.role !== void 0) decoded.role = session.role;
if (session.username !== void 0) { if (session.username !== void 0) {
if (typeof session.username !== "string") throw new TypeError("The authentication response has an invalid username"); if (typeof session.username !== "string") throw new TypeError("The authentication response has an invalid username");
decoded.username = session.username; decoded.username = session.username;
@@ -1340,18 +1339,18 @@ function decodeAuthSession(value) {
} }
var authDisabledSession = { var authDisabledSession = {
authenticated: true, authenticated: true,
role: "admin", roles: ["read", "control", "write", "administrator"],
auth_disabled: true auth_disabled: true
}; };
async function fetchAuthSession() { async function fetchAuthSession() {
try { try {
const response = await fetch("/auth/session"); const response = await fetch("/auth/session");
if (response.status === 404) return authDisabledSession; if (response.status === 404) return authDisabledSession;
if (!response.ok) return { authenticated: false }; if (!response.ok) return { authenticated: false, roles: [] };
return decodeAuthSession(await response.json()); return decodeAuthSession(await response.json());
} catch (error) { } catch (error) {
console.error("Auth check failed:", error); console.error("Auth check failed:", error);
return { authenticated: false }; return { authenticated: false, roles: [] };
} }
} }
async function login(username, password) { async function login(username, password) {
@@ -1380,14 +1379,14 @@ async function listUsers() {
if (!Array.isArray(value) || !value.every((user) => { if (!Array.isArray(value) || !value.every((user) => {
if (typeof user !== "object" || user === null) return false; if (typeof user !== "object" || user === null) return false;
const record = user; 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"); throw new TypeError("The user list response is malformed");
} }
return value; return value;
} }
async function createUser(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, role }) }); await userRequest("/auth/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password, roles }) });
} }
async function updateUser(username, changes) { async function updateUser(username, changes) {
await userRequest(`/auth/users/${encodeURIComponent(username)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(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); void loadDecoderRegistry(refreshOperatorLayoutCapabilities);
var authRole = null; var authRole = null;
var authRoles = [];
var authUsername = null; var authUsername = null;
var authEnabled = true; 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() { async function checkAuthStatus() {
return fetchAuthSession(); return fetchAuthSession();
} }
@@ -1906,7 +1920,7 @@ async function authLogin(username, password) {
async function authLogout() { async function authLogout() {
try { try {
await logout(); await logout();
authRole = null; setAuthRoles([]);
authUsername = null; authUsername = null;
disconnect(); disconnect();
setDecodeHistoryOverlayVisible(false); setDecodeHistoryOverlayVisible(false);
@@ -1978,9 +1992,9 @@ function updateAuthUI() {
syncTopBarAccess(); syncTopBarAccess();
return; return;
} }
if (authRole) { if (authRoles.length > 0) {
if (badge) badge.style.display = "block"; 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) { if (headerAuthBtn2) {
headerAuthBtn2.textContent = "Logout"; headerAuthBtn2.textContent = "Logout";
headerAuthBtn2.style.display = "block"; headerAuthBtn2.style.display = "block";
@@ -1995,8 +2009,8 @@ function updateAuthUI() {
syncTopBarAccess(); syncTopBarAccess();
} }
function applyAuthRestrictions() { function applyAuthRestrictions() {
if (!authRole) return; if (authRoles.length === 0) return;
if (authRole === "user") { if (!hasAuthRole("control")) {
const pttBtn2 = document.getElementById("ptt-btn"); const pttBtn2 = document.getElementById("ptt-btn");
const powerBtn2 = document.getElementById("power-btn"); const powerBtn2 = document.getElementById("power-btn");
const lockBtn2 = document.getElementById("lock-btn"); const lockBtn2 = document.getElementById("lock-btn");
@@ -2286,20 +2300,21 @@ window.applyDecodeHistoryRetention = function() {
} }
}; };
function syncTopBarAccess() { function syncTopBarAccess() {
const loggedOut = authEnabled && !authRole; const loggedOut = authEnabled && authRoles.length === 0;
const tabBar = document.getElementById("tab-bar"); const tabBar = document.getElementById("tab-bar");
const rigSwitch = document.querySelector(".header-rig-switch"); const rigSwitch = document.querySelector(".header-rig-switch");
if (tabBar) tabBar.style.display = ""; if (tabBar) tabBar.style.display = "";
document.querySelectorAll(".tab-bar .tab").forEach((btn) => { document.querySelectorAll(".tab-bar .tab").forEach((btn) => {
const isMain = btn.dataset.tab === "main"; 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; btn.disabled = false;
}); });
if (rigSwitch) { if (rigSwitch) {
rigSwitch.style.display = loggedOut ? "none" : ""; rigSwitch.style.display = loggedOut ? "none" : "";
} }
if (headerRigSwitchSelect) { if (headerRigSwitchSelect) {
headerRigSwitchSelect.disabled = loggedOut || authRole === "user" || lastRigIds.length === 0; headerRigSwitchSelect.disabled = loggedOut || !hasAuthRole("control") || lastRigIds.length === 0;
} }
} }
var overviewDrawPending = false; var overviewDrawPending = false;
@@ -2935,7 +2950,7 @@ function applyRigList(activeRigId, rigIds, displayNames) {
} }
const nextKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || ""); const nextKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || "");
const rigListChanged = prevKey !== nextKey; const rigListChanged = prevKey !== nextKey;
const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "user"; const disableSwitch = lastRigIds.length === 0 || !hasAuthRole("control");
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch); populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
updateRigSubtitle(lastActiveRigId); updateRigSubtitle(lastActiveRigId);
window.trxUi?.setActiveRig(lastActiveRigId); window.trxUi?.setActiveRig(lastActiveRigId);
@@ -4538,7 +4553,7 @@ function scheduleTuneLinkSync() {
async function applyTuneLink(link) { async function applyTuneLink(link) {
const wanted = link.rig || link.mode || link.freqHz != null || link.bandwidthHz != null; const wanted = link.rig || link.mode || link.freqHz != null || link.bandwidthHz != null;
if (!wanted) return; if (!wanted) return;
if (authRole === "user") { if (!hasAuthRole("control")) {
showHint("Read-only session — link not applied", 2500); showHint("Read-only session — link not applied", 2500);
return; return;
} }
@@ -5256,7 +5271,7 @@ async function postPath(path, options = {}) {
} }
const resp = await fetch(path, { method: "POST" }); const resp = await fetch(path, { method: "POST" });
if (authEnabled && resp.status === 401) { if (authEnabled && resp.status === 401) {
authRole = null; setAuthRoles([]);
if (es) es.close(); if (es) es.close();
showAuthGate(); showAuthGate();
throw new Error("Authentication required"); throw new Error("Authentication required");
@@ -5281,8 +5296,8 @@ async function switchRigFromSelect(selectEl) {
showHint("No rig selected", 1500); showHint("No rig selected", 1500);
return; return;
} }
if (authRole === "user") { if (!hasAuthRole("control")) {
showHint("Admin role required", 1500); showHint("Control role required", 1500);
return; return;
} }
if (!lastRigIds.includes(selectEl.value)) { if (!lastRigIds.includes(selectEl.value)) {
@@ -5895,10 +5910,15 @@ function navigateToTab(name, options = {}) {
window.trxUi?.closeMobileOverlays?.(); window.trxUi?.closeMobileOverlays?.();
const leavingSatellites = _activeTab === "satellites" && name !== "satellites"; const leavingSatellites = _activeTab === "satellites" && name !== "satellites";
const { updateHistory = true, replaceHistory = false } = options; const { updateHistory = true, replaceHistory = false } = options;
if (authEnabled && !authRole && name !== "main") { if (authEnabled && authRoles.length === 0 && name !== "main") {
showAuthGate(); showAuthGate();
return; 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}"]`); const btn = document.querySelector(`.tab-bar .tab[data-tab="${name}"]`);
if (!btn) return; if (!btn) return;
_activeTab = name; _activeTab = name;
@@ -6013,7 +6033,7 @@ async function initializeApp() {
const authStatus = await checkAuthStatus(); const authStatus = await checkAuthStatus();
authEnabled = !authStatus.auth_disabled; authEnabled = !authStatus.auth_disabled;
if (!authEnabled) { if (!authEnabled) {
authRole = "admin"; setAuthRoles(ALL_AUTH_ROLES);
hideAuthGate(); hideAuthGate();
updateAuthUI(); updateAuthUI();
connect(); connect();
@@ -6024,7 +6044,7 @@ async function initializeApp() {
return; return;
} }
if (authStatus.authenticated) { if (authStatus.authenticated) {
authRole = authStatus.role ?? null; setAuthRoles(authStatus.roles);
authUsername = authStatus.username ?? null; authUsername = authStatus.username ?? null;
hideAuthGate(); hideAuthGate();
updateAuthUI(); updateAuthUI();
@@ -6051,12 +6071,22 @@ function initSettingsUI() {
} }
async function refreshUserManagement() { async function refreshUserManagement() {
const section = document.getElementById("user-management"); const section = document.getElementById("user-management");
if (!section) return; const tab = document.getElementById("settings-users-tab");
section.style.display = authEnabled && authRole === "admin" ? "block" : "none"; if (!section || !tab) return;
if (section.style.display === "none") return; const canManageUsers = authEnabled && hasAuthRole("administrator");
tab.style.display = canManageUsers ? "" : "none";
if (!canManageUsers) {
const panel = document.getElementById("subtab-settings-users");
if (panel) panel.style.display = "none";
if (tab.classList.contains("active")) {
document.querySelector('[data-subtab="settings-scheduler"]')?.click();
}
return;
}
const list = requiredElement("user-list"); const list = requiredElement("user-list");
try { try {
const users = await listUsers(); const users = await listUsers();
const adminCount = users.filter((user) => user.roles.includes("administrator")).length;
list.replaceChildren(...users.map((user) => { list.replaceChildren(...users.map((user) => {
const row = document.createElement("div"); const row = document.createElement("div");
row.className = "sch-row"; row.className = "sch-row";
@@ -6064,18 +6094,28 @@ async function refreshUserManagement() {
const name = document.createElement("strong"); const name = document.createElement("strong");
name.textContent = user.username; name.textContent = user.username;
name.style.minWidth = "10rem"; name.style.minWidth = "10rem";
const role = document.createElement("select"); const roleInputs = ALL_AUTH_ROLES.map((value) => {
role.className = "auth-input"; const label = document.createElement("label");
for (const value of ["user", "admin"]) { label.className = "auth-role-choice";
const option = document.createElement("option"); const input = document.createElement("input");
option.value = value; input.type = "checkbox";
option.textContent = value === "admin" ? "Admin" : "User"; input.value = value;
option.selected = user.role === value; input.checked = user.roles.includes(value);
role.append(option); 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 password = document.createElement("input"); const password = document.createElement("input");
password.type = "password"; password.type = "password";
password.placeholder = "New password"; password.placeholder = "New password (8+ characters)";
password.autocomplete = "new-password"; password.autocomplete = "new-password";
password.className = "auth-input"; password.className = "auth-input";
password.minLength = 8; password.minLength = 8;
@@ -6083,7 +6123,9 @@ async function refreshUserManagement() {
save.type = "button"; save.type = "button";
save.textContent = "Save"; save.textContent = "Save";
save.addEventListener("click", async () => { 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; if (password.value) changes.password = password.value;
await runUserOperation(() => updateUser(user.username, changes)); await runUserOperation(() => updateUser(user.username, changes));
}); });
@@ -6091,13 +6133,14 @@ async function refreshUserManagement() {
remove.type = "button"; remove.type = "button";
remove.textContent = "Remove"; remove.textContent = "Remove";
remove.className = "danger"; remove.className = "danger";
remove.disabled = user.username === authUsername; remove.disabled = user.username === authUsername || isOnlyAdmin;
if (isOnlyAdmin) remove.title = "The final administrator cannot be removed";
remove.addEventListener("click", async () => { remove.addEventListener("click", async () => {
if (await window.trxUi.confirm({ title: "Remove user?", message: `Remove ${user.username} and revoke their sessions?`, confirmLabel: "Remove", danger: true })) { if (await window.trxUi.confirm({ title: "Remove user?", message: `Remove ${user.username} and revoke their sessions?`, confirmLabel: "Remove", danger: true })) {
await runUserOperation(() => deleteUser(user.username)); await runUserOperation(() => deleteUser(user.username));
} }
}); });
row.append(name, role, password, save, remove); row.append(name, roles, password, save, remove);
return row; return row;
})); }));
} catch (error) { } catch (error) {
@@ -6124,12 +6167,14 @@ document.getElementById("user-create-form")?.addEventListener("submit", (event)
event.preventDefault(); event.preventDefault();
const username = requiredElement("user-create-username"); const username = requiredElement("user-create-username");
const password = requiredElement("user-create-password"); 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 () => { 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 = ""; username.value = "";
password.value = ""; password.value = "";
role.value = "user"; roles.forEach((input) => {
input.checked = input.value === "read";
});
}); });
}); });
requiredElement("auth-form").addEventListener("submit", async (e) => { requiredElement("auth-form").addEventListener("submit", async (e) => {
@@ -6142,7 +6187,7 @@ requiredElement("auth-form").addEventListener("submit", async (e) => {
btn.textContent = "Logging in..."; btn.textContent = "Logging in...";
try { try {
const result = await authLogin(usernameEl.value, passwordEl.value); const result = await authLogin(usernameEl.value, passwordEl.value);
authRole = result.role ?? null; setAuthRoles(result.roles);
authUsername = result.username ?? usernameEl.value; authUsername = result.username ?? usernameEl.value;
passwordEl.value = ""; passwordEl.value = "";
hideAuthGate(); hideAuthGate();
@@ -6164,7 +6209,7 @@ requiredElement("auth-form").addEventListener("submit", async (e) => {
var headerAuthBtn = document.getElementById("header-auth-btn"); var headerAuthBtn = document.getElementById("header-auth-btn");
if (headerAuthBtn) { if (headerAuthBtn) {
headerAuthBtn.addEventListener("click", async () => { 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 })) { 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(); await authLogout();
} }
@@ -6210,6 +6255,9 @@ Object.defineProperties(trxState, {
authRole: { get() { authRole: { get() {
return authRole; return authRole;
} }, } },
authRoles: { get() {
return authRoles;
} },
decoderRegistry: { get() { decoderRegistry: { get() {
return decoderRegistry; return decoderRegistry;
} }, } },
@@ -358,7 +358,7 @@ var bgdWindow = window;
btn.title = bgdDirty ? "Apply these bookmarks to the background decoder" : "No changes to save"; btn.title = bgdDirty ? "Apply these bookmarks to the background decoder" : "No changes to save";
} }
function isControlRole() { function isControlRole() {
return backgroundDecodeRole === "admin" || hostState.authEnabled === false; return backgroundDecodeRole === "administrator" || backgroundDecodeRole === "control" || hostState.authEnabled === false;
} }
function showToast(msg, isError) { function showToast(msg, isError) {
const el = document.getElementById("background-decode-toast"); const el = document.getElementById("background-decode-toast");
@@ -42,7 +42,7 @@ function bmEsc(str) {
return d.innerHTML; return d.innerHTML;
} }
function bmCanControl() { function bmCanControl() {
return !hostState.authEnabled || hostState.authRole === "admin"; return !hostState.authEnabled || hostState.authRoles.includes("administrator") || hostState.authRoles.includes("write");
} }
function bmSyncAccess() { function bmSyncAccess() {
const canCtrl = bmCanControl(); const canCtrl = bmCanControl();
@@ -47,6 +47,9 @@ var entryRigName = null;
var entryGrid = null; var entryGrid = null;
var qsos = []; var qsos = [];
var workedRequest = 0; var workedRequest = 0;
function canWriteLogbook() {
return !hostState.authEnabled || hostState.authRoles.includes("administrator") || hostState.authRoles.includes("write");
}
function notify(message, kind) { function notify(message, kind) {
if (bridge.trxUi.notify) bridge.trxUi.notify(message, kind ? { kind } : void 0); if (bridge.trxUi.notify) bridge.trxUi.notify(message, kind ? { kind } : void 0);
else hostCore.showHint(message, 2e3); else hostCore.showHint(message, 2e3);
@@ -300,6 +303,11 @@ function renderRows() {
row.appendChild(cell); row.appendChild(cell);
} }
const actions = document.createElement("td"); const actions = document.createElement("td");
if (!canWriteLogbook()) {
row.appendChild(actions);
fragment.appendChild(row);
continue;
}
const confirm = document.createElement("button"); const confirm = document.createElement("button");
confirm.type = "button"; confirm.type = "button";
confirm.className = "log-row-btn"; confirm.className = "log-row-btn";
@@ -420,14 +428,19 @@ importFile?.addEventListener("change", () => {
if (file) void importAdif(file); if (file) void importAdif(file);
importFile.value = ""; importFile.value = "";
}); });
if (canWriteLogbook()) {
bridge.logContact = (seed) => { bridge.logContact = (seed) => {
bridge.navigateToTab?.("logbook"); bridge.navigateToTab?.("logbook");
void openEntry(seed).then(() => callInput?.focus()); void openEntry(seed).then(() => callInput?.focus());
}; };
} else {
if (form) form.style.display = "none";
if (importBtn) importBtn.style.display = "none";
}
renderStation(); renderStation();
if (cabrilloCallsign && !cabrilloCallsign.value) { if (cabrilloCallsign && !cabrilloCallsign.value) {
cabrilloCallsign.value = stationCallEl?.textContent?.trim() ?? ""; cabrilloCallsign.value = stationCallEl?.textContent?.trim() ?? "";
} }
syncCabrilloLink(); syncCabrilloLink();
void openEntry(); if (canWriteLogbook()) void openEntry();
void refreshLog(); void refreshLog();
@@ -272,7 +272,7 @@ function schedulerOptionalEl(id) {
const nextBtn = schedulerEl("scheduler-next-btn"); const nextBtn = schedulerEl("scheduler-next-btn");
if (!prevBtn || !nextBtn) return; if (!prevBtn || !nextBtn) return;
const state = schedulerInterleaveState(currentConfig); 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; prevBtn.disabled = !enabled;
nextBtn.disabled = !enabled; nextBtn.disabled = !enabled;
const hint = enabled ? "Select a different active scheduler entry" : "Available only when multiple scheduler entries are active"; 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"); const panel = schedulerEl("scheduler-panel");
if (!panel) return; if (!panel) return;
const mode = currentConfig && currentConfig.mode || "disabled"; const mode = currentConfig && currentConfig.mode || "disabled";
const isControl = schedulerRole === "admin"; const isControl = schedulerRole === "administrator" || schedulerRole === "control";
setSelected("scheduler-mode-select", mode); setSelected("scheduler-mode-select", mode);
const satEnabled = currentConfig && currentConfig.satellites && currentConfig.satellites.enabled; const satEnabled = currentConfig && currentConfig.satellites && currentConfig.satellites.enabled;
const controlRow = document.querySelector(".scheduler-control-row"); const controlRow = document.querySelector(".scheduler-control-row");
@@ -1461,6 +1461,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<button class="sub-tab" data-subtab="settings-background-decode">Background Decode</button> <button class="sub-tab" data-subtab="settings-background-decode">Background Decode</button>
<button class="sub-tab" data-subtab="settings-bandplan">Bandplan</button> <button class="sub-tab" data-subtab="settings-bandplan">Bandplan</button>
<button class="sub-tab" data-subtab="settings-history">History</button> <button class="sub-tab" data-subtab="settings-history">History</button>
<button id="settings-users-tab" class="sub-tab" data-subtab="settings-users" style="display:none;">Users</button>
</div> </div>
<div id="subtab-settings-scheduler" class="sub-tab-panel"> <div id="subtab-settings-scheduler" class="sub-tab-panel">
<div id="scheduler-panel" class="sch-panel"> <div id="scheduler-panel" class="sch-panel">
@@ -1750,19 +1751,25 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div> </div>
</div> </div>
</div> </div>
<section id="user-management" style="display:none; margin-top:1rem;"> <div id="subtab-settings-users" class="sub-tab-panel" style="display:none;">
<h2 class="section-heading">User management</h2> <div id="user-management">
<div class="settings-card"> <div class="settings-card">
<form id="user-create-form" class="sch-row" style="flex-wrap:wrap; gap:.5rem;"> <form id="user-create-form" class="sch-row" style="flex-wrap:wrap; gap:.5rem;">
<input id="user-create-username" class="auth-input" placeholder="Username" autocomplete="off" required /> <input id="user-create-username" class="auth-input" placeholder="Username" autocomplete="off" required />
<input id="user-create-password" class="auth-input" type="password" placeholder="Password (8+ characters)" autocomplete="new-password" minlength="8" required /> <input id="user-create-password" class="auth-input" type="password" placeholder="Password (8+ characters)" autocomplete="new-password" minlength="8" required />
<select id="user-create-role" class="auth-input"><option value="user">User</option><option value="admin">Admin</option></select> <span id="user-create-roles" class="auth-role-choices">
<label class="auth-role-choice"><input type="checkbox" value="read" checked /> Read</label>
<label class="auth-role-choice"><input type="checkbox" value="control" /> Control</label>
<label class="auth-role-choice"><input type="checkbox" value="write" /> Write</label>
<label class="auth-role-choice"><input type="checkbox" value="administrator" /> Administrator</label>
</span>
<button type="submit" class="auth-submit">Add user</button> <button type="submit" class="auth-submit">Add user</button>
</form> </form>
<div id="user-management-error" class="auth-error" style="display:none;"></div> <div id="user-management-error" class="auth-error" style="display:none;"></div>
<div id="user-list" style="margin-top:.75rem;"></div> <div id="user-list" style="margin-top:.75rem;"></div>
</div> </div>
</section> </div>
</div>
</div> </div>
<div id="tab-about" class="tab-panel" style="display:none;"> <div id="tab-about" class="tab-panel" style="display:none;">
<h2 class="section-heading">About</h2> <h2 class="section-heading">About</h2>
@@ -216,6 +216,8 @@ body {
.auth-role { margin-top: var(--space-4); color: var(--text-muted); font-size: var(--fs-sm); } .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-input { width: auto; min-width: 9rem; flex: 1 1 10rem; margin-bottom: 0; }
#user-management .auth-submit { width: auto; } #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; } #user-management button { padding: 0.55rem 0.75rem; }
.label { color: var(--text-muted); font-size: 0.9rem; margin-bottom: 6px; display: block; } .label { color: var(--text-muted); font-size: 0.9rem; margin-bottom: 6px; display: block; }
@@ -2,11 +2,11 @@
// //
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
export type AuthRole = "user" | "admin"; export type AuthRole = "read" | "control" | "write" | "administrator";
export interface AuthSession { export interface AuthSession {
authenticated: boolean; authenticated: boolean;
role?: AuthRole; roles: AuthRole[];
username?: string; username?: string;
auth_disabled?: boolean; auth_disabled?: boolean;
} }
@@ -19,14 +19,14 @@ function decodeAuthSession(value: unknown): AuthSession {
if (typeof session.authenticated !== "boolean") { if (typeof session.authenticated !== "boolean") {
throw new TypeError("The authentication response has no authenticated flag"); throw new TypeError("The authentication response has no authenticated flag");
} }
if (session.role !== undefined && session.role !== "user" && session.role !== "admin") { if (!Array.isArray(session.roles) || !session.roles.every(role =>
throw new TypeError("The authentication response has an invalid 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") { if (session.auth_disabled !== undefined && typeof session.auth_disabled !== "boolean") {
throw new TypeError("The authentication response has an invalid auth_disabled flag"); throw new TypeError("The authentication response has an invalid auth_disabled flag");
} }
const decoded: AuthSession = { authenticated: session.authenticated }; const decoded: AuthSession = { authenticated: session.authenticated, roles: session.roles as AuthRole[] };
if (session.role !== undefined) decoded.role = session.role;
if (session.username !== undefined) { if (session.username !== undefined) {
if (typeof session.username !== "string") throw new TypeError("The authentication response has an invalid username"); if (typeof session.username !== "string") throw new TypeError("The authentication response has an invalid username");
decoded.username = session.username; decoded.username = session.username;
@@ -37,7 +37,7 @@ function decodeAuthSession(value: unknown): AuthSession {
const authDisabledSession: AuthSession = { const authDisabledSession: AuthSession = {
authenticated: true, authenticated: true,
role: "admin", roles: ["read", "control", "write", "administrator"],
auth_disabled: true, auth_disabled: true,
}; };
@@ -45,11 +45,11 @@ export async function fetchAuthSession(): Promise<AuthSession> {
try { try {
const response = await fetch("/auth/session"); const response = await fetch("/auth/session");
if (response.status === 404) return authDisabledSession; if (response.status === 404) return authDisabledSession;
if (!response.ok) return { authenticated: false }; if (!response.ok) return { authenticated: false, roles: [] };
return decodeAuthSession(await response.json()); return decodeAuthSession(await response.json());
} catch (error: unknown) { } catch (error: unknown) {
console.error("Auth check failed:", error); 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<AuthSes
return decodeAuthSession(await response.json()); return decodeAuthSession(await response.json());
} }
export interface ManagedUser { username: string; role: AuthRole } export interface ManagedUser { username: string; roles: AuthRole[] }
async function userRequest(path: string, init?: RequestInit): Promise<Response> { async function userRequest(path: string, init?: RequestInit): Promise<Response> {
const response = await fetch(path, init); const response = await fetch(path, init);
@@ -83,18 +83,19 @@ export async function listUsers(): Promise<ManagedUser[]> {
if (!Array.isArray(value) || !value.every((user: unknown) => { if (!Array.isArray(value) || !value.every((user: unknown) => {
if (typeof user !== "object" || user === null) return false; if (typeof user !== "object" || user === null) return false;
const record = user as Record<string, unknown>; const record = user as Record<string, unknown>;
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"); throw new TypeError("The user list response is malformed");
} }
return value as ManagedUser[]; return value as ManagedUser[];
} }
export async function createUser(username: string, password: string, role: AuthRole): Promise<void> { export async function createUser(username: string, password: string, roles: AuthRole[]): Promise<void> {
await userRequest("/auth/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password, role }) }); 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<void> { export async function updateUser(username: string, changes: { password?: string; roles?: AuthRole[] }): Promise<void> {
await userRequest(`/auth/users/${encodeURIComponent(username)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(changes) }); await userRequest(`/auth/users/${encodeURIComponent(username)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(changes) });
} }
@@ -231,6 +231,7 @@ interface TrxState {
readonly decodeHistoryRetentionMin: number; readonly decodeHistoryRetentionMin: number;
readonly authEnabled: boolean; readonly authEnabled: boolean;
readonly authRole: AuthRole | null; readonly authRole: AuthRole | null;
readonly authRoles: readonly AuthRole[];
readonly decoderRegistry: typeof decoderRegistry; readonly decoderRegistry: typeof decoderRegistry;
readonly sseSessionId: string | null; readonly sseSessionId: string | null;
readonly primaryRds: RdsData | null; readonly primaryRds: RdsData | null;
@@ -411,9 +412,28 @@ void loadDecoderRegistry(refreshOperatorLayoutCapabilities);
// --- Authentication --- // --- Authentication ---
let authRole: AuthRole | null = null; let authRole: AuthRole | null = null;
let authRoles: AuthRole[] = [];
let authUsername: string | null = null; let authUsername: string | null = null;
let authEnabled = true; let authEnabled = true;
const ALL_AUTH_ROLES: readonly AuthRole[] = ["read", "control", "write", "administrator"];
const AUTH_ROLE_LABELS: Record<AuthRole, string> = {
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() { async function checkAuthStatus() {
return fetchAuthSession(); return fetchAuthSession();
} }
@@ -425,7 +445,7 @@ async function authLogin(username: string, password: string) {
async function authLogout() { async function authLogout() {
try { try {
await logout(); await logout();
authRole = null; setAuthRoles([]);
authUsername = null; authUsername = null;
// Disconnect and show auth gate without page reload // Disconnect and show auth gate without page reload
disconnect(); disconnect();
@@ -511,9 +531,9 @@ function updateAuthUI() {
return; return;
} }
if (authRole) { if (authRoles.length > 0) {
if (badge) badge.style.display = "block"; 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) { if (headerAuthBtn) {
headerAuthBtn.textContent = "Logout"; headerAuthBtn.textContent = "Logout";
headerAuthBtn.style.display = "block"; headerAuthBtn.style.display = "block";
@@ -529,10 +549,10 @@ function updateAuthUI() {
} }
function applyAuthRestrictions() { function applyAuthRestrictions() {
if (!authRole) return; if (authRoles.length === 0) return;
// Disable TX/PTT/frequency/mode/VFO controls for user role // 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 pttBtn = document.getElementById("ptt-btn") as HTMLButtonElement | null;
const powerBtn = document.getElementById("power-btn") as HTMLButtonElement | null; const powerBtn = document.getElementById("power-btn") as HTMLButtonElement | null;
const lockBtn = document.getElementById("lock-btn") as HTMLButtonElement | null; const lockBtn = document.getElementById("lock-btn") as HTMLButtonElement | null;
@@ -864,14 +884,15 @@ window.applyDecodeHistoryRetention = function() {
}; };
function syncTopBarAccess() { function syncTopBarAccess() {
const loggedOut = authEnabled && !authRole; const loggedOut = authEnabled && authRoles.length === 0;
const tabBar = document.getElementById("tab-bar"); const tabBar = document.getElementById("tab-bar");
const rigSwitch = document.querySelector<HTMLElement>(".header-rig-switch"); const rigSwitch = document.querySelector<HTMLElement>(".header-rig-switch");
if (tabBar) tabBar.style.display = ""; if (tabBar) tabBar.style.display = "";
document.querySelectorAll<HTMLButtonElement>(".tab-bar .tab").forEach((btn) => { document.querySelectorAll<HTMLButtonElement>(".tab-bar .tab").forEach((btn) => {
const isMain = btn.dataset.tab === "main"; 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; btn.disabled = false;
}); });
@@ -880,7 +901,7 @@ function syncTopBarAccess() {
} }
if (headerRigSwitchSelect) { 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 nextKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || "");
const rigListChanged = prevKey !== nextKey; const rigListChanged = prevKey !== nextKey;
const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "user"; const disableSwitch = lastRigIds.length === 0 || !hasAuthRole("control");
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch); populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
updateRigSubtitle(lastActiveRigId); updateRigSubtitle(lastActiveRigId);
window.trxUi?.setActiveRig(lastActiveRigId); window.trxUi?.setActiveRig(lastActiveRigId);
@@ -3349,7 +3370,7 @@ function scheduleTuneLinkSync() {
async function applyTuneLink(link: TuneLink) { async function applyTuneLink(link: TuneLink) {
const wanted = link.rig || link.mode || link.freqHz != null || link.bandwidthHz != null; const wanted = link.rig || link.mode || link.freqHz != null || link.bandwidthHz != null;
if (!wanted) return; if (!wanted) return;
if (authRole === "user") { if (!hasAuthRole("control")) {
showHint("Read-only session — link not applied", 2500); showHint("Read-only session — link not applied", 2500);
return; return;
} }
@@ -4159,7 +4180,7 @@ async function postPath(path: string, options: PostOptions = {}) {
const resp = await fetch(path, { method: "POST" }); const resp = await fetch(path, { method: "POST" });
if (authEnabled && resp.status === 401) { if (authEnabled && resp.status === 401) {
// Not authenticated - return to login // Not authenticated - return to login
authRole = null; setAuthRoles([]);
if (es) es.close(); if (es) es.close();
showAuthGate(); showAuthGate();
throw new Error("Authentication required"); throw new Error("Authentication required");
@@ -4188,8 +4209,8 @@ async function switchRigFromSelect(selectEl: HTMLSelectElement) {
showHint("No rig selected", 1500); showHint("No rig selected", 1500);
return; return;
} }
if (authRole === "user") { if (!hasAuthRole("control")) {
showHint("Admin role required", 1500); showHint("Control role required", 1500);
return; return;
} }
if (!lastRigIds.includes(selectEl.value)) { if (!lastRigIds.includes(selectEl.value)) {
@@ -4871,10 +4892,15 @@ function navigateToTab(name: TabName, options: { updateHistory?: boolean; replac
window.trxUi?.closeMobileOverlays?.(); window.trxUi?.closeMobileOverlays?.();
const leavingSatellites = _activeTab === "satellites" && name !== "satellites"; const leavingSatellites = _activeTab === "satellites" && name !== "satellites";
const { updateHistory = true, replaceHistory = false } = options; const { updateHistory = true, replaceHistory = false } = options;
if (authEnabled && !authRole && name !== "main") { if (authEnabled && authRoles.length === 0 && name !== "main") {
showAuthGate(); showAuthGate();
return; return;
} }
if (authEnabled && name === "logbook" && !hasAuthRole("write")) {
showHint("Write role required for logbook access", 2500);
navigateToTab("main", options);
return;
}
const btn = document.querySelector<HTMLElement>(`.tab-bar .tab[data-tab="${name}"]`); const btn = document.querySelector<HTMLElement>(`.tab-bar .tab[data-tab="${name}"]`);
if (!btn) return; if (!btn) return;
_activeTab = name; _activeTab = name;
@@ -5010,7 +5036,7 @@ async function initializeApp() {
authEnabled = !authStatus.auth_disabled; authEnabled = !authStatus.auth_disabled;
if (!authEnabled) { if (!authEnabled) {
authRole = "admin"; setAuthRoles(ALL_AUTH_ROLES);
hideAuthGate(); hideAuthGate();
updateAuthUI(); updateAuthUI();
connect(); connect();
@@ -5023,7 +5049,7 @@ async function initializeApp() {
if (authStatus.authenticated) { if (authStatus.authenticated) {
// User has valid session // User has valid session
authRole = authStatus.role ?? null; setAuthRoles(authStatus.roles);
authUsername = authStatus.username ?? null; authUsername = authStatus.username ?? null;
hideAuthGate(); hideAuthGate();
updateAuthUI(); updateAuthUI();
@@ -5055,12 +5081,22 @@ function initSettingsUI() {
async function refreshUserManagement() { async function refreshUserManagement() {
const section = document.getElementById("user-management"); const section = document.getElementById("user-management");
if (!section) return; const tab = document.getElementById("settings-users-tab");
section.style.display = authEnabled && authRole === "admin" ? "block" : "none"; if (!section || !tab) return;
if (section.style.display === "none") return; const canManageUsers = authEnabled && hasAuthRole("administrator");
tab.style.display = canManageUsers ? "" : "none";
if (!canManageUsers) {
const panel = document.getElementById("subtab-settings-users");
if (panel) panel.style.display = "none";
if (tab.classList.contains("active")) {
document.querySelector<HTMLButtonElement>('[data-subtab="settings-scheduler"]')?.click();
}
return;
}
const list = requiredElement("user-list"); const list = requiredElement("user-list");
try { try {
const users = await listUsers(); const users = await listUsers();
const adminCount = users.filter(user => user.roles.includes("administrator")).length;
list.replaceChildren(...users.map((user) => { list.replaceChildren(...users.map((user) => {
const row = document.createElement("div"); const row = document.createElement("div");
row.className = "sch-row"; row.className = "sch-row";
@@ -5068,27 +5104,44 @@ async function refreshUserManagement() {
const name = document.createElement("strong"); const name = document.createElement("strong");
name.textContent = user.username; name.textContent = user.username;
name.style.minWidth = "10rem"; name.style.minWidth = "10rem";
const role = document.createElement("select"); const roleInputs = ALL_AUTH_ROLES.map((value) => {
role.className = "auth-input"; const label = document.createElement("label");
for (const value of ["user", "admin"] as AuthRole[]) { label.className = "auth-role-choice";
const option = document.createElement("option"); option.value = value; option.textContent = value === "admin" ? "Admin" : "User"; option.selected = user.role === value; role.append(option); 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 password = document.createElement("input"); 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"; const save = document.createElement("button"); save.type = "button"; save.textContent = "Save";
save.addEventListener("click", async () => { 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; if (password.value) changes.password = password.value;
await runUserOperation(() => updateUser(user.username, changes)); await runUserOperation(() => updateUser(user.username, changes));
}); });
const remove = document.createElement("button"); remove.type = "button"; remove.textContent = "Remove"; remove.className = "danger"; const remove = document.createElement("button"); remove.type = "button"; remove.textContent = "Remove"; remove.className = "danger";
remove.disabled = user.username === authUsername; remove.disabled = user.username === authUsername || isOnlyAdmin;
if (isOnlyAdmin) remove.title = "The final administrator cannot be removed";
remove.addEventListener("click", async () => { remove.addEventListener("click", async () => {
if (await window.trxUi.confirm({ title: "Remove user?", message: `Remove ${user.username} and revoke their sessions?`, confirmLabel: "Remove", danger: true })) { if (await window.trxUi.confirm({ title: "Remove user?", message: `Remove ${user.username} and revoke their sessions?`, confirmLabel: "Remove", danger: true })) {
await runUserOperation(() => deleteUser(user.username)); await runUserOperation(() => deleteUser(user.username));
} }
}); });
row.append(name, role, password, save, remove); row.append(name, roles, password, save, remove);
return row; return row;
})); }));
} catch (error) { } catch (error) {
@@ -5118,10 +5171,11 @@ document.getElementById("user-create-form")?.addEventListener("submit", (event)
event.preventDefault(); event.preventDefault();
const username = requiredElement<HTMLInputElement>("user-create-username"); const username = requiredElement<HTMLInputElement>("user-create-username");
const password = requiredElement<HTMLInputElement>("user-create-password"); const password = requiredElement<HTMLInputElement>("user-create-password");
const role = requiredElement<HTMLSelectElement>("user-create-role"); const roles = Array.from(document.querySelectorAll<HTMLInputElement>("#user-create-roles input[type=checkbox]"));
void runUserOperation(async () => { void runUserOperation(async () => {
await createUser(username.value, password.value, role.value as AuthRole); await createUser(username.value, password.value, roles.filter(input => input.checked).map(input => input.value as AuthRole));
username.value = ""; password.value = ""; role.value = "user"; username.value = ""; password.value = "";
roles.forEach(input => { input.checked = input.value === "read"; });
}); });
}); });
@@ -5137,7 +5191,7 @@ requiredElement<HTMLFormElement>("auth-form").addEventListener("submit", async (
try { try {
const result = await authLogin(usernameEl.value, passwordEl.value); const result = await authLogin(usernameEl.value, passwordEl.value);
authRole = result.role ?? null; setAuthRoles(result.roles);
authUsername = result.username ?? usernameEl.value; authUsername = result.username ?? usernameEl.value;
passwordEl.value = ""; passwordEl.value = "";
hideAuthGate(); hideAuthGate();
@@ -5161,7 +5215,7 @@ requiredElement<HTMLFormElement>("auth-form").addEventListener("submit", async (
const headerAuthBtn = document.getElementById("header-auth-btn") as HTMLButtonElement | null; const headerAuthBtn = document.getElementById("header-auth-btn") as HTMLButtonElement | null;
if (headerAuthBtn) { if (headerAuthBtn) {
headerAuthBtn.addEventListener("click", async () => { headerAuthBtn.addEventListener("click", async () => {
if (authRole) { if (authRoles.length > 0) {
// Logged in - show logout confirmation // 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 })) { 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(); await authLogout();
@@ -5191,6 +5245,7 @@ Object.defineProperties(trxState, {
decodeHistoryRetentionMin: { get() { return decodeHistoryRetentionMin; } }, decodeHistoryRetentionMin: { get() { return decodeHistoryRetentionMin; } },
authEnabled: { get() { return authEnabled; } }, authEnabled: { get() { return authEnabled; } },
authRole: { get() { return authRole; } }, authRole: { get() { return authRole; } },
authRoles: { get() { return authRoles; } },
decoderRegistry: { get() { return decoderRegistry; } }, decoderRegistry: { get() { return decoderRegistry; } },
sseSessionId: { get() { return sseSessionId; } }, sseSessionId: { get() { return sseSessionId; } },
primaryRds: { get() { return primaryRds; } }, primaryRds: { get() { return primaryRds; } },
@@ -468,7 +468,9 @@ const bgdWindow = window as unknown as BackgroundBridge;
} }
function isControlRole(): boolean { function isControlRole(): boolean {
return backgroundDecodeRole === "admin" || hostState.authEnabled === false; return backgroundDecodeRole === "administrator"
|| backgroundDecodeRole === "control"
|| hostState.authEnabled === false;
} }
function showToast(msg: string, isError: boolean): void { function showToast(msg: string, isError: boolean): void {
@@ -100,7 +100,9 @@ function bmEsc(str: unknown): string {
} }
function bmCanControl() { 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. // Show/hide the Add Bookmark / Select All buttons based on the current auth role.
@@ -26,6 +26,7 @@ export interface HostState {
readonly ownerCallsign: string | null; readonly ownerCallsign: string | null;
readonly authEnabled: boolean; readonly authEnabled: boolean;
readonly authRole: string | null; readonly authRole: string | null;
readonly authRoles: readonly string[];
readonly lastActiveRigId: string | null; readonly lastActiveRigId: string | null;
readonly lastRigIds: string[]; readonly lastRigIds: string[];
readonly lastRigDisplayNames: Record<string, string>; readonly lastRigDisplayNames: Record<string, string>;
@@ -120,6 +120,12 @@ let entryGrid: string | null = null;
let qsos: Qso[] = []; let qsos: Qso[] = [];
let workedRequest = 0; let workedRequest = 0;
function canWriteLogbook(): boolean {
return !hostState.authEnabled
|| hostState.authRoles.includes("administrator")
|| hostState.authRoles.includes("write");
}
function notify(message: string, kind?: string): void { function notify(message: string, kind?: string): void {
if (bridge.trxUi.notify) bridge.trxUi.notify(message, kind ? { kind } : undefined); if (bridge.trxUi.notify) bridge.trxUi.notify(message, kind ? { kind } : undefined);
else hostCore.showHint(message, 2000); else hostCore.showHint(message, 2000);
@@ -419,6 +425,11 @@ function renderRows(): void {
row.appendChild(cell); row.appendChild(cell);
} }
const actions = document.createElement("td"); 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 // 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. // than a form: a card arrives, and the contact counts towards an award.
const confirm = document.createElement("button"); 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. */ /** Start an entry from a decode, and show the operator where it went. */
if (canWriteLogbook()) {
bridge.logContact = (seed) => { bridge.logContact = (seed) => {
bridge.navigateToTab?.("logbook"); bridge.navigateToTab?.("logbook");
void openEntry(seed).then(() => callInput?.focus()); void openEntry(seed).then(() => callInput?.focus());
}; };
} else {
if (form) form.style.display = "none";
if (importBtn) importBtn.style.display = "none";
}
renderStation(); renderStation();
if (cabrilloCallsign && !cabrilloCallsign.value) { if (cabrilloCallsign && !cabrilloCallsign.value) {
cabrilloCallsign.value = stationCallEl?.textContent?.trim() ?? ""; cabrilloCallsign.value = stationCallEl?.textContent?.trim() ?? "";
} }
syncCabrilloLink(); syncCabrilloLink();
void openEntry(); if (canWriteLogbook()) void openEntry();
void refreshLog(); void refreshLog();
@@ -356,7 +356,7 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
if (!prevBtn || !nextBtn) return; if (!prevBtn || !nextBtn) return;
const state = schedulerInterleaveState(currentConfig); const state = schedulerInterleaveState(currentConfig);
const enabled = const enabled =
schedulerRole === "admin" && (schedulerRole === "administrator" || schedulerRole === "control") &&
!!currentRigId && !!currentRigId &&
!schedulerStepPending && !schedulerStepPending &&
state.activeEntries.length > 1; state.activeEntries.length > 1;
@@ -466,7 +466,7 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
if (!panel) return; if (!panel) return;
const mode = (currentConfig && currentConfig.mode) || "disabled"; const mode = (currentConfig && currentConfig.mode) || "disabled";
const isControl = schedulerRole === "admin"; const isControl = schedulerRole === "administrator" || schedulerRole === "control";
// Mode selector // Mode selector
setSelected("scheduler-mode-select", mode); setSelected("scheduler-mode-select", mode);
@@ -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)); const source = await bundleEntry(new URL("../src/plugins/background-decode.ts", import.meta.url));
new vm.Script(source).runInContext(context); 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)); await new Promise((resolve) => setTimeout(resolve, 0));
assert.ok(requested.includes("/background-decode/rig%2Fa")); assert.ok(requested.includes("/background-decode/rig%2Fa"));
assert.ok(requested.includes("/bookmarks")); assert.ok(requested.includes("/bookmarks"));
@@ -32,7 +32,8 @@ function hostFixture(overrides = {}) {
const calls = { postPath: [], setRigFrequency: [], armOptimisticFrequency: [], applyLocalTunedFrequency: [], syncBandwidthInput: [], scheduleSpectrumDraw: 0, syncModePicker: 0 }; const calls = { postPath: [], setRigFrequency: [], armOptimisticFrequency: [], applyLocalTunedFrequency: [], syncBandwidthInput: [], scheduleSpectrumDraw: 0, syncModePicker: 0 };
const state = { const state = {
authEnabled: false, authEnabled: false,
authRole: "admin", authRole: "administrator",
authRoles: ["read", "control", "write", "administrator"],
lastActiveRigId: null, lastActiveRigId: null,
lastRigIds: [], lastRigIds: [],
lastRigDisplayNames: {}, lastRigDisplayNames: {},
@@ -157,7 +158,7 @@ test("bookmark controls follow the host authentication state", async () => {
if (!elements.has(id)) elements.set(id, new ElementFixture()); if (!elements.has(id)) elements.set(id, new ElementFixture());
return elements.get(id); return elements.get(id);
}; };
const { window } = hostFixture({ authEnabled: true, authRole: "user" }); const { window } = hostFixture({ authEnabled: true, authRole: "read", authRoles: ["read"] });
const context = vm.createContext({ const context = vm.createContext({
window, window,
document: documentFixture(element), 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"); 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(""); await window.trx.modules.bookmarks.fetch("");
assert.equal(element("bm-add-btn").style.display, ""); assert.equal(element("bm-add-btn").style.display, "");
}); });
@@ -19,7 +19,8 @@ export function createHost({ state = {}, core = {}, modules = {} } = {}) {
serverLat: null, serverLat: null,
serverLon: null, serverLon: null,
authEnabled: false, authEnabled: false,
authRole: "admin", authRole: "administrator",
authRoles: ["read", "control", "write", "administrator"],
lastActiveRigId: null, lastActiveRigId: null,
lastRigIds: [], lastRigIds: [],
lastRigDisplayNames: {}, lastRigDisplayNames: {},
@@ -76,7 +76,7 @@ test("scheduler self-initializes for the active rig when a role is already known
return elements.get(id); return elements.get(id);
}; };
const window = { const window = {
...createHost({ state: { authRole: "admin", lastActiveRigId: "sdr" } }), ...createHost({ state: { authRole: "administrator", lastActiveRigId: "sdr" } }),
trxUi: { confirm: async () => true }, trxUi: { confirm: async () => true },
}; };
const context = vm.createContext({ const context = vm.createContext({
@@ -9,6 +9,7 @@ import { readFile } from "node:fs/promises";
const indexPath = new URL("../../assets/web/index.html", import.meta.url); const indexPath = new URL("../../assets/web/index.html", import.meta.url);
const pluginLoaderPath = new URL("../src/plugin-loader.ts", import.meta.url); const pluginLoaderPath = new URL("../src/plugin-loader.ts", import.meta.url);
const mapCorePath = new URL("../src/map-core.ts", import.meta.url); const mapCorePath = new URL("../src/map-core.ts", import.meta.url);
const appPath = new URL("../src/app.ts", import.meta.url);
test("index loads one first-party application bootstrap", async () => { test("index loads one first-party application bootstrap", async () => {
const html = await readFile(indexPath, "utf8"); const html = await readFile(indexPath, "utf8");
@@ -30,6 +31,17 @@ test("startup has no remote script or stylesheet dependencies", async () => {
); );
}); });
test("administrator user management is a dedicated Settings sub-tab", async () => {
const [html, app] = await Promise.all([
readFile(indexPath, "utf8"),
readFile(appPath, "utf8"),
]);
assert.match(html, /data-subtab="settings-users"/);
assert.match(html, /id="subtab-settings-users" class="sub-tab-panel"/);
assert.match(app, /authEnabled && hasAuthRole\("administrator"\)/);
assert.match(app, /settings-users-tab/);
});
test("lazy frontend features use modules and local map symbols", async () => { test("lazy frontend features use modules and local map symbols", async () => {
const [loader, map] = await Promise.all([ const [loader, map] = await Promise.all([
readFile(pluginLoaderPath, "utf8"), readFile(pluginLoaderPath, "utf8"),
@@ -232,7 +232,7 @@ export async function startWebFixture({
}; };
const jsonRoutes = new Map([ 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], ["/decoders", DECODER_REGISTRY],
["/rigs", rigsResponse], ["/rigs", rigsResponse],
["/status", status], ["/status", status],
@@ -9,7 +9,7 @@ use std::sync::Arc;
use actix_web::Error; use actix_web::Error;
use actix_web::{delete, get, post, put, web, HttpRequest, HttpResponse}; 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; use crate::server::status;
// ============================================================================ // ============================================================================
@@ -165,7 +165,7 @@ pub async fn create_bookmark(
body: web::Json<BookmarkInput>, body: web::Json<BookmarkInput>,
auth_state: web::Data<crate::server::auth::AuthState>, auth_state: web::Data<crate::server::auth::AuthState>,
) -> Result<HttpResponse, Error> { ) -> Result<HttpResponse, Error> {
require_control(&req, &auth_state)?; require_write(&req, &auth_state)?;
let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref()); let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
if store.freq_taken(body.freq_hz, None) { if store.freq_taken(body.freq_hz, None) {
return Err(actix_web::error::ErrorConflict( return Err(actix_web::error::ErrorConflict(
@@ -201,7 +201,7 @@ pub async fn update_bookmark(
body: web::Json<BookmarkInput>, body: web::Json<BookmarkInput>,
auth_state: web::Data<crate::server::auth::AuthState>, auth_state: web::Data<crate::server::auth::AuthState>,
) -> Result<HttpResponse, Error> { ) -> Result<HttpResponse, Error> {
require_control(&req, &auth_state)?; require_write(&req, &auth_state)?;
let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref()); let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
let id = path.into_inner(); let id = path.into_inner();
if store.freq_taken(body.freq_hz, Some(&id)) { if store.freq_taken(body.freq_hz, Some(&id)) {
@@ -235,7 +235,7 @@ pub async fn delete_bookmark(
query: web::Query<BookmarkScopeQuery>, query: web::Query<BookmarkScopeQuery>,
auth_state: web::Data<crate::server::auth::AuthState>, auth_state: web::Data<crate::server::auth::AuthState>,
) -> Result<HttpResponse, Error> { ) -> Result<HttpResponse, Error> {
require_control(&req, &auth_state)?; require_write(&req, &auth_state)?;
let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref()); let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
let id = path.into_inner(); let id = path.into_inner();
if store.remove(&id) { if store.remove(&id) {
@@ -253,7 +253,7 @@ pub async fn batch_delete_bookmarks(
query: web::Query<BookmarkScopeQuery>, query: web::Query<BookmarkScopeQuery>,
auth_state: web::Data<crate::server::auth::AuthState>, auth_state: web::Data<crate::server::auth::AuthState>,
) -> Result<HttpResponse, Error> { ) -> Result<HttpResponse, Error> {
require_control(&req, &auth_state)?; require_write(&req, &auth_state)?;
let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref()); let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
let mut deleted = 0usize; let mut deleted = 0usize;
for id in &body.ids { for id in &body.ids {
@@ -272,7 +272,7 @@ pub async fn batch_move_bookmarks(
query: web::Query<BookmarkScopeQuery>, query: web::Query<BookmarkScopeQuery>,
auth_state: web::Data<crate::server::auth::AuthState>, auth_state: web::Data<crate::server::auth::AuthState>,
) -> Result<HttpResponse, Error> { ) -> Result<HttpResponse, Error> {
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 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 to_store = resolve_bookmark_store(Some(body.to.as_str()), store_map.get_ref());
let mut moved = 0usize; let mut moved = 0usize;
@@ -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::qso::{adif_mode_for_rig_mode, band_for_hz, mode_for_decoder};
use trx_logbook::{LogQuery, Logbook, Qso}; 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; use crate::server::auth::AuthState;
/// What a contact looks like on the wire. /// What a contact looks like on the wire.
@@ -225,7 +225,7 @@ pub async fn add_qso(
logbook: web::Data<Arc<Logbook>>, logbook: web::Data<Arc<Logbook>>,
auth_state: web::Data<AuthState>, auth_state: web::Data<AuthState>,
) -> Result<HttpResponse, actix_web::Error> { ) -> Result<HttpResponse, actix_web::Error> {
require_control(&req, auth_state.get_ref())?; require_write(&req, auth_state.get_ref())?;
let qso = match input.into_inner().into_qso(None) { let qso = match input.into_inner().into_qso(None) {
Ok(qso) => qso, Ok(qso) => qso,
Err(reason) => { Err(reason) => {
@@ -248,7 +248,7 @@ pub async fn edit_qso(
logbook: web::Data<Arc<Logbook>>, logbook: web::Data<Arc<Logbook>>,
auth_state: web::Data<AuthState>, auth_state: web::Data<AuthState>,
) -> Result<HttpResponse, actix_web::Error> { ) -> Result<HttpResponse, actix_web::Error> {
require_control(&req, auth_state.get_ref())?; require_write(&req, auth_state.get_ref())?;
let id = path.into_inner(); let id = path.into_inner();
let Some(existing) = book(&logbook).get(&id) else { let Some(existing) = book(&logbook).get(&id) else {
return Ok(HttpResponse::NotFound().json(serde_json::json!({ "error": "no such contact" }))); return Ok(HttpResponse::NotFound().json(serde_json::json!({ "error": "no such contact" })));
@@ -276,7 +276,7 @@ pub async fn delete_qso(
logbook: web::Data<Arc<Logbook>>, logbook: web::Data<Arc<Logbook>>,
auth_state: web::Data<AuthState>, auth_state: web::Data<AuthState>,
) -> Result<HttpResponse, actix_web::Error> { ) -> Result<HttpResponse, actix_web::Error> {
require_control(&req, auth_state.get_ref())?; require_write(&req, auth_state.get_ref())?;
match book(&logbook).delete(&path.into_inner()) { match book(&logbook).delete(&path.into_inner()) {
Ok(true) => Ok(HttpResponse::Ok().json(serde_json::json!({ "deleted": true }))), Ok(true) => Ok(HttpResponse::Ok().json(serde_json::json!({ "deleted": true }))),
Ok(false) => { Ok(false) => {
@@ -312,7 +312,7 @@ pub async fn import_adi(
logbook: web::Data<Arc<Logbook>>, logbook: web::Data<Arc<Logbook>>,
auth_state: web::Data<AuthState>, auth_state: web::Data<AuthState>,
) -> Result<HttpResponse, actix_web::Error> { ) -> Result<HttpResponse, actix_web::Error> {
require_control(&req, auth_state.get_ref())?; require_write(&req, auth_state.get_ref())?;
match book(&logbook).import_adi(&body) { match book(&logbook).import_adi(&body) {
Ok(outcome) => Ok(HttpResponse::Ok().json(outcome)), Ok(outcome) => Ok(HttpResponse::Ok().json(outcome)),
Err(err) => Ok(HttpResponse::InternalServerError() Err(err) => Ok(HttpResponse::InternalServerError()
@@ -391,16 +391,20 @@ fn gz_cache_entry(src: &[u8], name: &str) -> GzCacheEntry {
GzCacheEntry { gz, br, etag } GzCacheEntry { gz, br, etag }
} }
fn require_control( fn require_write(
req: &HttpRequest, req: &HttpRequest,
auth_state: &crate::server::auth::AuthState, auth_state: &crate::server::auth::AuthState,
) -> Result<(), actix_web::Error> { ) -> Result<(), actix_web::Error> {
if !auth_state.config.enabled { if !auth_state.config.enabled
return Ok(()); || crate::server::auth::session_grants(
} req,
match crate::server::auth::get_session_role(req, auth_state) { auth_state,
Some(crate::server::auth::AuthRole::Admin) => Ok(()), crate::server::auth::AuthRole::Write,
_ => Err(actix_web::error::ErrorForbidden("admin role required")), )
{
Ok(())
} else {
Err(actix_web::error::ErrorForbidden("write role required"))
} }
} }
@@ -960,6 +964,9 @@ mod tests {
std::path::PathBuf::from("unused-users.json"), std::path::PathBuf::from("unused-users.json"),
None, None,
None, None,
false,
"guest".to_string(),
None,
std::time::Duration::from_secs(3600), std::time::Duration::from_secs(3600),
false, false,
crate::server::auth::SameSite::Lax, crate::server::auth::SameSite::Lax,
@@ -975,6 +982,9 @@ mod tests {
directory.path().join("users.json"), directory.path().join("users.json"),
Some("admin".to_string()), Some("admin".to_string()),
Some("password123".to_string()), Some("password123".to_string()),
false,
"guest".to_string(),
None,
std::time::Duration::from_secs(3600), std::time::Duration::from_secs(3600),
false, false,
crate::server::auth::SameSite::Lax, crate::server::auth::SameSite::Lax,
@@ -1171,10 +1181,16 @@ mod tests {
let logbook = std::sync::Arc::new( let logbook = std::sync::Arc::new(
trx_logbook::Logbook::open(&dir.path().join("logbook.jsonl")).expect("open"), 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( let app = actix_test::init_service(
App::new() App::new()
.app_data(web::Data::new(logbook)) .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::add_qso)
.service(logbook::list_qsos), .service(logbook::list_qsos),
) )
@@ -1184,6 +1200,10 @@ mod tests {
&app, &app,
actix_test::TestRequest::post() actix_test::TestRequest::post()
.uri("/api/logbook") .uri("/api/logbook")
.cookie(actix_web::cookie::Cookie::new(
"trx_http_sid",
session_id.clone(),
))
.set_json(serde_json::json!({ .set_json(serde_json::json!({
"call": "SP2SJG", "freq_hz": 14_074_000_u64, "mode": "FT8", "call": "SP2SJG", "freq_hz": 14_074_000_u64, "mode": "FT8",
})) }))
@@ -1199,12 +1219,47 @@ mod tests {
&app, &app,
actix_test::TestRequest::get() actix_test::TestRequest::get()
.uri("/api/logbook") .uri("/api/logbook")
.cookie(actix_web::cookie::Cookie::new("trx_http_sid", session_id))
.to_request(), .to_request(),
) )
.await; .await;
assert_eq!(listed["total"], 0); 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 /// A contact needs a callsign; the panel is not the only thing that has to
/// insist on it. /// insist on it.
#[actix_web::test] #[actix_web::test]
@@ -19,7 +19,7 @@ use argon2::{
}; };
use futures_util::future::LocalBoxFuture; use futures_util::future::LocalBoxFuture;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::{BTreeSet, HashMap};
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, RwLock}; use std::sync::{Arc, Mutex, RwLock};
@@ -30,29 +30,51 @@ use tracing::warn;
pub type SessionId = String; pub type SessionId = String;
/// Authentication role /// 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")] #[serde(rename_all = "lowercase")]
pub enum AuthRole { pub enum AuthRole {
/// Read-only access. Read,
User, Control,
/// Full control and user-management access. Write,
Admin, Administrator,
} }
impl AuthRole { impl AuthRole {
pub fn as_str(&self) -> &'static str { pub fn as_str(&self) -> &'static str {
match self { match self {
Self::User => "user", Self::Read => "read",
Self::Admin => "admin", Self::Control => "control",
Self::Write => "write",
Self::Administrator => "administrator",
} }
} }
pub fn all() -> BTreeSet<Self> {
[Self::Read, Self::Control, Self::Write, Self::Administrator]
.into_iter()
.collect()
}
}
fn grants(roles: &BTreeSet<AuthRole>, required: AuthRole) -> bool {
roles.contains(&AuthRole::Administrator)
|| roles.contains(&required)
|| required == AuthRole::Read && roles.contains(&AuthRole::Control)
}
fn effective_roles(roles: &BTreeSet<AuthRole>) -> Vec<AuthRole> {
if roles.contains(&AuthRole::Administrator) {
AuthRole::all().into_iter().collect()
} else {
roles.iter().copied().collect()
}
} }
/// Session record stored in the session store /// Session record stored in the session store
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct SessionRecord { pub struct SessionRecord {
pub username: String, pub username: String,
pub role: AuthRole, pub roles: BTreeSet<AuthRole>,
pub issued_at: SystemTime, pub issued_at: SystemTime,
pub expires_at: SystemTime, pub expires_at: SystemTime,
pub last_seen: SystemTime, pub last_seen: SystemTime,
@@ -82,14 +104,14 @@ impl SessionStore {
} }
/// Create a new session with the given role and TTL /// 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<AuthRole>, ttl: Duration) -> SessionId {
let now = SystemTime::now(); let now = SystemTime::now();
let expires_at = now + ttl; let expires_at = now + ttl;
let session_id = Self::generate_session_id(); let session_id = Self::generate_session_id();
let record = SessionRecord { let record = SessionRecord {
username, username,
role, roles,
issued_at: now, issued_at: now,
expires_at, expires_at,
last_seen: now, last_seen: now,
@@ -184,6 +206,9 @@ pub struct AuthConfig {
pub users_file: PathBuf, pub users_file: PathBuf,
pub bootstrap_admin_username: Option<String>, pub bootstrap_admin_username: Option<String>,
pub bootstrap_admin_password: Option<String>, pub bootstrap_admin_password: Option<String>,
pub bootstrap_read_enabled: bool,
pub bootstrap_read_username: String,
pub bootstrap_read_password: Option<String>,
pub session_ttl: Duration, pub session_ttl: Duration,
pub cookie_secure: bool, pub cookie_secure: bool,
pub cookie_same_site: SameSite, pub cookie_same_site: SameSite,
@@ -191,11 +216,15 @@ pub struct AuthConfig {
impl AuthConfig { impl AuthConfig {
/// Create a new auth config with all fields /// Create a new auth config with all fields
#[allow(clippy::too_many_arguments)]
pub fn new( pub fn new(
enabled: bool, enabled: bool,
users_file: PathBuf, users_file: PathBuf,
bootstrap_admin_username: Option<String>, bootstrap_admin_username: Option<String>,
bootstrap_admin_password: Option<String>, bootstrap_admin_password: Option<String>,
bootstrap_read_enabled: bool,
bootstrap_read_username: String,
bootstrap_read_password: Option<String>,
session_ttl: Duration, session_ttl: Duration,
cookie_secure: bool, cookie_secure: bool,
cookie_same_site: SameSite, cookie_same_site: SameSite,
@@ -205,6 +234,9 @@ impl AuthConfig {
users_file, users_file,
bootstrap_admin_username, bootstrap_admin_username,
bootstrap_admin_password, bootstrap_admin_password,
bootstrap_read_enabled,
bootstrap_read_username,
bootstrap_read_password,
session_ttl, session_ttl,
cookie_secure, cookie_secure,
cookie_same_site, cookie_same_site,
@@ -212,11 +244,50 @@ impl AuthConfig {
} }
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize)]
pub struct UserRecord { pub struct UserRecord {
pub username: String, pub username: String,
pub password_hash: String, pub password_hash: String,
pub role: AuthRole, pub roles: BTreeSet<AuthRole>,
}
impl<'de> Deserialize<'de> for UserRecord {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
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<BTreeSet<AuthRole>>,
#[serde(default)]
role: Option<LegacyRole>,
}
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)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
@@ -256,11 +327,31 @@ impl UserStore {
.bootstrap_admin_password .bootstrap_admin_password
.as_deref() .as_deref()
.ok_or_else(|| "bootstrap administrator password is missing".to_string())?; .ok_or_else(|| "bootstrap administrator password is missing".to_string())?;
vec![UserRecord { let mut records = vec![UserRecord {
username: validate_username(username)?.to_string(), username: validate_username(username)?.to_string(),
password_hash: hash_password(password)?, 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(); let mut users = HashMap::new();
for record in records { for record in records {
@@ -269,7 +360,10 @@ impl UserStore {
return Err("user database contains duplicate usernames".to_string()); 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()); return Err("user database must contain at least one administrator".to_string());
} }
let store = Self { let store = Self {
@@ -282,14 +376,14 @@ impl UserStore {
Ok(store) Ok(store)
} }
fn authenticate(&self, username: &str, password: &str) -> Option<AuthRole> { fn authenticate(&self, username: &str, password: &str) -> Option<BTreeSet<AuthRole>> {
let users = self.users.read().unwrap_or_else(|e| e.into_inner()); let users = self.users.read().unwrap_or_else(|e| e.into_inner());
let record = users.get(username)?; let record = users.get(username)?;
let parsed = PasswordHash::new(&record.password_hash).ok()?; let parsed = PasswordHash::new(&record.password_hash).ok()?;
Argon2::default() Argon2::default()
.verify_password(password.as_bytes(), &parsed) .verify_password(password.as_bytes(), &parsed)
.ok()?; .ok()?;
Some(record.role) Some(record.roles.clone())
} }
fn list(&self) -> Vec<ManagedUser> { fn list(&self) -> Vec<ManagedUser> {
@@ -298,20 +392,20 @@ impl UserStore {
.values() .values()
.map(|u| ManagedUser { .map(|u| ManagedUser {
username: u.username.clone(), username: u.username.clone(),
role: u.role, roles: u.roles.clone(),
}) })
.collect(); .collect();
result.sort_by(|a, b| a.username.cmp(&b.username)); result.sort_by(|a, b| a.username.cmp(&b.username));
result result
} }
fn add(&self, username: &str, password: &str, role: AuthRole) -> Result<(), String> { fn add(&self, username: &str, password: &str, roles: BTreeSet<AuthRole>) -> Result<(), String> {
let username = validate_username(username)?.to_string(); let username = validate_username(username)?.to_string();
validate_password(password)?; validate_password(password)?;
let record = UserRecord { let record = UserRecord {
username: username.clone(), username: username.clone(),
password_hash: hash_password(password)?, password_hash: hash_password(password)?,
role, roles: validate_roles(roles)?,
}; };
let mut users = self.users.write().unwrap_or_else(|e| e.into_inner()); let mut users = self.users.write().unwrap_or_else(|e| e.into_inner());
if users.contains_key(&username) { if users.contains_key(&username) {
@@ -329,7 +423,7 @@ impl UserStore {
&self, &self,
username: &str, username: &str,
password: Option<&str>, password: Option<&str>,
role: Option<AuthRole>, roles: Option<BTreeSet<AuthRole>>,
) -> Result<(), String> { ) -> Result<(), String> {
if let Some(value) = password { if let Some(value) = password {
validate_password(value)?; validate_password(value)?;
@@ -340,9 +434,15 @@ impl UserStore {
.get(username) .get(username)
.cloned() .cloned()
.ok_or_else(|| "user not found".to_string())?; .ok_or_else(|| "user not found".to_string())?;
if old.role == AuthRole::Admin if old.roles.contains(&AuthRole::Administrator)
&& role == Some(AuthRole::User) && roles
&& users.values().filter(|u| u.role == AuthRole::Admin).count() == 1 .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()); return Err("cannot demote the last administrator".to_string());
} }
@@ -350,8 +450,8 @@ impl UserStore {
if let Some(hash) = new_hash { if let Some(hash) = new_hash {
record.password_hash = hash; record.password_hash = hash;
} }
if let Some(value) = role { if let Some(value) = roles {
record.role = value; record.roles = validate_roles(value)?;
} }
if let Err(error) = persist_users(&self.path, &users) { if let Err(error) = persist_users(&self.path, &users) {
users.insert(username.to_string(), old); users.insert(username.to_string(), old);
@@ -366,8 +466,12 @@ impl UserStore {
.get(username) .get(username)
.cloned() .cloned()
.ok_or_else(|| "user not found".to_string())?; .ok_or_else(|| "user not found".to_string())?;
if old.role == AuthRole::Admin if old.roles.contains(&AuthRole::Administrator)
&& users.values().filter(|u| u.role == AuthRole::Admin).count() == 1 && users
.values()
.filter(|u| u.roles.contains(&AuthRole::Administrator))
.count()
== 1
{ {
return Err("cannot remove the last administrator".to_string()); return Err("cannot remove the last administrator".to_string());
} }
@@ -400,6 +504,10 @@ fn persist_users(path: &Path, users: &HashMap<String, UserRecord>) -> Result<(),
fn hash_password(password: &str) -> Result<String, String> { fn hash_password(password: &str) -> Result<String, String> {
validate_password(password)?; validate_password(password)?;
hash_password_value(password)
}
fn hash_password_value(password: &str) -> Result<String, String> {
Argon2::default() Argon2::default()
.hash_password(password.as_bytes(), &SaltString::generate(&mut OsRng)) .hash_password(password.as_bytes(), &SaltString::generate(&mut OsRng))
.map(|hash| hash.to_string()) .map(|hash| hash.to_string())
@@ -420,11 +528,18 @@ fn validate_username(username: &str) -> Result<&str, String> {
fn validate_password(password: &str) -> Result<(), String> { fn validate_password(password: &str) -> Result<(), String> {
if password.len() < 8 { 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(()) Ok(())
} }
fn validate_roles(roles: BTreeSet<AuthRole>) -> Result<BTreeSet<AuthRole>, 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. /// Simple per-IP rate limiter for login attempts.
/// ///
/// Tracks failed attempts per IP and enforces a cooldown window after /// Tracks failed attempts per IP and enforces a cooldown window after
@@ -521,7 +636,7 @@ pub struct LoginRequest {
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct SessionStatus { pub struct SessionStatus {
pub authenticated: bool, pub authenticated: bool,
pub role: Option<String>, pub roles: Vec<AuthRole>,
pub username: Option<String>, pub username: Option<String>,
pub auth_disabled: bool, pub auth_disabled: bool,
} }
@@ -530,27 +645,27 @@ pub struct SessionStatus {
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct LoginResponse { pub struct LoginResponse {
pub authenticated: bool, pub authenticated: bool,
pub role: String, pub roles: Vec<AuthRole>,
pub username: String, pub username: String,
} }
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct ManagedUser { pub struct ManagedUser {
pub username: String, pub username: String,
pub role: AuthRole, pub roles: BTreeSet<AuthRole>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct CreateUserRequest { pub struct CreateUserRequest {
pub username: String, pub username: String,
pub password: String, pub password: String,
pub role: AuthRole, pub roles: BTreeSet<AuthRole>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct UpdateUserRequest { pub struct UpdateUserRequest {
pub password: Option<String>, pub password: Option<String>,
pub role: Option<AuthRole>, pub roles: Option<BTreeSet<AuthRole>>,
} }
/// Extract session from cookie /// Extract session from cookie
@@ -560,10 +675,14 @@ fn extract_session_id(req: &HttpRequest) -> Option<SessionId> {
} }
/// Get session from request, return role if valid /// Get session from request, return role if valid
pub fn get_session_role(req: &HttpRequest, auth_state: &AuthState) -> Option<AuthRole> { pub fn get_session_roles(req: &HttpRequest, auth_state: &AuthState) -> Option<BTreeSet<AuthRole>> {
let session_id = extract_session_id(req)?; let session_id = extract_session_id(req)?;
let record = auth_state.store.get(&session_id)?; 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<SessionRecord, HttpResponse> { fn require_admin(req: &HttpRequest, auth_state: &AuthState) -> Result<SessionRecord, HttpResponse> {
@@ -573,7 +692,7 @@ fn require_admin(req: &HttpRequest, auth_state: &AuthState) -> Result<SessionRec
HttpResponse::Unauthorized() HttpResponse::Unauthorized()
.json(serde_json::json!({"error":"authentication required"})) .json(serde_json::json!({"error":"authentication required"}))
})?; })?;
if session.role != AuthRole::Admin { if !grants(&session.roles, AuthRole::Administrator) {
return Err(HttpResponse::Forbidden() return Err(HttpResponse::Forbidden()
.json(serde_json::json!({"error":"administrator role required"}))); .json(serde_json::json!({"error":"administrator role required"})));
} }
@@ -606,7 +725,7 @@ pub async fn login(
}))); })));
} }
let role = match auth_state let roles = match auth_state
.users .users
.authenticate(&body.username, &body.password) .authenticate(&body.username, &body.password)
{ {
@@ -622,10 +741,11 @@ pub async fn login(
auth_state.rate_limiter.reset(&peer_ip); auth_state.rate_limiter.reset(&peer_ip);
// Create session // Create session
let session_id = let session_id = auth_state.store.create(
auth_state body.username.clone(),
.store roles.clone(),
.create(body.username.clone(), role, auth_state.config.session_ttl); auth_state.config.session_ttl,
);
let mut cookie = Cookie::new("trx_http_sid", session_id); let mut cookie = Cookie::new("trx_http_sid", session_id);
cookie.set_path("/"); cookie.set_path("/");
@@ -645,7 +765,7 @@ pub async fn login(
Ok(HttpResponse::Ok().cookie(cookie).json(LoginResponse { Ok(HttpResponse::Ok().cookie(cookie).json(LoginResponse {
authenticated: true, authenticated: true,
role: role.as_str().to_string(), roles: effective_roles(&roles),
username: body.username.clone(), username: body.username.clone(),
})) }))
} }
@@ -686,7 +806,7 @@ pub async fn session_status(
if !auth_state.config.enabled { if !auth_state.config.enabled {
return Ok(HttpResponse::Ok().json(SessionStatus { return Ok(HttpResponse::Ok().json(SessionStatus {
authenticated: true, authenticated: true,
role: Some("admin".to_string()), roles: effective_roles(&AuthRole::all()),
username: None, username: None,
auth_disabled: true, auth_disabled: true,
})); }));
@@ -697,7 +817,7 @@ pub async fn session_status(
// User has valid session // User has valid session
return Ok(HttpResponse::Ok().json(SessionStatus { return Ok(HttpResponse::Ok().json(SessionStatus {
authenticated: true, authenticated: true,
role: Some(session_record.role.as_str().to_string()), roles: effective_roles(&session_record.roles),
username: Some(session_record.username), username: Some(session_record.username),
auth_disabled: false, auth_disabled: false,
})); }));
@@ -706,7 +826,7 @@ pub async fn session_status(
// Auth required but no valid session // Auth required but no valid session
Ok(HttpResponse::Ok().json(SessionStatus { Ok(HttpResponse::Ok().json(SessionStatus {
authenticated: false, authenticated: false,
role: None, roles: Vec::new(),
username: None, username: None,
auth_disabled: false, auth_disabled: false,
})) }))
@@ -733,11 +853,11 @@ pub async fn create_user(
} }
match auth_state match auth_state
.users .users
.add(&body.username, &body.password, body.role) .add(&body.username, &body.password, body.roles.clone())
{ {
Ok(()) => HttpResponse::Created().json(ManagedUser { Ok(()) => HttpResponse::Created().json(ManagedUser {
username: body.username.clone(), username: body.username.clone(),
role: body.role, roles: body.roles.clone(),
}), }),
Err(error) => HttpResponse::BadRequest().json(serde_json::json!({"error": error})), 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) { if let Err(response) = require_admin(&req, &auth_state) {
return response; return response;
} }
if body.password.is_none() && body.role.is_none() { if body.password.is_none() && body.roles.is_none() {
return HttpResponse::BadRequest() return HttpResponse::BadRequest()
.json(serde_json::json!({"error":"password or role is required"})); .json(serde_json::json!({"error":"password or role is required"}));
} }
match auth_state match auth_state
.users .users
.update(&username, body.password.as_deref(), body.role) .update(&username, body.password.as_deref(), body.roles.clone())
{ {
Ok(()) => { Ok(()) => {
auth_state.store.remove_user(&username); auth_state.store.remove_user(&username);
@@ -803,9 +923,13 @@ pub async fn delete_user(
enum RouteAccess { enum RouteAccess {
/// Publicly accessible (no auth required) /// Publicly accessible (no auth required)
Public, Public,
/// Read-only (user or admin role required) /// Read-only resources (Read, Control, or Administrator required)
Read, 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, Control,
} }
@@ -857,7 +981,6 @@ impl RouteAccess {
|| path == "/spectrum" || path == "/spectrum"
|| path == "/meter" || path == "/meter"
|| path == "/audio" || path == "/audio"
|| path == "/bookmarks"
|| path.starts_with("/status?") || path.starts_with("/status?")
|| path.starts_with("/rigs?") || path.starts_with("/rigs?")
|| path.starts_with("/events?") || path.starts_with("/events?")
@@ -866,8 +989,6 @@ impl RouteAccess {
|| path.starts_with("/spectrum?") || path.starts_with("/spectrum?")
|| path.starts_with("/meter?") || path.starts_with("/meter?")
|| path.starts_with("/audio?") || path.starts_with("/audio?")
|| path.starts_with("/bookmarks?")
|| path.starts_with("/bookmarks/")
|| path.starts_with("/scheduler/") || path.starts_with("/scheduler/")
|| path.starts_with("/scheduler-control") || path.starts_with("/scheduler-control")
|| path.starts_with("/channels/") || path.starts_with("/channels/")
@@ -875,15 +996,30 @@ impl RouteAccess {
return Self::Read; 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 // All other routes require control
Self::Control Self::Control
} }
fn allows(&self, role: Option<AuthRole>) -> bool { fn allows(&self, roles: Option<&BTreeSet<AuthRole>>) -> bool {
match self { match self {
Self::Public => true, Self::Public => true,
Self::Read => role.is_some(), Self::Read => roles.is_some_and(|roles| grants(roles, AuthRole::Read)),
Self::Control => matches!(role, Some(AuthRole::Admin)), 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 // 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 // Access denied
return Box::pin(async move { return Box::pin(async move {
if role.is_some() { if roles.is_some() {
// Has session but insufficient permissions - 403 Forbidden // Has session but insufficient permissions - 403 Forbidden
Err(actix_web::error::ErrorForbidden( Err(actix_web::error::ErrorForbidden(
"Insufficient permissions".to_string(), "Insufficient permissions".to_string(),
@@ -984,6 +1120,25 @@ mod tests {
use super::*; use super::*;
use actix_web::{test as aw_test, App}; use actix_web::{test as aw_test, App};
fn roles(values: &[AuthRole]) -> BTreeSet<AuthRole> {
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] #[test]
fn test_route_access_public_paths() { fn test_route_access_public_paths() {
assert_eq!(RouteAccess::from_path("/"), RouteAccess::Public); 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("/spectrum"), RouteAccess::Read);
assert_eq!(RouteAccess::from_path("/meter"), RouteAccess::Read); assert_eq!(RouteAccess::from_path("/meter"), RouteAccess::Read);
assert_eq!(RouteAccess::from_path("/audio"), 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] #[test]
@@ -1030,30 +1187,45 @@ mod tests {
#[test] #[test]
fn test_route_access_allows() { 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(None));
assert!(RouteAccess::Public.allows(Some(AuthRole::User))); assert!(RouteAccess::Public.allows(Some(&read)));
assert!(RouteAccess::Public.allows(Some(AuthRole::Admin))); assert!(RouteAccess::Public.allows(Some(&administrator)));
assert!(!RouteAccess::Read.allows(None)); assert!(!RouteAccess::Read.allows(None));
assert!(RouteAccess::Read.allows(Some(AuthRole::User))); assert!(RouteAccess::Read.allows(Some(&read)));
assert!(RouteAccess::Read.allows(Some(AuthRole::Admin))); 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(None));
assert!(!RouteAccess::Control.allows(Some(AuthRole::User))); assert!(!RouteAccess::Control.allows(Some(&read)));
assert!(RouteAccess::Control.allows(Some(AuthRole::Admin))); assert!(RouteAccess::Control.allows(Some(&control)));
assert!(RouteAccess::Control.allows(Some(&administrator)));
} }
#[test] #[test]
fn test_session_store_create_and_get() { fn test_session_store_create_and_get() {
let store = SessionStore::new(); let store = SessionStore::new();
let ttl = Duration::from_secs(3600); 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); let record = store.get(&session_id);
assert!(record.is_some()); assert!(record.is_some());
let record = record.unwrap(); let record = record.unwrap();
assert_eq!(record.username, "alice"); assert_eq!(record.username, "alice");
assert_eq!(record.role, AuthRole::User); assert_eq!(record.roles, roles(&[AuthRole::Read]));
assert!(!record.is_expired()); assert!(!record.is_expired());
} }
@@ -1061,7 +1233,7 @@ mod tests {
fn test_session_store_remove() { fn test_session_store_remove() {
let store = SessionStore::new(); let store = SessionStore::new();
let ttl = Duration::from_secs(3600); 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); store.remove(&session_id);
assert!(store.get(&session_id).is_none()); assert!(store.get(&session_id).is_none());
@@ -1070,35 +1242,33 @@ mod tests {
#[test] #[test]
fn user_store_bootstraps_and_manages_users() { fn user_store_bootstraps_and_manages_users() {
let directory = tempfile::tempdir().unwrap(); let directory = tempfile::tempdir().unwrap();
let config = AuthConfig::new( let config = test_auth_config(directory.path().join("users.json"));
true,
directory.path().join("users.json"),
Some("admin".to_string()),
Some("password123".to_string()),
Duration::from_secs(3600),
false,
SameSite::Lax,
);
let users = UserStore::open(&config).unwrap(); let users = UserStore::open(&config).unwrap();
assert_eq!( assert_eq!(
users.authenticate("admin", "password123"), users.authenticate("admin", "password123"),
Some(AuthRole::Admin) Some(AuthRole::all())
); );
assert_eq!(users.authenticate("admin", "wrong"), None); assert_eq!(users.authenticate("admin", "wrong"), None);
users.add("alice", "password456", AuthRole::User).unwrap();
assert_eq!( assert_eq!(
users.authenticate("alice", "password456"), users.authenticate("guest", "guest"),
Some(AuthRole::User) Some(roles(&[AuthRole::Read]))
); );
users 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(); .unwrap();
assert_eq!( assert_eq!(
users.authenticate("alice", "password789"), users.authenticate("alice", "password789"),
Some(AuthRole::Admin) Some(AuthRole::all())
); );
users.remove("admin").unwrap(); users.remove("admin").unwrap();
assert_eq!(users.list().len(), 1); assert_eq!(users.list().len(), 2);
drop(users); drop(users);
let mut reopen_config = config.clone(); let mut reopen_config = config.clone();
@@ -1107,7 +1277,7 @@ mod tests {
let reopened = UserStore::open(&reopen_config).unwrap(); let reopened = UserStore::open(&reopen_config).unwrap();
assert_eq!( assert_eq!(
reopened.authenticate("alice", "password789"), reopened.authenticate("alice", "password789"),
Some(AuthRole::Admin) Some(AuthRole::all())
); );
let database = fs::read_to_string(&reopen_config.users_file).unwrap(); let database = fs::read_to_string(&reopen_config.users_file).unwrap();
assert!(database.contains("$argon2")); assert!(database.contains("$argon2"));
@@ -1117,32 +1287,53 @@ mod tests {
#[test] #[test]
fn user_store_protects_last_admin() { fn user_store_protects_last_admin() {
let directory = tempfile::tempdir().unwrap(); let directory = tempfile::tempdir().unwrap();
let config = AuthConfig::new( let config = test_auth_config(directory.path().join("users.json"));
true,
directory.path().join("users.json"),
Some("admin".to_string()),
Some("password123".to_string()),
Duration::from_secs(3600),
false,
SameSite::Lax,
);
let users = UserStore::open(&config).unwrap(); let users = UserStore::open(&config).unwrap();
assert!(users.remove("admin").is_err()); 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] #[actix_web::test]
async fn admin_endpoints_manage_multiple_users_and_reject_regular_users() { async fn admin_endpoints_manage_multiple_users_and_reject_regular_users() {
let directory = tempfile::tempdir().unwrap(); let directory = tempfile::tempdir().unwrap();
let config = AuthConfig::new( let config = test_auth_config(directory.path().join("users.json"));
true,
directory.path().join("users.json"),
Some("admin".to_string()),
Some("password123".to_string()),
Duration::from_secs(3600),
false,
SameSite::Lax,
);
let state = web::Data::new(AuthState::new(config).unwrap()); let state = web::Data::new(AuthState::new(config).unwrap());
let app = aw_test::init_service( let app = aw_test::init_service(
App::new() App::new()
@@ -1167,7 +1358,7 @@ mod tests {
.uri("/auth/users") .uri("/auth/users")
.insert_header((actix_web::http::header::COOKIE, admin_cookie.clone())) .insert_header((actix_web::http::header::COOKIE, admin_cookie.clone()))
.set_json( .set_json(
serde_json::json!({"username":"alice","password":"password456","role":"user"}), serde_json::json!({"username":"alice","password":"password456","roles":["read"]}),
) )
.to_request(); .to_request();
assert_eq!( assert_eq!(
@@ -1193,7 +1384,7 @@ mod tests {
let request = aw_test::TestRequest::patch() let request = aw_test::TestRequest::patch()
.uri("/auth/users/alice") .uri("/auth/users/alice")
.insert_header((actix_web::http::header::COOKIE, admin_cookie.clone())) .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(); .to_request();
assert_eq!( assert_eq!(
aw_test::call_service(&app, request).await.status(), aw_test::call_service(&app, request).await.status(),
@@ -1201,11 +1392,31 @@ mod tests {
); );
let request = aw_test::TestRequest::delete() let request = aw_test::TestRequest::delete()
.uri("/auth/users/alice") .uri("/auth/users/alice")
.insert_header((actix_web::http::header::COOKIE, admin_cookie)) .insert_header((actix_web::http::header::COOKIE, admin_cookie.clone()))
.to_request(); .to_request();
assert_eq!( assert_eq!(
aw_test::call_service(&app, request).await.status(), aw_test::call_service(&app, request).await.status(),
actix_web::http::StatusCode::OK actix_web::http::StatusCode::OK
); );
// Once only one administrator remains, neither API operation may
// leave the database without an administrator.
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!({"roles":["read"]}))
.to_request();
assert_eq!(
aw_test::call_service(&app, request).await.status(),
actix_web::http::StatusCode::BAD_REQUEST
);
let request = aw_test::TestRequest::delete()
.uri("/auth/users/admin")
.insert_header((actix_web::http::header::COOKIE, admin_cookie))
.to_request();
assert_eq!(
aw_test::call_service(&app, request).await.status(),
actix_web::http::StatusCode::BAD_REQUEST
);
} }
} }
@@ -257,6 +257,9 @@ fn build_server(
context.http_auth.users_file.clone().into(), context.http_auth.users_file.clone().into(),
context.http_auth.bootstrap_admin_username.clone(), context.http_auth.bootstrap_admin_username.clone(),
context.http_auth.bootstrap_admin_password.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), Duration::from_secs(context.http_auth.session_ttl_secs),
context.http_auth.cookie_secure, context.http_auth.cookie_secure,
same_site, same_site,
+45
View File
@@ -277,6 +277,12 @@ pub struct HttpAuthConfig {
/// Read the bootstrap administrator password from this file instead. /// Read the bootstrap administrator password from this file instead.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub bootstrap_admin_password_file: Option<String>, pub bootstrap_admin_password_file: Option<String>,
/// 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<String>,
/// Session time-to-live in minutes /// Session time-to-live in minutes
pub session_ttl_min: u64, pub session_ttl_min: u64,
/// Set Secure flag on session cookie (required for HTTPS) /// Set Secure flag on session cookie (required for HTTPS)
@@ -293,6 +299,9 @@ impl Default for HttpAuthConfig {
bootstrap_admin_username: None, bootstrap_admin_username: None,
bootstrap_admin_password: None, bootstrap_admin_password: None,
bootstrap_admin_password_file: 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, session_ttl_min: 480,
cookie_secure: false, cookie_secure: false,
cookie_same_site: CookieSameSite::Lax, cookie_same_site: CookieSameSite::Lax,
@@ -830,6 +839,15 @@ impl ClientConfig {
.bootstrap_admin_password_file .bootstrap_admin_password_file
.is_none() .is_none()
&& self.frontends.http.auth.bootstrap_admin_password.is_some() && 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_file.is_none()
&& !self.frontends.http_json.auth.tokens.is_empty() && !self.frontends.http_json.auth.tokens.is_empty()
} }
@@ -903,6 +921,9 @@ impl ClientConfig {
bootstrap_admin_username: Some("admin".to_string()), bootstrap_admin_username: Some("admin".to_string()),
bootstrap_admin_password: Some("change-this-password".to_string()), bootstrap_admin_password: Some("change-this-password".to_string()),
bootstrap_admin_password_file: 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, session_ttl_min: 480,
cookie_secure: false, cookie_secure: false,
cookie_same_site: CookieSameSite::Lax, cookie_same_site: CookieSameSite::Lax,
@@ -961,6 +982,15 @@ fn validate_http_auth(auth: &HttpAuthConfig) -> Result<(), String> {
.to_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 // Session TTL must be > 0
if auth.session_ttl_min == 0 { if auth.session_ttl_min == 0 {
@@ -1268,6 +1298,18 @@ home-hf = "audio://10.0.0.5:4600"
assert!(config.validate().is_err()); 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] #[test]
fn test_validate_auth_disabled_ignores_user_settings() { fn test_validate_auth_disabled_ignores_user_settings() {
let mut config = ClientConfig::default(); 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_eq!(auth.users_file, "trx-http-users.json");
assert!(auth.bootstrap_admin_username.is_none()); assert!(auth.bootstrap_admin_username.is_none());
assert!(auth.bootstrap_admin_password.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_eq!(auth.session_ttl_min, 480);
assert!(!auth.cookie_secure); assert!(!auth.cookie_secure);
assert!(matches!(auth.cookie_same_site, CookieSameSite::Lax)); assert!(matches!(auth.cookie_same_site, CookieSameSite::Lax));
+3
View File
@@ -210,6 +210,9 @@ enabled = false
users_file = "trx-http-users.json" users_file = "trx-http-users.json"
bootstrap_admin_username = "admin" bootstrap_admin_username = "admin"
bootstrap_admin_password = "change-this-password" bootstrap_admin_password = "change-this-password"
bootstrap_read_enabled = true
bootstrap_read_username = "guest"
bootstrap_read_password = "guest"
session_ttl_min = 480 session_ttl_min = 480
cookie_secure = false cookie_secure = false
cookie_same_site = "Lax" cookie_same_site = "Lax"