diff --git a/client b/client index 914d48cf..1e3c9de4 160000 --- a/client +++ b/client @@ -1 +1 @@ -Subproject commit 914d48cf39f06b612b596bd67816b4ed306faa23 +Subproject commit 1e3c9de431a28de635ef4caac5c7ea69835b0822 diff --git a/web/CHANGELOG.md b/web/CHANGELOG.md index 6224d989..b0c6cdcc 100644 --- a/web/CHANGELOG.md +++ b/web/CHANGELOG.md @@ -16,7 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added -- **Role, user and permission administration** — `/admin/permissions` consumes +- **Role, user and permission administration** — `/permissions` consumes `ListRoles`, `AddRole`, `RemoveRole`, `ListUsers`, `AssignUserRole`, `ListRolePermissions`, `ListGrantableObjects`, `GrantPermission` and `RevokePermission`. Direct and inherited grants are distinguished, and the @@ -37,6 +37,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed +- **Permissions moved out of the admin panel into their own nav section, split + in three** — `/admin/permissions` is gone; the pages are `/permissions/roles` + (which roles exist), `/permissions/users` (who holds them) and + `/permissions/grants` (what a role may do). No new gRPC endpoint is consumed: + the same `GrantPermission` and `RevokePermission` calls now come from one + `POST /permissions/grants/apply`, which issues one call per `object|action` + pair so a shortcut — a whole row, a whole profile, everything — is a longer + list rather than a different request. Shortcuts grant the wildcard objects + `ListGrantableObjects` already returns (`data:*`, `data:/*`, + `journal:*`, `ecb:*`), so they keep covering profiles and tables created + later, and `AddRole` may be followed by a starter set of those grants in the + same submission. Revoking tolerates the `NOT_FOUND` a pair the role does not + hold directly answers with, which is what makes "remove all of this" + idempotent. - **Web authorization follows permission objects, not role names** — structural pages check their `struct:/manage` permission from `GetAuthorization`, so inherited `superadmin` authorization works and data roles are no longer diff --git a/web/README.md b/web/README.md index 96fb64b0..13360bee 100644 --- a/web/README.md +++ b/web/README.md @@ -18,7 +18,9 @@ cargo run -p server -- server ``` Open to log in. The admin panel is at - and analytics remains at . +, roles and permissions are at +, and analytics remains at +. The access token is kept in an HTTP-only cookie. The default gRPC endpoint is `http://[::1]:50051`. Both addresses can be changed: @@ -70,6 +72,15 @@ src/ templates/ … export.html login/ login/ mod logic state ui login.html + permissions/ permissions/ + common/ tabs.html the section switcher + loader logic state ui + roles/ roles/ + mod loader logic state ui roles.html + users/ users/ + mod loader logic state ui users.html + grants/ grants/ + mod loader logic state ui grants.html static/app.css the only stylesheet, at /static/app.css ``` @@ -101,6 +112,10 @@ endpoint in a comment on line 1. | `GET /admin/validation/sets/new` | `pages/add_validation/` | `set.html` | | `GET /admin/import` | `pages/import_export/import/` | `import.html` | | `GET /admin/export` | `pages/import_export/export/` | `export.html` | +| `GET /permissions` | `pages/permissions/` | redirect to the first open section | +| `GET /permissions/roles` | `pages/permissions/roles/` | `roles.html` | +| `GET /permissions/users` | `pages/permissions/users/` | `users.html` | +| `GET /permissions/grants` | `pages/permissions/grants/` | `grants.html` | Every form `POST` answers with `ui/alert_fragment.html`, swapped into the page's `#submission-status`. Analytics errors use the lighter `ui/notice.html`. @@ -127,6 +142,36 @@ files include a table-name header row before the column-name row. Browser files are read locally and submitted to Axum; the backend is accessed only through the existing `TablesData` gRPC service. +## Permissions + +Permissions are a nav section of their own, not a page inside the admin panel: +managing people is a different job from designing tables, and the two are held +by different accounts. The section is three pages, one per decision, with a tab +bar between them: + +- **`/permissions/roles`** — which roles exist, what they inherit from, how many + people hold each, and removing the ones nobody holds. Creating a role can hand + it starter access (`Read everything` or `Read and write everything`) in the + same submission. +- **`/permissions/users`** — who holds which role, and password resets. Only + users whose role the caller outranks carry controls. +- **`/permissions/grants`** — what a role may do, as one matrix per profile: + objects down the side, `read / insert / update / delete` across the top. A + cell is a grant held directly (click to revoke), an inherited one (changed on + the parent role), or an empty one (click to grant). + +Every button on the grants page — one cell, one row's `All`, a profile's +`Full access`, the page's `Full access to everything` — posts the same form to +`POST /permissions/grants/apply`, and differs only in the list of +`object|action` pairs it carries. The lists are built in Rust, so the server is +never asked to work out what "everything" meant, and the shortcuts grant the +wildcard objects (`data:*`, `data:/*`, `journal:*`, `ecb:*`), which +keeps them covering profiles and tables created later. `ecb:*` is read-only +everywhere, because the server writes it. + +The table-definition workspace still edits one table's grants in place; it posts +to the same endpoint and returns to itself. + ## Analytics The first SQL result column is used for category labels. Bar and line charts use diff --git a/web/src/authz.rs b/web/src/authz.rs index c09f7b39..6ce7e132 100644 --- a/web/src/authz.rs +++ b/web/src/authz.rs @@ -39,6 +39,21 @@ pub(crate) fn can_open_admin(snapshot: &AuthorizationSnapshot) -> bool { .any(|area| can_manage(snapshot, area)) } +/// The server's authority ranking (`server/src/auth/rbac/roles.rs`), mirrored +/// so the permission pages only offer what the server will accept: superadmin +/// outranks admin, admin outranks every data role, and no role outranks itself. +pub(crate) fn outranks(actor: &str, target: &str) -> bool { + rank(actor) > rank(target) +} + +fn rank(role: &str) -> u8 { + match role { + "superadmin" => 3, + "admin" => 2, + _ => 1, + } +} + pub(crate) fn table_object(profile: &str, table: &str) -> String { format!("data:{profile}/{table}") } @@ -90,6 +105,15 @@ mod tests { assert!(!can_manage(&authorization, STRUCT_ROLE)); } + #[test] + fn nobody_administers_a_peer_or_a_superior() { + assert!(outranks("superadmin", "admin")); + assert!(outranks("admin", "sales")); + assert!(!outranks("admin", "admin")); + assert!(!outranks("admin", "superadmin")); + assert!(!outranks("sales", "clerk")); + } + #[test] fn data_wildcards_match_the_server_object_shapes() { let global = snapshot(&[("data:*", "read")]); diff --git a/web/src/lib.rs b/web/src/lib.rs index d6554185..8af98615 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -139,7 +139,7 @@ fn router(state: AppState) -> Router { .merge(pages::login::router()) .merge(pages::register::router()) .merge(pages::admin::admin::router()) - .merge(pages::admin::permissions::router()) + .merge(pages::permissions::router()) .merge(pages::admin::table_definition::router()) .merge(pages::add_table::router()) .merge(pages::add_logic::router()) @@ -317,6 +317,60 @@ mod tests { } } + /// Permissions is a nav section of its own, so its three pages are mounted + /// at the top level rather than under /admin — and each of them, like every + /// other page behind a session, sends an anonymous visitor to the login + /// page instead of calling the backend. + #[tokio::test] + async fn the_permission_sections_are_mounted_and_need_a_session() { + for path in [ + "/permissions", + "/permissions/roles", + "/permissions/users", + "/permissions/grants", + ] { + let (status, _) = get(path).await; + assert_eq!( + status, + axum::http::StatusCode::SEE_OTHER, + "{path} did not send an anonymous visitor to the login page" + ); + } + + // The forms answer a lost session the way every form does: with the + // failure rendered into the page, not a 404 from an unmounted route. + for (path, body) in [ + ("/permissions/roles/create", "name=sales&access=none"), + ("/permissions/roles/remove", "role=sales"), + ("/permissions/users/role", "username=alice&role=sales"), + ( + "/permissions/users/password", + "username=alice&new_password=a&new_password_confirmation=a", + ), + ( + "/permissions/grants/apply", + "role=sales&mode=grant&pair=data%3A*%7Cread", + ), + ] { + let response = test_router() + .oneshot( + Request::builder() + .method("POST") + .uri(path) + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + response.status(), + axum::http::StatusCode::UNPROCESSABLE_ENTITY, + "{path} is not mounted" + ); + } + } + #[tokio::test] async fn stylesheet_is_served_once_for_every_page() { let (status, body) = get("/static/app.css").await; diff --git a/web/src/pages/add_table/logic.rs b/web/src/pages/add_table/logic.rs index 19c64a8b..3a08caa3 100644 --- a/web/src/pages/add_table/logic.rs +++ b/web/src/pages/add_table/logic.rs @@ -6,7 +6,7 @@ use axum::{ extract::{Query, State}, - http::{HeaderMap, HeaderValue, StatusCode, header}, + http::{HeaderMap, HeaderValue, StatusCode}, response::{Html, IntoResponse, Redirect, Response}, }; use axum_extra::extract::Form; @@ -107,10 +107,10 @@ pub(crate) async fn create_table( let Ok(location) = HeaderValue::try_from(location) else { return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid redirect").into_response(); }; - let mut response = StatusCode::SEE_OTHER.into_response(); - response - .headers_mut() - .insert(header::LOCATION, location.clone()); + // `hx-redirect` alone, with no body and no 3xx: the form is posted + // over XHR, and the browser would follow a `Location` itself, + // leaving htmx to swap the whole redirected page into `#builder`. + let mut response = Html(String::new()).into_response(); response.headers_mut().insert("hx-redirect", location); response } diff --git a/web/src/pages/admin/admin/loader.rs b/web/src/pages/admin/admin/loader.rs index 2663b681..de47f8dd 100644 --- a/web/src/pages/admin/admin/loader.rs +++ b/web/src/pages/admin/admin/loader.rs @@ -138,8 +138,6 @@ pub(crate) async fn load_admin_page( can_manage_tables: crate::authz::can_manage(&authorization, crate::authz::STRUCT_TABLE), can_manage_scripts: crate::authz::can_manage(&authorization, crate::authz::STRUCT_SCRIPT), can_manage_validations: crate::authz::can_manage(&authorization, crate::authz::STRUCT_VALIDATION), - can_manage_permissions: crate::authz::can_manage(&authorization, crate::authz::STRUCT_ROLE) - || crate::authz::can_manage(&authorization, crate::authz::STRUCT_USER), can_export: authorization.permissions.iter().any(|permission| { permission.action == "read" && permission.object.starts_with("data:") }), diff --git a/web/src/pages/admin/admin/state.rs b/web/src/pages/admin/admin/state.rs index 9e39ab42..7f4b2596 100644 --- a/web/src/pages/admin/admin/state.rs +++ b/web/src/pages/admin/admin/state.rs @@ -17,7 +17,6 @@ pub(crate) struct AdminPageState { pub can_manage_tables: bool, pub can_manage_scripts: bool, pub can_manage_validations: bool, - pub can_manage_permissions: bool, pub can_export: bool, } diff --git a/web/src/pages/admin/admin/ui.rs b/web/src/pages/admin/admin/ui.rs index 177e167e..24b81874 100644 --- a/web/src/pages/admin/admin/ui.rs +++ b/web/src/pages/admin/admin/ui.rs @@ -50,6 +50,7 @@ mod tests { authenticated: true, role: "admin".to_string(), can_admin: true, + can_permissions: true, can_import: false, can_export: false, active: "admin", @@ -64,7 +65,6 @@ mod tests { can_manage_tables: true, can_manage_scripts: true, can_manage_validations: true, - can_manage_permissions: true, can_export: true, }; let html = render_page(&page); @@ -75,7 +75,6 @@ mod tests { "/admin/validation/new", "/admin/validation/sets/new", "/admin/export", - "/admin/permissions", "/logout", ] { assert!(html.contains(route), "missing admin action route {route}"); diff --git a/web/src/pages/admin/mod.rs b/web/src/pages/admin/mod.rs index e00362e6..e0097f09 100644 --- a/web/src/pages/admin/mod.rs +++ b/web/src/pages/admin/mod.rs @@ -1,3 +1,2 @@ pub(crate) mod admin; -pub(crate) mod permissions; pub(crate) mod table_definition; diff --git a/web/src/pages/admin/permissions/loader.rs b/web/src/pages/admin/permissions/loader.rs deleted file mode 100644 index fee86bc2..00000000 --- a/web/src/pages/admin/permissions/loader.rs +++ /dev/null @@ -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 { - 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::>(); - 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()), - } -} diff --git a/web/src/pages/admin/permissions/logic.rs b/web/src/pages/admin/permissions/logic.rs deleted file mode 100644 index 52f48262..00000000 --- a/web/src/pages/admin/permissions/logic.rs +++ /dev/null @@ -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, - headers: HeaderMap, - Query(selection): Query, -) -> 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, - headers: HeaderMap, - Form(form): Form, -) -> 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, - headers: HeaderMap, - Form(form): Form, -) -> 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, - headers: HeaderMap, - Form(form): Form, -) -> 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, - headers: HeaderMap, - Form(form): Form, -) -> 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, - headers: HeaderMap, - Form(form): Form, -) -> 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, - headers: HeaderMap, - Form(form): Form, -) -> 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(headers: &HeaderMap, destination: &str, operation: F) -> Response -where - F: std::future::Future>, -{ - 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(), - } -} diff --git a/web/src/pages/admin/permissions/mod.rs b/web/src/pages/admin/permissions/mod.rs deleted file mode 100644 index 7202447e..00000000 --- a/web/src/pages/admin/permissions/mod.rs +++ /dev/null @@ -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 { - 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)) -} diff --git a/web/src/pages/admin/permissions/state.rs b/web/src/pages/admin/permissions/state.rs deleted file mode 100644 index 5364f902..00000000 --- a/web/src/pages/admin/permissions/state.rs +++ /dev/null @@ -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, - pub users: Vec, - pub selected_role: String, - pub direct_permissions: Vec, - pub effective_permissions: Vec, - pub grantable_objects: Vec, - 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), -} diff --git a/web/src/pages/admin/permissions/ui.rs b/web/src/pages/admin/permissions/ui.rs deleted file mode 100644 index 7d2de5e5..00000000 --- a/web/src/pages/admin/permissions/ui.rs +++ /dev/null @@ -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")); - } -} diff --git a/web/src/pages/mod.rs b/web/src/pages/mod.rs index 7c2dacd4..5e1015b4 100644 --- a/web/src/pages/mod.rs +++ b/web/src/pages/mod.rs @@ -5,4 +5,5 @@ pub(crate) mod admin; pub(crate) mod analytics; pub(crate) mod import_export; pub(crate) mod login; +pub(crate) mod permissions; pub(crate) mod register; diff --git a/web/src/pages/permissions/common/loader.rs b/web/src/pages/permissions/common/loader.rs new file mode 100644 index 00000000..6e398b7f --- /dev/null +++ b/web/src/pages/permissions/common/loader.rs @@ -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, + headers: &HeaderMap, +) -> Result { + 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, + headers: &HeaderMap, +) -> Result, 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, + headers: &HeaderMap, +) -> Result, 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 { + 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::>() + }; + assert_eq!(names("superadmin"), vec!["admin", "sales"]); + assert_eq!(names("admin"), vec!["sales"]); + } +} diff --git a/web/src/pages/permissions/common/logic.rs b/web/src/pages/permissions/common/logic.rs new file mode 100644 index 00000000..0ad48d7f --- /dev/null +++ b/web/src/pages/permissions/common/logic.rs @@ -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(headers: &HeaderMap, destination: &str, operation: F) -> Response +where + F: std::future::Future>, +{ + 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()); + } +} diff --git a/web/src/pages/permissions/common/mod.rs b/web/src/pages/permissions/common/mod.rs new file mode 100644 index 00000000..8ea48817 --- /dev/null +++ b/web/src/pages/permissions/common/mod.rs @@ -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; diff --git a/web/src/pages/permissions/common/state.rs b/web/src/pages/permissions/common/state.rs new file mode 100644 index 00000000..e9dd47aa --- /dev/null +++ b/web/src/pages/permissions/common/state.rs @@ -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, 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"); + } +} diff --git a/web/src/pages/permissions/common/ui.rs b/web/src/pages/permissions/common/ui.rs new file mode 100644 index 00000000..b712191c --- /dev/null +++ b/web/src/pages/permissions/common/ui.rs @@ -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)) +} diff --git a/web/src/pages/permissions/grants/loader.rs b/web/src/pages/permissions/grants/loader.rs new file mode 100644 index 00000000..d1f10860 --- /dev/null +++ b/web/src/pages/permissions/grants/loader.rs @@ -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 { + 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 { + let mut groups: Vec = 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); + } +} diff --git a/web/src/pages/permissions/grants/logic.rs b/web/src/pages/permissions/grants/logic.rs new file mode 100644 index 00000000..bdd040b0 --- /dev/null +++ b/web/src/pages/permissions/grants/logic.rs @@ -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, + headers: HeaderMap, + Query(selection): Query, +) -> 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, + headers: HeaderMap, + Form(form): Form, +) -> 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 +} diff --git a/web/src/pages/permissions/grants/mod.rs b/web/src/pages/permissions/grants/mod.rs new file mode 100644 index 00000000..1ab809c7 --- /dev/null +++ b/web/src/pages/permissions/grants/mod.rs @@ -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 { + Router::new() + .route("/permissions/grants", get(logic::page)) + .route("/permissions/grants/apply", post(logic::apply)) +} diff --git a/web/src/pages/permissions/grants/state.rs b/web/src/pages/permissions/grants/state.rs new file mode 100644 index 00000000..801d76e1 --- /dev/null +++ b/web/src/pages/permissions/grants/state.rs @@ -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, + #[serde(default)] + pub return_to: String, +} + +pub(crate) struct GrantsPage { + pub nav: Nav, + pub tabs: Tabs, + pub roles: Vec, + 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, + 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, +} + +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, +} + +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 { + 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 { + 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 { + 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 { + self.wildcards() + .flat_map(|row| row.all_pairs()) + .collect() + } + + /// Read on this group's wildcard objects. + pub(crate) fn read_pairs(&self) -> Vec { + 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 { + 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 { + self.rows.iter().filter(|row| row.wildcard) + } +} + +impl ObjectRow { + pub(crate) fn all_pairs(&self) -> Vec { + self.cells + .iter() + .filter(|cell| cell.allowed) + .map(|cell| pair(&self.object, &cell.action)) + .collect() + } + + pub(crate) fn direct_pairs(&self) -> Vec { + 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 { + vec![pair(&self.object, action)] + } + + fn pair_for(&self, action: &str) -> Option { + 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 { + 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"] + ); + } +} diff --git a/web/src/pages/permissions/grants/ui.rs b/web/src/pages/permissions/grants/ui.rs new file mode 100644 index 00000000..223d528b --- /dev/null +++ b/web/src/pages/permissions/grants/ui.rs @@ -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 { + 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")); + } +} diff --git a/web/src/pages/permissions/mod.rs b/web/src/pages/permissions/mod.rs new file mode 100644 index 00000000..40200047 --- /dev/null +++ b/web/src/pages/permissions/mod.rs @@ -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 { + 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, 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), + } +} diff --git a/web/src/pages/permissions/roles/loader.rs b/web/src/pages/permissions/roles/loader.rs new file mode 100644 index 00000000..713c3eeb --- /dev/null +++ b/web/src/pages/permissions/roles/loader.rs @@ -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 { + 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, + }) +} diff --git a/web/src/pages/permissions/roles/logic.rs b/web/src/pages/permissions/roles/logic.rs new file mode 100644 index 00000000..ce81bc97 --- /dev/null +++ b/web/src/pages/permissions/roles/logic.rs @@ -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, + headers: HeaderMap, + Query(selection): Query, +) -> 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, + headers: HeaderMap, + Form(form): Form, +) -> 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, + headers: HeaderMap, + Form(form): Form, +) -> 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 +} diff --git a/web/src/pages/permissions/roles/mod.rs b/web/src/pages/permissions/roles/mod.rs new file mode 100644 index 00000000..74121a98 --- /dev/null +++ b/web/src/pages/permissions/roles/mod.rs @@ -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 { + Router::new() + .route("/permissions/roles", get(logic::page)) + .route("/permissions/roles/create", post(logic::create)) + .route("/permissions/roles/remove", post(logic::remove)) +} diff --git a/web/src/pages/permissions/roles/state.rs b/web/src/pages/permissions/roles/state.rs new file mode 100644 index 00000000..cb16b884 --- /dev/null +++ b/web/src/pages/permissions/roles/state.rs @@ -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, + /// Roles a new role may inherit from. + pub parents: Vec, + 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, + /// 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, 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"); + } +} diff --git a/web/src/pages/permissions/roles/ui.rs b/web/src/pages/permissions/roles/ui.rs new file mode 100644 index 00000000..3c32ec02 --- /dev/null +++ b/web/src/pages/permissions/roles/ui.rs @@ -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")); + } +} diff --git a/web/src/pages/permissions/users/loader.rs b/web/src/pages/permissions/users/loader.rs new file mode 100644 index 00000000..2c61940d --- /dev/null +++ b/web/src/pages/permissions/users/loader.rs @@ -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 { + 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, + }) +} diff --git a/web/src/pages/permissions/users/logic.rs b/web/src/pages/permissions/users/logic.rs new file mode 100644 index 00000000..2d07e4be --- /dev/null +++ b/web/src/pages/permissions/users/logic.rs @@ -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, + headers: HeaderMap, + Query(selection): Query, +) -> 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, + headers: HeaderMap, + Form(form): Form, +) -> 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, + headers: HeaderMap, + Form(form): Form, +) -> 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 +} diff --git a/web/src/pages/permissions/users/mod.rs b/web/src/pages/permissions/users/mod.rs new file mode 100644 index 00000000..ec332c7a --- /dev/null +++ b/web/src/pages/permissions/users/mod.rs @@ -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 { + Router::new() + .route("/permissions/users", get(logic::page)) + .route("/permissions/users/role", post(logic::assign_role)) + .route("/permissions/users/password", post(logic::reset_password)) +} diff --git a/web/src/pages/permissions/users/state.rs b/web/src/pages/permissions/users/state.rs new file mode 100644 index 00000000..622c7d4e --- /dev/null +++ b/web/src/pages/permissions/users/state.rs @@ -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, + /// Roles the caller may hand out. + pub roles: Vec, + 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() + } +} diff --git a/web/src/pages/permissions/users/ui.rs b/web/src/pages/permissions/users/ui.rs new file mode 100644 index 00000000..7f531265 --- /dev/null +++ b/web/src/pages/permissions/users/ui.rs @@ -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")); + } +} diff --git a/web/src/ui/mod.rs b/web/src/ui/mod.rs index 51f0cf7a..0d12ff6d 100644 --- a/web/src/ui/mod.rs +++ b/web/src/ui/mod.rs @@ -16,19 +16,22 @@ pub(crate) struct Nav { pub authenticated: bool, pub role: String, pub can_admin: bool, + pub can_permissions: bool, pub can_import: bool, pub can_export: bool, pub active: &'static str, } impl Nav { - /// `active` is the nav link to highlight: `"admin"`, `"analytics"`, - /// `"login"`, or `""` for pages that are not themselves nav entries. + /// `active` is the nav link to highlight: `"admin"`, `"permissions"`, + /// `"analytics"`, `"login"`, or `""` for pages that are not themselves nav + /// entries. pub(crate) fn new(headers: &HeaderMap, active: &'static str) -> Self { Self { authenticated: crate::cookie_value(headers, SESSION_COOKIE).is_some(), role: String::new(), can_admin: false, + can_permissions: false, can_import: false, can_export: false, active, @@ -41,6 +44,10 @@ impl Nav { ) -> Self { self.role = authorization.role.clone(); self.can_admin = crate::authz::can_open_admin(authorization); + // Permissions is its own nav section, so it is not gated on the admin + // panel: managing either roles or users is enough to open it. + self.can_permissions = crate::authz::can_manage(authorization, crate::authz::STRUCT_ROLE) + || crate::authz::can_manage(authorization, crate::authz::STRUCT_USER); self.can_import = authorization.permissions.iter().any(|permission| { permission.action == "insert" && permission.object.starts_with("data:") }); @@ -57,6 +64,7 @@ impl Default for Nav { authenticated: false, role: String::new(), can_admin: false, + can_permissions: false, can_import: false, can_export: false, active: "", diff --git a/web/static/app.css b/web/static/app.css index 180148f8..74b6b4e8 100644 --- a/web/static/app.css +++ b/web/static/app.css @@ -178,6 +178,45 @@ .danger-panel h2 { color: #a12b2b; } .form-actions button.danger-submit { background: #a12b2b; } + /* ---------- Permissions (pages/permissions) ---------- */ + + /* The three sections — roles, people, access — are separate pages, and this + is the switcher between them, rendered by pages/permissions/tabs.html. */ + .tabs { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 18px; } + .tab { display: grid; gap: 2px; padding: 10px 16px; border: 1px solid #d9dfe7; border-radius: 9px; color: #33415c; background: white; text-decoration: none; } + .tab small { color: #7c8796; font-size: 11px; } + .tab:hover { background: #f2f6fc; } + .tab.selected { border-color: #a9c7f6; background: #eaf2ff; } + .tab.selected span { font-weight: 700; color: #1d4ed8; } + + .table-scroll { overflow-x: auto; } + .link-action { color: #2563eb; text-decoration: none; } + .notice { padding: 10px 12px; border: 1px solid #cfe0c4; border-radius: 8px; color: #21643a; background: #f2f9f3; } + + /* The shortcut buttons — read everything, full access, clear — sit above the + matrix they fill in, so they are wider than the cell buttons and read as + sentences rather than as symbols. */ + .shortcut-row { margin: 12px 0 4px; } + .shortcut-row button.secondary { margin-top: 0; padding: 8px 14px; } + + /* Objects down the side, actions across the top. Each cell is its own form, + because a grant is one call to the server either way. */ + .grant-matrix td, .grant-matrix th { white-space: normal; } + .grant-matrix th { text-transform: capitalize; } + .grant-matrix .object-label { display: block; font-weight: 600; color: #24324a; } + .grant-matrix td small { display: block; margin: 2px 0; color: #7c8796; } + .grant-matrix td code { font-size: 11px; color: #5b6678; } + .grant-cell { width: 82px; text-align: center; vertical-align: middle; } + .grant-cell .actions { justify-content: center; gap: 4px; } + .grant-cell form { margin: 0 auto; } + .grant-cell button { min-width: 34px; padding: 4px 9px; border-radius: 5px; cursor: pointer; } + button.cell-on { border: 1px solid #a9d3b4; color: #21643a; background: #eefaf1; } + button.cell-on:hover { border-color: #e0b7b3; color: #a12b2b; background: #fdf3f3; } + button.cell-off { border: 1px dashed #cdd5e0; color: #8a94a4; background: white; } + button.cell-off:hover { border-style: solid; border-color: #a9c7f6; color: #1d4ed8; background: #eef3fe; } + .cell-closed { color: #c3cad4; } + .cell-inherited { color: #1d4ed8; } + /* ---------- Narrow screens ---------- */ @media (max-width: 850px) { diff --git a/web/templates/pages/admin/admin/admin.html b/web/templates/pages/admin/admin/admin.html index ce51673a..e0769d1b 100644 --- a/web/templates/pages/admin/admin/admin.html +++ b/web/templates/pages/admin/admin/admin.html @@ -17,7 +17,6 @@ {% if page.can_manage_scripts %}Add logic{% endif %} {% if page.can_manage_validations %}Add validation{% endif %} {% if page.can_manage_validations %}Add rule{% endif %} - {% if page.can_manage_permissions %}Permissions{% endif %} {% if page.can_export %}Export{% endif %} diff --git a/web/templates/pages/admin/permissions/permissions.html b/web/templates/pages/admin/permissions/permissions.html deleted file mode 100644 index 3dcd9b7b..00000000 --- a/web/templates/pages/admin/permissions/permissions.html +++ /dev/null @@ -1,107 +0,0 @@ -{% extends "ui/base.html" %} - -{% block title %}Roles and permissions{% endblock %} - -{% block content %} -
-
-
-

