Skip to main content

hessra_cap_schema/
lib.rs

1//! # Hessra Capability Schema
2//!
3//! Declarative schemas for principals that own targets in a Hessra deployment.
4//! A schema names the targets a principal owns, the operations on each target,
5//! and the designations the principal requires at mint time for each operation.
6//!
7//! Schemas are policy-side configuration: they tell the capability engine
8//! "the engine refuses to mint a capability for this target/operation unless
9//! these designations are attached." This is the issuer-side guard against
10//! silently broadening capabilities by forgetting to designate.
11//!
12//! ## Reserved labels
13//!
14//! Some designation labels are reserved for engine-built-in semantics and
15//! cannot appear in `required_designations`. The schema validator rejects
16//! them at load time with [`SchemaError::ReservedLabel`]. Currently:
17//!
18//! - `"anchor"`: the principal that can verify a capability. Configured via
19//!   policy (`anchor_to_subject = true` or `anchor = "<principal>"`) or via
20//!   `MintOptions.anchor`. Implemented in the token using the same designation
21//!   mechanism as application labels but treated as a distinct concept.
22//! - `"facet"`: a per-capability ULID-style identifier the engine attaches
23//!   when forwarding facets are enabled. Pairs with an in-memory map the
24//!   issuer-and-verifier engine consults, giving per-cap revocation and
25//!   single-use-on-ack semantics.
26//!
27//! See [`RESERVED_LABELS`].
28
29use serde::Deserialize;
30use std::collections::HashMap;
31use std::ffi::OsStr;
32use std::fs;
33use std::path::{Path, PathBuf};
34use thiserror::Error;
35
36/// Designation labels that the engine handles through dedicated paths and
37/// must not appear in any operation's `required_designations`.
38///
39/// When a new label is added here it must also be wired through the engine's
40/// dedicated path. Adding a string to this constant alone is not enough.
41pub const RESERVED_LABELS: &[&str] = &["anchor", "facet"];
42
43/// The schema for a single target object: the operations it exposes and the
44/// designations each operation requires.
45#[derive(Debug, Clone, Deserialize)]
46pub struct TargetSchema {
47    /// The target's object id (e.g., `"filesystem:source"`).
48    pub id: String,
49    /// Operations the target exposes.
50    #[serde(default)]
51    pub operations: Vec<OperationSchema>,
52}
53
54/// The schema for a single operation on a target.
55#[derive(Debug, Clone, Deserialize)]
56pub struct OperationSchema {
57    /// The operation name (e.g., `"read"`, `"invoke"`).
58    pub name: String,
59    /// Designation labels that must be attached at mint time for this
60    /// operation. Excludes reserved labels (anchor, etc.), which are enforced
61    /// through dedicated engine paths.
62    #[serde(default)]
63    pub required_designations: Vec<String>,
64}
65
66/// Top-level schema TOML wrapper. Only `[[targets]]` is consumed; other
67/// top-level sections (e.g., `[tool]` from a harness manifest) are ignored.
68#[derive(Debug, Deserialize)]
69struct SchemaFile {
70    #[serde(default)]
71    targets: Vec<TargetSchema>,
72}
73
74/// Registry of target schemas, populated from one or more TOML sources.
75///
76/// Construct with [`SchemaRegistry::new`] for an empty registry, or via the
77/// loading constructors. Use [`SchemaRegistry::add_file`] /
78/// [`SchemaRegistry::add_toml`] to compose multiple sources; duplicate target
79/// ids and reserved labels are caught at load time.
80#[derive(Debug, Default, Clone)]
81pub struct SchemaRegistry {
82    targets: HashMap<String, TargetSchema>,
83    /// Where each target was first declared, for error messages on duplicates.
84    sources: HashMap<String, String>,
85}
86
87impl SchemaRegistry {
88    /// Create an empty registry. The engine treats this as "no schemas declared,
89    /// no required_designations enforcement."
90    pub fn new() -> Self {
91        Self::default()
92    }
93
94    /// Parse a TOML string and return a fresh registry.
95    pub fn from_toml(content: &str) -> Result<Self, SchemaError> {
96        let mut reg = Self::new();
97        reg.add_toml_source(content, "<inline>")?;
98        Ok(reg)
99    }
100
101    /// Load a single TOML file.
102    pub fn from_file(path: &Path) -> Result<Self, SchemaError> {
103        let mut reg = Self::new();
104        reg.add_file(path)?;
105        Ok(reg)
106    }
107
108    /// Load every `*.toml` file in a directory, non-recursively. Files are
109    /// loaded in lexicographic order so the same directory produces the same
110    /// registry across runs.
111    pub fn from_dir(dir: &Path) -> Result<Self, SchemaError> {
112        let mut reg = Self::new();
113        let mut entries: Vec<PathBuf> = fs::read_dir(dir)
114            .map_err(|source| SchemaError::Io {
115                path: dir.to_path_buf(),
116                source,
117            })?
118            .filter_map(|res| res.ok().map(|e| e.path()))
119            .filter(|p| p.is_file() && p.extension() == Some(OsStr::new("toml")))
120            .collect();
121        entries.sort();
122        for path in entries {
123            reg.add_file(&path)?;
124        }
125        Ok(reg)
126    }
127
128    /// Add a TOML file to this registry. Errors on duplicate target ids or
129    /// reserved labels.
130    pub fn add_file(&mut self, path: &Path) -> Result<(), SchemaError> {
131        let content = fs::read_to_string(path).map_err(|source| SchemaError::Io {
132            path: path.to_path_buf(),
133            source,
134        })?;
135        self.add_toml_source(&content, &path.display().to_string())
136    }
137
138    /// Add a TOML string to this registry, attributing any errors to the
139    /// `<inline>` source. Use [`SchemaRegistry::add_file`] when you have a path
140    /// for better error messages.
141    pub fn add_toml(&mut self, content: &str) -> Result<(), SchemaError> {
142        self.add_toml_source(content, "<inline>")
143    }
144
145    /// Add a single target schema directly. Errors on duplicate id or reserved
146    /// labels; useful for tests and programmatic construction.
147    pub fn add_target(&mut self, schema: TargetSchema) -> Result<(), SchemaError> {
148        self.add_target_with_source(schema, "<programmatic>")
149    }
150
151    /// Look up a target's schema.
152    pub fn get(&self, target: &str) -> Option<&TargetSchema> {
153        self.targets.get(target)
154    }
155
156    /// Look up the required designations for a `(target, operation)` pair.
157    /// Returns `None` if either the target or the operation is not declared,
158    /// which the engine treats as "no enforcement runs."
159    pub fn required_designations(&self, target: &str, operation: &str) -> Option<&[String]> {
160        self.targets
161            .get(target)?
162            .operations
163            .iter()
164            .find(|op| op.name == operation)
165            .map(|op| op.required_designations.as_slice())
166    }
167
168    /// Iterate all declared target schemas.
169    pub fn targets(&self) -> impl Iterator<Item = &TargetSchema> {
170        self.targets.values()
171    }
172
173    /// Whether the registry has any targets.
174    pub fn is_empty(&self) -> bool {
175        self.targets.is_empty()
176    }
177
178    fn add_toml_source(&mut self, content: &str, source: &str) -> Result<(), SchemaError> {
179        let parsed: SchemaFile = toml::from_str(content).map_err(|err| SchemaError::Parse {
180            source: PathBuf::from(source),
181            err,
182        })?;
183        for target in parsed.targets {
184            self.add_target_with_source(target, source)?;
185        }
186        Ok(())
187    }
188
189    fn add_target_with_source(
190        &mut self,
191        schema: TargetSchema,
192        source: &str,
193    ) -> Result<(), SchemaError> {
194        validate_target(&schema)?;
195
196        if let Some(first_source) = self.sources.get(&schema.id) {
197            return Err(SchemaError::DuplicateTarget {
198                id: schema.id,
199                first: first_source.clone(),
200                second: source.to_string(),
201            });
202        }
203
204        self.sources.insert(schema.id.clone(), source.to_string());
205        self.targets.insert(schema.id.clone(), schema);
206        Ok(())
207    }
208}
209
210fn validate_target(schema: &TargetSchema) -> Result<(), SchemaError> {
211    let mut seen_ops: std::collections::HashSet<&str> = std::collections::HashSet::new();
212    for op in &schema.operations {
213        if !seen_ops.insert(op.name.as_str()) {
214            return Err(SchemaError::DuplicateOperation {
215                target: schema.id.clone(),
216                op: op.name.clone(),
217            });
218        }
219        for label in &op.required_designations {
220            if RESERVED_LABELS.contains(&label.as_str()) {
221                return Err(SchemaError::ReservedLabel {
222                    target: schema.id.clone(),
223                    op: op.name.clone(),
224                    label: label.clone(),
225                });
226            }
227        }
228    }
229    Ok(())
230}
231
232/// Errors from schema parsing and validation.
233#[derive(Error, Debug)]
234pub enum SchemaError {
235    #[error("failed to read schema file {}: {source}", path.display())]
236    Io {
237        path: PathBuf,
238        #[source]
239        source: std::io::Error,
240    },
241
242    #[error("failed to parse schema TOML at {}: {err}", source.display())]
243    Parse {
244        source: PathBuf,
245        #[source]
246        err: toml::de::Error,
247    },
248
249    #[error("duplicate target id '{id}' (first declared in {first}, redeclared in {second})")]
250    DuplicateTarget {
251        id: String,
252        first: String,
253        second: String,
254    },
255
256    #[error("target '{target}' declares operation '{op}' more than once")]
257    DuplicateOperation { target: String, op: String },
258
259    #[error(
260        "target '{target}' operation '{op}' lists reserved label '{label}' in required_designations; reserved labels are handled by the engine through a dedicated path and cannot be declared here"
261    )]
262    ReservedLabel {
263        target: String,
264        op: String,
265        label: String,
266    },
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    #[test]
274    fn empty_registry_returns_none_for_lookups() {
275        let reg = SchemaRegistry::new();
276        assert!(reg.is_empty());
277        assert!(reg.get("anything").is_none());
278        assert!(reg.required_designations("a", "b").is_none());
279    }
280
281    #[test]
282    fn parses_single_target_with_required_designations() {
283        let toml = r#"
284[[targets]]
285id = "filesystem:source"
286operations = [
287  { name = "read",  required_designations = ["path_prefix"] },
288  { name = "write", required_designations = ["path_prefix"] },
289]
290"#;
291        let reg = SchemaRegistry::from_toml(toml).expect("parse");
292        let req = reg
293            .required_designations("filesystem:source", "read")
294            .expect("op exists");
295        assert_eq!(req, ["path_prefix"]);
296        let req = reg
297            .required_designations("filesystem:source", "write")
298            .expect("op exists");
299        assert_eq!(req, ["path_prefix"]);
300    }
301
302    #[test]
303    fn missing_target_or_op_returns_none() {
304        let toml = r#"
305[[targets]]
306id = "tool:web-search"
307operations = [{ name = "invoke" }]
308"#;
309        let reg = SchemaRegistry::from_toml(toml).expect("parse");
310        assert!(reg.required_designations("nope", "invoke").is_none());
311        assert!(
312            reg.required_designations("tool:web-search", "nope")
313                .is_none()
314        );
315        let req = reg
316            .required_designations("tool:web-search", "invoke")
317            .expect("op exists");
318        assert!(req.is_empty());
319    }
320
321    #[test]
322    fn duplicate_target_in_same_file_errors() {
323        let toml = r#"
324[[targets]]
325id = "filesystem:source"
326operations = []
327
328[[targets]]
329id = "filesystem:source"
330operations = []
331"#;
332        let err = SchemaRegistry::from_toml(toml).expect_err("must reject duplicate");
333        match err {
334            SchemaError::DuplicateTarget { id, .. } => assert_eq!(id, "filesystem:source"),
335            other => panic!("wrong error variant: {other:?}"),
336        }
337    }
338
339    #[test]
340    fn duplicate_operation_within_target_errors() {
341        let toml = r#"
342[[targets]]
343id = "filesystem:source"
344operations = [
345  { name = "read" },
346  { name = "read" },
347]
348"#;
349        let err = SchemaRegistry::from_toml(toml).expect_err("must reject duplicate op");
350        match err {
351            SchemaError::DuplicateOperation { target, op } => {
352                assert_eq!(target, "filesystem:source");
353                assert_eq!(op, "read");
354            }
355            other => panic!("wrong error variant: {other:?}"),
356        }
357    }
358
359    #[test]
360    fn anchor_in_required_designations_is_rejected() {
361        let toml = r#"
362[[targets]]
363id = "filesystem:source"
364operations = [
365  { name = "read", required_designations = ["anchor", "path_prefix"] },
366]
367"#;
368        let err = SchemaRegistry::from_toml(toml).expect_err("must reject anchor");
369        match err {
370            SchemaError::ReservedLabel { label, .. } => assert_eq!(label, "anchor"),
371            other => panic!("wrong error variant: {other:?}"),
372        }
373    }
374
375    #[test]
376    fn facet_in_required_designations_is_rejected() {
377        let toml = r#"
378[[targets]]
379id = "tool:web-search"
380operations = [
381  { name = "invoke", required_designations = ["facet"] },
382]
383"#;
384        let err = SchemaRegistry::from_toml(toml).expect_err("must reject facet");
385        match err {
386            SchemaError::ReservedLabel { label, .. } => assert_eq!(label, "facet"),
387            other => panic!("wrong error variant: {other:?}"),
388        }
389    }
390
391    #[test]
392    fn unknown_top_level_sections_are_ignored() {
393        // Bundled-with-manifest case: harness-specific [tool] and [input_schema]
394        // sections coexist with [[targets]]. The schema loader consumes only
395        // [[targets]].
396        let toml = r#"
397[tool]
398name = "birthday_discord"
399type = "subprocess"
400command = "deno run birthday_discord.ts"
401
402[input_schema]
403type = "object"
404
405[[targets]]
406id = "tool:birthday-discord"
407operations = [{ name = "invoke" }]
408"#;
409        let reg = SchemaRegistry::from_toml(toml).expect("parse");
410        assert!(reg.get("tool:birthday-discord").is_some());
411    }
412
413    #[test]
414    fn add_target_programmatic_and_duplicate_detection() {
415        let mut reg = SchemaRegistry::new();
416        reg.add_target(TargetSchema {
417            id: "tool:web-search".to_string(),
418            operations: vec![OperationSchema {
419                name: "invoke".to_string(),
420                required_designations: vec!["query".to_string()],
421            }],
422        })
423        .expect("first add");
424
425        let dup = reg.add_target(TargetSchema {
426            id: "tool:web-search".to_string(),
427            operations: vec![],
428        });
429        assert!(matches!(dup, Err(SchemaError::DuplicateTarget { .. })));
430    }
431
432    #[test]
433    fn add_toml_composes_multiple_sources() {
434        let mut reg = SchemaRegistry::new();
435        reg.add_toml(
436            r#"
437[[targets]]
438id = "filesystem:source"
439operations = [{ name = "read", required_designations = ["path_prefix"] }]
440"#,
441        )
442        .expect("first");
443        reg.add_toml(
444            r#"
445[[targets]]
446id = "tool:discord-dm"
447operations = [{ name = "send", required_designations = ["user_id"] }]
448"#,
449        )
450        .expect("second");
451
452        assert_eq!(reg.targets().count(), 2);
453        assert!(reg.get("filesystem:source").is_some());
454        assert!(reg.get("tool:discord-dm").is_some());
455    }
456}