Add restricted Guest and Transmit roles #64
@@ -926,7 +926,9 @@ main
|
|||||||
### HTTP Frontend Auth
|
### HTTP Frontend Auth
|
||||||
|
|
||||||
- Optional Argon2id-backed managed accounts with HttpOnly session cookies
|
- Optional Argon2id-backed managed accounts with HttpOnly session cookies
|
||||||
- Composable Read, Control, Write, and Administrator roles, with policy shared by middleware and handlers
|
- An exclusive Guest role plus composable Read, Control, Transmit, Write, and Administrator roles, with policy shared by middleware and handlers
|
||||||
|
- Guest sessions receive read-only station access but no account-control endpoints or panels
|
||||||
|
- Transmit separately gates PTT, TX audio frames, and TX power-limit changes
|
||||||
- Atomic JSON persistence with migration from the legacy single-role schema
|
- Atomic JSON persistence with migration from the legacy single-role schema
|
||||||
- Account enable/disable, administrator CRUD, self-service password changes, and session revocation on security changes
|
- Account enable/disable, administrator CRUD, self-service password changes, and session revocation on security changes
|
||||||
- A database invariant always preserves at least one enabled administrator
|
- A database invariant always preserves at least one enabled administrator
|
||||||
|
|||||||
@@ -541,8 +541,8 @@ setting, which is also what LoTW's station locations expect.
|
|||||||
rotate operators through one station callsign, which is why contest loggers record it per QSO.
|
rotate operators through one station callsign, which is why contest loggers record it per QSO.
|
||||||
It is stored per QSO, defaulted from the configured callsign so a single operator never touches
|
It is stored per QSO, defaulted from the configured callsign so a single operator never touches
|
||||||
it, and changed on the station line at the top of the panel where it sticks for the session.
|
it, and changed on the station line at the top of the panel where it sticks for the session.
|
||||||
It cannot be taken from the session's identity: the auth roles are `admin` and `user`, with no
|
It cannot be inferred from the session's identity: an account username need not be an operator
|
||||||
notion of who is logged in.
|
callsign, and operational accounts may be shared.
|
||||||
|
|
||||||
**Server clock, and the log says so.** The server is the machine at the radio; the browser may
|
**Server clock, and the log says so.** The server is the machine at the radio; the browser may
|
||||||
be on a phone in another timezone with a clock nobody has checked. QSO times are UTC from the
|
be on a phone in another timezone with a clock nobody has checked. QSO times are UTC from the
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
Accounts may combine **Read**, **Control**, **Write**, and **Administrator** roles.
|
**Guest** provides read-only station access and is exclusive. Non-Guest accounts
|
||||||
|
may combine **Read**, **Control**, **Transmit**, **Write**, and **Administrator** roles.
|
||||||
Administrator implies all permissions.
|
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.
|
||||||
@@ -336,13 +337,14 @@ Logo and favicon are embedded at compile time via `include_bytes!`. The logo ima
|
|||||||
|
|
||||||
### 7.1 Route Access Classification
|
### 7.1 Route Access Classification
|
||||||
|
|
||||||
Routes are classified into three tiers:
|
Routes are classified into access 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` | Read, Control, or Administrator role |
|
| **Read** | `/status`, `/events`, `/audio`, `/decode`, `/spectrum`, `/bookmarks` | Guest, Read, Control, Transmit, or Administrator role |
|
||||||
| **Control** | `/set_freq`, `/set_mode`, `/set_ptt`, `/toggle_power`, radio-control POST routes | Control or Administrator role |
|
| **Control** | `/set_freq`, `/set_mode`, `/toggle_power`, receive-side radio-control POST routes | Control or Administrator role |
|
||||||
|
| **Transmit** | `/set_ptt`, `/set_tx_limit`, outbound `/audio` frames | Transmit or Administrator role |
|
||||||
| **Write** | Logbook access and bookmark mutations | Write or Administrator role |
|
| **Write** | Logbook access and bookmark mutations | Write or Administrator role |
|
||||||
|
|
||||||
### 7.2 Session Management
|
### 7.2 Session Management
|
||||||
@@ -354,8 +356,9 @@ Routes are classified into three tiers:
|
|||||||
|
|
||||||
### 7.3 User Management
|
### 7.3 User Management
|
||||||
|
|
||||||
Every authenticated account gets a Settings > Account tab for changing its own
|
Every authenticated non-Guest account gets a Settings > Account tab for changing
|
||||||
password. Only administrators get Settings > Users, where accounts can be
|
its own password. Guest sees neither Account nor Users and both account-control
|
||||||
|
APIs deny Guest sessions. Only administrators get Settings > Users, where accounts can be
|
||||||
created, enabled/disabled, assigned multiple roles, given a new password, or
|
created, enabled/disabled, assigned multiple roles, given a new password, or
|
||||||
removed. The final enabled administrator cannot be disabled, removed, or
|
removed. The final enabled administrator cannot be disabled, removed, or
|
||||||
demoted. Account security changes revoke every active session for that account.
|
demoted. Account security changes revoke every active session for that account.
|
||||||
|
|||||||
+15
-12
@@ -354,9 +354,9 @@ A name in any of those maps that no remote answers to is a config error.
|
|||||||
| `bootstrap_admin_username` | string | — | First administrator, used only if the database is absent |
|
| `bootstrap_admin_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_enabled` | bool | `true` | Create the default Guest account when the database is absent |
|
||||||
| `bootstrap_read_username` | string | `"guest"` | Initial read-only username |
|
| `bootstrap_read_username` | string | `"guest"` | Initial Guest username |
|
||||||
| `bootstrap_read_password` | string | `"guest"` | Initial read-only password |
|
| `bootstrap_read_password` | string | `"guest"` | Initial Guest 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` |
|
||||||
@@ -589,11 +589,12 @@ 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 independent
|
The HTTP frontend supports an optional user/password ACL:
|
||||||
roles. One account may have any combination:
|
|
||||||
|
|
||||||
|
- **Guest** — read-only station access with no Account or Users controls; Guest cannot be combined with another role
|
||||||
- **Read** — monitoring, audio, decode streams, and bookmark reads
|
- **Read** — monitoring, audio, decode streams, and bookmark reads
|
||||||
- **Control** — full radio receive/transmit controls
|
- **Control** — tuning, mode, power, and receive-side radio controls
|
||||||
|
- **Transmit** — PTT, transmitted audio, and TX power-limit controls
|
||||||
- **Write** — logbook access and bookmark changes
|
- **Write** — logbook access and bookmark changes
|
||||||
- **Administrator** — user management and all other permissions
|
- **Administrator** — user management and all other permissions
|
||||||
|
|
||||||
@@ -615,7 +616,7 @@ 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 (with every role), the default `guest`/`guest` Read
|
the initial administrator (with every non-Guest role), the default `guest`/`guest` Guest
|
||||||
account, and the Argon2id-hashed user database. Change or disable the guest
|
account, and the Argon2id-hashed user database. Change or disable the guest
|
||||||
credentials in configuration before first startup on an exposed deployment.
|
credentials in configuration before first startup on an exposed deployment.
|
||||||
|
|
||||||
@@ -625,8 +626,9 @@ credentials in configuration before first startup on an exposed deployment.
|
|||||||
- 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.
|
||||||
- Roles are independent; for example, an account may have Read and Write without Control.
|
- Non-Guest roles are independent; for example, an account may have Read and Write without Control.
|
||||||
- Every signed-in user can change their own password in Settings > Account. This signs out all of their sessions.
|
- Guest accounts have no account-control panels and cannot call account-control endpoints.
|
||||||
|
- Every non-Guest signed-in user can change their own password in Settings > Account. This signs out all of their sessions.
|
||||||
- Administrators can add, enable/disable, or remove users and change roles/passwords in Settings > Users.
|
- Administrators can add, enable/disable, or remove users and change roles/passwords in Settings > Users.
|
||||||
- At least one enabled administrator must always remain and cannot be disabled, removed, or demoted.
|
- At least one enabled administrator must always remain and cannot be disabled, removed, or demoted.
|
||||||
- Disabling/removing an account or changing its password/roles revokes all of its sessions.
|
- Disabling/removing an account or changing its password/roles revokes all of its sessions.
|
||||||
@@ -639,12 +641,13 @@ credentials in configuration before first startup on an exposed deployment.
|
|||||||
| `/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/roles |
|
| `/auth/session` | GET | Check current session/roles |
|
||||||
| `/auth/account/password` | PATCH | Change the signed-in user's password after verifying the current password |
|
| `/auth/account/password` | PATCH | Change a non-Guest user's password after verifying the current password |
|
||||||
| `/auth/users` | GET/POST | List or add users (admin only) |
|
| `/auth/users` | GET/POST | List or add users (admin only) |
|
||||||
| `/auth/users/{username}` | PATCH/DELETE | Change enabled state/password/roles or remove user (administrator only) |
|
| `/auth/users/{username}` | PATCH/DELETE | Change enabled state/password/roles or remove user (administrator only) |
|
||||||
|
|
||||||
Read routes require Read. Radio mutations require Control. Logbook access and
|
Read routes accept Guest or require Read. Tuning and receive-side radio mutations
|
||||||
bookmark mutations require Write. Administrator grants every permission.
|
require Control. PTT, transmitted audio, and TX limit changes require Transmit.
|
||||||
|
Logbook access and bookmark mutations require Write. Administrator grants every permission.
|
||||||
|
|
||||||
### Frontend Flow
|
### Frontend Flow
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
import {
|
import {
|
||||||
|
AUTH_ADMIN_ROLES,
|
||||||
AUTH_ROLES,
|
AUTH_ROLES,
|
||||||
AUTH_ROLE_LABELS,
|
AUTH_ROLE_LABELS,
|
||||||
changeOwnPassword,
|
changeOwnPassword,
|
||||||
createUser,
|
createUser,
|
||||||
deleteUser,
|
deleteUser,
|
||||||
fetchAuthSession,
|
fetchAuthSession,
|
||||||
|
hasAccountControls,
|
||||||
hasAuthRole,
|
hasAuthRole,
|
||||||
listUsers,
|
listUsers,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
normalizeAuthRoles,
|
normalizeAuthRoles,
|
||||||
updateUser
|
updateUser
|
||||||
} from "./chunk-BB2X7SND.js";
|
} from "./chunk-PISLBJGN.js";
|
||||||
|
|
||||||
// src/webgl-renderer.ts
|
// src/webgl-renderer.ts
|
||||||
(function initTrxWebGl(global) {
|
(function initTrxWebGl(global) {
|
||||||
@@ -1846,6 +1848,19 @@ function buildRoleChoices(selected) {
|
|||||||
element.append(label);
|
element.append(label);
|
||||||
return { input, value };
|
return { input, value };
|
||||||
});
|
});
|
||||||
|
inputs.forEach(({ input, value }) => {
|
||||||
|
input.addEventListener("change", () => {
|
||||||
|
if (!input.checked) return;
|
||||||
|
if (value === "guest") {
|
||||||
|
inputs.forEach((choice) => {
|
||||||
|
if (choice.value !== "guest") choice.input.checked = false;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const guest = inputs.find((choice) => choice.value === "guest");
|
||||||
|
if (guest) guest.input.checked = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
return { element, inputs };
|
return { element, inputs };
|
||||||
}
|
}
|
||||||
async function checkAuthStatus() {
|
async function checkAuthStatus() {
|
||||||
@@ -1932,7 +1947,13 @@ function updateAuthUI() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (authRoles.length > 0) {
|
if (authRoles.length > 0) {
|
||||||
if (accountTab) accountTab.style.display = "";
|
const canManageAccount = hasAccountControls(authRoles);
|
||||||
|
if (accountTab) accountTab.style.display = canManageAccount ? "" : "none";
|
||||||
|
if (!canManageAccount && accountTab?.classList.contains("active")) {
|
||||||
|
const panel = document.getElementById("subtab-settings-account");
|
||||||
|
if (panel) panel.style.display = "none";
|
||||||
|
document.querySelector('[data-subtab="settings-scheduler"]')?.click();
|
||||||
|
}
|
||||||
if (badge) badge.style.display = "block";
|
if (badge) badge.style.display = "block";
|
||||||
if (badgeRole) badgeRole.textContent = `${authUsername || "local"} — ${authRoles.map((role) => AUTH_ROLE_LABELS[role]).join(", ")}`;
|
if (badgeRole) badgeRole.textContent = `${authUsername || "local"} — ${authRoles.map((role) => AUTH_ROLE_LABELS[role]).join(", ")}`;
|
||||||
if (headerAuthBtn2) {
|
if (headerAuthBtn2) {
|
||||||
@@ -1951,30 +1972,33 @@ function updateAuthUI() {
|
|||||||
}
|
}
|
||||||
function applyAuthRestrictions() {
|
function applyAuthRestrictions() {
|
||||||
if (authRoles.length === 0) return;
|
if (authRoles.length === 0) return;
|
||||||
if (!hasAuthRole2("control")) {
|
if (!hasAuthRole2("transmit")) {
|
||||||
const pttBtn2 = document.getElementById("ptt-btn");
|
const pttBtn2 = document.getElementById("ptt-btn");
|
||||||
|
const txLimitInput2 = document.getElementById("tx-limit");
|
||||||
|
const txLimitBtn2 = document.getElementById("tx-limit-btn");
|
||||||
|
const txAudioBtn2 = document.getElementById("tx-audio-btn");
|
||||||
|
const txLimitRow2 = document.getElementById("tx-limit-row");
|
||||||
|
if (pttBtn2) pttBtn2.disabled = true;
|
||||||
|
if (txAudioBtn2) txAudioBtn2.disabled = true;
|
||||||
|
if (txLimitBtn2) txLimitBtn2.disabled = true;
|
||||||
|
if (txLimitInput2) txLimitInput2.disabled = true;
|
||||||
|
if (txLimitRow2) txLimitRow2.style.opacity = "0.5";
|
||||||
|
}
|
||||||
|
if (!hasAuthRole2("control")) {
|
||||||
const powerBtn2 = document.getElementById("power-btn");
|
const powerBtn2 = document.getElementById("power-btn");
|
||||||
const lockBtn2 = document.getElementById("lock-btn");
|
const lockBtn2 = document.getElementById("lock-btn");
|
||||||
const freqInput = document.getElementById("freq");
|
const freqInput = document.getElementById("freq");
|
||||||
const centerFreqInput = document.getElementById("center-freq");
|
const centerFreqInput = document.getElementById("center-freq");
|
||||||
const modeSelect = document.getElementById("mode");
|
const modeSelect = document.getElementById("mode");
|
||||||
const txLimitInput2 = document.getElementById("tx-limit");
|
|
||||||
const txLimitBtn2 = document.getElementById("tx-limit-btn");
|
|
||||||
const txAudioBtn2 = document.getElementById("tx-audio-btn");
|
|
||||||
const txLimitRow2 = document.getElementById("tx-limit-row");
|
|
||||||
const jogUp = document.getElementById("jog-up");
|
const jogUp = document.getElementById("jog-up");
|
||||||
const jogDown = document.getElementById("jog-down");
|
const jogDown = document.getElementById("jog-down");
|
||||||
const jogButtons = document.querySelectorAll(".jog-step button");
|
const jogButtons = document.querySelectorAll(".jog-step button");
|
||||||
const vfoButtons = document.querySelectorAll("#vfo-picker button");
|
const vfoButtons = document.querySelectorAll("#vfo-picker button");
|
||||||
if (pttBtn2) pttBtn2.disabled = true;
|
|
||||||
if (powerBtn2) powerBtn2.disabled = true;
|
if (powerBtn2) powerBtn2.disabled = true;
|
||||||
if (lockBtn2) lockBtn2.disabled = true;
|
if (lockBtn2) lockBtn2.disabled = true;
|
||||||
if (txAudioBtn2) txAudioBtn2.disabled = true;
|
|
||||||
if (txLimitBtn2) txLimitBtn2.disabled = true;
|
|
||||||
if (freqInput) freqInput.disabled = true;
|
if (freqInput) freqInput.disabled = true;
|
||||||
if (centerFreqInput) centerFreqInput.disabled = true;
|
if (centerFreqInput) centerFreqInput.disabled = true;
|
||||||
if (modeSelect) modeSelect.disabled = true;
|
if (modeSelect) modeSelect.disabled = true;
|
||||||
if (txLimitInput2) txLimitInput2.disabled = true;
|
|
||||||
vfoButtons.forEach((btn) => btn.disabled = true);
|
vfoButtons.forEach((btn) => btn.disabled = true);
|
||||||
const jogWheel2 = document.getElementById("jog-wheel");
|
const jogWheel2 = document.getElementById("jog-wheel");
|
||||||
if (jogUp) jogUp.disabled = true;
|
if (jogUp) jogUp.disabled = true;
|
||||||
@@ -2011,7 +2035,6 @@ function applyAuthRestrictions() {
|
|||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (txLimitRow2) txLimitRow2.style.opacity = "0.5";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function applyCapabilities(caps) {
|
function applyCapabilities(caps) {
|
||||||
@@ -4229,9 +4252,16 @@ function formatSignal(sUnits) {
|
|||||||
return overDb === 0 ? `${sigUnit("S")}9` : `${sigUnit("S")}9+${overDb}${sigUnit("dB")}`;
|
return overDb === 0 ? `${sigUnit("S")}9` : `${sigUnit("S")}9+${overDb}${sigUnit("dB")}`;
|
||||||
}
|
}
|
||||||
function setDisabled(disabled) {
|
function setDisabled(disabled) {
|
||||||
[freqEl, centerFreqEl, modeEl, pttBtn, powerBtn, txLimitInput, txLimitBtn, lockBtn].forEach((el) => {
|
const controlDisabled = disabled || authEnabled && !hasAuthRole2("control");
|
||||||
if (el) el.disabled = disabled;
|
const transmitDisabled = disabled || authEnabled && !hasAuthRole2("transmit");
|
||||||
|
[freqEl, centerFreqEl, modeEl, powerBtn, lockBtn].forEach((el) => {
|
||||||
|
if (el) el.disabled = controlDisabled;
|
||||||
});
|
});
|
||||||
|
[pttBtn, txLimitInput, txLimitBtn].forEach((el) => {
|
||||||
|
if (el) el.disabled = transmitDisabled;
|
||||||
|
});
|
||||||
|
const transmitAudio = document.getElementById("tx-audio-btn");
|
||||||
|
if (transmitAudio) transmitAudio.disabled = transmitDisabled || !hasWebCodecs;
|
||||||
syncModePicker();
|
syncModePicker();
|
||||||
}
|
}
|
||||||
var serverVersion = null;
|
var serverVersion = null;
|
||||||
@@ -5974,7 +6004,7 @@ async function initializeApp() {
|
|||||||
const authStatus = await checkAuthStatus();
|
const authStatus = await checkAuthStatus();
|
||||||
authEnabled = !authStatus.auth_disabled;
|
authEnabled = !authStatus.auth_disabled;
|
||||||
if (!authEnabled) {
|
if (!authEnabled) {
|
||||||
setAuthRoles(AUTH_ROLES);
|
setAuthRoles(AUTH_ADMIN_ROLES);
|
||||||
hideAuthGate();
|
hideAuthGate();
|
||||||
updateAuthUI();
|
updateAuthUI();
|
||||||
connect();
|
connect();
|
||||||
@@ -6047,10 +6077,15 @@ async function refreshUserManagement() {
|
|||||||
enabledLabel.append(enabled, " Enabled");
|
enabledLabel.append(enabled, " Enabled");
|
||||||
const isOnlyAdmin = user.enabled && hasAuthRole(user.roles, "administrator") && enabledAdminCount === 1;
|
const isOnlyAdmin = user.enabled && hasAuthRole(user.roles, "administrator") && enabledAdminCount === 1;
|
||||||
const administratorInput = roleInputs.find((item) => item.value === "administrator")?.input;
|
const administratorInput = roleInputs.find((item) => item.value === "administrator")?.input;
|
||||||
|
const guestInput = roleInputs.find((item) => item.value === "guest")?.input;
|
||||||
if (isOnlyAdmin && administratorInput) {
|
if (isOnlyAdmin && administratorInput) {
|
||||||
administratorInput.disabled = true;
|
administratorInput.disabled = true;
|
||||||
administratorInput.title = "The final administrator cannot be demoted";
|
administratorInput.title = "The final administrator cannot be demoted";
|
||||||
}
|
}
|
||||||
|
if (isOnlyAdmin && guestInput) {
|
||||||
|
guestInput.disabled = true;
|
||||||
|
guestInput.title = "The final administrator cannot become a Guest";
|
||||||
|
}
|
||||||
if (isOnlyAdmin) {
|
if (isOnlyAdmin) {
|
||||||
enabled.disabled = true;
|
enabled.disabled = true;
|
||||||
enabled.title = "The final enabled administrator cannot be disabled";
|
enabled.title = "The final enabled administrator cannot be disabled";
|
||||||
@@ -7303,6 +7338,10 @@ function startTxAudio() {
|
|||||||
void stopTxAudio();
|
void stopTxAudio();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (authEnabled && !hasAuthRole2("transmit")) {
|
||||||
|
audioStatus.textContent = "Transmit role required";
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!hasWebCodecs) {
|
if (!hasWebCodecs) {
|
||||||
audioStatus.textContent = "Audio requires Chrome/Edge";
|
audioStatus.textContent = "Audio requires Chrome/Edge";
|
||||||
return;
|
return;
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
hasAuthRole
|
hasAuthRole
|
||||||
} from "./chunk-BB2X7SND.js";
|
} from "./chunk-PISLBJGN.js";
|
||||||
import {
|
import {
|
||||||
hostState
|
hostState
|
||||||
} from "./chunk-KL66PICH.js";
|
} from "./chunk-KL66PICH.js";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
hasAuthRole
|
hasAuthRole
|
||||||
} from "./chunk-BB2X7SND.js";
|
} from "./chunk-PISLBJGN.js";
|
||||||
import {
|
import {
|
||||||
hostCore,
|
hostCore,
|
||||||
hostState
|
hostState
|
||||||
|
|||||||
+11
-3
@@ -1,8 +1,11 @@
|
|||||||
// src/api/auth.ts
|
// src/api/auth.ts
|
||||||
var AUTH_ROLES = ["read", "control", "write", "administrator"];
|
var AUTH_ROLES = ["guest", "read", "control", "transmit", "write", "administrator"];
|
||||||
|
var AUTH_ADMIN_ROLES = AUTH_ROLES.filter((role) => role !== "guest");
|
||||||
var AUTH_ROLE_LABELS = {
|
var AUTH_ROLE_LABELS = {
|
||||||
|
guest: "Guest",
|
||||||
read: "Read",
|
read: "Read",
|
||||||
control: "Control",
|
control: "Control",
|
||||||
|
transmit: "Transmit",
|
||||||
write: "Write",
|
write: "Write",
|
||||||
administrator: "Administrator"
|
administrator: "Administrator"
|
||||||
};
|
};
|
||||||
@@ -13,7 +16,10 @@ function normalizeAuthRoles(roles) {
|
|||||||
return AUTH_ROLES.filter((role) => roles.includes(role));
|
return AUTH_ROLES.filter((role) => roles.includes(role));
|
||||||
}
|
}
|
||||||
function hasAuthRole(roles, required) {
|
function hasAuthRole(roles, required) {
|
||||||
return roles.includes("administrator") || roles.includes(required) || required === "read" && roles.includes("control");
|
return roles.includes("administrator") || roles.includes(required) || required === "read" && roles.includes("guest") || required === "read" && (roles.includes("control") || roles.includes("transmit"));
|
||||||
|
}
|
||||||
|
function hasAccountControls(roles) {
|
||||||
|
return roles.length > 0 && !roles.includes("guest");
|
||||||
}
|
}
|
||||||
function decodeRoles(value, context) {
|
function decodeRoles(value, context) {
|
||||||
if (!Array.isArray(value) || !value.every(isAuthRole)) {
|
if (!Array.isArray(value) || !value.every(isAuthRole)) {
|
||||||
@@ -45,7 +51,7 @@ function decodeAuthSession(value) {
|
|||||||
}
|
}
|
||||||
var authDisabledSession = {
|
var authDisabledSession = {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
roles: ["read", "control", "write", "administrator"],
|
roles: [...AUTH_ADMIN_ROLES],
|
||||||
auth_disabled: true
|
auth_disabled: true
|
||||||
};
|
};
|
||||||
async function fetchAuthSession() {
|
async function fetchAuthSession() {
|
||||||
@@ -114,9 +120,11 @@ async function logout() {
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
AUTH_ROLES,
|
AUTH_ROLES,
|
||||||
|
AUTH_ADMIN_ROLES,
|
||||||
AUTH_ROLE_LABELS,
|
AUTH_ROLE_LABELS,
|
||||||
normalizeAuthRoles,
|
normalizeAuthRoles,
|
||||||
hasAuthRole,
|
hasAuthRole,
|
||||||
|
hasAccountControls,
|
||||||
fetchAuthSession,
|
fetchAuthSession,
|
||||||
login,
|
login,
|
||||||
listUsers,
|
listUsers,
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
hasAuthRole
|
hasAuthRole
|
||||||
} from "./chunk-BB2X7SND.js";
|
} from "./chunk-PISLBJGN.js";
|
||||||
import {
|
import {
|
||||||
hostCore,
|
hostCore,
|
||||||
hostState
|
hostState
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
hasAuthRole
|
hasAuthRole
|
||||||
} from "./chunk-BB2X7SND.js";
|
} from "./chunk-PISLBJGN.js";
|
||||||
import {
|
import {
|
||||||
hostState
|
hostState
|
||||||
} from "./chunk-KL66PICH.js";
|
} from "./chunk-KL66PICH.js";
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
|
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
|
||||||
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
|
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
|
||||||
"test": "node --test tests/*.test.mjs",
|
"test": "node --test tests/*.test.mjs",
|
||||||
"test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.mjs && node tests/mobile-layout.mjs && node tests/satellite-predictions.mjs && node tests/background-decode.mjs && node tests/logbook.mjs && node tests/account-management.mjs",
|
"test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.mjs && node tests/mobile-layout.mjs && node tests/satellite-predictions.mjs && node tests/background-decode.mjs && node tests/logbook.mjs && node tests/account-management.mjs && node tests/transmit-role.mjs",
|
||||||
"verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts"
|
"verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -6,10 +6,13 @@ import type { AuthRole } from "./generated.js";
|
|||||||
|
|
||||||
export type { AuthRole };
|
export type { AuthRole };
|
||||||
|
|
||||||
export const AUTH_ROLES: readonly AuthRole[] = ["read", "control", "write", "administrator"];
|
export const AUTH_ROLES: readonly AuthRole[] = ["guest", "read", "control", "transmit", "write", "administrator"];
|
||||||
|
export const AUTH_ADMIN_ROLES: readonly AuthRole[] = AUTH_ROLES.filter(role => role !== "guest");
|
||||||
export const AUTH_ROLE_LABELS: Readonly<Record<AuthRole, string>> = {
|
export const AUTH_ROLE_LABELS: Readonly<Record<AuthRole, string>> = {
|
||||||
|
guest: "Guest",
|
||||||
read: "Read",
|
read: "Read",
|
||||||
control: "Control",
|
control: "Control",
|
||||||
|
transmit: "Transmit",
|
||||||
write: "Write",
|
write: "Write",
|
||||||
administrator: "Administrator",
|
administrator: "Administrator",
|
||||||
};
|
};
|
||||||
@@ -25,7 +28,12 @@ export function normalizeAuthRoles(roles: readonly AuthRole[]): AuthRole[] {
|
|||||||
export function hasAuthRole(roles: readonly AuthRole[], required: AuthRole): boolean {
|
export function hasAuthRole(roles: readonly AuthRole[], required: AuthRole): boolean {
|
||||||
return roles.includes("administrator")
|
return roles.includes("administrator")
|
||||||
|| roles.includes(required)
|
|| roles.includes(required)
|
||||||
|| required === "read" && roles.includes("control");
|
|| required === "read" && roles.includes("guest")
|
||||||
|
|| required === "read" && (roles.includes("control") || roles.includes("transmit"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasAccountControls(roles: readonly AuthRole[]): boolean {
|
||||||
|
return roles.length > 0 && !roles.includes("guest");
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeRoles(value: unknown, context: string): AuthRole[] {
|
function decodeRoles(value: unknown, context: string): AuthRole[] {
|
||||||
@@ -67,7 +75,7 @@ function decodeAuthSession(value: unknown): AuthSession {
|
|||||||
|
|
||||||
const authDisabledSession: AuthSession = {
|
const authDisabledSession: AuthSession = {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
roles: ["read", "control", "write", "administrator"],
|
roles: [...AUTH_ADMIN_ROLES],
|
||||||
auth_disabled: true,
|
auth_disabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export type RigListResponse = { active_remote: string | null, rigs: Array<RigLis
|
|||||||
|
|
||||||
export type FrontendMeta = { clients: number, rigctl_clients: number, audio_clients: number, rigctl_addr: string | null, active_remote: string | null, remotes: Array<string>, owner_callsign: string | null, owner_website_url: string | null, owner_website_name: string | null, ais_vessel_url_base: string | null, show_sdr_gain_control: boolean, initial_map_zoom: number, spectrum_coverage_margin_hz: number, spectrum_usable_span_ratio: number, bandplan_enabled: boolean, bandplan_region: string, decode_history_retention_min: bigint, server_connected: boolean, };
|
export type FrontendMeta = { clients: number, rigctl_clients: number, audio_clients: number, rigctl_addr: string | null, active_remote: string | null, remotes: Array<string>, owner_callsign: string | null, owner_website_url: string | null, owner_website_name: string | null, ais_vessel_url_base: string | null, show_sdr_gain_control: boolean, initial_map_zoom: number, spectrum_coverage_margin_hz: number, spectrum_usable_span_ratio: number, bandplan_enabled: boolean, bandplan_region: string, decode_history_retention_min: bigint, server_connected: boolean, };
|
||||||
|
|
||||||
export type AuthRole = "read" | "control" | "write" | "administrator";
|
export type AuthRole = "guest" | "read" | "control" | "transmit" | "write" | "administrator";
|
||||||
|
|
||||||
export type DecoderActivation = "mode_bound" | "toggle";
|
export type DecoderActivation = "mode_bound" | "toggle";
|
||||||
|
|
||||||
|
|||||||
@@ -26,8 +26,10 @@ import {
|
|||||||
updateUser,
|
updateUser,
|
||||||
deleteUser,
|
deleteUser,
|
||||||
changeOwnPassword,
|
changeOwnPassword,
|
||||||
|
AUTH_ADMIN_ROLES,
|
||||||
AUTH_ROLES,
|
AUTH_ROLES,
|
||||||
AUTH_ROLE_LABELS,
|
AUTH_ROLE_LABELS,
|
||||||
|
hasAccountControls,
|
||||||
hasAuthRole as rolesInclude,
|
hasAuthRole as rolesInclude,
|
||||||
normalizeAuthRoles,
|
normalizeAuthRoles,
|
||||||
} from "./api/auth.js";
|
} from "./api/auth.js";
|
||||||
@@ -441,6 +443,17 @@ function buildRoleChoices(selected: readonly AuthRole[]) {
|
|||||||
element.append(label);
|
element.append(label);
|
||||||
return { input, value };
|
return { input, value };
|
||||||
});
|
});
|
||||||
|
inputs.forEach(({ input, value }) => {
|
||||||
|
input.addEventListener("change", () => {
|
||||||
|
if (!input.checked) return;
|
||||||
|
if (value === "guest") {
|
||||||
|
inputs.forEach(choice => { if (choice.value !== "guest") choice.input.checked = false; });
|
||||||
|
} else {
|
||||||
|
const guest = inputs.find(choice => choice.value === "guest");
|
||||||
|
if (guest) guest.input.checked = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
return { element, inputs };
|
return { element, inputs };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -544,7 +557,13 @@ function updateAuthUI() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (authRoles.length > 0) {
|
if (authRoles.length > 0) {
|
||||||
if (accountTab) accountTab.style.display = "";
|
const canManageAccount = hasAccountControls(authRoles);
|
||||||
|
if (accountTab) accountTab.style.display = canManageAccount ? "" : "none";
|
||||||
|
if (!canManageAccount && accountTab?.classList.contains("active")) {
|
||||||
|
const panel = document.getElementById("subtab-settings-account");
|
||||||
|
if (panel) panel.style.display = "none";
|
||||||
|
document.querySelector<HTMLButtonElement>('[data-subtab="settings-scheduler"]')?.click();
|
||||||
|
}
|
||||||
if (badge) badge.style.display = "block";
|
if (badge) badge.style.display = "block";
|
||||||
if (badgeRole) badgeRole.textContent = `${authUsername || "local"} — ${authRoles.map(role => AUTH_ROLE_LABELS[role]).join(", ")}`;
|
if (badgeRole) badgeRole.textContent = `${authUsername || "local"} — ${authRoles.map(role => AUTH_ROLE_LABELS[role]).join(", ")}`;
|
||||||
if (headerAuthBtn) {
|
if (headerAuthBtn) {
|
||||||
@@ -565,35 +584,38 @@ function updateAuthUI() {
|
|||||||
function applyAuthRestrictions() {
|
function applyAuthRestrictions() {
|
||||||
if (authRoles.length === 0) return;
|
if (authRoles.length === 0) return;
|
||||||
|
|
||||||
// Disable TX/PTT/frequency/mode/VFO controls for user role
|
if (!hasAuthRole("transmit")) {
|
||||||
if (!hasAuthRole("control")) {
|
|
||||||
const pttBtn = document.getElementById("ptt-btn") as HTMLButtonElement | null;
|
const pttBtn = document.getElementById("ptt-btn") as HTMLButtonElement | null;
|
||||||
|
const txLimitInput = document.getElementById("tx-limit") as HTMLInputElement | null;
|
||||||
|
const txLimitBtn = document.getElementById("tx-limit-btn") as HTMLButtonElement | null;
|
||||||
|
const txAudioBtn = document.getElementById("tx-audio-btn") as HTMLButtonElement | null;
|
||||||
|
const txLimitRow = document.getElementById("tx-limit-row");
|
||||||
|
if (pttBtn) pttBtn.disabled = true;
|
||||||
|
if (txAudioBtn) txAudioBtn.disabled = true;
|
||||||
|
if (txLimitBtn) txLimitBtn.disabled = true;
|
||||||
|
if (txLimitInput) txLimitInput.disabled = true;
|
||||||
|
if (txLimitRow) txLimitRow.style.opacity = "0.5";
|
||||||
|
}
|
||||||
|
|
||||||
|
// General tuning and receive-side controls require Control.
|
||||||
|
if (!hasAuthRole("control")) {
|
||||||
const powerBtn = document.getElementById("power-btn") as HTMLButtonElement | null;
|
const 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;
|
||||||
const freqInput = document.getElementById("freq") as HTMLInputElement | null;
|
const freqInput = document.getElementById("freq") as HTMLInputElement | null;
|
||||||
const centerFreqInput = document.getElementById("center-freq") as HTMLInputElement | null;
|
const centerFreqInput = document.getElementById("center-freq") as HTMLInputElement | null;
|
||||||
const modeSelect = document.getElementById("mode") as HTMLSelectElement | null;
|
const modeSelect = document.getElementById("mode") as HTMLSelectElement | null;
|
||||||
const txLimitInput = document.getElementById("tx-limit") as HTMLInputElement | null;
|
|
||||||
const txLimitBtn = document.getElementById("tx-limit-btn") as HTMLButtonElement | null;
|
|
||||||
const txAudioBtn = document.getElementById("tx-audio-btn") as HTMLButtonElement | null;
|
|
||||||
const txLimitRow = document.getElementById("tx-limit-row");
|
|
||||||
const jogUp = document.getElementById("jog-up") as HTMLButtonElement | null;
|
const jogUp = document.getElementById("jog-up") as HTMLButtonElement | null;
|
||||||
const jogDown = document.getElementById("jog-down") as HTMLButtonElement | null;
|
const jogDown = document.getElementById("jog-down") as HTMLButtonElement | null;
|
||||||
const jogButtons = document.querySelectorAll<HTMLButtonElement>(".jog-step button");
|
const jogButtons = document.querySelectorAll<HTMLButtonElement>(".jog-step button");
|
||||||
const vfoButtons = document.querySelectorAll<HTMLButtonElement>("#vfo-picker button");
|
const vfoButtons = document.querySelectorAll<HTMLButtonElement>("#vfo-picker button");
|
||||||
|
|
||||||
// Disable TX buttons
|
|
||||||
if (pttBtn) pttBtn.disabled = true;
|
|
||||||
if (powerBtn) powerBtn.disabled = true;
|
if (powerBtn) powerBtn.disabled = true;
|
||||||
if (lockBtn) lockBtn.disabled = true;
|
if (lockBtn) lockBtn.disabled = true;
|
||||||
if (txAudioBtn) txAudioBtn.disabled = true;
|
|
||||||
if (txLimitBtn) txLimitBtn.disabled = true;
|
|
||||||
|
|
||||||
// Disable frequency/mode inputs
|
// Disable frequency/mode inputs
|
||||||
if (freqInput) freqInput.disabled = true;
|
if (freqInput) freqInput.disabled = true;
|
||||||
if (centerFreqInput) centerFreqInput.disabled = true;
|
if (centerFreqInput) centerFreqInput.disabled = true;
|
||||||
if (modeSelect) modeSelect.disabled = true;
|
if (modeSelect) modeSelect.disabled = true;
|
||||||
if (txLimitInput) txLimitInput.disabled = true;
|
|
||||||
|
|
||||||
// Disable VFO selector
|
// Disable VFO selector
|
||||||
vfoButtons.forEach(btn => btn.disabled = true);
|
vfoButtons.forEach(btn => btn.disabled = true);
|
||||||
@@ -637,9 +659,6 @@ function applyAuthRestrictions() {
|
|||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Hide TX-specific UI but keep controls visible (disabled)
|
|
||||||
if (txLimitRow) txLimitRow.style.opacity = "0.5";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3061,9 +3080,16 @@ function formatSignal(sUnits: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setDisabled(disabled: boolean) {
|
function setDisabled(disabled: boolean) {
|
||||||
[freqEl, centerFreqEl, modeEl, pttBtn, powerBtn, txLimitInput, txLimitBtn, lockBtn].forEach((el) => {
|
const controlDisabled = disabled || (authEnabled && !hasAuthRole("control"));
|
||||||
if (el) el.disabled = disabled;
|
const transmitDisabled = disabled || (authEnabled && !hasAuthRole("transmit"));
|
||||||
|
[freqEl, centerFreqEl, modeEl, powerBtn, lockBtn].forEach((el) => {
|
||||||
|
if (el) el.disabled = controlDisabled;
|
||||||
});
|
});
|
||||||
|
[pttBtn, txLimitInput, txLimitBtn].forEach((el) => {
|
||||||
|
if (el) el.disabled = transmitDisabled;
|
||||||
|
});
|
||||||
|
const transmitAudio = document.getElementById("tx-audio-btn") as HTMLButtonElement | null;
|
||||||
|
if (transmitAudio) transmitAudio.disabled = transmitDisabled || !hasWebCodecs;
|
||||||
syncModePicker();
|
syncModePicker();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5050,7 +5076,7 @@ async function initializeApp() {
|
|||||||
authEnabled = !authStatus.auth_disabled;
|
authEnabled = !authStatus.auth_disabled;
|
||||||
|
|
||||||
if (!authEnabled) {
|
if (!authEnabled) {
|
||||||
setAuthRoles(AUTH_ROLES);
|
setAuthRoles(AUTH_ADMIN_ROLES);
|
||||||
hideAuthGate();
|
hideAuthGate();
|
||||||
updateAuthUI();
|
updateAuthUI();
|
||||||
connect();
|
connect();
|
||||||
@@ -5132,10 +5158,15 @@ async function refreshUserManagement() {
|
|||||||
&& rolesInclude(user.roles, "administrator")
|
&& rolesInclude(user.roles, "administrator")
|
||||||
&& enabledAdminCount === 1;
|
&& enabledAdminCount === 1;
|
||||||
const administratorInput = roleInputs.find(item => item.value === "administrator")?.input;
|
const administratorInput = roleInputs.find(item => item.value === "administrator")?.input;
|
||||||
|
const guestInput = roleInputs.find(item => item.value === "guest")?.input;
|
||||||
if (isOnlyAdmin && administratorInput) {
|
if (isOnlyAdmin && administratorInput) {
|
||||||
administratorInput.disabled = true;
|
administratorInput.disabled = true;
|
||||||
administratorInput.title = "The final administrator cannot be demoted";
|
administratorInput.title = "The final administrator cannot be demoted";
|
||||||
}
|
}
|
||||||
|
if (isOnlyAdmin && guestInput) {
|
||||||
|
guestInput.disabled = true;
|
||||||
|
guestInput.title = "The final administrator cannot become a Guest";
|
||||||
|
}
|
||||||
if (isOnlyAdmin) {
|
if (isOnlyAdmin) {
|
||||||
enabled.disabled = true;
|
enabled.disabled = true;
|
||||||
enabled.title = "The final enabled administrator cannot be disabled";
|
enabled.title = "The final enabled administrator cannot be disabled";
|
||||||
@@ -6346,6 +6377,10 @@ function stopRxAudio() {
|
|||||||
|
|
||||||
function startTxAudio() {
|
function startTxAudio() {
|
||||||
if (txActive) { void stopTxAudio(); return; }
|
if (txActive) { void stopTxAudio(); return; }
|
||||||
|
if (authEnabled && !hasAuthRole("transmit")) {
|
||||||
|
audioStatus.textContent = "Transmit role required";
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!hasWebCodecs) {
|
if (!hasWebCodecs) {
|
||||||
audioStatus.textContent = "Audio requires Chrome/Edge";
|
audioStatus.textContent = "Audio requires Chrome/Edge";
|
||||||
return;
|
return;
|
||||||
|
|||||||
+24
-2
@@ -8,7 +8,7 @@ import { startBrowser, startWebFixture } from "./web-fixture.mjs";
|
|||||||
|
|
||||||
/* global document */
|
/* global document */
|
||||||
|
|
||||||
const ALL_ROLES = ["read", "control", "write", "administrator"];
|
const ALL_ROLES = ["read", "control", "transmit", "write", "administrator"];
|
||||||
const fixture = await startWebFixture({
|
const fixture = await startWebFixture({
|
||||||
authSession: {
|
authSession: {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
@@ -47,14 +47,16 @@ try {
|
|||||||
createRoles: [...document.querySelectorAll("#user-create-roles input")].map((input) => input.value),
|
createRoles: [...document.querySelectorAll("#user-create-roles input")].map((input) => input.value),
|
||||||
adminEnabledLocked: admin?.querySelector('input[type="checkbox"]')?.disabled,
|
adminEnabledLocked: admin?.querySelector('input[type="checkbox"]')?.disabled,
|
||||||
adminRoleLocked: role(admin, "administrator")?.disabled,
|
adminRoleLocked: role(admin, "administrator")?.disabled,
|
||||||
|
adminGuestLocked: role(admin, "guest")?.disabled,
|
||||||
adminRemoveLocked: admin?.querySelector("button.danger")?.disabled,
|
adminRemoveLocked: admin?.querySelector("button.danger")?.disabled,
|
||||||
listenerEnabled: listener?.querySelector('input[type="checkbox"]')?.checked,
|
listenerEnabled: listener?.querySelector('input[type="checkbox"]')?.checked,
|
||||||
listenerRead: role(listener, "read")?.checked,
|
listenerRead: role(listener, "read")?.checked,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
assert.deepEqual(state.createRoles, ALL_ROLES);
|
assert.deepEqual(state.createRoles, ["guest", ...ALL_ROLES]);
|
||||||
assert.equal(state.adminEnabledLocked, true);
|
assert.equal(state.adminEnabledLocked, true);
|
||||||
assert.equal(state.adminRoleLocked, true);
|
assert.equal(state.adminRoleLocked, true);
|
||||||
|
assert.equal(state.adminGuestLocked, true);
|
||||||
assert.equal(state.adminRemoveLocked, true);
|
assert.equal(state.adminRemoveLocked, true);
|
||||||
assert.equal(state.listenerEnabled, false);
|
assert.equal(state.listenerEnabled, false);
|
||||||
assert.equal(state.listenerRead, true);
|
assert.equal(state.listenerRead, true);
|
||||||
@@ -63,3 +65,23 @@ try {
|
|||||||
await browser.close();
|
await browser.close();
|
||||||
await fixture.close();
|
await fixture.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const guestFixture = await startWebFixture({
|
||||||
|
authSession: {
|
||||||
|
authenticated: true,
|
||||||
|
username: "guest",
|
||||||
|
roles: ["guest"],
|
||||||
|
auth_disabled: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const guestBrowser = await startBrowser(chromium);
|
||||||
|
try {
|
||||||
|
await guestBrowser.page.goto(`${guestFixture.origin}/settings`, { waitUntil: "domcontentloaded" });
|
||||||
|
await guestBrowser.page.locator("#tab-settings").waitFor({ state: "visible" });
|
||||||
|
assert.equal(await guestBrowser.page.locator("#settings-account-tab").isVisible(), false);
|
||||||
|
assert.equal(await guestBrowser.page.locator("#settings-users-tab").isVisible(), false);
|
||||||
|
assert.deepEqual(guestBrowser.runtimeErrors, []);
|
||||||
|
} finally {
|
||||||
|
await guestBrowser.browser.close();
|
||||||
|
await guestFixture.close();
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,10 +15,15 @@ function loadAuth(fetch) {
|
|||||||
return context.AuthApi;
|
return context.AuthApi;
|
||||||
}
|
}
|
||||||
|
|
||||||
test("role policy is centralized and preserves the Control-to-Read implication", () => {
|
test("role policy centralizes Guest and separates Control from Transmit", () => {
|
||||||
const auth = loadAuth(async () => { throw new Error("unused"); });
|
const auth = loadAuth(async () => { throw new Error("unused"); });
|
||||||
assert.deepEqual(Array.from(auth.AUTH_ROLES), ["read", "control", "write", "administrator"]);
|
assert.deepEqual(Array.from(auth.AUTH_ROLES), ["guest", "read", "control", "transmit", "write", "administrator"]);
|
||||||
|
assert.equal(auth.hasAuthRole(["guest"], "read"), true);
|
||||||
|
assert.equal(auth.hasAccountControls(["guest"]), false);
|
||||||
|
assert.equal(auth.hasAccountControls(["read"]), true);
|
||||||
assert.equal(auth.hasAuthRole(["control"], "read"), true);
|
assert.equal(auth.hasAuthRole(["control"], "read"), true);
|
||||||
|
assert.equal(auth.hasAuthRole(["transmit"], "read"), true);
|
||||||
|
assert.equal(auth.hasAuthRole(["control"], "transmit"), false);
|
||||||
assert.equal(auth.hasAuthRole(["control"], "write"), false);
|
assert.equal(auth.hasAuthRole(["control"], "write"), false);
|
||||||
assert.equal(auth.hasAuthRole(["administrator"], "write"), true);
|
assert.equal(auth.hasAuthRole(["administrator"], "write"), true);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ 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,
|
||||||
authRoles: ["read", "control", "write", "administrator"],
|
authRoles: ["read", "control", "transmit", "write", "administrator"],
|
||||||
lastActiveRigId: null,
|
lastActiveRigId: null,
|
||||||
lastRigIds: [],
|
lastRigIds: [],
|
||||||
lastRigDisplayNames: {},
|
lastRigDisplayNames: {},
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function createHost({ state = {}, core = {}, modules = {} } = {}) {
|
|||||||
serverLat: null,
|
serverLat: null,
|
||||||
serverLon: null,
|
serverLon: null,
|
||||||
authEnabled: false,
|
authEnabled: false,
|
||||||
authRoles: ["read", "control", "write", "administrator"],
|
authRoles: ["read", "control", "transmit", "write", "administrator"],
|
||||||
lastActiveRigId: null,
|
lastActiveRigId: null,
|
||||||
lastRigIds: [],
|
lastRigIds: [],
|
||||||
lastRigDisplayNames: {},
|
lastRigDisplayNames: {},
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ test("account lifecycle controls include self-service passwords and enable state
|
|||||||
assert.match(html, /id="account-password-form"/);
|
assert.match(html, /id="account-password-form"/);
|
||||||
assert.match(html, /id="user-create-enabled"/);
|
assert.match(html, /id="user-create-enabled"/);
|
||||||
assert.match(app, /changeOwnPassword/);
|
assert.match(app, /changeOwnPassword/);
|
||||||
|
assert.match(app, /hasAccountControls\(authRoles\)/);
|
||||||
assert.match(app, /enabledAdminCount/);
|
assert.match(app, /enabledAdminCount/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { chromium } from "playwright-core";
|
||||||
|
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
|
||||||
|
|
||||||
|
async function controlState(page) {
|
||||||
|
return {
|
||||||
|
frequency: await page.locator("#freq").isDisabled(),
|
||||||
|
ptt: await page.locator("#ptt-btn").isDisabled(),
|
||||||
|
txAudio: await page.locator("#tx-audio-btn").isDisabled(),
|
||||||
|
txLimit: await page.locator("#tx-limit-btn").isDisabled(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inspectRole(roles) {
|
||||||
|
const fixture = await startWebFixture({
|
||||||
|
tx: true,
|
||||||
|
authSession: {
|
||||||
|
authenticated: true,
|
||||||
|
username: "operator",
|
||||||
|
roles,
|
||||||
|
auth_disabled: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const session = await startBrowser(chromium);
|
||||||
|
try {
|
||||||
|
await session.page.goto(`${fixture.origin}/`, { waitUntil: "domcontentloaded" });
|
||||||
|
await session.page.locator("#content").waitFor({ state: "visible" });
|
||||||
|
await session.page.waitForTimeout(1200);
|
||||||
|
assert.deepEqual(session.runtimeErrors, []);
|
||||||
|
return await controlState(session.page);
|
||||||
|
} finally {
|
||||||
|
await session.browser.close();
|
||||||
|
await fixture.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepEqual(await inspectRole(["control"]), {
|
||||||
|
frequency: false,
|
||||||
|
ptt: true,
|
||||||
|
txAudio: true,
|
||||||
|
txLimit: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(await inspectRole(["transmit"]), {
|
||||||
|
frequency: true,
|
||||||
|
ptt: false,
|
||||||
|
txAudio: false,
|
||||||
|
txLimit: false,
|
||||||
|
});
|
||||||
@@ -145,7 +145,7 @@ export async function startWebFixture({
|
|||||||
bandplanEnabled = false,
|
bandplanEnabled = false,
|
||||||
bandplanUnauthorizedFirst = false,
|
bandplanUnauthorizedFirst = false,
|
||||||
satPasses = null,
|
satPasses = null,
|
||||||
authSession = { authenticated: true, roles: ["read", "control", "write", "administrator"], auth_disabled: true },
|
authSession = { authenticated: true, roles: ["read", "control", "transmit", "write", "administrator"], auth_disabled: true },
|
||||||
users = [],
|
users = [],
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const rigItems = ["rig-a", "rig-b"].map((remote) => ({
|
const rigItems = ["rig-a", "rig-b"].map((remote) => ({
|
||||||
|
|||||||
@@ -737,6 +737,7 @@ pub async fn audio_ws(
|
|||||||
body: web::Payload,
|
body: web::Payload,
|
||||||
query: web::Query<AudioQuery>,
|
query: web::Query<AudioQuery>,
|
||||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||||
|
auth_state: web::Data<crate::server::auth::AuthState>,
|
||||||
) -> Result<HttpResponse, Error> {
|
) -> Result<HttpResponse, Error> {
|
||||||
let Some(tx_sender) = context.audio.tx.as_ref().cloned() else {
|
let Some(tx_sender) = context.audio.tx.as_ref().cloned() else {
|
||||||
return Ok(HttpResponse::NotFound().body("audio not enabled"));
|
return Ok(HttpResponse::NotFound().body("audio not enabled"));
|
||||||
@@ -746,6 +747,7 @@ pub async fn audio_ws(
|
|||||||
if !req.headers().contains_key("upgrade") {
|
if !req.headers().contains_key("upgrade") {
|
||||||
return Ok(HttpResponse::NoContent().finish());
|
return Ok(HttpResponse::NoContent().finish());
|
||||||
}
|
}
|
||||||
|
let tx_session_id = crate::server::auth::extract_session_id(&req);
|
||||||
|
|
||||||
// If a channel_id is specified, subscribe to the per-channel broadcaster.
|
// If a channel_id is specified, subscribe to the per-channel broadcaster.
|
||||||
// The entry is created asynchronously when AUDIO_MSG_VCHAN_ALLOCATED arrives
|
// The entry is created asynchronously when AUDIO_MSG_VCHAN_ALLOCATED arrives
|
||||||
@@ -880,6 +882,16 @@ pub async fn audio_ws(
|
|||||||
msg = msg_stream.recv() => {
|
msg = msg_stream.recv() => {
|
||||||
match msg {
|
match msg {
|
||||||
Some(Ok(Message::Binary(data))) => {
|
Some(Ok(Message::Binary(data))) => {
|
||||||
|
let can_transmit = !auth_state.config.enabled
|
||||||
|
|| crate::server::auth::session_id_grants(
|
||||||
|
tx_session_id.as_ref(),
|
||||||
|
&auth_state,
|
||||||
|
crate::server::auth::AuthRole::Transmit,
|
||||||
|
);
|
||||||
|
if !can_transmit {
|
||||||
|
warn!("Audio WS: closing after unauthorized TX frame");
|
||||||
|
break;
|
||||||
|
}
|
||||||
let _ = tx_sender.send(Bytes::from(data.to_vec())).await;
|
let _ = tx_sender.send(Bytes::from(data.to_vec())).await;
|
||||||
}
|
}
|
||||||
Some(Ok(Message::Close(_))) => break,
|
Some(Ok(Message::Close(_))) => break,
|
||||||
|
|||||||
@@ -35,8 +35,10 @@ pub type SessionId = String;
|
|||||||
)]
|
)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum AuthRole {
|
pub enum AuthRole {
|
||||||
|
Guest,
|
||||||
Read,
|
Read,
|
||||||
Control,
|
Control,
|
||||||
|
Transmit,
|
||||||
Write,
|
Write,
|
||||||
Administrator,
|
Administrator,
|
||||||
}
|
}
|
||||||
@@ -44,15 +46,23 @@ pub enum AuthRole {
|
|||||||
impl AuthRole {
|
impl AuthRole {
|
||||||
pub fn as_str(&self) -> &'static str {
|
pub fn as_str(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
|
Self::Guest => "guest",
|
||||||
Self::Read => "read",
|
Self::Read => "read",
|
||||||
Self::Control => "control",
|
Self::Control => "control",
|
||||||
|
Self::Transmit => "transmit",
|
||||||
Self::Write => "write",
|
Self::Write => "write",
|
||||||
Self::Administrator => "administrator",
|
Self::Administrator => "administrator",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn all() -> BTreeSet<Self> {
|
pub fn full_access() -> BTreeSet<Self> {
|
||||||
[Self::Read, Self::Control, Self::Write, Self::Administrator]
|
[
|
||||||
|
Self::Read,
|
||||||
|
Self::Control,
|
||||||
|
Self::Transmit,
|
||||||
|
Self::Write,
|
||||||
|
Self::Administrator,
|
||||||
|
]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -60,7 +70,8 @@ impl AuthRole {
|
|||||||
fn grants(self, required: Self) -> bool {
|
fn grants(self, required: Self) -> bool {
|
||||||
self == Self::Administrator
|
self == Self::Administrator
|
||||||
|| self == required
|
|| self == required
|
||||||
|| self == Self::Control && required == Self::Read
|
|| self == Self::Guest && required == Self::Read
|
||||||
|
|| matches!(self, Self::Control | Self::Transmit) && required == Self::Read
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,9 +85,13 @@ fn roles_grant_any(roles: &AuthRoles, required: &[AuthRole]) -> bool {
|
|||||||
required.iter().any(|role| roles_grant(roles, *role))
|
required.iter().any(|role| roles_grant(roles, *role))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn roles_allow_account_controls(roles: &AuthRoles) -> bool {
|
||||||
|
!roles.contains(&AuthRole::Guest)
|
||||||
|
}
|
||||||
|
|
||||||
fn effective_roles(roles: &AuthRoles) -> Vec<AuthRole> {
|
fn effective_roles(roles: &AuthRoles) -> Vec<AuthRole> {
|
||||||
if roles.contains(&AuthRole::Administrator) {
|
if roles.contains(&AuthRole::Administrator) {
|
||||||
AuthRole::all().into_iter().collect()
|
AuthRole::full_access().into_iter().collect()
|
||||||
} else {
|
} else {
|
||||||
roles.iter().copied().collect()
|
roles.iter().copied().collect()
|
||||||
}
|
}
|
||||||
@@ -311,7 +326,7 @@ impl<'de> Deserialize<'de> for UserRecord {
|
|||||||
let roles = match (stored.roles, stored.role) {
|
let roles = match (stored.roles, stored.role) {
|
||||||
(Some(roles), _) => roles,
|
(Some(roles), _) => roles,
|
||||||
(None, Some(LegacyRole::User)) => [AuthRole::Read].into_iter().collect(),
|
(None, Some(LegacyRole::User)) => [AuthRole::Read].into_iter().collect(),
|
||||||
(None, Some(LegacyRole::Admin)) => AuthRole::all(),
|
(None, Some(LegacyRole::Admin)) => AuthRole::full_access(),
|
||||||
(None, None) => {
|
(None, None) => {
|
||||||
return Err(serde::de::Error::missing_field("roles"));
|
return Err(serde::de::Error::missing_field("roles"));
|
||||||
}
|
}
|
||||||
@@ -390,7 +405,7 @@ impl UserStore {
|
|||||||
let mut records = vec![UserRecord {
|
let mut records = vec![UserRecord {
|
||||||
username: validate_username(&administrator.username)?.to_string(),
|
username: validate_username(&administrator.username)?.to_string(),
|
||||||
password_hash: hash_password(&administrator.password)?,
|
password_hash: hash_password(&administrator.password)?,
|
||||||
roles: AuthRole::all(),
|
roles: AuthRole::full_access(),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
}];
|
}];
|
||||||
if let Some(reader) = &config.bootstrap_read {
|
if let Some(reader) = &config.bootstrap_read {
|
||||||
@@ -405,7 +420,7 @@ impl UserStore {
|
|||||||
records.push(UserRecord {
|
records.push(UserRecord {
|
||||||
username: validate_username(&reader.username)?.to_string(),
|
username: validate_username(&reader.username)?.to_string(),
|
||||||
password_hash: hash_password_value(&reader.password)?,
|
password_hash: hash_password_value(&reader.password)?,
|
||||||
roles: [AuthRole::Read].into_iter().collect(),
|
roles: [AuthRole::Guest].into_iter().collect(),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -617,6 +632,9 @@ fn validate_roles(roles: AuthRoles) -> Result<AuthRoles, String> {
|
|||||||
if roles.is_empty() {
|
if roles.is_empty() {
|
||||||
return Err("at least one role is required".to_string());
|
return Err("at least one role is required".to_string());
|
||||||
}
|
}
|
||||||
|
if roles.contains(&AuthRole::Guest) && roles.len() != 1 {
|
||||||
|
return Err("guest cannot be combined with other roles".to_string());
|
||||||
|
}
|
||||||
Ok(roles)
|
Ok(roles)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -766,7 +784,7 @@ pub struct ChangePasswordRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Extract session from cookie
|
/// Extract session from cookie
|
||||||
fn extract_session_id(req: &HttpRequest) -> Option<SessionId> {
|
pub fn extract_session_id(req: &HttpRequest) -> Option<SessionId> {
|
||||||
req.cookie("trx_http_sid")
|
req.cookie("trx_http_sid")
|
||||||
.map(|cookie| cookie.value().to_string())
|
.map(|cookie| cookie.value().to_string())
|
||||||
}
|
}
|
||||||
@@ -779,7 +797,18 @@ pub fn get_session_roles(req: &HttpRequest, auth_state: &AuthState) -> Option<Au
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn session_grants(req: &HttpRequest, auth_state: &AuthState, role: AuthRole) -> bool {
|
pub fn session_grants(req: &HttpRequest, auth_state: &AuthState, role: AuthRole) -> bool {
|
||||||
get_session_roles(req, auth_state).is_some_and(|roles| roles_grant(&roles, role))
|
let session_id = extract_session_id(req);
|
||||||
|
session_id_grants(session_id.as_ref(), auth_state, role)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn session_id_grants(
|
||||||
|
session_id: Option<&SessionId>,
|
||||||
|
auth_state: &AuthState,
|
||||||
|
role: AuthRole,
|
||||||
|
) -> bool {
|
||||||
|
session_id
|
||||||
|
.and_then(|id| auth_state.store.get(id))
|
||||||
|
.is_some_and(|session| roles_grant(&session.roles, role))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn require_session(
|
fn require_session(
|
||||||
@@ -812,6 +841,19 @@ fn require_admin(req: &HttpRequest, auth_state: &AuthState) -> Result<SessionRec
|
|||||||
require_role(req, auth_state, AuthRole::Administrator)
|
require_role(req, auth_state, AuthRole::Administrator)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn require_account_controls(
|
||||||
|
req: &HttpRequest,
|
||||||
|
auth_state: &AuthState,
|
||||||
|
) -> Result<SessionRecord, HttpResponse> {
|
||||||
|
let session = require_session(req, auth_state)?;
|
||||||
|
if !roles_allow_account_controls(&session.roles) {
|
||||||
|
return Err(HttpResponse::Forbidden().json(serde_json::json!({
|
||||||
|
"error":"guest accounts cannot access account controls"
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
Ok(session)
|
||||||
|
}
|
||||||
|
|
||||||
fn session_cookie(value: String, config: &AuthConfig, max_age: Duration) -> Cookie<'static> {
|
fn session_cookie(value: String, config: &AuthConfig, max_age: Duration) -> Cookie<'static> {
|
||||||
let mut cookie = Cookie::new("trx_http_sid", value);
|
let mut cookie = Cookie::new("trx_http_sid", value);
|
||||||
cookie.set_path("/");
|
cookie.set_path("/");
|
||||||
@@ -925,7 +967,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,
|
||||||
roles: effective_roles(&AuthRole::all()),
|
roles: effective_roles(&AuthRole::full_access()),
|
||||||
username: None,
|
username: None,
|
||||||
auth_disabled: true,
|
auth_disabled: true,
|
||||||
}));
|
}));
|
||||||
@@ -958,7 +1000,7 @@ pub async fn change_own_password(
|
|||||||
body: web::Json<ChangePasswordRequest>,
|
body: web::Json<ChangePasswordRequest>,
|
||||||
auth_state: web::Data<AuthState>,
|
auth_state: web::Data<AuthState>,
|
||||||
) -> impl Responder {
|
) -> impl Responder {
|
||||||
let session = match require_session(&req, &auth_state) {
|
let session = match require_account_controls(&req, &auth_state) {
|
||||||
Ok(value) => value,
|
Ok(value) => value,
|
||||||
Err(response) => return response,
|
Err(response) => return response,
|
||||||
};
|
};
|
||||||
@@ -1086,16 +1128,18 @@ pub async fn delete_user(
|
|||||||
enum RouteAccess {
|
enum RouteAccess {
|
||||||
/// Publicly accessible (no auth required)
|
/// Publicly accessible (no auth required)
|
||||||
Public,
|
Public,
|
||||||
/// Any valid account session.
|
/// Account controls are available to non-guest sessions.
|
||||||
Authenticated,
|
Account,
|
||||||
/// Read-only resources (Read, Control, or Administrator required)
|
/// Read-only resources (Guest, Read, Control, or Administrator required)
|
||||||
Read,
|
Read,
|
||||||
/// Bookmarks (Read, Control, Write, or Administrator required)
|
/// Bookmarks (Guest, Read, Control, Transmit, Write, or Administrator required)
|
||||||
ReadWrite,
|
ReadWrite,
|
||||||
/// Logbook access (Write or Administrator required)
|
/// Logbook access (Write or Administrator required)
|
||||||
Write,
|
Write,
|
||||||
/// Radio control (Control or Administrator required)
|
/// Radio control (Control or Administrator required)
|
||||||
Control,
|
Control,
|
||||||
|
/// Transmit actions (Transmit or Administrator required)
|
||||||
|
Transmit,
|
||||||
/// Managed-account administration.
|
/// Managed-account administration.
|
||||||
Administrator,
|
Administrator,
|
||||||
}
|
}
|
||||||
@@ -1118,11 +1162,14 @@ impl RouteAccess {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if path == "/auth/account/password" {
|
if path == "/auth/account/password" {
|
||||||
return Self::Authenticated;
|
return Self::Account;
|
||||||
}
|
}
|
||||||
if path == "/auth/users" || path.starts_with("/auth/users/") {
|
if path == "/auth/users" || path.starts_with("/auth/users/") {
|
||||||
return Self::Administrator;
|
return Self::Administrator;
|
||||||
}
|
}
|
||||||
|
if path == "/set_ptt" || path == "/set_tx_limit" {
|
||||||
|
return Self::Transmit;
|
||||||
|
}
|
||||||
|
|
||||||
// Static assets. The band plan is one of them: it is compiled into the
|
// Static assets. The band plan is one of them: it is compiled into the
|
||||||
// binary and identical for every user, but ".json" is not an asset
|
// binary and identical for every user, but ".json" is not an asset
|
||||||
@@ -1177,12 +1224,13 @@ impl RouteAccess {
|
|||||||
fn allows(&self, roles: Option<&AuthRoles>) -> bool {
|
fn allows(&self, roles: Option<&AuthRoles>) -> bool {
|
||||||
match self {
|
match self {
|
||||||
Self::Public => true,
|
Self::Public => true,
|
||||||
Self::Authenticated => roles.is_some(),
|
Self::Account => roles.is_some_and(roles_allow_account_controls),
|
||||||
Self::Read => roles.is_some_and(|roles| roles_grant(roles, AuthRole::Read)),
|
Self::Read => roles.is_some_and(|roles| roles_grant(roles, AuthRole::Read)),
|
||||||
Self::ReadWrite => roles
|
Self::ReadWrite => roles
|
||||||
.is_some_and(|roles| roles_grant_any(roles, &[AuthRole::Read, AuthRole::Write])),
|
.is_some_and(|roles| roles_grant_any(roles, &[AuthRole::Read, AuthRole::Write])),
|
||||||
Self::Write => roles.is_some_and(|roles| roles_grant(roles, AuthRole::Write)),
|
Self::Write => roles.is_some_and(|roles| roles_grant(roles, AuthRole::Write)),
|
||||||
Self::Control => roles.is_some_and(|roles| roles_grant(roles, AuthRole::Control)),
|
Self::Control => roles.is_some_and(|roles| roles_grant(roles, AuthRole::Control)),
|
||||||
|
Self::Transmit => roles.is_some_and(|roles| roles_grant(roles, AuthRole::Transmit)),
|
||||||
Self::Administrator => {
|
Self::Administrator => {
|
||||||
roles.is_some_and(|roles| roles_grant(roles, AuthRole::Administrator))
|
roles.is_some_and(|roles| roles_grant(roles, AuthRole::Administrator))
|
||||||
}
|
}
|
||||||
@@ -1319,7 +1367,7 @@ mod tests {
|
|||||||
assert_eq!(RouteAccess::from_path("/auth/logout"), RouteAccess::Public);
|
assert_eq!(RouteAccess::from_path("/auth/logout"), RouteAccess::Public);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
RouteAccess::from_path("/auth/account/password"),
|
RouteAccess::from_path("/auth/account/password"),
|
||||||
RouteAccess::Authenticated
|
RouteAccess::Account
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
RouteAccess::from_path("/auth/users"),
|
RouteAccess::from_path("/auth/users"),
|
||||||
@@ -1356,23 +1404,33 @@ mod tests {
|
|||||||
RouteAccess::Control
|
RouteAccess::Control
|
||||||
);
|
);
|
||||||
assert_eq!(RouteAccess::from_path("/set_mode"), RouteAccess::Control);
|
assert_eq!(RouteAccess::from_path("/set_mode"), RouteAccess::Control);
|
||||||
|
assert_eq!(RouteAccess::from_path("/set_ptt"), RouteAccess::Transmit);
|
||||||
|
assert_eq!(
|
||||||
|
RouteAccess::from_path("/set_tx_limit"),
|
||||||
|
RouteAccess::Transmit
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_route_access_allows() {
|
fn test_route_access_allows() {
|
||||||
|
let guest = roles(&[AuthRole::Guest]);
|
||||||
let read = roles(&[AuthRole::Read]);
|
let read = roles(&[AuthRole::Read]);
|
||||||
let control = roles(&[AuthRole::Control]);
|
let control = roles(&[AuthRole::Control]);
|
||||||
|
let transmit = roles(&[AuthRole::Transmit]);
|
||||||
let write = roles(&[AuthRole::Write]);
|
let write = roles(&[AuthRole::Write]);
|
||||||
let administrator = roles(&[AuthRole::Administrator]);
|
let administrator = roles(&[AuthRole::Administrator]);
|
||||||
assert!(RouteAccess::Public.allows(None));
|
assert!(RouteAccess::Public.allows(None));
|
||||||
assert!(RouteAccess::Public.allows(Some(&read)));
|
assert!(RouteAccess::Public.allows(Some(&read)));
|
||||||
assert!(RouteAccess::Public.allows(Some(&administrator)));
|
assert!(RouteAccess::Public.allows(Some(&administrator)));
|
||||||
assert!(!RouteAccess::Authenticated.allows(None));
|
assert!(!RouteAccess::Account.allows(None));
|
||||||
assert!(RouteAccess::Authenticated.allows(Some(&write)));
|
assert!(!RouteAccess::Account.allows(Some(&guest)));
|
||||||
|
assert!(RouteAccess::Account.allows(Some(&write)));
|
||||||
|
|
||||||
assert!(!RouteAccess::Read.allows(None));
|
assert!(!RouteAccess::Read.allows(None));
|
||||||
|
assert!(RouteAccess::Read.allows(Some(&guest)));
|
||||||
assert!(RouteAccess::Read.allows(Some(&read)));
|
assert!(RouteAccess::Read.allows(Some(&read)));
|
||||||
assert!(RouteAccess::Read.allows(Some(&control)));
|
assert!(RouteAccess::Read.allows(Some(&control)));
|
||||||
|
assert!(RouteAccess::Read.allows(Some(&transmit)));
|
||||||
assert!(!RouteAccess::Read.allows(Some(&write)));
|
assert!(!RouteAccess::Read.allows(Some(&write)));
|
||||||
assert!(RouteAccess::Read.allows(Some(&administrator)));
|
assert!(RouteAccess::Read.allows(Some(&administrator)));
|
||||||
|
|
||||||
@@ -1387,7 +1445,11 @@ mod tests {
|
|||||||
assert!(!RouteAccess::Control.allows(None));
|
assert!(!RouteAccess::Control.allows(None));
|
||||||
assert!(!RouteAccess::Control.allows(Some(&read)));
|
assert!(!RouteAccess::Control.allows(Some(&read)));
|
||||||
assert!(RouteAccess::Control.allows(Some(&control)));
|
assert!(RouteAccess::Control.allows(Some(&control)));
|
||||||
|
assert!(!RouteAccess::Control.allows(Some(&transmit)));
|
||||||
assert!(RouteAccess::Control.allows(Some(&administrator)));
|
assert!(RouteAccess::Control.allows(Some(&administrator)));
|
||||||
|
assert!(!RouteAccess::Transmit.allows(Some(&control)));
|
||||||
|
assert!(RouteAccess::Transmit.allows(Some(&transmit)));
|
||||||
|
assert!(RouteAccess::Transmit.allows(Some(&administrator)));
|
||||||
assert!(!RouteAccess::Administrator.allows(Some(&control)));
|
assert!(!RouteAccess::Administrator.allows(Some(&control)));
|
||||||
assert!(RouteAccess::Administrator.allows(Some(&administrator)));
|
assert!(RouteAccess::Administrator.allows(Some(&administrator)));
|
||||||
}
|
}
|
||||||
@@ -1416,6 +1478,28 @@ mod tests {
|
|||||||
assert!(store.get(&session_id).is_none());
|
assert!(store.get(&session_id).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transmit_grant_tracks_live_session_revocation() {
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let state = AuthState::new(test_auth_config(directory.path().join("users.json"))).unwrap();
|
||||||
|
let session_id = state.store.create(
|
||||||
|
"operator".to_string(),
|
||||||
|
roles(&[AuthRole::Transmit]),
|
||||||
|
Duration::from_secs(3600),
|
||||||
|
);
|
||||||
|
assert!(session_id_grants(
|
||||||
|
Some(&session_id),
|
||||||
|
&state,
|
||||||
|
AuthRole::Transmit
|
||||||
|
));
|
||||||
|
state.store.remove(&session_id);
|
||||||
|
assert!(!session_id_grants(
|
||||||
|
Some(&session_id),
|
||||||
|
&state,
|
||||||
|
AuthRole::Transmit
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[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();
|
||||||
@@ -1423,12 +1507,12 @@ mod tests {
|
|||||||
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::all())
|
Some(AuthRole::full_access())
|
||||||
);
|
);
|
||||||
assert_eq!(users.authenticate("admin", "wrong"), None);
|
assert_eq!(users.authenticate("admin", "wrong"), None);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
users.authenticate("guest", "guest"),
|
users.authenticate("guest", "guest"),
|
||||||
Some(roles(&[AuthRole::Read]))
|
Some(roles(&[AuthRole::Guest]))
|
||||||
);
|
);
|
||||||
users
|
users
|
||||||
.add("alice", "password456", roles(&[AuthRole::Read]), true)
|
.add("alice", "password456", roles(&[AuthRole::Read]), true)
|
||||||
@@ -1438,11 +1522,16 @@ mod tests {
|
|||||||
Some(roles(&[AuthRole::Read]))
|
Some(roles(&[AuthRole::Read]))
|
||||||
);
|
);
|
||||||
users
|
users
|
||||||
.update("alice", Some("password789"), Some(AuthRole::all()), None)
|
.update(
|
||||||
|
"alice",
|
||||||
|
Some("password789"),
|
||||||
|
Some(AuthRole::full_access()),
|
||||||
|
None,
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
users.authenticate("alice", "password789"),
|
users.authenticate("alice", "password789"),
|
||||||
Some(AuthRole::all())
|
Some(AuthRole::full_access())
|
||||||
);
|
);
|
||||||
users.remove("admin").unwrap();
|
users.remove("admin").unwrap();
|
||||||
assert_eq!(users.list().len(), 2);
|
assert_eq!(users.list().len(), 2);
|
||||||
@@ -1453,7 +1542,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::all())
|
Some(AuthRole::full_access())
|
||||||
);
|
);
|
||||||
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"));
|
||||||
@@ -1500,6 +1589,15 @@ mod tests {
|
|||||||
assert_eq!(users.authenticate("guest", "guest"), None);
|
assert_eq!(users.authenticate("guest", "guest"), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn guest_is_an_exclusive_role() {
|
||||||
|
assert_eq!(
|
||||||
|
validate_roles(roles(&[AuthRole::Guest])).unwrap(),
|
||||||
|
roles(&[AuthRole::Guest])
|
||||||
|
);
|
||||||
|
assert!(validate_roles(roles(&[AuthRole::Guest, AuthRole::Read])).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn legacy_single_roles_are_migrated() {
|
fn legacy_single_roles_are_migrated() {
|
||||||
let read: UserRecord = serde_json::from_value(serde_json::json!({
|
let read: UserRecord = serde_json::from_value(serde_json::json!({
|
||||||
@@ -1517,7 +1615,7 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(read.roles, roles(&[AuthRole::Read]));
|
assert_eq!(read.roles, roles(&[AuthRole::Read]));
|
||||||
assert!(read.enabled);
|
assert!(read.enabled);
|
||||||
assert_eq!(administrator.roles, AuthRole::all());
|
assert_eq!(administrator.roles, AuthRole::full_access());
|
||||||
assert!(administrator.enabled);
|
assert!(administrator.enabled);
|
||||||
assert!(serde_json::to_value(administrator)
|
assert!(serde_json::to_value(administrator)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -1743,11 +1841,15 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[actix_web::test]
|
#[actix_web::test]
|
||||||
async fn users_can_change_their_own_password() {
|
async fn non_guest_users_can_change_their_own_password() {
|
||||||
let directory = tempfile::tempdir().unwrap();
|
let directory = tempfile::tempdir().unwrap();
|
||||||
let state = web::Data::new(
|
let state = web::Data::new(
|
||||||
AuthState::new(test_auth_config(directory.path().join("users.json"))).unwrap(),
|
AuthState::new(test_auth_config(directory.path().join("users.json"))).unwrap(),
|
||||||
);
|
);
|
||||||
|
state
|
||||||
|
.users
|
||||||
|
.add("reader", "reader-password", roles(&[AuthRole::Read]), true)
|
||||||
|
.unwrap();
|
||||||
let app = aw_test::init_service(
|
let app = aw_test::init_service(
|
||||||
App::new()
|
App::new()
|
||||||
.app_data(state)
|
.app_data(state)
|
||||||
@@ -1760,16 +1862,16 @@ mod tests {
|
|||||||
&app,
|
&app,
|
||||||
aw_test::TestRequest::post()
|
aw_test::TestRequest::post()
|
||||||
.uri("/auth/login")
|
.uri("/auth/login")
|
||||||
.set_json(serde_json::json!({"username":"guest","password":"guest"}))
|
.set_json(serde_json::json!({"username":"reader","password":"reader-password"}))
|
||||||
.to_request(),
|
.to_request(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let guest_cookie = response.response().cookies().next().unwrap().to_string();
|
let reader_cookie = response.response().cookies().next().unwrap().to_string();
|
||||||
|
|
||||||
let change = |current: &str, new: &str| {
|
let change = |current: &str, new: &str| {
|
||||||
aw_test::TestRequest::patch()
|
aw_test::TestRequest::patch()
|
||||||
.uri("/auth/account/password")
|
.uri("/auth/account/password")
|
||||||
.insert_header((actix_web::http::header::COOKIE, guest_cookie.clone()))
|
.insert_header((actix_web::http::header::COOKIE, reader_cookie.clone()))
|
||||||
.set_json(serde_json::json!({
|
.set_json(serde_json::json!({
|
||||||
"current_password":current,"new_password":new
|
"current_password":current,"new_password":new
|
||||||
}))
|
}))
|
||||||
@@ -1782,7 +1884,7 @@ mod tests {
|
|||||||
actix_web::http::StatusCode::FORBIDDEN
|
actix_web::http::StatusCode::FORBIDDEN
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
aw_test::call_service(&app, change("guest", "new-password"))
|
aw_test::call_service(&app, change("reader-password", "new-password"))
|
||||||
.await
|
.await
|
||||||
.status(),
|
.status(),
|
||||||
actix_web::http::StatusCode::OK
|
actix_web::http::StatusCode::OK
|
||||||
@@ -1791,7 +1893,7 @@ mod tests {
|
|||||||
&app,
|
&app,
|
||||||
aw_test::TestRequest::get()
|
aw_test::TestRequest::get()
|
||||||
.uri("/auth/session")
|
.uri("/auth/session")
|
||||||
.insert_header((actix_web::http::header::COOKIE, guest_cookie))
|
.insert_header((actix_web::http::header::COOKIE, reader_cookie))
|
||||||
.to_request(),
|
.to_request(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -1801,7 +1903,7 @@ mod tests {
|
|||||||
&app,
|
&app,
|
||||||
aw_test::TestRequest::post()
|
aw_test::TestRequest::post()
|
||||||
.uri("/auth/login")
|
.uri("/auth/login")
|
||||||
.set_json(serde_json::json!({"username":"guest","password":"new-password"}))
|
.set_json(serde_json::json!({"username":"reader","password":"new-password"}))
|
||||||
.to_request(),
|
.to_request(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -1809,4 +1911,40 @@ mod tests {
|
|||||||
actix_web::http::StatusCode::OK
|
actix_web::http::StatusCode::OK
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn guest_cannot_change_account_password() {
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let state = web::Data::new(
|
||||||
|
AuthState::new(test_auth_config(directory.path().join("users.json"))).unwrap(),
|
||||||
|
);
|
||||||
|
let app = aw_test::init_service(
|
||||||
|
App::new()
|
||||||
|
.app_data(state)
|
||||||
|
.service(login)
|
||||||
|
.service(change_own_password),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let response = aw_test::call_service(
|
||||||
|
&app,
|
||||||
|
aw_test::TestRequest::post()
|
||||||
|
.uri("/auth/login")
|
||||||
|
.set_json(serde_json::json!({"username":"guest","password":"guest"}))
|
||||||
|
.to_request(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let guest_cookie = response.response().cookies().next().unwrap().to_string();
|
||||||
|
let response = aw_test::call_service(
|
||||||
|
&app,
|
||||||
|
aw_test::TestRequest::patch()
|
||||||
|
.uri("/auth/account/password")
|
||||||
|
.insert_header((actix_web::http::header::COOKIE, guest_cookie))
|
||||||
|
.set_json(serde_json::json!({
|
||||||
|
"current_password":"guest","new_password":"new-password"
|
||||||
|
}))
|
||||||
|
.to_request(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), actix_web::http::StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -277,11 +277,11 @@ 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.
|
/// Create a Guest account when bootstrapping a new database.
|
||||||
pub bootstrap_read_enabled: bool,
|
pub bootstrap_read_enabled: bool,
|
||||||
/// Username for the read-only bootstrap account.
|
/// Username for the Guest bootstrap account.
|
||||||
pub bootstrap_read_username: String,
|
pub bootstrap_read_username: String,
|
||||||
/// Password for the read-only bootstrap account.
|
/// Password for the Guest bootstrap account.
|
||||||
pub bootstrap_read_password: Option<String>,
|
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,
|
||||||
@@ -989,7 +989,7 @@ fn validate_http_auth(auth: &HttpAuthConfig) -> Result<(), String> {
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.is_none_or(str::is_empty))
|
.is_none_or(str::is_empty))
|
||||||
{
|
{
|
||||||
return Err("[frontends.http.auth] enabled bootstrap read account requires a non-empty username and password".to_string());
|
return Err("[frontends.http.auth] enabled bootstrap Guest account requires a non-empty username and password".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Session TTL must be > 0
|
// Session TTL must be > 0
|
||||||
|
|||||||
Reference in New Issue
Block a user