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
|
- **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
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user