Skip to main content

basil_core/core/
capability.rs

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