Skip to main content

ranvier_core/
iam.rs

1//! Enterprise IAM (Identity and Access Management) at the Schematic boundary.
2//!
3//! Provides protocol-agnostic IAM types that Axon checks at `execute()` time:
4//!
5//! * [`IamPolicy`] — what level of identity is required
6//! * [`IamVerifier`] — how tokens are verified (OIDC/JWKS, HS256, custom)
7//! * [`IamIdentity`] — the verified identity result
8//! * [`IamToken`] — Bus-injectable bearer token
9//!
10//! The HTTP layer (or test harness) inserts an [`IamToken`] into the Bus.
11//! The Axon boundary reads the token, calls the [`IamVerifier`], checks
12//! the [`IamPolicy`], and injects the resulting [`IamIdentity`] for
13//! downstream Transitions to consume.
14
15use async_trait::async_trait;
16use serde::{Deserialize, Serialize};
17use std::collections::HashMap;
18use std::fmt;
19use std::sync::Arc;
20
21// ── Policy ─────────────────────────────────────────────────────
22
23/// IAM policy enforced at the Axon/Schematic execution boundary.
24#[derive(Clone, Debug, Default, PartialEq)]
25pub enum IamPolicy {
26    /// No identity verification required.
27    #[default]
28    None,
29    /// Any valid, verified identity is sufficient.
30    RequireIdentity,
31    /// Identity must possess the specified role.
32    RequireRole(String),
33    /// Identity must possess ALL specified claims.
34    RequireClaims(Vec<String>),
35}
36
37// ── Identity ───────────────────────────────────────────────────
38
39/// Verified identity produced by an [`IamVerifier`].
40///
41/// Inserted into the Bus after successful verification so Transition
42/// code can read it via `bus.read::<IamIdentity>()`.
43#[derive(Clone, Debug, Serialize, Deserialize)]
44pub struct IamIdentity {
45    pub subject: String,
46    pub issuer: Option<String>,
47    pub roles: Vec<String>,
48    pub claims: HashMap<String, serde_json::Value>,
49}
50
51impl IamIdentity {
52    pub fn new(subject: impl Into<String>) -> Self {
53        Self {
54            subject: subject.into(),
55            issuer: None,
56            roles: Vec::new(),
57            claims: HashMap::new(),
58        }
59    }
60
61    pub fn with_issuer(mut self, issuer: impl Into<String>) -> Self {
62        self.issuer = Some(issuer.into());
63        self
64    }
65
66    pub fn with_role(mut self, role: impl Into<String>) -> Self {
67        self.roles.push(role.into());
68        self
69    }
70
71    pub fn with_roles(mut self, roles: Vec<String>) -> Self {
72        self.roles = roles;
73        self
74    }
75
76    pub fn with_claim(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
77        self.claims.insert(key.into(), value);
78        self
79    }
80
81    pub fn has_role(&self, role: &str) -> bool {
82        self.roles.iter().any(|r| r == role)
83    }
84
85    pub fn has_claim(&self, claim: &str) -> bool {
86        self.claims.contains_key(claim)
87    }
88}
89
90// ── Error ──────────────────────────────────────────────────────
91
92/// Error returned when IAM verification or policy enforcement fails.
93#[derive(Debug)]
94pub enum IamError {
95    /// No token was provided but the policy requires one.
96    MissingToken,
97    /// Token is invalid or malformed.
98    InvalidToken(String),
99    /// Token has expired.
100    Expired,
101    /// Identity lacks the required role.
102    InsufficientRole {
103        required: String,
104        found: Vec<String>,
105    },
106    /// Identity is missing one or more required claims.
107    MissingClaims(Vec<String>),
108}
109
110impl fmt::Display for IamError {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        match self {
113            Self::MissingToken => write!(f, "IAM: no token provided"),
114            Self::InvalidToken(msg) => write!(f, "IAM: invalid token: {}", msg),
115            Self::Expired => write!(f, "IAM: token expired"),
116            Self::InsufficientRole { required, found } => {
117                write!(f, "IAM: required role '{}', found {:?}", required, found)
118            }
119            Self::MissingClaims(claims) => write!(f, "IAM: missing claims: {:?}", claims),
120        }
121    }
122}
123
124impl std::error::Error for IamError {}
125
126// ── Verifier trait ─────────────────────────────────────────────
127
128/// Trait for verifying bearer tokens at the Schematic/Axon boundary.
129///
130/// Implementations may use:
131/// - JWKS key sets with RS256/ES256 (OIDC / OAuth2)
132/// - Shared secrets with HS256
133/// - External identity providers
134/// - Custom verification logic
135#[async_trait]
136pub trait IamVerifier: Send + Sync {
137    /// Verify the raw token string and return the verified identity.
138    async fn verify(&self, token: &str) -> Result<IamIdentity, IamError>;
139}
140
141// ── Bus-injectable types ───────────────────────────────────────
142
143/// Bearer token injected into the Bus by the HTTP layer (or test harness).
144///
145/// The Axon boundary reads this and feeds it to the [`IamVerifier`].
146#[derive(Clone, Debug)]
147pub struct IamToken(pub String);
148
149/// Bus-injectable handle containing the verifier and policy.
150///
151/// Attach to an Axon via `with_iam()` or inject directly into the Bus.
152#[derive(Clone)]
153pub struct IamHandle {
154    pub policy: IamPolicy,
155    pub verifier: Arc<dyn IamVerifier>,
156}
157
158impl IamHandle {
159    pub fn new(policy: IamPolicy, verifier: Arc<dyn IamVerifier>) -> Self {
160        Self { policy, verifier }
161    }
162}
163
164// ── Policy enforcement ─────────────────────────────────────────
165
166/// Enforce [`IamPolicy`] against a verified [`IamIdentity`].
167///
168/// Returns `Ok(())` if the identity satisfies the policy, or an
169/// appropriate [`IamError`] if not.
170pub fn enforce_policy(policy: &IamPolicy, identity: &IamIdentity) -> Result<(), IamError> {
171    match policy {
172        IamPolicy::None => Ok(()),
173        IamPolicy::RequireIdentity => Ok(()), // presence of identity is sufficient
174        IamPolicy::RequireRole(role) => {
175            if identity.has_role(role) {
176                Ok(())
177            } else {
178                Err(IamError::InsufficientRole {
179                    required: role.clone(),
180                    found: identity.roles.clone(),
181                })
182            }
183        }
184        IamPolicy::RequireClaims(required) => {
185            let missing: Vec<String> = required
186                .iter()
187                .filter(|c| !identity.has_claim(c))
188                .cloned()
189                .collect();
190            if missing.is_empty() {
191                Ok(())
192            } else {
193                Err(IamError::MissingClaims(missing))
194            }
195        }
196    }
197}
198
199// ── Auth context (absorbed from ranvier-auth) ─────────────────
200
201/// Source scheme of an authenticated subject.
202#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
203pub enum AuthScheme {
204    Bearer,
205    ApiKey,
206}
207
208/// Auth context propagated through the Bus.
209///
210/// The HTTP boundary (or test harness) constructs an `AuthContext` and
211/// inserts it into the Bus via [`inject_auth_context`].  Downstream
212/// Transitions read it via [`auth_context`].
213#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
214pub struct AuthContext {
215    pub subject: String,
216    pub roles: Vec<String>,
217    pub scheme: AuthScheme,
218}
219
220impl AuthContext {
221    pub fn new(subject: impl Into<String>, roles: Vec<String>, scheme: AuthScheme) -> Self {
222        Self {
223            subject: subject.into(),
224            roles,
225            scheme,
226        }
227    }
228
229    pub fn has_role(&self, role: &str) -> bool {
230        self.roles.iter().any(|candidate| candidate == role)
231    }
232}
233
234/// Insert an [`AuthContext`] into the Bus.
235pub fn inject_auth_context(bus: &mut crate::bus::Bus, ctx: AuthContext) {
236    bus.insert(ctx);
237}
238
239/// Read the [`AuthContext`] from the Bus.
240pub fn auth_context(bus: &crate::bus::Bus) -> Option<&AuthContext> {
241    bus.read::<AuthContext>()
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn policy_none_always_passes() {
250        let id = IamIdentity::new("alice");
251        assert!(enforce_policy(&IamPolicy::None, &id).is_ok());
252    }
253
254    #[test]
255    fn policy_require_identity_passes_for_any_subject() {
256        let id = IamIdentity::new("bob");
257        assert!(enforce_policy(&IamPolicy::RequireIdentity, &id).is_ok());
258    }
259
260    #[test]
261    fn policy_require_role_passes_when_present() {
262        let id = IamIdentity::new("carol").with_role("admin");
263        assert!(enforce_policy(&IamPolicy::RequireRole("admin".into()), &id).is_ok());
264    }
265
266    #[test]
267    fn policy_require_role_fails_when_absent() {
268        let id = IamIdentity::new("dave").with_role("user");
269        let err = enforce_policy(&IamPolicy::RequireRole("admin".into()), &id).unwrap_err();
270        assert!(matches!(err, IamError::InsufficientRole { .. }));
271    }
272
273    #[test]
274    fn policy_require_claims_passes_when_all_present() {
275        let id = IamIdentity::new("eve")
276            .with_claim("email", serde_json::json!("eve@example.com"))
277            .with_claim("org", serde_json::json!("acme"));
278        assert!(
279            enforce_policy(
280                &IamPolicy::RequireClaims(vec!["email".into(), "org".into()]),
281                &id
282            )
283            .is_ok()
284        );
285    }
286
287    #[test]
288    fn policy_require_claims_fails_when_missing() {
289        let id =
290            IamIdentity::new("frank").with_claim("email", serde_json::json!("frank@example.com"));
291        let err = enforce_policy(
292            &IamPolicy::RequireClaims(vec!["email".into(), "org".into()]),
293            &id,
294        )
295        .unwrap_err();
296        match err {
297            IamError::MissingClaims(missing) => assert_eq!(missing, vec!["org".to_string()]),
298            other => panic!("Expected MissingClaims, got {:?}", other),
299        }
300    }
301
302    #[test]
303    fn auth_context_has_role() {
304        let ctx = AuthContext::new("alice", vec!["admin".into(), "user".into()], AuthScheme::Bearer);
305        assert!(ctx.has_role("admin"));
306        assert!(ctx.has_role("user"));
307        assert!(!ctx.has_role("superadmin"));
308    }
309
310    #[test]
311    fn auth_context_bus_inject_and_read() {
312        let ctx = AuthContext::new("bob", vec!["editor".into()], AuthScheme::ApiKey);
313        let mut bus = crate::bus::Bus::new();
314        inject_auth_context(&mut bus, ctx.clone());
315        assert_eq!(auth_context(&bus), Some(&ctx));
316    }
317
318    #[test]
319    fn auth_context_serde_roundtrip() {
320        let ctx = AuthContext::new("carol", vec!["admin".into()], AuthScheme::Bearer);
321        let json = serde_json::to_string(&ctx).expect("serialize");
322        let back: AuthContext = serde_json::from_str(&json).expect("deserialize");
323        assert_eq!(ctx, back);
324    }
325}