Skip to main content

backbone_auth/
grants.rs

1//! Permissions as data: the canonical name, the matching rules, the grants a person holds and the
2//! source they are read from.
3//!
4//! A schema declares the permission *vocabulary* and what each route requires; who holds which
5//! permission is data in the identity module's tables. This module is the framework half of that
6//! split. It knows nothing of entities or tables:
7//!
8//! - **Canonical name.** `<resource>:<action>`, the resource an entity in snake_case
9//!   (`payroll_entry`), the action `read`, `create`, `update`, `delete`, `restore`, `purge` or the
10//!   name of a transition or custom verb (`payroll_entry:process`). Either half may be `*` in a
11//!   *held* permission: `payroll_entry:*`, `*:read`, `*:*`.
12//! - **Matching.** [`permission_matches`] decides whether one held permission covers one required
13//!   one. [`AuthContext::require`](crate::middleware::AuthContext::require) applies it to
14//!   `AuthContext.permissions` and refuses with [`PermissionRequired`].
15//! - **Legacy spellings.** Three older spellings still read, and each use raises a deprecation
16//!   signal ([`legacy_spellings_seen`], and a `tracing` warning once per spelling): `resource.action`
17//!   (`buckets.read`), `<action>:<module>` (`write:payroll`) and the three-segment transition name
18//!   `<model>:transition:<verb>` (`*:transition:*` covers a verb, never a generic read or write).
19//! - **Where grants come from.** A [`GrantSource`] loads a person's [`Grants`]. With none installed,
20//!   `AuthContext.permissions` is whatever the host's authentication put there, and `require`
21//!   checks exactly that: nothing is ever allowed by default. Installing a resolver (see
22//!   [`crate::resolver`], feature `axum`) replaces the source, once per request.
23//!
24//! `ADMIN` and `SUPERUSER` hold everything by name, and that authority is spelled in
25//! [`Grants::effective`] as `*:*`, so a resolver-written `AuthContext` carries it as an ordinary
26//! permission. `require` itself never grants by role name.
27
28use std::collections::{BTreeMap, BTreeSet};
29use std::sync::{Mutex, OnceLock};
30
31use async_trait::async_trait;
32use uuid::Uuid;
33
34/// The permission that matches everything.
35pub const GOD_PERMISSION: &str = "*:*";
36
37/// Role names that hold every permission by name.
38pub const AUTHORITY_ROLES: [&str; 2] = ["SUPERUSER", "ADMIN"];
39
40/// The violation code a refused permission answers with.
41pub const PERMISSION_REQUIRED: &str = "permission_required";
42
43/// The actions every entity's generic routes use. A legacy transition permission
44/// (`<model>:transition:<verb>`) never covers one of these.
45pub const GENERIC_ACTIONS: [&str; 6] = ["read", "create", "update", "delete", "restore", "purge"];
46
47/// The caller lacks a permission a route requires.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct PermissionRequired {
50    /// The canonical permission the route requires.
51    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/// Marks a request an outer gate has already admitted on a narrower rule than a permission (a
63/// self-service lane serving a caller their own rows). A route's permission layer lets it pass.
64///
65/// Only code that can write request extensions can attach it, which is code the host owns: a
66/// client cannot. Attach it only after the narrower rule has decided for *this* request.
67#[derive(Debug, Clone, Copy, Default)]
68pub struct PermissionWaiver;
69
70/// How a held permission was spelled, when it is not the canonical `<resource>:<action>`.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum LegacySpelling {
73    /// `resource.action`
74    Dotted,
75    /// `<action>:<module>`, `admin:*` or `admin:<module>`
76    ActionFirst,
77    /// `<model>:transition:<verb>`
78    Transition,
79    /// A plural resource name (`buckets:read` for `bucket:read`)
80    Plural,
81    /// A module-wide grant (`payroll:read` for an entity of the payroll module)
82    ModuleGrain,
83    /// The composite action `write` (`ticket:write`), which covered every mutation but a delete
84    WriteAction,
85}
86
87impl LegacySpelling {
88    /// A stable name for logs and counters.
89    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/// A held permission read into its parts.
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct Held {
104    /// The resource half (`*` for any).
105    pub resource: String,
106    /// The action half (`*` for any).
107    pub action: String,
108    /// For a legacy transition name, the permission covers only verbs, never generic actions.
109    pub verbs_only: bool,
110    /// How it was spelled when that is not canonical.
111    pub legacy: Option<LegacySpelling>,
112}
113
114impl Held {
115    /// The canonical spelling. A legacy "every verb" grant (`*:transition:*`) has no single one: it
116    /// reads `<resource>:<each verb>`, because a canonical `*` would also open generic reads and
117    /// writes, which it never did.
118    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
136/// Read a held permission string. `None` for a string that is no permission in any spelling (it
137/// then matches nothing).
138///
139/// | Spelling | Reads as |
140/// |---|---|
141/// | `payroll_entry:process`, `payroll_entry:*`, `*:read`, `*:*` | itself |
142/// | `*` | `*:*` |
143/// | `buckets.read` | `buckets:read` (legacy) |
144/// | `*:transition:*`, `ticket:transition:resolve` | verbs of any or that model, never generic actions (legacy) |
145/// | `admin:*` | `*:*` (legacy) |
146/// | `admin:payroll` | `payroll:*` (legacy) |
147/// | `write:payroll`, `read:payroll`, `delete:payroll` | `payroll:write` etc. (legacy) |
148///
149/// The action `write` is the composite the older vocabulary used: it covers `create`, `update`,
150/// `restore` and every transition or verb, never `read`, `delete` or `purge` (see [`match_held`]).
151pub 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
215/// The canonical spelling of a stored permission, or `None` when it is no permission.
216pub fn normalise(raw: &str) -> Option<String> {
217    read_held(raw).map(|h| h.canonical())
218}
219
220/// The plural the schema generator gives a snake_case entity name: `ies` for a final `y` after a
221/// consonant, `es` after `s`, `x`, `ch` or `sh`, else `s`.
222pub 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
239/// Whether `held` covers `required` (a canonical `<resource>:<action>`), and how the grant was
240/// spelled: `Some(None)` for a canonical match, `Some(Some(spelling))` for a legacy one, `None` for
241/// no match.
242pub 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    // A plural resource name still reads for one release.
261    if held.resource == plural_of(rr) {
262        return Some(legacy.or(Some(LegacySpelling::Plural)));
263    }
264    None
265}
266
267/// Whether the one held permission string covers `required`.
268pub fn permission_matches(held: &str, required: &str) -> bool {
269    read_held(held).is_some_and(|h| match_held(&h, required).is_some())
270}
271
272/// Every spelling a legacy reader accepted, keyed by the stored string, with its canonical form.
273static SEEN: OnceLock<Mutex<BTreeMap<String, String>>> = OnceLock::new();
274const SEEN_CAP: usize = 4096;
275
276/// Raise the deprecation signal for a legacy spelling that granted access: a `tracing` warning, once
277/// per stored string, and a record [`legacy_spellings_seen`] reports.
278pub 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
294/// The legacy permission strings that have granted access in this process, with the canonical form
295/// each should be rewritten to.
296pub 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
306/// Check `required` (and the `alternates` a route also accepts, themselves legacy) against the held
307/// permission strings. A canonical match wins over a legacy one so a person holding both raises no
308/// signal.
309pub 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/// The roles and permissions one person holds, in one tenant.
350#[derive(Debug, Clone, Default, PartialEq, Eq)]
351pub struct Grants {
352    /// Role names held.
353    pub roles: BTreeSet<String>,
354    /// Permission strings held, as stored.
355    pub permissions: BTreeSet<String>,
356}
357
358impl Grants {
359    /// Grants from role names and permission strings.
360    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    /// Holds everything: an authority role by name, or the god permission.
374    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    /// The permission list written into `AuthContext.permissions` and published to a client: what is
380    /// held, with authority spelled as `*:*`, because that is what it means to every check.
381    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    /// Whether the holder may do what `required` (a canonical `<resource>:<action>`) says.
390    pub fn allows(&self, required: &str) -> bool {
391        self.is_authority() || check_held(self.permissions.iter(), required, &[])
392    }
393}
394
395/// A grant source could not answer. A caller of it must treat that as "no access", never as "no
396/// grants held, carry on".
397#[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/// Where a person's grants are read from: the identity module's tables in a composed service, a
409/// fixed table in a test.
410///
411/// Install one with [`crate::resolver::GrantResolver`] (feature `axum`). A service that installs
412/// none keeps whatever `AuthContext.permissions` its authentication produced; no source means no
413/// grants, never every grant.
414#[async_trait]
415pub trait GrantSource: Send + Sync {
416    /// The grants of `user` while acting in `unit` (the org unit or company the session acts at).
417    ///
418    /// Run on the request's bound connection when the host binds one, which is what lets fenced
419    /// membership rows be read. An error means the store could not be read: the request is refused.
420    async fn load(&self, user: Uuid, unit: Option<Uuid>) -> Result<Grants, GrantError>;
421
422    /// [`load`](Self::load) with the request's extensions, for a source that reads the tenant's own
423    /// pool off them. Defaults to `load`.
424    #[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        // `admin:<action>` is an entity named admin, not a module grant.
487        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}