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;
15use std::sync::Once;
16
17/// The operation being authorized. Borrows so `Custom` carries a handler's
18/// `&str` name without allocating; it's `Copy`, and the lifetime elides to
19/// `Action<'_>` at every call site.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Action<'a> {
22 List,
23 Read,
24 Create,
25 Update,
26 Delete,
27 Export,
28 /// A custom action, identified by its `CustomAction::name`.
29 Custom(&'a str),
30}
31
32impl<'a> Action<'a> {
33 /// The token an authorizer compares against (and a DB backend stores). Custom
34 /// actions serialize to their literal name, so a `publish` action is the
35 /// grant `"publish"`.
36 pub fn as_str(&self) -> &'a str {
37 match self {
38 Action::List => "list",
39 Action::Read => "read",
40 Action::Create => "create",
41 Action::Update => "update",
42 Action::Delete => "delete",
43 Action::Export => "export",
44 Action::Custom(name) => name,
45 }
46 }
47}
48
49/// A pluggable authorization policy. `can` is synchronous and called several
50/// times per request, so an implementation must not perform I/O here — a
51/// DB-backed one reads a pre-loaded in-memory cache.
52pub trait Authorizer: Send + Sync {
53 /// Whether a principal holding `roles` may perform `action` on `resource`
54 /// (the resource's `base_path()`).
55 fn can(&self, roles: &[String], resource: &str, action: &Action<'_>) -> bool;
56}
57
58static AUTHORIZER: OnceCell<Box<dyn Authorizer>> = OnceCell::new();
59
60/// Register the global authorization policy. Set-once: a later call is ignored
61/// with a warning, matching `set_storage`.
62pub fn set_authorizer(authorizer: Box<dyn Authorizer>) {
63 if AUTHORIZER.set(authorizer).is_err() {
64 tracing::warn!("adminx authorizer already initialized; ignoring reset");
65 }
66}
67
68/// The registered authorizer, if any. `None` means the built-in role-list check
69/// is used.
70pub fn authorizer() -> Option<&'static dyn Authorizer> {
71 AUTHORIZER.get().map(|b| b.as_ref())
72}
73
74/// The one place the access decision is made, so the invariants live together:
75///
76/// 1. Auth unconfigured ⇒ allow (the panel is public while prototyping).
77/// 2. A password-verified but MFA-pending session never passes.
78///
79/// Then: consult the registered [`Authorizer`] if there is one, otherwise fall
80/// back to the historical `allowed_roles ∩ ctx.roles()` intersection — so with
81/// no authorizer registered this is byte-for-byte the old `is_authorized`.
82/// Warn exactly once — not per request — that the panel is wide open.
83static UNCONFIGURED_WARNED: Once = Once::new();
84
85pub fn authorize(
86 ctx: &ReqCtx,
87 allowed_roles: &[String],
88 resource: &str,
89 action: Action<'_>,
90) -> bool {
91 if !crate::auth::is_configured() {
92 // A real request reached an access check with auth off: the whole panel
93 // (and API) is public. Fine while prototyping, dangerous in production —
94 // say so loudly, once, so a forgotten `configure_auth` can't hide.
95 UNCONFIGURED_WARNED.call_once(|| {
96 tracing::warn!(
97 "adminx: authentication is NOT configured — every page and API route is \
98 PUBLIC and RBAC is bypassed. Call `configure_auth(..)` (and seed an admin) \
99 to secure the panel. This warning is shown once."
100 );
101 });
102 return true;
103 }
104 if crate::auth::mfa_pending(ctx) {
105 return false;
106 }
107 let roles = ctx.roles();
108 match authorizer() {
109 Some(a) => a.can(&roles, resource, &action),
110 None => allowed_roles.iter().any(|r| roles.contains(r)),
111 }
112}