Add restricted Guest and Transmit roles #64
@@ -926,8 +926,9 @@ main
|
||||
### HTTP Frontend Auth
|
||||
|
||||
- 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
|
||||
- Transmit separately gates PTT, TX audio frames, and TX power-limit changes
|
||||
- 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
|
||||
- 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.
|
||||
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 cannot be taken from the session's identity: the auth roles are `admin` and `user`, with no
|
||||
notion of who is logged in.
|
||||
It cannot be inferred from the session's identity: an account username need not be an operator
|
||||
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
|
||||
be on a phone in another timezone with a clock nobody has checked. QSO times are UTC from the
|
||||
|
||||
@@ -127,7 +127,7 @@ When auth is enabled, an **auth gate** blocks the UI with:
|
||||
- Role badge display
|
||||
|
||||
**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.
|
||||
|
||||
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
|
||||
|
||||
Routes are classified into three tiers:
|
||||
Routes are classified into access tiers:
|
||||
|
||||
| Tier | Examples | Requirement |
|
||||
|---|---|---|
|
||||
| **Public** | `/`, `/index.html`, `/map`, login/session endpoints, static assets | None |
|
||||
| **Read** | `/status`, `/events`, `/audio`, `/decode`, `/spectrum`, `/bookmarks` | Guest, Read, Control, or Administrator role |
|
||||
| **Control** | `/set_freq`, `/set_mode`, `/set_ptt`, `/toggle_power`, radio-control POST routes | Control or Administrator role |
|
||||
| **Read** | `/status`, `/events`, `/audio`, `/decode`, `/spectrum`, `/bookmarks` | Guest, Read, Control, Transmit, 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 |
|
||||
|
||||
### 7.2 Session Management
|
||||
|
||||
+5
-3
@@ -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
|
||||
- **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
|
||||
- **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/{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
|
||||
bookmark mutations require Write. Administrator grants every permission.
|
||||
Read routes accept Guest or require Read. Tuning and receive-side radio mutations
|
||||
require Control. PTT, transmitted audio, and TX limit changes require Transmit.
|
||||
Logbook access and bookmark mutations require Write. Administrator grants every permission.
|
||||
|
||||
### Frontend Flow
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
logout,
|
||||
normalizeAuthRoles,
|
||||
updateUser
|
||||
} from "./chunk-FT2RH7BL.js";
|
||||
} from "./chunk-PISLBJGN.js";
|
||||
|
||||
// src/webgl-renderer.ts
|
||||
(function initTrxWebGl(global) {
|
||||
@@ -1972,30 +1972,33 @@ function updateAuthUI() {
|
||||
}
|
||||
function applyAuthRestrictions() {
|
||||
if (authRoles.length === 0) return;
|
||||
if (!hasAuthRole2("control")) {
|
||||
if (!hasAuthRole2("transmit")) {
|
||||
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 lockBtn2 = document.getElementById("lock-btn");
|
||||
const freqInput = document.getElementById("freq");
|
||||
const centerFreqInput = document.getElementById("center-freq");
|
||||
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 jogDown = document.getElementById("jog-down");
|
||||
const jogButtons = document.querySelectorAll(".jog-step button");
|
||||
const vfoButtons = document.querySelectorAll("#vfo-picker button");
|
||||
if (pttBtn2) pttBtn2.disabled = true;
|
||||
if (powerBtn2) powerBtn2.disabled = true;
|
||||
if (lockBtn2) lockBtn2.disabled = true;
|
||||
if (txAudioBtn2) txAudioBtn2.disabled = true;
|
||||
if (txLimitBtn2) txLimitBtn2.disabled = true;
|
||||
if (freqInput) freqInput.disabled = true;
|
||||
if (centerFreqInput) centerFreqInput.disabled = true;
|
||||
if (modeSelect) modeSelect.disabled = true;
|
||||
if (txLimitInput2) txLimitInput2.disabled = true;
|
||||
vfoButtons.forEach((btn) => btn.disabled = true);
|
||||
const jogWheel2 = document.getElementById("jog-wheel");
|
||||
if (jogUp) jogUp.disabled = true;
|
||||
@@ -2032,7 +2035,6 @@ function applyAuthRestrictions() {
|
||||
btn.disabled = true;
|
||||
}
|
||||
});
|
||||
if (txLimitRow2) txLimitRow2.style.opacity = "0.5";
|
||||
}
|
||||
}
|
||||
function applyCapabilities(caps) {
|
||||
@@ -4250,9 +4252,16 @@ function formatSignal(sUnits) {
|
||||
return overDb === 0 ? `${sigUnit("S")}9` : `${sigUnit("S")}9+${overDb}${sigUnit("dB")}`;
|
||||
}
|
||||
function setDisabled(disabled) {
|
||||
[freqEl, centerFreqEl, modeEl, pttBtn, powerBtn, txLimitInput, txLimitBtn, lockBtn].forEach((el) => {
|
||||
if (el) el.disabled = disabled;
|
||||
const controlDisabled = disabled || authEnabled && !hasAuthRole2("control");
|
||||
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();
|
||||
}
|
||||
var serverVersion = null;
|
||||
@@ -7329,6 +7338,10 @@ function startTxAudio() {
|
||||
void stopTxAudio();
|
||||
return;
|
||||
}
|
||||
if (authEnabled && !hasAuthRole2("transmit")) {
|
||||
audioStatus.textContent = "Transmit role required";
|
||||
return;
|
||||
}
|
||||
if (!hasWebCodecs) {
|
||||
audioStatus.textContent = "Audio requires Chrome/Edge";
|
||||
return;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
hasAuthRole
|
||||
} from "./chunk-FT2RH7BL.js";
|
||||
} from "./chunk-PISLBJGN.js";
|
||||
import {
|
||||
hostState
|
||||
} from "./chunk-KL66PICH.js";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
hasAuthRole
|
||||
} from "./chunk-FT2RH7BL.js";
|
||||
} from "./chunk-PISLBJGN.js";
|
||||
import {
|
||||
hostCore,
|
||||
hostState
|
||||
|
||||
+3
-2
@@ -1,10 +1,11 @@
|
||||
// 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_ROLE_LABELS = {
|
||||
guest: "Guest",
|
||||
read: "Read",
|
||||
control: "Control",
|
||||
transmit: "Transmit",
|
||||
write: "Write",
|
||||
administrator: "Administrator"
|
||||
};
|
||||
@@ -15,7 +16,7 @@ function normalizeAuthRoles(roles) {
|
||||
return AUTH_ROLES.filter((role) => roles.includes(role));
|
||||
}
|
||||
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) {
|
||||
return roles.length > 0 && !roles.includes("guest");
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
hasAuthRole
|
||||
} from "./chunk-FT2RH7BL.js";
|
||||
} from "./chunk-PISLBJGN.js";
|
||||
import {
|
||||
hostCore,
|
||||
hostState
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
hasAuthRole
|
||||
} from "./chunk-FT2RH7BL.js";
|
||||
} from "./chunk-PISLBJGN.js";
|
||||
import {
|
||||
hostState
|
||||
} from "./chunk-KL66PICH.js";
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
|
||||
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -6,12 +6,13 @@ import type { AuthRole } from "./generated.js";
|
||||
|
||||
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_ROLE_LABELS: Readonly<Record<AuthRole, string>> = {
|
||||
guest: "Guest",
|
||||
read: "Read",
|
||||
control: "Control",
|
||||
transmit: "Transmit",
|
||||
write: "Write",
|
||||
administrator: "Administrator",
|
||||
};
|
||||
@@ -28,7 +29,7 @@ export function hasAuthRole(roles: readonly AuthRole[], required: AuthRole): boo
|
||||
return roles.includes("administrator")
|
||||
|| roles.includes(required)
|
||||
|| 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 {
|
||||
|
||||
@@ -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 AuthRole = "guest" | "read" | "control" | "write" | "administrator";
|
||||
export type AuthRole = "guest" | "read" | "control" | "transmit" | "write" | "administrator";
|
||||
|
||||
export type DecoderActivation = "mode_bound" | "toggle";
|
||||
|
||||
|
||||
@@ -584,35 +584,38 @@ function updateAuthUI() {
|
||||
function applyAuthRestrictions() {
|
||||
if (authRoles.length === 0) return;
|
||||
|
||||
// Disable TX/PTT/frequency/mode/VFO controls for user role
|
||||
if (!hasAuthRole("control")) {
|
||||
if (!hasAuthRole("transmit")) {
|
||||
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 lockBtn = document.getElementById("lock-btn") as HTMLButtonElement | null;
|
||||
const freqInput = document.getElementById("freq") as HTMLInputElement | null;
|
||||
const centerFreqInput = document.getElementById("center-freq") as HTMLInputElement | 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 jogDown = document.getElementById("jog-down") as HTMLButtonElement | null;
|
||||
const jogButtons = document.querySelectorAll<HTMLButtonElement>(".jog-step button");
|
||||
const vfoButtons = document.querySelectorAll<HTMLButtonElement>("#vfo-picker button");
|
||||
|
||||
// Disable TX buttons
|
||||
if (pttBtn) pttBtn.disabled = true;
|
||||
if (powerBtn) powerBtn.disabled = true;
|
||||
if (lockBtn) lockBtn.disabled = true;
|
||||
if (txAudioBtn) txAudioBtn.disabled = true;
|
||||
if (txLimitBtn) txLimitBtn.disabled = true;
|
||||
|
||||
// Disable frequency/mode inputs
|
||||
if (freqInput) freqInput.disabled = true;
|
||||
if (centerFreqInput) centerFreqInput.disabled = true;
|
||||
if (modeSelect) modeSelect.disabled = true;
|
||||
if (txLimitInput) txLimitInput.disabled = true;
|
||||
|
||||
// Disable VFO selector
|
||||
vfoButtons.forEach(btn => btn.disabled = true);
|
||||
@@ -656,9 +659,6 @@ function applyAuthRestrictions() {
|
||||
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) {
|
||||
[freqEl, centerFreqEl, modeEl, pttBtn, powerBtn, txLimitInput, txLimitBtn, lockBtn].forEach((el) => {
|
||||
if (el) el.disabled = disabled;
|
||||
const controlDisabled = disabled || (authEnabled && !hasAuthRole("control"));
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -6370,6 +6377,10 @@ function stopRxAudio() {
|
||||
|
||||
function startTxAudio() {
|
||||
if (txActive) { void stopTxAudio(); return; }
|
||||
if (authEnabled && !hasAuthRole("transmit")) {
|
||||
audioStatus.textContent = "Transmit role required";
|
||||
return;
|
||||
}
|
||||
if (!hasWebCodecs) {
|
||||
audioStatus.textContent = "Audio requires Chrome/Edge";
|
||||
return;
|
||||
|
||||
@@ -8,7 +8,7 @@ import { startBrowser, startWebFixture } from "./web-fixture.mjs";
|
||||
|
||||
/* global document */
|
||||
|
||||
const ALL_ROLES = ["read", "control", "write", "administrator"];
|
||||
const ALL_ROLES = ["read", "control", "transmit", "write", "administrator"];
|
||||
const fixture = await startWebFixture({
|
||||
authSession: {
|
||||
authenticated: true,
|
||||
|
||||
@@ -15,13 +15,15 @@ function loadAuth(fetch) {
|
||||
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"); });
|
||||
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.hasAccountControls(["guest"]), false);
|
||||
assert.equal(auth.hasAccountControls(["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(["administrator"], "write"), true);
|
||||
});
|
||||
|
||||
@@ -32,7 +32,7 @@ function hostFixture(overrides = {}) {
|
||||
const calls = { postPath: [], setRigFrequency: [], armOptimisticFrequency: [], applyLocalTunedFrequency: [], syncBandwidthInput: [], scheduleSpectrumDraw: 0, syncModePicker: 0 };
|
||||
const state = {
|
||||
authEnabled: false,
|
||||
authRoles: ["read", "control", "write", "administrator"],
|
||||
authRoles: ["read", "control", "transmit", "write", "administrator"],
|
||||
lastActiveRigId: null,
|
||||
lastRigIds: [],
|
||||
lastRigDisplayNames: {},
|
||||
|
||||
@@ -19,7 +19,7 @@ export function createHost({ state = {}, core = {}, modules = {} } = {}) {
|
||||
serverLat: null,
|
||||
serverLon: null,
|
||||
authEnabled: false,
|
||||
authRoles: ["read", "control", "write", "administrator"],
|
||||
authRoles: ["read", "control", "transmit", "write", "administrator"],
|
||||
lastActiveRigId: null,
|
||||
lastRigIds: [],
|
||||
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,
|
||||
bandplanUnauthorizedFirst = false,
|
||||
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 = [],
|
||||
} = {}) {
|
||||
const rigItems = ["rig-a", "rig-b"].map((remote) => ({
|
||||
|
||||
@@ -737,6 +737,7 @@ pub async fn audio_ws(
|
||||
body: web::Payload,
|
||||
query: web::Query<AudioQuery>,
|
||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||
auth_state: web::Data<crate::server::auth::AuthState>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let Some(tx_sender) = context.audio.tx.as_ref().cloned() else {
|
||||
return Ok(HttpResponse::NotFound().body("audio not enabled"));
|
||||
@@ -746,6 +747,7 @@ pub async fn audio_ws(
|
||||
if !req.headers().contains_key("upgrade") {
|
||||
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.
|
||||
// The entry is created asynchronously when AUDIO_MSG_VCHAN_ALLOCATED arrives
|
||||
@@ -880,6 +882,16 @@ pub async fn audio_ws(
|
||||
msg = msg_stream.recv() => {
|
||||
match msg {
|
||||
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;
|
||||
}
|
||||
Some(Ok(Message::Close(_))) => break,
|
||||
|
||||
@@ -38,6 +38,7 @@ pub enum AuthRole {
|
||||
Guest,
|
||||
Read,
|
||||
Control,
|
||||
Transmit,
|
||||
Write,
|
||||
Administrator,
|
||||
}
|
||||
@@ -48,13 +49,20 @@ impl AuthRole {
|
||||
Self::Guest => "guest",
|
||||
Self::Read => "read",
|
||||
Self::Control => "control",
|
||||
Self::Transmit => "transmit",
|
||||
Self::Write => "write",
|
||||
Self::Administrator => "administrator",
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
.collect()
|
||||
}
|
||||
@@ -63,7 +71,7 @@ impl AuthRole {
|
||||
self == Self::Administrator
|
||||
|| self == required
|
||||
|| 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
|
||||
fn extract_session_id(req: &HttpRequest) -> Option<SessionId> {
|
||||
pub fn extract_session_id(req: &HttpRequest) -> Option<SessionId> {
|
||||
req.cookie("trx_http_sid")
|
||||
.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 {
|
||||
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(
|
||||
@@ -1113,12 +1132,14 @@ enum RouteAccess {
|
||||
Account,
|
||||
/// Read-only resources (Guest, Read, Control, or Administrator required)
|
||||
Read,
|
||||
/// Bookmarks (Guest, Read, Control, Write, or Administrator required)
|
||||
/// Bookmarks (Guest, Read, Control, Transmit, Write, or Administrator required)
|
||||
ReadWrite,
|
||||
/// Logbook access (Write or Administrator required)
|
||||
Write,
|
||||
/// Radio control (Control or Administrator required)
|
||||
Control,
|
||||
/// Transmit actions (Transmit or Administrator required)
|
||||
Transmit,
|
||||
/// Managed-account administration.
|
||||
Administrator,
|
||||
}
|
||||
@@ -1146,6 +1167,9 @@ impl RouteAccess {
|
||||
if path == "/auth/users" || path.starts_with("/auth/users/") {
|
||||
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
|
||||
// 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])),
|
||||
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::Transmit => roles.is_some_and(|roles| roles_grant(roles, AuthRole::Transmit)),
|
||||
Self::Administrator => {
|
||||
roles.is_some_and(|roles| roles_grant(roles, AuthRole::Administrator))
|
||||
}
|
||||
@@ -1379,6 +1404,11 @@ mod tests {
|
||||
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]
|
||||
@@ -1386,6 +1416,7 @@ mod tests {
|
||||
let guest = roles(&[AuthRole::Guest]);
|
||||
let read = roles(&[AuthRole::Read]);
|
||||
let control = roles(&[AuthRole::Control]);
|
||||
let transmit = roles(&[AuthRole::Transmit]);
|
||||
let write = roles(&[AuthRole::Write]);
|
||||
let administrator = roles(&[AuthRole::Administrator]);
|
||||
assert!(RouteAccess::Public.allows(None));
|
||||
@@ -1399,6 +1430,7 @@ mod tests {
|
||||
assert!(RouteAccess::Read.allows(Some(&guest)));
|
||||
assert!(RouteAccess::Read.allows(Some(&read)));
|
||||
assert!(RouteAccess::Read.allows(Some(&control)));
|
||||
assert!(RouteAccess::Read.allows(Some(&transmit)));
|
||||
assert!(!RouteAccess::Read.allows(Some(&write)));
|
||||
assert!(RouteAccess::Read.allows(Some(&administrator)));
|
||||
|
||||
@@ -1413,7 +1445,11 @@ mod tests {
|
||||
assert!(!RouteAccess::Control.allows(None));
|
||||
assert!(!RouteAccess::Control.allows(Some(&read)));
|
||||
assert!(RouteAccess::Control.allows(Some(&control)));
|
||||
assert!(!RouteAccess::Control.allows(Some(&transmit)));
|
||||
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(&administrator)));
|
||||
}
|
||||
@@ -1442,6 +1478,28 @@ mod tests {
|
||||
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]
|
||||
fn user_store_bootstraps_and_manages_users() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user