Skip to main content

adminx_core/
authz.rs

1// adminx-core/src/authz.rs
2//
3// The authorization seam. Today's role check (a static per-resource role list)
4// is one strategy; this module lets a crate like `adminx-rbac` plug in a richer
5// one without adminx-core depending on it — the same pattern `storage` uses for
6// pluggable backends.
7//
8// The action being performed is threaded in from each `Resource` method, so a
9// backend can decide per operation ("editor may update but not delete") rather
10// than per resource. With no backend registered, `authorize` falls back to the
11// original role-list intersection, so behaviour is unchanged until a crate opts in.
12
13use crate::request::ReqCtx;
14use once_cell::sync::OnceCell;
15
16/// The operation being authorized. Borrows so `Custom` carries a handler's
17/// `&str` name without allocating; it's `Copy`, and the lifetime elides to
18/// `Action<'_>` at every call site.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum Action<'a> {
21    List,
22    Read,
23    Create,
24    Update,
25    Delete,
26    Export,
27    /// A custom action, identified by its `CustomAction::name`.
28    Custom(&'a str),
29}
30
31impl<'a> Action<'a> {
32    /// The token an authorizer compares against (and a DB backend stores). Custom
33    /// actions serialize to their literal name, so a `publish` action is the
34    /// grant `"publish"`.
35    pub fn as_str(&self) -> &'a str {
36        match self {
37            Action::List => "list",
38            Action::Read => "read",
39            Action::Create => "create",
40            Action::Update => "update",
41            Action::Delete => "delete",
42            Action::Export => "export",
43            Action::Custom(name) => name,
44        }
45    }
46}
47
48/// A pluggable authorization policy. `can` is synchronous and called several
49/// times per request, so an implementation must not perform I/O here — a
50/// DB-backed one reads a pre-loaded in-memory cache.
51pub trait Authorizer: Send + Sync {
52    /// Whether a principal holding `roles` may perform `action` on `resource`
53    /// (the resource's `base_path()`).
54    fn can(&self, roles: &[String], resource: &str, action: &Action<'_>) -> bool;
55}
56
57static AUTHORIZER: OnceCell<Box<dyn Authorizer>> = OnceCell::new();
58
59/// Register the global authorization policy. Set-once: a later call is ignored
60/// with a warning, matching `set_storage`.
61pub fn set_authorizer(authorizer: Box<dyn Authorizer>) {
62    if AUTHORIZER.set(authorizer).is_err() {
63        tracing::warn!("adminx authorizer already initialized; ignoring reset");
64    }
65}
66
67/// The registered authorizer, if any. `None` means the built-in role-list check
68/// is used.
69pub fn authorizer() -> Option<&'static dyn Authorizer> {
70    AUTHORIZER.get().map(|b| b.as_ref())
71}
72
73/// The one place the access decision is made, so the invariants live together:
74///
75/// 1. Auth unconfigured ⇒ allow (the panel is public while prototyping).
76/// 2. A password-verified but MFA-pending session never passes.
77///
78/// Then: consult the registered [`Authorizer`] if there is one, otherwise fall
79/// back to the historical `allowed_roles ∩ ctx.roles()` intersection — so with
80/// no authorizer registered this is byte-for-byte the old `is_authorized`.
81pub fn authorize(
82    ctx: &ReqCtx,
83    allowed_roles: &[String],
84    resource: &str,
85    action: Action<'_>,
86) -> bool {
87    if !crate::auth::is_configured() {
88        return true;
89    }
90    if crate::auth::mfa_pending(ctx) {
91        return false;
92    }
93    let roles = ctx.roles();
94    match authorizer() {
95        Some(a) => a.can(&roles, resource, &action),
96        None => allowed_roles.iter().any(|r| roles.contains(r)),
97    }
98}