web permission system updated
This commit is contained in:
2
server
2
server
Submodule server updated: b88981db05...068cfbdba0
@@ -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
|
||||
discovery is authenticated, export is limited to readable tables, and
|
||||
import is limited to tables for which the snapshot carries `insert`.
|
||||
- **Permission mutations retire the browser session** — the current backend
|
||||
invalidates every existing token after a policy change. After a successful
|
||||
role, assignment, grant or revoke operation the web clears that stale cookie
|
||||
and explains why the administrator must sign in again.
|
||||
- **Permission mutations keep the browser session** — grants, role changes and
|
||||
assignments now reload the live authorization state without replacing the
|
||||
identity-only JWT. The permissions workspace reloads in place and shows the
|
||||
updated effective policy.
|
||||
- **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
|
||||
`ACCOUNTING_TRANSFER` compound column. Server-generated companion types
|
||||
|
||||
@@ -16,6 +16,7 @@ pub(crate) async fn load_page(
|
||||
headers: &HeaderMap,
|
||||
selection: Selection,
|
||||
) -> Result<PermissionPageState, LoadError> {
|
||||
let updated = selection.updated;
|
||||
let mut auth = state.auth;
|
||||
let authorization = auth
|
||||
.get_authorization(
|
||||
@@ -120,6 +121,7 @@ pub(crate) async fn load_page(
|
||||
grantable_objects,
|
||||
can_manage_roles,
|
||||
can_manage_users,
|
||||
updated,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -36,15 +36,17 @@ pub(crate) async fn add_role(
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<AddRoleForm>,
|
||||
) -> Response {
|
||||
let role = form.name.trim().to_string();
|
||||
let destination = format!("/admin/permissions?role={role}&updated=true");
|
||||
let request_headers = headers.clone();
|
||||
mutate(&headers, async move {
|
||||
mutate(&headers, &destination, async move {
|
||||
let mut auth = state.auth;
|
||||
auth.add_role(authenticated_request(&request_headers, AddRoleRequest {
|
||||
name: form.name.trim().to_string(),
|
||||
name: role,
|
||||
parent: form.parent.trim().to_string(),
|
||||
}).map_err(|_| "Missing session".to_string())?)
|
||||
.await.map_err(|error| error.message().to_string())?;
|
||||
Ok("Role created. Sign in again to continue.")
|
||||
Ok(())
|
||||
}).await
|
||||
}
|
||||
|
||||
@@ -53,14 +55,15 @@ pub(crate) async fn remove_role(
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<RoleForm>,
|
||||
) -> Response {
|
||||
let destination = "/admin/permissions?updated=true";
|
||||
let request_headers = headers.clone();
|
||||
mutate(&headers, async move {
|
||||
mutate(&headers, destination, async move {
|
||||
let mut auth = state.auth;
|
||||
auth.remove_role(authenticated_request(&request_headers, RemoveRoleRequest {
|
||||
name: form.role,
|
||||
}).map_err(|_| "Missing session".to_string())?)
|
||||
.await.map_err(|error| error.message().to_string())?;
|
||||
Ok("Role removed. Sign in again to continue.")
|
||||
Ok(())
|
||||
}).await
|
||||
}
|
||||
|
||||
@@ -69,8 +72,9 @@ pub(crate) async fn grant(
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<PermissionForm>,
|
||||
) -> Response {
|
||||
let destination = format!("/admin/permissions?role={}&updated=true", form.role);
|
||||
let request_headers = headers.clone();
|
||||
mutate(&headers, async move {
|
||||
mutate(&headers, &destination, async move {
|
||||
let mut auth = state.auth;
|
||||
auth.grant_permission(authenticated_request(&request_headers, GrantPermissionRequest {
|
||||
role: form.role,
|
||||
@@ -78,7 +82,7 @@ pub(crate) async fn grant(
|
||||
action: form.action,
|
||||
}).map_err(|_| "Missing session".to_string())?)
|
||||
.await.map_err(|error| error.message().to_string())?;
|
||||
Ok("Permission granted. Sign in again to continue.")
|
||||
Ok(())
|
||||
}).await
|
||||
}
|
||||
|
||||
@@ -87,8 +91,9 @@ pub(crate) async fn revoke(
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<PermissionForm>,
|
||||
) -> Response {
|
||||
let destination = format!("/admin/permissions?role={}&updated=true", form.role);
|
||||
let request_headers = headers.clone();
|
||||
mutate(&headers, async move {
|
||||
mutate(&headers, &destination, async move {
|
||||
let mut auth = state.auth;
|
||||
auth.revoke_permission(authenticated_request(&request_headers, RevokePermissionRequest {
|
||||
role: form.role,
|
||||
@@ -96,7 +101,7 @@ pub(crate) async fn revoke(
|
||||
action: form.action,
|
||||
}).map_err(|_| "Missing session".to_string())?)
|
||||
.await.map_err(|error| error.message().to_string())?;
|
||||
Ok("Permission revoked. Sign in again to continue.")
|
||||
Ok(())
|
||||
}).await
|
||||
}
|
||||
|
||||
@@ -105,27 +110,28 @@ pub(crate) async fn assign_user_role(
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<AssignRoleForm>,
|
||||
) -> Response {
|
||||
let destination = "/admin/permissions?updated=true";
|
||||
let request_headers = headers.clone();
|
||||
mutate(&headers, async move {
|
||||
mutate(&headers, destination, async move {
|
||||
let mut auth = state.auth;
|
||||
auth.assign_user_role(authenticated_request(&request_headers, AssignUserRoleRequest {
|
||||
username: form.username,
|
||||
role: form.role,
|
||||
}).map_err(|_| "Missing session".to_string())?)
|
||||
.await.map_err(|error| error.message().to_string())?;
|
||||
Ok("User role changed. Sign in again to continue.")
|
||||
Ok(())
|
||||
}).await
|
||||
}
|
||||
|
||||
async fn mutate<F>(headers: &HeaderMap, operation: F) -> Response
|
||||
async fn mutate<F>(headers: &HeaderMap, destination: &str, operation: F) -> Response
|
||||
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) {
|
||||
return rejection;
|
||||
}
|
||||
match operation.await {
|
||||
Ok(_) => stale_session_response(),
|
||||
Ok(()) => success_redirect(destination),
|
||||
Err(message) => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
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();
|
||||
response.headers_mut().insert(
|
||||
header::SET_COOKIE,
|
||||
HeaderValue::from_static("analytics_token=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0"),
|
||||
);
|
||||
let Ok(destination) = HeaderValue::try_from(destination) else {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid redirect").into_response();
|
||||
};
|
||||
response.headers_mut().insert(
|
||||
header::LOCATION,
|
||||
HeaderValue::from_static("/login?permissions_changed=1"),
|
||||
destination.clone(),
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
"hx-redirect",
|
||||
HeaderValue::from_static("/login?permissions_changed=1"),
|
||||
destination,
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ use crate::auth::{GrantableObject, Permission, Role, UserSummary};
|
||||
pub(crate) struct Selection {
|
||||
#[serde(default)]
|
||||
pub role: String,
|
||||
#[serde(default)]
|
||||
pub updated: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, serde::Deserialize)]
|
||||
@@ -41,6 +43,7 @@ pub(crate) struct PermissionPageState {
|
||||
pub grantable_objects: Vec<GrantableObject>,
|
||||
pub can_manage_roles: bool,
|
||||
pub can_manage_users: bool,
|
||||
pub updated: bool,
|
||||
}
|
||||
|
||||
impl PermissionPageState {
|
||||
|
||||
@@ -85,6 +85,7 @@ mod tests {
|
||||
}],
|
||||
can_manage_roles: true,
|
||||
can_manage_users: true,
|
||||
updated: false,
|
||||
};
|
||||
|
||||
let html = render_page(&page);
|
||||
|
||||
@@ -16,7 +16,6 @@ pub(crate) async fn login_page(
|
||||
) -> Html<String> {
|
||||
Html(ui::render_page(
|
||||
Nav::new(&headers, "login"),
|
||||
query.permissions_changed,
|
||||
query.initial_password_set,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -7,8 +7,6 @@ pub(crate) struct LoginInput {
|
||||
|
||||
#[derive(Default, serde::Deserialize)]
|
||||
pub(crate) struct LoginQuery {
|
||||
#[serde(default)]
|
||||
pub permissions_changed: bool,
|
||||
#[serde(default)]
|
||||
pub initial_password_set: bool,
|
||||
}
|
||||
|
||||
@@ -7,18 +7,15 @@ use crate::ui::{Alert, Nav, render};
|
||||
#[template(path = "pages/login/login.html")]
|
||||
struct LoginPage {
|
||||
nav: Nav,
|
||||
permissions_changed: bool,
|
||||
initial_password_set: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn render_page(
|
||||
nav: Nav,
|
||||
permissions_changed: bool,
|
||||
initial_password_set: bool,
|
||||
) -> String {
|
||||
render(&LoginPage {
|
||||
nav,
|
||||
permissions_changed,
|
||||
initial_password_set,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
<div class="actions"><a href="/admin">← Admin panel</a></div>
|
||||
</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 %}
|
||||
<section class="panel">
|
||||
<h2>Data roles</h2>
|
||||
|
||||
@@ -154,7 +154,7 @@
|
||||
{% if !page.permission_object.is_empty() %}
|
||||
<section class="panel">
|
||||
<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">
|
||||
<thead><tr><th>Role</th><th>Actions</th></tr></thead>
|
||||
<tbody>{% for role in page.role_permissions %}<tr>
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
<form class="login-card" hx-post="/login" hx-target="#login-status" hx-swap="innerHTML"
|
||||
hx-disabled-elt="button" novalidate>
|
||||
<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 %}
|
||||
<label>Username or email<input name="identifier" autocomplete="username"></label>
|
||||
<label>Password <span>(optional)</span><input name="password" type="password" autocomplete="current-password"></label>
|
||||
|
||||
Reference in New Issue
Block a user