Skip to main content

rust_webx_host/
authz.rs

1//! Resource-based authorization module for the rust-webx framework.
2//!
3//! Provides:
4//! - `ResourceAuthorization` —an `IAuthorizationPolicy` that checks
5//!   whether a user's roles or permissions grant access to a specific
6//!   resource (identified by the matched route pattern).
7//! - `AuthorizerSet` —collects all `IDynamicAuthorizer` instances from DI
8//!   and runs them in sequence on protected routes.
9//!
10//! # Example
11//!
12//! ```ignore
13//! use rust_webx_host::authz::{ResourceAuthorization, resource_auth_middleware};
14//!
15//! let policy = ResourceAuthorization::new()
16//!     .allow_role("/api/users", "admin")
17//!     .allow_role("/api/users/{id}", "user")
18//!     .allow_permission("/api/admin/**", "admin:access");
19//!
20//! let middleware = resource_auth_middleware(Arc::new(policy));
21//! ```
22//!
23//! # Dynamic authorizer (preferred)
24//!
25//! ```ignore
26//! use rust_webx_core::auth::{IClaims, IDynamicAuthorizer};
27//!
28//! #[derive(Default)]
29//! struct RoleAuthorizer;
30//!
31//! #[async_trait::async_trait]
32//! impl IDynamicAuthorizer for RoleAuthorizer {
33//!     async fn authorize(&self, claims: &dyn IClaims, pattern: &str, method: &str) -> Result<()> {
34//!         // your custom logic
35//!         Ok(())
36//!     }
37//! }
38//! ```
39//!
40//! Then register in DI:
41//! ```ignore
42//! svc.singleton::<dyn IDynamicAuthorizer>(|_| Arc::new(RoleAuthorizer::default()))
43//! ```
44
45use rust_webx_core::auth::{IAuthorizationPolicy, IClaims, IDynamicAuthorizer};
46use rust_webx_core::error::Result;
47use rust_webx_core::http::IHttpContext;
48use rust_webx_core::middleware::IMiddleware;
49use std::collections::HashMap;
50use std::ops::ControlFlow;
51use std::sync::Arc;
52
53// ---------------------------------------------------------------------------
54// ResourceAuthorization —IAuthorizationPolicy implementation
55// ---------------------------------------------------------------------------
56
57/// Authorization policy that maps route patterns to allowed roles and permissions.
58///
59/// * `resource_key` →`allowed_roles`: users with ANY of these roles are granted access.
60/// * `resource_key` →`required_permissions`: users with ANY of these permissions are granted access.
61///
62/// A user is authorized if they have at least one matching role OR at least one
63/// matching permission.
64pub struct ResourceAuthorization {
65    allowed_roles: HashMap<String, Vec<String>>,
66    required_permissions: HashMap<String, Vec<String>>,
67}
68
69impl ResourceAuthorization {
70    /// Create a new, empty authorization policy.
71    pub fn new() -> Self {
72        Self {
73            allowed_roles: HashMap::new(),
74            required_permissions: HashMap::new(),
75        }
76    }
77
78    /// Allow a specific role to access a resource.
79    ///
80    /// `resource_key` is the route pattern (e.g., `"/api/users"`).
81    pub fn allow_role(mut self, resource_key: impl Into<String>, role: impl Into<String>) -> Self {
82        self.allowed_roles
83            .entry(resource_key.into())
84            .or_default()
85            .push(role.into());
86        self
87    }
88
89    /// Require a specific permission to access a resource.
90    pub fn allow_permission(
91        mut self,
92        resource_key: impl Into<String>,
93        permission: impl Into<String>,
94    ) -> Self {
95        self.required_permissions
96            .entry(resource_key.into())
97            .or_default()
98            .push(permission.into());
99        self
100    }
101}
102
103impl Default for ResourceAuthorization {
104    fn default() -> Self {
105        Self::new()
106    }
107}
108
109#[async_trait::async_trait]
110impl IAuthorizationPolicy for ResourceAuthorization {
111    async fn authorize(
112        &self,
113        claims: &dyn IClaims,
114        resource_key: &str,
115        _method: &str,
116    ) -> Result<()> {
117        // Check role-based access
118        if let Some(allowed) = self.allowed_roles.get(resource_key) {
119            if allowed.iter().any(|r| claims.roles().contains(r)) {
120                return Ok(());
121            }
122        }
123
124        // Check permission-based access
125        if let Some(required) = self.required_permissions.get(resource_key) {
126            if required.iter().any(|p| claims.permissions().contains(p)) {
127                return Ok(());
128            }
129        }
130
131        // No matching policies found —deny by default
132        Err(rust_webx_core::error::Error::Forbidden(format!(
133            "no policy grants access to '{} {}'",
134            _method, resource_key
135        )))
136    }
137}
138
139// ---------------------------------------------------------------------------
140// Middleware
141// ---------------------------------------------------------------------------
142
143/// Authorization middleware that enforces an `IAuthorizationPolicy`.
144///
145/// Reads the authenticated claims and matched route pattern from the HTTP context
146/// and calls `policy.authorize()`.
147struct ResourceAuthMiddleware {
148    policy: Arc<dyn IAuthorizationPolicy>,
149}
150
151#[async_trait::async_trait]
152impl IMiddleware for ResourceAuthMiddleware {
153    async fn invoke(&self, ctx: &mut dyn IHttpContext) -> Result<ControlFlow<()>> {
154        // Get the matched route pattern (set by the router)
155        let route_pattern = match ctx.request().route_pattern() {
156            Some(p) => p.to_string(),
157            None => {
158                // No route matched —skip authorization (e.g., static files, health checks)
159                return Ok(ControlFlow::Continue(()));
160            }
161        };
162
163        let method = ctx.request().method().to_string();
164
165        // Get claims from context (set by authentication middleware)
166        // IHttpContext extends IClaimsExt, so claims() is available directly.
167        match ctx.claims() {
168            Some(claims) => self.policy.authorize(claims, &route_pattern, &method).await.map(|_| ControlFlow::Continue(())),
169            None => Err(rust_webx_core::error::Error::Unauthorized(
170                "no authentication claims found".to_string(),
171            )),
172        }
173    }
174}
175
176/// Create a resource-based authorization middleware.
177///
178/// Register this middleware AFTER the authentication middleware in the pipeline.
179/// The middleware reads the route pattern (set by the router) and
180/// claims (set by the auth middleware) to enforce the policy.
181pub fn resource_auth_middleware(policy: Arc<dyn IAuthorizationPolicy>) -> impl IMiddleware {
182    ResourceAuthMiddleware { policy }
183}
184
185// ---------------------------------------------------------------------------
186// AuthorizerSet —dynamic authorizer collection
187// ---------------------------------------------------------------------------
188
189/// A set of `IDynamicAuthorizer` instances collected from the DI container.
190///
191/// Created automatically in `HostBuilder::build()` when one or more
192/// `IDynamicAuthorizer` implementations are registered. If none are registered,
193/// this is `None` and no dynamic authorization checks run.
194#[derive(Clone)]
195pub struct AuthorizerSet {
196    authorizers: Vec<Arc<dyn IDynamicAuthorizer>>,
197}
198
199impl AuthorizerSet {
200    /// Create a new set from a vector of authorizers.
201    pub fn new(authorizers: Vec<Arc<dyn IDynamicAuthorizer>>) -> Self {
202        Self { authorizers }
203    }
204
205    /// Run all registered authorizers in sequence.
206    ///
207    /// Returns `Ok(())` if ALL authorizers pass. Returns the first `Err` if any denies.
208    pub async fn authorize(
209        &self,
210        claims: &dyn IClaims,
211        route_pattern: &str,
212        method: &str,
213    ) -> Result<()> {
214        for authorizer in &self.authorizers {
215            authorizer.authorize(claims, route_pattern, method).await?;
216        }
217        Ok(())
218    }
219
220    /// Returns `true` if there are no authorizers registered.
221    pub fn is_empty(&self) -> bool {
222        self.authorizers.is_empty()
223    }
224}
225
226/// Result from collecting `IDynamicAuthorizer` instances from DI.
227/// `None` means no authorizers are registered (pass-through mode).
228pub fn collect_authorizers(
229    provider: &rust_dix::ServiceProvider,
230) -> Option<Arc<AuthorizerSet>> {
231    let authorizers: Vec<Arc<dyn IDynamicAuthorizer>> = provider.get_all();
232    if authorizers.is_empty() {
233        None
234    } else {
235        Some(Arc::new(AuthorizerSet::new(authorizers)))
236    }
237}