web permission system updated

This commit is contained in:
Priec
2026-08-09 18:49:54 +02:00
parent fd551ef7fb
commit ff044d65f1
12 changed files with 40 additions and 34 deletions

2
server

Submodule server updated: b88981db05...068cfbdba0

View File

@@ -44,10 +44,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- **Analytics and import/export use the caller's visible data** — profile-tree - **Analytics and import/export use the caller's visible data** — profile-tree
discovery is authenticated, export is limited to readable tables, and discovery is authenticated, export is limited to readable tables, and
import is limited to tables for which the snapshot carries `insert`. import is limited to tables for which the snapshot carries `insert`.
- **Permission mutations retire the browser session** — the current backend - **Permission mutations keep the browser session** — grants, role changes and
invalidates every existing token after a policy change. After a successful assignments now reload the live authorization state without replacing the
role, assignment, grant or revoke operation the web clears that stale cookie identity-only JWT. The permissions workspace reloads in place and shows the
and explains why the administrator must sign in again. updated effective policy.
- **The column-type picker is the server's list** — it no longer carries its - **The column-type picker is the server's list** — it no longer carries its
own. Types the web crate never offered are now reachable: `numeric` and the own. Types the web crate never offered are now reachable: `numeric` and the
`ACCOUNTING_TRANSFER` compound column. Server-generated companion types `ACCOUNTING_TRANSFER` compound column. Server-generated companion types

View File

@@ -16,6 +16,7 @@ pub(crate) async fn load_page(
headers: &HeaderMap, headers: &HeaderMap,
selection: Selection, selection: Selection,
) -> Result<PermissionPageState, LoadError> { ) -> Result<PermissionPageState, LoadError> {
let updated = selection.updated;
let mut auth = state.auth; let mut auth = state.auth;
let authorization = auth let authorization = auth
.get_authorization( .get_authorization(
@@ -120,6 +121,7 @@ pub(crate) async fn load_page(
grantable_objects, grantable_objects,
can_manage_roles, can_manage_roles,
can_manage_users, can_manage_users,
updated,
}) })
} }

View File

