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 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 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 std::collections::HashMap;
46use std::ops::ControlFlow;
47use std::sync::Arc;
48use webx_core::auth::{IAuthorizationPolicy, IClaims, IDynamicAuthorizer};
49use webx_core::error::Result;
50use webx_core::http::IHttpContext;
51use webx_core::middleware::IMiddleware;
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(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(webx_core::error::Error::Unauthorized(
182 "authentication required".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/// Result from collecting `IDynamicAuthorizer` instances from DI.
239/// `None` means no authorizers are registered (pass-through mode).
240pub fn collect_authorizers(provider: &rust_dix::ServiceProvider) -> Option<Arc<AuthorizerSet>> {
241 let authorizers: Vec<Arc<dyn IDynamicAuthorizer>> = provider.get_all();
242 if authorizers.is_empty() {
243 None
244 } else {
245 Some(Arc::new(AuthorizerSet::new(authorizers)))
246 }
247}