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.policy.authorize(claims, &route_pattern, &method).await.map(|_| ControlFlow::Continue(())),
177            None => Err(rust_webx_core::error::Error::Unauthorized(
178                "no authentication claims found".to_string(),
179            )),
180        }
181    }
182}
183
184/// Create a resource-based authorization middleware.
185///
186/// Register this middleware AFTER the authentication middleware in the pipeline.
187/// The middleware reads the route pattern (set by the router) and
188/// claims (set by the auth middleware) to enforce the policy.
189pub fn resource_auth_middleware(policy: Arc<dyn IAuthorizationPolicy>) -> impl IMiddleware {
190    ResourceAuthMiddleware { policy }
191}
192
193// ---------------------------------------------------------------------------
194// AuthorizerSet —dynamic authorizer collection
195// ---------------------------------------------------------------------------
196
197/// A set of `IDynamicAuthorizer` instances collected from the DI container.
198///
199/// Created automatically in `HostBuilder::build()` when one or more
200/// `IDynamicAuthorizer` implementations are registered. If none are registered,
201/// this is `None` and no dynamic authorization checks run.
202#[derive(Clone)]
203pub struct AuthorizerSet {
204    authorizers: Vec<Arc<dyn IDynamicAuthorizer>>,
205}
206
207impl AuthorizerSet {
208    /// Create a new set from a vector of authorizers.
209    pub fn new(authorizers: Vec<Arc<dyn IDynamicAuthorizer>>) -> Self {
210        Self { authorizers }
211    }
212
213    /// Run all registered authorizers in sequence.
214    ///
215    /// Returns `Ok(())` if ALL authorizers pass. Returns the first `Err` if any denies.
216    pub async fn authorize(
217        &self,
218        claims: &dyn IClaims,
219        route_pattern: &str,
220        method: &str,
221    ) -> Result<()> {
222        for authorizer in &self.authorizers {
223            authorizer.authorize(claims, route_pattern, method).await?;
224        }
225        Ok(())
226    }
227
228    /// Returns `true` if there are no authorizers registered.
229    pub fn is_empty(&self) -> bool {
230        self.authorizers.is_empty()
231    }
232}
233
234/// Build a [`ResourceAuthorization`] policy from compile-time [`RouteEntry`] metadata.
235pub fn build_resource_policy_from_routes() -> ResourceAuthorization {
236    use rust_webx_core::route::scan::RouteEntry;
237
238    let mut policy = ResourceAuthorization::new();
239    for entry in inventory::iter::<RouteEntry> {
240        if !entry.required_role.is_empty() && entry.required_role != "authenticated" {
241            policy = policy.allow_role(entry.path, entry.required_role);
242        }
243        if !entry.required_permission.is_empty() {
244            policy = policy.allow_permission(entry.path, entry.required_permission);
245        }
246    }
247    policy
248}
249
250/// Result from collecting `IDynamicAuthorizer` instances from DI.
251/// `None` means no authorizers are registered (pass-through mode).
252pub fn collect_authorizers(
253    provider: &rust_dix::ServiceProvider,
254) -> Option<Arc<AuthorizerSet>> {
255    let authorizers: Vec<Arc<dyn IDynamicAuthorizer>> = provider.get_all();
256    if authorizers.is_empty() {
257        None
258    } else {
259        Some(Arc::new(AuthorizerSet::new(authorizers)))
260    }
261}