1use crate::backend::BackendError;
29use crate::catalog::{Class, Engine, GenerateSpec, KeyAlgorithm, KeyEntry, MissingPolicy};
30use crate::manager::{BackendManager, ManagerError, Routed};
31use basil_proto::{AeadAlgorithm, KeyType};
32
33#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
35pub struct ReconcileSummary {
36 pub present: usize,
38 pub generated: usize,
40 pub warned: usize,
42}
43
44#[derive(Debug, thiserror::Error)]
50pub enum ReconcileError {
51 #[error("required key(s) absent from backend: {}", .0.join(", "))]
54 RequiredMissing(Vec<String>),
55
56 #[error("probing key `{key}` failed (backend unreachable or rejecting): {source}")]
61 Probe {
62 key: String,
64 source: BackendError,
66 },
67
68 #[error("generating key `{key}` failed: {source}")]
70 Generate {
71 key: String,
73 source: ManagerError,
75 },
76}
77
78enum Existence {
81 Present,
82 Absent,
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum KeyStatus {
89 Present,
91 Missing(MissingPolicy),
96}
97
98#[derive(Debug, Clone)]
100pub struct KeyCheck {
101 pub name: String,
103 pub status: KeyStatus,
105}
106
107#[derive(Debug, Clone, Default)]
115pub struct CheckReport {
116 pub keys: Vec<KeyCheck>,
118}
119
120impl CheckReport {
121 #[must_use]
123 pub fn present_count(&self) -> usize {
124 self.keys
125 .iter()
126 .filter(|k| k.status == KeyStatus::Present)
127 .count()
128 }
129
130 pub fn missing(&self) -> impl Iterator<Item = (&str, MissingPolicy)> {
132 self.keys.iter().filter_map(|k| match k.status {
133 KeyStatus::Missing(policy) => Some((k.name.as_str(), policy)),
134 KeyStatus::Present => None,
135 })
136 }
137
138 #[must_use]
143 pub fn required_missing(&self) -> Vec<&str> {
144 self.missing()
145 .filter(|(_, policy)| *policy == MissingPolicy::Error)
146 .map(|(name, _)| name)
147 .collect()
148 }
149
150 #[must_use]
154 pub fn should_fail_required(&self) -> bool {
155 self.missing()
156 .any(|(_, policy)| policy == MissingPolicy::Error)
157 }
158}
159
160impl BackendManager {
161 pub async fn reconcile(&self) -> Result<ReconcileSummary, ReconcileError> {
176 use crate::catalog::MissingPolicy;
177
178 let mut summary = ReconcileSummary::default();
179 let mut required_missing: Vec<String> = Vec::new();
180
181 let keys: Vec<(String, KeyEntry)> = self
184 .keys()
185 .map(|(name, entry)| (name.clone(), entry.clone()))
186 .collect();
187
188 for (name, entry) in &keys {
189 match self.probe(name).await? {
190 Existence::Present => summary.present += 1,
191 Existence::Absent => match entry.missing {
192 MissingPolicy::Error => {
193 required_missing.push(name.clone());
194 }
195 MissingPolicy::Warn => {
196 tracing::warn!(
197 key = %name,
198 "catalog key absent from backend (missing=warn); ops will fail until it exists"
199 );
200 summary.warned += 1;
201 }
202 MissingPolicy::Generate => {
203 self.generate_missing(name, entry).await?;
204 tracing::info!(key = %name, "generated absent key (missing=generate)");
205 summary.generated += 1;
206 }
207 },
208 }
209 }
210
211 if !required_missing.is_empty() {
212 required_missing.sort();
213 return Err(ReconcileError::RequiredMissing(required_missing));
214 }
215 Ok(summary)
216 }
217
218 pub async fn check(&self) -> Result<CheckReport, ReconcileError> {
237 let mut report = CheckReport::default();
239 for (name, entry) in self.keys() {
240 let status = match self.probe(name).await? {
241 Existence::Present => KeyStatus::Present,
242 Existence::Absent => KeyStatus::Missing(entry.missing),
243 };
244 report.keys.push(KeyCheck {
245 name: name.clone(),
246 status,
247 });
248 }
249 Ok(report)
250 }
251
252 async fn probe(&self, name: &str) -> Result<Existence, ReconcileError> {
261 let routed = self.resolve(name).map_err(|source| ReconcileError::Probe {
262 key: name.to_string(),
263 source: match source {
266 ManagerError::Backend(e) => e,
267 other => BackendError::Backend(other.to_string()),
268 },
269 })?;
270
271 let result = match (routed.class(), routed.engine) {
277 (Class::Asymmetric, Engine::Kv2) | (Class::Sealing, _) => {
283 self.probe_materialize_to_use(&routed).await
284 }
285 (Class::Asymmetric | Class::Symmetric, _) => {
286 routed.backend.key_metadata(routed.path()).await.map(|_| ())
287 }
288 (Class::Value | Class::Public, _) => {
289 routed.backend.kv_get(routed.path(), None).await.map(|_| ())
290 }
291 };
292
293 match result {
294 Ok(()) => Ok(Existence::Present),
295 Err(BackendError::KeyNotFound(_)) => Ok(Existence::Absent),
296 Err(source) => Err(ReconcileError::Probe {
297 key: name.to_string(),
298 source,
299 }),
300 }
301 }
302
303 async fn probe_materialize_to_use(&self, routed: &Routed<'_>) -> Result<(), BackendError> {
312 routed.backend.kv_get(routed.path(), None).await?;
314 let public_path = routed.public_path().ok_or_else(|| {
316 BackendError::KeyNotFound(format!(
317 "{} has no public_path; its public half is not provisioned",
318 routed.path()
319 ))
320 })?;
321 routed.backend.kv_get(public_path, None).await?;
322 Ok(())
323 }
324
325 async fn generate_missing(&self, name: &str, entry: &KeyEntry) -> Result<(), ReconcileError> {
329 let to_err = |source| ReconcileError::Generate {
330 key: name.to_string(),
331 source,
332 };
333 let routed = self.resolve(name).map_err(to_err)?;
334 let path = routed.path().to_string();
335
336 match entry.class {
337 Class::Asymmetric if routed.engine == Engine::Kv2 => {
344 return Err(ReconcileError::Generate {
345 key: name.to_string(),
346 source: ManagerError::Backend(BackendError::Backend(format!(
347 "value-store signing key `{name}` (engine=kv2) cannot be \
348 generated in-broker; provision its Ed25519 seed out of band"
349 ))),
350 });
351 }
352 Class::Asymmetric => {
353 let key_type = asym_key_type(name, entry.key_type).map_err(to_err)?;
354 routed
355 .require_mint_key_type("generate", key_type)
356 .map_err(to_err)?;
357 routed
358 .backend
359 .create_named_key(&path, key_type)
360 .await
361 .map_err(ManagerError::from)
362 .map_err(to_err)?;
363 }
364 Class::Symmetric => {
365 let aead = sym_aead(name, entry.key_type).map_err(to_err)?;
366 routed
367 .backend
368 .create_named_aead(&path, aead)
369 .await
370 .map_err(ManagerError::from)
371 .map_err(to_err)?;
372 }
373 Class::Sealing => {
374 return Err(ReconcileError::Generate {
379 key: name.to_string(),
380 source: ManagerError::Backend(BackendError::Backend(format!(
381 "sealing key `{name}` cannot be generated in-broker; \
382 provision its X25519 private out of band"
383 ))),
384 });
385 }
386 Class::Value | Class::Public => {
387 let spec: &GenerateSpec =
391 entry
392 .generate
393 .as_ref()
394 .ok_or_else(|| ReconcileError::Generate {
395 key: name.to_string(),
396 source: ManagerError::Backend(BackendError::Backend(
397 "missing=generate value/public key has no generate recipe".into(),
398 )),
399 })?;
400 for write in self
401 .generated_writes_for_key(name, spec)
402 .await
403 .map_err(to_err)?
404 {
405 let write_route = self.resolve(&write.key_id).map_err(to_err)?;
406 write_route
407 .backend
408 .kv_put(write_route.path(), &write.value)
409 .await
410 .map_err(ManagerError::from)
411 .map_err(to_err)?;
412 }
413 }
414 }
415 Ok(())
416 }
417}
418
419fn asym_key_type(name: &str, key_type: Option<KeyAlgorithm>) -> Result<KeyType, ManagerError> {
423 match key_type {
424 Some(KeyAlgorithm::Ed25519) => Ok(KeyType::Ed25519),
425 Some(KeyAlgorithm::Ed25519Nkey) => Ok(KeyType::Ed25519Nkey),
426 Some(KeyAlgorithm::Rsa2048) => Ok(KeyType::Rsa2048),
427 Some(KeyAlgorithm::EcdsaP256) => Ok(KeyType::EcdsaP256),
428 Some(KeyAlgorithm::EcdsaP384) => Ok(KeyType::EcdsaP384),
429 Some(KeyAlgorithm::EcdsaP521) => Ok(KeyType::EcdsaP521),
430 Some(KeyAlgorithm::MlDsa44 | KeyAlgorithm::MlDsa65 | KeyAlgorithm::MlDsa87) => {
436 Err(ManagerError::Backend(BackendError::Backend(format!(
437 "ML-DSA signing key `{name}` is software-custodied; provision it \
438 via the `new_key` provider path, not startup generate"
439 ))))
440 }
441 Some(
442 KeyAlgorithm::Aes256Gcm
443 | KeyAlgorithm::ChaCha20Poly1305
444 | KeyAlgorithm::X25519
445 | KeyAlgorithm::MlKem512
446 | KeyAlgorithm::MlKem768
447 | KeyAlgorithm::MlKem1024,
448 )
449 | None => Err(ManagerError::Backend(BackendError::Backend(format!(
450 "asymmetric key `{name}` has no signing keyType to generate from"
451 )))),
452 }
453}
454
455fn sym_aead(name: &str, key_type: Option<KeyAlgorithm>) -> Result<AeadAlgorithm, ManagerError> {
458 match key_type {
459 Some(KeyAlgorithm::Aes256Gcm) => Ok(AeadAlgorithm::Aes256Gcm),
460 Some(KeyAlgorithm::ChaCha20Poly1305) => Ok(AeadAlgorithm::Chacha20Poly1305),
461 _ => Err(ManagerError::Backend(BackendError::Backend(format!(
462 "symmetric key `{name}` has no AEAD keyType to generate from"
463 )))),
464 }
465}
466
467#[cfg(test)]
468mod tests {
469 use super::*;
470 use crate::backend::{Backend, KeyMetadata, KvValue, NewKey, PublicKey};
471 use crate::catalog::Catalog;
472 use async_trait::async_trait;
473 use std::collections::BTreeMap;
474 use std::sync::Mutex;
475 use std::sync::atomic::{AtomicUsize, Ordering};
476
477 #[derive(Clone, Copy)]
479 enum Probe {
480 Present,
482 Absent,
484 Unreachable,
486 }
487
488 #[derive(Default)]
492 struct Recorder {
493 create_named_key_calls: AtomicUsize,
494 create_named_aead_calls: AtomicUsize,
495 kv_put_calls: AtomicUsize,
496 last_create_path: Mutex<Option<String>>,
497 last_kv_put: Mutex<Option<(String, Vec<u8>)>>,
498 last_aead: Mutex<Option<AeadAlgorithm>>,
499 last_key_type: Mutex<Option<KeyType>>,
500 }
501
502 struct MockBackend {
503 probe: Probe,
504 rec: std::sync::Arc<Recorder>,
505 }
506
507 impl MockBackend {
508 fn new(probe: Probe) -> (Self, std::sync::Arc<Recorder>) {
509 let rec = std::sync::Arc::new(Recorder::default());
510 (
511 Self {
512 probe,
513 rec: rec.clone(),
514 },
515 rec,
516 )
517 }
518 }
519
520 #[async_trait]
521 impl Backend for MockBackend {
522 fn kind(&self) -> &'static str {
523 "mock"
524 }
525
526 async fn new_key(&self, _key_type: KeyType) -> Result<NewKey, BackendError> {
527 Err(BackendError::Unsupported(
528 "new_key (unused in reconcile tests)",
529 ))
530 }
531
532 async fn public_key(&self, _key_id: &str) -> Result<Vec<u8>, BackendError> {
533 Err(BackendError::Unsupported("public_key"))
534 }
535
536 async fn public_key_with_meta(&self, _key_id: &str) -> Result<PublicKey, BackendError> {
537 Err(BackendError::Unsupported("public_key_with_meta"))
538 }
539
540 async fn key_metadata(&self, _key_id: &str) -> Result<KeyMetadata, BackendError> {
542 match self.probe {
543 Probe::Present => Ok(KeyMetadata {
544 key_type: Some(KeyType::Ed25519),
545 latest_version: 1,
546 }),
547 Probe::Absent => Err(BackendError::KeyNotFound("absent".into())),
548 Probe::Unreachable => Err(BackendError::Transport("connection refused".into())),
549 }
550 }
551
552 async fn kv_get(
554 &self,
555 _key_id: &str,
556 _version: Option<u32>,
557 ) -> Result<KvValue, BackendError> {
558 match self.probe {
559 Probe::Present => Ok(KvValue {
560 value: b"present".to_vec(),
561 version: 1,
562 }),
563 Probe::Absent => Err(BackendError::KeyNotFound("absent".into())),
564 Probe::Unreachable => Err(BackendError::Transport("connection refused".into())),
565 }
566 }
567
568 async fn kv_get_secret(
571 &self,
572 _key_id: &str,
573 _version: Option<u32>,
574 ) -> Result<zeroize::Zeroizing<Vec<u8>>, BackendError> {
575 match self.probe {
576 Probe::Present => Ok(zeroize::Zeroizing::new(b"present".to_vec())),
577 Probe::Absent => Err(BackendError::KeyNotFound("absent".into())),
578 Probe::Unreachable => Err(BackendError::Transport("connection refused".into())),
579 }
580 }
581
582 async fn create_named_key(
583 &self,
584 key_id: &str,
585 key_type: KeyType,
586 ) -> Result<NewKey, BackendError> {
587 self.rec
588 .create_named_key_calls
589 .fetch_add(1, Ordering::SeqCst);
590 *self.rec.last_create_path.lock().unwrap() = Some(key_id.to_string());
591 *self.rec.last_key_type.lock().unwrap() = Some(key_type);
592 Ok(NewKey {
593 key_id: key_id.to_string(),
594 public_key: vec![1, 2, 3],
595 })
596 }
597
598 async fn create_named_aead(
599 &self,
600 key_id: &str,
601 aead: AeadAlgorithm,
602 ) -> Result<(), BackendError> {
603 self.rec
604 .create_named_aead_calls
605 .fetch_add(1, Ordering::SeqCst);
606 *self.rec.last_create_path.lock().unwrap() = Some(key_id.to_string());
607 *self.rec.last_aead.lock().unwrap() = Some(aead);
608 Ok(())
609 }
610
611 async fn kv_put(&self, key_id: &str, value: &[u8]) -> Result<u32, BackendError> {
612 self.rec.kv_put_calls.fetch_add(1, Ordering::SeqCst);
613 *self.rec.last_kv_put.lock().unwrap() = Some((key_id.to_string(), value.to_vec()));
614 Ok(1)
615 }
616
617 async fn sign(&self, _key_id: &str, _message: &[u8]) -> Result<Vec<u8>, BackendError> {
618 Err(BackendError::Unsupported("sign"))
619 }
620
621 async fn verify(
622 &self,
623 _key_id: &str,
624 _message: &[u8],
625 _signature: &[u8],
626 ) -> Result<bool, BackendError> {
627 Err(BackendError::Unsupported("verify"))
628 }
629 }
630
631 const CATALOG: &str = r#"{
634 "schemaVersion": 1,
635 "backends": { "b": { "kind": "vault", "addr": "http://127.0.0.1:8200" } },
636 "keys": {
637 "req.signer": {
638 "class": "asymmetric", "keyType": "ed25519", "backend": "b",
639 "path": "req-signer", "writable": true, "missing": "error",
640 "description": "a required signing key"
641 },
642 "warn.value": {
643 "class": "value", "backend": "b", "engine": "kv2",
644 "path": "secret/data/warn/value", "writable": true, "missing": "warn",
645 "description": "a warn-on-missing value"
646 },
647 "gen.signer": {
648 "class": "asymmetric", "keyType": "ed25519", "backend": "b",
649 "path": "gen-signer", "writable": true, "missing": "generate",
650 "description": "a generate-on-missing signing key"
651 },
652 "gen.box": {
653 "class": "symmetric", "keyType": "aes-256-gcm", "backend": "b",
654 "path": "gen-box", "writable": true, "missing": "generate",
655 "description": "a generate-on-missing AEAD key"
656 },
657 "gen.value": {
658 "class": "value", "backend": "b", "engine": "kv2",
659 "path": "secret/data/gen/value", "writable": true, "missing": "generate",
660 "generate": { "format": "ascii-printable", "bytes": 24 },
661 "description": "a generate-on-missing value"
662 }
663 }
664 }"#;
665
666 fn parse() -> Catalog {
667 serde_json::from_str(CATALOG).expect("catalog parses")
668 }
669
670 fn manager_with(probe: Probe) -> (BackendManager, std::sync::Arc<Recorder>) {
673 let (backend, rec) = MockBackend::new(probe);
674 let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
675 backends.insert("b".into(), Box::new(backend));
676 let mgr = BackendManager::new(parse(), backends).expect("manager constructs");
677 (mgr, rec)
678 }
679
680 #[tokio::test]
681 async fn all_present_is_a_noop_with_present_count() {
682 let (mgr, rec) = manager_with(Probe::Present);
683 let summary = mgr.reconcile().await.expect("all-present reconciles ok");
684 assert_eq!(summary.present, 5);
685 assert_eq!(summary.generated, 0);
686 assert_eq!(summary.warned, 0);
687 assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 0);
689 assert_eq!(rec.create_named_aead_calls.load(Ordering::SeqCst), 0);
690 assert_eq!(rec.kv_put_calls.load(Ordering::SeqCst), 0);
691 }
692
693 #[tokio::test]
694 async fn required_missing_is_fatal_and_reports_the_key() {
695 let (mgr, _rec) = manager_with(Probe::Absent);
698 let err = mgr
699 .reconcile()
700 .await
701 .expect_err("a required-missing key must fail closed");
702 match err {
703 ReconcileError::RequiredMissing(keys) => {
704 assert_eq!(keys, vec!["req.signer".to_string()]);
705 }
706 other => panic!("expected RequiredMissing, got {other:?}"),
707 }
708 }
709
710 #[tokio::test]
711 async fn warn_missing_is_ok_and_counted() {
712 const NO_REQUIRED: &str = r#"{
718 "schemaVersion": 1,
719 "backends": {
720 "b": {
721 "kind": "vault", "addr": "http://127.0.0.1:8200",
722 "engines": ["transit", "kv2"], "capabilities": [],
723 "mintKeyTypes": ["ed25519"]
724 }
725 },
726 "keys": {
727 "warn.value": {
728 "class": "value", "backend": "b", "engine": "kv2",
729 "path": "secret/data/warn/value", "writable": true, "missing": "warn",
730 "description": "a warn-on-missing value"
731 }
732 }
733 }"#;
734 let cat: Catalog = serde_json::from_str(NO_REQUIRED).expect("parses");
735 let (backend, _rec) = MockBackend::new(Probe::Absent);
736 let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
737 backends.insert("b".into(), Box::new(backend));
738 let mgr = BackendManager::new(cat, backends).expect("constructs");
739 let summary = mgr.reconcile().await.expect("warn-only reconciles ok");
740 assert_eq!(summary.warned, 1);
741 assert_eq!(summary.present, 0);
742 assert_eq!(summary.generated, 0);
743 }
744
745 #[tokio::test]
746 async fn generate_missing_creates_crypto_and_value_keys() {
747 const GEN_ONLY: &str = r#"{
750 "schemaVersion": 1,
751 "backends": { "b": { "kind": "vault", "addr": "http://127.0.0.1:8200" } },
752 "keys": {
753 "gen.signer": {
754 "class": "asymmetric", "keyType": "ed25519", "backend": "b",
755 "path": "gen-signer", "writable": true, "missing": "generate",
756 "description": "asym generate"
757 },
758 "gen.box": {
759 "class": "symmetric", "keyType": "aes-256-gcm", "backend": "b",
760 "path": "gen-box", "writable": true, "missing": "generate",
761 "description": "sym generate"
762 },
763 "gen.value": {
764 "class": "value", "backend": "b", "engine": "kv2",
765 "path": "secret/data/gen/value", "writable": true, "missing": "generate",
766 "generate": { "format": "ascii-printable", "bytes": 24 },
767 "description": "value generate"
768 }
769 }
770 }"#;
771 let cat: Catalog = serde_json::from_str(GEN_ONLY).expect("parses");
772 let (backend, rec) = MockBackend::new(Probe::Absent);
773 let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
774 backends.insert("b".into(), Box::new(backend));
775 let mgr = BackendManager::new(cat, backends).expect("constructs");
776
777 let summary = mgr.reconcile().await.expect("generate reconciles ok");
778 assert_eq!(summary.generated, 3);
779 assert_eq!(summary.present, 0);
780 assert_eq!(summary.warned, 0);
781
782 assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 1);
784 assert_eq!(*rec.last_key_type.lock().unwrap(), Some(KeyType::Ed25519));
785 assert_eq!(rec.create_named_aead_calls.load(Ordering::SeqCst), 1);
787 assert_eq!(
788 *rec.last_aead.lock().unwrap(),
789 Some(AeadAlgorithm::Aes256Gcm)
790 );
791 assert_eq!(rec.kv_put_calls.load(Ordering::SeqCst), 1);
793 let (path, value) = rec.last_kv_put.lock().unwrap().clone().expect("a kv write");
794 assert_eq!(path, "secret/data/gen/value");
795 assert_eq!(value.len(), 24);
796 assert!(value.iter().all(|b| (b'!'..=b'~').contains(b)));
797 }
798
799 fn generated_asymmetric_catalog(key_type: &str, mint_key_types: &str) -> String {
800 format!(
801 r#"{{
802 "schemaVersion": 1,
803 "backends": {{
804 "b": {{
805 "kind": "vault", "addr": "http://127.0.0.1:8200",
806 "engines": ["transit"], "capabilities": [],
807 "mintKeyTypes": [{mint_key_types}]
808 }}
809 }},
810 "keys": {{
811 "gen.signer": {{
812 "class": "asymmetric", "keyType": "{key_type}", "backend": "b",
813 "path": "gen-signer", "writable": true, "missing": "generate",
814 "description": "generated signer"
815 }}
816 }}
817 }}"#
818 )
819 }
820
821 #[tokio::test]
822 async fn generate_missing_creates_rsa_when_static_preset_allows_it() {
823 let cat: Catalog =
824 serde_json::from_str(&generated_asymmetric_catalog("rsa-2048", r#""rsa-2048""#))
825 .expect("parses");
826 let (backend, rec) = MockBackend::new(Probe::Absent);
827 let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
828 backends.insert("b".into(), Box::new(backend));
829 let mgr = BackendManager::new(cat, backends).expect("constructs");
830
831 let summary = mgr.reconcile().await.expect("rsa generate reconciles ok");
832 assert_eq!(summary.generated, 1);
833 assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 1);
834 assert_eq!(
835 rec.last_create_path.lock().unwrap().as_deref(),
836 Some("gen-signer")
837 );
838 assert_eq!(*rec.last_key_type.lock().unwrap(), Some(KeyType::Rsa2048));
839 }
840
841 #[tokio::test]
842 async fn generate_missing_creates_ecdsa_p256_when_static_preset_allows_it() {
843 let cat: Catalog = serde_json::from_str(&generated_asymmetric_catalog(
844 "ecdsa-p256",
845 r#""ecdsa-p256""#,
846 ))
847 .expect("parses");
848 let (backend, rec) = MockBackend::new(Probe::Absent);
849 let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
850 backends.insert("b".into(), Box::new(backend));
851 let mgr = BackendManager::new(cat, backends).expect("constructs");
852
853 let summary = mgr.reconcile().await.expect("ecdsa generate reconciles ok");
854 assert_eq!(summary.generated, 1);
855 assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 1);
856 assert_eq!(
857 rec.last_create_path.lock().unwrap().as_deref(),
858 Some("gen-signer")
859 );
860 assert_eq!(*rec.last_key_type.lock().unwrap(), Some(KeyType::EcdsaP256));
861 }
862
863 #[tokio::test]
864 async fn generate_missing_creates_nkey_when_static_preset_allows_it() {
865 let cat: Catalog = serde_json::from_str(&generated_asymmetric_catalog(
866 "ed25519-nkey",
867 r#""ed25519-nkey""#,
868 ))
869 .expect("parses");
870 let (backend, rec) = MockBackend::new(Probe::Absent);
871 let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
872 backends.insert("b".into(), Box::new(backend));
873 let mgr = BackendManager::new(cat, backends).expect("constructs");
874
875 let summary = mgr.reconcile().await.expect("nkey generate reconciles ok");
876 assert_eq!(summary.generated, 1);
877 assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 1);
878 assert_eq!(
879 rec.last_create_path.lock().unwrap().as_deref(),
880 Some("gen-signer")
881 );
882 assert_eq!(
883 *rec.last_key_type.lock().unwrap(),
884 Some(KeyType::Ed25519Nkey)
885 );
886 }
887
888 #[tokio::test]
889 async fn generate_missing_rejects_key_type_absent_from_static_backend_preset() {
890 const GEN_RSA_UNSUPPORTED: &str = r#"{
891 "schemaVersion": 1,
892 "backends": {
893 "b": {
894 "kind": "vault", "addr": "http://127.0.0.1:8200",
895 "engines": ["transit"], "capabilities": [],
896 "mintKeyTypes": ["ed25519"]
897 }
898 },
899 "keys": {
900 "gen.rsa": {
901 "class": "asymmetric", "keyType": "rsa-2048", "backend": "b",
902 "path": "gen-rsa", "writable": true, "missing": "generate",
903 "description": "rsa generate"
904 }
905 }
906 }"#;
907 let cat: Catalog = serde_json::from_str(GEN_RSA_UNSUPPORTED).expect("parses");
908 let (backend, rec) = MockBackend::new(Probe::Absent);
909 let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
910 backends.insert("b".into(), Box::new(backend));
911 let mgr = BackendManager::new(cat, backends).expect("constructs");
912
913 let err = mgr
914 .reconcile()
915 .await
916 .expect_err("rsa is absent from preset");
917 assert!(matches!(
918 err,
919 ReconcileError::Generate {
920 source: ManagerError::UnsupportedKeyType {
921 backend,
922 op: "generate",
923 key_type: KeyType::Rsa2048
924 },
925 ..
926 } if backend == "b"
927 ));
928 assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 0);
929 }
930
931 #[tokio::test]
932 async fn kv2_signer_missing_generate_refuses_in_broker() {
933 const KV2_SIGNER: &str = r#"{
939 "schemaVersion": 1,
940 "backends": { "b": { "kind": "vault", "addr": "http://127.0.0.1:8200" } },
941 "keys": {
942 "kv2.signer": {
943 "class": "asymmetric", "keyType": "ed25519", "backend": "b", "engine": "kv2",
944 "path": "secret/data/kv2/signer", "writable": true, "missing": "generate",
945 "description": "a value-store materialize-to-sign key"
946 }
947 }
948 }"#;
949 let cat: Catalog = serde_json::from_str(KV2_SIGNER).expect("parses");
950 let (backend, rec) = MockBackend::new(Probe::Absent);
951 let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
952 backends.insert("b".into(), Box::new(backend));
953 let mgr = BackendManager::new(cat, backends).expect("constructs");
954
955 let err = mgr
956 .reconcile()
957 .await
958 .expect_err("a kv2 signing key cannot be generated in-broker");
959 assert!(matches!(err, ReconcileError::Generate { .. }));
960 assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 0);
962 assert_eq!(rec.kv_put_calls.load(Ordering::SeqCst), 0);
963 }
964
965 #[tokio::test]
966 async fn kv2_signer_present_probes_kv_not_transit() {
967 const KV2_SIGNER: &str = r#"{
971 "schemaVersion": 1,
972 "backends": { "b": { "kind": "vault", "addr": "http://127.0.0.1:8200" } },
973 "keys": {
974 "kv2.signer": {
975 "class": "asymmetric", "keyType": "ed25519", "backend": "b", "engine": "kv2",
976 "path": "secret/data/kv2/signer",
977 "publicPath": "secret/data/kv2/signer-public",
978 "writable": true, "missing": "error",
979 "description": "a value-store materialize-to-sign key"
980 }
981 }
982 }"#;
983 let cat: Catalog = serde_json::from_str(KV2_SIGNER).expect("parses");
984 let (backend, rec) = MockBackend::new(Probe::Present);
985 let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
986 backends.insert("b".into(), Box::new(backend));
987 let mgr = BackendManager::new(cat, backends).expect("constructs");
988
989 let summary = mgr
990 .reconcile()
991 .await
992 .expect("present kv2 signer reconciles");
993 assert_eq!(summary.present, 1);
994 assert_eq!(summary.generated, 0);
995 assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 0);
997 }
998
999 #[tokio::test]
1000 async fn materialize_key_without_public_path_probes_absent() {
1001 const NO_PUB: &str = r#"{
1006 "schemaVersion": 1,
1007 "backends": { "b": { "kind": "vault", "addr": "http://127.0.0.1:8200" } },
1008 "keys": {
1009 "kv2.signer": {
1010 "class": "asymmetric", "keyType": "ed25519", "backend": "b", "engine": "kv2",
1011 "path": "secret/data/kv2/signer", "writable": true, "missing": "error",
1012 "description": "a kv2 signer missing its publicPath"
1013 }
1014 }
1015 }"#;
1016 let cat: Catalog = serde_json::from_str(NO_PUB).expect("parses");
1017 let (backend, _rec) = MockBackend::new(Probe::Present);
1020 let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
1021 backends.insert("b".into(), Box::new(backend));
1022 let mgr = BackendManager::new(cat, backends).expect("constructs");
1023
1024 let err = mgr
1025 .reconcile()
1026 .await
1027 .expect_err("a materialize key with no publicPath must fail closed");
1028 match err {
1029 ReconcileError::RequiredMissing(keys) => assert_eq!(keys, vec!["kv2.signer"]),
1030 other => panic!("expected RequiredMissing, got {other:?}"),
1031 }
1032 }
1033
1034 #[tokio::test]
1035 async fn unreachable_backend_is_fatal_not_treated_as_absent() {
1036 let (mgr, rec) = manager_with(Probe::Unreachable);
1039 let err = mgr
1040 .reconcile()
1041 .await
1042 .expect_err("an unreachable backend must fail closed");
1043 match err {
1044 ReconcileError::Probe { source, .. } => {
1045 assert!(matches!(source, BackendError::Transport(_)));
1046 }
1047 other => panic!("expected Probe (unreachable), got {other:?}"),
1048 }
1049 assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 0);
1052 assert_eq!(rec.create_named_aead_calls.load(Ordering::SeqCst), 0);
1053 assert_eq!(rec.kv_put_calls.load(Ordering::SeqCst), 0);
1054 }
1055
1056 #[tokio::test]
1057 async fn present_keys_are_never_created() {
1058 let (mgr, rec) = manager_with(Probe::Present);
1060 mgr.reconcile().await.expect("reconciles");
1061 assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 0);
1062 assert_eq!(rec.create_named_aead_calls.load(Ordering::SeqCst), 0);
1063 assert_eq!(rec.kv_put_calls.load(Ordering::SeqCst), 0);
1064 }
1065
1066 fn status_of<'a>(report: &'a CheckReport, name: &str) -> &'a KeyStatus {
1069 &report
1070 .keys
1071 .iter()
1072 .find(|k| k.name == name)
1073 .expect("key in report")
1074 .status
1075 }
1076
1077 #[tokio::test]
1078 async fn check_all_present_reports_every_key_present_and_creates_nothing() {
1079 let (mgr, rec) = manager_with(Probe::Present);
1080 let report = mgr.check().await.expect("check succeeds when reachable");
1081 assert_eq!(report.keys.len(), 5);
1083 assert_eq!(report.present_count(), 5);
1084 assert_eq!(report.missing().count(), 0);
1085 assert!(!report.should_fail_required());
1086 assert!(report.required_missing().is_empty());
1087 assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 0);
1089 assert_eq!(rec.create_named_aead_calls.load(Ordering::SeqCst), 0);
1090 assert_eq!(rec.kv_put_calls.load(Ordering::SeqCst), 0);
1091 }
1092
1093 #[tokio::test]
1094 async fn check_all_absent_classifies_each_key_by_its_missing_policy() {
1095 let (mgr, rec) = manager_with(Probe::Absent);
1098 let report = mgr.check().await.expect("check succeeds when reachable");
1099
1100 assert_eq!(report.present_count(), 0);
1101 assert_eq!(
1103 *status_of(&report, "req.signer"),
1104 KeyStatus::Missing(MissingPolicy::Error)
1105 );
1106 assert_eq!(
1107 *status_of(&report, "warn.value"),
1108 KeyStatus::Missing(MissingPolicy::Warn)
1109 );
1110 assert_eq!(
1111 *status_of(&report, "gen.signer"),
1112 KeyStatus::Missing(MissingPolicy::Generate)
1113 );
1114 assert_eq!(
1115 *status_of(&report, "gen.box"),
1116 KeyStatus::Missing(MissingPolicy::Generate)
1117 );
1118 assert_eq!(
1119 *status_of(&report, "gen.value"),
1120 KeyStatus::Missing(MissingPolicy::Generate)
1121 );
1122
1123 assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 0);
1125 assert_eq!(rec.create_named_aead_calls.load(Ordering::SeqCst), 0);
1126 assert_eq!(rec.kv_put_calls.load(Ordering::SeqCst), 0);
1127 }
1128
1129 #[tokio::test]
1130 async fn check_should_fail_required_iff_an_error_class_key_is_absent() {
1131 let (mgr, _rec) = manager_with(Probe::Absent);
1134 let report = mgr.check().await.expect("check ok");
1135 assert!(report.should_fail_required());
1136 assert_eq!(report.required_missing(), vec!["req.signer"]);
1137
1138 let (mgr, _rec) = manager_with(Probe::Present);
1140 let report = mgr.check().await.expect("check ok");
1141 assert!(!report.should_fail_required());
1142 assert!(report.required_missing().is_empty());
1143 }
1144
1145 #[tokio::test]
1146 async fn check_warn_and_generate_absent_do_not_trip_require() {
1147 const WARN_GEN_ONLY: &str = r#"{
1150 "schemaVersion": 1,
1151 "backends": { "b": { "kind": "vault", "addr": "http://127.0.0.1:8200" } },
1152 "keys": {
1153 "warn.value": {
1154 "class": "value", "backend": "b", "engine": "kv2",
1155 "path": "secret/data/warn/value", "writable": true, "missing": "warn",
1156 "description": "warn"
1157 },
1158 "gen.signer": {
1159 "class": "asymmetric", "keyType": "ed25519", "backend": "b",
1160 "path": "gen-signer", "writable": true, "missing": "generate",
1161 "description": "generate"
1162 }
1163 }
1164 }"#;
1165 let cat: Catalog = serde_json::from_str(WARN_GEN_ONLY).expect("parses");
1166 let (backend, _rec) = MockBackend::new(Probe::Absent);
1167 let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
1168 backends.insert("b".into(), Box::new(backend));
1169 let mgr = BackendManager::new(cat, backends).expect("constructs");
1170
1171 let report = mgr.check().await.expect("check ok");
1172 assert_eq!(report.missing().count(), 2);
1174 assert!(!report.should_fail_required());
1175 assert!(report.required_missing().is_empty());
1176 }
1177
1178 #[tokio::test]
1179 async fn check_unreachable_backend_is_fatal_not_treated_as_absent() {
1180 let (mgr, _rec) = manager_with(Probe::Unreachable);
1183 let err = mgr
1184 .check()
1185 .await
1186 .expect_err("an unreachable backend must fail closed");
1187 match err {
1188 ReconcileError::Probe { source, .. } => {
1189 assert!(matches!(source, BackendError::Transport(_)));
1190 }
1191 other => panic!("expected Probe (unreachable), got {other:?}"),
1192 }
1193 }
1194
1195 #[tokio::test]
1196 async fn check_report_keys_are_in_catalog_name_order() {
1197 let (mgr, _rec) = manager_with(Probe::Present);
1200 let report = mgr.check().await.expect("check ok");
1201 let names: Vec<&str> = report.keys.iter().map(|k| k.name.as_str()).collect();
1202 let mut sorted = names.clone();
1203 sorted.sort_unstable();
1204 assert_eq!(names, sorted);
1205 }
1206}