[feat](trx-frontend-http): add restricted Guest role
CI / lint (pull_request) Successful in 2m24s
CI / test (pull_request) Successful in 8m16s
CI / frontend (pull_request) Successful in 4m17s
CI / reuse (pull_request) Successful in 5s

Assisted-By: Codex (GPT-5)
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
sjg
2026-08-11 16:04:51 +02:00
parent 5e9dae02c7
commit 44870bc941
17 changed files with 240 additions and 66 deletions
@@ -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