use crate::rbac::permission::Permission;
pub fn is_opaque_permission_path(s: &str) -> bool {
!s.is_empty()
&& s.chars().next().is_some_and(|c| c.is_ascii_lowercase())
&& s.split('.').all(|seg| {
!seg.is_empty()
&& seg
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
})
}
pub fn scope_tier(scope: &str) -> u8 {
match scope {
"user" => 0,
"group" => 1,
_ => 2,
}
}
pub fn fold_tiered(decision: &mut Option<(u8, bool)>, tier: u8, granted: bool) {
match *decision {
Some((t, _)) if t < tier => {}
Some((t, v)) if t == tier => {
if v {
*decision = Some((tier, granted));
}
}
_ => {
*decision = Some((tier, granted));
}
}
}
pub fn expand_grant_permission(raw: &str) -> Vec<String> {
if Permission::from_path(raw).is_some() {
return vec![raw.to_string()];
}
let expanded: Vec<String> = Permission::expand_domain(raw)
.iter()
.map(|p| p.name().to_string())
.collect();
if !expanded.is_empty() {
return expanded;
}
if is_opaque_permission_path(raw) {
return vec![raw.to_string()];
}
Vec::new()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GrantRow {
pub scope: String,
pub permission: String,
pub granted: bool,
pub resource_id: Option<String>,
}
impl GrantRow {
pub fn flat(scope: &str, permission: &str, granted: bool) -> Self {
Self {
scope: scope.to_string(),
permission: permission.to_string(),
granted,
resource_id: None,
}
}
}
pub fn resolve_grant_rows(defaults: &[String], grants: &[GrantRow]) -> Vec<String> {
let mut decisions: std::collections::HashMap<String, Option<(u8, bool)>> = defaults
.iter()
.map(|p| (p.clone(), Some((scope_tier("group"), true))))
.collect();
for grant in grants {
if grant.resource_id.is_some() {
continue;
}
let tier = scope_tier(&grant.scope);
for perm in expand_grant_permission(&grant.permission) {
let mut decision = decisions.remove(&perm).flatten();
if decision.is_none() {
decision = Some((tier, grant.granted));
} else {
fold_tiered(&mut decision, tier, grant.granted);
}
if let Some(d) = decision {
decisions.insert(perm, Some(d));
}
}
}
let mut effective: Vec<String> = decisions
.into_iter()
.filter(|(_, verdict)| verdict.is_some_and(|(_, granted)| granted))
.map(|(perm, _)| perm)
.collect();
effective.sort();
effective
}
#[cfg(test)]
mod tests {
use super::*;
fn grant(scope: &str, permission: &str, granted: bool) -> GrantRow {
GrantRow::flat(scope, permission, granted)
}
#[test]
fn defaults_are_group_tier_so_a_global_deny_cannot_revoke_them() {
let defaults = vec!["panel.read".to_string()];
let out = resolve_grant_rows(&defaults, &[grant("global", "panel.read", false)]);
assert_eq!(out, vec!["panel.read"]);
let out = resolve_grant_rows(&defaults, &[grant("user", "panel.read", false)]);
assert!(out.is_empty());
}
#[test]
fn unseen_permissions_are_decided_by_their_first_row_at_any_scope() {
let out = resolve_grant_rows(&[], &[grant("global", "usage.read.self", true)]);
assert_eq!(out, vec!["usage.read.self"]);
let out = resolve_grant_rows(
&[],
&[
grant("user", "usage.read.self", false),
grant("global", "usage.read.self", true),
],
);
assert!(out.is_empty());
}
#[test]
fn resource_pinned_rows_never_widen_the_flat_set() {
let mut pinned = grant("user", "panel.read", true);
pinned.resource_id = Some("platform-42".to_string());
assert!(resolve_grant_rows(&[], &[pinned]).is_empty());
}
#[test]
fn full_matrix_matches_both_services_branches() {
let defaults = vec!["base.perm".to_string()];
let rows = vec![
grant("global", "base.perm", false), grant("group", "extra.one", true), grant("user", "extra.one", false), grant("group", "extra.two", true), grant("global", "extra.two", false), grant("user", "base.perm", true), ];
let out = resolve_grant_rows(&defaults, &rows);
assert_eq!(out, vec!["base.perm", "extra.two"]);
}
#[test]
fn opaque_paths_accept_runtime_ids_and_reject_uppercase() {
assert!(is_opaque_permission_path("usage.read.self"));
assert!(is_opaque_permission_path(
"platform.550e8400-e29b-41d4-a716.access"
));
assert!(!is_opaque_permission_path("Usage.read"));
assert!(!is_opaque_permission_path("a..b"));
assert!(!is_opaque_permission_path(""));
}
#[test]
fn user_tier_beats_group_beats_global_regardless_of_row_order() {
let mut forward: Option<(u8, bool)> = None;
fold_tiered(&mut forward, scope_tier("global"), true);
fold_tiered(&mut forward, scope_tier("group"), false);
fold_tiered(&mut forward, scope_tier("user"), true);
assert_eq!(forward, Some((0, true)));
let mut reverse: Option<(u8, bool)> = None;
fold_tiered(&mut reverse, scope_tier("user"), true);
fold_tiered(&mut reverse, scope_tier("group"), false);
fold_tiered(&mut reverse, scope_tier("global"), false);
assert_eq!(reverse, Some((0, true)), "row order must not matter");
}
#[test]
fn deny_is_sticky_within_one_tier() {
let mut d: Option<(u8, bool)> = None;
fold_tiered(&mut d, scope_tier("group"), true);
fold_tiered(&mut d, scope_tier("group"), false);
assert_eq!(d, Some((1, false)));
fold_tiered(&mut d, scope_tier("group"), true);
assert_eq!(d, Some((1, false)));
fold_tiered(&mut d, scope_tier("user"), true);
assert_eq!(d, Some((0, true)));
}
#[test]
fn expansion_covers_typed_domain_and_opaque_forms() {
assert_eq!(expand_grant_permission("rbac.read"), vec!["rbac.read"]);
let expanded = expand_grant_permission("rbac");
assert!(!expanded.is_empty());
assert!(expanded.iter().all(|p| p.starts_with("rbac.")));
assert_eq!(
expand_grant_permission("usage.read.self"),
vec!["usage.read.self"]
);
assert!(expand_grant_permission("NOT A PATH").is_empty());
}
}