@@ -36,15 +36,17 @@ pub(crate) async fn add_role(
headers: HeaderMap, headers: HeaderMap,
Form(form): Form<AddRoleForm>, Form(form): Form<AddRoleForm>,
) -> Response { ) -> Response {
let role = form.name.trim().to_string();
let destination = format!("/admin/permissions?role={role}&updated=true");
let request_headers = headers.clone(); let request_headers = headers.clone();
mutate(&headers, async move { mutate(&headers, &destination, async move {
let mut auth = state.auth; let mut auth = state.auth;
auth.add_role(authenticated_request(&request_headers, AddRoleRequest { auth.add_role(authenticated_request(&request_headers, AddRoleRequest {
name: form.name.trim().to_string(), name: role,
parent: form.parent.trim().to_string(), parent: form.parent.trim().to_string(),
}).map_err(|_| "Missing session".to_string())?) }).map_err(|_| "Missing session".to_string())?)
.await.map_err(|error| error.message().to_string())?; .await.map_err(|error| error.message().to_string())?;
Ok("Role created. Sign in again to continue.") Ok(())
}).await }).await
} }
@@ -53,14 +55,15 @@ pub(crate) async fn remove_role(
headers: HeaderMap, headers: HeaderMap,
Form(form): Form<RoleForm>, Form(form): Form<RoleForm>,
) -> Response { ) -> Response {
let destination = "/admin/permissions?updated=true";
let request_headers = headers.clone(); let request_headers = headers.clone();
mutate(&headers, async move { mutate(&headers, destination, async move {
let mut auth = state.auth; let mut auth = state.auth;
auth.remove_role(authenticated_request(&request_headers, RemoveRoleRequest { auth.remove_role(authenticated_request(&request_headers, RemoveRoleRequest {
name: form.role, name: form.role,
}).map_err(|_| "Missing session".to_string())?) }).map_err(|_| "Missing session".to_string())?)
.await.map_err(|error| error.message().to_string())?; .await.map_err(|error| error.message().to_string())?;
Ok("Role removed. Sign in again to continue.") Ok(())
}).await }).await
} }
@@ -69,8 +72,9 @@ pub(crate) async fn grant(
headers: HeaderMap, headers: HeaderMap,
Form(form): Form<PermissionForm>, Form(form): Form<PermissionForm>,
) -> Response { ) -> Response {
let destination = format!("/admin/permissions?role={}&updated=true", form.role);
let request_headers = headers.clone(); let request_headers = headers.clone();
mutate(&headers, async move { mutate(&headers, &destination, async move {
let mut auth = state.auth; let mut auth = state.auth;
auth.grant_permission(authenticated_request(&request_headers, GrantPermissionRequest { auth.grant_permission(authenticated_request(&request_headers, GrantPermissionRequest {
role: form.role, role: form.role,
@@ -78,7 +82,7 @@ pub(crate) async fn grant(
action: form.action, action: form.action,
}).map_err(|_| "Missing session".to_string())?) }).map_err(|_| "Missing session".to_string())?)
.await.map_err(|error| error.message().to_string())?; .await.map_err(|error| error.message().to_string())?;
Ok("Permission granted. Sign in again to continue.") Ok(())
}).await }).await
} }
@@ -87,8 +91,9 @@ pub(crate) async fn revoke(
headers: HeaderMap, headers: HeaderMap,
Form(form): Form<PermissionForm>, Form(form): Form<PermissionForm>,
) -> Response { ) -> Response {
let destination = format!("/admin/permissions?role={}&updated=true", form.role);
let request_headers = headers.clone(); let request_headers = headers.clone();
mutate(&headers, async move { mutate(&headers, &destination, async move {
let mut auth = state.auth; let mut auth = state.auth;
auth.revoke_permission(authenticated_request(&request_headers, RevokePermissionRequest { auth.revoke_permission(authenticated_request(&request_headers, RevokePermissionRequest {
role: form.role, role: form.role,
@@ -96,7 +101,7 @@ pub(crate) async fn revoke(
action: form.action, action: form.action,
}).map_err(|_| "Missing session".to_string())?) }).map_err(|_| "Missing session".to_string())?)
.await.map_err(|error| error.message().to_string())?; .await.map_err(|error| error.message().to_string())?;
Ok("Permission revoked. Sign in again to continue.") Ok(())
}).await }).await
} }
@@ -105,27 +110,28 @@ pub(crate) async fn assign_user_role(
headers: HeaderMap, headers: HeaderMap,
Form(form): Form<AssignRoleForm>, Form(form): Form<AssignRoleForm>,
) -> Response { ) -> Response {
let destination = "/admin/permissions?updated=true";
let request_headers = headers.clone(); let request_headers = headers.clone();
mutate(&headers, async move { mutate(&headers, destination, async move {
let mut auth = state.auth; let mut auth = state.auth;
auth.assign_user_role(authenticated_request(&request_headers, AssignUserRoleRequest { auth.assign_user_role(authenticated_request(&request_headers, AssignUserRoleRequest {
username: form.username, username: form.username,
role: form.role, role: form.role,
}).map_err(|_| "Missing session".to_string())?) }).map_err(|_| "Missing session".to_string())?)
.await.map_err(|error| error.message().to_string())?; .await.map_err(|error| error.message().to_string())?;
Ok("User role changed. Sign in again to continue.") Ok(())
}).await }).await
} }
async fn mutate<F>(headers: &HeaderMap, operation: F) -> Response async fn mutate<F>(headers: &HeaderMap, destination: &str, operation: F) -> Response
where where
F: std::future::Future<Output = Result<&'static str, String>>, F: std::future::Future<Output = Result<(), String>>,
{ {
if let Some(rejection) = reject_cross_site(headers) { if let Some(rejection) = reject_cross_site(headers) {
return rejection; return rejection;
} }
match operation.await { match operation.await {
Ok(_) => stale_session_response(), Ok(()) => success_redirect(destination),
Err(message) => ( Err(message) => (
StatusCode::UNPROCESSABLE_ENTITY, StatusCode::UNPROCESSABLE_ENTITY,
Html(ui::render_mutation_error(&message)), Html(ui::render_mutation_error(&message)),
@@ -133,19 +139,18 @@ where
} }
} }
fn stale_session_response() -> Response { fn success_redirect(destination: &str) -> Response {
let mut response = StatusCode::SEE_OTHER.into_response(); let mut response = StatusCode::SEE_OTHER.into_response();
response.headers_mut().insert( let Ok(destination) = HeaderValue::try_from(destination) else {
header::SET_COOKIE, return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid redirect").into_response();
HeaderValue::from_static("analytics_token=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0"), };
);
response.headers_mut().insert( response.headers_mut().insert(
header::LOCATION, header::LOCATION,
HeaderValue::from_static("/login?permissions_changed=1"), destination.clone(),
); );
response.headers_mut().insert( response.headers_mut().insert(
"hx-redirect", "hx-redirect",
HeaderValue::from_static("/login?permissions_changed=1"), destination,
); );
response response
} }

View File

@@ -4,6 +4,8 @@ use crate::auth::{GrantableObject, Permission, Role, UserSummary};
pub(crate) struct Selection { pub(crate) struct Selection {
#[serde(default)] #[serde(default)]
pub role: String, pub role: String,
#[serde(default)]
pub updated: bool,
} }
#[derive(Clone, Debug, Default, serde::Deserialize)] #[derive(Clone, Debug, Default, serde::Deserialize)]
@@ -41,6 +43,7 @@ pub(crate) struct PermissionPageState {
pub grantable_objects: Vec<GrantableObject>, pub grantable_objects: Vec<GrantableObject>,
pub can_manage_roles: bool, pub can_manage_roles: bool,
pub can_manage_users: bool, pub can_manage_users: bool,
pub updated: bool,
} }
impl PermissionPageState { impl PermissionPageState {

View File

@@ -85,6 +85,7 @@ mod tests {
}], }],
can_manage_roles: true, can_manage_roles: true,
can_manage_users: true, can_manage_users: true,
updated: false,
}; };
let html = render_page(&page); let html = render_page(&page);

