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
109impl ResourceAuthorization {
110    /// Returns true when this policy has explicit rules for `resource_key`.
111    pub fn covers_route(&self, resource_key: &str) -> bool {
112        self.allowed_roles.contains_key(resource_key)
113            || self.required_permissions.contains_key(resource_key)
114    }
115}
116
117#[async_trait::async_trait]
118impl IAuthorizationPolicy for ResourceAuthorization {
119    async fn authorize(
120        &self,
121        claims: &dyn IClaims,
122        resource_key: &str,
123        _method: &str,
124    ) -> Result<()> {
125        // Check role-based access
126        if let Some(allowed) = self.allowed_roles.get(resource_key) {
127            if allowed.iter().any(|r| claims.roles().contains(r)) {
128                return Ok(());
129            }
130        }
131
132        // Check permission-based access
133        if let Some(required) = self.required_permissions.get(resource_key) {
134            if required.iter().any(|p| claims.permissions().contains(p)) {
135                return Ok(());
136            }
137        }
138
139        // No matching policies found —deny by default
140        Err(rust_webx_core::error::Error::Forbidden(format!(
141            "no policy grants access to '{} {}'",
142            _method, resource_key
143        )))
144    }
145}
146
147// ---------------------------------------------------------------------------
148// Middleware
149// ---------------------------------------------------------------------------
150
151/// Authorization middleware that enforces an `IAuthorizationPolicy`.
152///
153/// Reads the authenticated claims and matched route pattern from the HTTP context
154/// and calls `policy.authorize()`.
155struct ResourceAuthMiddleware {
156    policy: Arc<dyn IAuthorizationPolicy>,
157}
158
159#[async_trait::async_trait]
160impl IMiddleware for ResourceAuthMiddleware {
161    async fn invoke(&self, ctx: &mut dyn IHttpContext) -> Result<ControlFlow<()>> {
162        // Get the matched route pattern (set by the router)
163        let route_pattern = match ctx.request().route_pattern() {
164            Some(p) => p.to_string(),
165            None => {
166                // No route matched —skip authorization (e.g., static files, health checks)
167                return Ok(ControlFlow::Continue(()));
168            }
169        };
170
171        let method = ctx.request().method().to_string();
172
173        // Get claims from context (set by authentication middleware)
174        // IHttpContext extends IClaimsExt, so claims() is available directly.
175        match ctx.claims() {
176            Some(claims) => self
177                .policy
178                .authorize(claims, &route_pattern, &method)
179                .await
180                .map(|_| ControlFlow::Continue(())),
181            None => Err(rust_webx_core::error::Error::Unauthorized(
182                "no authentication claims found".to_string(),
183            )),
184        }
185    }
186}
187
188/// Create a resource-based authorization middleware.
189///
190/// Register this middleware AFTER the authentication middleware in the pipeline.
191/// The middleware reads the route pattern (set by the router) and
192/// claims (set by the auth middleware) to enforce the policy.
193pub fn resource_auth_middleware(policy: Arc<dyn IAuthorizationPolicy>) -> impl IMiddleware {
194    ResourceAuthMiddleware { policy }
195}
196
197// ---------------------------------------------------------------------------
198// AuthorizerSet —dynamic authorizer collection
199// ---------------------------------------------------------------------------
200
201/// A set of `IDynamicAuthorizer` instances collected from the DI container.
202///
203/// Created automatically in `HostBuilder::build()` when one or more
204/// `IDynamicAuthorizer` implementations are registered. If none are registered,
205/// this is `None` and no dynamic authorization checks run.
206#[derive(Clone)]
207pub struct AuthorizerSet {
208    authorizers: Vec<Arc<dyn IDynamicAuthorizer>>,
209}
210
211impl AuthorizerSet {
212    /// Create a new set from a vector of authorizers.
213    pub fn new(authorizers: Vec<Arc<dyn IDynamicAuthorizer>>) -> Self {
214        Self { authorizers }
215    }
216
217    /// Run all registered authorizers in sequence.
218    ///
219    /// Returns `Ok(())` if ALL authorizers pass. Returns the first `Err` if any denies.
220    pub async fn authorize(
221        &self,
222        claims: &dyn IClaims,
223        route_pattern: &str,
224        method: &str,
225    ) -> Result<()> {
226        for authorizer in &self.authorizers {
227            authorizer.authorize(claims, route_pattern, method).await?;
228        }
229        Ok(())
230    }
231
232    /// Returns `true` if there are no authorizers registered.
233    pub fn is_empty(&self) -> bool {
234        self.authorizers.is_empty()
235    }
236}
237
238/// Build a [`ResourceAuthorization`] policy from compile-time
239/// [`RouteEntry`](rust_webx_core::route::scan::RouteEntry) metadata.
240pub fn build_resource_policy_from_routes() -> ResourceAuthorization {
241    use rust_webx_core::route::scan::RouteEntry;
242
243    let mut policy = ResourceAuthorization::new();
244    for entry in inventory::iter::<RouteEntry> {
245        if !entry.required_role.is_empty() && entry.required_role != "authenticated" {
246            policy = policy.allow_role(entry.path, entry.required_role);
247        }
248        if !entry.required_permission.is_empty() {
249            policy = policy.allow_permission(entry.path, entry.required_permission);
250        }
251    }
252    policy
253}
254
255/// Result from collecting `IDynamicAuthorizer` instances from DI.
256/// `None` means no authorizers are registered (pass-through mode).
257pub fn collect_authorizers(provider: &rust_dix::ServiceProvider) -> Option<Arc<AuthorizerSet>> {
258    let authorizers: Vec<Arc<dyn IDynamicAuthorizer>> = provider.get_all();
259    if authorizers.is_empty() {
260        None
261    } else {
262        Some(Arc::new(AuthorizerSet::new(authorizers)))
263    }
264}