Skip to main content

nexo_core/agent/admin_rpc/
capabilities.rs

1//! Admin RPC capability gates.
2//!
3//! Layered grant model:
4//! - **plugin.toml** declares `[capabilities.admin] required +
5//!   optional` — what the microapp NEEDS to function.
6//! - **extensions.yaml** `entries.<id>.capabilities_grant: [...]`
7//!   — what the operator ALLOWS that microapp to do.
8//!
9//! Boot diff:
10//! - Required missing → fail-fast boot error.
11//! - Optional missing → warn log; runtime `-32004` on call.
12//! - Orphan grant (granted but not declared) → warn log; allowed
13//!   for forward-compat (operator may pre-grant before plugin
14//!   updates).
15//!
16//! Runtime: `CapabilitySet::check(microapp_id, capability)` is a
17//! synchronous lock-free lookup invoked before each admin RPC
18//! handler dispatch.
19
20use std::collections::{HashMap, HashSet};
21use std::sync::Arc;
22
23/// Resolved per-microapp capability grants. Built once at boot
24/// from the [validate] diff and held read-only inside the
25/// dispatcher behind an `Arc`.
26#[derive(Debug, Clone, Default)]
27pub struct CapabilitySet {
28    granted: HashMap<String, HashSet<String>>,
29}
30
31impl CapabilitySet {
32    /// Build from a fully-validated `microapp_id → granted
33    /// capabilities` map. Production callers go through
34    /// [`validate_capabilities_at_boot`] which produces this map
35    /// alongside the boot report.
36    pub fn from_grants(granted: HashMap<String, HashSet<String>>) -> Arc<Self> {
37        Arc::new(Self { granted })
38    }
39
40    /// Empty set — no microapp has any capability. Useful for
41    /// tests + as a safe default before boot validation runs.
42    pub fn empty() -> Arc<Self> {
43        Arc::new(Self::default())
44    }
45
46    /// Lock-free check on the hot path. `false` means the runtime
47    /// must return `-32004 capability_not_granted`.
48    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    /// All capabilities granted to a microapp. Operator-facing
55    /// diagnostic; not used on the hot path.
56    pub fn granted_for(&self, microapp_id: &str) -> Option<&HashSet<String>> {
57        self.granted.get(microapp_id)
58    }
59}
60
61/// Boot-time diff between plugin manifests + operator grants.
62#[derive(Debug, Default)]
63pub struct CapabilityBootReport {
64    /// Fail-fast errors. Caller (boot supervisor) MUST treat any
65    /// non-empty `errors` as boot failure.
66    pub errors: Vec<CapabilityBootError>,
67    /// Operator-facing warnings. Caller logs at WARN level.
68    pub warns: Vec<CapabilityBootWarn>,
69    /// Resolved grants ready to feed into [`CapabilitySet::from_grants`].
70    pub grants: HashMap<String, HashSet<String>>,
71}
72
73/// Boot-fatal capability mismatch.
74#[non_exhaustive]
75#[derive(Debug, Clone, PartialEq)]
76pub enum CapabilityBootError {
77    /// `plugin.toml` lists capabilities under `required` that the
78    /// operator did not grant in `extensions.yaml`. Microapp cannot
79    /// run.
80    RequiredNotGranted {
81        /// Microapp identifier.
82        microapp_id: String,
83        /// Required capabilities the operator did not grant.
84        missing: Vec<String>,
85    },
86}
87
88/// Boot-time warning — operator-facing diagnostic, not fatal.
89#[non_exhaustive]
90#[derive(Debug, Clone, PartialEq)]
91pub enum CapabilityBootWarn {
92    /// `plugin.toml` lists optional capabilities the operator did
93    /// not grant. The microapp boots; runtime calls to those
94    /// capabilities return `-32004`.
95    OptionalNotGranted {
96        /// Microapp identifier.
97        microapp_id: String,
98        /// Optional capabilities the operator did not grant.
99        missing: Vec<String>,
100    },
101    /// Operator granted capabilities the plugin manifest does not
102    /// declare. Allowed (forward-compat for upgrades) but warned.
103    OrphanGrant {
104        /// Microapp identifier.
105        microapp_id: String,
106        /// Capabilities granted but not declared.
107        orphan: Vec<String>,
108    },
109}
110
111/// Boot-time validator. Diffs each microapp's `plugin.toml`
112/// declared admin capabilities against the operator's
113/// `extensions.yaml` grants and produces a [`CapabilityBootReport`].
114///
115/// Caller wires:
116/// 1. Read all discovered plugin manifests (already done by plugin
117///    discovery at boot).
118/// 2. Read `extensions.yaml.entries`.
119/// 3. Call this fn → `CapabilityBootReport`.
120/// 4. Treat `errors` as fail-fast. Log `warns` at WARN level.
121/// 5. Feed `grants` into `CapabilitySet::from_grants(...)`.
122pub 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/// Local mirror of [`nexo_plugin_manifest::AdminCapabilities`] —
177/// the capability check layer is in `nexo-core` and we don't want
178/// it to depend on the manifest crate (manifest depends on
179/// nothing core-side; keeping the inversion is healthier).
180/// Boot wiring fills this from the parsed manifest.
181#[derive(Debug, Clone, Default, PartialEq)]
182pub struct AdminCapabilityDecl {
183    /// Capabilities the microapp cannot run without.
184    pub required: Vec<String>,
185    /// Capabilities the microapp can run without.
186    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        // Just orphan warn, no missing.
258        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}