Compare commits

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