web permissions2

This commit is contained in:
Priec
2026-08-11 14:33:24 +02:00
parent 5602140d05
commit 077d69d756
47 changed files with 2348 additions and 674 deletions

2
client

Submodule client updated: 914d48cf39...1e3c9de431

View File

@@ -16,7 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Added
- **Role, user and permission administration** — `/admin/permissions` consumes
- **Role, user and permission administration** — `/permissions` consumes
`ListRoles`, `AddRole`, `RemoveRole`, `ListUsers`, `AssignUserRole`,
`ListRolePermissions`, `ListGrantableObjects`, `GrantPermission` and
`RevokePermission`. Direct and inherited grants are distinguished, and the
@@ -37,6 +37,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Changed
- **Permissions moved out of the admin panel into their own nav section, split
in three** — `/admin/permissions` is gone; the pages are `/permissions/roles`
(which roles exist), `/permissions/users` (who holds them) and
`/permissions/grants` (what a role may do). No new gRPC endpoint is consumed:
the same `GrantPermission` and `RevokePermission` calls now come from one
`POST /permissions/grants/apply`, which issues one call per `object|action`
pair so a shortcut — a whole row, a whole profile, everything — is a longer
list rather than a different request. Shortcuts grant the wildcard objects
`ListGrantableObjects` already returns (`data:*`, `data:<profile>/*`,
`journal:*`, `ecb:*`), so they keep covering profiles and tables created
later, and `AddRole` may be followed by a starter set of those grants in the
same submission. Revoking tolerates the `NOT_FOUND` a pair the role does not
hold directly answers with, which is what makes "remove all of this"
idempotent.
- **Web authorization follows permission objects, not role names** — structural
pages check their `struct:<area>/manage` permission from `GetAuthorization`,
so inherited `superadmin` authorization works and data roles are no longer

View File

@@ -18,7 +18,9 @@ cargo run -p server -- server
```
Open <http://127.0.0.1:3000/login> to log in. The admin panel is at
<http://127.0.0.1:3000/admin> and analytics remains at <http://127.0.0.1:3000>.
<http://127.0.0.1:3000/admin>, roles and permissions are at
<http://127.0.0.1:3000/permissions>, and analytics remains at
<http://127.0.0.1:3000>.
The access token is kept in an HTTP-only cookie. The default gRPC endpoint is
`http://[::1]:50051`. Both addresses can be changed:
@@ -70,6 +72,15 @@ src/ templates/
… export.html
login/ login/
mod logic state ui login.html
permissions/ permissions/
common/ tabs.html the section switcher
loader logic state ui
roles/ roles/
mod loader logic state ui roles.html
users/ users/
mod loader logic state ui users.html
grants/ grants/
mod loader logic state ui grants.html
static/app.css the only stylesheet, at /static/app.css
```
@@ -101,6 +112,10 @@ endpoint in a comment on line 1.
| `GET /admin/validation/sets/new` | `pages/add_validation/` | `set.html` |
| `GET /admin/import` | `pages/import_export/import/` | `import.html` |
| `GET /admin/export` | `pages/import_export/export/` | `export.html` |
| `GET /permissions` | `pages/permissions/` | redirect to the first open section |
| `GET /permissions/roles` | `pages/permissions/roles/` | `roles.html` |
| `GET /permissions/users` | `pages/permissions/users/` | `users.html` |
| `GET /permissions/grants` | `pages/permissions/grants/` | `grants.html` |
Every form `POST` answers with `ui/alert_fragment.html`, swapped into the page's
`#submission-status`. Analytics errors use the lighter `ui/notice.html`.
@@ -127,6 +142,36 @@ files include a table-name header row before the column-name row. Browser files
are read locally and submitted to Axum; the backend is accessed only through the
existing `TablesData` gRPC service.
## Permissions
Permissions are a nav section of their own, not a page inside the admin panel:
managing people is a different job from designing tables, and the two are held
by different accounts. The section is three pages, one per decision, with a tab
bar between them:
- **`/permissions/roles`** — which roles exist, what they inherit from, how many
people hold each, and removing the ones nobody holds. Creating a role can hand
it starter access (`Read everything` or `Read and write everything`) in the
same submission.
- **`/permissions/users`** — who holds which role, and password resets. Only
users whose role the caller outranks carry controls.
- **`/permissions/grants`** — what a role may do, as one matrix per profile:
objects down the side, `read / insert / update / delete` across the top. A
cell is a grant held directly (click to revoke), an inherited one (changed on
the parent role), or an empty one (click to grant).
Every button on the grants page — one cell, one row's `All`, a profile's
`Full access`, the page's `Full access to everything` — posts the same form to
`POST /permissions/grants/apply`, and differs only in the list of
`object|action` pairs it carries. The lists are built in Rust, so the server is
never asked to work out what "everything" meant, and the shortcuts grant the
wildcard objects (`data:*`, `data:<profile>/*`, `journal:*`, `ecb:*`), which
keeps them covering profiles and tables created later. `ecb:*` is read-only
everywhere, because the server writes it.
The table-definition workspace still edits one table's grants in place; it posts
to the same endpoint and returns to itself.
## Analytics
The first SQL result column is used for category labels. Bar and line charts use

View File

@@ -39,6 +39,21 @@ pub(crate) fn can_open_admin(snapshot: &AuthorizationSnapshot) -> bool {
.any(|area| can_manage(snapshot, area))
}
/// The server's authority ranking (`server/src/auth/rbac/roles.rs`), mirrored
/// so the permission pages only offer what the server will accept: superadmin
/// outranks admin, admin outranks every data role, and no role outranks itself.
pub(crate) fn outranks(actor: &str, target: &str) -> bool {
rank(actor) > rank(target)
}
fn rank(role: &str) -> u8 {
match role {
"superadmin" => 3,
"admin" => 2,
_ => 1,
}
}
pub(crate) fn table_object(profile: &str, table: &str) -> String {
format!("data:{profile}/{table}")
}
@@ -90,6 +105,15 @@ mod tests {
assert!(!can_manage(&authorization, STRUCT_ROLE));
}
#[test]
fn nobody_administers_a_peer_or_a_superior() {
assert!(outranks("superadmin", "admin"));
assert!(outranks("admin", "sales"));
assert!(!outranks("admin", "admin"));
assert!(!outranks("admin", "superadmin"));
assert!(!outranks("sales", "clerk"));
}
#[test]
fn data_wildcards_match_the_server_object_shapes() {
let global = snapshot(&[("data:*", "read")]);

View File

@@ -139,7 +139,7 @@ fn router(state: AppState) -> Router {
.merge(pages::login::router())
.merge(pages::register::router())
.merge(pages::admin::admin::router())
.merge(pages::admin::permissions::router())
.merge(pages::permissions::router())
.merge(pages::admin::table_definition::router())
.merge(pages::add_table::router())
.merge(pages::add_logic::router())
@@ -317,6 +317,60 @@ mod tests {
}
}
/// Permissions is a nav section of its own, so its three pages are mounted
/// at the top level rather than under /admin — and each of them, like every
/// other page behind a session, sends an anonymous visitor to the login
/// page instead of calling the backend.
#[tokio::test]
async fn the_permission_sections_are_mounted_and_need_a_session() {
for path in [
"/permissions",
"/permissions/roles",
"/permissions/users",
"/permissions/grants",
] {
let (status, _) = get(path).await;
assert_eq!(
status,
axum::http::StatusCode::SEE_OTHER,
"{path} did not send an anonymous visitor to the login page"
);
}
// The forms answer a lost session the way every form does: with the
// failure rendered into the page, not a 404 from an unmounted route.
for (path, body) in [
("/permissions/roles/create", "name=sales&access=none"),
("/permissions/roles/remove", "role=sales"),
("/permissions/users/role", "username=alice&role=sales"),
(
"/permissions/users/password",
"username=alice&new_password=a&new_password_confirmation=a",
),
(
"/permissions/grants/apply",
"role=sales&mode=grant&pair=data%3A*%7Cread",
),
] {
let response = test_router()
.oneshot(
Request::builder()
.method("POST")
.uri(path)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.status(),
axum::http::StatusCode::UNPROCESSABLE_ENTITY,
"{path} is not mounted"
);
}
}
#[tokio::test]
async fn stylesheet_is_served_once_for_every_page() {
let (status, body) = get("/static/app.css").await;

View File

@@ -6,7 +6,7 @@
use axum::{
extract::{Query, State},
http::{HeaderMap, HeaderValue, StatusCode, header},
http::{HeaderMap, HeaderValue, StatusCode},
response::{Html, IntoResponse, Redirect, Response},
};
use axum_extra::extract::Form;
@@ -107,10 +107,10 @@ pub(crate) async fn create_table(
let Ok(location) = HeaderValue::try_from(location) else {
return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid redirect").into_response();
};
let mut response = StatusCode::SEE_OTHER.into_response();
response
.headers_mut()
.insert(header::LOCATION, location.clone());
// `hx-redirect` alone, with no body and no 3xx: the form is posted
// over XHR, and the browser would follow a `Location` itself,
// leaving htmx to swap the whole redirected page into `#builder`.
let mut response = Html(String::new()).into_response();
response.headers_mut().insert("hx-redirect", location);
response
}

View File

@@ -138,8 +138,6 @@ pub(crate) async fn load_admin_page(
can_manage_tables: crate::authz::can_manage(&authorization, crate::authz::STRUCT_TABLE),
can_manage_scripts: crate::authz::can_manage(&authorization, crate::authz::STRUCT_SCRIPT),
can_manage_validations: crate::authz::can_manage(&authorization, crate::authz::STRUCT_VALIDATION),
can_manage_permissions: crate::authz::can_manage(&authorization, crate::authz::STRUCT_ROLE)
|| crate::authz::can_manage(&authorization, crate::authz::STRUCT_USER),
can_export: authorization.permissions.iter().any(|permission| {
permission.action == "read" && permission.object.starts_with("data:")
}),

View File

@@ -17,7 +17,6 @@ pub(crate) struct AdminPageState {
pub can_manage_tables: bool,
pub can_manage_scripts: bool,
pub can_manage_validations: bool,
pub can_manage_permissions: bool,
pub can_export: bool,
}

View File

@@ -50,6 +50,7 @@ mod tests {
authenticated: true,
role: "admin".to_string(),
can_admin: true,
can_permissions: true,
can_import: false,
can_export: false,
active: "admin",
@@ -64,7 +65,6 @@ mod tests {
can_manage_tables: true,
can_manage_scripts: true,
can_manage_validations: true,
can_manage_permissions: true,
can_export: true,
};
let html = render_page(&page);
@@ -75,7 +75,6 @@ mod tests {
"/admin/validation/new",
"/admin/validation/sets/new",
"/admin/export",
"/admin/permissions",
"/logout",
] {
assert!(html.contains(route), "missing admin action route {route}");

View File

@@ -1,3 +1,2 @@
pub(crate) mod admin;
pub(crate) mod permissions;
pub(crate) mod table_definition;

View File

@@ -1,134 +0,0 @@
use axum::http::HeaderMap;
use crate::{
AppState,
auth::{
GetAuthorizationRequest, ListGrantableObjectsRequest, ListRolePermissionsRequest,
ListRolesRequest, ListUsersRequest,
},
services::authenticated_request,
};
use super::state::{LoadError, PermissionPageState, Selection};
pub(crate) async fn load_page(
state: AppState,
headers: &HeaderMap,
selection: Selection,
) -> Result<PermissionPageState, LoadError> {
let updated = selection.updated;
let mut auth = state.auth;
let authorization = auth
.get_authorization(
authenticated_request(headers, GetAuthorizationRequest {})
.map_err(|_| LoadError::Unauthenticated)?,
)
.await
.map_err(status_error)?
.into_inner();
let can_manage_roles = crate::authz::can_manage(&authorization, crate::authz::STRUCT_ROLE);
let can_manage_users = crate::authz::can_manage(&authorization, crate::authz::STRUCT_USER);
if !can_manage_roles && !can_manage_users {
return Err(LoadError::Forbidden);
}
let roles = if can_manage_roles || can_manage_users {
auth.list_roles(
authenticated_request(headers, ListRolesRequest {})
.map_err(|_| LoadError::Unauthenticated)?,
)
.await
.map_err(status_error)?
.into_inner()
.roles
} else {
Vec::new()
};
let users = if can_manage_users {
auth.list_users(
authenticated_request(headers, ListUsersRequest {})
.map_err(|_| LoadError::Unauthenticated)?,
)
.await
.map_err(status_error)?
.into_inner()
.users
} else {
Vec::new()
};
let editable_roles = roles
.iter()
.filter(|role| role.kind == "data")
.map(|role| role.name.as_str())
.collect::<Vec<_>>();
let selected_role = if selection.role.is_empty() {
editable_roles.first().copied().unwrap_or_default().to_string()
} else if editable_roles.contains(&selection.role.as_str()) {
selection.role
} else {
return Err(LoadError::InvalidSelection(format!(
"Role '{}' is not an editable data role.",
selection.role
)));
};
let (direct_permissions, effective_permissions, grantable_objects) =
if can_manage_roles && !selected_role.is_empty() {
let permissions = auth
.list_role_permissions(
authenticated_request(
headers,
ListRolePermissionsRequest {
role: selected_role.clone(),
},
)
.map_err(|_| LoadError::Unauthenticated)?,
)
.await
.map_err(status_error)?
.into_inner();
let objects = auth
.list_grantable_objects(
authenticated_request(
headers,
ListGrantableObjectsRequest {
target_role: selected_role.clone(),
},
)
.map_err(|_| LoadError::Unauthenticated)?,
)
.await
.map_err(status_error)?
.into_inner()
.objects;
(
permissions.permissions,
permissions.effective_permissions,
objects,
)
} else {
(Vec::new(), Vec::new(), Vec::new())
};
Ok(PermissionPageState {
nav: crate::ui::Nav::new(headers, "admin").with_authorization(&authorization),
roles,
users,
selected_role,
direct_permissions,
effective_permissions,
grantable_objects,
can_manage_roles,
can_manage_users,
updated,
})
}
fn status_error(error: tonic::Status) -> LoadError {
match error.code() {
tonic::Code::Unauthenticated => LoadError::Unauthenticated,
tonic::Code::PermissionDenied => LoadError::Forbidden,
_ => LoadError::Backend(error.message().to_string()),
}
}

View File

@@ -1,191 +0,0 @@
use axum::{
extract::{Query, State},
http::{HeaderMap, HeaderValue, StatusCode, header},
response::{Html, IntoResponse, Redirect, Response},
};
use axum_extra::extract::Form;
use crate::{
AppState,
auth::{
AddRoleRequest, AssignUserRoleRequest, GrantPermissionRequest, RemoveRoleRequest,
ResetUserPasswordRequest, RevokePermissionRequest,
},
services::{authenticated_request, reject_cross_site},
};
use super::{
loader,
state::{
AddRoleForm, AssignRoleForm, LoadError, PermissionForm, ResetPasswordForm, RoleForm,
Selection,
},
ui,
};
pub(crate) async fn page(
State(state): State<AppState>,
headers: HeaderMap,
Query(selection): Query<Selection>,
) -> Response {
match loader::load_page(state, &headers, selection).await {
Ok(page) => Html(ui::render_page(&page)).into_response(),
Err(error) => load_error(error),
}
}
pub(crate) async fn add_role(
State(state): State<AppState>,
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, &destination, async move {
let mut auth = state.auth;
auth.add_role(authenticated_request(&request_headers, AddRoleRequest {
name: role,
parent: form.parent.trim().to_string(),
}).map_err(|_| "Missing session".to_string())?)
.await.map_err(|error| error.message().to_string())?;
Ok(())
}).await
}
pub(crate) async fn remove_role(
State(state): State<AppState>,
headers: HeaderMap,
Form(form): Form<RoleForm>,
) -> Response {
let destination = "/admin/permissions?updated=true";
let request_headers = headers.clone();
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(())
}).await
}
pub(crate) async fn grant(
State(state): State<AppState>,
headers: HeaderMap,
Form(form): Form<PermissionForm>,
) -> Response {
let destination = format!("/admin/permissions?role={}&updated=true", form.role);
let request_headers = headers.clone();
mutate(&headers, &destination, async move {
let mut auth = state.auth;
auth.grant_permission(authenticated_request(&request_headers, GrantPermissionRequest {
role: form.role,
object: form.object,
action: form.action,
}).map_err(|_| "Missing session".to_string())?)
.await.map_err(|error| error.message().to_string())?;
Ok(())
}).await
}
pub(crate) async fn revoke(
State(state): State<AppState>,
headers: HeaderMap,
Form(form): Form<PermissionForm>,
) -> Response {
let destination = format!("/admin/permissions?role={}&updated=true", form.role);
let request_headers = headers.clone();
mutate(&headers, &destination, async move {
let mut auth = state.auth;
auth.revoke_permission(authenticated_request(&request_headers, RevokePermissionRequest {
role: form.role,
object: form.object,
action: form.action,
}).map_err(|_| "Missing session".to_string())?)
.await.map_err(|error| error.message().to_string())?;
Ok(())
}).await
}
pub(crate) async fn assign_user_role(
State(state): State<AppState>,
headers: HeaderMap,
Form(form): Form<AssignRoleForm>,
) -> Response {
let destination = "/admin/permissions?updated=true";
let request_headers = headers.clone();
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(())
}).await
}
pub(crate) async fn reset_user_password(
State(state): State<AppState>,
headers: HeaderMap,
Form(form): Form<ResetPasswordForm>,
) -> Response {
let destination = "/admin/permissions?updated=true";
let request_headers = headers.clone();
mutate(&headers, destination, async move {
let mut auth = state.auth;
auth.reset_user_password(authenticated_request(
&request_headers,
ResetUserPasswordRequest {
username: form.username,
new_password: form.new_password,
new_password_confirmation: form.new_password_confirmation,
},
).map_err(|_| "Missing session".to_string())?)
.await
.map_err(|error| error.message().to_string())?;
Ok(())
}).await
}
async fn mutate<F>(headers: &HeaderMap, destination: &str, operation: F) -> Response
where
F: std::future::Future<Output = Result<(), String>>,
{
if let Some(rejection) = reject_cross_site(headers) {
return rejection;
}
match operation.await {
Ok(()) => success_redirect(destination),
Err(message) => (
StatusCode::UNPROCESSABLE_ENTITY,
Html(ui::render_mutation_error(&message)),
).into_response(),
}
}
fn success_redirect(destination: &str) -> Response {
let mut response = StatusCode::SEE_OTHER.into_response();
let Ok(destination) = HeaderValue::try_from(destination) else {
return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid redirect").into_response();
};
response.headers_mut().insert(
header::LOCATION,
destination.clone(),
);
response.headers_mut().insert(
"hx-redirect",
destination,
);
response
}
fn load_error(error: LoadError) -> Response {
match error {
LoadError::Unauthenticated => Redirect::to("/login").into_response(),
LoadError::Forbidden => (StatusCode::FORBIDDEN, Html(ui::render_error("You do not have role or user management permission."))).into_response(),
LoadError::InvalidSelection(message) => (StatusCode::BAD_REQUEST, Html(ui::render_error(&message))).into_response(),
LoadError::Backend(message) => (StatusCode::BAD_GATEWAY, Html(ui::render_error(&message))).into_response(),
}
}