Authorization

-

Roles and permissions

-

Manage data roles, their direct grants, inheritance, and user assignments.

-
- -
- - {% if page.updated %}

Administration updated. The current session remains valid and all subsequent requests use the new state.

{% endif %} - - {% if page.can_manage_roles %} -
-

Data roles

-
- -
-
- - - -
- {% if page.selected_role_is_removable() %} -
- - -
- {% endif %} -
- -
-

Grants for {{ page.selected_role }}

-

Direct grants can be revoked here. “Inherited” permissions come from the role’s parent.

-
- - - - {% for object in page.grantable_objects %} - - - - - - {% endfor %} - -
ObjectScopeActions
{{ object.object }}{{ object.kind }}{% if !object.profile.is_empty() %} · {{ object.profile }}{% endif %}{% if !object.table.is_empty() %} / {{ object.table }}{% endif %} -
- {% for action in object.allowed_actions %} - {% if page.direct(object.object.as_str(), action.as_str()) %} -
- - -
- {% else if page.effective(object.object.as_str(), action.as_str()) %} - {{ action }} · inherited - {% else %} -
- - -
- {% endif %} - {% endfor %} -
-
-
-
- {% endif %} - - {% if page.can_manage_users %} -
-

Users

- - - {% for user in page.users %} - - - - {% endfor %} -
UsernameEmailCurrent roleAssign roleReset password
{{ user.username }}{{ user.email }}{{ user.role }}
- - - -
{% if page.can_reset_password(user) %}
- - - - -
{% endif %}
-
- {% endif %} -
-
-{% endblock %} diff --git a/web/templates/pages/admin/table_definition/workspace.html b/web/templates/pages/admin/table_definition/workspace.html index 4c2f8fa1..a335a876 100644 --- a/web/templates/pages/admin/table_definition/workspace.html +++ b/web/templates/pages/admin/table_definition/workspace.html @@ -154,22 +154,26 @@ {% if !page.permission_object.is_empty() %}

