use std::collections::{BTreeMap, BTreeSet};
use std::sync::{Mutex, OnceLock};
use async_trait::async_trait;
use uuid::Uuid;
pub const GOD_PERMISSION: &str = "*:*";
pub const AUTHORITY_ROLES: [&str; 2] = ["SUPERUSER", "ADMIN"];
pub const PERMISSION_REQUIRED: &str = "permission_required";
pub const GENERIC_ACTIONS: [&str; 6] = ["read", "create", "update", "delete", "restore", "purge"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PermissionRequired {
pub permission: String,
}
impl std::fmt::Display for PermissionRequired {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "the permission `{}` is required", self.permission)
}
}
impl std::error::Error for PermissionRequired {}
#[derive(Debug, Clone, Copy, Default)]
pub struct PermissionWaiver;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LegacySpelling {
Dotted,
ActionFirst,
Transition,
Plural,
ModuleGrain,
WriteAction,
}
impl LegacySpelling {
pub fn as_str(&self) -> &'static str {
match self {
Self::Dotted => "resource.action",
Self::ActionFirst => "action:module",
Self::Transition => "model:transition:verb",
Self::Plural => "plural resource",
Self::ModuleGrain => "module-wide grant",
Self::WriteAction => "resource:write",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Held {
pub resource: String,
pub action: String,
pub verbs_only: bool,
pub legacy: Option<LegacySpelling>,
}
impl Held {
pub fn canonical(&self) -> String {
if self.verbs_only && self.action == "*" {
return format!("{}:<each verb>", self.resource);
}
format!("{}:{}", self.resource, self.action)
}
}
fn is_name(s: &str) -> bool {
!s.is_empty()
&& s.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
}
fn is_half(s: &str) -> bool {
s == "*" || is_name(s)
}
pub fn read_held(raw: &str) -> Option<Held> {
let raw = raw.trim();
if raw == "*" {
return Some(Held {
resource: "*".into(),
action: "*".into(),
verbs_only: false,
legacy: None,
});
}
if !raw.contains(':') {
let (resource, action) = raw.split_once('.')?;
if is_half(resource) && is_half(action) {
return Some(Held {
resource: resource.into(),
action: action.into(),
verbs_only: false,
legacy: Some(LegacySpelling::Dotted),
});
}
return None;
}
let parts: Vec<&str> = raw.split(':').collect();
match parts.as_slice() {
[resource, "transition", verb] if is_half(resource) && is_half(verb) => Some(Held {
resource: (*resource).into(),
action: (*verb).into(),
verbs_only: true,
legacy: Some(LegacySpelling::Transition),
}),
["admin", "*"] => Some(Held {
resource: "*".into(),
action: "*".into(),
verbs_only: false,
legacy: Some(LegacySpelling::ActionFirst),
}),
["admin", module]
if is_name(module) && !GENERIC_ACTIONS.contains(module) && *module != "write" =>
{
Some(Held {
resource: (*module).into(),
action: "*".into(),
verbs_only: false,
legacy: Some(LegacySpelling::ActionFirst),
})
}
[action, module] if matches!(*action, "read" | "write" | "delete") && is_name(module) => {
Some(Held {
resource: (*module).into(),
action: (*action).into(),
verbs_only: false,
legacy: Some(LegacySpelling::ActionFirst),
})
}
[resource, action] if is_half(resource) && is_half(action) => Some(Held {
resource: (*resource).into(),
action: (*action).into(),
verbs_only: false,
legacy: None,
}),
_ => None,
}
}
pub fn normalise(raw: &str) -> Option<String> {
read_held(raw).map(|h| h.canonical())
}
pub fn plural_of(singular: &str) -> String {
let vowel = |c: char| matches!(c, 'a' | 'e' | 'i' | 'o' | 'u');
if let Some(stem) = singular.strip_suffix('y') {
if stem.chars().last().is_some_and(|c| !vowel(c)) {
return format!("{stem}ies");
}
}
if singular.ends_with('s')
|| singular.ends_with('x')
|| singular.ends_with("ch")
|| singular.ends_with("sh")
{
return format!("{singular}es");
}
format!("{singular}s")
}
pub fn match_held(held: &Held, required: &str) -> Option<Option<LegacySpelling>> {
let (rr, ra) = required.split_once(':')?;
if held.verbs_only && GENERIC_ACTIONS.contains(&ra) {
return None;
}
let write_alias =
held.action == "write" && ra != "write" && !matches!(ra, "read" | "delete" | "purge");
if !(held.action == "*" || held.action == ra || write_alias) {
return None;
}
let legacy = if write_alias {
held.legacy.or(Some(LegacySpelling::WriteAction))
} else {
held.legacy
};
if held.resource == "*" || held.resource == rr {
return Some(legacy);
}
if held.resource == plural_of(rr) {
return Some(legacy.or(Some(LegacySpelling::Plural)));
}
None
}
pub fn permission_matches(held: &str, required: &str) -> bool {
read_held(held).is_some_and(|h| match_held(&h, required).is_some())
}
static SEEN: OnceLock<Mutex<BTreeMap<String, String>>> = OnceLock::new();
const SEEN_CAP: usize = 4096;
pub fn note_legacy(held: &str, canonical: &str, spelling: LegacySpelling) {
let map = SEEN.get_or_init(|| Mutex::new(BTreeMap::new()));
let Ok(mut map) = map.lock() else { return };
if map.len() >= SEEN_CAP || map.contains_key(held) {
return;
}
map.insert(held.to_string(), canonical.to_string());
tracing::warn!(
target: "backbone::auth",
stored = held,
canonical,
spelling = spelling.as_str(),
"a legacy permission spelling granted access; rewrite the stored permission (see the permission migration guide)"
);
}
pub fn legacy_spellings_seen() -> Vec<(String, String)> {
SEEN.get()
.and_then(|m| {
m.lock()
.ok()
.map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
})
.unwrap_or_default()
}
pub fn check_held<'a>(
held: impl IntoIterator<Item = &'a String> + Clone,
required: &str,
alternates: &[&str],
) -> bool {
let mut legacy_hit: Option<(String, String, LegacySpelling)> = None;
for raw in held.clone() {
let Some(h) = read_held(raw) else { continue };
if let Some(how) = match_held(&h, required) {
match how {
None => return true,
Some(spelling) => {
legacy_hit.get_or_insert((raw.clone(), h.canonical(), spelling));
}
}
}
}
if legacy_hit.is_none() {
for alt in alternates {
for raw in held.clone() {
let Some(h) = read_held(raw) else { continue };
if match_held(&h, alt).is_some() {
legacy_hit.get_or_insert((
raw.clone(),
h.canonical(),
h.legacy.unwrap_or(LegacySpelling::ModuleGrain),
));
}
}
}
}
match legacy_hit {
Some((raw, canonical, spelling)) => {
note_legacy(&raw, &canonical, spelling);
true
}
None => false,
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Grants {
pub roles: BTreeSet<String>,
pub permissions: BTreeSet<String>,
}
impl Grants {
pub fn new<R, P>(roles: R, permissions: P) -> Self
where
R: IntoIterator,
R::Item: Into<String>,
P: IntoIterator,
P::Item: Into<String>,
{
Self {
roles: roles.into_iter().map(Into::into).collect(),
permissions: permissions.into_iter().map(Into::into).collect(),
}
}
pub fn is_authority(&self) -> bool {
AUTHORITY_ROLES.iter().any(|r| self.roles.contains(*r))
|| self.permissions.contains(GOD_PERMISSION)
}
pub fn effective(&self) -> Vec<String> {
let mut out = self.permissions.clone();
if self.is_authority() {
out.insert(GOD_PERMISSION.to_string());
}
out.into_iter().collect()
}
pub fn allows(&self, required: &str) -> bool {
self.is_authority() || check_held(self.permissions.iter(), required, &[])
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GrantError(pub String);
impl std::fmt::Display for GrantError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "grants could not be loaded: {}", self.0)
}
}
impl std::error::Error for GrantError {}
#[async_trait]
pub trait GrantSource: Send + Sync {
async fn load(&self, user: Uuid, unit: Option<Uuid>) -> Result<Grants, GrantError>;
#[cfg(feature = "axum")]
async fn load_for_request(
&self,
user: Uuid,
unit: Option<Uuid>,
_extensions: &axum::http::Extensions,
) -> Result<Grants, GrantError> {
self.load(user, unit).await
}
}
#[cfg(test)]
mod tests {
use super::*;
fn held(list: &[&str]) -> Vec<String> {
list.iter().map(|s| s.to_string()).collect()
}
fn allows(list: &[&str], required: &str) -> bool {
check_held(held(list).iter(), required, &[])
}
#[test]
fn a_held_permission_covers_exactly_its_resource_and_action() {
assert!(allows(&["ticket:read"], "ticket:read"));
assert!(!allows(&["ticket:read"], "ticket:update"));
assert!(!allows(&["ticket:read"], "project:read"));
assert!(!allows(&[], "ticket:read"));
assert!(
!allows(&["ticket"], "ticket:read"),
"no colon, no dot: no permission"
);
}
#[test]
fn wildcards_cover_a_half_or_everything() {
assert!(allows(&["ticket:*"], "ticket:resolve"));
assert!(!allows(&["ticket:*"], "project:read"));
assert!(allows(&["*:read"], "project:read"));
assert!(allows(&["*:read"], "ticket:read"));
assert!(!allows(&["*:read"], "ticket:update"));
assert!(allows(&["*:*"], "anything:at_all"));
assert!(allows(&["*"], "ticket:read"), "a bare star reads as *:*");
}
#[test]
fn a_dotted_permission_reads_as_resource_colon_action_with_a_signal() {
assert_eq!(normalise("buckets.read").as_deref(), Some("buckets:read"));
assert!(allows(&["ticket.read"], "ticket:read"));
assert!(allows(&["ticket.*"], "ticket:update"));
assert!(legacy_spellings_seen()
.iter()
.any(|(k, v)| k == "ticket.read" && v == "ticket:read"));
}
#[test]
fn an_action_first_permission_reads_as_module_colon_action() {
assert_eq!(normalise("write:payroll").as_deref(), Some("payroll:write"));
assert_eq!(normalise("admin:*").as_deref(), Some("*:*"));
assert_eq!(normalise("admin:payroll").as_deref(), Some("payroll:*"));
assert!(allows(&["admin:*"], "ticket:read"));
assert_eq!(normalise("admin:read").as_deref(), Some("admin:read"));
}
#[test]
fn a_legacy_transition_permission_covers_verbs_never_generic_actions() {
assert_eq!(normalise("ticket:transition:resolve").as_deref(), Some("ticket:resolve"));
assert_eq!(normalise("*:transition:*").as_deref(), Some("*:<each verb>"), "no single canonical form: never *:*");
assert!(allows(&["ticket:transition:resolve"], "ticket:resolve"));
assert!(allows(&["ticket:transition:*"], "ticket:resolve"));
assert!(allows(&["*:transition:*"], "ticket:resolve"));
assert!(
!allows(&["*:transition:*"], "ticket:read"),
"a verb grant must not open reads"
);
assert!(!allows(&["*:transition:*"], "ticket:update"));
assert!(!allows(&["*:transition:*"], "ticket:delete"));
assert!(!allows(&["project:transition:*"], "ticket:resolve"));
assert!(!allows(&["ticket:transition:resolve"], "ticket:archive"));
}
#[test]
fn a_plural_resource_still_reads_for_one_release() {
assert!(allows(&["tickets:read"], "ticket:read"));
assert!(allows(&["categories:read"], "category:read"));
assert!(allows(&["addresses:read"], "address:read"));
assert!(!allows(&["tickets:read"], "ticket:update"));
assert!(!allows(&["ticketss:read"], "ticket:read"));
}
#[test]
fn a_route_may_also_accept_the_module_grain_spelling() {
let list = held(&["payroll:read"]);
assert!(!check_held(list.iter(), "salary_slip:read", &[]));
assert!(check_held(
list.iter(),
"salary_slip:read",
&["payroll:read"]
));
let list = held(&["write:payroll"]);
assert!(check_held(
list.iter(),
"salary_slip:update",
&["payroll:write"]
));
assert!(!check_held(
list.iter(),
"salary_slip:update",
&["payroll:read"]
));
}
#[test]
fn the_composite_write_action_covers_mutations_and_verbs_only() {
assert!(allows(&["ticket:write"], "ticket:update"));
assert!(allows(&["ticket:write"], "ticket:create"));
assert!(allows(&["ticket:write"], "ticket:restore"));
assert!(allows(&["ticket:write"], "ticket:resolve"));
assert!(!allows(&["ticket:write"], "ticket:read"));
assert!(!allows(&["ticket:write"], "ticket:delete"));
assert!(!allows(&["ticket:write"], "ticket:purge"));
assert!(
allows(&["tickets:write"], "ticket:update"),
"collection grain reads too"
);
}
#[test]
fn junk_is_no_permission() {
for junk in [
"",
":",
"a:b:c",
"A:B",
"ticket:",
":read",
"a b:c",
"ticket.read.x",
] {
assert!(read_held(junk).is_none(), "{junk:?}");
assert!(!allows(&[junk], "ticket:read"), "{junk:?}");
}
}
#[test]
fn authority_is_a_role_by_name_or_the_god_permission_and_spells_as_a_star_pair() {
for g in [
Grants::new(["ADMIN"], Vec::<String>::new()),
Grants::new(["SUPERUSER"], Vec::<String>::new()),
Grants::new(["X"], ["*:*"]),
] {
assert!(g.is_authority());
assert!(g.allows("anything:at_all"));
assert_eq!(g.effective(), vec!["*:*".to_string()]);
}
let g = Grants::new(["CHART_ADMIN"], Vec::<String>::new());
assert!(!g.is_authority());
assert!(!g.allows("account:read"));
assert!(g.effective().is_empty());
}
#[test]
fn the_plural_follows_the_generators_rule() {
assert_eq!(plural_of("ticket"), "tickets");
assert_eq!(plural_of("category"), "categories");
assert_eq!(plural_of("day"), "days");
assert_eq!(plural_of("address"), "addresses");
assert_eq!(plural_of("box"), "boxes");
}
}