View File

@@ -1,19 +0,0 @@
mod loader;
mod logic;
mod state;
mod ui;
use axum::{Router, routing::{get, post}};
use crate::AppState;
pub(crate) fn router() -> Router<AppState> {
Router::new()
.route("/admin/permissions", get(logic::page))
.route("/admin/permissions/roles", post(logic::add_role))
.route("/admin/permissions/roles/remove", post(logic::remove_role))
.route("/admin/permissions/grant", post(logic::grant))
.route("/admin/permissions/revoke", post(logic::revoke))
.route("/admin/permissions/users/role", post(logic::assign_user_role))
.route("/admin/permissions/users/password", post(logic::reset_user_password))
}

View File

@@ -1,103 +0,0 @@
use crate::auth::{GrantableObject, Permission, Role, UserSummary};
#[derive(Clone, Debug, Default, serde::Deserialize)]
pub(crate) struct Selection {
#[serde(default)]
pub role: String,
#[serde(default)]
pub updated: bool,
}
#[derive(Clone, Debug, Default, serde::Deserialize)]
pub(crate) struct AddRoleForm {
pub name: String,
#[serde(default)]
pub parent: String,
}
#[derive(Clone, Debug, Default, serde::Deserialize)]
pub(crate) struct RoleForm {
pub role: String,
}
#[derive(Clone, Debug, Default, serde::Deserialize)]
pub(crate) struct PermissionForm {
pub role: String,
pub object: String,
pub action: String,
}
#[derive(Clone, Debug, Default, serde::Deserialize)]
pub(crate) struct AssignRoleForm {
pub username: String,
pub role: String,
}
#[derive(Clone, Debug, Default, serde::Deserialize)]
pub(crate) struct ResetPasswordForm {
pub username: String,
pub new_password: String,
pub new_password_confirmation: String,
}
pub(crate) struct PermissionPageState {
pub nav: crate::ui::Nav,
pub roles: Vec<Role>,
pub users: Vec<UserSummary>,
pub selected_role: String,
pub direct_permissions: Vec<Permission>,
pub effective_permissions: Vec<Permission>,
pub grantable_objects: Vec<GrantableObject>,
pub can_manage_roles: bool,
pub can_manage_users: bool,
pub updated: bool,
}
impl PermissionPageState {
pub(crate) fn editable_roles(&self) -> Vec<&Role> {
self.roles.iter().filter(|role| role.kind == "data").collect()
}
pub(crate) fn assignable_roles(&self) -> Vec<&Role> {
self.roles
.iter()
.filter(|role| {
role.kind == "data" || (self.nav.role == "superadmin" && role.name == "admin")
})
.collect()
}
pub(crate) fn selected(&self, role: &str) -> bool {
self.selected_role == role
}
pub(crate) fn direct(&self, object: &str, action: &str) -> bool {
crate::authz::is_direct_permission(&self.direct_permissions, object, action)
}
pub(crate) fn effective(&self, object: &str, action: &str) -> bool {
crate::authz::permissions_permit(&self.effective_permissions, object, action)
}
pub(crate) fn selected_role_is_removable(&self) -> bool {
self.roles
.iter()
.find(|role| role.name == self.selected_role)
.is_some_and(|role| !role.built_in && role.kind == "data")
}
pub(crate) fn can_reset_password(&self, user: &UserSummary) -> bool {
match self.nav.role.as_str() {
"superadmin" => user.role != "superadmin",
"admin" => !matches!(user.role.as_str(), "superadmin" | "admin"),
_ => false,
}
}
}
pub(crate) enum LoadError {
Unauthenticated,
Forbidden,
InvalidSelection(String),
Backend(String),
}

View File

@@ -1,97 +0,0 @@
use askama::Template;
use crate::ui::{Alert, ErrorPage, Nav, render};
use super::state::PermissionPageState;
#[derive(Template)]
#[template(path = "pages/admin/permissions/permissions.html")]
struct PermissionPage<'a> {
nav: Nav,
page: &'a PermissionPageState,
}
pub(crate) fn render_page(page: &PermissionPageState) -> String {
render(&PermissionPage {
nav: page.nav.clone(),
page,
})
}
pub(crate) fn render_error(message: &str) -> String {
render(&ErrorPage {
nav: Nav::default(),
heading: "Permissions unavailable",
message,
})
}
pub(crate) fn render_mutation_error(message: &str) -> String {
render(&Alert::error("Could not update permissions", message))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
auth::{GrantableObject, Permission, Role, UserSummary},
pages::admin::permissions::state::PermissionPageState,
};
#[test]
fn permission_page_distinguishes_direct_and_inherited_grants() {
let page = PermissionPageState {
nav: Nav {
authenticated: true,
role: "admin".to_string(),
can_admin: true,
can_import: false,
can_export: false,
active: "admin",
},
roles: vec![Role {
name: "sales".to_string(),
kind: "data".to_string(),
built_in: false,
parent: "guest".to_string(),
}],
users: vec![UserSummary {
id: "1".to_string(),
username: "alice".to_string(),
email: String::new(),
role: "sales".to_string(),
}],
selected_role: "sales".to_string(),
direct_permissions: vec![Permission {
object: "data:billing/invoice".to_string(),
action: "insert".to_string(),
}],
effective_permissions: vec![
Permission {
object: "data:billing/invoice".to_string(),
action: "insert".to_string(),
},
Permission {
object: "data:billing/invoice".to_string(),
action: "read".to_string(),
},
],
grantable_objects: vec![GrantableObject {
object: "data:billing/invoice".to_string(),
profile: "billing".to_string(),
table: "invoice".to_string(),
kind: "table".to_string(),
allowed_actions: vec!["read".to_string(), "insert".to_string()],
}],
can_manage_roles: true,
can_manage_users: true,
updated: false,
};
let html = render_page(&page);
assert!(!html.contains("Template error"), "{html}");
assert!(html.contains("read · inherited"));
assert!(html.contains("insert ✓"));
assert!(html.contains("/admin/permissions/revoke"));
}
}