Data permissions for {{ page.selection.table }}

-

These grants cover this table family and take effect on subsequent requests without replacing the current session.

+

+ 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 + Permissions page. +

{% for role in page.role_permissions %}
RoleActions
{{ role.role }}
{% for permission in role.actions %} {% if permission.direct %} -
- + +
{% else if permission.effective %} {{ permission.action }} · inherited {% else %} -
- + +
{% endif %} diff --git a/web/templates/pages/permissions/grants/grants.html b/web/templates/pages/permissions/grants/grants.html new file mode 100644 index 00000000..de758a3f --- /dev/null +++ b/web/templates/pages/permissions/grants/grants.html @@ -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) %} +
+ + + {% for pair in pairs %}{% endfor %} + +
+{% endmacro %} + +{% block content %} +
+
+
+

Permissions

+

Access

+

What a role may do with the data. Everyone holding the role gets exactly this.

+
+
+ + {% include "pages/permissions/tabs.html" %} + + {% if page.updated %}

Access updated. It applies to the next request every holder of the role makes.

{% endif %} + + {% if page.roles.is_empty() %} +
+

No role to edit

+

There is no role here you outrank. Create one first.

+
+ {% else %} + +
+

Role

+
+ +
+

+ {% if !page.selected_parent.is_empty() %} + {{ page.selected_role }} inherits everything {{ page.selected_parent }} has. Inherited access shows below but is changed on the parent. + {% else %} + {{ page.selected_role }} 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 %} +

