//! AuthGuard — the mechanism behind `@authenticate`. //! //! `AuthGuard` is the bridge between a raw session/JWT token string (extracted //! from the request) and a fully-resolved `IdentityContext`. //! //! Execution: //! 1. Extract session ID from the token (JWT decode or opaque lookup) //! 2. Validate the Session node in Engram (expiry check) //! 3. Load the User node //! 4. Traverse `User ──has_role──▶ Role` edges //! 5. Traverse `Role ──grants──▶ Scope` edges //! 6. Return `IdentityContext` — fully resolved, ready for downstream use //! //! `@public` bypasses this guard entirely (see `el-aop::PublicMarker`). use crate::{ context::IdentityContext, engram::EngramClient, error::{IdentityError, IdentityResult}, nodes::{Role, Scope, User, EDGE_GRANTS, EDGE_HAS_ROLE, NODE_ROLE, NODE_SCOPE, NODE_USER}, session::SessionManager, }; use std::sync::Arc; /// AuthGuard resolves a session/token string into a full `IdentityContext`. /// /// Configured once at application startup and shared across requests. pub struct AuthGuard { client: Arc, session_manager: Arc, } impl AuthGuard { pub fn new(client: Arc, session_manager: Arc) -> Self { Self { client, session_manager } } /// Authenticate a request given its session ID. /// /// This is called by `@authenticate` in the AOP chain. For every protected /// endpoint, this runs before the handler. If it returns `Err`, the request /// is rejected. pub fn authenticate(&self, session_id: &str) -> IdentityResult { // 1. Validate session (checks expiry, lazy-deletes expired) let session = self.session_manager.validate(session_id)?; // 2. Load User node let user_id_str = session.user_id.to_string(); let user_node = self .client .get_node(&user_id_str) .map_err(|e| IdentityError::GraphError(e.to_string()))? .ok_or_else(|| IdentityError::UserNotFound(user_id_str.clone()))?; let user = User::from_value(&user_node) .ok_or_else(|| IdentityError::GraphError("user node parse failed".into()))?; // 3. Load roles via has_role edges let role_nodes = self .client .find_connected(&user_id_str, EDGE_HAS_ROLE) .map_err(|e| IdentityError::GraphError(e.to_string()))?; let roles: Vec = role_nodes .iter() .filter_map(Role::from_value) .collect(); // 4. Load scopes via grants edges from each role let mut scopes: Vec = Vec::new(); for role in &roles { let scope_nodes = self .client .find_connected(&role.id.to_string(), EDGE_GRANTS) .map_err(|e| IdentityError::GraphError(e.to_string()))?; scopes.extend(scope_nodes.iter().filter_map(Scope::from_value)); } // Deduplicate scopes by name scopes.dedup_by(|a, b| a.name == b.name); Ok(IdentityContext::new(user, session, roles, scopes)) } /// Require a specific role — returns Err::Forbidden if missing. pub fn require_role( &self, ctx: &IdentityContext, role: &str, ) -> IdentityResult<()> { if ctx.has_role(role) { Ok(()) } else { Err(IdentityError::Forbidden(role.to_string())) } } /// Require a specific scope — returns Err::ScopeForbidden if missing. pub fn require_scope( &self, ctx: &IdentityContext, scope: &str, ) -> IdentityResult<()> { if ctx.has_scope(scope) { Ok(()) } else { Err(IdentityError::ScopeForbidden(scope.to_string())) } } /// Register a user in the Engram graph. /// /// Creates the User node. Call this after first OAuth login or on signup. pub fn register_user(&self, user: &User) -> IdentityResult<()> { self.client .create_node(NODE_USER, user.to_value()) .map(|_| ()) .map_err(|e| IdentityError::GraphError(e.to_string())) } /// Assign a role to a user by creating a `has_role` edge. pub fn assign_role(&self, user_id: &str, role: &Role) -> IdentityResult<()> { // Ensure role node exists if self.client.get_node(&role.id.to_string()) .map_err(|e| IdentityError::GraphError(e.to_string()))? .is_none() { self.client .create_node(NODE_ROLE, role.to_value()) .map_err(|e| IdentityError::GraphError(e.to_string()))?; } // Edge: User → Role self.client .create_edge(user_id, &role.id.to_string(), EDGE_HAS_ROLE) .map_err(|e| IdentityError::GraphError(e.to_string())) } /// Register a scope and link it to a role via a `grants` edge. pub fn assign_scope_to_role(&self, role: &Role, scope: &Scope) -> IdentityResult<()> { if self.client.get_node(&scope.id.to_string()) .map_err(|e| IdentityError::GraphError(e.to_string()))? .is_none() { self.client .create_node(NODE_SCOPE, scope.to_value()) .map_err(|e| IdentityError::GraphError(e.to_string()))?; } self.client .create_edge(&role.id.to_string(), &scope.id.to_string(), EDGE_GRANTS) .map_err(|e| IdentityError::GraphError(e.to_string())) } }