1use 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#[derive(Debug, Clone)]
20enum ActionSet {
21 All,
23 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
36type Grants = HashMap<String, HashMap<String, ActionSet>>;
39
40#[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 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 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 }
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 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
106async 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 if out.len() as u64 >= res.total || fetched < PER_PAGE {
127 break;
128 }
129 page += 1;
130 }
131 Ok(out)
132}
133
134fn 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 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}