1use std::collections::BTreeSet;
43use std::str::FromStr;
44
45use crate::catalog::{Capability, Catalog, Engine, KeyAlgorithm, MissingPolicy};
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
49pub enum CapabilityPolicy {
50 #[default]
52 Strict,
53 Degraded,
56 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#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct CapabilityGap {
89 pub backend: String,
91 pub key: Option<String>,
94 pub missing_engines: Vec<Engine>,
96 pub missing_capabilities: Vec<Capability>,
98 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#[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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
151pub struct CapabilitySummary {
152 pub enforced: usize,
154 pub skipped_undeclared: usize,
156 pub warnings: usize,
158}
159
160pub fn enforce_capabilities(
172 catalog: &Catalog,
173 policy: CapabilityPolicy,
174) -> Result<CapabilitySummary, CapabilityError> {
175 let mut summary = CapabilitySummary::default();
176 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 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 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 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 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 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 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 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 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 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 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}