182 lines
6.5 KiB
Rust
182 lines
6.5 KiB
Rust
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"));
|
|
}
|
|
}
|