Skip to main content

klieo_ops/
approver_registry.rs

1//! Approver identity → Ed25519 pubkey registry for FourEyesGate.
2//!
3//! `ApproverRegistry` is the abstract port; `StaticApproverRegistry`
4//! loads from a YAML file at construction. Hot-reload, IdP-backed
5//! registries, and HSM-backed pubkey resolution are out-of-scope
6//! follow-ups (the trait reservation allows them without breaking
7//! callers).
8
9use crate::key_registry::{load_ed25519_yaml, KeyRegistryError, StaticEd25519KeyRegistry};
10use ed25519_dalek::VerifyingKey;
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13use std::path::Path;
14use thiserror::Error;
15
16/// Stable approver identifier (e.g. an email, employee id, or
17/// IdP-issued sub-claim).
18#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
19pub struct ApproverId(pub String);
20
21impl std::fmt::Display for ApproverId {
22    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23        f.write_str(&self.0)
24    }
25}
26
27/// Errors raised by approver-registry loading + lookup.
28#[derive(Debug, Error)]
29#[non_exhaustive]
30pub enum ApproverRegistryError {
31    /// Wraps the underlying key-registry error.
32    #[error(transparent)]
33    Load(#[from] KeyRegistryError),
34}
35
36/// Lookup approver pubkeys by id.
37pub trait ApproverRegistry: Send + Sync {
38    /// Return the Ed25519 verifying key for `approver`, or `None` if
39    /// the approver is not registered.
40    fn lookup(&self, approver: &ApproverId) -> Option<VerifyingKey>;
41}
42
43/// In-memory registry loaded from a YAML file at construction.
44///
45/// YAML shape:
46/// ```yaml
47/// alice@insurer.de: "BASE64_ED25519_PUBKEY"
48/// bob@insurer.de:   "BASE64_ED25519_PUBKEY"
49/// ```
50pub struct StaticApproverRegistry {
51    inner: StaticEd25519KeyRegistry<ApproverId>,
52}
53
54impl StaticApproverRegistry {
55    /// Build from an in-memory map (testing path).
56    #[must_use]
57    pub fn from_map(keys: HashMap<ApproverId, VerifyingKey>) -> Self {
58        Self {
59            inner: StaticEd25519KeyRegistry::from_map(keys),
60        }
61    }
62
63    /// Load from a YAML file on disk.
64    pub fn from_yaml(path: impl AsRef<Path>) -> Result<Self, ApproverRegistryError> {
65        let keys = load_ed25519_yaml(path, ApproverId)?;
66        Ok(Self {
67            inner: StaticEd25519KeyRegistry::from_map(keys),
68        })
69    }
70}
71
72impl ApproverRegistry for StaticApproverRegistry {
73    fn lookup(&self, approver: &ApproverId) -> Option<VerifyingKey> {
74        use crate::key_registry::Ed25519KeyRegistry as _;
75        self.inner.lookup(approver)
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82    use ed25519_dalek::SigningKey;
83
84    fn deterministic_key(seed: u8) -> SigningKey {
85        SigningKey::from_bytes(&[seed; 32])
86    }
87
88    #[test]
89    fn round_trip_via_yaml() {
90        let sk = deterministic_key(1);
91        let pk = sk.verifying_key();
92        use base64::Engine as _;
93        let pk_b64 = base64::engine::general_purpose::STANDARD.encode(pk.to_bytes());
94
95        let yaml = format!("alice@insurer.de: \"{pk_b64}\"\n");
96        let path = std::env::temp_dir().join("klieo-test-approvers.yaml");
97        std::fs::write(&path, yaml).unwrap();
98
99        let reg = StaticApproverRegistry::from_yaml(&path).expect("load");
100        let resolved = reg
101            .lookup(&ApproverId("alice@insurer.de".into()))
102            .expect("found");
103        assert_eq!(resolved.to_bytes(), pk.to_bytes());
104    }
105
106    #[test]
107    fn missing_approver_returns_none() {
108        let reg = StaticApproverRegistry::from_map(HashMap::new());
109        assert!(reg.lookup(&ApproverId("ghost".into())).is_none());
110    }
111}