Add restricted guest role
CI / lint (pull_request) Successful in 2m26s
CI / test (pull_request) Successful in 9m22s
CI / frontend (pull_request) Successful in 5m15s
CI / reuse (pull_request) Successful in 5s

This commit is contained in:
sjg
2026-08-11 15:24:51 +02:00
parent 5e9dae02c7
commit 721ff04908
17 changed files with 240 additions and 66 deletions
+2 -1
View File
@@ -926,7 +926,8 @@ main
### HTTP Frontend Auth
- 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, 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
- 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
+6 -4
View File
@@ -126,7 +126,8 @@ When auth is enabled, an **auth gate** blocks the UI with:
- Error message area (red `#ff6b6b`)
- Role badge display
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**, **Write**, and **Administrator** roles.
Administrator implies all permissions.
Session cookie: `trx_http_sid`, HttpOnly, configurable Secure and SameSite attributes.
@@ -341,7 +342,7 @@ Routes are classified into three tiers:
| Tier | Examples | Requirement |
|---|---|---|
| **Public** | `/`, `/index.html`, `/map`, login/session endpoints, static assets | None |
| **Read** | `/status`, `/events`, `/audio`, `/decode`, `/spectrum`, `/bookmarks` | Read, Control, or Administrator role |
| **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 |
| **Write** | Logbook access and bookmark mutations | Write or Administrator role |
@@ -354,8 +355,9 @@ Routes are classified into three tiers:
### 7.3 User Management
Every authenticated account gets a Settings > Account tab for changing its own
password. Only administrators get Settings > Users, where accounts can be
Every authenticated non-Guest account gets a Settings > Account tab for changing
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
removed. The final enabled administrator cannot be disabled, removed, or
demoted. Account security changes revoke every active session for that account.
+11 -10
View File
@@ -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_password` | string | — | First administrator password |
| `bootstrap_admin_password_file` | string | — | Read the bootstrap password from this file instead |
| `bootstrap_read_enabled` | bool | `true` | Create the default read-only account when the database is absent |
| `bootstrap_read_username` | string | `"guest"` | Initial read-only username |
| `bootstrap_read_password` | string | `"guest"` | Initial read-only password |
| `bootstrap_read_enabled` | bool | `true` | Create the default Guest account when the database is absent |
| `bootstrap_read_username` | string | `"guest"` | Initial Guest username |
| `bootstrap_read_password` | string | `"guest"` | Initial Guest password |
| `session_ttl_min` | u64 | `480` | Session lifetime |
| `cookie_secure` | bool | `false` | Set Secure on the session cookie (needs HTTPS) |
| `cookie_same_site` | string | `"Lax"` | `Strict`, `Lax`, or `None` |
@@ -589,9 +589,9 @@ left as it was, rather than publishing a frequency the rig is not on.
## Authentication
The HTTP frontend supports an optional user/password ACL with multiple independent
roles. One account may have any combination:
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
- **Write** — logbook access and bookmark changes
@@ -615,7 +615,7 @@ cookie_same_site = "Lax" # Strict|Lax|None
When `enabled = false` (the default), all auth is bypassed and the UI behaves
as before. When enabling it for the first time, bootstrap credentials create
the initial administrator (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
credentials in configuration before first startup on an exposed deployment.
@@ -625,8 +625,9 @@ credentials in configuration before first startup on an exposed deployment.
- Sessions are in-memory; a server restart invalidates all sessions.
- Rate limiting is applied per IP to mitigate brute-force attempts.
- User records persist in `users_file`; passwords are stored as salted Argon2id hashes.
- 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.
- Non-Guest roles are independent; for example, an account may have Read and Write without Control.
- 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.
- 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.
@@ -639,11 +640,11 @@ credentials in configuration before first startup on an exposed deployment.
| `/auth/login` | POST | Submit `{ "username": "...", "password": "..." }` |
| `/auth/logout` | POST | Clear session |
| `/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/{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. Radio mutations require Control. Logbook access and
bookmark mutations require Write. Administrator grants every permission.
### Frontend Flow
@@ -1,17 +1,19 @@
import {
AUTH_ADMIN_ROLES,
AUTH_ROLES,
AUTH_ROLE_LABELS,
changeOwnPassword,
createUser,
deleteUser,
fetchAuthSession,
hasAccountControls,
hasAuthRole,
listUsers,
login,
logout,
normalizeAuthRoles,
updateUser
} from "./chunk-BB2X7SND.js";
} from "./chunk-FT2RH7BL.js";
// src/webgl-renderer.ts
(function initTrxWebGl(global) {
@@ -1846,6 +1848,19 @@ function buildRoleChoices(selected) {
element.append(label);
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 };
}
async function checkAuthStatus() {
@@ -1932,7 +1947,13 @@ function updateAuthUI() {
return;
}
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 (badgeRole) badgeRole.textContent = `${authUsername || "local"}${authRoles.map((role) => AUTH_ROLE_LABELS[role]).join(", ")}`;
if (headerAuthBtn2) {
@@ -5974,7 +5995,7 @@ async function initializeApp() {
const authStatus = await checkAuthStatus();
authEnabled = !authStatus.auth_disabled;
if (!authEnabled) {
setAuthRoles(AUTH_ROLES);
setAuthRoles(AUTH_ADMIN_ROLES);
hideAuthGate();
updateAuthUI();
connect();
@@ -6047,10 +6068,15 @@ async function refreshUserManagement() {
enabledLabel.append(enabled, " Enabled");
const isOnlyAdmin = user.enabled && hasAuthRole(user.roles, "administrator") && enabledAdminCount === 1;
const administratorInput = roleInputs.find((item) => item.value === "administrator")?.input;
const guestInput = roleInputs.find((item) => item.value === "guest")?.input;
if (isOnlyAdmin && administratorInput) {
administratorInput.disabled = true;
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) {
enabled.disabled = true;
enabled.title = "The final enabled administrator cannot be disabled";
@@ -1,6 +1,6 @@
import {
hasAuthRole
} from "./chunk-BB2X7SND.js";
} from "./chunk-FT2RH7BL.js";
import {
hostState
} from "./chunk-KL66PICH.js";
@@ -1,6 +1,6 @@
import {
hasAuthRole
} from "./chunk-BB2X7SND.js";
} from "./chunk-FT2RH7BL.js";
import {
hostCore,
hostState
@@ -1,6 +1,8 @@
// src/api/auth.ts
var AUTH_ROLES = ["read", "control", "write", "administrator"];
var AUTH_ROLES = ["guest", "read", "control", "write", "administrator"];
var AUTH_ADMIN_ROLES = AUTH_ROLES.filter((role) => role !== "guest");
var AUTH_ROLE_LABELS = {
guest: "Guest",
read: "Read",
control: "Control",
write: "Write",
@@ -13,7 +15,10 @@ 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("control");
return roles.includes("administrator") || roles.includes(required) || required === "read" && roles.includes("guest") || required === "read" && roles.includes("control");
}
function hasAccountControls(roles) {
return roles.length > 0 && !roles.includes("guest");
}
function decodeRoles(value, context) {
if (!Array.isArray(value) || !value.every(isAuthRole)) {
@@ -45,7 +50,7 @@ function decodeAuthSession(value) {
}
var authDisabledSession = {
authenticated: true,
roles: ["read", "control", "write", "administrator"],
roles: [...AUTH_ADMIN_ROLES],
auth_disabled: true
};
async function fetchAuthSession() {
@@ -114,9 +119,11 @@ async function logout() {
export {
AUTH_ROLES,
AUTH_ADMIN_ROLES,
AUTH_ROLE_LABELS,
normalizeAuthRoles,
hasAuthRole,
hasAccountControls,
fetchAuthSession,
login,
listUsers,
@@ -1,6 +1,6 @@
import {
hasAuthRole
} from "./chunk-BB2X7SND.js";
} from "./chunk-FT2RH7BL.js";
import {
hostCore,
hostState
@@ -1,6 +1,6 @@
import {
hasAuthRole
} from "./chunk-BB2X7SND.js";
} from "./chunk-FT2RH7BL.js";
import {
hostState
} from "./chunk-KL66PICH.js";
@@ -6,8 +6,10 @@ import type { AuthRole } from "./generated.js";
export type { AuthRole };
export const AUTH_ROLES: readonly AuthRole[] = ["read", "control", "write", "administrator"];
export const AUTH_ROLES: readonly AuthRole[] = ["guest", "read", "control", "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",
write: "Write",
@@ -25,9 +27,14 @@ export function normalizeAuthRoles(roles: readonly AuthRole[]): AuthRole[] {
export function hasAuthRole(roles: readonly AuthRole[], required: AuthRole): boolean {
return roles.includes("administrator")
|| roles.includes(required)
|| required === "read" && roles.includes("guest")
|| required === "read" && roles.includes("control");
}
export function hasAccountControls(roles: readonly AuthRole[]): boolean {
return roles.length > 0 && !roles.includes("guest");
}
function decodeRoles(value: unknown, context: string): AuthRole[] {
if (!Array.isArray(value) || !value.every(isAuthRole)) {
throw new TypeError(`${context} has invalid roles`);
@@ -67,7 +74,7 @@ function decodeAuthSession(value: unknown): AuthSession {
const authDisabledSession: AuthSession = {
authenticated: true,
roles: ["read", "control", "write", "administrator"],
roles: [...AUTH_ADMIN_ROLES],
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 AuthRole = "read" | "control" | "write" | "administrator";
export type AuthRole = "guest" | "read" | "control" | "write" | "administrator";
export type DecoderActivation = "mode_bound" | "toggle";
@@ -26,8 +26,10 @@ import {
updateUser,
deleteUser,
changeOwnPassword,
AUTH_ADMIN_ROLES,
AUTH_ROLES,
AUTH_ROLE_LABELS,
hasAccountControls,
hasAuthRole as rolesInclude,
normalizeAuthRoles,
} from "./api/auth.js";
@@ -441,6 +443,17 @@ function buildRoleChoices(selected: readonly AuthRole[]) {
element.append(label);
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 };
}
@@ -544,7 +557,13 @@ function updateAuthUI() {
}
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 (badgeRole) badgeRole.textContent = `${authUsername || "local"}${authRoles.map(role => AUTH_ROLE_LABELS[role]).join(", ")}`;
if (headerAuthBtn) {
@@ -5050,7 +5069,7 @@ async function initializeApp() {
authEnabled = !authStatus.auth_disabled;
if (!authEnabled) {
setAuthRoles(AUTH_ROLES);
setAuthRoles(AUTH_ADMIN_ROLES);
hideAuthGate();
updateAuthUI();
connect();
@@ -5132,10 +5151,15 @@ async function refreshUserManagement() {
&& rolesInclude(user.roles, "administrator")
&& enabledAdminCount === 1;
const administratorInput = roleInputs.find(item => item.value === "administrator")?.input;
const guestInput = roleInputs.find(item => item.value === "guest")?.input;
if (isOnlyAdmin && administratorInput) {
administratorInput.disabled = true;
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) {
enabled.disabled = true;
enabled.title = "The final enabled administrator cannot be disabled";
@@ -47,14 +47,16 @@ try {
createRoles: [...document.querySelectorAll("#user-create-roles input")].map((input) => input.value),
adminEnabledLocked: admin?.querySelector('input[type="checkbox"]')?.disabled,
adminRoleLocked: role(admin, "administrator")?.disabled,
adminGuestLocked: role(admin, "guest")?.disabled,
adminRemoveLocked: admin?.querySelector("button.danger")?.disabled,
listenerEnabled: listener?.querySelector('input[type="checkbox"]')?.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.adminRoleLocked, true);
assert.equal(state.adminGuestLocked, true);
assert.equal(state.adminRemoveLocked, true);
assert.equal(state.listenerEnabled, false);
assert.equal(state.listenerRead, true);
@@ -63,3 +65,23 @@ try {
await browser.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,9 +15,12 @@ function loadAuth(fetch) {
return context.AuthApi;
}
test("role policy is centralized and preserves the Control-to-Read implication", () => {
test("role policy centralizes Guest and implied read access", () => {
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", "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"], "write"), false);
assert.equal(auth.hasAuthRole(["administrator"], "write"), true);
@@ -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="user-create-enabled"/);
assert.match(app, /changeOwnPassword/);
assert.match(app, /hasAccountControls\(authRoles\)/);
assert.match(app, /enabledAdminCount/);
});
@@ -35,6 +35,7 @@ pub type SessionId = String;
)]
#[serde(rename_all = "lowercase")]
pub enum AuthRole {
Guest,
Read,
Control,
Write,
@@ -44,6 +45,7 @@ pub enum AuthRole {
impl AuthRole {
pub fn as_str(&self) -> &'static str {
match self {
Self::Guest => "guest",
Self::Read => "read",
Self::Control => "control",
Self::Write => "write",
@@ -51,7 +53,7 @@ impl AuthRole {
}
}
pub fn all() -> BTreeSet<Self> {
pub fn full_access() -> BTreeSet<Self> {
[Self::Read, Self::Control, Self::Write, Self::Administrator]
.into_iter()
.collect()
@@ -60,6 +62,7 @@ impl AuthRole {
fn grants(self, required: Self) -> bool {
self == Self::Administrator
|| self == required
|| self == Self::Guest && required == Self::Read
|| self == Self::Control && required == Self::Read
}
}
@@ -74,9 +77,13 @@ fn roles_grant_any(roles: &AuthRoles, required: &[AuthRole]) -> bool {
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> {
if roles.contains(&AuthRole::Administrator) {
AuthRole::all().into_iter().collect()
AuthRole::full_access().into_iter().collect()
} else {
roles.iter().copied().collect()
}
@@ -311,7 +318,7 @@ impl<'de> Deserialize<'de> for UserRecord {
let roles = match (stored.roles, stored.role) {
(Some(roles), _) => roles,
(None, Some(LegacyRole::User)) => [AuthRole::Read].into_iter().collect(),
(None, Some(LegacyRole::Admin)) => AuthRole::all(),
(None, Some(LegacyRole::Admin)) => AuthRole::full_access(),
(None, None) => {
return Err(serde::de::Error::missing_field("roles"));
}
@@ -390,7 +397,7 @@ impl UserStore {
let mut records = vec![UserRecord {
username: validate_username(&administrator.username)?.to_string(),
password_hash: hash_password(&administrator.password)?,
roles: AuthRole::all(),
roles: AuthRole::full_access(),
enabled: true,
}];
if let Some(reader) = &config.bootstrap_read {
@@ -405,7 +412,7 @@ impl UserStore {
records.push(UserRecord {
username: validate_username(&reader.username)?.to_string(),
password_hash: hash_password_value(&reader.password)?,
roles: [AuthRole::Read].into_iter().collect(),
roles: [AuthRole::Guest].into_iter().collect(),
enabled: true,
});
}
@@ -617,6 +624,9 @@ fn validate_roles(roles: AuthRoles) -> Result<AuthRoles, String> {
if roles.is_empty() {
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)
}
@@ -812,6 +822,19 @@ fn require_admin(req: &HttpRequest, auth_state: &AuthState) -> Result<SessionRec
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> {
let mut cookie = Cookie::new("trx_http_sid", value);
cookie.set_path("/");
@@ -925,7 +948,7 @@ pub async fn session_status(
if !auth_state.config.enabled {
return Ok(HttpResponse::Ok().json(SessionStatus {
authenticated: true,
roles: effective_roles(&AuthRole::all()),
roles: effective_roles(&AuthRole::full_access()),
username: None,
auth_disabled: true,
}));
@@ -958,7 +981,7 @@ pub async fn change_own_password(
body: web::Json<ChangePasswordRequest>,
auth_state: web::Data<AuthState>,
) -> impl Responder {
let session = match require_session(&req, &auth_state) {
let session = match require_account_controls(&req, &auth_state) {
Ok(value) => value,
Err(response) => return response,
};
@@ -1086,11 +1109,11 @@ pub async fn delete_user(
enum RouteAccess {
/// Publicly accessible (no auth required)
Public,
/// Any valid account session.
Authenticated,
/// Read-only resources (Read, Control, or Administrator required)
/// Account controls are available to non-guest sessions.
Account,
/// Read-only resources (Guest, Read, Control, or Administrator required)
Read,
/// Bookmarks (Read, Control, Write, or Administrator required)
/// Bookmarks (Guest, Read, Control, Write, or Administrator required)
ReadWrite,
/// Logbook access (Write or Administrator required)
Write,
@@ -1118,7 +1141,7 @@ impl RouteAccess {
}
if path == "/auth/account/password" {
return Self::Authenticated;
return Self::Account;
}
if path == "/auth/users" || path.starts_with("/auth/users/") {
return Self::Administrator;
@@ -1177,7 +1200,7 @@ impl RouteAccess {
fn allows(&self, roles: Option<&AuthRoles>) -> bool {
match self {
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::ReadWrite => roles
.is_some_and(|roles| roles_grant_any(roles, &[AuthRole::Read, AuthRole::Write])),
@@ -1319,7 +1342,7 @@ mod tests {
assert_eq!(RouteAccess::from_path("/auth/logout"), RouteAccess::Public);
assert_eq!(
RouteAccess::from_path("/auth/account/password"),
RouteAccess::Authenticated
RouteAccess::Account
);
assert_eq!(
RouteAccess::from_path("/auth/users"),
@@ -1360,6 +1383,7 @@ mod tests {
#[test]
fn test_route_access_allows() {
let guest = roles(&[AuthRole::Guest]);
let read = roles(&[AuthRole::Read]);
let control = roles(&[AuthRole::Control]);
let write = roles(&[AuthRole::Write]);
@@ -1367,10 +1391,12 @@ mod tests {
assert!(RouteAccess::Public.allows(None));
assert!(RouteAccess::Public.allows(Some(&read)));
assert!(RouteAccess::Public.allows(Some(&administrator)));
assert!(!RouteAccess::Authenticated.allows(None));
assert!(RouteAccess::Authenticated.allows(Some(&write)));
assert!(!RouteAccess::Account.allows(None));
assert!(!RouteAccess::Account.allows(Some(&guest)));
assert!(RouteAccess::Account.allows(Some(&write)));
assert!(!RouteAccess::Read.allows(None));
assert!(RouteAccess::Read.allows(Some(&guest)));
assert!(RouteAccess::Read.allows(Some(&read)));
assert!(RouteAccess::Read.allows(Some(&control)));
assert!(!RouteAccess::Read.allows(Some(&write)));
@@ -1423,12 +1449,12 @@ mod tests {
let users = UserStore::open(&config).unwrap();
assert_eq!(
users.authenticate("admin", "password123"),
Some(AuthRole::all())
Some(AuthRole::full_access())
);
assert_eq!(users.authenticate("admin", "wrong"), None);
assert_eq!(
users.authenticate("guest", "guest"),
Some(roles(&[AuthRole::Read]))
Some(roles(&[AuthRole::Guest]))
);
users
.add("alice", "password456", roles(&[AuthRole::Read]), true)
@@ -1438,11 +1464,16 @@ mod tests {
Some(roles(&[AuthRole::Read]))
);
users
.update("alice", Some("password789"), Some(AuthRole::all()), None)
.update(
"alice",
Some("password789"),
Some(AuthRole::full_access()),
None,
)
.unwrap();
assert_eq!(
users.authenticate("alice", "password789"),
Some(AuthRole::all())
Some(AuthRole::full_access())
);
users.remove("admin").unwrap();
assert_eq!(users.list().len(), 2);
@@ -1453,7 +1484,7 @@ mod tests {
let reopened = UserStore::open(&reopen_config).unwrap();
assert_eq!(
reopened.authenticate("alice", "password789"),
Some(AuthRole::all())
Some(AuthRole::full_access())
);
let database = fs::read_to_string(&reopen_config.users_file).unwrap();
assert!(database.contains("$argon2"));
@@ -1500,6 +1531,15 @@ mod tests {
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]
fn legacy_single_roles_are_migrated() {
let read: UserRecord = serde_json::from_value(serde_json::json!({
@@ -1517,7 +1557,7 @@ mod tests {
assert_eq!(read.roles, roles(&[AuthRole::Read]));
assert!(read.enabled);
assert_eq!(administrator.roles, AuthRole::all());
assert_eq!(administrator.roles, AuthRole::full_access());
assert!(administrator.enabled);
assert!(serde_json::to_value(administrator)
.unwrap()
@@ -1743,11 +1783,15 @@ mod tests {
}
#[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 state = web::Data::new(
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(
App::new()
.app_data(state)
@@ -1760,16 +1804,16 @@ mod tests {
&app,
aw_test::TestRequest::post()
.uri("/auth/login")
.set_json(serde_json::json!({"username":"guest","password":"guest"}))
.set_json(serde_json::json!({"username":"reader","password":"reader-password"}))
.to_request(),
)
.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| {
aw_test::TestRequest::patch()
.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!({
"current_password":current,"new_password":new
}))
@@ -1782,7 +1826,7 @@ mod tests {
actix_web::http::StatusCode::FORBIDDEN
);
assert_eq!(
aw_test::call_service(&app, change("guest", "new-password"))
aw_test::call_service(&app, change("reader-password", "new-password"))
.await
.status(),
actix_web::http::StatusCode::OK
@@ -1791,7 +1835,7 @@ mod tests {
&app,
aw_test::TestRequest::get()
.uri("/auth/session")
.insert_header((actix_web::http::header::COOKIE, guest_cookie))
.insert_header((actix_web::http::header::COOKIE, reader_cookie))
.to_request(),
)
.await;
@@ -1801,7 +1845,7 @@ mod tests {
&app,
aw_test::TestRequest::post()
.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(),
)
.await
@@ -1809,4 +1853,40 @@ mod tests {
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);
}
}
+4 -4
View File
@@ -277,11 +277,11 @@ pub struct HttpAuthConfig {
/// Read the bootstrap administrator password from this file instead.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bootstrap_admin_password_file: Option<String>,
/// Create a read-only account when bootstrapping a new database.
/// Create a Guest account when bootstrapping a new database.
pub bootstrap_read_enabled: bool,
/// Username for the read-only bootstrap account.
/// Username for the Guest bootstrap account.
pub bootstrap_read_username: String,
/// Password for the read-only bootstrap account.
/// Password for the Guest bootstrap account.
pub bootstrap_read_password: Option<String>,
/// Session time-to-live in minutes
pub session_ttl_min: u64,
@@ -989,7 +989,7 @@ fn validate_http_auth(auth: &HttpAuthConfig) -> Result<(), String> {
.as_deref()
.is_none_or(str::is_empty))
{
return Err("[frontends.http.auth] enabled bootstrap read account requires a non-empty username and password".to_string());
return Err("[frontends.http.auth] enabled bootstrap Guest account requires a non-empty username and password".to_string());
}
// Session TTL must be > 0