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}