1use std::collections::BTreeSet;
47use std::str::FromStr;
48
49use crate::catalog::{Capability, Catalog, Engine, KeyAlgorithm, MissingPolicy};
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
53pub enum CapabilityPolicy {
54 #[default]
56 Strict,
57 Degraded,
60 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#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct CapabilityGap {
93 pub backend: String,
95 pub key: Option<String>,
98 pub missing_engines: Vec<Engine>,
100 pub missing_capabilities: Vec<Capability>,
102 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#[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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
155pub struct CapabilitySummary {
156 pub enforced: usize,
158 pub skipped_undeclared: usize,
160 pub warnings: usize,
162}
163
164pub fn enforce_capabilities(
176 catalog: &Catalog,
177 policy: CapabilityPolicy,
178) -> Result<CapabilitySummary, CapabilityError> {
179 let mut summary = CapabilitySummary::default();
180 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 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 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 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 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 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 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 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 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 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 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}