Skip to main content

macp_auth/auth/
resolver.rs

1use crate::security::AuthIdentity;
2use std::collections::HashSet;
3use tonic::metadata::MetadataMap;
4
5#[derive(Debug)]
6pub enum AuthError {
7    InvalidCredential(String),
8    Expired,
9    MissingClaim(String),
10    FetchFailed(String),
11}
12
13impl std::fmt::Display for AuthError {
14    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
15        match self {
16            AuthError::InvalidCredential(msg) => write!(f, "invalid credential: {msg}"),
17            AuthError::Expired => write!(f, "credential expired"),
18            AuthError::MissingClaim(claim) => write!(f, "missing required claim: {claim}"),
19            AuthError::FetchFailed(msg) => write!(f, "key fetch failed: {msg}"),
20        }
21    }
22}
23
24impl std::error::Error for AuthError {}
25
26#[derive(Clone, Debug)]
27pub struct ResolvedIdentity {
28    pub sender: String,
29    pub allowed_modes: Option<HashSet<String>>,
30    pub can_start_sessions: bool,
31    pub max_open_sessions: Option<usize>,
32    pub can_manage_mode_registry: bool,
33    pub is_observer: bool,
34    pub resolver: String,
35}
36
37impl From<ResolvedIdentity> for AuthIdentity {
38    fn from(resolved: ResolvedIdentity) -> Self {
39        AuthIdentity {
40            sender: resolved.sender,
41            allowed_modes: resolved.allowed_modes,
42            can_start_sessions: resolved.can_start_sessions,
43            max_open_sessions: resolved.max_open_sessions,
44            can_manage_mode_registry: resolved.can_manage_mode_registry,
45            is_observer: resolved.is_observer,
46        }
47    }
48}
49
50/// Trait for pluggable auth resolvers.
51///
52/// Each resolver examines gRPC metadata and returns:
53/// - `Ok(Some(identity))` — positive verification, chain stops
54/// - `Ok(None)` — not my credential type, chain continues
55/// - `Err(e)` — credential is mine but invalid, chain stops with error
56#[async_trait::async_trait]
57pub trait AuthResolver: Send + Sync {
58    fn name(&self) -> &str;
59
60    async fn resolve(&self, metadata: &MetadataMap) -> Result<Option<ResolvedIdentity>, AuthError>;
61}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66
67    #[test]
68    fn auth_error_display_covers_all_variants() {
69        assert_eq!(
70            AuthError::InvalidCredential("bad token".to_string()).to_string(),
71            "invalid credential: bad token"
72        );
73        assert_eq!(AuthError::Expired.to_string(), "credential expired");
74        assert_eq!(
75            AuthError::MissingClaim("sub".to_string()).to_string(),
76            "missing required claim: sub"
77        );
78        assert_eq!(
79            AuthError::FetchFailed("connection refused".to_string()).to_string(),
80            "key fetch failed: connection refused"
81        );
82    }
83
84    #[test]
85    fn resolved_identity_converts_to_auth_identity_preserving_fields() {
86        let modes: HashSet<String> = ["macp.mode.decision.v1".to_string()].into_iter().collect();
87        let resolved = ResolvedIdentity {
88            sender: "agent://alice".to_string(),
89            allowed_modes: Some(modes.clone()),
90            can_start_sessions: false,
91            max_open_sessions: Some(3),
92            can_manage_mode_registry: true,
93            is_observer: true,
94            resolver: "jwt_bearer".to_string(),
95        };
96
97        let identity: AuthIdentity = resolved.into();
98        assert_eq!(identity.sender, "agent://alice");
99        assert_eq!(identity.allowed_modes, Some(modes));
100        assert!(!identity.can_start_sessions);
101        assert_eq!(identity.max_open_sessions, Some(3));
102        assert!(identity.can_manage_mode_registry);
103        assert!(identity.is_observer);
104    }
105}