uni_plugin/capability.rs
1//! Plugin capabilities — declared in manifest, granted at load time.
2//!
3//! A `Capability` is the unit of permission in the plugin framework. Every
4//! extension surface (`Capability::ScalarFn`, `Capability::Storage`, …) is
5//! gated by a capability; every host import that exposes powerful primitives
6//! (network, filesystem, secrets, host-side query) is gated by an attenuated
7//! capability (`Capability::Network { allow }`).
8//!
9//! Enforcement happens in three layers:
10//!
11//! 1. **Registrar gate** — `PluginRegistrar::scalar_fn` etc. check the
12//! effective capability set before accepting a registration.
13//! 2. **WIT linker** — for WASM plugins, host imports for capability-gated
14//! functions are linked into the wasmtime `Linker` only when the
15//! corresponding capability is granted. Ungranted host functions are
16//! not present in the plugin's imports table.
17//! 3. **Runtime pattern checks** — capability grants with patterns
18//! (`Filesystem { read: vec!["/data/**"] }`) validate the actual call
19//! arguments against the pattern before dispatching.
20
21use std::collections::BTreeSet;
22
23use serde::{Deserialize, Serialize};
24use smol_str::SmolStr;
25
26/// A single permission grant.
27///
28/// `Capability` is the leaf node of the permission model. A
29/// [`CapabilitySet`] is a collection of capabilities.
30#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
31#[serde(tag = "kind", rename_all = "kebab-case")]
32#[non_exhaustive]
33pub enum Capability {
34 // ---- Host import surfaces (capability-gated host functions) ----
35 /// HTTP / TCP egress; allow-list of URI patterns.
36 Network {
37 /// Glob patterns of permitted URIs (`https://api.example/**`). Defaults
38 /// to empty (deny-all) so a bare `"network"` declaration grants no
39 /// egress until patterns are specified.
40 #[serde(default)]
41 allow: Vec<SmolStr>,
42 },
43 /// Filesystem read / write access with per-direction path patterns.
44 Filesystem {
45 /// Glob patterns of readable paths (empty = deny-all).
46 #[serde(default)]
47 read: Vec<SmolStr>,
48 /// Glob patterns of writable paths (empty = deny-all).
49 #[serde(default)]
50 write: Vec<SmolStr>,
51 },
52 /// Invoking Cypher / Locy queries back into the host session.
53 HostQuery {
54 /// If `true`, only read queries are permitted.
55 #[serde(default)]
56 read_only: bool,
57 /// Optional scope-restriction (label / edge-type prefixes).
58 #[serde(default)]
59 scopes: Vec<SmolStr>,
60 },
61 /// KMS access for sign / verify operations.
62 Kms {
63 /// Permitted key identifiers (empty = deny-all).
64 #[serde(default)]
65 key_ids: Vec<SmolStr>,
66 },
67 /// Acquiring named secret handles (opaque to the plugin).
68 Secret {
69 /// Permitted secret identifiers (empty = deny-all).
70 #[serde(default)]
71 ids: Vec<SmolStr>,
72 },
73 /// Explicit lock primitives (`host.lock_nodes`, `host.lock_edges`).
74 Lock {
75 /// Granularity of locks permitted.
76 granularity: LockGranularity,
77 },
78 /// Scoped configuration K/V access (`host.config_get`).
79 Config {
80 /// Patterns of permitted config keys (empty = deny-all).
81 #[serde(default)]
82 keys: Vec<SmolStr>,
83 },
84 /// Per-plugin K/V store (scoped namespace).
85 PluginStorage,
86
87 // ---- Extension surfaces (gate Registrar methods) ----
88 /// Register Cypher scalar functions.
89 ScalarFn,
90 /// Register Cypher aggregate functions.
91 AggregateFn,
92 /// Register Cypher window functions.
93 WindowFn,
94 /// Register Cypher procedures (read-only mode).
95 Procedure,
96 /// Register procedures that may mutate the graph.
97 ProcedureWrites,
98 /// Register procedures that may issue DDL.
99 ProcedureSchema,
100 /// Register administrative procedures.
101 ProcedureDbms,
102 /// Register Locy aggregate functions.
103 LocyAggregate,
104 /// Register Locy predicates (including neural).
105 LocyPredicate,
106 /// Register physical operators / optimizer rules.
107 Operator,
108 /// Register index kinds.
109 Index,
110 /// Register storage backends by URI scheme.
111 Storage,
112 /// Register graph algorithms.
113 Algorithm,
114 /// Register CRDT kinds.
115 Crdt,
116 /// Register session / query lifecycle hooks.
117 Hook,
118 /// Register fine-grained mutation triggers.
119 Trigger,
120 /// Register background / scheduled jobs.
121 BackgroundJob {
122 /// Maximum concurrent invocations of this plugin's jobs.
123 max_concurrent: u32,
124 },
125 /// Register logical (Arrow extension) types.
126 Type,
127 /// Register authentication providers.
128 Auth,
129 /// Register authorization policies.
130 Authz,
131 /// Register wire / connector protocols.
132 Connector,
133 /// Register collations (sort orders).
134 Collation,
135 /// Register CDC output sinks.
136 Cdc,
137 /// Register catalogs / virtual schemas.
138 Catalog,
139 /// Authority to call meta-procedures (`uni.plugin.declare*`).
140 PluginDeclare,
141
142 // ---- Resource quotas ----
143 /// Maximum wasm linear memory per instance.
144 MemoryBytes(u64),
145 /// Maximum wasmtime fuel per call.
146 FuelPerCall(u64),
147 /// Maximum wall-clock milliseconds per call.
148 WallClockMillisPerCall(u64),
149 /// Maximum concurrent instances in the wasm pool.
150 ConcurrentInstances(u32),
151 /// Maximum total memory across all instances.
152 TotalMemoryBytes(u64),
153 /// Cap on rows yielded by a procedure.
154 MaxResultRows(u64),
155}
156
157/// Granularity of lock-capability grants.
158#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
159#[serde(rename_all = "kebab-case")]
160#[non_exhaustive]
161pub enum LockGranularity {
162 /// Per-node locks only.
163 Nodes,
164 /// Per-edge locks only.
165 Edges,
166 /// Both nodes and edges.
167 Both,
168 /// Global (graph-wide) locks.
169 Global,
170}
171
172/// A set of capabilities — declared by manifest, granted by loader.
173///
174/// The *effective* capability set is the intersection of declared and
175/// granted. Registrations attempted without the corresponding capability in
176/// the effective set fail with [`crate::PluginError::CapabilityRequired`].
177#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
178#[serde(transparent)]
179pub struct CapabilitySet {
180 set: BTreeSet<Capability>,
181}
182
183impl CapabilitySet {
184 /// Construct an empty capability set.
185 #[must_use]
186 pub fn new() -> Self {
187 Self::default()
188 }
189
190 /// Construct a capability set from an iterable.
191 #[must_use]
192 pub fn from_iter_of(caps: impl IntoIterator<Item = Capability>) -> Self {
193 Self {
194 set: caps.into_iter().collect(),
195 }
196 }
197
198 /// Construct a capability set from guest-manifest declarations, each of
199 /// which may be a bare name or a structured [`ManifestCapability`].
200 #[must_use]
201 pub fn from_manifest(caps: impl IntoIterator<Item = ManifestCapability>) -> Self {
202 Self::from_iter_of(caps.into_iter().map(|m| m.0))
203 }
204
205 /// Insert a capability; returns `true` if the capability was not already present.
206 pub fn insert(&mut self, cap: Capability) -> bool {
207 self.set.insert(cap)
208 }
209
210 /// Check whether the set contains the given capability (exact equality).
211 #[must_use]
212 pub fn contains(&self, cap: &Capability) -> bool {
213 self.set.contains(cap)
214 }
215
216 /// Check whether the set contains a registration-gating capability.
217 ///
218 /// Match is on the *variant* — `contains_variant(Capability::ScalarFn)`
219 /// returns `true` regardless of any associated data on other variants.
220 /// Useful for registrar gates like "any `BackgroundJob { max_concurrent }`
221 /// is sufficient regardless of the cap."
222 #[must_use]
223 pub fn contains_variant(&self, target: &Capability) -> bool {
224 self.set.iter().any(|c| variant_matches(c, target))
225 }
226
227 /// Intersect this set with another, returning a new set.
228 ///
229 /// The intersection is the effective capability set when manifest
230 /// declarations are intersected with host grants. Caps that match by
231 /// variant but differ in attenuation (e.g., two different `Network
232 /// { allow }` patterns) are *both retained* — the runtime check enforces
233 /// each individually.
234 #[must_use]
235 pub fn intersect(&self, other: &Self) -> Self {
236 let mut out = Self::new();
237 for c in &self.set {
238 if other.contains_variant(c) {
239 out.insert(c.clone());
240 }
241 }
242 out
243 }
244
245 /// Returns an iterator over the contained capabilities.
246 pub fn iter(&self) -> impl Iterator<Item = &Capability> {
247 self.set.iter()
248 }
249
250 /// Returns the number of distinct capabilities in the set.
251 #[must_use]
252 pub fn len(&self) -> usize {
253 self.set.len()
254 }
255
256 /// Returns `true` if the set is empty.
257 #[must_use]
258 pub fn is_empty(&self) -> bool {
259 self.set.is_empty()
260 }
261}
262
263fn variant_matches(a: &Capability, b: &Capability) -> bool {
264 std::mem::discriminant(a) == std::mem::discriminant(b)
265}
266
267impl Capability {
268 /// True if this is a [`Capability::Network`] grant whose allow-list
269 /// matches `url`.
270 ///
271 /// Used for layer-3 (call-time) attenuation of `uni.http.*` host fns: a
272 /// granted `Network { allow }` only permits URLs matching one of its
273 /// patterns. Non-`Network` capabilities never match.
274 #[must_use]
275 pub fn network_allows(&self, url: &str) -> bool {
276 matches!(self, Capability::Network { allow } if allow.iter().any(|p| wildcard_match(p, url)))
277 }
278
279 /// True if this is a [`Capability::Kms`] grant permitting `key_id`.
280 #[must_use]
281 pub fn kms_allows(&self, key_id: &str) -> bool {
282 matches!(self, Capability::Kms { key_ids } if key_ids.iter().any(|p| wildcard_match(p, key_id)))
283 }
284
285 /// True if this is a [`Capability::Secret`] grant permitting `id`.
286 #[must_use]
287 pub fn secret_allows(&self, id: &str) -> bool {
288 matches!(self, Capability::Secret { ids } if ids.iter().any(|p| wildcard_match(p, id)))
289 }
290
291 /// True if this is a [`Capability::Filesystem`] grant whose `read`
292 /// allow-list matches `path`.
293 ///
294 /// Patterns are matched with `wildcard_match` (path-opaque — `*` and `**`
295 /// both span `/`), which suits the `/data/**`-style grants in use.
296 #[must_use]
297 pub fn filesystem_read_allows(&self, path: &str) -> bool {
298 matches!(self, Capability::Filesystem { read, .. } if read.iter().any(|p| wildcard_match(p, path)))
299 }
300
301 /// True if this is a [`Capability::Filesystem`] grant whose `write`
302 /// allow-list matches `path`.
303 #[must_use]
304 pub fn filesystem_write_allows(&self, path: &str) -> bool {
305 matches!(self, Capability::Filesystem { write, .. } if write.iter().any(|p| wildcard_match(p, path)))
306 }
307}
308
309/// A capability as it appears in a **guest plugin manifest** (WASM / Extism) —
310/// either a bare capability name (`"network"`, `"scalar-fn"`) or a structured
311/// object carrying attenuation patterns
312/// (`{"kind":"network","allow":["https://api.example/**"]}`).
313///
314/// Bare names normalize to their **zero-attenuation** variant — e.g.
315/// `"network"` → `Network { allow: [] }` (deny-all egress) — so a guest must
316/// spell out patterns to gain real host-surface access. This lets guest
317/// manifests opt into the same rich [`Capability`] model the in-process Rhai /
318/// Rust paths use, while staying backward-compatible with manifests that listed
319/// bare capability names.
320#[derive(Clone, Debug)]
321pub struct ManifestCapability(pub Capability);
322
323impl<'de> Deserialize<'de> for ManifestCapability {
324 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
325 where
326 D: serde::Deserializer<'de>,
327 {
328 /// String-or-object shim. A JSON string is a bare name; a map is the
329 /// structured `Capability` form (internally tagged on `kind`).
330 #[derive(Deserialize)]
331 #[serde(untagged)]
332 enum Repr {
333 Bare(String),
334 Full(Capability),
335 }
336
337 let cap = match Repr::deserialize(deserializer)? {
338 Repr::Full(c) => c,
339 Repr::Bare(name) => {
340 // Reconstruct the internally-tagged object `{ "kind": <name> }`
341 // so unit variants and (defaulted-field) structured variants
342 // both round-trip through the canonical `Capability` serde.
343 let tagged = serde_json::json!({ "kind": name });
344 Capability::deserialize(tagged).map_err(serde::de::Error::custom)?
345 }
346 };
347 Ok(ManifestCapability(cap))
348 }
349}
350
351/// Anchored wildcard match where `*` (and `**`) match any run of characters.
352///
353/// Capability attenuation patterns (network URL allow-lists, KMS key ids,
354/// secret ids) are globs over opaque strings, not paths, so `**` is treated
355/// identically to `*` — both match any sequence including `/`. Uses the
356/// standard greedy two-pointer algorithm with backtracking; matching is
357/// anchored at both ends.
358fn wildcard_match(pattern: &str, text: &str) -> bool {
359 let p = pattern.as_bytes();
360 let t = text.as_bytes();
361 let (mut pi, mut ti) = (0usize, 0usize);
362 let mut star: Option<usize> = None;
363 let mut mark = 0usize;
364 while ti < t.len() {
365 if pi < p.len() && p[pi] == b'*' {
366 // Collapse consecutive `*` so `**` behaves like `*`.
367 while pi < p.len() && p[pi] == b'*' {
368 pi += 1;
369 }
370 if pi == p.len() {
371 return true;
372 }
373 star = Some(pi);
374 mark = ti;
375 } else if pi < p.len() && p[pi] == t[ti] {
376 pi += 1;
377 ti += 1;
378 } else if let Some(s) = star {
379 pi = s;
380 mark += 1;
381 ti = mark;
382 } else {
383 return false;
384 }
385 }
386 while pi < p.len() && p[pi] == b'*' {
387 pi += 1;
388 }
389 pi == p.len()
390}
391
392/// Determinism characterization — drives planner caching and hoisting.
393#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
394#[serde(rename_all = "kebab-case")]
395pub enum Determinism {
396 /// Same inputs always produce identical output. Cacheable; hoistable
397 /// from loops. Maps to DataFusion `Volatility::Immutable`.
398 Pure,
399 /// Stable within one session (e.g. `current_user()`). Maps to
400 /// DataFusion `Volatility::Stable`.
401 SessionScoped,
402 /// Non-deterministic (`rand()`, `now()`). Maps to DataFusion
403 /// `Volatility::Volatile`.
404 #[default]
405 Nondeterministic,
406}
407
408/// Declared side-effects of a plugin.
409#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
410#[serde(rename_all = "kebab-case")]
411pub enum SideEffects {
412 /// Reads only. Pure or session-scoped data access.
413 #[default]
414 ReadOnly,
415 /// May write to the graph.
416 Writes,
417 /// May perform external I/O (network, filesystem).
418 ExternalIo,
419}
420
421/// Lifetime scope of a plugin's registrations.
422#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
423#[serde(rename_all = "kebab-case")]
424pub enum Scope {
425 /// Lives until `Uni::remove_plugin` or instance drop. Visible to every
426 /// session. The default for compile-time and WASM plugins.
427 #[default]
428 Instance,
429 /// Lives until the registering `Session` is dropped. Not visible to
430 /// other sessions on the same instance. The default for PyO3 and Lua
431 /// REPL-style plugins.
432 Session,
433}
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438
439 #[test]
440 fn capability_set_default_empty() {
441 let s = CapabilitySet::new();
442 assert!(s.is_empty());
443 assert_eq!(s.len(), 0);
444 }
445
446 #[test]
447 fn capability_set_insert_dedup() {
448 let mut s = CapabilitySet::new();
449 assert!(s.insert(Capability::ScalarFn));
450 assert!(!s.insert(Capability::ScalarFn));
451 assert_eq!(s.len(), 1);
452 }
453
454 #[test]
455 fn intersect_keeps_matching_variants() {
456 let a = CapabilitySet::from_iter_of([
457 Capability::ScalarFn,
458 Capability::Storage,
459 Capability::Network {
460 allow: vec![SmolStr::new("https://api.example/**")],
461 },
462 ]);
463 let b = CapabilitySet::from_iter_of([
464 Capability::ScalarFn,
465 Capability::Network {
466 allow: vec![SmolStr::new("https://api.example/**")],
467 },
468 ]);
469 let inter = a.intersect(&b);
470 assert!(inter.contains(&Capability::ScalarFn));
471 assert!(!inter.contains_variant(&Capability::Storage));
472 assert!(inter.contains_variant(&Capability::Network { allow: vec![] }));
473 }
474
475 #[test]
476 fn contains_variant_ignores_attenuation() {
477 let s = CapabilitySet::from_iter_of([Capability::Network {
478 allow: vec![SmolStr::new("https://x.example/*")],
479 }]);
480 assert!(s.contains_variant(&Capability::Network { allow: vec![] }));
481 // Exact equality requires identical attenuation.
482 assert!(!s.contains(&Capability::Network { allow: vec![] }));
483 }
484
485 #[test]
486 fn determinism_default_is_nondeterministic() {
487 assert_eq!(Determinism::default(), Determinism::Nondeterministic);
488 }
489
490 #[test]
491 fn wildcard_match_basics() {
492 assert!(wildcard_match("*", "anything"));
493 assert!(wildcard_match("**", "any/thing"));
494 assert!(wildcard_match(
495 "https://api.example/**",
496 "https://api.example/v1/x"
497 ));
498 assert!(wildcard_match("exact", "exact"));
499 assert!(!wildcard_match("exact", "other"));
500 assert!(!wildcard_match(
501 "https://api.example/**",
502 "https://evil.example/x"
503 ));
504 assert!(wildcard_match("a*c", "abbbc"));
505 assert!(!wildcard_match("a*c", "abbb"));
506 }
507
508 #[test]
509 fn network_allows_matches_only_network_variant() {
510 let net = Capability::Network {
511 allow: vec![SmolStr::new("https://api.example/**")],
512 };
513 assert!(net.network_allows("https://api.example/v1/data"));
514 assert!(!net.network_allows("https://evil.example/x"));
515 // A non-network capability never grants network access.
516 assert!(!Capability::ScalarFn.network_allows("https://api.example/x"));
517 }
518
519 #[test]
520 fn kms_and_secret_allow_wildcard_and_exact() {
521 let kms = Capability::Kms {
522 key_ids: vec![SmolStr::new("*")],
523 };
524 assert!(kms.kms_allows("signing-key-1"));
525 let secret = Capability::Secret {
526 ids: vec![SmolStr::new("db-password")],
527 };
528 assert!(secret.secret_allows("db-password"));
529 assert!(!secret.secret_allows("other"));
530 }
531
532 #[test]
533 fn manifest_capability_parses_bare_and_structured() {
534 // Bare name → zero-attenuation variant (deny-all egress).
535 let bare: ManifestCapability = serde_json::from_str("\"network\"").unwrap();
536 assert!(matches!(&bare.0, Capability::Network { allow } if allow.is_empty()));
537 assert!(!bare.0.network_allows("https://api.example/x"));
538 // Bare unit variant.
539 let scalar: ManifestCapability = serde_json::from_str("\"scalar-fn\"").unwrap();
540 assert_eq!(scalar.0, Capability::ScalarFn);
541 // Structured object → carries the allow-list.
542 let structured: ManifestCapability =
543 serde_json::from_str(r#"{"kind":"network","allow":["https://api.example/**"]}"#)
544 .unwrap();
545 assert!(structured.0.network_allows("https://api.example/v1/x"));
546 assert!(!structured.0.network_allows("https://evil.example/x"));
547 // A whole manifest list folds into a CapabilitySet.
548 let set = CapabilitySet::from_manifest([bare, scalar, structured]);
549 assert!(set.contains_variant(&Capability::Network { allow: vec![] }));
550 assert!(set.contains(&Capability::ScalarFn));
551 }
552
553 #[test]
554 fn filesystem_allows_read_and_write_separately() {
555 let fs = Capability::Filesystem {
556 read: vec![SmolStr::new("/data/**")],
557 write: vec![SmolStr::new("/tmp/out/**")],
558 };
559 assert!(fs.filesystem_read_allows("/data/x/y.txt"));
560 assert!(!fs.filesystem_read_allows("/etc/passwd"));
561 assert!(fs.filesystem_write_allows("/tmp/out/log"));
562 // read grant does not imply write grant for the same path
563 assert!(!fs.filesystem_write_allows("/data/x/y.txt"));
564 // a non-filesystem capability never matches
565 assert!(!Capability::ScalarFn.filesystem_read_allows("/data/x"));
566 }
567}