klieo-ops 3.5.0

Operational layer above klieo-core: supervisor, governor, gates, escalation, worklog, handoff.
Documentation
//! Approver identity → Ed25519 pubkey registry for FourEyesGate.
//!
//! `ApproverRegistry` is the abstract port; `StaticApproverRegistry`
//! loads from a YAML file at construction. Hot-reload, IdP-backed
//! registries, and HSM-backed pubkey resolution are out-of-scope
//! follow-ups (the trait reservation allows them without breaking
//! callers).

use crate::key_registry::{load_ed25519_yaml, KeyRegistryError, StaticEd25519KeyRegistry};
use ed25519_dalek::VerifyingKey;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
use thiserror::Error;

/// Stable approver identifier (e.g. an email, employee id, or
/// IdP-issued sub-claim).
#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
pub struct ApproverId(pub String);

impl std::fmt::Display for ApproverId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.0)
    }
}

/// Errors raised by approver-registry loading + lookup.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum ApproverRegistryError {
    /// Wraps the underlying key-registry error.
    #[error(transparent)]
    Load(#[from] KeyRegistryError),
}

/// Lookup approver pubkeys by id.
pub trait ApproverRegistry: Send + Sync {
    /// Return the Ed25519 verifying key for `approver`, or `None` if
    /// the approver is not registered.
    fn lookup(&self, approver: &ApproverId) -> Option<VerifyingKey>;
}

/// In-memory registry loaded from a YAML file at construction.
///
/// YAML shape:
/// ```yaml
/// alice@insurer.de: "BASE64_ED25519_PUBKEY"
/// bob@insurer.de:   "BASE64_ED25519_PUBKEY"
/// ```
pub struct StaticApproverRegistry {
    inner: StaticEd25519KeyRegistry<ApproverId>,
}

impl StaticApproverRegistry {
    /// Build from an in-memory map (testing path).
    #[must_use]
    pub fn from_map(keys: HashMap<ApproverId, VerifyingKey>) -> Self {
        Self {
            inner: StaticEd25519KeyRegistry::from_map(keys),
        }
    }

    /// Load from a YAML file on disk.
    pub fn from_yaml(path: impl AsRef<Path>) -> Result<Self, ApproverRegistryError> {
        let keys = load_ed25519_yaml(path, ApproverId)?;
        Ok(Self {
            inner: StaticEd25519KeyRegistry::from_map(keys),
        })
    }
}

impl ApproverRegistry for StaticApproverRegistry {
    fn lookup(&self, approver: &ApproverId) -> Option<VerifyingKey> {
        use crate::key_registry::Ed25519KeyRegistry as _;
        self.inner.lookup(approver)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use ed25519_dalek::SigningKey;

    fn deterministic_key(seed: u8) -> SigningKey {
        SigningKey::from_bytes(&[seed; 32])
    }

    #[test]
    fn round_trip_via_yaml() {
        let sk = deterministic_key(1);
        let pk = sk.verifying_key();
        use base64::Engine as _;
        let pk_b64 = base64::engine::general_purpose::STANDARD.encode(pk.to_bytes());

        let yaml = format!("alice@insurer.de: \"{pk_b64}\"\n");
        let path = std::env::temp_dir().join("klieo-test-approvers.yaml");
        std::fs::write(&path, yaml).unwrap();

        let reg = StaticApproverRegistry::from_yaml(&path).expect("load");
        let resolved = reg
            .lookup(&ApproverId("alice@insurer.de".into()))
            .expect("found");
        assert_eq!(resolved.to_bytes(), pk.to_bytes());
    }

    #[test]
    fn missing_approver_returns_none() {
        let reg = StaticApproverRegistry::from_map(HashMap::new());
        assert!(reg.lookup(&ApproverId("ghost".into())).is_none());
    }
}