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) const READ: &str = "read"; /// Every exchange-rate object. Provider pipeline health is a global rather /// than per-profile concern. pub(crate) const ALL_EXCHANGE_RATES: &str = "exchange-rates:*"; /// 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. `import` is the whole authorization for the bulk endpoint: a role /// that holds it may load a file without holding `insert`. 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) } 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) } /// Whether the caller may see the reference-rate pipeline. Mirrors the /// server's exchange-rate authorization check. pub(crate) fn can_read_exchange_rates(snapshot: &AuthorizationSnapshot) -> bool { permits(snapshot, ALL_EXCHANGE_RATES, READ) } /// 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 { [ STRUCT_PROFILE, STRUCT_TABLE, STRUCT_SCRIPT, STRUCT_VALIDATION, STRUCT_ROLE, STRUCT_USER, ] .into_iter() .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}") } 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 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")); } /// The transfer links are shown on the grant and nothing else. A role /// invented this morning holds them exactly as superadmin does, and a role /// with every structural area but no transfer grant sees neither link. #[test] fn the_transfer_links_follow_the_grant_not_the_role_name() { let warehouse = AuthorizationSnapshot { role: "warehouse-night-shift".to_string(), ..snapshot(&[("data:acme/stock", IMPORT)]) }; assert!(can_transfer_anything(&warehouse, IMPORT)); assert!(!can_transfer_anything(&warehouse, EXPORT)); assert!(permits_table(&warehouse, "acme", "stock", IMPORT)); assert!(!permits_table(&warehouse, "acme", "invoice", IMPORT)); let structural = snapshot(&[(STRUCT_TABLE, MANAGE), (STRUCT_ROLE, MANAGE)]); assert!(!can_transfer_anything(&structural, IMPORT)); assert!(!can_transfer_anything(&structural, EXPORT)); } #[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")); } }