nexo_core/agent/admin_rpc/
capabilities.rs1use std::collections::{HashMap, HashSet};
21use std::sync::Arc;
22
23#[derive(Debug, Clone, Default)]
27pub struct CapabilitySet {
28 granted: HashMap<String, HashSet<String>>,
29}
30
31impl CapabilitySet {
32 pub fn from_grants(granted: HashMap<String, HashSet<String>>) -> Arc<Self> {
37 Arc::new(Self { granted })
38 }
39
40 pub fn empty() -> Arc<Self> {
43 Arc::new(Self::default())
44 }
45
46 pub fn check(&self, microapp_id: &str, capability: &str) -> bool {
49 self.granted
50 .get(microapp_id)
51 .is_some_and(|set| set.contains(capability))
52 }
53
54 pub fn granted_for(&self, microapp_id: &str) -> Option<&HashSet<String>> {
57 self.granted.get(microapp_id)
58 }
59}
60
61#[derive(Debug, Default)]
63pub struct CapabilityBootReport {
64 pub errors: Vec<CapabilityBootError>,
67 pub warns: Vec<CapabilityBootWarn>,
69 pub grants: HashMap<String, HashSet<String>>,
71}
72
73#[non_exhaustive]
75#[derive(Debug, Clone, PartialEq)]
76pub enum CapabilityBootError {
77 RequiredNotGranted {
81 microapp_id: String,
83 missing: Vec<String>,
85 },
86}
87
88#[non_exhaustive]
90#[derive(Debug, Clone, PartialEq)]
91pub enum CapabilityBootWarn {
92 OptionalNotGranted {
96 microapp_id: String,
98 missing: Vec<String>,
100 },
101 OrphanGrant {
104 microapp_id: String,
106 orphan: Vec<String>,
108 },
109}
110
111pub fn validate_capabilities_at_boot(
123 declarations: &[(String, AdminCapabilityDecl)],
124 grants: &HashMap<String, Vec<String>>,
125) -> CapabilityBootReport {
126 let mut report = CapabilityBootReport::default();
127
128 for (microapp_id, decl) in declarations {
129 let granted: HashSet<String> = grants
130 .get(microapp_id)
131 .cloned()
132 .unwrap_or_default()
133 .into_iter()
134 .collect();
135
136 let required: HashSet<String> = decl.required.iter().cloned().collect();
137 let optional: HashSet<String> = decl.optional.iter().cloned().collect();
138 let declared: HashSet<String> = required.union(&optional).cloned().collect();
139
140 let missing_required: Vec<String> = required.difference(&granted).cloned().collect();
141 if !missing_required.is_empty() {
142 let mut sorted = missing_required;
143 sorted.sort();
144 report.errors.push(CapabilityBootError::RequiredNotGranted {
145 microapp_id: microapp_id.clone(),
146 missing: sorted,
147 });
148 }
149
150 let missing_optional: Vec<String> = optional.difference(&granted).cloned().collect();
151 if !missing_optional.is_empty() {
152 let mut sorted = missing_optional;
153 sorted.sort();
154 report.warns.push(CapabilityBootWarn::OptionalNotGranted {
155 microapp_id: microapp_id.clone(),
156 missing: sorted,
157 });
158 }
159
160 let orphan: Vec<String> = granted.difference(&declared).cloned().collect();
161 if !orphan.is_empty() {
162 let mut sorted = orphan;
163 sorted.sort();
164 report.warns.push(CapabilityBootWarn::OrphanGrant {
165 microapp_id: microapp_id.clone(),
166 orphan: sorted,
167 });
168 }
169
170 report.grants.insert(microapp_id.clone(), granted);
171 }
172
173 report
174}
175
176#[derive(Debug, Clone, Default, PartialEq)]
182pub struct AdminCapabilityDecl {
183 pub required: Vec<String>,
185 pub optional: Vec<String>,
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192
193 fn decl(required: &[&str], optional: &[&str]) -> AdminCapabilityDecl {
194 AdminCapabilityDecl {
195 required: required.iter().map(|s| s.to_string()).collect(),
196 optional: optional.iter().map(|s| s.to_string()).collect(),
197 }
198 }
199
200 fn grant(items: &[&str]) -> Vec<String> {
201 items.iter().map(|s| s.to_string()).collect()
202 }
203
204 #[test]
205 fn required_missing_returns_boot_error() {
206 let decls = vec![(
207 "agent-creator".into(),
208 decl(&["agents_crud", "credentials_crud"], &[]),
209 )];
210 let mut grants_map = HashMap::new();
211 grants_map.insert("agent-creator".into(), grant(&["agents_crud"]));
212
213 let report = validate_capabilities_at_boot(&decls, &grants_map);
214 assert_eq!(report.errors.len(), 1);
215 match &report.errors[0] {
216 CapabilityBootError::RequiredNotGranted {
217 microapp_id,
218 missing,
219 } => {
220 assert_eq!(microapp_id, "agent-creator");
221 assert_eq!(missing, &vec!["credentials_crud".to_string()]);
222 }
223 }
224 }
225
226 #[test]
227 fn optional_missing_returns_warn_not_error() {
228 let decls = vec![(
229 "agent-creator".into(),
230 decl(&["agents_crud"], &["llm_keys_crud"]),
231 )];
232 let mut grants_map = HashMap::new();
233 grants_map.insert("agent-creator".into(), grant(&["agents_crud"]));
234
235 let report = validate_capabilities_at_boot(&decls, &grants_map);
236 assert!(report.errors.is_empty());
237 assert_eq!(report.warns.len(), 1);
238 match &report.warns[0] {
239 CapabilityBootWarn::OptionalNotGranted { missing, .. } => {
240 assert_eq!(missing, &vec!["llm_keys_crud".to_string()]);
241 }
242 other => panic!("expected OptionalNotGranted, got {other:?}"),
243 }
244 }
245
246 #[test]
247 fn orphan_grant_returns_warn() {
248 let decls = vec![("agent-creator".into(), decl(&["agents_crud"], &[]))];
249 let mut grants_map = HashMap::new();
250 grants_map.insert(
251 "agent-creator".into(),
252 grant(&["agents_crud", "future_capability"]),
253 );
254
255 let report = validate_capabilities_at_boot(&decls, &grants_map);
256 assert!(report.errors.is_empty());
257 assert_eq!(report.warns.len(), 1);
259 match &report.warns[0] {
260 CapabilityBootWarn::OrphanGrant { orphan, .. } => {
261 assert_eq!(orphan, &vec!["future_capability".to_string()]);
262 }
263 other => panic!("expected OrphanGrant, got {other:?}"),
264 }
265 }
266
267 #[test]
268 fn all_satisfied_no_errors_no_warns() {
269 let decls = vec![(
270 "agent-creator".into(),
271 decl(&["agents_crud"], &["llm_keys_crud"]),
272 )];
273 let mut grants_map = HashMap::new();
274 grants_map.insert(
275 "agent-creator".into(),
276 grant(&["agents_crud", "llm_keys_crud"]),
277 );
278
279 let report = validate_capabilities_at_boot(&decls, &grants_map);
280 assert!(report.errors.is_empty());
281 assert!(report.warns.is_empty());
282 }
283
284 #[test]
285 fn capability_set_check_lookup() {
286 let mut grants = HashMap::new();
287 grants.insert(
288 "agent-creator".to_string(),
289 HashSet::from(["agents_crud".to_string()]),
290 );
291 let set = CapabilitySet::from_grants(grants);
292 assert!(set.check("agent-creator", "agents_crud"));
293 assert!(!set.check("agent-creator", "credentials_crud"));
294 assert!(!set.check("unknown-app", "agents_crud"));
295 }
296
297 #[test]
298 fn capability_set_empty_denies_everything() {
299 let set = CapabilitySet::empty();
300 assert!(!set.check("any-app", "any_capability"));
301 }
302}