View File

@@ -5,4 +5,5 @@ pub(crate) mod admin;
pub(crate) mod analytics;
pub(crate) mod import_export;
pub(crate) mod login;
pub(crate) mod permissions;
pub(crate) mod register;

View File

@@ -0,0 +1,139 @@
//! The loads all three permission sections share: who the caller is, which
//! roles exist, and who holds them.
use axum::http::HeaderMap;
use tonic::transport::Channel;
use crate::{
auth::{
AuthorizationSnapshot, GetAuthorizationRequest, ListRolesRequest, ListUsersRequest, Role,
UserSummary, auth_service_client::AuthServiceClient,
},
services::authenticated_request,
};
use super::state::LoadError;
/// What the signed-in role may do in this section of the site.
pub(crate) struct Access {
pub authorization: AuthorizationSnapshot,
pub can_manage_roles: bool,
pub can_manage_users: bool,
}
/// Loads the caller's authorization and refuses anyone who manages neither
/// roles nor users, which is the gate on all three sections.
pub(crate) async fn access(
auth: &mut AuthServiceClient<Channel>,
headers: &HeaderMap,
) -> Result<Access, LoadError> {
let authorization = auth
.get_authorization(
authenticated_request(headers, GetAuthorizationRequest {})
.map_err(|_| LoadError::Unauthenticated)?,
)
.await
.map_err(status_error)?
.into_inner();
let can_manage_roles = crate::authz::can_manage(&authorization, crate::authz::STRUCT_ROLE);
let can_manage_users = crate::authz::can_manage(&authorization, crate::authz::STRUCT_USER);
if !can_manage_roles && !can_manage_users {
return Err(LoadError::Forbidden);
}
Ok(Access {
authorization,
can_manage_roles,
can_manage_users,
})
}
/// The navbar every permissions page carries. `"permissions"` is a nav entry of
/// its own, so the section is reachable without going through the admin panel.
pub(crate) fn nav(headers: &HeaderMap, access: &Access) -> crate::ui::Nav {
crate::ui::Nav::new(headers, "permissions").with_authorization(&access.authorization)
}
pub(crate) async fn roles(
auth: &mut AuthServiceClient<Channel>,
headers: &HeaderMap,
) -> Result<Vec<Role>, LoadError> {
Ok(auth
.list_roles(
authenticated_request(headers, ListRolesRequest {})
.map_err(|_| LoadError::Unauthenticated)?,
)
.await
.map_err(status_error)?
.into_inner()
.roles)
}
pub(crate) async fn users(
auth: &mut AuthServiceClient<Channel>,
headers: &HeaderMap,
) -> Result<Vec<UserSummary>, LoadError> {
Ok(auth
.list_users(
authenticated_request(headers, ListUsersRequest {})
.map_err(|_| LoadError::Unauthenticated)?,
)
.await
.map_err(status_error)?
.into_inner()
.users)
}
/// The roles whose permissions and assignments the caller may edit: everything
/// they outrank. For an admin that is every data role; for a superadmin it also
/// includes `admin`, which is how read access reaches a structural role. The
/// server enforces the same ranking, so this only keeps roles the caller cannot
/// use out of the pickers.
pub(crate) fn manageable(roles: &[Role], actor_role: &str) -> Vec<Role> {
roles
.iter()
.filter(|role| crate::authz::outranks(actor_role, &role.name))
.cloned()
.collect()
}
pub(crate) fn status_error(error: tonic::Status) -> LoadError {
match error.code() {
tonic::Code::Unauthenticated => LoadError::Unauthenticated,
tonic::Code::PermissionDenied => LoadError::Forbidden,
_ => LoadError::Backend(error.message().to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn role(name: &str, kind: &str) -> Role {
Role {
name: name.to_string(),
kind: kind.to_string(),
built_in: false,
parent: String::new(),
}
}
#[test]
fn only_a_superadmin_sees_admin_among_the_editable_roles() {
let roles = [
role("superadmin", "structural"),
role("admin", "structural"),
role("sales", "data"),
];
let names = |actor| {
manageable(&roles, actor)
.into_iter()
.map(|role| role.name)
.collect::<Vec<_>>()
};
assert_eq!(names("superadmin"), vec!["admin", "sales"]);
assert_eq!(names("admin"), vec!["sales"]);
}
}

View File

@@ -0,0 +1,86 @@
//! The response shapes every permissions form and page share.
use axum::{
http::{HeaderMap, HeaderValue, StatusCode},
response::{Html, IntoResponse, Redirect, Response},
};
use crate::services::reject_cross_site;
use super::{state::LoadError, ui};
/// Runs one form's worth of work and answers the way every form on the site
/// does: a redirect on success, the failure rendered into the form's target
/// otherwise.
pub(crate) async fn mutate<F>(headers: &HeaderMap, destination: &str, operation: F) -> Response
where
F: std::future::Future<Output = Result<(), String>>,
{
if let Some(rejection) = reject_cross_site(headers) {
return rejection;
}
match operation.await {
Ok(()) => success_redirect(destination),
Err(message) => (
StatusCode::UNPROCESSABLE_ENTITY,
Html(ui::render_mutation_error(&message)),
)
.into_response(),
}
}
/// Sends the browser to the reloaded page, the way `POST /login` does: an empty
/// 200 carrying `hx-redirect` alone.
///
/// Never a 303 with a `Location`. These forms are posted by htmx over XHR, and
/// the browser follows a 303 itself, transparently — htmx then sees a 200
/// holding the whole redirected page and swaps *that* into the form's target,
/// putting a second copy of the page inside the status line.
fn success_redirect(destination: &str) -> Response {
let Ok(destination) = HeaderValue::try_from(destination) else {
return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid redirect").into_response();
};
let mut response = Html(String::new()).into_response();
response.headers_mut().insert("hx-redirect", destination);
response
}
pub(crate) fn load_error(error: LoadError) -> Response {
match error {
LoadError::Unauthenticated => Redirect::to("/login").into_response(),
LoadError::Forbidden => (
StatusCode::FORBIDDEN,
Html(ui::render_error(
"This account manages neither roles nor users.",
)),
)
.into_response(),
LoadError::InvalidSelection(message) => {
(StatusCode::BAD_REQUEST, Html(ui::render_error(&message))).into_response()
}
LoadError::Backend(message) => {
(StatusCode::BAD_GATEWAY, Html(ui::render_error(&message))).into_response()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The browser follows a 3xx on an XHR by itself, so a redirect status here
/// would hand htmx the whole reloaded page to swap into the small status
/// line the form points at. The answer carries the destination in a header
/// and nothing in the body.
#[test]
fn a_successful_form_redirects_by_header_and_returns_no_markup_to_swap() {
let response = success_redirect("/permissions/users?updated=true");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers().get("hx-redirect").unwrap(),
"/permissions/users?updated=true"
);
assert!(response.headers().get(axum::http::header::LOCATION).is_none());
}
}

View File

@@ -0,0 +1,7 @@
//! What the three permission sections have in common: the gate on the whole
//! area, the role and user lists, and the response every form answers with.
pub(crate) mod loader;
pub(crate) mod logic;
pub(crate) mod state;
pub(crate) mod ui;

View File

@@ -0,0 +1,104 @@
/// Why a permissions page could not be shown.
pub(crate) enum LoadError {
Unauthenticated,
Forbidden,
InvalidSelection(String),
Backend(String),
}
/// The section switcher every permissions page carries.
///
/// The three sections answer three different questions — what roles exist, who
/// holds them, and what a role may do — and each is reachable only if the
/// signed-in role manages the area behind it.
#[derive(Clone, Debug)]
pub(crate) struct Tabs {
/// `"roles"`, `"users"`, or `"grants"`.
pub active: &'static str,
pub can_manage_roles: bool,
pub can_manage_users: bool,
}
impl Tabs {
pub(crate) fn new(active: &'static str, access: &super::loader::Access) -> Self {
Self {
active,
can_manage_roles: access.can_manage_roles,
can_manage_users: access.can_manage_users,
}
}
pub(crate) fn is(&self, section: &str) -> bool {
self.active == section
}
}
/// One `object|action` pair as it travels through a form.
///
/// Every bulk shortcut posts a list of these, so a button that grants read on
/// three objects and a button that grants one action on one table are the same
/// request with a different list, and the page never has to encode "all" as a
/// rule the server would have to re-derive.
pub(crate) fn pair(object: &str, action: &str) -> String {
format!("{object}|{action}")
}
/// Splits the pairs a form posted, rejecting anything not shaped like one.
pub(crate) fn parse_pairs(raw: &[String]) -> Result<Vec<(String, String)>, String> {
if raw.is_empty() {
return Err("Nothing to change: the form carried no permissions.".to_string());
}
raw.iter()
.map(|value| {
value
.split_once('|')
.filter(|(object, action)| !object.is_empty() && !action.is_empty())
.map(|(object, action)| (object.to_string(), action.to_string()))
.ok_or_else(|| format!("'{value}' is not an object|action pair."))
})
.collect()
}
/// Where a mutation should send the browser afterwards.
///
/// Grants are edited from the permissions page and from the table-definition
/// workspace, so the form says where it came from. Only a path on this site is
/// accepted, which keeps the field from being turned into an open redirect.
pub(crate) fn return_path(requested: &str, fallback: String) -> String {
let requested = requested.trim();
if requested.starts_with('/') && !requested.starts_with("//") {
requested.to_string()
} else {
fallback
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pairs_survive_a_round_trip_and_malformed_ones_are_refused() {
let encoded = vec![pair("data:acme/*", "read"), pair("journal:*", "insert")];
assert_eq!(
parse_pairs(&encoded).unwrap(),
vec![
("data:acme/*".to_string(), "read".to_string()),
("journal:*".to_string(), "insert".to_string()),
]
);
assert!(parse_pairs(&[]).is_err());
assert!(parse_pairs(&["data:acme/*".to_string()]).is_err());
assert!(parse_pairs(&["|read".to_string()]).is_err());
}
#[test]
fn a_return_path_may_only_point_back_at_this_site() {
let fallback = || "/permissions/grants".to_string();
assert_eq!(return_path("/admin/table-definition", fallback()), "/admin/table-definition");
assert_eq!(return_path("https://elsewhere.example", fallback()), "/permissions/grants");
assert_eq!(return_path("//elsewhere.example", fallback()), "/permissions/grants");
assert_eq!(return_path("", fallback()), "/permissions/grants");
}
}

View File

@@ -0,0 +1,13 @@
use crate::ui::{Alert, ErrorPage, Nav, render};
pub(crate) fn render_error(message: &str) -> String {
render(&ErrorPage {
nav: Nav::default(),
heading: "Permissions unavailable",
message,
})
}
pub(crate) fn render_mutation_error(message: &str) -> String {
render(&Alert::error("Could not update permissions", message))
}

View File

@@ -0,0 +1,252 @@
//! Builds the grant matrix: every object the selected role could be given,
//! grouped by profile, with each action marked as held, inherited, or not
//! available for this role.
use axum::http::HeaderMap;
use crate::{
AppState,
auth::{GrantableObject, ListGrantableObjectsRequest, ListRolePermissionsRequest, Permission},
pages::permissions::common::{
loader,
state::{LoadError, Tabs},
},
services::authenticated_request,
};
use super::state::{ACTIONS, Cell, GrantsPage, ObjectGroup, ObjectRow, RoleOption, Selection};
pub(crate) async fn load_page(
state: AppState,
headers: &HeaderMap,
selection: Selection,
) -> Result<GrantsPage, LoadError> {
let mut auth = state.auth;
let access = loader::access(&mut auth, headers).await?;
if !access.can_manage_roles {
return Err(LoadError::Forbidden);
}
let roles = loader::roles(&mut auth, headers).await?;
let editable = loader::manageable(&roles, &access.authorization.role);
let requested = selection.role.trim();
let selected = if requested.is_empty() {
editable.first().cloned()
} else {
match editable.iter().find(|role| role.name == requested) {
Some(role) => Some(role.clone()),
None => {
return Err(LoadError::InvalidSelection(format!(
"'{requested}' is not a role you may edit."
)));
}
}
};
let (permissions, objects) = match &selected {
Some(role) => {
let permissions = auth
.list_role_permissions(
authenticated_request(
headers,
ListRolePermissionsRequest {
role: role.name.clone(),
},
)
.map_err(|_| LoadError::Unauthenticated)?,
)
.await
.map_err(loader::status_error)?
.into_inner();
let objects = auth
.list_grantable_objects(
authenticated_request(
headers,
ListGrantableObjectsRequest {
target_role: role.name.clone(),
},
)
.map_err(|_| LoadError::Unauthenticated)?,
)
.await
.map_err(loader::status_error)?
.into_inner()
.objects;
(permissions, objects)
}
None => Default::default(),
};
Ok(GrantsPage {
nav: loader::nav(headers, &access),
tabs: Tabs::new("grants", &access),
roles: editable
.into_iter()
.map(|role| RoleOption {
name: role.name,
parent: role.parent,
})
.collect(),
selected_role: selected
.as_ref()
.map(|role| role.name.clone())
.unwrap_or_default(),
selected_parent: selected
.as_ref()
.map(|role| role.parent.clone())
.unwrap_or_default(),
selected_is_structural: selected
.as_ref()
.is_some_and(|role| role.kind == "structural"),
groups: group_objects(
&objects,
&permissions.permissions,
&permissions.effective_permissions,
),
updated: selection.updated,
})
}
/// Turns the server's flat object list into one panel per profile, keeping the
/// order it sent: the wildcards that cover everything first, then each profile
/// with its own wildcards ahead of its tables.
fn group_objects(
objects: &[GrantableObject],
direct: &[Permission],
effective: &[Permission],
) -> Vec<ObjectGroup> {
let mut groups: Vec<ObjectGroup> = Vec::new();
for object in objects {
let global = object.profile.is_empty();
let title = if global {
"Everything".to_string()
} else {
object.profile.clone()
};
let index = match groups.iter().position(|group| group.title == title) {
Some(index) => index,
None => {
groups.push(ObjectGroup {
title,
global,
rows: Vec::new(),
});
groups.len() - 1
}
};
let (label, note) = describe(object);
groups[index].rows.push(ObjectRow {
wildcard: object.table.is_empty(),
label,
note,
cells: ACTIONS
.iter()
.map(|action| {
let held = crate::authz::is_direct_permission(direct, &object.object, action);
Cell {
action: (*action).to_string(),
allowed: object.allowed_actions.iter().any(|allowed| allowed == action),
direct: held,
inherited: !held
&& crate::authz::permissions_permit(effective, &object.object, action),
}
})
.collect(),
object: object.object.clone(),
});
}
groups
}
/// What each object covers, said in the terms the person granting it thinks in
/// rather than in object strings.
fn describe(object: &GrantableObject) -> (String, String) {
match object.kind.as_str() {
"global_data" => (
"All tables".to_string(),
"Every table in every profile, including ones added later.".to_string(),
),
"global_journal" => (
"All journals".to_string(),
"Every profile's accounting journal.".to_string(),
),
"global_ecb" => (
"All exchange rates".to_string(),
"ECB rates are imported by the server, so they can only be read.".to_string(),
),
"profile" => (
"All tables".to_string(),
"Every table in this profile, including ones added later.".to_string(),
),
"journal" => (
"Journal".to_string(),
"This profile's accounting journal.".to_string(),
),
"ecb" => (
"Exchange rates".to_string(),
"Imported by the server, so read is the only grant.".to_string(),
),
_ => (
object.table.clone(),
"This table and its whole template family.".to_string(),
),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn object(object: &str, profile: &str, table: &str, kind: &str, actions: &[&str]) -> GrantableObject {
GrantableObject {
object: object.to_string(),
profile: profile.to_string(),
table: table.to_string(),
kind: kind.to_string(),
allowed_actions: actions.iter().map(|action| (*action).to_string()).collect(),
}
}
fn permission(object: &str, action: &str) -> Permission {
Permission {
object: object.to_string(),
action: action.to_string(),
}
}
#[test]
fn objects_group_by_profile_and_a_wildcard_grant_shows_as_inherited_on_its_tables() {
let objects = [
object("data:*", "", "", "global_data", &["read", "insert"]),
object("data:acme/*", "acme", "", "profile", &["read", "insert"]),
object("data:acme/invoices", "acme", "invoices", "table", &["read", "insert"]),
];
let direct = [permission("data:acme/*", "read")];
let effective = [permission("data:acme/*", "read")];
let groups = group_objects(&objects, &direct, &effective);
assert_eq!(groups.len(), 2);
assert!(groups[0].global);
assert_eq!(groups[1].title, "acme");
assert_eq!(groups[1].rows.len(), 2);
let profile_row = &groups[1].rows[0];
assert!(profile_row.wildcard);
assert!(profile_row.cells[0].direct);
// The table is covered by the profile wildcard, so read reads as
// inherited rather than as something to grant again.
let table_row = &groups[1].rows[1];
assert!(!table_row.wildcard);
assert!(!table_row.cells[0].direct);
assert!(table_row.cells[0].inherited);
// The server offered no delete for this role, so the column is closed.
assert!(!table_row.cells[3].allowed);
}
}

View File

@@ -0,0 +1,103 @@
use axum::{
extract::{Query, State},
http::HeaderMap,
response::{Html, IntoResponse, Response},
};
use axum_extra::extract::Form;
use crate::{
AppState,
auth::{GrantPermissionRequest, RevokePermissionRequest},
pages::permissions::common::{
logic::{load_error, mutate},
state::{parse_pairs, return_path},
},
services::authenticated_request,
};
use super::{
loader,
state::{ApplyForm, Selection},
ui,
};
pub(crate) async fn page(
State(state): State<AppState>,
headers: HeaderMap,
Query(selection): Query<Selection>,
) -> Response {
match loader::load_page(state, &headers, selection).await {
Ok(page) => Html(ui::render_page(&page)).into_response(),
Err(error) => load_error(error),
}
}
/// Applies one form's worth of grants or revokes.
///
/// The pairs arrive already resolved by the page — a shortcut is a longer list,
/// not a different rule — so each one is sent as its own call and the first
/// refusal stops the run and is reported. Revoking is the exception: a pair the
/// role does not hold directly is not a failure, because "remove all of this"
/// is posted from a list that includes what a shortcut would have granted.
pub(crate) async fn apply(
State(state): State<AppState>,
headers: HeaderMap,
Form(form): Form<ApplyForm>,
) -> Response {
let role = form.role.trim().to_string();
let destination = return_path(
&form.return_to,
format!("/permissions/grants?role={role}&updated=true"),
);
let request_headers = headers.clone();
mutate(&headers, &destination, async move {
let pairs = parse_pairs(&form.pair)?;
let granting = match form.mode.as_str() {
"grant" => true,
"revoke" => false,
other => return Err(format!("'{other}' is neither grant nor revoke.")),
};
let mut auth = state.auth;
for (object, action) in pairs {
if granting {
auth.grant_permission(
authenticated_request(
&request_headers,
GrantPermissionRequest {
role: role.clone(),
object: object.clone(),
action: action.clone(),
},
)
.map_err(|_| "Missing session".to_string())?,
)
.await
.map_err(|error| {
format!("Granting {action} on {object} failed: {}", error.message())
})?;
} else if let Err(error) = auth
.revoke_permission(
authenticated_request(
&request_headers,
RevokePermissionRequest {
role: role.clone(),
object: object.clone(),
action: action.clone(),
},
)
.map_err(|_| "Missing session".to_string())?,
)
.await
&& error.code() != tonic::Code::NotFound
{
return Err(format!(
"Revoking {action} on {object} failed: {}",
error.message()
));
}
}
Ok(())
})
.await
}

View File

@@ -0,0 +1,20 @@
//! What a role may do: the grant matrix, and the shortcuts that fill it in
//! whole rows, whole profiles, or everything at once.
mod loader;
mod logic;
mod state;
mod ui;
use axum::{
Router,
routing::{get, post},
};
use crate::AppState;
pub(crate) fn router() -> Router<AppState> {
Router::new()
.route("/permissions/grants", get(logic::page))
.route("/permissions/grants/apply", post(logic::apply))
}

View File

@@ -0,0 +1,271 @@
use crate::{
pages::permissions::common::state::{Tabs, pair},
ui::Nav,
};
/// The columns of every grant matrix, in the order the server lists them.
pub(crate) const ACTIONS: [&str; 4] = ["read", "insert", "update", "delete"];
#[derive(Clone, Debug, Default, serde::Deserialize)]
pub(crate) struct Selection {
#[serde(default)]
pub role: String,
#[serde(default)]
pub updated: bool,
}
/// One grant or revoke, however many pairs it covers.
///
/// A single cell and a "full access to this profile" shortcut post the same
/// form; only the list of pairs differs. `return_to` is set by the
/// table-definition workspace, which edits grants without leaving its own page.
#[derive(Clone, Debug, Default, serde::Deserialize)]
pub(crate) struct ApplyForm {
pub role: String,
/// `grant` or `revoke`.
pub mode: String,
#[serde(default)]
pub pair: Vec<String>,
#[serde(default)]
pub return_to: String,
}
pub(crate) struct GrantsPage {
pub nav: Nav,
pub tabs: Tabs,
pub roles: Vec<RoleOption>,
pub selected_role: String,
/// The selected role's parent, whose grants it inherits.
pub selected_parent: String,
/// `structural` roles may only ever be granted read, which the matrix shows
/// by leaving the write columns unavailable; this says why.
pub selected_is_structural: bool,
pub groups: Vec<ObjectGroup>,
pub updated: bool,
}
pub(crate) struct RoleOption {
pub name: String,
pub parent: String,
}
/// The objects of one profile, or the wildcards that cover every profile.
pub(crate) struct ObjectGroup {
pub title: String,
/// The `data:*` / `journal:*` / `ecb:*` group, which is the one whose
/// shortcuts mean "everything, including what is created later".
pub global: bool,
pub rows: Vec<ObjectRow>,
}
pub(crate) struct ObjectRow {
pub object: String,
pub label: String,
pub note: String,
/// Whether this object covers a whole profile (or everything) rather than
/// one table. Group shortcuts use only these, so "full access to this
/// profile" is one wildcard grant instead of one grant per table — and it
/// keeps covering tables added later.
pub wildcard: bool,
pub cells: Vec<Cell>,
}
pub(crate) struct Cell {
pub action: String,
/// Whether this action may be granted on this object for this role at all.
pub allowed: bool,
/// Held by the role itself, so revoking it here works.
pub direct: bool,
/// Already covered, by the parent role or by a wider object.
pub inherited: bool,
}
impl GrantsPage {
pub(crate) fn actions(&self) -> [&'static str; 4] {
ACTIONS
}
pub(crate) fn selected(&self, role: &str) -> bool {
self.selected_role == role
}
/// Read on every wildcard object: the "let them look at everything" grant.
pub(crate) fn everything_read_pairs(&self) -> Vec<String> {
self.global_group()
.map(|group| group.read_pairs())
.unwrap_or_default()
}
/// Every action the role may hold on every wildcard object.
pub(crate) fn everything_pairs(&self) -> Vec<String> {
self.global_group()
.map(|group| group.all_pairs())
.unwrap_or_default()
}
/// Every grant the role holds directly, which is what "start over" revokes.
pub(crate) fn direct_pairs(&self) -> Vec<String> {
self.groups
.iter()
.flat_map(|group| group.direct_pairs())
.collect()
}
pub(crate) fn has_direct(&self) -> bool {
self.groups.iter().any(ObjectGroup::has_direct)
}
fn global_group(&self) -> Option<&ObjectGroup> {
self.groups.iter().find(|group| group.global)
}
}
impl ObjectGroup {
/// Every action allowed on this group's wildcard objects.
pub(crate) fn all_pairs(&self) -> Vec<String> {
self.wildcards()
.flat_map(|row| row.all_pairs())
.collect()
}
/// Read on this group's wildcard objects.
pub(crate) fn read_pairs(&self) -> Vec<String> {
self.wildcards()
.filter_map(|row| row.pair_for("read"))
.collect()
}
/// Everything held directly anywhere in this group, tables included, so
/// clearing a profile really clears it.
pub(crate) fn direct_pairs(&self) -> Vec<String> {
self.rows.iter().flat_map(|row| row.direct_pairs()).collect()
}
pub(crate) fn has_direct(&self) -> bool {
self.rows.iter().any(ObjectRow::has_direct)
}
/// Whether this group has anything for its shortcuts to grant. The server
/// gives every profile its wildcards, so this is only ever false for a
/// group of bare tables — and a button that would post nothing is not shown
/// rather than left to fail on submit.
pub(crate) fn has_wildcards(&self) -> bool {
self.wildcards().next().is_some()
}
fn wildcards(&self) -> impl Iterator<Item = &ObjectRow> {
self.rows.iter().filter(|row| row.wildcard)
}
}
impl ObjectRow {
pub(crate) fn all_pairs(&self) -> Vec<String> {
self.cells
.iter()
.filter(|cell| cell.allowed)
.map(|cell| pair(&self.object, &cell.action))
.collect()
}
pub(crate) fn direct_pairs(&self) -> Vec<String> {
self.cells
.iter()
.filter(|cell| cell.direct)
.map(|cell| pair(&self.object, &cell.action))
.collect()
}
pub(crate) fn has_direct(&self) -> bool {
self.cells.iter().any(|cell| cell.direct)
}
/// One cell's pair, as the same list shape every other button posts.
pub(crate) fn one_pair(&self, action: &str) -> Vec<String> {
vec![pair(&self.object, action)]
}
fn pair_for(&self, action: &str) -> Option<String> {
self.cells
.iter()
.find(|cell| cell.action == action && cell.allowed)
.map(|cell| pair(&self.object, &cell.action))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cells(allowed: &[&str], direct: &[&str]) -> Vec<Cell> {
ACTIONS
.iter()
.map(|action| Cell {
action: (*action).to_string(),
allowed: allowed.contains(action),
direct: direct.contains(action),
inherited: false,
})
.collect()
}
fn group() -> ObjectGroup {
ObjectGroup {
title: "acme".to_string(),
global: false,
rows: vec![
ObjectRow {
object: "data:acme/*".to_string(),
label: "All tables".to_string(),
note: String::new(),
wildcard: true,
cells: cells(&ACTIONS, &["read"]),
},
ObjectRow {
object: "ecb:acme".to_string(),
label: "ECB rates".to_string(),
note: String::new(),
wildcard: true,
cells: cells(&["read"], &[]),
},
ObjectRow {
object: "data:acme/invoices".to_string(),
label: "invoices".to_string(),
note: String::new(),
wildcard: false,
cells: cells(&ACTIONS, &["delete"]),
},
],
}
}
#[test]
fn a_profile_shortcut_grants_the_wildcards_and_never_an_action_the_server_refuses() {
let group = group();
// The table row is covered by data:acme/*, so it is not granted again,
// and ECB rates only ever offer read.
assert_eq!(
group.all_pairs(),
vec![
"data:acme/*|read",
"data:acme/*|insert",
"data:acme/*|update",
"data:acme/*|delete",
"ecb:acme|read",
]
);
assert_eq!(
group.read_pairs(),
vec!["data:acme/*|read", "ecb:acme|read"]
);
}
#[test]
fn clearing_a_profile_covers_grants_made_on_single_tables() {
let group = group();
assert!(group.has_direct());
assert_eq!(
group.direct_pairs(),
vec!["data:acme/*|read", "data:acme/invoices|delete"]
);
}
}

View File

@@ -0,0 +1,127 @@
use askama::Template;
use crate::ui::{Nav, render};
use super::state::GrantsPage;
/// GET /permissions/grants
#[derive(Template)]
#[template(path = "pages/permissions/grants/grants.html")]
struct GrantsTemplate<'a> {
nav: Nav,
page: &'a GrantsPage,
}
pub(crate) fn render_page(page: &GrantsPage) -> String {
render(&GrantsTemplate {
nav: page.nav.clone(),
page,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pages::permissions::{
common::state::Tabs,
grants::state::{ACTIONS, Cell, GrantsPage, ObjectGroup, ObjectRow, RoleOption},
};
fn cells(allowed: &[&str], direct: &[&str], inherited: &[&str]) -> Vec<Cell> {
ACTIONS
.iter()
.map(|action| Cell {
action: (*action).to_string(),
allowed: allowed.contains(action),
direct: direct.contains(action),
inherited: inherited.contains(action),
})
.collect()
}
fn page() -> GrantsPage {
GrantsPage {
nav: Nav {
authenticated: true,
role: "admin".to_string(),
can_admin: true,
can_permissions: true,
can_import: false,
can_export: false,
active: "permissions",
},
tabs: Tabs {
active: "grants",
can_manage_roles: true,
can_manage_users: true,
},
roles: vec![RoleOption {
name: "sales".to_string(),
parent: "guest".to_string(),
}],
selected_role: "sales".to_string(),
selected_parent: "guest".to_string(),
selected_is_structural: false,
groups: vec![
ObjectGroup {
title: "Everything".to_string(),
global: true,
rows: vec![ObjectRow {
object: "data:*".to_string(),
label: "All tables".to_string(),
note: "Every table in every profile.".to_string(),
wildcard: true,
cells: cells(&ACTIONS, &[], &[]),
}],
},
ObjectGroup {
title: "billing".to_string(),
global: false,
rows: vec![ObjectRow {
object: "data:billing/invoice".to_string(),
label: "invoice".to_string(),
note: String::new(),
wildcard: false,
cells: cells(&ACTIONS, &["insert"], &["read"]),
}],
},
],
updated: false,
}
}
#[test]
fn the_matrix_separates_a_held_grant_from_an_inherited_one() {
let html = render_page(&page());
assert!(!html.contains("Template error"), "{html}");
// A held grant is a revoke button; an inherited one is not editable
// here, because it belongs to the parent role.
assert!(html.contains(r#"value="data:billing/invoice|insert""#));
assert!(html.contains("inherited"));
assert!(html.contains("/permissions/grants/apply"));
}
#[test]
fn the_shortcuts_post_whole_lists_of_pairs() {
let html = render_page(&page());
// "Full access to everything" is the wildcard row's four actions.
for action in ACTIONS {
assert!(
html.contains(&format!(r#"value="data:*|{action}""#)),
"missing data:*|{action}"
);
}
}
#[test]
fn a_site_without_roles_says_so_instead_of_rendering_an_empty_matrix() {
let mut page = page();
page.roles.clear();
page.selected_role.clear();
page.groups.clear();
let html = render_page(&page);
assert!(!html.contains("Template error"), "{html}");
assert!(html.contains("/permissions/roles"));
}
}

View File

@@ -0,0 +1,43 @@
//! Roles and permissions, as three questions asked separately:
//!
//! * `/permissions/roles` — which roles exist, and what they inherit.
//! * `/permissions/users` — who holds which role.
//! * `/permissions/grants` — what a role may do.
//!
//! It is a nav section of its own rather than a page inside the admin panel,
//! because managing users is not the same job as designing tables, and the two
//! are held by different people.
pub(crate) mod common;
mod grants;
mod roles;
mod users;
use axum::{
Router,
extract::State,
http::HeaderMap,
response::{IntoResponse, Redirect, Response},
routing::get,
};
use crate::AppState;
pub(crate) fn router() -> Router<AppState> {
Router::new()
.route("/permissions", get(entry))
.merge(roles::router())
.merge(users::router())
.merge(grants::router())
}
/// The navbar link. It lands on the first section the caller may open, so an
/// account that only manages users never sees a forbidden page on the way in.
async fn entry(State(state): State<AppState>, headers: HeaderMap) -> Response {
let mut auth = state.auth;
match common::loader::access(&mut auth, &headers).await {
Ok(access) if access.can_manage_roles => Redirect::to("/permissions/roles").into_response(),
Ok(_) => Redirect::to("/permissions/users").into_response(),
Err(error) => common::logic::load_error(error),
}
}

View File

@@ -0,0 +1,62 @@
use axum::http::HeaderMap;
use crate::{
AppState,
pages::permissions::common::{
loader,
state::{LoadError, Tabs},
},
};
use super::state::{RoleRow, RolesPage, Selection};
pub(crate) async fn load_page(
state: AppState,
headers: &HeaderMap,
selection: Selection,
) -> Result<RolesPage, LoadError> {
let mut auth = state.auth;
let access = loader::access(&mut auth, headers).await?;
if !access.can_manage_roles {
return Err(LoadError::Forbidden);
}
let roles = loader::roles(&mut auth, headers).await?;
// The user list is only there to say how many people hold each role, so a
// caller who does not manage users simply does not get that column.
let users = if access.can_manage_users {
Some(loader::users(&mut auth, headers).await?)
} else {
None
};
let editable = loader::manageable(&roles, &access.authorization.role);
let rows = roles
.iter()
.map(|role| RoleRow {
name: role.name.clone(),
kind: role.kind.clone(),
parent: role.parent.clone(),
built_in: role.built_in,
users: users.as_ref().map(|users| {
users
.iter()
.filter(|user| user.role == role.name)
.count()
}),
editable: editable.iter().any(|candidate| candidate.name == role.name),
})
.collect();
Ok(RolesPage {
nav: loader::nav(headers, &access),
tabs: Tabs::new("roles", &access),
roles: rows,
parents: editable
.iter()
.filter(|role| role.kind == "data")
.map(|role| role.name.clone())
.collect(),
updated: selection.updated,
})
}

View File

@@ -0,0 +1,102 @@
use axum::{
extract::{Query, State},
http::HeaderMap,
response::{Html, IntoResponse, Response},
};
use axum_extra::extract::Form;
use crate::{
AppState,
auth::{AddRoleRequest, GrantPermissionRequest, RemoveRoleRequest},
pages::permissions::common::logic::{load_error, mutate},
services::authenticated_request,
};
use super::{
loader,
state::{CreateRoleForm, RemoveRoleForm, Selection, starter_grants},
ui,
};
pub(crate) async fn page(
State(state): State<AppState>,
headers: HeaderMap,
Query(selection): Query<Selection>,
) -> Response {
match loader::load_page(state, &headers, selection).await {
Ok(page) => Html(ui::render_page(&page)).into_response(),
Err(error) => load_error(error),
}
}
/// Creates a role and, if a starter access level was chosen, hands it the
/// grants that go with it. The grants are separate calls, so a role whose
/// starter access fails halfway still exists — the failure names what is
/// missing and the access tab shows exactly what landed.
pub(crate) async fn create(
State(state): State<AppState>,
headers: HeaderMap,
Form(form): Form<CreateRoleForm>,
) -> Response {
let role = form.name.trim().to_string();
let destination = format!("/permissions/grants?role={role}&updated=true");
let request_headers = headers.clone();
mutate(&headers, &destination, async move {
let grants = starter_grants(form.access.trim())?;
let mut auth = state.auth;
auth.add_role(
authenticated_request(
&request_headers,
AddRoleRequest {
name: role.clone(),
parent: form.parent.trim().to_string(),
},
)
.map_err(|_| "Missing session".to_string())?,
)
.await
.map_err(|error| error.message().to_string())?;
for (object, action) in grants {
auth.grant_permission(
authenticated_request(
&request_headers,
GrantPermissionRequest {
role: role.clone(),
object: object.to_string(),
action: action.to_string(),
},
)
.map_err(|_| "Missing session".to_string())?,
)
.await
.map_err(|error| {
format!(
"Role '{role}' was created, but granting {action} on {object} failed: {}",
error.message()
)
})?;
}
Ok(())
})
.await
}
pub(crate) async fn remove(
State(state): State<AppState>,
headers: HeaderMap,
Form(form): Form<RemoveRoleForm>,
) -> Response {
let request_headers = headers.clone();
mutate(&headers, "/permissions/roles?updated=true", 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(())
})
.await
}

View File

@@ -0,0 +1,21 @@
//! Which roles exist: creating them, the tree they inherit through, and
//! removing the ones nobody holds.
mod loader;
mod logic;
mod state;
mod ui;
use axum::{
Router,
routing::{get, post},
};
use crate::AppState;
pub(crate) fn router() -> Router<AppState> {
Router::new()
.route("/permissions/roles", get(logic::page))
.route("/permissions/roles/create", post(logic::create))
.route("/permissions/roles/remove", post(logic::remove))
}

View File

@@ -0,0 +1,133 @@
use crate::{pages::permissions::common::state::Tabs, ui::Nav};
#[derive(Clone, Debug, Default, serde::Deserialize)]
pub(crate) struct Selection {
#[serde(default)]
pub updated: bool,
}
#[derive(Clone, Debug, Default, serde::Deserialize)]
pub(crate) struct CreateRoleForm {
pub name: String,
#[serde(default)]
pub parent: String,
/// Which starter access the new role gets: `none`, `read`, or `full`.
#[serde(default)]
pub access: String,
}
#[derive(Clone, Debug, Default, serde::Deserialize)]
pub(crate) struct RemoveRoleForm {
pub role: String,
}
pub(crate) struct RolesPage {
pub nav: Nav,
pub tabs: Tabs,
pub roles: Vec<RoleRow>,
/// Roles a new role may inherit from.
pub parents: Vec<String>,
pub updated: bool,
}
pub(crate) struct RoleRow {
pub name: String,
pub kind: String,
pub parent: String,
pub built_in: bool,
/// How many users hold the role. `None` when the caller does not manage
/// users and so never saw the list.
pub users: Option<usize>,
/// Whether the caller may open this role's access, which is also what makes
/// it removable.
pub editable: bool,
}
impl RoleRow {
pub(crate) fn removable(&self) -> bool {
self.editable && !self.built_in && self.users.is_none_or(|count| count == 0)
}
/// Why the delete button is missing, so an undeletable role says so instead
/// of showing nothing.
pub(crate) fn keeps_reason(&self) -> &'static str {
if !self.editable {
"outranks you"
} else if self.built_in {
"built in"
} else if self.users.is_some_and(|count| count > 0) {
"still assigned"
} else {
""
}
}
}
/// The grants a starter-access choice hands the new role.
///
/// These are wildcard objects on purpose: they keep covering profiles and
/// tables added later, which is what "everything" has to mean for a role
/// created before the data exists. ECB rates are written by the server, so they
/// are readable and nothing more.
pub(crate) fn starter_grants(access: &str) -> Result<Vec<(&'static str, &'static str)>, String> {
match access {
"" | "none" => Ok(Vec::new()),
"read" => Ok(vec![
("data:*", "read"),
("journal:*", "read"),
("ecb:*", "read"),
]),
"full" => {
let mut grants = vec![("ecb:*", "read")];
for object in ["data:*", "journal:*"] {
for action in ["read", "insert", "update", "delete"] {
grants.push((object, action));
}
}
Ok(grants)
}
other => Err(format!("'{other}' is not a starter access level.")),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn starter_access_never_hands_out_a_write_on_ecb_rates() {
assert!(starter_grants("none").unwrap().is_empty());
assert!(starter_grants("nonsense").is_err());
for level in ["read", "full"] {
let grants = starter_grants(level).unwrap();
assert!(
grants
.iter()
.all(|(object, action)| *object != "ecb:*" || *action == "read"),
"{level} granted a write on ECB rates"
);
}
assert_eq!(starter_grants("read").unwrap().len(), 3);
assert_eq!(starter_grants("full").unwrap().len(), 9);
}
#[test]
fn a_role_is_only_removable_once_nobody_holds_it() {
let row = |built_in, users, editable| RoleRow {
name: "sales".to_string(),
kind: "data".to_string(),
parent: String::new(),
built_in,
users,
editable,
};
assert!(row(false, Some(0), true).removable());
assert!(!row(false, Some(2), true).removable());
assert!(!row(true, Some(0), true).removable());
assert!(!row(false, Some(0), false).removable());
assert_eq!(row(false, Some(2), true).keeps_reason(), "still assigned");
}
}

View File

@@ -0,0 +1,78 @@
use askama::Template;
use crate::ui::{Nav, render};
use super::state::RolesPage;
/// GET /permissions/roles
#[derive(Template)]
#[template(path = "pages/permissions/roles/roles.html")]
struct RolesTemplate<'a> {
nav: Nav,
page: &'a RolesPage,
}
pub(crate) fn render_page(page: &RolesPage) -> String {
render(&RolesTemplate {
nav: page.nav.clone(),
page,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pages::permissions::{
common::state::Tabs,
roles::state::{RoleRow, RolesPage},
};
#[test]
fn the_role_list_offers_creation_and_holds_back_deletion_of_a_role_in_use() {
let page = RolesPage {
nav: Nav {
authenticated: true,
role: "admin".to_string(),
can_admin: true,
can_permissions: true,
can_import: false,
can_export: false,
active: "permissions",
},
tabs: Tabs {
active: "roles",
can_manage_roles: true,
can_manage_users: true,
},
roles: vec![
RoleRow {
name: "sales".to_string(),
kind: "data".to_string(),
parent: "guest".to_string(),
built_in: false,
users: Some(2),
editable: true,
},
RoleRow {
name: "temp".to_string(),
kind: "data".to_string(),
parent: String::new(),
built_in: false,
users: Some(0),
editable: true,
},
],
parents: vec!["sales".to_string()],
updated: false,
};
let html = render_page(&page);
assert!(!html.contains("Template error"), "{html}");
assert!(html.contains("/permissions/roles/create"));
// The unused role can go; the one two people hold says why it cannot.
assert!(html.contains(r#"value="temp""#));
assert!(html.contains("still assigned"));
// Every role links straight to the tab that edits what it may do.
assert!(html.contains("/permissions/grants?role=sales"));
}
}

View File

@@ -0,0 +1,46 @@
use axum::http::HeaderMap;
use crate::{
AppState,
pages::permissions::common::{
loader,
state::{LoadError, Tabs},
},
};
use super::state::{Selection, UserRow, UsersPage};
pub(crate) async fn load_page(
state: AppState,
headers: &HeaderMap,
selection: Selection,
) -> Result<UsersPage, LoadError> {
let mut auth = state.auth;
let access = loader::access(&mut auth, headers).await?;
if !access.can_manage_users {
return Err(LoadError::Forbidden);
}
let actor = access.authorization.role.clone();
let roles = loader::roles(&mut auth, headers).await?;
let users = loader::users(&mut auth, headers).await?;
Ok(UsersPage {
nav: loader::nav(headers, &access),
tabs: Tabs::new("users", &access),
users: users
.into_iter()
.map(|user| UserRow {
editable: crate::authz::outranks(&actor, &user.role),
username: user.username,
email: user.email,
role: user.role,
})
.collect(),
roles: loader::manageable(&roles, &actor)
.into_iter()
.map(|role| role.name)
.collect(),
updated: selection.updated,
})
}

View File

@@ -0,0 +1,83 @@
use axum::{
extract::{Query, State},
http::HeaderMap,
response::{Html, IntoResponse, Response},
};
use axum_extra::extract::Form;
use crate::{
AppState,
auth::{AssignUserRoleRequest, ResetUserPasswordRequest},
pages::permissions::common::logic::{load_error, mutate},
services::authenticated_request,
};
use super::{
loader,
state::{AssignRoleForm, ResetPasswordForm, Selection},
ui,
};
const DESTINATION: &str = "/permissions/users?updated=true";
pub(crate) async fn page(
State(state): State<AppState>,
headers: HeaderMap,
Query(selection): Query<Selection>,
) -> Response {
match loader::load_page(state, &headers, selection).await {
Ok(page) => Html(ui::render_page(&page)).into_response(),
Err(error) => load_error(error),
}
}
pub(crate) async fn assign_role(
State(state): State<AppState>,
headers: HeaderMap,
Form(form): Form<AssignRoleForm>,
) -> Response {
let request_headers = headers.clone();
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(())
})
.await
}
pub(crate) async fn reset_password(
State(state): State<AppState>,
headers: HeaderMap,
Form(form): Form<ResetPasswordForm>,
) -> Response {
let request_headers = headers.clone();
mutate(&headers, DESTINATION, async move {
let mut auth = state.auth;
auth.reset_user_password(
authenticated_request(
&request_headers,
ResetUserPasswordRequest {
username: form.username,
new_password: form.new_password,
new_password_confirmation: form.new_password_confirmation,
},
)
.map_err(|_| "Missing session".to_string())?,
)
.await
.map_err(|error| error.message().to_string())?;
Ok(())
})
.await
}

View File

@@ -0,0 +1,21 @@
//! Who holds which role: assigning a role to a user, and resetting a password
//! for a user the caller outranks.
mod loader;
mod logic;
mod state;
mod ui;
use axum::{
Router,
routing::{get, post},
};
use crate::AppState;
pub(crate) fn router() -> Router<AppState> {
Router::new()
.route("/permissions/users", get(logic::page))
.route("/permissions/users/role", post(logic::assign_role))
.route("/permissions/users/password", post(logic::reset_password))
}

View File

@@ -0,0 +1,44 @@
use crate::{pages::permissions::common::state::Tabs, ui::Nav};
#[derive(Clone, Debug, Default, serde::Deserialize)]
pub(crate) struct Selection {
#[serde(default)]
pub updated: bool,
}
#[derive(Clone, Debug, Default, serde::Deserialize)]
pub(crate) struct AssignRoleForm {
pub username: String,
pub role: String,
}
#[derive(Clone, Debug, Default, serde::Deserialize)]
pub(crate) struct ResetPasswordForm {
pub username: String,
pub new_password: String,
pub new_password_confirmation: String,
}
pub(crate) struct UsersPage {
pub nav: Nav,
pub tabs: Tabs,
pub users: Vec<UserRow>,
/// Roles the caller may hand out.
pub roles: Vec<String>,
pub updated: bool,
}
pub(crate) struct UserRow {
pub username: String,
pub email: String,
pub role: String,
/// Whether the caller outranks this user, which is what the server checks
/// before either changing their role or resetting their password.
pub editable: bool,
}
impl UsersPage {
pub(crate) fn editable_users(&self) -> usize {
self.users.iter().filter(|user| user.editable).count()
}
}

View File

@@ -0,0 +1,71 @@
use askama::Template;
use crate::ui::{Nav, render};
use super::state::UsersPage;
/// GET /permissions/users
#[derive(Template)]
#[template(path = "pages/permissions/users/users.html")]
struct UsersTemplate<'a> {
nav: Nav,
page: &'a UsersPage,
}
pub(crate) fn render_page(page: &UsersPage) -> String {
render(&UsersTemplate {
nav: page.nav.clone(),
page,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pages::permissions::{
common::state::Tabs,
users::state::{UserRow, UsersPage},
};
#[test]
fn a_user_the_caller_does_not_outrank_gets_no_controls() {
let page = UsersPage {
nav: Nav {
authenticated: true,
role: "admin".to_string(),
can_admin: true,
can_permissions: true,
can_import: false,
can_export: false,
active: "permissions",
},
tabs: Tabs {
active: "users",
can_manage_roles: true,
can_manage_users: true,
},
users: vec![
UserRow {
username: "alice".to_string(),
email: "alice@example.com".to_string(),
role: "sales".to_string(),
editable: true,
},
UserRow {
username: "root".to_string(),
email: String::new(),
role: "superadmin".to_string(),
editable: false,
},
],
roles: vec!["sales".to_string(), "guest".to_string()],
updated: false,
};
let html = render_page(&page);
assert!(!html.contains("Template error"), "{html}");
assert_eq!(html.matches("/permissions/users/role").count(), 1);
assert_eq!(html.matches("/permissions/users/password").count(), 1);
assert!(html.contains("outranks you"));
}
}

View File

@@ -16,19 +16,22 @@ pub(crate) struct Nav {
pub authenticated: bool,
pub role: String,
pub can_admin: bool,
pub can_permissions: bool,
pub can_import: bool,
pub can_export: bool,
pub active: &'static str,
}
impl Nav {
/// `active` is the nav link to highlight: `"admin"`, `"analytics"`,
/// `"login"`, or `""` for pages that are not themselves nav entries.
/// `active` is the nav link to highlight: `"admin"`, `"permissions"`,
/// `"analytics"`, `"login"`, or `""` for pages that are not themselves nav
/// entries.
pub(crate) fn new(headers: &HeaderMap, active: &'static str) -> Self {
Self {
authenticated: crate::cookie_value(headers, SESSION_COOKIE).is_some(),
role: String::new(),
can_admin: false,
can_permissions: false,
can_import: false,
can_export: false,
active,
@@ -41,6 +44,10 @@ impl Nav {
) -> Self {
self.role = authorization.role.clone();
self.can_admin = crate::authz::can_open_admin(authorization);
// Permissions is its own nav section, so it is not gated on the admin
// panel: managing either roles or users is enough to open it.
self.can_permissions = crate::authz::can_manage(authorization, crate::authz::STRUCT_ROLE)
|| crate::authz::can_manage(authorization, crate::authz::STRUCT_USER);
self.can_import = authorization.permissions.iter().any(|permission| {
permission.action == "insert" && permission.object.starts_with("data:")
});
@@ -57,6 +64,7 @@ impl Default for Nav {
authenticated: false,
role: String::new(),
can_admin: false,
can_permissions: false,
can_import: false,
can_export: false,
active: "",

View File

@@ -178,6 +178,45 @@
.danger-panel h2 { color: #a12b2b; }
.form-actions button.danger-submit { background: #a12b2b; }
/* ---------- Permissions (pages/permissions) ---------- */
/* The three sections — roles, people, access — are separate pages, and this
is the switcher between them, rendered by pages/permissions/tabs.html. */
.tabs { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 18px; }
.tab { display: grid; gap: 2px; padding: 10px 16px; border: 1px solid #d9dfe7; border-radius: 9px; color: #33415c; background: white; text-decoration: none; }
.tab small { color: #7c8796; font-size: 11px; }
.tab:hover { background: #f2f6fc; }
.tab.selected { border-color: #a9c7f6; background: #eaf2ff; }
.tab.selected span { font-weight: 700; color: #1d4ed8; }
.table-scroll { overflow-x: auto; }
.link-action { color: #2563eb; text-decoration: none; }
.notice { padding: 10px 12px; border: 1px solid #cfe0c4; border-radius: 8px; color: #21643a; background: #f2f9f3; }
/* The shortcut buttons — read everything, full access, clear — sit above the
matrix they fill in, so they are wider than the cell buttons and read as
sentences rather than as symbols. */
.shortcut-row { margin: 12px 0 4px; }
.shortcut-row button.secondary { margin-top: 0; padding: 8px 14px; }
/* Objects down the side, actions across the top. Each cell is its own form,
because a grant is one call to the server either way. */
.grant-matrix td, .grant-matrix th { white-space: normal; }
.grant-matrix th { text-transform: capitalize; }
.grant-matrix .object-label { display: block; font-weight: 600; color: #24324a; }
.grant-matrix td small { display: block; margin: 2px 0; color: #7c8796; }
.grant-matrix td code { font-size: 11px; color: #5b6678; }
.grant-cell { width: 82px; text-align: center; vertical-align: middle; }
.grant-cell .actions { justify-content: center; gap: 4px; }
.grant-cell form { margin: 0 auto; }
.grant-cell button { min-width: 34px; padding: 4px 9px; border-radius: 5px; cursor: pointer; }
button.cell-on { border: 1px solid #a9d3b4; color: #21643a; background: #eefaf1; }
button.cell-on:hover { border-color: #e0b7b3; color: #a12b2b; background: #fdf3f3; }
button.cell-off { border: 1px dashed #cdd5e0; color: #8a94a4; background: white; }
button.cell-off:hover { border-style: solid; border-color: #a9c7f6; color: #1d4ed8; background: #eef3fe; }
.cell-closed { color: #c3cad4; }
.cell-inherited { color: #1d4ed8; }
/* ---------- Narrow screens ---------- */
@media (max-width: 850px) {

View File

@@ -17,7 +17,6 @@
{% if page.can_manage_scripts %}<a href="/admin/logic/new">Add logic</a>{% endif %}
{% if page.can_manage_validations %}<a href="/admin/validation/new">Add validation</a>{% endif %}
{% if page.can_manage_validations %}<a href="/admin/validation/sets/new">Add rule</a>{% endif %}
{% if page.can_manage_permissions %}<a href="/admin/permissions">Permissions</a>{% endif %}
{% if page.can_export %}<a href="/admin/export">Export</a>{% endif %}
</div>
</section>

View File

@@ -1,107 +0,0 @@
{% extends "ui/base.html" %}
{% block title %}Roles and permissions{% endblock %}
{% block content %}
<main>
<section class="heading">
<div>
<p class="eyebrow">Authorization</p>
<h1>Roles and permissions</h1>
<p>Manage data roles, their direct grants, inheritance, and user assignments.</p>
</div>
<div class="actions"><a href="/admin">← Admin panel</a></div>
</section>
{% if page.updated %}<p class="notice">Administration updated. The current session remains valid and all subsequent requests use the new state.</p>{% endif %}
{% if page.can_manage_roles %}
<section class="panel">
<h2>Data roles</h2>
<form method="get" action="/admin/permissions" class="form-grid">
<label>Role
<select name="role" onchange="this.form.submit()">
{% for role in page.editable_roles() %}
<option value="{{ role.name }}" {% if page.selected(role.name.as_str()) %}selected{% endif %}>{{ role.name }}{% if !role.parent.is_empty() %} → {{ role.parent }}{% endif %}</option>
{% endfor %}
</select>
</label>
</form>
<form hx-post="/admin/permissions/roles" hx-target="#permission-status" class="form-grid">
<label>New role<input name="name" required maxlength="50" placeholder="sales"></label>
<label>Inherits from
<select name="parent"><option value="">No parent</option>{% for role in page.editable_roles() %}<option value="{{ role.name }}">{{ role.name }}</option>{% endfor %}</select>
</label>
<button type="submit">Create role</button>
</form>
{% if page.selected_role_is_removable() %}
<form hx-post="/admin/permissions/roles/remove" hx-target="#permission-status">
<input type="hidden" name="role" value="{{ page.selected_role }}">
<button type="submit" class="danger">Remove {{ page.selected_role }}</button>
</form>
{% endif %}
</section>
<section class="panel">
<h2>Grants for {{ page.selected_role }}</h2>
<p class="hint">Direct grants can be revoked here. “Inherited” permissions come from the roles parent.</p>
<div class="table-scroll">
<table class="builder-table">
<thead><tr><th>Object</th><th>Scope</th><th>Actions</th></tr></thead>
<tbody>
{% for object in page.grantable_objects %}
<tr>
<td><code>{{ object.object }}</code></td>
<td>{{ object.kind }}{% if !object.profile.is_empty() %} · {{ object.profile }}{% endif %}{% if !object.table.is_empty() %} / {{ object.table }}{% endif %}</td>
<td>
<div class="actions">
{% for action in object.allowed_actions %}
{% if page.direct(object.object.as_str(), action.as_str()) %}
<form hx-post="/admin/permissions/revoke" hx-target="#permission-status">
<input type="hidden" name="role" value="{{ page.selected_role }}"><input type="hidden" name="object" value="{{ object.object }}"><input type="hidden" name="action" value="{{ action }}">
<button type="submit" class="danger">{{ action }} ✓</button>
</form>
{% else if page.effective(object.object.as_str(), action.as_str()) %}
<span class="tag">{{ action }} · inherited</span>
{% else %}
<form hx-post="/admin/permissions/grant" hx-target="#permission-status">
<input type="hidden" name="role" value="{{ page.selected_role }}"><input type="hidden" name="object" value="{{ object.object }}"><input type="hidden" name="action" value="{{ action }}">
<button type="submit" class="secondary">Grant {{ action }}</button>
</form>
{% endif %}
{% endfor %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endif %}
{% if page.can_manage_users %}
<section class="panel">
<h2>Users</h2>
<table class="builder-table">
<thead><tr><th>Username</th><th>Email</th><th>Current role</th><th>Assign role</th><th>Reset password</th></tr></thead>
<tbody>{% for user in page.users %}<tr>
<td>{{ user.username }}</td><td>{{ user.email }}</td><td>{{ user.role }}</td>
<td><form hx-post="/admin/permissions/users/role" hx-target="#permission-status" class="actions">
<input type="hidden" name="username" value="{{ user.username }}">
<select name="role">{% for role in page.assignable_roles() %}<option value="{{ role.name }}" {% if user.role == role.name %}selected{% endif %}>{{ role.name }}</option>{% endfor %}</select>
<button type="submit">Assign</button>
</form></td>
<td>{% if page.can_reset_password(user) %}<form hx-post="/admin/permissions/users/password" hx-target="#permission-status" class="actions">
<input type="hidden" name="username" value="{{ user.username }}">
<input name="new_password" type="password" autocomplete="new-password" placeholder="New password" required>
<input name="new_password_confirmation" type="password" autocomplete="new-password" placeholder="Confirm password" required>
<button type="submit">Reset</button>
</form>{% endif %}</td>
</tr>{% endfor %}</tbody>
</table>
</section>
{% endif %}
<div id="permission-status" aria-live="polite"></div>
</main>
{% endblock %}

View File

@@ -154,22 +154,26 @@
{% 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 and take effect on subsequent requests without replacing the current session.</p>
<p class="hint">
These grants cover this table family and take effect on subsequent requests without replacing the
current session. Wider access — a whole profile, or every table at once — is on the
<a href="/permissions/grants">Permissions page</a>.
</p>
<table class="builder-table">
<thead><tr><th>Role</th><th>Actions</th></tr></thead>
<tbody>{% for role in page.role_permissions %}<tr>
<td>{{ role.role }}</td>
<td><div class="actions">{% for permission in role.actions %}
{% if permission.direct %}
<form hx-post="/admin/permissions/revoke" hx-target="#permission-status">
<input type="hidden" name="role" value="{{ role.role }}"><input type="hidden" name="object" value="{{ page.permission_object }}"><input type="hidden" name="action" value="{{ permission.action }}">
<form hx-post="/permissions/grants/apply" hx-target="#permission-status">
<input type="hidden" name="role" value="{{ role.role }}"><input type="hidden" name="mode" value="revoke"><input type="hidden" name="pair" value="{{ page.permission_object }}|{{ permission.action }}"><input type="hidden" name="return_to" value="/admin/table-definition{{ page.selection.query() }}">
<button type="submit" class="danger">{{ permission.action }} ✓</button>
</form>
{% else if permission.effective %}
<span class="tag">{{ permission.action }} · inherited</span>
{% else %}
<form hx-post="/admin/permissions/grant" hx-target="#permission-status">
<input type="hidden" name="role" value="{{ role.role }}"><input type="hidden" name="object" value="{{ page.permission_object }}"><input type="hidden" name="action" value="{{ permission.action }}">
<form hx-post="/permissions/grants/apply" hx-target="#permission-status">
<input type="hidden" name="role" value="{{ role.role }}"><input type="hidden" name="mode" value="grant"><input type="hidden" name="pair" value="{{ page.permission_object }}|{{ permission.action }}"><input type="hidden" name="return_to" value="/admin/table-definition{{ page.selection.query() }}">
<button type="submit" class="secondary">Grant {{ permission.action }}</button>
</form>
{% endif %}

View File

@@ -0,0 +1,140 @@
{#
GET /permissions/grants — crate::pages::permissions::grants::ui::GrantsTemplate
One matrix per profile: objects down the side, actions across the top. Every
button on the page — a single cell, a whole row, a whole profile, everything —
posts the same form to /permissions/grants/apply and differs only in the list
of object|action pairs it carries. The lists are built in Rust
(grants::state), so the page never asks the server to work out what "all"
meant.
#}
{% extends "ui/base.html" %}
{% block title %}Access{% endblock %}
{% macro apply(role, mode, pairs, label, style, hint) %}
<form hx-post="/permissions/grants/apply" hx-target="#permission-status">
<input type="hidden" name="role" value="{{ role }}">
<input type="hidden" name="mode" value="{{ mode }}">
{% for pair in pairs %}<input type="hidden" name="pair" value="{{ pair }}">{% endfor %}
<button type="submit" class="{{ style }}" title="{{ hint }}">{{ label }}</button>
</form>
{% endmacro %}
{% block content %}
<main>
<section class="heading">
<div>
<p class="eyebrow">Permissions</p>
<h1>Access</h1>
<p>What a role may do with the data. Everyone holding the role gets exactly this.</p>
</div>
</section>
{% include "pages/permissions/tabs.html" %}
{% if page.updated %}<p class="notice">Access updated. It applies to the next request every holder of the role makes.</p>{% endif %}
{% if page.roles.is_empty() %}
<section class="panel">
<h2>No role to edit</h2>
<p class="hint">There is no role here you outrank. <a href="/permissions/roles">Create one</a> first.</p>
</section>
{% else %}
<section class="panel">
<h2>Role</h2>
<form method="get" action="/permissions/grants" class="form-grid">
<label>Editing
<select name="role" onchange="this.form.submit()">
{% for role in page.roles %}
<option value="{{ role.name }}" {% if page.selected(role.name.as_str()) %}selected{% endif %}>{{ role.name }}{% if !role.parent.is_empty() %} → inherits {{ role.parent }}{% endif %}</option>
{% endfor %}
</select>
</label>
</form>
<p class="hint">
{% if !page.selected_parent.is_empty() %}
<strong>{{ page.selected_role }}</strong> inherits everything <strong>{{ page.selected_parent }}</strong> has. Inherited access shows below but is changed on the parent.
{% else %}
<strong>{{ page.selected_role }}</strong> inherits from nothing, so what you see below is all it has.
{% endif %}
{% if page.selected_is_structural %}
This role designs the system and may never write row data, so only the read column is open.
{% endif %}
</p>
<h3 class="panel-subhead">Shortcuts</h3>
<p class="hint">These grant the wildcard objects, so they keep covering profiles and tables created later.</p>
<div class="actions shortcut-row">
{% call apply(page.selected_role, "grant", page.everything_read_pairs(), "Read everything", "secondary", "Read on every profile, journal and exchange rate") %}{% endcall %}
{% call apply(page.selected_role, "grant", page.everything_pairs(), "Full access to everything", "secondary", "Every action the role may hold, on every profile") %}{% endcall %}
{% if page.has_direct() %}
{% call apply(page.selected_role, "revoke", page.direct_pairs(), "Remove all access", "danger", "Revoke every grant this role holds directly") %}{% endcall %}
{% endif %}
</div>
</section>
{% for group in page.groups %}
<section class="panel">
<h2>{% if group.global %}Everything, everywhere{% else %}Profile: {{ group.title }}{% endif %}<span class="count">{{ group.rows.len() }}</span></h2>
<div class="actions shortcut-row">
{% if group.has_wildcards() %}
{% call apply(page.selected_role, "grant", group.read_pairs(), "Read only", "secondary", "Read on everything in here") %}{% endcall %}
{% call apply(page.selected_role, "grant", group.all_pairs(), "Full access", "secondary", "Every action the role may hold, on everything in here") %}{% endcall %}
{% endif %}
{% if group.has_direct() %}
{% call apply(page.selected_role, "revoke", group.direct_pairs(), "Clear", "danger", "Revoke everything this role holds directly in here") %}{% endcall %}
{% endif %}
</div>
<div class="table-scroll">
<table class="builder-table grant-matrix">
<thead>
<tr>
<th>Object</th>
{% for action in page.actions() %}<th>{{ action }}</th>{% endfor %}
<th>Row</th>
</tr>
</thead>
<tbody>
{% for row in group.rows %}
<tr>
<td>
<span class="object-label">{{ row.label }}</span>
{% if !row.note.is_empty() %}<small>{{ row.note }}</small>{% endif %}
<code>{{ row.object }}</code>
</td>
{% for cell in row.cells %}
<td class="grant-cell">
{% if !cell.allowed %}
<span class="cell-closed" title="Not available for this role">·</span>
{% else if cell.direct %}
{% call apply(page.selected_role, "revoke", row.one_pair(cell.action.as_str()), "✓", "cell-on", "Held directly — click to revoke") %}{% endcall %}
{% else if cell.inherited %}
<span class="cell-inherited" title="inherited: covered by the parent role or a wider grant"></span>
{% else %}
{% call apply(page.selected_role, "grant", row.one_pair(cell.action.as_str()), "+", "cell-off", "Click to grant") %}{% endcall %}
{% endif %}
</td>
{% endfor %}
<td class="grant-cell">
<div class="actions">
{% call apply(page.selected_role, "grant", row.all_pairs(), "All", "secondary", "Grant every action available on this object") %}{% endcall %}
{% if row.has_direct() %}
{% call apply(page.selected_role, "revoke", row.direct_pairs(), "None", "danger", "Revoke this object's direct grants") %}{% endcall %}
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endfor %}
{% endif %}
<div id="permission-status" aria-live="polite"></div>
</main>
{% endblock %}

View File

@@ -0,0 +1,84 @@
{# GET /permissions/roles — crate::pages::permissions::roles::ui::RolesTemplate #}
{% extends "ui/base.html" %}
{% block title %}Roles{% endblock %}
{% block content %}
<main>
<section class="heading">
<div>
<p class="eyebrow">Permissions</p>
<h1>Roles</h1>
<p>A role is a named set of permissions. People are given roles; roles are given access.</p>
</div>
</section>
{% include "pages/permissions/tabs.html" %}
{% if page.updated %}<p class="notice">Roles updated. Signed-in sessions stay valid and pick up the change on their next request.</p>{% endif %}
<section class="panel">
<h2>New role</h2>
<p class="hint">
Inheriting from another role starts the new one with everything that role has, and keeps it in step
as that role changes. Starter access is a shortcut for the two common cases; anything narrower is a
few clicks on the Access tab.
</p>
<form hx-post="/permissions/roles/create" hx-target="#permission-status" class="form-grid">
<label>Name<input name="name" required maxlength="50" placeholder="sales"></label>
<label>Inherits from
<select name="parent">
<option value="">Nothing — starts empty</option>
{% for parent in page.parents %}<option value="{{ parent }}">{{ parent }}</option>{% endfor %}
</select>
</label>
<label>Starter access
<select name="access">
<option value="none">None — grant it on the Access tab</option>
<option value="read">Read everything</option>
<option value="full">Read and write everything</option>
</select>
</label>
<div class="form-actions"><button type="submit">Create role</button></div>
</form>
</section>
<section class="panel">
<h2>Existing roles<span class="count">{{ page.roles.len() }}</span></h2>
<div class="table-scroll">
<table class="builder-table">
<thead><tr><th>Role</th><th>Kind</th><th>Inherits from</th><th>People</th><th>Access</th><th>Remove</th></tr></thead>
<tbody>
{% for role in page.roles %}
<tr>
<td>{{ role.name }}{% if role.built_in %} <span class="tag">built in</span>{% endif %}</td>
<td>{{ role.kind }}</td>
<td>{% if role.parent.is_empty() %}<span class="hint"></span>{% else %}{{ role.parent }}{% endif %}</td>
<td>{% match role.users %}{% when Some with (count) %}{{ count }}{% when None %}<span class="hint"></span>{% endmatch %}</td>
<td>
{% if role.editable %}
<a class="link-action" href="/permissions/grants?role={{ role.name }}">Edit access</a>
{% else %}
<span class="hint">outranks you</span>
{% endif %}
</td>
<td>
{% if role.removable() %}
<form hx-post="/permissions/roles/remove" hx-target="#permission-status">
<input type="hidden" name="role" value="{{ role.name }}">
<button type="submit" class="danger">Remove</button>
</form>
{% else %}
<span class="hint">{{ role.keeps_reason() }}</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
<div id="permission-status" aria-live="polite"></div>
</main>
{% endblock %}

View File

@@ -0,0 +1,26 @@
{#
The section switcher shared by the three permission pages. Every one of them
carries a `page.tabs` (crate::pages::permissions::common::state::Tabs), which
says which section is open and which the signed-in role may open at all.
The three are deliberately separate pages: making a role, handing it to
someone, and deciding what it may do are three different decisions, and doing
them on one screen was what made the old page hard to read.
#}
<nav class="tabs" aria-label="permission sections">
{% if page.tabs.can_manage_roles %}
<a href="/permissions/roles" class="tab {% if page.tabs.is("roles") %}selected{% endif %}" {% if page.tabs.is("roles") %}aria-current="page"{% endif %}>
<span>Roles</span><small>Create roles and their inheritance</small>
</a>
{% endif %}
{% if page.tabs.can_manage_users %}
<a href="/permissions/users" class="tab {% if page.tabs.is("users") %}selected{% endif %}" {% if page.tabs.is("users") %}aria-current="page"{% endif %}>
<span>People</span><small>Give a user a role</small>
</a>
{% endif %}
{% if page.tabs.can_manage_roles %}
<a href="/permissions/grants" class="tab {% if page.tabs.is("grants") %}selected{% endif %}" {% if page.tabs.is("grants") %}aria-current="page"{% endif %}>
<span>Access</span><small>What a role may do</small>
</a>
{% endif %}
</nav>

View File

@@ -0,0 +1,64 @@
{# GET /permissions/users — crate::pages::permissions::users::ui::UsersTemplate #}
{% extends "ui/base.html" %}
{% block title %}People{% endblock %}
{% block content %}
<main>
<section class="heading">
<div>
<p class="eyebrow">Permissions</p>
<h1>People</h1>
<p>Give a user a role. What that role may do is decided once, on the Access tab.</p>
</div>
</section>
{% include "pages/permissions/tabs.html" %}
{% if page.updated %}<p class="notice">Updated. The person's next request uses the new role; they do not have to sign in again.</p>{% endif %}
<section class="panel">
<h2>Users<span class="count">{{ page.users.len() }}</span></h2>
<p class="hint">
You can only change someone whose role you outrank — {{ page.editable_users() }} of {{ page.users.len() }} here.
</p>
<div class="table-scroll">
<table class="builder-table">
<thead><tr><th>User</th><th>Email</th><th>Role</th><th>Change role</th><th>Reset password</th></tr></thead>
<tbody>
{% for user in page.users %}
<tr>
<td>{{ user.username }}</td>
<td>{% if user.email.is_empty() %}<span class="hint"></span>{% else %}{{ user.email }}{% endif %}</td>
<td>{{ user.role }}</td>
{% if user.editable %}
<td>
<form hx-post="/permissions/users/role" hx-target="#permission-status" class="actions">
<input type="hidden" name="username" value="{{ user.username }}">
<select name="role">
{% for role in page.roles %}<option value="{{ role }}" {% if user.role == role.as_str() %}selected{% endif %}>{{ role }}</option>{% endfor %}
</select>
<button type="submit">Assign</button>
</form>
</td>
<td>
<form hx-post="/permissions/users/password" hx-target="#permission-status" class="actions">
<input type="hidden" name="username" value="{{ user.username }}">
<input name="new_password" type="password" autocomplete="new-password" placeholder="New password" required>
<input name="new_password_confirmation" type="password" autocomplete="new-password" placeholder="Confirm" required>
<button type="submit">Reset</button>
</form>
</td>
{% else %}
<td colspan="2"><span class="hint">outranks you</span></td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
<div id="permission-status" aria-live="polite"></div>
</main>
{% endblock %}

View File

@@ -23,6 +23,7 @@
<!-- Desktop Menu -->
<ul class="hidden items-center gap-4 md:flex">
{% if nav.can_admin %}<li><a href="/admin" class="{% if nav.active == "admin" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark dark:hover:text-primary-dark{% endif %} underline-offset-2 hover:text-primary focus:outline-hidden focus:underline" {% if nav.active == "admin" %}aria-current="page"{% endif %}>Admin</a></li>{% endif %}
{% if nav.can_permissions %}<li><a href="/permissions" class="{% if nav.active == "permissions" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark dark:hover:text-primary-dark{% endif %} underline-offset-2 hover:text-primary focus:outline-hidden focus:underline" {% if nav.active == "permissions" %}aria-current="page"{% endif %}>Permissions</a></li>{% endif %}
<li><a href="/" class="{% if nav.active == "analytics" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark dark:hover:text-primary-dark{% endif %} underline-offset-2 hover:text-primary focus:outline-hidden focus:underline" {% if nav.active == "analytics" %}aria-current="page"{% endif %}>Analytics</a></li>
{% if nav.can_import %}<li><a href="/admin/import" class="font-medium text-on-surface underline-offset-2 hover:text-primary focus:outline-hidden focus:underline dark:text-on-surface-dark dark:hover:text-primary-dark">Import</a></li>{% endif %}
{% if nav.can_export %}<li><a href="/admin/export" class="font-medium text-on-surface underline-offset-2 hover:text-primary focus:outline-hidden focus:underline dark:text-on-surface-dark dark:hover:text-primary-dark">Export</a></li>{% endif %}
@@ -46,6 +47,7 @@
<!-- Mobile Menu -->
<ul x-cloak x-show="mobileMenuIsOpen" x-transition:enter="transition motion-reduce:transition-none ease-out duration-300" x-transition:enter-start="-translate-y-full" x-transition:enter-end="translate-y-0" x-transition:leave="transition motion-reduce:transition-none ease-out duration-300" x-transition:leave-start="translate-y-0" x-transition:leave-end="-translate-y-full" id="mobileMenu" class="fixed max-h-svh overflow-y-auto inset-x-0 top-0 z-10 flex flex-col divide-y divide-outline rounded-b-radius border-b border-outline bg-surface-alt px-6 pb-6 pt-20 dark:divide-outline-dark dark:border-outline-dark dark:bg-surface-dark-alt md:hidden">
{% if nav.can_admin %}<li class="py-4"><a href="/admin" class="w-full text-lg {% if nav.active == "admin" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark{% endif %} focus:underline" {% if nav.active == "admin" %}aria-current="page"{% endif %}>Admin</a></li>{% endif %}
{% if nav.can_permissions %}<li class="py-4"><a href="/permissions" class="w-full text-lg {% if nav.active == "permissions" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark{% endif %} focus:underline" {% if nav.active == "permissions" %}aria-current="page"{% endif %}>Permissions</a></li>{% endif %}
<li class="py-4"><a href="/" class="w-full text-lg {% if nav.active == "analytics" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark{% endif %} focus:underline" {% if nav.active == "analytics" %}aria-current="page"{% endif %}>Analytics</a></li>
{% if nav.can_import %}<li class="py-4"><a href="/admin/import" class="w-full text-lg font-medium text-on-surface focus:underline dark:text-on-surface-dark">Import</a></li>{% endif %}
{% if nav.can_export %}<li class="py-4"><a href="/admin/export" class="w-full text-lg font-medium text-on-surface focus:underline dark:text-on-surface-dark">Export</a></li>{% endif %}