[feat](trx-frontend-http): separate transmit permission
CI / frontend (pull_request) Successful in 5m21s
CI / lint (pull_request) Successful in 2m24s
CI / test (pull_request) Successful in 9m11s
CI / test (push) Successful in 8m16s
CI / frontend (push) Successful in 4m24s
CI / reuse (push) Successful in 5s
CI / reuse (pull_request) Successful in 5s
CI / lint (push) Successful in 2m25s

Assisted-By: Codex (GPT-5)
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit was merged in pull request #64.
This commit is contained in:
sjg
2026-08-11 18:36:33 +02:00
parent 44870bc941
commit e978cf8a84
22 changed files with 217 additions and 62 deletions
+2 -1
View File
@@ -926,8 +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
- An exclusive Guest role plus composable Read, Control, Write, and Administrator roles, with policy shared by middleware and handlers - An exclusive Guest role plus composable Read, Control, Transmit, Write, and Administrator roles, with policy shared by middleware and handlers
- Guest sessions receive read-only station access but no account-control endpoints or panels - 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
+2 -2
View File
@@ -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
+5 -4
View File
@@ -127,7 +127,7 @@ When auth is enabled, an **auth gate** blocks the UI with:
- Role badge display - Role badge display
**Guest** provides read-only station access and is exclusive. Non-Guest accounts **Guest** provides read-only station access and is exclusive. Non-Guest accounts
may combine **Read**, **Control**, **Write**, and **Administrator** roles. may combine **Read**, **Control**, **Transmit**, **Write**, and **Administrator** roles.
Administrator implies all permissions. 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.
@@ -337,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` | Guest, 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
+5 -3
View File
@@ -593,7 +593,8 @@ The HTTP frontend supports an optional user/password ACL:
- **Guest** — read-only station access with no Account or Users controls; Guest cannot be combined with another role - **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
@@ -644,8 +645,9 @@ credentials in configuration before first startup on an exposed deployment.
| `/auth/users` | GET/POST | List or add users (admin only) | | `/auth/users` | 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 accept Guest or 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
@@ -13,7 +13,7 @@ import {
logout, logout,
normalizeAuthRoles, normalizeAuthRoles,
updateUser updateUser
} from "./chunk-FT2RH7BL.js"; } from "./chunk-PISLBJGN.js";
// src/webgl-renderer.ts // src/webgl-renderer.ts
(function initTrxWebGl(global) { (function initTrxWebGl(global) {
@@ -1972,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;
@@ -2032,7 +2035,6 @@ function applyAuthRestrictions() {
btn.disabled = true; btn.disabled = true;
} }
}); });
if (txLimitRow2) txLimitRow2.style.opacity = "0.5";
} }
} }
function applyCapabilities(caps) { function applyCapabilities(caps) {
@@ -4250,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;
@@ -7329,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,6 +1,6 @@
import { import {
hasAuthRole hasAuthRole
} from "./chunk-FT2RH7BL.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-FT2RH7BL.js"; } from "./chunk-PISLBJGN.js";
import { import {
hostCore, hostCore,
hostState hostState
@@ -1,10 +1,11 @@
// src/api/auth.ts // src/api/auth.ts
var AUTH_ROLES = ["guest", "read", "control", "write", "administrator"]; var AUTH_ROLES = ["guest", "read", "control", "transmit", "write", "administrator"];
var AUTH_ADMIN_ROLES = AUTH_ROLES.filter((role) => role !== "guest"); var AUTH_ADMIN_ROLES = AUTH_ROLES.filter((role) => role !== "guest");
var AUTH_ROLE_LABELS = { var AUTH_ROLE_LABELS = {
guest: "Guest", guest: "Guest",
read: "Read", read: "Read",
control: "Control", control: "Control",
transmit: "Transmit",
write: "Write", write: "Write",
administrator: "Administrator" administrator: "Administrator"
}; };
@@ -15,7 +16,7 @@ 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("guest") || required === "read" && roles.includes("control"); return roles.includes("administrator") || roles.includes(required) || required === "read" && roles.includes("guest") || required === "read" && (roles.includes("control") || roles.includes("transmit"));
} }
function hasAccountControls(roles) { function hasAccountControls(roles) {
return roles.length > 0 && !roles.includes("guest"); return roles.length > 0 && !roles.includes("guest");
@@ -1,6 +1,6 @@
import { import {
hasAuthRole hasAuthRole
} from "./chunk-FT2RH7BL.js"; } from "./chunk-PISLBJGN.js";
import { import {
hostCore, hostCore,
hostState hostState
@@ -1,6 +1,6 @@
import { import {
hasAuthRole hasAuthRole
} from "./chunk-FT2RH7BL.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,12 +6,13 @@ import type { AuthRole } from "./generated.js";
export type { AuthRole }; export type { AuthRole };
export const AUTH_ROLES: readonly AuthRole[] = ["guest", "read", "control", "write", "administrator"]; export const AUTH_ROLES: readonly AuthRole[] = ["guest", "read", "control", "transmit", "write", "administrator"];
export const AUTH_ADMIN_ROLES: readonly AuthRole[] = AUTH_ROLES.filter(role => role !== "guest"); export const AUTH_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", guest: "Guest",
read: "Read", read: "Read",
control: "Control", control: "Control",
transmit: "Transmit",
write: "Write", write: "Write",
administrator: "Administrator", administrator: "Administrator",
}; };
@@ -28,7 +29,7 @@ export function hasAuthRole(roles: readonly AuthRole[], required: AuthRole): boo
return roles.includes("administrator") return roles.includes("administrator")
|| roles.includes(required) || roles.includes(required)
|| required === "read" && roles.includes("guest") || required === "read" && roles.includes("guest")
|| required === "read" && roles.includes("control"); || required === "read" && (roles.includes("control") || roles.includes("transmit"));
} }
export function hasAccountControls(roles: readonly AuthRole[]): boolean { export function hasAccountControls(roles: readonly AuthRole[]): boolean {
@@ -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 = "guest" | "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";
@@ -584,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);
@@ -656,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";
} }
} }
@@ -3080,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();
} }
@@ -6370,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;
@@ -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,
@@ -15,13 +15,15 @@ function loadAuth(fetch) {
return context.AuthApi; return context.AuthApi;
} }
test("role policy centralizes Guest and implied read access", () => { test("role policy centralizes Guest and separates Control from Transmit", () => {
const auth = loadAuth(async () => { throw new Error("unused"); }); const auth = loadAuth(async () => { throw new Error("unused"); });
assert.deepEqual(Array.from(auth.AUTH_ROLES), ["guest", "read", "control", "write", "administrator"]); assert.deepEqual(Array.from(auth.AUTH_ROLES), ["guest", "read", "control", "transmit", "write", "administrator"]);
assert.equal(auth.hasAuthRole(["guest"], "read"), true); assert.equal(auth.hasAuthRole(["guest"], "read"), true);
assert.equal(auth.hasAccountControls(["guest"]), false); assert.equal(auth.hasAccountControls(["guest"]), false);
assert.equal(auth.hasAccountControls(["read"]), true); 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: {},
@@ -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,
@@ -38,6 +38,7 @@ pub enum AuthRole {
Guest, Guest,
Read, Read,
Control, Control,
Transmit,
Write, Write,
Administrator, Administrator,
} }
@@ -48,22 +49,29 @@ impl AuthRole {
Self::Guest => "guest", 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 full_access() -> BTreeSet<Self> { pub fn full_access() -> BTreeSet<Self> {
[Self::Read, Self::Control, Self::Write, Self::Administrator] [
.into_iter() Self::Read,
.collect() Self::Control,
Self::Transmit,
Self::Write,
Self::Administrator,
]
.into_iter()
.collect()
} }
fn grants(self, required: Self) -> bool { fn grants(self, required: Self) -> bool {
self == Self::Administrator self == Self::Administrator
|| self == required || self == required
|| self == Self::Guest && required == Self::Read || self == Self::Guest && required == Self::Read
|| self == Self::Control && required == Self::Read || matches!(self, Self::Control | Self::Transmit) && required == Self::Read
} }
} }
@@ -776,7 +784,7 @@ pub struct ChangePasswordRequest {
} }
/// Extract session from cookie /// 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())
} }
@@ -789,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(
@@ -1113,12 +1132,14 @@ enum RouteAccess {
Account, Account,
/// Read-only resources (Guest, Read, Control, or Administrator required) /// Read-only resources (Guest, Read, Control, or Administrator required)
Read, Read,
/// Bookmarks (Guest, 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,
} }
@@ -1146,6 +1167,9 @@ impl RouteAccess {
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
@@ -1206,6 +1230,7 @@ impl RouteAccess {
.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))
} }
@@ -1379,6 +1404,11 @@ 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]
@@ -1386,6 +1416,7 @@ mod tests {
let guest = roles(&[AuthRole::Guest]); 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));
@@ -1399,6 +1430,7 @@ mod tests {
assert!(RouteAccess::Read.allows(Some(&guest))); 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)));
@@ -1413,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)));
} }
@@ -1442,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();