diff --git a/server b/server index 9ad620a7..227c4103 160000 --- a/server +++ b/server @@ -1 +1 @@ -Subproject commit 9ad620a72fe31291df4f0d538d33cd79c7f321e0 +Subproject commit 227c4103e96616a65de175f894cc4ca451fa52fa diff --git a/web/locales/cs/main.ftl b/web/locales/cs/main.ftl index 0983057a..6a2c4800 100644 --- a/web/locales/cs/main.ftl +++ b/web/locales/cs/main.ftl @@ -604,7 +604,7 @@ import-err-header-lengths = Řádek hlavičky tabulek a sloupců mají různou d import-err-row-width = Řádek dat CSV má jiný počet polí než hlavička. import-err-no-data-rows = CSV obsahuje hlavičky, ale žádné řádky dat. import-err-missing-type = Chybí typ sloupce '{ $column }' -import-err-permission = Vyžaduje se oprávnění k vkládání pro alespoň jednu čitelnou tabulku. +import-err-permission = Vyžaduje se oprávnění k importu. Tabulka navíc potřebuje oprávnění k vkládání, aby do ní šlo načítat. import-err-unterminated-quote = CSV obsahuje neuzavřenou uvozovkovou hodnotu. import-err-empty = CSV je prázdné. import-err-bad-boolean = Neplatná booleovská hodnota '{ $value }' @@ -627,7 +627,7 @@ export-err-tables-not-in-profile = Tabulka nepatří do vybraného rozsahu. export-err-missing-structure = Backend vynechal požadovanou strukturu tabulky. export-err-negative-count = Backend vrátil záporný počet řádků. export-err-range = Export přesahuje podporovaný rozsah pozic řádků. -export-err-permission = Vyžaduje se oprávnění ke čtení pro alespoň jednu tabulku. +export-err-permission = Vyžaduje se oprávnění k exportu. Tabulka navíc potřebuje oprávnění ke čtení, aby ji šlo stáhnout. # --- Přidat logiku ---------------------------------------------------------------- add-logic-title = Přidat logiku diff --git a/web/locales/en/main.ftl b/web/locales/en/main.ftl index 74d76c3c..4321f3c4 100644 --- a/web/locales/en/main.ftl +++ b/web/locales/en/main.ftl @@ -591,7 +591,7 @@ import-err-header-lengths = Table and column header rows have different lengths. import-err-row-width = A CSV data row has a different number of fields than the header. import-err-no-data-rows = CSV contains headers but no data rows. import-err-missing-type = Missing type for column '{ $column }' -import-err-permission = Insert permission is required for at least one readable table. +import-err-permission = Import permission is required. A table also needs insert permission before it can be loaded. import-err-unterminated-quote = CSV contains an unterminated quoted value. import-err-empty = CSV is empty. import-err-bad-boolean = Invalid boolean value '{ $value }' @@ -614,7 +614,7 @@ export-err-tables-not-in-profile = The table does not belong to the selected sco export-err-missing-structure = The backend omitted a requested table structure. export-err-negative-count = The backend returned a negative row count. export-err-range = The export exceeds the supported row-position range. -export-err-permission = Read permission is required for at least one table. +export-err-permission = Export permission is required. A table also needs read permission before it can be downloaded. # --- Add logic ------------------------------------------------------------- add-logic-title = Add logic diff --git a/web/locales/sk/main.ftl b/web/locales/sk/main.ftl index a97eaac8..79569fbe 100644 --- a/web/locales/sk/main.ftl +++ b/web/locales/sk/main.ftl @@ -604,7 +604,7 @@ import-err-header-lengths = Riadok hlavičky tabuliek a stĺpcov majú rôznu d import-err-row-width = Riadok údajov CSV má iný počet polí ako hlavička. import-err-no-data-rows = CSV obsahuje hlavičky, ale žiadne riadky údajov. import-err-missing-type = Chýba typ stĺpca '{ $column }' -import-err-permission = Vyžaduje sa oprávnenie na vkladanie pre aspoň jednu čitateľnú tabuľku. +import-err-permission = Vyžaduje sa oprávnenie na import. Tabuľka navyše potrebuje oprávnenie na vkladanie, aby sa do nej dalo načítať. import-err-unterminated-quote = CSV obsahuje neuzavretú úvodzovkovú hodnotu. import-err-empty = CSV je prázdny. import-err-bad-boolean = Neplatná boolovská hodnota '{ $value }' @@ -627,7 +627,7 @@ export-err-tables-not-in-profile = Tabuľka nepatrí do vybraného rozsahu. export-err-missing-structure = Backend vynechal požadovanú štruktúru tabuľky. export-err-negative-count = Backend vrátil záporný počet riadkov. export-err-range = Export presahuje podporovaný rozsah pozícií riadkov. -export-err-permission = Vyžaduje sa oprávnenie na čítanie pre aspoň jednu tabuľku. +export-err-permission = Vyžaduje sa oprávnenie na export. Tabuľka navyše potrebuje oprávnenie na čítanie, aby sa dala stiahnuť. # --- Pridať logiku ------------------------------------------------------------ add-logic-title = Pridať logiku diff --git a/web/src/authz.rs b/web/src/authz.rs index 7ef937e9..1de37cf7 100644 --- a/web/src/authz.rs +++ b/web/src/authz.rs @@ -14,8 +14,14 @@ pub(crate) const READ: &str = "read"; /// per-profile question. pub(crate) const ALL_ECB: &str = "ecb:*"; -/// Mirrors `server/src/auth/rbac/roles.rs`. -pub(crate) const SUPERADMIN: &str = "superadmin"; +/// The two bulk-transfer actions, mirroring +/// `server/src/auth/rbac/objects.rs`. Taking a whole table out as a file is +/// its own grant rather than something `read` implies, and loading one in is +/// its own grant rather than something `insert` implies — so a superadmin can +/// hand a role the row access without the bulk transfer, or the other way +/// round. +pub(crate) const EXPORT: &str = "export"; +pub(crate) const IMPORT: &str = "import"; pub(crate) fn permits(snapshot: &AuthorizationSnapshot, object: &str, action: &str) -> bool { permissions_permit(&snapshot.permissions, object, action) @@ -41,12 +47,15 @@ pub(crate) fn can_read_ecb(snapshot: &AuthorizationSnapshot) -> bool { permits(snapshot, ALL_ECB, READ) } -/// Whether the caller is the superadmin. Bulk data transfer — CSV import and -/// export — is theirs alone: it moves whole tables at once, which is the same -/// reach as the backup they already hold, and a data role's own grants say -/// nothing about whether it should have that. -pub(crate) fn is_superadmin(snapshot: &AuthorizationSnapshot) -> bool { - snapshot.role == SUPERADMIN +/// Whether the caller holds a transfer action on any table at all — what the +/// Import and Export links are shown for. Which tables those are is decided +/// per table by [`permits_table`], since a grant may name one table, one +/// profile, or everything. +pub(crate) fn can_transfer_anything(snapshot: &AuthorizationSnapshot, action: &str) -> bool { + snapshot + .permissions + .iter() + .any(|permission| permission.action == action && permission.object.starts_with("data:")) } pub(crate) fn can_open_admin(snapshot: &AuthorizationSnapshot) -> bool { diff --git a/web/src/pages/admin/admin/loader.rs b/web/src/pages/admin/admin/loader.rs index 5d2500f5..50e0657e 100644 --- a/web/src/pages/admin/admin/loader.rs +++ b/web/src/pages/admin/admin/loader.rs @@ -175,8 +175,8 @@ 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_import: crate::authz::is_superadmin(&authorization), - can_export: crate::authz::is_superadmin(&authorization), + can_import: crate::authz::can_transfer_anything(&authorization, crate::authz::IMPORT), + can_export: crate::authz::can_transfer_anything(&authorization, crate::authz::EXPORT), can_ecb: crate::authz::can_read_ecb(&authorization), }) } diff --git a/web/src/pages/import_export/common/loader.rs b/web/src/pages/import_export/common/loader.rs index af2ea853..e3a5f24a 100644 --- a/web/src/pages/import_export/common/loader.rs +++ b/web/src/pages/import_export/common/loader.rs @@ -25,10 +25,19 @@ pub(crate) struct Profile { pub tables: Vec, } +/// The tables one transfer page may offer. +/// +/// `transfer_action` is the page's own grant — `export` or `import`. +/// `row_action` is what the transfer does underneath, `read` or `insert`, and +/// it is checked as well: the backend authorizes the row calls the transfer +/// makes, so a table offered on the strength of the transfer grant alone would +/// be refused halfway through the download. A table appears here only when the +/// role holds both. pub(crate) async fn load_catalog( state: AppState, headers: &HeaderMap, - required_action: &str, + transfer_action: &str, + row_action: &str, ) -> Result { let request = authenticated_request(headers, GetAuthorizationRequest {}) .map_err(|_| LoadError::Unauthenticated)?; @@ -43,9 +52,13 @@ pub(crate) async fn load_catalog( .into_inner(); // Hiding the link is not the check: the route is refused here too, so a // typed URL gets the same answer as a missing button. - if !crate::authz::is_superadmin(&authorization) { + if !crate::authz::can_transfer_anything(&authorization, transfer_action) { return Err(LoadError::Forbidden); } + let permits = |profile: &str, table: &str| { + crate::authz::permits_table(&authorization, profile, table, transfer_action) + && crate::authz::permits_table(&authorization, profile, table, row_action) + }; let mut definitions = state.definitions; let tree = definitions .get_profile_tree(authenticated_request(headers, Empty {}).map_err(|_| LoadError::Unauthenticated)?) @@ -72,9 +85,7 @@ pub(crate) async fn load_catalog( ) .into_iter() .map(|table| table.name) - .filter(|table| { - crate::authz::permits_table(&authorization, GLOBAL_SCOPE, table, required_action) - }) + .filter(|table| permits(GLOBAL_SCOPE, table)) .collect::>(); let locale = Locale::from_headers(headers); @@ -91,14 +102,7 @@ pub(crate) async fn load_catalog( .tables .into_iter() .filter(|table| !table.global) - .filter(|table| { - crate::authz::permits_table( - &authorization, - &profile.name, - &table.name, - required_action, - ) - }) + .filter(|table| permits(&profile.name, &table.name)) .map(|table| table.name) .collect::>(); (!tables.is_empty()).then_some(Profile { @@ -108,15 +112,15 @@ pub(crate) async fn load_catalog( }) })); - // 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. A wildcard-only - // insert role has no table name to offer, so its scope stays empty and the - // backend remains the authoritative permission check. + // A role that may import but not read 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. A wildcard-only grant has no table name to offer, so its scope + // stays empty and the backend remains the authoritative permission check. for permission in authorization .permissions .iter() - .filter(|permission| permission.action == required_action) + .filter(|permission| permission.action == transfer_action) { let Some(target) = permission.object.strip_prefix("data:") else { continue; @@ -127,6 +131,9 @@ pub(crate) async fn load_catalog( if profile_name == "*" || table_name == "*" { continue; } + if !permits(profile_name, table_name) { + continue; + } if let Some(profile) = profiles .iter_mut() .find(|profile| profile.name == profile_name) diff --git a/web/src/pages/import_export/export/loader.rs b/web/src/pages/import_export/export/loader.rs index 1852840d..88c01b63 100644 --- a/web/src/pages/import_export/export/loader.rs +++ b/web/src/pages/import_export/export/loader.rs @@ -8,7 +8,7 @@ pub(crate) async fn load_page( state: AppState, headers: &HeaderMap, ) -> Result { - let catalog = load_catalog(state, headers, "read").await?; + let catalog = load_catalog(state, headers, crate::authz::EXPORT, "read").await?; Ok(ExportPageState { nav: crate::ui::Nav::from_authorization(headers, "", &catalog.authorization), catalog, diff --git a/web/src/pages/import_export/import/loader.rs b/web/src/pages/import_export/import/loader.rs index cc17184b..7bb599c8 100644 --- a/web/src/pages/import_export/import/loader.rs +++ b/web/src/pages/import_export/import/loader.rs @@ -13,7 +13,7 @@ pub(crate) async fn load_page( form: ImportForm, error: Option, ) -> Result { - let catalog = load_catalog(state, headers, "insert").await?; + let catalog = load_catalog(state, headers, crate::authz::IMPORT, "insert").await?; Ok(ImportPageState { nav: crate::ui::Nav::from_authorization(headers, "", &catalog.authorization), catalog, diff --git a/web/src/pages/permissions/grants/state.rs b/web/src/pages/permissions/grants/state.rs index 801d76e1..300dea95 100644 --- a/web/src/pages/permissions/grants/state.rs +++ b/web/src/pages/permissions/grants/state.rs @@ -4,7 +4,14 @@ use crate::{ }; /// The columns of every grant matrix, in the order the server lists them. -pub(crate) const ACTIONS: [&str; 4] = ["read", "insert", "update", "delete"]; +pub(crate) const ACTIONS: [&str; 6] = [ + "read", + "insert", + "update", + "delete", + "export", + "import", +]; #[derive(Clone, Debug, Default, serde::Deserialize)] pub(crate) struct Selection { @@ -81,7 +88,7 @@ pub(crate) struct Cell { } impl GrantsPage { - pub(crate) fn actions(&self) -> [&'static str; 4] { + pub(crate) fn actions(&self) -> [&'static str; ACTIONS.len()] { ACTIONS } @@ -250,6 +257,8 @@ mod tests { "data:acme/*|insert", "data:acme/*|update", "data:acme/*|delete", + "data:acme/*|export", + "data:acme/*|import", "ecb:acme|read", ] ); diff --git a/web/src/pages/permissions/roles/state.rs b/web/src/pages/permissions/roles/state.rs index 63643787..11d7673c 100644 --- a/web/src/pages/permissions/roles/state.rs +++ b/web/src/pages/permissions/roles/state.rs @@ -91,6 +91,10 @@ pub(crate) fn starter_grants( grants.push((object, action)); } } + // Bulk transfer applies to tables, not to journals, which the + // transfer pages never offer. + grants.push(("data:*", "export")); + grants.push(("data:*", "import")); Ok(grants) } other => Err(tr!( @@ -122,7 +126,9 @@ mod tests { } assert_eq!(starter_grants(locale, "read").unwrap().len(), 3); - assert_eq!(starter_grants(locale, "full").unwrap().len(), 9); + // Four row actions on two wildcard objects, ECB read, and the two + // transfer actions, which apply to tables alone. + assert_eq!(starter_grants(locale, "full").unwrap().len(), 11); } #[test] diff --git a/web/src/ui/mod.rs b/web/src/ui/mod.rs index e54fabd8..cab9d5eb 100644 --- a/web/src/ui/mod.rs +++ b/web/src/ui/mod.rs @@ -154,10 +154,10 @@ impl Nav { // panel: managing either roles or users is enough to open it. nav.can_permissions = crate::authz::can_manage(authorization, crate::authz::STRUCT_ROLE) || crate::authz::can_manage(authorization, crate::authz::STRUCT_USER); - // Both transfer pages are the superadmin's — see - // `crate::authz::is_superadmin`. - nav.can_import = crate::authz::is_superadmin(authorization); - nav.can_export = nav.can_import; + nav.can_import = + crate::authz::can_transfer_anything(authorization, crate::authz::IMPORT); + nav.can_export = + crate::authz::can_transfer_anything(authorization, crate::authz::EXPORT); nav.can_ecb = crate::authz::can_read_ecb(authorization); nav }