Skip to main content

basil_core/core/
capability.rs

1// SPDX-FileCopyrightText: 2026 OpenBasil Contributors
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! Backend **capability enforcement**: does each backend *provide* what the
6//! catalog *requires*?
7//!
8//! Two declarations, two roles:
9//!
10//! - **Provides**: `BackendRef::engines` + `BackendRef::capabilities` +
11//!   `BackendRef::mint_key_types`: what the server instance supports. In nix this
12//!   comes from a version preset (`VAULT_2_0`, `OPENBAO_2_5`); a hand-written
13//!   catalog supplies the same JSON (so a non-nix adapter works too). It is the
14//!   version→capability table, in config rather than hardcoded in the binary.
15//! - **Requires**: what *this* deployment needs. Mostly **derived** (and so it
16//!   can't drift): each key's effective [`Engine`] and its [`KeyAlgorithm`]'s
17//!   [`required_capability`](crate::catalog::KeyAlgorithm::required_capability).
18//!   For the few non-key-derivable needs there is the explicit
19//!   `BackendRef::requires` list, unioned with the derived set.
20//!
21//! Enforcement is `required ⊆ provided`. It is a **pure** catalog check: no
22//! backend I/O and no extra Vault privilege, so it runs both at startup and in
23//! the offline `check` lint, and needs no reachable server. The live cross-check
24//! against a server's real version is a separate (future) concern.
25//!
26//! # Policy
27//!
28//! [`CapabilityPolicy`] is the operator's stance, a **ceiling** over each key's
29//! [`MissingPolicy`]:
30//!
31//! - `strict` (default, secure): any unmet requirement aborts startup; every
32//!   gap is reported together.
33//! - `degraded`, which defers to per-key `missing`: a gap on a `missing="error"` key is
34//!   still fatal; on any other key it is logged and the broker serves the rest
35//!   (the runtime `Unsupported` backstop catches the op if it is ever made).
36//! - `off` skips the check entirely.
37//!
38//! # Undeclared backends
39//!
40//! A backend that declares **no** provides (`engines`, `capabilities`, and
41//! `mint_key_types` all empty) is *capability-unknown*: the check is skipped
42//! with a warning, even under `strict`. The agent can't manufacture the data, and
43//! failing closed on absence would punish every minimal catalog. Real enforcement
44//! is opt-in by declaring the provides set (which the nix presets make trivial).
45
46use std::collections::BTreeSet;
47use std::str::FromStr;
48
49use crate::catalog::{Capability, Catalog, Engine, KeyAlgorithm, MissingPolicy};
50
51/// How startup handles a backend that cannot satisfy a required capability.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
53pub enum CapabilityPolicy {
54    /// Any unmet requirement aborts startup (fail closed). The default.
55    #[default]
56    Strict,
57    /// Defer to each key's [`MissingPolicy`]: a gap on a `missing="error"` key is
58    /// fatal; otherwise it is logged and the broker serves the rest.
59    Degraded,
60    /// Skip the capability check entirely.
61    Off,
62}
63
64impl FromStr for CapabilityPolicy {
65    type Err = String;
66
67    fn from_str(s: &str) -> Result<Self, Self::Err> {
68        match s {
69            "strict" => Ok(Self::Strict),
70            "degraded" => Ok(Self::Degraded),
71            "off" => Ok(Self::Off),
72            other => Err(format!(
73                "invalid capability-policy `{other}` (want one of: strict, degraded, off)"
74            )),
75        }
76    }
77}
78
79impl std::fmt::Display for CapabilityPolicy {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        f.write_str(match self {
82            Self::Strict => "strict",
83            Self::Degraded => "degraded",
84            Self::Off => "off",
85        })
86    }
87}
88
89/// One unmet capability requirement, for a single key, or (when `key` is
90/// `None`) a backend-level explicit `requires` entry.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct CapabilityGap {
93    /// The catalog backend name whose provides fell short.
94    pub backend: String,
95    /// The catalog key whose needs aren't met, or `None` for a backend-level
96    /// `requires` gap (which is not tied to any one key).
97    pub key: Option<String>,
98    /// Required engines the backend does not provide.
99    pub missing_engines: Vec<Engine>,
100    /// Required capabilities the backend does not provide.
101    pub missing_capabilities: Vec<Capability>,
102    /// Required native mint/import key algorithms the backend does not provide.
103    pub missing_mint_key_types: Vec<KeyAlgorithm>,
104}
105
106impl std::fmt::Display for CapabilityGap {
107    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108        match &self.key {
109            Some(k) => write!(f, "key `{k}` (backend `{}`)", self.backend)?,
110            None => write!(f, "backend `{}` requires", self.backend)?,
111        }
112        if !self.missing_engines.is_empty() {
113            let engines: Vec<&str> = self.missing_engines.iter().map(|e| e.token()).collect();
114            write!(f, " needs engine(s) [{}]", engines.join(", "))?;
115        }
116        if !self.missing_capabilities.is_empty() {
117            let caps: Vec<&str> = self
118                .missing_capabilities
119                .iter()
120                .map(Capability::token)
121                .collect();
122            write!(f, " needs capabilit(ies) [{}]", caps.join(", "))?;
123        }
124        if !self.missing_mint_key_types.is_empty() {
125            let types: Vec<&str> = self
126                .missing_mint_key_types
127                .iter()
128                .map(|alg| alg.token())
129                .collect();
130            write!(f, " needs mint key type(s) [{}]", types.join(", "))?;
131        }
132        Ok(())
133    }
134}
135
136/// A fatal capability-enforcement failure (fail closed). Carries **every** gap,
137/// not just the first, so one startup attempt surfaces all the misfits.
138#[derive(Debug, thiserror::Error)]
139#[error(
140    "backend capability check failed ({} unmet requirement(s)):\n  - {}",
141    .0.len(),
142    format_gaps(&.0)
143)]
144pub struct CapabilityError(pub Vec<CapabilityGap>);
145
146fn format_gaps(gaps: &[CapabilityGap]) -> String {
147    gaps.iter()
148        .map(ToString::to_string)
149        .collect::<Vec<_>>()
150        .join("\n  - ")
151}
152
153/// Summary of a non-fatal capability pass, for the startup log.
154#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
155pub struct CapabilitySummary {
156    /// Backends whose declared provides were checked.
157    pub enforced: usize,
158    /// Backends skipped because they declared no provides (capability-unknown).
159    pub skipped_undeclared: usize,
160    /// Gaps logged but not made fatal (degraded mode).
161    pub warnings: usize,
162}
163
164/// Validate every backend's declared provides against what its keys (and its
165/// explicit `requires`) need, applying `policy`.
166///
167/// Pure and offline: it reads only the catalog, makes no backend calls, and
168/// needs no Vault privilege. Suitable for both startup and the `check` lint.
169///
170/// # Errors
171///
172/// [`CapabilityError`] (carrying every gap) when `policy` makes one or more
173/// unmet requirements fatal: always under `strict`, and under `degraded` for a
174/// gap on a `missing="error"` key.
175pub fn enforce_capabilities(
176    catalog: &Catalog,
177    policy: CapabilityPolicy,
178) -> Result<CapabilitySummary, CapabilityError> {
179    let mut summary = CapabilitySummary::default();
180    // ubs false positive - not secret comparison
181    /* ubs:ignore */
182    if policy == CapabilityPolicy::Off {
183        return Ok(summary);
184    }
185
186    let mut fatal: Vec<CapabilityGap> = Vec::new();
187
188    for (bname, backend) in &catalog.backends {
189        // Undeclared provides → capability-unknown → skip (even under strict).
190        if backend.engines.is_empty()
191            && backend.capabilities.is_empty()
192            && backend.mint_key_types.is_empty()
193        {
194            summary.skipped_undeclared += 1;
195            tracing::warn!(
196                backend = %bname,
197                "backend declares no engines/capabilities/mint key types; capability enforcement skipped \
198                 (declare a provides set, or set capability-policy=\"off\" to silence)"
199            );
200            continue;
201        }
202        summary.enforced += 1;
203
204        let provided_engines: BTreeSet<Engine> = backend.engines.iter().copied().collect();
205        let provided_caps: BTreeSet<Capability> = backend.capabilities.iter().cloned().collect();
206        let provided_mint_key_types: BTreeSet<KeyAlgorithm> =
207            backend.mint_key_types.iter().copied().collect();
208
209        // Surface (non-fatally) any capability token this build doesn't know:
210        // a likely typo, but carried opaque so newer-engine names still work.
211        for cap in backend.capabilities.iter().chain(&backend.requires) {
212            if !cap.is_known() {
213                tracing::warn!(
214                    backend = %bname, capability = %cap,
215                    "unrecognized capability token; treated as opaque for enforcement"
216                );
217            }
218        }
219
220        // Backend-level explicit `requires` (no key to attach a policy to).
221        let missing_req: Vec<Capability> = backend
222            .requires
223            .iter()
224            .filter(|c| !provided_caps.contains(c))
225            .cloned()
226            .collect();
227        if !missing_req.is_empty() {
228            let gap = CapabilityGap {
229                backend: bname.clone(),
230                key: None,
231                missing_engines: Vec::new(),
232                missing_capabilities: missing_req,
233                missing_mint_key_types: Vec::new(),
234            };
235            match policy {
236                CapabilityPolicy::Strict => fatal.push(gap),
237                CapabilityPolicy::Degraded => {
238                    tracing::warn!(gap = %gap, "capability gap (degraded; backend-level requires)");
239                    summary.warnings += 1;
240                }
241                CapabilityPolicy::Off => {}
242            }
243        }
244
245        for (gap, missing) in key_capability_gaps(
246            catalog,
247            bname,
248            &provided_engines,
249            &provided_caps,
250            &provided_mint_key_types,
251        ) {
252            match policy {
253                CapabilityPolicy::Strict => fatal.push(gap),
254                CapabilityPolicy::Degraded => {
255                    if missing == MissingPolicy::Error {
256                        fatal.push(gap);
257                    } else {
258                        tracing::warn!(gap = %gap, "capability gap (degraded; non-required key)");
259                        summary.warnings += 1;
260                    }
261                }
262                CapabilityPolicy::Off => {}
263            }
264        }
265    }
266
267    if fatal.is_empty() {
268        Ok(summary)
269    } else {
270        fatal.sort_by(|a, b| (&a.backend, &a.key).cmp(&(&b.backend, &b.key)));
271        Err(CapabilityError(fatal))
272    }
273}
274
275fn key_capability_gaps(
276    catalog: &Catalog,
277    backend: &str,
278    provided_engines: &BTreeSet<Engine>,
279    provided_caps: &BTreeSet<Capability>,
280    provided_mint_key_types: &BTreeSet<KeyAlgorithm>,
281) -> Vec<(CapabilityGap, MissingPolicy)> {
282    let mut gaps = Vec::new();
283
284    for (kname, entry) in &catalog.keys {
285        // ubs false positive: not a secret comparison
286        /* ubs:ignore */
287        if entry.backend != backend {
288            continue;
289        }
290        let engine = entry.effective_engine();
291        let missing_engines = if provided_engines.contains(&engine) {
292            Vec::new()
293        } else {
294            vec![engine]
295        };
296        let missing_capabilities: Vec<Capability> = entry
297            .key_type
298            .and_then(KeyAlgorithm::required_capability)
299            .filter(|c| !provided_caps.contains(c))
300            .into_iter()
301            .collect();
302        let missing_mint_key_types: Vec<KeyAlgorithm> = required_mint_key_type(entry)
303            .filter(|alg| !provided_mint_key_types.contains(alg))
304            .into_iter()
305            .collect();
306
307        if missing_engines.is_empty()
308            && missing_capabilities.is_empty()
309            && missing_mint_key_types.is_empty()
310        {
311            continue;
312        }
313        gaps.push((
314            CapabilityGap {
315                backend: backend.to_owned(),
316                key: Some(kname.clone()),
317                missing_engines,
318                missing_capabilities,
319                missing_mint_key_types,
320            },
321            entry.missing,
322        ));
323    }
324
325    gaps
326}
327
328fn required_mint_key_type(entry: &crate::catalog::KeyEntry) -> Option<KeyAlgorithm> {
329    if entry.class == crate::catalog::Class::Asymmetric
330        && entry.effective_engine() == Engine::Transit
331        && entry.missing == MissingPolicy::Generate
332    {
333        entry.key_type
334    } else {
335        None
336    }
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342
343    /// A catalog with one `vault` backend (given provides) and the listed keys.
344    /// Each key is `(name, engine_json, key_type_json, missing_json)` fragments.
345    fn catalog_json(provides: &str, keys: &str) -> String {
346        format!(
347            r#"{{
348              "schemaVersion": 1,
349              "backends": {{ "bao": {{ "kind": "vault", "addr": "http://127.0.0.1:8200"{provides} }} }},
350              "keys": {{ {keys} }}
351            }}"#
352        )
353    }
354
355    fn parse(json: &str) -> Catalog {
356        serde_json::from_str(json).expect("catalog parses")
357    }
358
359    const SIGNER: &str = r#""web.sig": { "class": "asymmetric", "keyType": "ed25519", "backend": "bao", "engine": "transit", "path": "web", "writable": true, "description": "d" }"#;
360    const GENERATED_RSA_SIGNER: &str = r#""web.sig": { "class": "asymmetric", "keyType": "rsa-2048", "backend": "bao", "engine": "transit", "path": "web", "writable": true, "missing": "generate", "description": "d" }"#;
361
362    #[test]
363    fn capability_token_round_trips_known_and_unknown() {
364        for tok in [
365            "byok-import",
366            "prehash-sign",
367            "pqc-transit",
368            "pki-crl",
369            "jwt-auth",
370            "approle-auth",
371        ] {
372            let cap = Capability::from(tok.to_string());
373            assert!(cap.is_known(), "{tok} should be known");
374            assert_eq!(cap.token(), tok);
375        }
376        let unknown = Capability::from("ml-kem-1024-transit".to_string());
377        assert!(!unknown.is_known());
378        assert_eq!(unknown.token(), "ml-kem-1024-transit");
379        assert_eq!(
380            unknown,
381            Capability::Other("ml-kem-1024-transit".to_string())
382        );
383    }
384
385    #[test]
386    fn unknown_capability_deserializes_without_error() {
387        // An engine release adds a capability our enum doesn't know: a
388        // hand-written catalog must still load (open type, no rebuild needed).
389        let cat = parse(&catalog_json(
390            r#", "engines": ["transit"], "capabilities": ["some-2027-feature"]"#,
391            SIGNER,
392        ));
393        let caps = &cat.backends["bao"].capabilities;
394        assert_eq!(caps, &[Capability::Other("some-2027-feature".to_string())]);
395    }
396
397    #[test]
398    fn undeclared_backend_is_skipped_even_under_strict() {
399        // No engines/capabilities declared -> capability-unknown -> skipped.
400        let cat = parse(&catalog_json("", SIGNER));
401        let summary =
402            enforce_capabilities(&cat, CapabilityPolicy::Strict).expect("skipped, not fatal");
403        assert_eq!(summary.skipped_undeclared, 1);
404        assert_eq!(summary.enforced, 0);
405    }
406
407    #[test]
408    fn declared_backend_satisfying_requirements_passes() {
409        let cat = parse(&catalog_json(
410            r#", "engines": ["transit", "kv2"], "capabilities": []"#,
411            SIGNER,
412        ));
413        let summary =
414            enforce_capabilities(&cat, CapabilityPolicy::Strict).expect("requirements met");
415        assert_eq!(summary.enforced, 1);
416        assert_eq!(summary.skipped_undeclared, 0);
417    }
418
419    #[test]
420    fn missing_engine_is_fatal_under_strict() {
421        // Key needs transit; backend declares only kv2.
422        let cat = parse(&catalog_json(
423            r#", "engines": ["kv2"], "capabilities": []"#,
424            SIGNER,
425        ));
426        let err = enforce_capabilities(&cat, CapabilityPolicy::Strict).expect_err("should fail");
427        assert_eq!(err.0.len(), 1);
428        assert_eq!(err.0[0].key.as_deref(), Some("web.sig"));
429        assert_eq!(err.0[0].missing_engines, vec![Engine::Transit]);
430    }
431
432    #[test]
433    fn missing_engine_on_error_key_is_fatal_in_degraded_too() {
434        // Default missing policy is `error`, so a degraded run still fails it.
435        let cat = parse(&catalog_json(
436            r#", "engines": ["kv2"], "capabilities": []"#,
437            SIGNER,
438        ));
439        let err = enforce_capabilities(&cat, CapabilityPolicy::Degraded)
440            .expect_err("error-key gap is fatal");
441        assert_eq!(err.0.len(), 1);
442    }
443
444    #[test]
445    fn missing_engine_on_warn_key_is_tolerated_in_degraded() {
446        let warn_key = r#""web.sig": { "class": "asymmetric", "keyType": "ed25519", "backend": "bao", "engine": "transit", "path": "web", "writable": true, "missing": "warn", "description": "d" }"#;
447        let cat = parse(&catalog_json(
448            r#", "engines": ["kv2"], "capabilities": []"#,
449            warn_key,
450        ));
451        let summary =
452            enforce_capabilities(&cat, CapabilityPolicy::Degraded).expect("warn key tolerated");
453        assert_eq!(summary.warnings, 1);
454    }
455
456    #[test]
457    fn explicit_requires_gap_is_fatal_under_strict() {
458        // Backend provides transit but the deployment requires byok-import.
459        let cat = parse(&catalog_json(
460            r#", "engines": ["transit"], "capabilities": [], "requires": ["byok-import"]"#,
461            SIGNER,
462        ));
463        let err = enforce_capabilities(&cat, CapabilityPolicy::Strict).expect_err("requires unmet");
464        let gap = err
465            .0
466            .iter()
467            .find(|g| g.key.is_none())
468            .expect("backend-level gap");
469        assert_eq!(gap.missing_capabilities, vec![Capability::ByokImport]);
470    }
471
472    #[test]
473    fn generate_key_type_must_be_in_static_backend_preset() {
474        let cat = parse(&catalog_json(
475            r#", "engines": ["transit"], "capabilities": [], "mintKeyTypes": ["ed25519"]"#,
476            GENERATED_RSA_SIGNER,
477        ));
478        let err = enforce_capabilities(&cat, CapabilityPolicy::Strict)
479            .expect_err("rsa generate must require preset support");
480        assert_eq!(err.0.len(), 1);
481        let gap = &err.0[0];
482        assert_eq!(gap.key.as_deref(), Some("web.sig"));
483        assert_eq!(gap.missing_mint_key_types, vec![KeyAlgorithm::Rsa2048]);
484    }
485
486    #[test]
487    fn generate_key_type_declared_in_static_backend_preset_passes() {
488        let cat = parse(&catalog_json(
489            r#", "engines": ["transit"], "capabilities": [], "mintKeyTypes": ["rsa-2048"]"#,
490            GENERATED_RSA_SIGNER,
491        ));
492        enforce_capabilities(&cat, CapabilityPolicy::Strict).expect("rsa generate supported");
493    }
494
495    #[test]
496    fn explicit_requires_met_passes() {
497        let cat = parse(&catalog_json(
498            r#", "engines": ["transit"], "capabilities": ["byok-import"], "requires": ["byok-import"]"#,
499            SIGNER,
500        ));
501        enforce_capabilities(&cat, CapabilityPolicy::Strict).expect("requires satisfied");
502    }
503
504    #[test]
505    fn off_policy_skips_everything() {
506        let cat = parse(&catalog_json(
507            r#", "engines": ["kv2"], "capabilities": []"#,
508            SIGNER,
509        ));
510        let summary = enforce_capabilities(&cat, CapabilityPolicy::Off).expect("off never fails");
511        assert_eq!(summary, CapabilitySummary::default());
512    }
513
514    #[test]
515    fn policy_parses_and_displays() {
516        for (s, p) in [
517            ("strict", CapabilityPolicy::Strict),
518            ("degraded", CapabilityPolicy::Degraded),
519            ("off", CapabilityPolicy::Off),
520        ] {
521            assert_eq!(s.parse::<CapabilityPolicy>().unwrap(), p);
522            assert_eq!(p.to_string(), s);
523        }
524        assert!("bogus".parse::<CapabilityPolicy>().is_err());
525    }
526}