Skip to main content

adminx_rbac/
authorizer.rs

1// adminx-rbac/src/authorizer.rs
2//
3// The DB-backed authorizer. Grants live in `adminx_permissions`, but the
4// authorization decision is on the request hot path (called several times per
5// page) and adminx-core's `Authorizer::can` is synchronous. So we load every
6// grant into an in-memory cache once (and on `reload`), and `can` answers from
7// the cache with no I/O — the storage async work is confined to `reload`.
8
9use std::collections::{HashMap, HashSet};
10use std::sync::{Arc, RwLock};
11
12use adminx_core::authz::{Action, Authorizer};
13use adminx_core::storage::{storage, QueryOptions, StorageError};
14use serde_json::Value;
15
16use crate::ability::MANAGE;
17
18/// Actions granted on one resource key for one role.
19#[derive(Debug, Clone)]
20enum ActionSet {
21    /// A `manage` grant — every action.
22    All,
23    /// A specific set of action tokens.
24    Only(HashSet<String>),
25}
26
27impl ActionSet {
28    fn allows(&self, action: &str) -> bool {
29        match self {
30            ActionSet::All => true,
31            ActionSet::Only(set) => set.contains(action),
32        }
33    }
34}
35
36/// role -> (resource-key -> actions). The resource key is a `base_path()` or the
37/// `"*"` wildcard.
38type Grants = HashMap<String, HashMap<String, ActionSet>>;
39
40/// The registered authorization backend. Cheap to clone — the cache is shared
41/// (`Arc`), so the instance handed to `set_authorizer` and the one kept for
42/// `reload` see the same data.
43#[derive(Clone, Default)]
44pub struct DbAuthorizer {
45    cache: Arc<RwLock<Grants>>,
46}
47
48impl DbAuthorizer {
49    pub fn new() -> Self {
50        Self::default()
51    }
52
53    /// Re-read every grant from `adminx_permissions` into the cache. Async (the
54    /// only place this backend touches storage); call at startup and after any
55    /// edit to the permission table.
56    pub async fn reload(&self) -> Result<(), StorageError> {
57        let rows = load_all("adminx_permissions").await?;
58        let mut grants: Grants = HashMap::new();
59        for row in &rows {
60            let (Some(role), Some(resource), Some(action)) = (
61                str_field(row, "role"),
62                str_field(row, "resource"),
63                str_field(row, "action"),
64            ) else {
65                tracing::warn!("adminx-rbac: skipping permission row missing a field: {row}");
66                continue;
67            };
68            // Treat "*" as a synonym for "manage" (any action).
69            let per_resource = grants.entry(role).or_default();
70            let entry = per_resource
71                .entry(resource)
72                .or_insert_with(|| ActionSet::Only(HashSet::new()));
73            if action == MANAGE || action == "*" {
74                *entry = ActionSet::All;
75            } else if let ActionSet::Only(set) = entry {
76                set.insert(action);
77            }
78            // (if the entry is already `All`, a specific grant adds nothing)
79        }
80        *self.cache.write().unwrap_or_else(|e| e.into_inner()) = grants;
81        tracing::info!(
82            "adminx-rbac: loaded {} grant row(s) across {} role(s)",
83            rows.len(),
84            self.cache.read().unwrap_or_else(|e| e.into_inner()).len()
85        );
86        Ok(())
87    }
88}
89
90impl Authorizer for DbAuthorizer {
91    fn can(&self, roles: &[String], resource: &str, action: &Action<'_>) -> bool {
92        let action = action.as_str();
93        let grants = self.cache.read().unwrap_or_else(|e| e.into_inner());
94        roles.iter().any(|role| {
95            let Some(per_resource) = grants.get(role) else {
96                return false;
97            };
98            // A grant on the exact resource or on the "*" wildcard both count.
99            let exact = per_resource.get(resource).is_some_and(|s| s.allows(action));
100            let wild = per_resource.get("*").is_some_and(|s| s.allows(action));
101            exact || wild
102        })
103    }
104}
105
106/// Read every row of `table` by paging through `Storage::list`. The permission
107/// table is tiny (tens–hundreds of rows), so a full scan into memory is the
108/// simplest correct load — `can` then never touches the database.
109async fn load_all(table: &str) -> Result<Vec<Value>, StorageError> {
110    const PER_PAGE: u64 = 500;
111    let mut out: Vec<Value> = Vec::new();
112    let mut page = 1u64;
113    loop {
114        let opts = QueryOptions {
115            page,
116            per_page: PER_PAGE,
117            sort_by: None,
118            sort_desc: false,
119            filters: Vec::new(),
120        };
121        let res = storage().list(table, &opts).await?;
122        let fetched = res.rows.len() as u64;
123        out.extend(res.rows);
124        // Stop when we've collected the reported total, or a short/empty page
125        // tells us there's no more (guards backends that under-report `total`).
126        if out.len() as u64 >= res.total || fetched < PER_PAGE {
127            break;
128        }
129        page += 1;
130    }
131    Ok(out)
132}
133
134/// Pull a non-empty string column out of a row (numbers/strings both handled).
135fn str_field(row: &Value, key: &str) -> Option<String> {
136    match row.get(key)? {
137        Value::String(s) if !s.is_empty() => Some(s.clone()),
138        Value::Number(n) => Some(n.to_string()),
139        _ => None,
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    fn set(items: &[(&str, &str, &str)]) -> DbAuthorizer {
148        // Build a cache directly, bypassing storage.
149        let mut grants: Grants = HashMap::new();
150        for (role, resource, action) in items {
151            let per = grants.entry(role.to_string()).or_default();
152            let entry = per
153                .entry(resource.to_string())
154                .or_insert_with(|| ActionSet::Only(HashSet::new()));
155            if *action == MANAGE || *action == "*" {
156                *entry = ActionSet::All;
157            } else if let ActionSet::Only(s) = entry {
158                s.insert(action.to_string());
159            }
160        }
161        DbAuthorizer {
162            cache: Arc::new(RwLock::new(grants)),
163        }
164    }
165
166    fn roles(rs: &[&str]) -> Vec<String> {
167        rs.iter().map(|s| s.to_string()).collect()
168    }
169
170    #[test]
171    fn manage_all_allows_everything() {
172        let a = set(&[("admin", "*", "manage")]);
173        let r = roles(&["admin"]);
174        for act in [
175            Action::List,
176            Action::Read,
177            Action::Create,
178            Action::Update,
179            Action::Delete,
180            Action::Export,
181            Action::Custom("publish"),
182        ] {
183            assert!(a.can(&r, "posts", &act), "admin should do {act:?} on posts");
184            assert!(a.can(&r, "anything", &act));
185        }
186    }
187
188    #[test]
189    fn scoped_grant_allows_only_that_tuple() {
190        let a = set(&[("editor", "posts", "update")]);
191        let r = roles(&["editor"]);
192        assert!(a.can(&r, "posts", &Action::Update));
193        assert!(!a.can(&r, "posts", &Action::Delete), "no delete grant");
194        assert!(!a.can(&r, "comments", &Action::Update), "wrong resource");
195        assert!(!a.can(&roles(&["viewer"]), "posts", &Action::Update), "unknown role");
196    }
197
198    #[test]
199    fn custom_action_is_granted_by_name() {
200        let a = set(&[("editor", "posts", "publish")]);
201        let r = roles(&["editor"]);
202        assert!(a.can(&r, "posts", &Action::Custom("publish")));
203        assert!(!a.can(&r, "posts", &Action::Custom("archive")));
204    }
205
206    #[test]
207    fn manage_on_one_resource_is_not_global() {
208        let a = set(&[("editor", "posts", "manage")]);
209        let r = roles(&["editor"]);
210        assert!(a.can(&r, "posts", &Action::Delete));
211        assert!(!a.can(&r, "users", &Action::Read), "manage is per-resource");
212    }
213
214    #[test]
215    fn resource_wildcard_grant() {
216        let a = set(&[("viewer", "*", "read")]);
217        let r = roles(&["viewer"]);
218        assert!(a.can(&r, "posts", &Action::Read));
219        assert!(a.can(&r, "users", &Action::Read));
220        assert!(!a.can(&r, "posts", &Action::Update), "read-only wildcard");
221    }
222
223    #[test]
224    fn star_action_normalizes_to_manage() {
225        let a = set(&[("admin", "posts", "*")]);
226        assert!(a.can(&roles(&["admin"]), "posts", &Action::Delete));
227    }
228
229    #[test]
230    fn union_across_roles() {
231        let a = set(&[("editor", "posts", "update"), ("viewer", "*", "read")]);
232        let both = roles(&["editor", "viewer"]);
233        assert!(a.can(&both, "posts", &Action::Update), "from editor");
234        assert!(a.can(&both, "users", &Action::Read), "from viewer");
235        assert!(!a.can(&both, "users", &Action::Delete), "neither grants this");
236    }
237
238    #[test]
239    fn empty_cache_denies() {
240        let a = DbAuthorizer::new();
241        assert!(!a.can(&roles(&["admin"]), "posts", &Action::Read));
242    }
243}