use chrono::{DateTime, Utc};
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>,
pub expires_at: Option<DateTime<Utc>>,
}
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,
expires_at: None,
}
}
#[must_use]
pub fn with_expiry(mut self, at: DateTime<Utc>) -> Self {
self.expires_at = Some(at);
self
}
}
fn is_visible_at(row: &GrantRow, now: DateTime<Utc>) -> bool {
match row.expires_at {
None => true,
Some(deadline) => now < deadline,
}
}
pub fn resolve_grant_rows_at(
defaults: &[String],
grants: &[GrantRow],
now: DateTime<Utc>,
) -> 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() || !is_visible_at(grant, now) {
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
}
pub fn resolve_grant_rows(defaults: &[String], grants: &[GrantRow]) -> Vec<String> {
resolve_grant_rows_at(defaults, grants, Utc::now())
}
#[cfg(test)]
mod tests {
use chrono::{Duration, TimeZone};
use super::*;
fn t0() -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 3, 1, 12, 0, 0).unwrap()
}
fn at(offset_secs: i64) -> DateTime<Utc> {
t0() + Duration::seconds(offset_secs)
}
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());
}
#[test]
fn an_expired_grant_row_is_invisible_and_fail_closed_at_the_boundary() {
let rows = vec![grant("user", "usage.read.self", true).with_expiry(at(60))];
assert_eq!(
resolve_grant_rows_at(&[], &rows, at(59)),
vec!["usage.read.self"]
);
assert!(resolve_grant_rows_at(&[], &rows, at(60)).is_empty());
assert!(resolve_grant_rows_at(&[], &rows, at(61)).is_empty());
}
#[test]
fn an_expired_grant_row_leaves_only_the_defaults() {
let defaults = vec!["base.perm".to_string()];
let rows = vec![grant("user", "extra.one", true).with_expiry(at(60))];
assert_eq!(
resolve_grant_rows_at(&defaults, &rows, at(59)),
vec!["base.perm", "extra.one"]
);
assert_eq!(
resolve_grant_rows_at(&defaults, &rows, at(60)),
vec!["base.perm"]
);
}
#[test]
fn an_expired_deny_row_stops_suppressing() {
let defaults = vec!["panel.read".to_string()];
let rows = vec![grant("user", "panel.read", false).with_expiry(at(60))];
assert!(resolve_grant_rows_at(&defaults, &rows, at(59)).is_empty());
assert_eq!(
resolve_grant_rows_at(&defaults, &rows, at(60)),
vec!["panel.read"]
);
let rows = vec![
grant("user", "usage.read.self", false).with_expiry(at(60)),
grant("group", "usage.read.self", true),
];
assert!(resolve_grant_rows_at(&[], &rows, at(59)).is_empty());
assert_eq!(
resolve_grant_rows_at(&[], &rows, at(60)),
vec!["usage.read.self"]
);
}
#[test]
fn a_permanent_grant_survives_an_expired_deny_of_the_same_permission() {
let rows = vec![
grant("user", "panel.read", false).with_expiry(at(60)),
grant("user", "panel.read", true),
];
assert!(resolve_grant_rows_at(&[], &rows, at(59)).is_empty());
assert_eq!(
resolve_grant_rows_at(&[], &rows, at(60)),
vec!["panel.read"]
);
}
#[test]
fn rows_without_a_deadline_are_permanent_at_any_instant() {
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),
];
let expected = vec!["base.perm".to_string(), "extra.two".to_string()];
let century = 86_400 * 365 * 100;
for now in [at(-century), at(0), at(century)] {
assert_eq!(resolve_grant_rows_at(&defaults, &rows, now), expected);
}
assert_eq!(resolve_grant_rows(&defaults, &rows), expected);
assert!(rows.iter().all(|row| row.expires_at.is_none()));
}
#[test]
fn the_wall_clock_wrapper_honours_deadlines() {
let century = 86_400 * 365 * 100;
let live = grant("user", "usage.read.self", true).with_expiry(at(century));
assert_eq!(resolve_grant_rows(&[], &[live]), vec!["usage.read.self"]);
let dead = grant("user", "usage.read.self", true).with_expiry(at(-century));
assert!(resolve_grant_rows(&[], &[dead]).is_empty());
}
#[test]
fn with_expiry_sets_the_deadline_and_keeps_the_other_fields() {
let deadline = at(3600);
let row = grant("group", "panel.read", true).with_expiry(deadline);
assert_eq!(row.expires_at, Some(deadline));
assert_eq!(row.scope, "group");
assert_eq!(row.permission, "panel.read");
assert!(row.granted);
assert!(row.resource_id.is_none());
let row = row.with_expiry(at(7200));
assert_eq!(row.expires_at, Some(at(7200)));
let mut pinned = grant("user", "panel.read", true).with_expiry(at(7200));
pinned.resource_id = Some("platform-42".to_string());
assert!(resolve_grant_rows_at(&[], &[pinned], at(0)).is_empty());
}
#[test]
fn an_expired_grant_falls_back_to_a_surviving_row_at_a_lower_tier() {
let dead = grant("user", "panel.read", true).with_expiry(at(-1));
let live = grant("group", "panel.read", true);
assert_eq!(
resolve_grant_rows_at(&[], &[dead.clone(), live], at(0)),
vec!["panel.read"]
);
let wider_deny = grant("group", "panel.read", false);
assert!(resolve_grant_rows_at(&[], &[dead, wider_deny], at(0)).is_empty());
}
#[test]
fn an_unknown_scope_falls_to_the_global_tier() {
let defaults = vec!["base.perm".to_string()];
let unknown = grant("typo-scope", "base.perm", false);
assert_eq!(
resolve_grant_rows_at(&defaults, &[unknown], at(0)),
vec!["base.perm"]
);
let user = grant("user", "base.perm", false);
assert!(resolve_grant_rows_at(&defaults, &[user], at(0)).is_empty());
assert_eq!(
resolve_grant_rows_at(&[], &[grant("typo-scope", "new.perm", true)], at(0)),
vec!["new.perm"]
);
}
#[test]
fn an_expired_domain_row_hides_every_expanded_leaf() {
let live = grant("user", "plugin", true);
let expanded = resolve_grant_rows_at(&[], &[live], at(0));
assert!(expanded.contains(&"plugin.read".to_string()));
assert!(expanded.contains(&"plugin.manage".to_string()));
let dead = grant("user", "plugin", true).with_expiry(at(-1));
assert!(resolve_grant_rows_at(&[], &[dead], at(0)).is_empty());
}
#[test]
fn an_expired_resource_pinned_row_stays_invisible() {
let mut pinned = grant("user", "panel.read", false).with_expiry(at(-1));
pinned.resource_id = Some("platform-42".to_string());
let live = grant("user", "panel.read", true);
assert_eq!(
resolve_grant_rows_at(&[], &[pinned, live], at(0)),
vec!["panel.read"]
);
}
}