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}