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 ### Added
- **Role, user and permission administration** — `/admin/permissions` consumes - **Role, user and permission administration** — `/permissions` consumes
`ListRoles`, `AddRole`, `RemoveRole`, `ListUsers`, `AssignUserRole`, `ListRoles`, `AddRole`, `RemoveRole`, `ListUsers`, `AssignUserRole`,
`ListRolePermissions`, `ListGrantableObjects`, `GrantPermission` and `ListRolePermissions`, `ListGrantableObjects`, `GrantPermission` and
`RevokePermission`. Direct and inherited grants are distinguished, and the `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 ### 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 - **Web authorization follows permission objects, not role names** — structural
pages check their `struct:<area>/manage` permission from `GetAuthorization`, pages check their `struct:<area>/manage` permission from `GetAuthorization`,
so inherited `superadmin` authorization works and data roles are no longer 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 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 The access token is kept in an HTTP-only cookie. The default gRPC endpoint is
`http://[::1]:50051`. Both addresses can be changed: `http://[::1]:50051`. Both addresses can be changed:
@@ -70,6 +72,15 @@ src/ templates/
… export.html … export.html
login/ login/ login/ login/
mod logic state ui login.html 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 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/validation/sets/new` | `pages/add_validation/` | `set.html` |
| `GET /admin/import` | `pages/import_export/import/` | `import.html` | | `GET /admin/import` | `pages/import_export/import/` | `import.html` |
| `GET /admin/export` | `pages/import_export/export/` | `export.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 Every form `POST` answers with `ui/alert_fragment.html`, swapped into the page's
`#submission-status`. Analytics errors use the lighter `ui/notice.html`. `#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 are read locally and submitted to Axum; the backend is accessed only through the
existing `TablesData` gRPC service. 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 ## Analytics
The first SQL result column is used for category labels. Bar and line charts use 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)) .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 { pub(crate) fn table_object(profile: &str, table: &str) -> String {
format!("data:{profile}/{table}") format!("data:{profile}/{table}")
} }
@@ -90,6 +105,15 @@ mod tests {
assert!(!can_manage(&authorization, STRUCT_ROLE)); 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] #[test]
fn data_wildcards_match_the_server_object_shapes() { fn data_wildcards_match_the_server_object_shapes() {
let global = snapshot(&[("data:*", "read")]); let global = snapshot(&[("data:*", "read")]);

View File

@@ -139,7 +139,7 @@ fn router(state: AppState) -> Router {
.merge(pages::login::router()) .merge(pages::login::router())
.merge(pages::register::router()) .merge(pages::register::router())
.merge(pages::admin::admin::router()) .merge(pages::admin::admin::router())
.merge(pages::admin::permissions::router()) .merge(pages::permissions::router())
.merge(pages::admin::table_definition::router()) .merge(pages::admin::table_definition::router())
.merge(pages::add_table::router()) .merge(pages::add_table::router())
.merge(pages::add_logic::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] #[tokio::test]
async fn stylesheet_is_served_once_for_every_page() { async fn stylesheet_is_served_once_for_every_page() {
let (status, body) = get("/static/app.css").await; let (status, body) = get("/static/app.css").await;

View File

@@ -6,7 +6,7 @@
use axum::{ use axum::{
extract::{Query, State}, extract::{Query, State},
http::{HeaderMap, HeaderValue, StatusCode, header}, http::{HeaderMap, HeaderValue, StatusCode},
response::{Html, IntoResponse, Redirect, Response}, response::{Html, IntoResponse, Redirect, Response},
}; };
use axum_extra::extract::Form; use axum_extra::extract::Form;
@@ -107,10 +107,10 @@ pub(crate) async fn create_table(
let Ok(location) = HeaderValue::try_from(location) else { let Ok(location) = HeaderValue::try_from(location) else {
return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid redirect").into_response(); return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid redirect").into_response();
}; };
let mut response = StatusCode::SEE_OTHER.into_response(); // `hx-redirect` alone, with no body and no 3xx: the form is posted
response // over XHR, and the browser would follow a `Location` itself,
.headers_mut() // leaving htmx to swap the whole redirected page into `#builder`.
.insert(header::LOCATION, location.clone()); let mut response = Html(String::new()).into_response();
response.headers_mut().insert("hx-redirect", location); response.headers_mut().insert("hx-redirect", location);
response 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_tables: crate::authz::can_manage(&authorization, crate::authz::STRUCT_TABLE),
can_manage_scripts: crate::authz::can_manage(&authorization, crate::authz::STRUCT_SCRIPT), 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_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| { can_export: authorization.permissions.iter().any(|permission| {
permission.action == "read" && permission.object.starts_with("data:") 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_tables: bool,
pub can_manage_scripts: bool, pub can_manage_scripts: bool,
pub can_manage_validations: bool, pub can_manage_validations: bool,
pub can_manage_permissions: bool,
pub can_export: bool, pub can_export: bool,
} }

View File

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

View File

@@ -1,3 +1,2 @@
pub(crate) mod admin; pub(crate) mod admin;
pub(crate) mod permissions;
pub(crate) mod table_definition; 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 analytics;
pub(crate) mod import_export; pub(crate) mod import_export;
pub(crate) mod login; pub(crate) mod login;
pub(crate) mod permissions;
pub(crate) mod register; 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 authenticated: bool,
pub role: String, pub role: String,
pub can_admin: bool, pub can_admin: bool,
pub can_permissions: bool,
pub can_import: bool, pub can_import: bool,
pub can_export: bool, pub can_export: bool,
pub active: &'static str, pub active: &'static str,
} }
impl Nav { impl Nav {
/// `active` is the nav link to highlight: `"admin"`, `"analytics"`, /// `active` is the nav link to highlight: `"admin"`, `"permissions"`,
/// `"login"`, or `""` for pages that are not themselves nav entries. /// `"analytics"`, `"login"`, or `""` for pages that are not themselves nav
/// entries.
pub(crate) fn new(headers: &HeaderMap, active: &'static str) -> Self { pub(crate) fn new(headers: &HeaderMap, active: &'static str) -> Self {
Self { Self {
authenticated: crate::cookie_value(headers, SESSION_COOKIE).is_some(), authenticated: crate::cookie_value(headers, SESSION_COOKIE).is_some(),
role: String::new(), role: String::new(),
can_admin: false, can_admin: false,
can_permissions: false,
can_import: false, can_import: false,
can_export: false, can_export: false,
active, active,
@@ -41,6 +44,10 @@ impl Nav {
) -> Self { ) -> Self {
self.role = authorization.role.clone(); self.role = authorization.role.clone();
self.can_admin = crate::authz::can_open_admin(authorization); 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| { self.can_import = authorization.permissions.iter().any(|permission| {
permission.action == "insert" && permission.object.starts_with("data:") permission.action == "insert" && permission.object.starts_with("data:")
}); });
@@ -57,6 +64,7 @@ impl Default for Nav {
authenticated: false, authenticated: false,
role: String::new(), role: String::new(),
can_admin: false, can_admin: false,
can_permissions: false,
can_import: false, can_import: false,
can_export: false, can_export: false,
active: "", active: "",

View File

@@ -178,6 +178,45 @@
.danger-panel h2 { color: #a12b2b; } .danger-panel h2 { color: #a12b2b; }
.form-actions button.danger-submit { background: #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 ---------- */ /* ---------- Narrow screens ---------- */
@media (max-width: 850px) { @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_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/new">Add validation</a>{% endif %}
{% if page.can_manage_validations %}<a href="/admin/validation/sets/new">Add rule</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 %} {% if page.can_export %}<a href="/admin/export">Export</a>{% endif %}
</div> </div>
</section> </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() %} {% if !page.permission_object.is_empty() %}
<section class="panel"> <section class="panel">
<h2>Data permissions for <code>{{ page.selection.table }}</code></h2> <h2>Data permissions for <code>{{ page.selection.table }}</code></h2>
<p class="hint">These grants cover this table family 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"> <table class="builder-table">
<thead><tr><th>Role</th><th>Actions</th></tr></thead> <thead><tr><th>Role</th><th>Actions</th></tr></thead>
<tbody>{% for role in page.role_permissions %}<tr> <tbody>{% for role in page.role_permissions %}<tr>
<td>{{ role.role }}</td> <td>{{ role.role }}</td>
<td><div class="actions">{% for permission in role.actions %} <td><div class="actions">{% for permission in role.actions %}
{% if permission.direct %} {% if permission.direct %}
<form hx-post="/admin/permissions/revoke" hx-target="#permission-status"> <form hx-post="/permissions/grants/apply" 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 }}"> <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> <button type="submit" class="danger">{{ permission.action }} ✓</button>
</form> </form>
{% else if permission.effective %} {% else if permission.effective %}
<span class="tag">{{ permission.action }} · inherited</span> <span class="tag">{{ permission.action }} · inherited</span>
{% else %} {% else %}
<form hx-post="/admin/permissions/grant" hx-target="#permission-status"> <form hx-post="/permissions/grants/apply" 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 }}"> <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> <button type="submit" class="secondary">Grant {{ permission.action }}</button>
</form> </form>
{% endif %} {% 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 --> <!-- Desktop Menu -->
<ul class="hidden items-center gap-4 md:flex"> <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_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> <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_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 %} {% 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 --> <!-- 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"> <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_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> <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_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 %} {% 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 %}