use std::collections::BTreeSet;
use std::str::FromStr;
use crate::catalog::{Capability, Catalog, Engine, KeyAlgorithm, MissingPolicy};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CapabilityPolicy {
#[default]
Strict,
Degraded,
Off,
}
impl FromStr for CapabilityPolicy {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"strict" => Ok(Self::Strict),
"degraded" => Ok(Self::Degraded),
"off" => Ok(Self::Off),
other => Err(format!(
"invalid capability-policy `{other}` (want one of: strict, degraded, off)"
)),
}
}
}
impl std::fmt::Display for CapabilityPolicy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Strict => "strict",
Self::Degraded => "degraded",
Self::Off => "off",
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CapabilityGap {
pub backend: String,
pub key: Option<String>,
pub missing_engines: Vec<Engine>,
pub missing_capabilities: Vec<Capability>,
pub missing_mint_key_types: Vec<KeyAlgorithm>,
}
impl std::fmt::Display for CapabilityGap {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.key {
Some(k) => write!(f, "key `{k}` (backend `{}`)", self.backend)?,
None => write!(f, "backend `{}` requires", self.backend)?,
}
if !self.missing_engines.is_empty() {
let engines: Vec<&str> = self.missing_engines.iter().map(|e| e.token()).collect();
write!(f, " needs engine(s) [{}]", engines.join(", "))?;
}
if !self.missing_capabilities.is_empty() {
let caps: Vec<&str> = self
.missing_capabilities
.iter()
.map(Capability::token)
.collect();
write!(f, " needs capabilit(ies) [{}]", caps.join(", "))?;
}
if !self.missing_mint_key_types.is_empty() {
let types: Vec<&str> = self
.missing_mint_key_types
.iter()
.map(|alg| alg.token())
.collect();
write!(f, " needs mint key type(s) [{}]", types.join(", "))?;
}
Ok(())
}
}
#[derive(Debug, thiserror::Error)]
#[error(
"backend capability check failed ({} unmet requirement(s)):\n - {}",
.0.len(),
format_gaps(&.0)
)]
pub struct CapabilityError(pub Vec<CapabilityGap>);
fn format_gaps(gaps: &[CapabilityGap]) -> String {
gaps.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n - ")
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct CapabilitySummary {
pub enforced: usize,
pub skipped_undeclared: usize,
pub warnings: usize,
}
pub fn enforce_capabilities(
catalog: &Catalog,
policy: CapabilityPolicy,
) -> Result<CapabilitySummary, CapabilityError> {
let mut summary = CapabilitySummary::default();
if policy == CapabilityPolicy::Off {
return Ok(summary);
}
let mut fatal: Vec<CapabilityGap> = Vec::new();
for (bname, backend) in &catalog.backends {
if backend.engines.is_empty()
&& backend.capabilities.is_empty()
&& backend.mint_key_types.is_empty()
{
summary.skipped_undeclared += 1;
tracing::warn!(
backend = %bname,
"backend declares no engines/capabilities/mint key types; capability enforcement skipped \
(declare a provides set, or set capability-policy=\"off\" to silence)"
);
continue;
}
summary.enforced += 1;
let provided_engines: BTreeSet<Engine> = backend.engines.iter().copied().collect();
let provided_caps: BTreeSet<Capability> = backend.capabilities.iter().cloned().collect();
let provided_mint_key_types: BTreeSet<KeyAlgorithm> =
backend.mint_key_types.iter().copied().collect();
for cap in backend.capabilities.iter().chain(&backend.requires) {
if !cap.is_known() {
tracing::warn!(
backend = %bname, capability = %cap,
"unrecognized capability token; treated as opaque for enforcement"
);
}
}
let missing_req: Vec<Capability> = backend
.requires
.iter()
.filter(|c| !provided_caps.contains(c))
.cloned()
.collect();
if !missing_req.is_empty() {
let gap = CapabilityGap {
backend: bname.clone(),
key: None,
missing_engines: Vec::new(),
missing_capabilities: missing_req,
missing_mint_key_types: Vec::new(),
};
match policy {
CapabilityPolicy::Strict => fatal.push(gap),
CapabilityPolicy::Degraded => {
tracing::warn!(gap = %gap, "capability gap (degraded; backend-level requires)");
summary.warnings += 1;
}
CapabilityPolicy::Off => {}
}
}
for (gap, missing) in key_capability_gaps(
catalog,
bname,
&provided_engines,
&provided_caps,
&provided_mint_key_types,
) {
match policy {
CapabilityPolicy::Strict => fatal.push(gap),
CapabilityPolicy::Degraded => {
if missing == MissingPolicy::Error {
fatal.push(gap);
} else {
tracing::warn!(gap = %gap, "capability gap (degraded; non-required key)");
summary.warnings += 1;
}
}
CapabilityPolicy::Off => {}
}
}
}
if fatal.is_empty() {
Ok(summary)
} else {
fatal.sort_by(|a, b| (&a.backend, &a.key).cmp(&(&b.backend, &b.key)));
Err(CapabilityError(fatal))
}
}
fn key_capability_gaps(
catalog: &Catalog,
backend: &str,
provided_engines: &BTreeSet<Engine>,
provided_caps: &BTreeSet<Capability>,
provided_mint_key_types: &BTreeSet<KeyAlgorithm>,
) -> Vec<(CapabilityGap, MissingPolicy)> {
let mut gaps = Vec::new();
for (kname, entry) in &catalog.keys {
if entry.backend != backend {
continue;
}
let engine = entry.effective_engine();
let missing_engines = if provided_engines.contains(&engine) {
Vec::new()
} else {
vec![engine]
};
let missing_capabilities: Vec<Capability> = entry
.key_type
.and_then(KeyAlgorithm::required_capability)
.filter(|c| !provided_caps.contains(c))
.into_iter()
.collect();
let missing_mint_key_types: Vec<KeyAlgorithm> = required_mint_key_type(entry)
.filter(|alg| !provided_mint_key_types.contains(alg))
.into_iter()
.collect();
if missing_engines.is_empty()
&& missing_capabilities.is_empty()
&& missing_mint_key_types.is_empty()
{
continue;
}
gaps.push((
CapabilityGap {
backend: backend.to_owned(),
key: Some(kname.clone()),
missing_engines,
missing_capabilities,
missing_mint_key_types,
},
entry.missing,
));
}
gaps
}
fn required_mint_key_type(entry: &crate::catalog::KeyEntry) -> Option<KeyAlgorithm> {
if entry.class == crate::catalog::Class::Asymmetric
&& entry.effective_engine() == Engine::Transit
&& entry.missing == MissingPolicy::Generate
{
entry.key_type
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
fn catalog_json(provides: &str, keys: &str) -> String {
format!(
r#"{{
"schemaVersion": 1,
"backends": {{ "bao": {{ "kind": "vault", "addr": "http://127.0.0.1:8200"{provides} }} }},
"keys": {{ {keys} }}
}}"#
)
}
fn parse(json: &str) -> Catalog {
serde_json::from_str(json).expect("catalog parses")
}
const SIGNER: &str = r#""web.sig": { "class": "asymmetric", "keyType": "ed25519", "backend": "bao", "engine": "transit", "path": "web", "writable": true, "description": "d" }"#;
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" }"#;
#[test]
fn capability_token_round_trips_known_and_unknown() {
for tok in [
"byok-import",
"prehash-sign",
"pqc-transit",
"pki-crl",
"jwt-auth",
"approle-auth",
] {
let cap = Capability::from(tok.to_string());
assert!(cap.is_known(), "{tok} should be known");
assert_eq!(cap.token(), tok);
}
let unknown = Capability::from("ml-kem-1024-transit".to_string());
assert!(!unknown.is_known());
assert_eq!(unknown.token(), "ml-kem-1024-transit");
assert_eq!(
unknown,
Capability::Other("ml-kem-1024-transit".to_string())
);
}
#[test]
fn unknown_capability_deserializes_without_error() {
let cat = parse(&catalog_json(
r#", "engines": ["transit"], "capabilities": ["some-2027-feature"]"#,
SIGNER,
));
let caps = &cat.backends["bao"].capabilities;
assert_eq!(caps, &[Capability::Other("some-2027-feature".to_string())]);
}
#[test]
fn undeclared_backend_is_skipped_even_under_strict() {
let cat = parse(&catalog_json("", SIGNER));
let summary =
enforce_capabilities(&cat, CapabilityPolicy::Strict).expect("skipped, not fatal");
assert_eq!(summary.skipped_undeclared, 1);
assert_eq!(summary.enforced, 0);
}
#[test]
fn declared_backend_satisfying_requirements_passes() {
let cat = parse(&catalog_json(
r#", "engines": ["transit", "kv2"], "capabilities": []"#,
SIGNER,
));
let summary =
enforce_capabilities(&cat, CapabilityPolicy::Strict).expect("requirements met");
assert_eq!(summary.enforced, 1);
assert_eq!(summary.skipped_undeclared, 0);
}
#[test]
fn missing_engine_is_fatal_under_strict() {
let cat = parse(&catalog_json(
r#", "engines": ["kv2"], "capabilities": []"#,
SIGNER,
));
let err = enforce_capabilities(&cat, CapabilityPolicy::Strict).expect_err("should fail");
assert_eq!(err.0.len(), 1);
assert_eq!(err.0[0].key.as_deref(), Some("web.sig"));
assert_eq!(err.0[0].missing_engines, vec![Engine::Transit]);
}
#[test]
fn missing_engine_on_error_key_is_fatal_in_degraded_too() {
let cat = parse(&catalog_json(
r#", "engines": ["kv2"], "capabilities": []"#,
SIGNER,
));
let err = enforce_capabilities(&cat, CapabilityPolicy::Degraded)
.expect_err("error-key gap is fatal");
assert_eq!(err.0.len(), 1);
}
#[test]
fn missing_engine_on_warn_key_is_tolerated_in_degraded() {
let warn_key = r#""web.sig": { "class": "asymmetric", "keyType": "ed25519", "backend": "bao", "engine": "transit", "path": "web", "writable": true, "missing": "warn", "description": "d" }"#;
let cat = parse(&catalog_json(
r#", "engines": ["kv2"], "capabilities": []"#,
warn_key,
));
let summary =
enforce_capabilities(&cat, CapabilityPolicy::Degraded).expect("warn key tolerated");
assert_eq!(summary.warnings, 1);
}
#[test]
fn explicit_requires_gap_is_fatal_under_strict() {
let cat = parse(&catalog_json(
r#", "engines": ["transit"], "capabilities": [], "requires": ["byok-import"]"#,
SIGNER,
));
let err = enforce_capabilities(&cat, CapabilityPolicy::Strict).expect_err("requires unmet");
let gap = err
.0
.iter()
.find(|g| g.key.is_none())
.expect("backend-level gap");
assert_eq!(gap.missing_capabilities, vec![Capability::ByokImport]);
}
#[test]
fn generate_key_type_must_be_in_static_backend_preset() {
let cat = parse(&catalog_json(
r#", "engines": ["transit"], "capabilities": [], "mintKeyTypes": ["ed25519"]"#,
GENERATED_RSA_SIGNER,
));
let err = enforce_capabilities(&cat, CapabilityPolicy::Strict)
.expect_err("rsa generate must require preset support");
assert_eq!(err.0.len(), 1);
let gap = &err.0[0];
assert_eq!(gap.key.as_deref(), Some("web.sig"));
assert_eq!(gap.missing_mint_key_types, vec![KeyAlgorithm::Rsa2048]);
}
#[test]
fn generate_key_type_declared_in_static_backend_preset_passes() {
let cat = parse(&catalog_json(
r#", "engines": ["transit"], "capabilities": [], "mintKeyTypes": ["rsa-2048"]"#,
GENERATED_RSA_SIGNER,
));
enforce_capabilities(&cat, CapabilityPolicy::Strict).expect("rsa generate supported");
}
#[test]
fn explicit_requires_met_passes() {
let cat = parse(&catalog_json(
r#", "engines": ["transit"], "capabilities": ["byok-import"], "requires": ["byok-import"]"#,
SIGNER,
));
enforce_capabilities(&cat, CapabilityPolicy::Strict).expect("requires satisfied");
}
#[test]
fn off_policy_skips_everything() {
let cat = parse(&catalog_json(
r#", "engines": ["kv2"], "capabilities": []"#,
SIGNER,
));
let summary = enforce_capabilities(&cat, CapabilityPolicy::Off).expect("off never fails");
assert_eq!(summary, CapabilitySummary::default());
}
#[test]
fn policy_parses_and_displays() {
for (s, p) in [
("strict", CapabilityPolicy::Strict),
("degraded", CapabilityPolicy::Degraded),
("off", CapabilityPolicy::Off),
] {
assert_eq!(s.parse::<CapabilityPolicy>().unwrap(), p);
assert_eq!(p.to_string(), s);
}
assert!("bogus".parse::<CapabilityPolicy>().is_err());
}
}