klieo-ops 0.3.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 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 {
    /// YAML file failed to read or parse.
    #[error("approver registry load: {0}")]
    Load(String),
    /// Base64-decoded pubkey was not 32 bytes (Ed25519 public-key size).
    #[error("approver `{approver}`: pubkey is not 32 bytes")]
    BadKey {
        /// Offending approver id.
        approver: ApproverId,
    },
}

/// 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 {
    keys: HashMap<ApproverId, VerifyingKey>,
}

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

    /// Load from a YAML file on disk.
    pub fn from_yaml(path: impl AsRef<Path>) -> Result<Self, ApproverRegistryError> {
        let body = std::fs::read_to_string(path.as_ref())
            .map_err(|e| ApproverRegistryError::Load(format!("read: {e}")))?;
        let raw: HashMap<String, String> = serde_yaml::from_str(&body)
            .map_err(|e| ApproverRegistryError::Load(format!("parse: {e}")))?;
        let mut keys = HashMap::with_capacity(raw.len());
        for (id, b64) in raw {
            use base64::Engine as _;
            let bytes = base64::engine::general_purpose::STANDARD
                .decode(&b64)
                .map_err(|e| ApproverRegistryError::Load(format!("base64 `{id}`: {e}")))?;
            let approver = ApproverId(id);
            let pubkey_bytes: [u8; 32] =
                bytes
                    .try_into()
                    .map_err(|_| ApproverRegistryError::BadKey {
                        approver: approver.clone(),
                    })?;
            let vk = VerifyingKey::from_bytes(&pubkey_bytes).map_err(|_| {
                ApproverRegistryError::BadKey {
                    approver: approver.clone(),
                }
            })?;
            keys.insert(approver, vk);
        }
        Ok(Self { keys })
    }
}

impl ApproverRegistry for StaticApproverRegistry {
    fn lookup(&self, approver: &ApproverId) -> Option<VerifyingKey> {
        self.keys.get(approver).copied()
    }
}

#[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());
    }
}