1use std::collections::{BTreeMap, BTreeSet};
29use std::sync::{Mutex, OnceLock};
30
31use async_trait::async_trait;
32use uuid::Uuid;
33
34pub const GOD_PERMISSION: &str = "*:*";
36
37pub const AUTHORITY_ROLES: [&str; 2] = ["SUPERUSER", "ADMIN"];
39
40pub const PERMISSION_REQUIRED: &str = "permission_required";
42
43pub const GENERIC_ACTIONS: [&str; 6] = ["read", "create", "update", "delete", "restore", "purge"];
46
47#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct PermissionRequired {
50 pub permission: String,
52}
53
54impl std::fmt::Display for PermissionRequired {
55 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56 write!(f, "the permission `{}` is required", self.permission)
57 }
58}
59
60impl std::error::Error for PermissionRequired {}
61
62#[derive(Debug, Clone, Copy, Default)]
68pub struct PermissionWaiver;
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum LegacySpelling {
73 Dotted,
75 ActionFirst,
77 Transition,
79 Plural,
81 ModuleGrain,
83 WriteAction,
85}
86
87impl LegacySpelling {
88 pub fn as_str(&self) -> &'static str {
90 match self {
91 Self::Dotted => "resource.action",
92 Self::ActionFirst => "action:module",
93 Self::Transition => "model:transition:verb",
94 Self::Plural => "plural resource",
95 Self::ModuleGrain => "module-wide grant",
96 Self::WriteAction => "resource:write",
97 }
98 }
99}
100
101#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct Held {
104 pub resource: String,
106 pub action: String,
108 pub verbs_only: bool,
110 pub legacy: Option<LegacySpelling>,
112}
113
114impl Held {
115 pub fn canonical(&self) -> String {
119 if self.verbs_only && self.action == "*" {
120 return format!("{}:<each verb>", self.resource);
121 }
122 format!("{}:{}", self.resource, self.action)
123 }
124}
125
126fn is_name(s: &str) -> bool {
127 !s.is_empty()
128 && s.bytes()
129 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
130}
131
132fn is_half(s: &str) -> bool {
133 s == "*" || is_name(s)
134}
135
136pub fn read_held(raw: &str) -> Option<Held> {
152 let raw = raw.trim();
153 if raw == "*" {
154 return Some(Held {
155 resource: "*".into(),
156 action: "*".into(),
157 verbs_only: false,
158 legacy: None,
159 });
160 }
161 if !raw.contains(':') {
162 let (resource, action) = raw.split_once('.')?;
163 if is_half(resource) && is_half(action) {
164 return Some(Held {
165 resource: resource.into(),
166 action: action.into(),
167 verbs_only: false,
168 legacy: Some(LegacySpelling::Dotted),
169 });
170 }
171 return None;
172 }
173 let parts: Vec<&str> = raw.split(':').collect();
174 match parts.as_slice() {
175 [resource, "transition", verb] if is_half(resource) && is_half(verb) => Some(Held {
176 resource: (*resource).into(),
177 action: (*verb).into(),
178 verbs_only: true,
179 legacy: Some(LegacySpelling::Transition),
180 }),
181 ["admin", "*"] => Some(Held {
182 resource: "*".into(),
183 action: "*".into(),
184 verbs_only: false,
185 legacy: Some(LegacySpelling::ActionFirst),
186 }),
187 ["admin", module]
188 if is_name(module) && !GENERIC_ACTIONS.contains(module) && *module != "write" =>
189 {
190 Some(Held {
191 resource: (*module).into(),
192 action: "*".into(),
193 verbs_only: false,
194 legacy: Some(LegacySpelling::ActionFirst),
195 })
196 }
197 [action, module] if matches!(*action, "read" | "write" | "delete") && is_name(module) => {
198 Some(Held {
199 resource: (*module).into(),
200 action: (*action).into(),
201 verbs_only: false,
202 legacy: Some(LegacySpelling::ActionFirst),
203 })
204 }
205 [resource, action] if is_half(resource) && is_half(action) => Some(Held {
206 resource: (*resource).into(),
207 action: (*action).into(),
208 verbs_only: false,
209 legacy: None,
210 }),
211 _ => None,
212 }
213}
214
215pub fn normalise(raw: &str) -> Option<String> {
217 read_held(raw).map(|h| h.canonical())
218}
219
220pub fn plural_of(singular: &str) -> String {
223 let vowel = |c: char| matches!(c, 'a' | 'e' | 'i' | 'o' | 'u');
224 if let Some(stem) = singular.strip_suffix('y') {
225 if stem.chars().last().is_some_and(|c| !vowel(c)) {
226 return format!("{stem}ies");
227 }
228 }
229 if singular.ends_with('s')
230 || singular.ends_with('x')
231 || singular.ends_with("ch")
232 || singular.ends_with("sh")
233 {
234 return format!("{singular}es");
235 }
236 format!("{singular}s")
237}
238
239pub fn match_held(held: &Held, required: &str) -> Option<Option<LegacySpelling>> {
243 let (rr, ra) = required.split_once(':')?;
244 if held.verbs_only && GENERIC_ACTIONS.contains(&ra) {
245 return None;
246 }
247 let write_alias =
248 held.action == "write" && ra != "write" && !matches!(ra, "read" | "delete" | "purge");
249 if !(held.action == "*" || held.action == ra || write_alias) {
250 return None;
251 }
252 let legacy = if write_alias {
253 held.legacy.or(Some(LegacySpelling::WriteAction))
254 } else {
255 held.legacy
256 };
257 if held.resource == "*" || held.resource == rr {
258 return Some(legacy);
259 }
260 if held.resource == plural_of(rr) {
262 return Some(legacy.or(Some(LegacySpelling::Plural)));
263 }
264 None
265}
266
267pub fn permission_matches(held: &str, required: &str) -> bool {
269 read_held(held).is_some_and(|h| match_held(&h, required).is_some())
270}
271
272static SEEN: OnceLock<Mutex<BTreeMap<String, String>>> = OnceLock::new();
274const SEEN_CAP: usize = 4096;
275
276pub fn note_legacy(held: &str, canonical: &str, spelling: LegacySpelling) {
279 let map = SEEN.get_or_init(|| Mutex::new(BTreeMap::new()));
280 let Ok(mut map) = map.lock() else { return };
281 if map.len() >= SEEN_CAP || map.contains_key(held) {
282 return;
283 }
284 map.insert(held.to_string(), canonical.to_string());
285 tracing::warn!(
286 target: "backbone::auth",
287 stored = held,
288 canonical,
289 spelling = spelling.as_str(),
290 "a legacy permission spelling granted access; rewrite the stored permission (see the permission migration guide)"
291 );
292}
293
294pub fn legacy_spellings_seen() -> Vec<(String, String)> {
297 SEEN.get()
298 .and_then(|m| {
299 m.lock()
300 .ok()
301 .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
302 })
303 .unwrap_or_default()
304}
305
306pub fn check_held<'a>(
310 held: impl IntoIterator<Item = &'a String> + Clone,
311 required: &str,
312 alternates: &[&str],
313) -> bool {
314 let mut legacy_hit: Option<(String, String, LegacySpelling)> = None;
315 for raw in held.clone() {
316 let Some(h) = read_held(raw) else { continue };
317 if let Some(how) = match_held(&h, required) {
318 match how {
319 None => return true,
320 Some(spelling) => {
321 legacy_hit.get_or_insert((raw.clone(), h.canonical(), spelling));
322 }
323 }
324 }
325 }
326 if legacy_hit.is_none() {
327 for alt in alternates {
328 for raw in held.clone() {
329 let Some(h) = read_held(raw) else { continue };
330 if match_held(&h, alt).is_some() {
331 legacy_hit.get_or_insert((
332 raw.clone(),
333 h.canonical(),
334 h.legacy.unwrap_or(LegacySpelling::ModuleGrain),
335 ));
336 }
337 }
338 }
339 }
340 match legacy_hit {
341 Some((raw, canonical, spelling)) => {
342 note_legacy(&raw, &canonical, spelling);
343 true
344 }
345 None => false,
346 }
347}
348
349#[derive(Debug, Clone, Default, PartialEq, Eq)]
351pub struct Grants {
352 pub roles: BTreeSet<String>,
354 pub permissions: BTreeSet<String>,
356}
357
358impl Grants {
359 pub fn new<R, P>(roles: R, permissions: P) -> Self
361 where
362 R: IntoIterator,
363 R::Item: Into<String>,
364 P: IntoIterator,
365 P::Item: Into<String>,
366 {
367 Self {
368 roles: roles.into_iter().map(Into::into).collect(),
369 permissions: permissions.into_iter().map(Into::into).collect(),
370 }
371 }
372
373 pub fn is_authority(&self) -> bool {
375 AUTHORITY_ROLES.iter().any(|r| self.roles.contains(*r))
376 || self.permissions.contains(GOD_PERMISSION)
377 }
378
379 pub fn effective(&self) -> Vec<String> {
382 let mut out = self.permissions.clone();
383 if self.is_authority() {
384 out.insert(GOD_PERMISSION.to_string());
385 }
386 out.into_iter().collect()
387 }
388
389 pub fn allows(&self, required: &str) -> bool {
391 self.is_authority() || check_held(self.permissions.iter(), required, &[])
392 }
393}
394
395#[derive(Debug, Clone, PartialEq, Eq)]
398pub struct GrantError(pub String);
399
400impl std::fmt::Display for GrantError {
401 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
402 write!(f, "grants could not be loaded: {}", self.0)
403 }
404}
405
406impl std::error::Error for GrantError {}
407
408#[async_trait]
415pub trait GrantSource: Send + Sync {
416 async fn load(&self, user: Uuid, unit: Option<Uuid>) -> Result<Grants, GrantError>;
421
422 #[cfg(feature = "axum")]
425 async fn load_for_request(
426 &self,
427 user: Uuid,
428 unit: Option<Uuid>,
429 _extensions: &axum::http::Extensions,
430 ) -> Result<Grants, GrantError> {
431 self.load(user, unit).await
432 }
433}
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438
439 fn held(list: &[&str]) -> Vec<String> {
440 list.iter().map(|s| s.to_string()).collect()
441 }
442
443 fn allows(list: &[&str], required: &str) -> bool {
444 check_held(held(list).iter(), required, &[])
445 }
446
447 #[test]
448 fn a_held_permission_covers_exactly_its_resource_and_action() {
449 assert!(allows(&["ticket:read"], "ticket:read"));
450 assert!(!allows(&["ticket:read"], "ticket:update"));
451 assert!(!allows(&["ticket:read"], "project:read"));
452 assert!(!allows(&[], "ticket:read"));
453 assert!(
454 !allows(&["ticket"], "ticket:read"),
455 "no colon, no dot: no permission"
456 );
457 }
458
459 #[test]
460 fn wildcards_cover_a_half_or_everything() {
461 assert!(allows(&["ticket:*"], "ticket:resolve"));
462 assert!(!allows(&["ticket:*"], "project:read"));
463 assert!(allows(&["*:read"], "project:read"));
464 assert!(allows(&["*:read"], "ticket:read"));
465 assert!(!allows(&["*:read"], "ticket:update"));
466 assert!(allows(&["*:*"], "anything:at_all"));
467 assert!(allows(&["*"], "ticket:read"), "a bare star reads as *:*");
468 }
469
470 #[test]
471 fn a_dotted_permission_reads_as_resource_colon_action_with_a_signal() {
472 assert_eq!(normalise("buckets.read").as_deref(), Some("buckets:read"));
473 assert!(allows(&["ticket.read"], "ticket:read"));
474 assert!(allows(&["ticket.*"], "ticket:update"));
475 assert!(legacy_spellings_seen()
476 .iter()
477 .any(|(k, v)| k == "ticket.read" && v == "ticket:read"));
478 }
479
480 #[test]
481 fn an_action_first_permission_reads_as_module_colon_action() {
482 assert_eq!(normalise("write:payroll").as_deref(), Some("payroll:write"));
483 assert_eq!(normalise("admin:*").as_deref(), Some("*:*"));
484 assert_eq!(normalise("admin:payroll").as_deref(), Some("payroll:*"));
485 assert!(allows(&["admin:*"], "ticket:read"));
486 assert_eq!(normalise("admin:read").as_deref(), Some("admin:read"));
488 }
489
490 #[test]
491 fn a_legacy_transition_permission_covers_verbs_never_generic_actions() {
492 assert_eq!(normalise("ticket:transition:resolve").as_deref(), Some("ticket:resolve"));
493 assert_eq!(normalise("*:transition:*").as_deref(), Some("*:<each verb>"), "no single canonical form: never *:*");
494 assert!(allows(&["ticket:transition:resolve"], "ticket:resolve"));
495 assert!(allows(&["ticket:transition:*"], "ticket:resolve"));
496 assert!(allows(&["*:transition:*"], "ticket:resolve"));
497 assert!(
498 !allows(&["*:transition:*"], "ticket:read"),
499 "a verb grant must not open reads"
500 );
501 assert!(!allows(&["*:transition:*"], "ticket:update"));
502 assert!(!allows(&["*:transition:*"], "ticket:delete"));
503 assert!(!allows(&["project:transition:*"], "ticket:resolve"));
504 assert!(!allows(&["ticket:transition:resolve"], "ticket:archive"));
505 }
506
507 #[test]
508 fn a_plural_resource_still_reads_for_one_release() {
509 assert!(allows(&["tickets:read"], "ticket:read"));
510 assert!(allows(&["categories:read"], "category:read"));
511 assert!(allows(&["addresses:read"], "address:read"));
512 assert!(!allows(&["tickets:read"], "ticket:update"));
513 assert!(!allows(&["ticketss:read"], "ticket:read"));
514 }
515
516 #[test]
517 fn a_route_may_also_accept_the_module_grain_spelling() {
518 let list = held(&["payroll:read"]);
519 assert!(!check_held(list.iter(), "salary_slip:read", &[]));
520 assert!(check_held(
521 list.iter(),
522 "salary_slip:read",
523 &["payroll:read"]
524 ));
525 let list = held(&["write:payroll"]);
526 assert!(check_held(
527 list.iter(),
528 "salary_slip:update",
529 &["payroll:write"]
530 ));
531 assert!(!check_held(
532 list.iter(),
533 "salary_slip:update",
534 &["payroll:read"]
535 ));
536 }
537
538 #[test]
539 fn the_composite_write_action_covers_mutations_and_verbs_only() {
540 assert!(allows(&["ticket:write"], "ticket:update"));
541 assert!(allows(&["ticket:write"], "ticket:create"));
542 assert!(allows(&["ticket:write"], "ticket:restore"));
543 assert!(allows(&["ticket:write"], "ticket:resolve"));
544 assert!(!allows(&["ticket:write"], "ticket:read"));
545 assert!(!allows(&["ticket:write"], "ticket:delete"));
546 assert!(!allows(&["ticket:write"], "ticket:purge"));
547 assert!(
548 allows(&["tickets:write"], "ticket:update"),
549 "collection grain reads too"
550 );
551 }
552
553 #[test]
554 fn junk_is_no_permission() {
555 for junk in [
556 "",
557 ":",
558 "a:b:c",
559 "A:B",
560 "ticket:",
561 ":read",
562 "a b:c",
563 "ticket.read.x",
564 ] {
565 assert!(read_held(junk).is_none(), "{junk:?}");
566 assert!(!allows(&[junk], "ticket:read"), "{junk:?}");
567 }
568 }
569
570 #[test]
571 fn authority_is_a_role_by_name_or_the_god_permission_and_spells_as_a_star_pair() {
572 for g in [
573 Grants::new(["ADMIN"], Vec::<String>::new()),
574 Grants::new(["SUPERUSER"], Vec::<String>::new()),
575 Grants::new(["X"], ["*:*"]),
576 ] {
577 assert!(g.is_authority());
578 assert!(g.allows("anything:at_all"));
579 assert_eq!(g.effective(), vec!["*:*".to_string()]);
580 }
581 let g = Grants::new(["CHART_ADMIN"], Vec::<String>::new());
582 assert!(!g.is_authority());
583 assert!(!g.allows("account:read"));
584 assert!(g.effective().is_empty());
585 }
586
587 #[test]
588 fn the_plural_follows_the_generators_rule() {
589 assert_eq!(plural_of("ticket"), "tickets");
590 assert_eq!(plural_of("category"), "categories");
591 assert_eq!(plural_of("day"), "days");
592 assert_eq!(plural_of("address"), "addresses");
593 assert_eq!(plural_of("box"), "boxes");
594 }
595}