+ +

Shortcuts

+

These grant the wildcard objects, so they keep covering profiles and tables created later.

+
+ {% 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 %} +
+
+ + {% for group in page.groups %} +
+

{% if group.global %}Everything, everywhere{% else %}Profile: {{ group.title }}{% endif %}{{ group.rows.len() }}

+
+ {% 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 %} +
+ +
+ + + + + {% for action in page.actions() %}{% endfor %} + + + + + {% for row in group.rows %} + + + {% for cell in row.cells %} + + {% endfor %} + + + {% endfor %} + +
Object{{ action }}Row
+ {{ row.label }} + {% if !row.note.is_empty() %}{{ row.note }}{% endif %} + {{ row.object }} + + {% if !cell.allowed %} + · + {% 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 %} + + {% else %} + {% call apply(page.selected_role, "grant", row.one_pair(cell.action.as_str()), "+", "cell-off", "Click to grant") %}{% endcall %} + {% endif %} + +
+ {% 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 %} +
+
+
+
+ {% endfor %} + {% endif %} + +
+
+{% endblock %} diff --git a/web/templates/pages/permissions/roles/roles.html b/web/templates/pages/permissions/roles/roles.html new file mode 100644 index 00000000..c89d5273 --- /dev/null +++ b/web/templates/pages/permissions/roles/roles.html @@ -0,0 +1,84 @@ +{# GET /permissions/roles — crate::pages::permissions::roles::ui::RolesTemplate #} +{% extends "ui/base.html" %} + +{% block title %}Roles{% endblock %} + +{% block content %} +
+
+
+

Permissions

+

Roles

+

A role is a named set of permissions. People are given roles; roles are given access.

+
+
+ + {% include "pages/permissions/tabs.html" %} + + {% if page.updated %}

Roles updated. Signed-in sessions stay valid and pick up the change on their next request.

{% endif %} + +
+

New role

+

+ 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. +

+
+ + + +
+
+
+ +
+

Existing roles{{ page.roles.len() }}

+
+ + + + {% for role in page.roles %} + + + + + + + + + {% endfor %} + +
RoleKindInherits fromPeopleAccessRemove
{{ role.name }}{% if role.built_in %} built in{% endif %}{{ role.kind }}{% if role.parent.is_empty() %}{% else %}{{ role.parent }}{% endif %}{% match role.users %}{% when Some with (count) %}{{ count }}{% when None %}{% endmatch %} + {% if role.editable %} + Edit access + {% else %} + outranks you + {% endif %} + + {% if role.removable() %} +
+ + +
+ {% else %} + {{ role.keeps_reason() }} + {% endif %} +
+
+
+ +
+
+{% endblock %} diff --git a/web/templates/pages/permissions/tabs.html b/web/templates/pages/permissions/tabs.html new file mode 100644 index 00000000..2b38ace4 --- /dev/null +++ b/web/templates/pages/permissions/tabs.html @@ -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. +#} + diff --git a/web/templates/pages/permissions/users/users.html b/web/templates/pages/permissions/users/users.html new file mode 100644 index 00000000..5f416223 --- /dev/null +++ b/web/templates/pages/permissions/users/users.html @@ -0,0 +1,64 @@ +{# GET /permissions/users — crate::pages::permissions::users::ui::UsersTemplate #} +{% extends "ui/base.html" %} + +{% block title %}People{% endblock %} + +{% block content %} +
+
+
+

Permissions

+

People

+

Give a user a role. What that role may do is decided once, on the Access tab.

+
+
+ + {% include "pages/permissions/tabs.html" %} + + {% if page.updated %}

Updated. The person's next request uses the new role; they do not have to sign in again.

{% endif %} + +
+

Users{{ page.users.len() }}

+

+ You can only change someone whose role you outrank — {{ page.editable_users() }} of {{ page.users.len() }} here. +

+
+ + + + {% for user in page.users %} + + + + + {% if user.editable %} + + + {% else %} + + {% endif %} + + {% endfor %} + +
UserEmailRoleChange roleReset password
{{ user.username }}{% if user.email.is_empty() %}{% else %}{{ user.email }}{% endif %}{{ user.role }} +
+ + + +
+
+
+ + + + +
+
outranks you
+
+
+ +
+
+{% endblock %} diff --git a/web/templates/ui/navbar.html b/web/templates/ui/navbar.html index 6422c8e5..94cfda87 100644 --- a/web/templates/ui/navbar.html +++ b/web/templates/ui/navbar.html @@ -23,6 +23,7 @@