From fd551ef7fb3bc63da09dc569fe41ee009934bd39 Mon Sep 17 00:00:00 2001 From: Priec Date: Sun, 9 Aug 2026 16:30:27 +0200 Subject: [PATCH] web synchronized with the new changes --- server | 2 +- web/CHANGELOG.md | 30 +++- web/src/authz.rs | 106 ++++++++++++ web/src/lib.rs | 18 +- web/src/pages/add_logic/loader.rs | 4 +- web/src/pages/add_logic/logic.rs | 2 +- web/src/pages/add_table/loader.rs | 4 +- web/src/pages/add_table/logic.rs | 7 +- web/src/pages/add_validation/loader.rs | 4 +- web/src/pages/add_validation/logic.rs | 4 +- web/src/pages/admin/admin/loader.rs | 12 +- web/src/pages/admin/admin/state.rs | 5 + web/src/pages/admin/admin/ui.rs | 10 +- web/src/pages/admin/mod.rs | 1 + web/src/pages/admin/permissions/loader.rs | 132 +++++++++++++++ web/src/pages/admin/permissions/logic.rs | 160 ++++++++++++++++++ web/src/pages/admin/permissions/mod.rs | 18 ++ web/src/pages/admin/permissions/state.rs | 85 ++++++++++ web/src/pages/admin/permissions/ui.rs | 96 +++++++++++ .../pages/admin/table_definition/loader.rs | 85 +++++++++- web/src/pages/admin/table_definition/logic.rs | 2 +- web/src/pages/admin/table_definition/state.rs | 13 ++ web/src/pages/admin/table_definition/ui.rs | 24 ++- web/src/pages/analytics/loader.rs | 29 +++- web/src/pages/analytics/logic.rs | 19 ++- web/src/pages/import_export/common/loader.rs | 70 +++++++- web/src/pages/import_export/export/loader.rs | 5 +- web/src/pages/import_export/export/logic.rs | 2 +- web/src/pages/import_export/import/loader.rs | 5 +- web/src/pages/import_export/import/logic.rs | 2 +- web/src/pages/login/logic.rs | 81 ++++++++- web/src/pages/login/mod.rs | 9 +- web/src/pages/login/state.rs | 15 ++ web/src/pages/login/ui.rs | 28 ++- web/src/ui/mod.rs | 25 ++- web/templates/pages/add_table/add_table.html | 2 +- web/templates/pages/admin/admin/admin.html | 14 +- .../pages/admin/permissions/permissions.html | 99 +++++++++++ .../admin/table_definition/workspace.html | 29 ++++ .../pages/import_export/export/export.html | 2 +- .../pages/import_export/import/import.html | 12 +- .../pages/login/initial_password.html | 20 +++ web/templates/pages/login/login.html | 3 + web/templates/ui/navbar.html | 8 +- 44 files changed, 1215 insertions(+), 88 deletions(-) create mode 100644 web/src/authz.rs create mode 100644 web/src/pages/admin/permissions/loader.rs create mode 100644 web/src/pages/admin/permissions/logic.rs create mode 100644 web/src/pages/admin/permissions/mod.rs create mode 100644 web/src/pages/admin/permissions/state.rs create mode 100644 web/src/pages/admin/permissions/ui.rs create mode 100644 web/templates/pages/admin/permissions/permissions.html create mode 100644 web/templates/pages/login/initial_password.html diff --git a/server b/server index dffa41c1..b88981db 160000 --- a/server +++ b/server @@ -1 +1 @@ -Subproject commit dffa41c1f7c285f714fa68dc1449f700755ca0e1 +Subproject commit b88981db0587ff651c7b4d43b778eeff6dfc06e1 diff --git a/web/CHANGELOG.md b/web/CHANGELOG.md index 366685df..fbd08783 100644 --- a/web/CHANGELOG.md +++ b/web/CHANGELOG.md @@ -16,6 +16,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- **Role, user and permission administration** — `/admin/permissions` consumes + `ListRoles`, `AddRole`, `RemoveRole`, `ListUsers`, `AssignUserRole`, + `ListRolePermissions`, `ListGrantableObjects`, `GrantPermission` and + `RevokePermission`. Direct and inherited grants are distinguished, and the + action controls come from each grantable object returned by the backend. +- **Table-family permissions in table definition** — selecting a table shows + its grant matrix for every editable data role. Creating a table redirects to + that table in the definition workspace so its initial grants can be assigned + immediately. +- **Bootstrap administrator claim** — `/initial-password` consumes + `SetInitialPassword` for the one-time `admin` and `superadmin` setup flow. - **`TableDefinition.ListColumnTypes`** — called by the add-table and table-definition loaders. The whole response is consumed: `name`, `group`, `declarable`, `compound`, `spelling`, `requires_currency`, `creation_only`, @@ -25,6 +36,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed +- **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 + treated as administrators. Login redirects structural users to the admin + workspace and data users to analytics. +- **Analytics and import/export use the caller's visible data** — profile-tree + discovery is authenticated, export is limited to readable tables, and + import is limited to tables for which the snapshot carries `insert`. +- **Permission mutations retire the browser session** — the current backend + invalidates every existing token after a policy change. After a successful + role, assignment, grant or revoke operation the web clears that stale cookie + and explains why the administrator must sign in again. - **The column-type picker is the server's list** — it no longer carries its own. Types the web crate never offered are now reachable: `numeric` and the `ACCOUNTING_TRANSFER` compound column. Server-generated companion types @@ -59,13 +82,6 @@ The endpoints and fields below are part of the current proto surface but the web crate does not implement them. They are listed so it stays visible what the backend gained that this client has not wired up yet. -- **`AuthService` role administration** — `SetInitialPassword`, `ListRoles`, - `AddRole`, `RemoveRole`, `GrantPermission`, `RevokePermission`, - `ListRolePermissions`, `ListGrantableObjects`, `AssignUserRole` and - `ListUsers` are not called by any page; there is no role-administration UI. - `GetAuthorization` still reads only `role` — the reworked `Permission` - object/action surface (structural areas, `data:`/`journal:` objects) is - ignored. - **`TableDefinition.PostTableDefinition` — `LINK(table)` columns** — the old per-request link UI was removed when links became columns, and the new spelling is not implemented. The picker offers `link` (the `ListColumnTypes` diff --git a/web/src/authz.rs b/web/src/authz.rs new file mode 100644 index 00000000..c09f7b39 --- /dev/null +++ b/web/src/authz.rs @@ -0,0 +1,106 @@ +use crate::auth::{AuthorizationSnapshot, Permission}; + +pub(crate) const STRUCT_PROFILE: &str = "struct:profile"; +pub(crate) const STRUCT_TABLE: &str = "struct:table"; +pub(crate) const STRUCT_SCRIPT: &str = "struct:script"; +pub(crate) const STRUCT_VALIDATION: &str = "struct:validation"; +pub(crate) const STRUCT_ROLE: &str = "struct:role"; +pub(crate) const STRUCT_USER: &str = "struct:user"; +pub(crate) const MANAGE: &str = "manage"; + +pub(crate) fn permits(snapshot: &AuthorizationSnapshot, object: &str, action: &str) -> bool { + permissions_permit(&snapshot.permissions, object, action) +} + +pub(crate) fn permissions_permit( + permissions: &[Permission], + object: &str, + action: &str, +) -> bool { + permissions + .iter() + .any(|permission| permission.action == action && object_matches(&permission.object, object)) +} + +pub(crate) fn can_manage(snapshot: &AuthorizationSnapshot, area: &str) -> bool { + permits(snapshot, area, MANAGE) +} + +pub(crate) fn can_open_admin(snapshot: &AuthorizationSnapshot) -> bool { + [ + STRUCT_PROFILE, + STRUCT_TABLE, + STRUCT_SCRIPT, + STRUCT_VALIDATION, + STRUCT_ROLE, + STRUCT_USER, + ] + .into_iter() + .any(|area| can_manage(snapshot, area)) +} + +pub(crate) fn table_object(profile: &str, table: &str) -> String { + format!("data:{profile}/{table}") +} + +pub(crate) fn permits_table( + snapshot: &AuthorizationSnapshot, + profile: &str, + table: &str, + action: &str, +) -> bool { + permits(snapshot, &table_object(profile, table), action) +} + +pub(crate) fn is_direct_permission(permissions: &[Permission], object: &str, action: &str) -> bool { + permissions + .iter() + .any(|permission| permission.object == object && permission.action == action) +} + +fn object_matches(pattern: &str, object: &str) -> bool { + match pattern.strip_suffix('*') { + Some(prefix) => object.starts_with(prefix), + None => pattern == object, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn snapshot(permissions: &[(&str, &str)]) -> AuthorizationSnapshot { + AuthorizationSnapshot { + role: "bookkeeper".to_string(), + permissions: permissions + .iter() + .map(|(object, action)| Permission { + object: (*object).to_string(), + action: (*action).to_string(), + }) + .collect(), + } + } + + #[test] + fn structural_access_comes_from_permissions_not_role_names() { + let authorization = snapshot(&[(STRUCT_TABLE, MANAGE)]); + assert!(can_manage(&authorization, STRUCT_TABLE)); + assert!(can_open_admin(&authorization)); + assert!(!can_manage(&authorization, STRUCT_ROLE)); + } + + #[test] + fn data_wildcards_match_the_server_object_shapes() { + let global = snapshot(&[("data:*", "read")]); + assert!(permits_table(&global, "acme", "invoice", "read")); + + let profile = snapshot(&[("data:acme/*", "insert")]); + assert!(permits_table(&profile, "acme", "invoice", "insert")); + assert!(!permits_table(&profile, "other", "invoice", "insert")); + + let table = snapshot(&[("data:acme/invoice", "delete")]); + assert!(permits_table(&table, "acme", "invoice", "delete")); + assert!(!permits_table(&table, "acme", "customer", "delete")); + } +} diff --git a/web/src/lib.rs b/web/src/lib.rs index 7eae1e19..1d531423 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -11,6 +11,7 @@ mod pages; mod schema; mod services; mod ui; +mod authz; mod analytics { include!(concat!( env!("CARGO_MANIFEST_DIR"), @@ -138,6 +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::admin::table_definition::router()) .merge(pages::add_table::router()) .merge(pages::add_logic::router()) @@ -244,8 +246,17 @@ mod tests { assert!(!body.contains("hx-post=\"/logout\"")); } - /// The register form carries every `RegisterRequest` field the TUI client - /// asks for, and the suggestion lists for the three the client suggests. + #[tokio::test] + async fn fresh_installation_can_open_the_initial_password_form() { + let (status, body) = get("/initial-password").await; + assert!(status.is_success()); + assert!(body.contains("Claim bootstrap administrator")); + assert!(body.contains("value=\"admin\"")); + assert!(body.contains("value=\"superadmin\"")); + } + + /// The register form carries every user-provided `RegisterRequest` field. + /// Role assignment belongs to the administrator permissions page. #[tokio::test] async fn the_register_form_offers_the_same_fields_as_the_client() { let (_, body) = get("/register").await; @@ -254,7 +265,6 @@ mod tests { "email", "password", "password_confirmation", - "role", "timezone", "phone_country", ] { @@ -263,7 +273,7 @@ mod tests { "the register form is missing the {field} field" ); } - assert!(body.contains("value=\"accountant\"")); + assert!(!body.contains("name=\"role\"")); assert!(body.contains("value=\"Europe/Bratislava\"")); assert!(body.contains("value=\"SK\"")); } diff --git a/web/src/pages/add_logic/loader.rs b/web/src/pages/add_logic/loader.rs index 2ff56d0e..8e21a151 100644 --- a/web/src/pages/add_logic/loader.rs +++ b/web/src/pages/add_logic/loader.rs @@ -26,7 +26,7 @@ pub(crate) async fn load_page( _ => LoadError::Backend(error.message().to_string()), })? .into_inner(); - if authorization.role != "admin" { + if !crate::authz::can_manage(&authorization, crate::authz::STRUCT_SCRIPT) { return Err(LoadError::Forbidden); } @@ -50,7 +50,7 @@ pub(crate) async fn load_page( }) .collect(); Ok(AddLogicPageState { - nav: crate::ui::Nav::new(headers, "admin").with_role(authorization.role), + nav: crate::ui::Nav::new(headers, "admin").with_authorization(&authorization), tables, form, error, diff --git a/web/src/pages/add_logic/logic.rs b/web/src/pages/add_logic/logic.rs index 386ca28f..5f8864da 100644 --- a/web/src/pages/add_logic/logic.rs +++ b/web/src/pages/add_logic/logic.rs @@ -66,7 +66,7 @@ fn render_loaded(result: Result) -> Err(LoadError::Unauthenticated) => Redirect::to("/login").into_response(), Err(LoadError::Forbidden) => ( StatusCode::FORBIDDEN, - Html(ui::render_submission_error("Administrator access is required.")), + Html(ui::render_submission_error("Script-management permission is required.")), ) .into_response(), Err(LoadError::Backend(message)) => ( diff --git a/web/src/pages/add_table/loader.rs b/web/src/pages/add_table/loader.rs index 34403b78..2f3fe748 100644 --- a/web/src/pages/add_table/loader.rs +++ b/web/src/pages/add_table/loader.rs @@ -33,7 +33,7 @@ pub(crate) async fn load_page( _ => LoadError::Backend(error.message().to_string()), })? .into_inner(); - if authorization.role != "admin" { + if !crate::authz::can_manage(&authorization, crate::authz::STRUCT_TABLE) { return Err(LoadError::Forbidden); } @@ -85,7 +85,7 @@ pub(crate) async fn load_page( } Ok(AddTablePageState { - nav: crate::ui::Nav::new(headers, "admin").with_role(authorization.role), + nav: crate::ui::Nav::new(headers, "admin").with_authorization(&authorization), profiles: tree .profiles .into_iter() diff --git a/web/src/pages/add_table/logic.rs b/web/src/pages/add_table/logic.rs index 36867c4c..19c64a8b 100644 --- a/web/src/pages/add_table/logic.rs +++ b/web/src/pages/add_table/logic.rs @@ -100,7 +100,10 @@ pub(crate) async fn create_table( let mut definitions = state.definitions; match definitions.post_table_definition(request).await { Ok(response) if response.get_ref().success => { - let location = format!("/admin?profile={profile_name}"); + let location = format!( + "/admin/table-definition?profile={profile_name}&table={}", + page.draft.table_name + ); let Ok(location) = HeaderValue::try_from(location) else { return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid redirect").into_response(); }; @@ -153,7 +156,7 @@ fn load_error_response(error: LoadError) -> Response { LoadError::Forbidden => ( StatusCode::FORBIDDEN, Html(ui::render_submission_error( - "Administrator access is required.", + "Table-management permission is required.", )), ) .into_response(), diff --git a/web/src/pages/add_validation/loader.rs b/web/src/pages/add_validation/loader.rs index 939a36ce..1e75aabd 100644 --- a/web/src/pages/add_validation/loader.rs +++ b/web/src/pages/add_validation/loader.rs @@ -27,7 +27,7 @@ pub(crate) async fn load_page( _ => LoadError::Backend(error.message().to_string()), })? .into_inner(); - if authorization.role != "admin" { + if !crate::authz::can_manage(&authorization, crate::authz::STRUCT_VALIDATION) { return Err(LoadError::Forbidden); } let mut definitions = state.definitions; @@ -43,7 +43,7 @@ pub(crate) async fn load_page( .flat_map(|profile| profile.tables.iter().map(|table| table.name.clone())) .collect(); Ok(ValidationPageState { - nav: crate::ui::Nav::new(headers, "admin").with_role(authorization.role), + nav: crate::ui::Nav::new(headers, "admin").with_authorization(&authorization), profiles, tables, form, diff --git a/web/src/pages/add_validation/logic.rs b/web/src/pages/add_validation/logic.rs index 91306a04..446364ae 100644 --- a/web/src/pages/add_validation/logic.rs +++ b/web/src/pages/add_validation/logic.rs @@ -127,7 +127,7 @@ fn render_set_loaded( Html(ui::render_set_page(nav, &form, error.as_deref())).into_response() } Err(LoadError::Unauthenticated) => Redirect::to("/login").into_response(), - Err(LoadError::Forbidden) => (StatusCode::FORBIDDEN, Html(ui::render_error("Administrator access is required."))).into_response(), + Err(LoadError::Forbidden) => (StatusCode::FORBIDDEN, Html(ui::render_error("Validation-management permission is required."))).into_response(), Err(LoadError::Backend(message)) => (StatusCode::BAD_GATEWAY, Html(ui::render_error(&message))).into_response(), } } @@ -136,7 +136,7 @@ fn render_loaded(result: Result) - match result { Ok(page) => Html(ui::render_page(&page)).into_response(), Err(LoadError::Unauthenticated) => Redirect::to("/login").into_response(), - Err(LoadError::Forbidden) => (StatusCode::FORBIDDEN, Html(ui::render_error("Administrator access is required."))).into_response(), + Err(LoadError::Forbidden) => (StatusCode::FORBIDDEN, Html(ui::render_error("Validation-management permission is required."))).into_response(), Err(LoadError::Backend(message)) => (StatusCode::BAD_GATEWAY, Html(ui::render_error(&message))).into_response(), } } diff --git a/web/src/pages/admin/admin/loader.rs b/web/src/pages/admin/admin/loader.rs index f0cb39c4..2663b681 100644 --- a/web/src/pages/admin/admin/loader.rs +++ b/web/src/pages/admin/admin/loader.rs @@ -29,7 +29,7 @@ pub(crate) async fn load_admin_page( })? .into_inner(); - if authorization.role != "admin" { + if !crate::authz::can_manage(&authorization, crate::authz::STRUCT_TABLE) { return Err(LoadError::Forbidden); } @@ -129,12 +129,20 @@ pub(crate) async fn load_admin_page( }; Ok(AdminPageState { - nav: crate::ui::Nav::new(headers, "admin").with_role(authorization.role), + nav: crate::ui::Nav::new(headers, "admin").with_authorization(&authorization), profiles, selected_profile, tables, selected_table, columns, + 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 d90c1ab5..9e39ab42 100644 --- a/web/src/pages/admin/admin/state.rs +++ b/web/src/pages/admin/admin/state.rs @@ -14,6 +14,11 @@ pub(crate) struct AdminPageState { pub tables: Vec, pub selected_table: Option, pub columns: Vec, + pub can_manage_tables: bool, + pub can_manage_scripts: bool, + pub can_manage_validations: bool, + pub can_manage_permissions: bool, + pub can_export: bool, } #[derive(Debug)] diff --git a/web/src/pages/admin/admin/ui.rs b/web/src/pages/admin/admin/ui.rs index 2e4eece4..177e167e 100644 --- a/web/src/pages/admin/admin/ui.rs +++ b/web/src/pages/admin/admin/ui.rs @@ -49,6 +49,9 @@ mod tests { let nav = Nav { authenticated: true, role: "admin".to_string(), + can_admin: true, + can_import: false, + can_export: false, active: "admin", }; let page = AdminPageState { @@ -58,6 +61,11 @@ mod tests { tables: Vec::new(), selected_table: None, columns: Vec::new(), + can_manage_tables: true, + can_manage_scripts: true, + can_manage_validations: true, + can_manage_permissions: true, + can_export: true, }; let html = render_page(&page); for route in [ @@ -66,8 +74,8 @@ mod tests { "/admin/logic/new", "/admin/validation/new", "/admin/validation/sets/new", - "/admin/import", "/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 e0097f09..e00362e6 100644 --- a/web/src/pages/admin/mod.rs +++ b/web/src/pages/admin/mod.rs @@ -1,2 +1,3 @@ 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 new file mode 100644 index 00000000..387188e8 --- /dev/null +++ b/web/src/pages/admin/permissions/loader.rs @@ -0,0 +1,132 @@ +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 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, + }) +} + +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 new file mode 100644 index 00000000..4618f7ea --- /dev/null +++ b/web/src/pages/admin/permissions/logic.rs @@ -0,0 +1,160 @@ +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, + RevokePermissionRequest, + }, + services::{authenticated_request, reject_cross_site}, +}; + +use super::{ + loader, + state::{AddRoleForm, AssignRoleForm, LoadError, PermissionForm, 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 request_headers = headers.clone(); + mutate(&headers, async move { + let mut auth = state.auth; + auth.add_role(authenticated_request(&request_headers, AddRoleRequest { + name: form.name.trim().to_string(), + parent: form.parent.trim().to_string(), + }).map_err(|_| "Missing session".to_string())?) + .await.map_err(|error| error.message().to_string())?; + Ok("Role created. Sign in again to continue.") + }).await +} + +pub(crate) async fn remove_role( + State(state): State, + headers: HeaderMap, + Form(form): Form, +) -> Response { + let request_headers = headers.clone(); + mutate(&headers, async move { + let mut auth = state.auth; + auth.remove_role(authenticated_request(&request_headers, RemoveRoleRequest { + name: form.role, + }).map_err(|_| "Missing session".to_string())?) + .await.map_err(|error| error.message().to_string())?; + Ok("Role removed. Sign in again to continue.") + }).await +} + +pub(crate) async fn grant( + State(state): State, + headers: HeaderMap, + Form(form): Form, +) -> Response { + let request_headers = headers.clone(); + mutate(&headers, 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("Permission granted. Sign in again to continue.") + }).await +} + +pub(crate) async fn revoke( + State(state): State, + headers: HeaderMap, + Form(form): Form, +) -> Response { + let request_headers = headers.clone(); + mutate(&headers, 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("Permission revoked. Sign in again to continue.") + }).await +} + +pub(crate) async fn assign_user_role( + State(state): State, + headers: HeaderMap, + Form(form): Form, +) -> Response { + let request_headers = headers.clone(); + mutate(&headers, async move { + let mut auth = state.auth; + auth.assign_user_role(authenticated_request(&request_headers, AssignUserRoleRequest { + username: form.username, + role: form.role, + }).map_err(|_| "Missing session".to_string())?) + .await.map_err(|error| error.message().to_string())?; + Ok("User role changed. Sign in again to continue.") + }).await +} + +async fn mutate(headers: &HeaderMap, operation: F) -> Response +where + F: std::future::Future>, +{ + if let Some(rejection) = reject_cross_site(headers) { + return rejection; + } + match operation.await { + Ok(_) => stale_session_response(), + Err(message) => ( + StatusCode::UNPROCESSABLE_ENTITY, + Html(ui::render_mutation_error(&message)), + ).into_response(), + } +} + +fn stale_session_response() -> Response { + let mut response = StatusCode::SEE_OTHER.into_response(); + response.headers_mut().insert( + header::SET_COOKIE, + HeaderValue::from_static("analytics_token=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0"), + ); + response.headers_mut().insert( + header::LOCATION, + HeaderValue::from_static("/login?permissions_changed=1"), + ); + response.headers_mut().insert( + "hx-redirect", + HeaderValue::from_static("/login?permissions_changed=1"), + ); + 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 new file mode 100644 index 00000000..db9f96c4 --- /dev/null +++ b/web/src/pages/admin/permissions/mod.rs @@ -0,0 +1,18 @@ +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)) +} diff --git a/web/src/pages/admin/permissions/state.rs b/web/src/pages/admin/permissions/state.rs new file mode 100644 index 00000000..aa6f6823 --- /dev/null +++ b/web/src/pages/admin/permissions/state.rs @@ -0,0 +1,85 @@ +use crate::auth::{GrantableObject, Permission, Role, UserSummary}; + +#[derive(Clone, Debug, Default, serde::Deserialize)] +pub(crate) struct Selection { + #[serde(default)] + pub role: String, +} + +#[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, +} + +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, +} + +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) 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 new file mode 100644 index 00000000..582fe961 --- /dev/null +++ b/web/src/pages/admin/permissions/ui.rs @@ -0,0 +1,96 @@ +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, + }; + + 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/admin/table_definition/loader.rs b/web/src/pages/admin/table_definition/loader.rs index 0cbae17c..3e7389d5 100644 --- a/web/src/pages/admin/table_definition/loader.rs +++ b/web/src/pages/admin/table_definition/loader.rs @@ -13,7 +13,7 @@ use tonic::transport::Channel; use crate::{ AppState, - auth::GetAuthorizationRequest, + auth::{GetAuthorizationRequest, ListGrantableObjectsRequest, ListRolePermissionsRequest, ListRolesRequest}, definitions::{ common::Empty, table_definition::{ @@ -27,7 +27,7 @@ use crate::{ use super::state::{ DetailColumn, LoadError, PageInputs, RenameEntry, ScriptView, TableDefinitionPageState, - TableDetailView, TableSummary, + TableDetailView, TablePermissionAction, TableRolePermissions, TableSummary, }; /// Reads the column-type vocabulary on its own. @@ -67,7 +67,7 @@ pub(crate) async fn load_page( _ => LoadError::Backend(error.message().to_string()), })? .into_inner(); - if authorization.role != "admin" { + if !crate::authz::can_manage(&authorization, crate::authz::STRUCT_TABLE) { return Err(LoadError::Forbidden); } @@ -220,8 +220,83 @@ pub(crate) async fn load_page( false => Vec::new(), }; + let mut permission_object = String::new(); + let mut role_permissions = Vec::new(); + if inputs.selection.has_table() + && crate::authz::can_manage(&authorization, crate::authz::STRUCT_ROLE) + { + let expected_object = crate::authz::table_object( + &inputs.selection.profile, + &inputs.selection.table, + ); + let roles = auth + .list_roles( + authenticated_request(headers, ListRolesRequest {}) + .map_err(|_| LoadError::Unauthenticated)?, + ) + .await + .map_err(|error| LoadError::Backend(error.message().to_string()))? + .into_inner() + .roles; + for role in roles.into_iter().filter(|role| role.kind == "data") { + let grantable = auth + .list_grantable_objects( + authenticated_request( + headers, + ListGrantableObjectsRequest { + target_role: role.name.clone(), + }, + ) + .map_err(|_| LoadError::Unauthenticated)?, + ) + .await + .map_err(|error| LoadError::Backend(error.message().to_string()))? + .into_inner() + .objects + .into_iter() + .find(|object| object.object == expected_object); + let Some(grantable) = grantable else { + continue; + }; + permission_object = expected_object.clone(); + let permissions = auth + .list_role_permissions( + authenticated_request( + headers, + ListRolePermissionsRequest { + role: role.name.clone(), + }, + ) + .map_err(|_| LoadError::Unauthenticated)?, + ) + .await + .map_err(|error| LoadError::Backend(error.message().to_string()))? + .into_inner(); + role_permissions.push(TableRolePermissions { + role: role.name, + actions: grantable + .allowed_actions + .into_iter() + .map(|action| TablePermissionAction { + direct: crate::authz::is_direct_permission( + &permissions.permissions, + &expected_object, + &action, + ), + effective: crate::authz::permissions_permit( + &permissions.effective_permissions, + &expected_object, + &action, + ), + action, + }) + .collect(), + }); + } + } + Ok(TableDefinitionPageState { - nav: crate::ui::Nav::new(headers, "admin").with_role(authorization.role), + nav: crate::ui::Nav::new(headers, "admin").with_authorization(&authorization), profiles, tables, detail, @@ -235,5 +310,7 @@ pub(crate) async fn load_page( error: inputs.error, sql: inputs.sql, generated: inputs.generated, + permission_object, + role_permissions, }) } diff --git a/web/src/pages/admin/table_definition/logic.rs b/web/src/pages/admin/table_definition/logic.rs index 9281bace..c2c2974a 100644 --- a/web/src/pages/admin/table_definition/logic.rs +++ b/web/src/pages/admin/table_definition/logic.rs @@ -462,7 +462,7 @@ fn load_error_response(error: LoadError) -> Response { LoadError::Forbidden => ( StatusCode::FORBIDDEN, Html(ui::render_load_error( - "Administrator access is required.", + "Table-management permission is required.", )), ) .into_response(), diff --git a/web/src/pages/admin/table_definition/state.rs b/web/src/pages/admin/table_definition/state.rs index 8e994504..d0d618f1 100644 --- a/web/src/pages/admin/table_definition/state.rs +++ b/web/src/pages/admin/table_definition/state.rs @@ -255,6 +255,19 @@ pub(crate) struct TableDefinitionPageState { pub error: Option, pub sql: Option, pub generated: Vec, + pub permission_object: String, + pub role_permissions: Vec, +} + +pub(crate) struct TableRolePermissions { + pub role: String, + pub actions: Vec, +} + +pub(crate) struct TablePermissionAction { + pub action: String, + pub direct: bool, + pub effective: bool, } impl TableDefinitionPageState { diff --git a/web/src/pages/admin/table_definition/ui.rs b/web/src/pages/admin/table_definition/ui.rs index 0a0a5aee..3e93a2a1 100644 --- a/web/src/pages/admin/table_definition/ui.rs +++ b/web/src/pages/admin/table_definition/ui.rs @@ -99,7 +99,7 @@ mod tests { use crate::{ pages::admin::table_definition::state::{ CopyForm, DetailColumn, InvoiceTemplateForm, RenameForm, Selection, TableDetailView, - TableSummary, + TablePermissionAction, TableRolePermissions, TableSummary, }, schema::ColumnDraft, }; @@ -149,6 +149,8 @@ mod tests { error: None, sql: None, generated: Vec::new(), + permission_object: String::new(), + role_permissions: Vec::new(), } } @@ -296,6 +298,26 @@ mod tests { assert!(html.contains("1 column added")); } + #[test] + fn selected_table_exposes_its_data_permission_actions() { + let mut page = page(); + page.permission_object = "data:billing/invoice".to_string(); + page.role_permissions = vec![TableRolePermissions { + role: "bookkeeper".to_string(), + actions: vec![TablePermissionAction { + action: "read".to_string(), + direct: false, + effective: false, + }], + }]; + + let html = render_workspace(&page); + assert!(!html.contains("Template error"), "{html}"); + assert!(html.contains("Data permissions for")); + assert!(html.contains("data:billing/invoice")); + assert!(html.contains("Grant read")); + } + #[test] fn a_load_failure_answers_with_the_dialog() { let html = render_load_error("The backend is unreachable."); diff --git a/web/src/pages/analytics/loader.rs b/web/src/pages/analytics/loader.rs index 84ce9e22..8b6cbde9 100644 --- a/web/src/pages/analytics/loader.rs +++ b/web/src/pages/analytics/loader.rs @@ -9,6 +9,7 @@ use crate::{ AnalyticsTable, ExecuteAnalyticsQueryRequest, GetAnalyticsCatalogRequest, GetAnalyticsCatalogResponse, analytics_value, }, + auth::GetAuthorizationRequest, definitions::common::Empty, services::authenticated_request, }; @@ -23,12 +24,19 @@ pub(crate) enum LoadError { Backend(String), } -pub(crate) async fn load_profiles(state: AppState) -> Result, String> { +pub(crate) async fn load_profiles( + state: AppState, + headers: &HeaderMap, +) -> Result, LoadError> { + let request = authenticated_request(headers, Empty {}).map_err(|_| LoadError::Unauthenticated)?; let mut definitions = state.definitions; let tree = definitions - .get_profile_tree(tonic::Request::new(Empty {})) + .get_profile_tree(request) .await - .map_err(|error| error.message().to_string())? + .map_err(|error| match error.code() { + tonic::Code::Unauthenticated => LoadError::Unauthenticated, + _ => LoadError::Backend(error.message().to_string()), + })? .into_inner(); Ok(tree .profiles @@ -40,6 +48,21 @@ pub(crate) async fn load_profiles(state: AppState) -> Result, .collect()) } +pub(crate) async fn load_navigation( + state: AppState, + headers: &HeaderMap, +) -> crate::ui::Nav { + let Ok(request) = authenticated_request(headers, GetAuthorizationRequest {}) else { + return crate::ui::Nav::new(headers, "analytics"); + }; + let mut auth = state.auth; + match auth.get_authorization(request).await { + Ok(response) => crate::ui::Nav::new(headers, "analytics") + .with_authorization(response.get_ref()), + Err(_) => crate::ui::Nav::new(headers, "analytics"), + } +} + pub(crate) async fn load_catalog( state: AppState, headers: &HeaderMap, diff --git a/web/src/pages/analytics/logic.rs b/web/src/pages/analytics/logic.rs index f68da588..819502be 100644 --- a/web/src/pages/analytics/logic.rs +++ b/web/src/pages/analytics/logic.rs @@ -7,7 +7,7 @@ use axum::{ use crate::{ AppState, - ui::{Nav, Notice, render}, + ui::{Notice, render}, }; use super::{ @@ -16,14 +16,21 @@ use super::{ ui, }; -pub(crate) async fn analytics_page(headers: HeaderMap) -> Html { - Html(ui::render_page(Nav::new(&headers, "analytics"))) +pub(crate) async fn analytics_page( + State(state): State, + headers: HeaderMap, +) -> Html { + Html(ui::render_page(loader::load_navigation(state, &headers).await)) } -pub(crate) async fn load_profiles(State(state): State) -> Html { - match loader::load_profiles(state).await { +pub(crate) async fn load_profiles( + State(state): State, + headers: HeaderMap, +) -> Html { + match loader::load_profiles(state, &headers).await { Ok(profiles) => Html(ui::render_profile_options(&profiles)), - Err(message) => Html(ui::render_profile_options_error(&message)), + Err(LoadError::Unauthenticated) => Html(ui::render_profile_options_error("Sign in to list the profiles you may read.")), + Err(LoadError::Backend(message)) => Html(ui::render_profile_options_error(&message)), } } diff --git a/web/src/pages/import_export/common/loader.rs b/web/src/pages/import_export/common/loader.rs index 452df1fa..bbe1a2f8 100644 --- a/web/src/pages/import_export/common/loader.rs +++ b/web/src/pages/import_export/common/loader.rs @@ -9,6 +9,7 @@ use crate::{ pub(crate) struct Catalog { pub profiles: Vec, + pub authorization: crate::auth::AuthorizationSnapshot, } pub(crate) struct Profile { @@ -19,6 +20,7 @@ pub(crate) struct Profile { pub(crate) async fn load_catalog( state: AppState, headers: &HeaderMap, + required_action: &str, ) -> Result { let request = authenticated_request(headers, GetAuthorizationRequest {}) .map_err(|_| LoadError::Unauthenticated)?; @@ -31,7 +33,10 @@ pub(crate) async fn load_catalog( _ => LoadError::Backend(error.message().to_string()), })? .into_inner(); - if authorization.role != "admin" { + let has_required_permission = authorization.permissions.iter().any(|permission| { + permission.action == required_action && permission.object.starts_with("data:") + }); + if !has_required_permission { return Err(LoadError::Forbidden); } let mut definitions = state.definitions; @@ -40,15 +45,66 @@ pub(crate) async fn load_catalog( .await .map_err(|error| LoadError::Backend(error.message().to_string()))? .into_inner(); - Ok(Catalog { - profiles: tree + let mut profiles = tree .profiles .into_iter() - .map(|profile| Profile { - name: profile.name, - tables: profile.tables.into_iter().map(|table| table.name).collect(), + .filter_map(|profile| { + let tables = profile + .tables + .into_iter() + .filter(|table| { + crate::authz::permits_table( + &authorization, + &profile.name, + &table.name, + required_action, + ) + }) + .map(|table| table.name) + .collect::>(); + (!tables.is_empty()).then_some(Profile { + name: profile.name, + tables, + }) }) - .collect(), + .collect::>(); + + // An insert-only role is deliberately absent from GetProfileTree because + // that listing is filtered by read permission. Exact table grants still + // carry enough information to offer their target here. Wildcard-only + // insert roles can type a target manually and the backend remains the + // authoritative permission check. + for permission in authorization + .permissions + .iter() + .filter(|permission| permission.action == required_action) + { + let Some(target) = permission.object.strip_prefix("data:") else { + continue; + }; + let Some((profile_name, table_name)) = target.split_once('/') else { + continue; + }; + if profile_name == "*" || table_name == "*" { + continue; + } + if let Some(profile) = profiles + .iter_mut() + .find(|profile| profile.name == profile_name) + { + if !profile.tables.iter().any(|table| table == table_name) { + profile.tables.push(table_name.to_string()); + } + } else { + profiles.push(Profile { + name: profile_name.to_string(), + tables: vec![table_name.to_string()], + }); + } + } + Ok(Catalog { + profiles, + authorization, }) } diff --git a/web/src/pages/import_export/export/loader.rs b/web/src/pages/import_export/export/loader.rs index 798dfd8f..b237fed3 100644 --- a/web/src/pages/import_export/export/loader.rs +++ b/web/src/pages/import_export/export/loader.rs @@ -8,8 +8,9 @@ pub(crate) async fn load_page( state: AppState, headers: &HeaderMap, ) -> Result { + let catalog = load_catalog(state, headers, "read").await?; Ok(ExportPageState { - nav: crate::ui::Nav::new(headers, "admin"), - catalog: load_catalog(state, headers).await?, + nav: crate::ui::Nav::new(headers, "").with_authorization(&catalog.authorization), + catalog, }) } diff --git a/web/src/pages/import_export/export/logic.rs b/web/src/pages/import_export/export/logic.rs index 95b34585..666fb0c3 100644 --- a/web/src/pages/import_export/export/logic.rs +++ b/web/src/pages/import_export/export/logic.rs @@ -150,7 +150,7 @@ pub(crate) async fn export_csv( fn load_error(error: LoadError) -> Response { match error { LoadError::Unauthenticated => Redirect::to("/login").into_response(), - LoadError::Forbidden => (StatusCode::FORBIDDEN, Html(ui::render_error("Administrator access is required."))).into_response(), + LoadError::Forbidden => (StatusCode::FORBIDDEN, Html(ui::render_error("Read permission is required for at least one table."))).into_response(), LoadError::Backend(message) => backend_error(&message), } } diff --git a/web/src/pages/import_export/import/loader.rs b/web/src/pages/import_export/import/loader.rs index 7967ef64..1d8fef4d 100644 --- a/web/src/pages/import_export/import/loader.rs +++ b/web/src/pages/import_export/import/loader.rs @@ -13,9 +13,10 @@ pub(crate) async fn load_page( form: ImportForm, error: Option, ) -> Result { + let catalog = load_catalog(state, headers, "insert").await?; Ok(ImportPageState { - nav: crate::ui::Nav::new(headers, "admin"), - catalog: load_catalog(state, headers).await?, + nav: crate::ui::Nav::new(headers, "").with_authorization(&catalog.authorization), + catalog, form, error, }) diff --git a/web/src/pages/import_export/import/logic.rs b/web/src/pages/import_export/import/logic.rs index 6d1fac10..286a5b2c 100644 --- a/web/src/pages/import_export/import/logic.rs +++ b/web/src/pages/import_export/import/logic.rs @@ -197,7 +197,7 @@ fn render_loaded(result: Result) -> Re fn load_error(error: LoadError) -> Response { match error { LoadError::Unauthenticated => Redirect::to("/login").into_response(), - LoadError::Forbidden => (StatusCode::FORBIDDEN, Html(ui::render_error("Administrator access is required."))).into_response(), + LoadError::Forbidden => (StatusCode::FORBIDDEN, Html(ui::render_error("Insert permission is required for at least one readable table."))).into_response(), LoadError::Backend(message) => backend_error(&message), } } diff --git a/web/src/pages/login/logic.rs b/web/src/pages/login/logic.rs index 1085a4dd..87b00c17 100644 --- a/web/src/pages/login/logic.rs +++ b/web/src/pages/login/logic.rs @@ -1,17 +1,72 @@ use axum::{ Form, - extract::State, + extract::{Query, State}, http::{HeaderMap, HeaderValue, StatusCode, header}, response::{Html, IntoResponse, Response}, }; use tonic::Request; -use crate::{AppState, auth::LoginRequest, ui::Nav}; +use crate::{AppState, auth::{LoginRequest, SetInitialPasswordRequest}, services::reject_cross_site, ui::Nav}; -use super::{state::LoginInput, ui}; +use super::{state::{InitialPasswordInput, LoginInput, LoginQuery}, ui}; -pub(crate) async fn login_page(headers: HeaderMap) -> Html { - Html(ui::render_page(Nav::new(&headers, "login"))) +pub(crate) async fn login_page( + headers: HeaderMap, + Query(query): Query, +) -> Html { + Html(ui::render_page( + Nav::new(&headers, "login"), + query.permissions_changed, + query.initial_password_set, + )) +} + +pub(crate) async fn initial_password_page(headers: HeaderMap) -> Html { + Html(ui::render_initial_password_page(Nav::new(&headers, "login"))) +} + +pub(crate) async fn set_initial_password( + State(state): State, + headers: HeaderMap, + Form(input): Form, +) -> Response { + if let Some(rejection) = reject_cross_site(&headers) { + return rejection; + } + let username = input.username.trim(); + if !matches!(username, "admin" | "superadmin") { + return error( + StatusCode::UNPROCESSABLE_ENTITY, + "Only the bootstrap admin or superadmin account can be claimed here.", + ); + } + let mut auth = state.auth; + match auth + .set_initial_password(tonic::Request::new(SetInitialPasswordRequest { + username: username.to_string(), + password: input.password, + password_confirmation: input.password_confirmation, + })) + .await + { + Ok(_) => { + let mut response = StatusCode::SEE_OTHER.into_response(); + response.headers_mut().insert( + header::LOCATION, + HeaderValue::from_static("/login?initial_password_set=1"), + ); + response.headers_mut().insert( + "hx-redirect", + HeaderValue::from_static("/login?initial_password_set=1"), + ); + response + } + Err(status) => ( + StatusCode::UNPROCESSABLE_ENTITY, + Html(ui::render_initial_password_error(status.message())), + ) + .into_response(), + } } pub(crate) async fn login( @@ -49,9 +104,19 @@ pub(crate) async fn login( let mut response = Html(String::new()).into_response(); response.headers_mut().insert(header::SET_COOKIE, cookie); - response - .headers_mut() - .insert("hx-redirect", HeaderValue::from_static("/admin")); + let destination = if login + .authorization + .as_ref() + .is_some_and(crate::authz::can_open_admin) + { + "/admin" + } else { + "/" + }; + response.headers_mut().insert( + "hx-redirect", + HeaderValue::from_static(destination), + ); response } diff --git a/web/src/pages/login/mod.rs b/web/src/pages/login/mod.rs index 77bbee07..a74f7487 100644 --- a/web/src/pages/login/mod.rs +++ b/web/src/pages/login/mod.rs @@ -1,5 +1,5 @@ //! GET /login → login.html -//! POST /login → sets the session cookie, then hx-redirects to /admin +//! POST /login → sets the session cookie, then redirects by authorization plane //! //! The matching POST /logout lives with the admin page, which owns the button. @@ -12,5 +12,10 @@ pub(crate) mod state; pub(crate) mod ui; pub(crate) fn router() -> Router { - Router::new().route("/login", get(logic::login_page).post(logic::login)) + Router::new() + .route("/login", get(logic::login_page).post(logic::login)) + .route( + "/initial-password", + get(logic::initial_password_page).post(logic::set_initial_password), + ) } diff --git a/web/src/pages/login/state.rs b/web/src/pages/login/state.rs index a02fc15c..955e2c94 100644 --- a/web/src/pages/login/state.rs +++ b/web/src/pages/login/state.rs @@ -4,3 +4,18 @@ pub(crate) struct LoginInput { #[serde(default)] pub password: String, } + +#[derive(Default, serde::Deserialize)] +pub(crate) struct LoginQuery { + #[serde(default)] + pub permissions_changed: bool, + #[serde(default)] + pub initial_password_set: bool, +} + +#[derive(serde::Deserialize)] +pub(crate) struct InitialPasswordInput { + pub username: String, + pub password: String, + pub password_confirmation: String, +} diff --git a/web/src/pages/login/ui.rs b/web/src/pages/login/ui.rs index b6efce77..ad9c0d03 100644 --- a/web/src/pages/login/ui.rs +++ b/web/src/pages/login/ui.rs @@ -7,13 +7,37 @@ use crate::ui::{Alert, Nav, render}; #[template(path = "pages/login/login.html")] struct LoginPage { nav: Nav, + permissions_changed: bool, + initial_password_set: bool, } -pub(crate) fn render_page(nav: Nav) -> String { - render(&LoginPage { nav }) +pub(crate) fn render_page( + nav: Nav, + permissions_changed: bool, + initial_password_set: bool, +) -> String { + render(&LoginPage { + nav, + permissions_changed, + initial_password_set, + }) } /// POST /login — the #login-status swap when the credentials are rejected. pub(crate) fn render_error(message: &str) -> String { render(&Alert::error("Could not sign in", message)) } + +#[derive(Template)] +#[template(path = "pages/login/initial_password.html")] +struct InitialPasswordPage { + nav: Nav, +} + +pub(crate) fn render_initial_password_page(nav: Nav) -> String { + render(&InitialPasswordPage { nav }) +} + +pub(crate) fn render_initial_password_error(message: &str) -> String { + render(&Alert::error("Could not claim the bootstrap account", message)) +} diff --git a/web/src/ui/mod.rs b/web/src/ui/mod.rs index 30f94090..51f0cf7a 100644 --- a/web/src/ui/mod.rs +++ b/web/src/ui/mod.rs @@ -15,6 +15,9 @@ pub(crate) const SESSION_COOKIE: &str = "analytics_token"; pub(crate) struct Nav { pub authenticated: bool, pub role: String, + pub can_admin: bool, + pub can_import: bool, + pub can_export: bool, pub active: &'static str, } @@ -25,14 +28,25 @@ impl Nav { Self { authenticated: crate::cookie_value(headers, SESSION_COOKIE).is_some(), role: String::new(), + can_admin: false, + can_import: false, + can_export: false, active, } } - /// The admin page is the only one that learns the caller's role, so it is - /// the only one that can show the role badge. - pub(crate) fn with_role(mut self, role: String) -> Self { - self.role = role; + pub(crate) fn with_authorization( + mut self, + authorization: &crate::auth::AuthorizationSnapshot, + ) -> Self { + self.role = authorization.role.clone(); + self.can_admin = crate::authz::can_open_admin(authorization); + self.can_import = authorization.permissions.iter().any(|permission| { + permission.action == "insert" && permission.object.starts_with("data:") + }); + self.can_export = authorization.permissions.iter().any(|permission| { + permission.action == "read" && permission.object.starts_with("data:") + }); self } } @@ -42,6 +56,9 @@ impl Default for Nav { Self { authenticated: false, role: String::new(), + can_admin: false, + can_import: false, + can_export: false, active: "", } } diff --git a/web/templates/pages/add_table/add_table.html b/web/templates/pages/add_table/add_table.html index c690bfdb..d733003a 100644 --- a/web/templates/pages/add_table/add_table.html +++ b/web/templates/pages/add_table/add_table.html @@ -4,7 +4,7 @@ {% block title %}Add table{% endblock %} {% block eyebrow %}Table definition{% endblock %} {% block heading %}Add table{% endblock %} -{% block lead %}

Build the table one column at a time, the same way the terminal client does.

{% endblock %} +{% block lead %}

Build the table one column at a time. After creation, its table-definition workspace opens so you can assign the initial role permissions.

{% endblock %} {% block form %} {# diff --git a/web/templates/pages/admin/admin/admin.html b/web/templates/pages/admin/admin/admin.html index a19c164d..ce51673a 100644 --- a/web/templates/pages/admin/admin/admin.html +++ b/web/templates/pages/admin/admin/admin.html @@ -12,13 +12,13 @@

Browse profiles, tables, and their physical columns.

- Table definition - Add table - Add logic - Add validation - Add rule - Import - Export + {% if page.can_manage_tables %}Table definition{% endif %} + {% if page.can_manage_tables %}Add table{% endif %} + {% 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 %}
{% include "pages/admin/admin/workspace.html" %}
diff --git a/web/templates/pages/admin/permissions/permissions.html b/web/templates/pages/admin/permissions/permissions.html new file mode 100644 index 00000000..1d0c6a86 --- /dev/null +++ b/web/templates/pages/admin/permissions/permissions.html @@ -0,0 +1,99 @@ +{% 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.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 role
{{ user.username }}{{ user.email }}{{ user.role }}
+ + + +
+
+ {% endif %} +
+
+{% endblock %} diff --git a/web/templates/pages/admin/table_definition/workspace.html b/web/templates/pages/admin/table_definition/workspace.html index e43d94a7..8a711b8c 100644 --- a/web/templates/pages/admin/table_definition/workspace.html +++ b/web/templates/pages/admin/table_definition/workspace.html @@ -151,6 +151,35 @@ {% endif %} +{% if !page.permission_object.is_empty() %} +
+

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

+

These grants cover this table family. A successful change retires the current session, so you will be asked to sign in again.

+ + + {% for role in page.role_permissions %} + + + {% endfor %} +
RoleActions
{{ role.role }}
{% for permission in role.actions %} + {% if permission.direct %} +
+ + +
+ {% else if permission.effective %} + {{ permission.action }} · inherited + {% else %} +
+ + +
+ {% endif %} + {% endfor %}
+
+
+{% endif %} + {% if page.table_is_writable() %} {# Append columns. The panel stages columns without writing anything; the diff --git a/web/templates/pages/import_export/export/export.html b/web/templates/pages/import_export/export/export.html index 0db147c3..bb1db9ab 100644 --- a/web/templates/pages/import_export/export/export.html +++ b/web/templates/pages/import_export/export/export.html @@ -27,7 +27,7 @@ {%- for profile in page.catalog.profiles %}{% for table in profile.tables %}{% endfor %}{% endfor -%}
- Cancel + Cancel
diff --git a/web/templates/pages/import_export/import/import.html b/web/templates/pages/import_export/import/import.html index 90c95ceb..3bf73f85 100644 --- a/web/templates/pages/import_export/import/import.html +++ b/web/templates/pages/import_export/import/import.html @@ -12,12 +12,7 @@ hx-disabled-elt="button[type=submit]">
+ + {% for profile in page.catalog.profiles %}{% endfor %} + {%- for profile in page.catalog.profiles %}{% for table in profile.tables %}{% endfor %}{% endfor -%} @@ -33,7 +31,7 @@ {%- if let Some(message) = page.error %}{% call alert::error("Could not import CSV", message) %}{% endcall %}{% endif -%}
- Cancel + Cancel
diff --git a/web/templates/pages/login/initial_password.html b/web/templates/pages/login/initial_password.html new file mode 100644 index 00000000..00b9a65f --- /dev/null +++ b/web/templates/pages/login/initial_password.html @@ -0,0 +1,20 @@ +{% extends "ui/base.html" %} + +{% block title %}Claim administrator{% endblock %} + +{% block content %} +
+ +
+{% endblock %} diff --git a/web/templates/pages/login/login.html b/web/templates/pages/login/login.html index beffd383..0506fe2a 100644 --- a/web/templates/pages/login/login.html +++ b/web/templates/pages/login/login.html @@ -8,10 +8,13 @@ diff --git a/web/templates/ui/navbar.html b/web/templates/ui/navbar.html index f96e1b3d..b2e1768d 100644 --- a/web/templates/ui/navbar.html +++ b/web/templates/ui/navbar.html @@ -22,8 +22,10 @@