Files
komp_ac/web/src/authz.rs
2026-08-23 22:28:53 +02:00

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"));
}
}