View File

@@ -16,7 +16,6 @@ pub(crate) async fn login_page(
) -> Html<String> { ) -> Html<String> {
Html(ui::render_page( Html(ui::render_page(
Nav::new(&headers, "login"), Nav::new(&headers, "login"),
query.permissions_changed,
query.initial_password_set, query.initial_password_set,
)) ))
} }

View File

@@ -7,8 +7,6 @@ pub(crate) struct LoginInput {
#[derive(Default, serde::Deserialize)] #[derive(Default, serde::Deserialize)]
pub(crate) struct LoginQuery { pub(crate) struct LoginQuery {
#[serde(default)]
pub permissions_changed: bool,
#[serde(default)] #[serde(default)]
pub initial_password_set: bool, pub initial_password_set: bool,
} }

View File

@@ -7,18 +7,15 @@ use crate::ui::{Alert, Nav, render};
#[template(path = "pages/login/login.html")] #[template(path = "pages/login/login.html")]
struct LoginPage { struct LoginPage {
nav: Nav, nav: Nav,
permissions_changed: bool,
initial_password_set: bool, initial_password_set: bool,
} }
pub(crate) fn render_page( pub(crate) fn render_page(
nav: Nav, nav: Nav,
permissions_changed: bool,
initial_password_set: bool, initial_password_set: bool,
) -> String { ) -> String {
render(&LoginPage { render(&LoginPage {
nav, nav,
permissions_changed,
initial_password_set, initial_password_set,
}) })
} }

View File

@@ -13,6 +13,8 @@
<div class="actions"><a href="/admin">← Admin panel</a></div> <div class="actions"><a href="/admin">← Admin panel</a></div>
</section> </section>
{% if page.updated %}<p class="notice">Authorization updated. The current session remains valid and all subsequent requests use the new policy.</p>{% endif %}
{% if page.can_manage_roles %} {% if page.can_manage_roles %}
<section class="panel"> <section class="panel">
<h2>Data roles</h2> <h2>Data roles</h2>

View File

@@ -154,7 +154,7 @@
{% if !page.permission_object.is_empty() %} {% if !page.permission_object.is_empty() %}
<section class="panel"> <section class="panel">
<h2>Data permissions for <code>{{ page.selection.table }}</code></h2> <h2>Data permissions for <code>{{ page.selection.table }}</code></h2>
<p class="hint">These grants cover this table family. A successful change retires the current session, so you will be asked to sign in again.</p> <p class="hint">These grants cover this table family and take effect on subsequent requests without replacing the current session.</p>
<table class="builder-table"> <table class="builder-table">
<thead><tr><th>Role</th><th>Actions</th></tr></thead> <thead><tr><th>Role</th><th>Actions</th></tr></thead>
<tbody>{% for role in page.role_permissions %}<tr> <tbody>{% for role in page.role_permissions %}<tr>

View File

@@ -8,7 +8,6 @@
<form class="login-card" hx-post="/login" hx-target="#login-status" hx-swap="innerHTML" <form class="login-card" hx-post="/login" hx-target="#login-status" hx-swap="innerHTML"
hx-disabled-elt="button" novalidate> hx-disabled-elt="button" novalidate>
<h1>Sign in</h1> <h1>Sign in</h1>
{% if permissions_changed %}<p class="notice">Authorization changed successfully. Sign in again because the server retired the previous session.</p>{% endif %}
{% if initial_password_set %}<p class="notice">The initial password was set. You can sign in now.</p>{% endif %} {% if initial_password_set %}<p class="notice">The initial password was set. You can sign in now.</p>{% endif %}
<label>Username or email<input name="identifier" autocomplete="username"></label> <label>Username or email<input name="identifier" autocomplete="username"></label>
<label>Password <span>(optional)</span><input name="password" type="password" autocomplete="current-password"></label> <label>Password <span>(optional)</span><input name="password" type="password" autocomplete="current-password"></label>