Skip to main content

webx_core/
auth.rs

1//! Authentication and authorization traits for the rust-webx framework.
2//!
3//! Provides:
4//! - `IClaims`: Extracted auth claims (JWT or other token types).
5//! - `IAuthenticationHandler`: Authenticate an HTTP request and produce claims.
6//! - `IAuthorizationPolicy`: Check if authenticated claims can access a resource.
7
8use crate::error::{Error, Result};
9use crate::http::IHttpContext;
10use std::collections::HashMap;
11
12/// Claims extracted from an authentication token (JWT, etc.).
13///
14/// Stored in `IHttpContext` extensions via `IClaimsExt`.
15pub trait IClaims: Send + Sync {
16    /// The user / subject identifier.
17    fn subject(&self) -> &str;
18
19    /// Roles assigned to the user.
20    fn roles(&self) -> &[String];
21
22    /// Permissions assigned to the user.
23    fn permissions(&self) -> &[String];
24
25    /// Raw claims map (key-value pairs from the token).
26    fn claims(&self) -> &HashMap<String, String>;
27
28    /// Clone the claims into a new boxed trait object.
29    fn clone_box(&self) -> Box<dyn IClaims>;
30
31    // ── Convenience helpers (default implementations) ──
32
33    /// Check whether a specific role is assigned.
34    ///
35    /// ```ignore
36    /// if claims.has_role("admin") { ... }
37    /// ```
38    fn has_role(&self, role: &str) -> bool {
39        self.roles().iter().any(|r| r == role)
40    }
41
42    /// Alias for `subject()` — returns the user identifier.
43    fn get_userid(&self) -> &str {
44        self.subject()
45    }
46
47    /// Read the display name from the raw claims map (key `"name"`).
48    /// Returns `None` when absent.
49    fn get_username(&self) -> Option<&str> {
50        self.claims().get("name").map(|s| s.as_str())
51    }
52
53    /// Read the tenant identifier from the raw claims map (key `"tenant_id"`).
54    /// Returns `None` when absent.
55    fn get_tenantid(&self) -> Option<&str> {
56        self.claims()
57            .get("tenant_id")
58            .or_else(|| self.claims().get("tenant"))
59            .map(|s| s.as_str())
60    }
61
62    /// Read the email address from the raw claims map (key `"email"`).
63    /// Returns `None` when absent.
64    fn get_email(&self) -> Option<&str> {
65        self.claims().get("email").map(|s| s.as_str())
66    }
67
68    /// The token identifier (`jti` claim), when the token carries one.
69    ///
70    /// The authentication middleware looks this up in
71    /// [`ISessionStore`](crate::security::ISessionStore) to reject revoked
72    /// tokens. A token without a `jti` cannot be revoked individually; the
73    /// revocation check then leaves it alone (it still expires at `exp`).
74    fn jti(&self) -> Option<&str> {
75        self.claims().get("jti").map(String::as_str)
76    }
77}
78
79impl Clone for Box<dyn IClaims> {
80    fn clone(&self) -> Self {
81        self.clone_box()
82    }
83}
84
85/// Authentication scheme interface.
86///
87/// Implementations read credentials from the HTTP context (e.g., JWT bearer token,
88/// API key header, cookie) and return claims or `None` if unauthenticated.
89#[async_trait::async_trait]
90pub trait IAuthenticationHandler: Send + Sync {
91    /// Authenticate the request and return claims, or `None` if not authenticated.
92    ///
93    /// Uses `&mut dyn IHttpContext` so the returned future is `Send`
94    /// (required by `tokio::spawn` — `&dyn IHttpContext` is `!Send`
95    /// because `IHttpContext` is not `Sync`).
96    async fn authenticate(&self, ctx: &mut dyn IHttpContext) -> Result<Option<Box<dyn IClaims>>>;
97}
98
99/// Authorization policy that checks whether an authenticated user
100/// can access a given resource.
101///
102/// The `resource_key` is the original route pattern string (e.g., `"/api/users/{id}"`),
103/// enabling dynamic identity-authorization-data systems to match routes directly.
104#[async_trait::async_trait]
105pub trait IAuthorizationPolicy: Send + Sync {
106    /// Check if the authenticated user can access the resource.
107    ///
108    /// * `claims`  — the user's authentication claims.
109    /// * `resource_key` — the original route pattern string.
110    /// * `method` — the HTTP method.
111    ///
112    /// Returns `Ok(())` if authorized, or an `Err` if forbidden.
113    async fn authorize(&self, claims: &dyn IClaims, resource_key: &str, method: &str)
114        -> Result<()>;
115}
116
117/// The `#[authorize]` rules declared on the matched endpoint.
118///
119/// A zero-allocation view over the route metadata's `AuthorizeRule` list — the
120/// framework hands it to [`IDynamicAuthorizer::authorize_with_rules`] so a
121/// dynamic backend sees the *same literals* the endpoint declared (e.g.
122/// `#[authorize(permission = "order.create")]`), instead of re-deriving the
123/// requirement from route + method.
124///
125/// **Multi-rule semantics are AND, preserved here:** each item is one
126/// `#[authorize]` attribute and *every* item must hold. Within one item, the
127/// role list is any-of and the permission list is any-of; an empty list leaves
128/// that dimension unconstrained, so a rule with both empty means "any
129/// authenticated user". [`DeclaredRules::roles`] and
130/// [`DeclaredRules::permissions`] therefore yield one list per rule rather than
131/// a flattened union.
132///
133/// An endpoint without `#[authorize]` has no rules ([`DeclaredRules::empty`]);
134/// a bare `#[authorize]` has one rule with both lists empty.
135///
136/// New in 1.2.0; additive — `authorize` keeps its 1.1 signature.
137///
138/// **Legacy view (1.3.0):** the framework now hands the authorizer the
139/// endpoint's `&'static [AuthorizeRule]` slice directly through
140/// [`IDynamicAuthorizer::authorize_with_declared_rules`], so this wrapper no
141/// longer needs to be built on the hot path. It stays for source compatibility
142/// and is scheduled for removal in 2.0 — new code should take the raw slice.
143#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
144pub struct DeclaredRules<'a> {
145    rules: &'a [crate::route::scan::AuthorizeRule],
146}
147
148impl<'a> DeclaredRules<'a> {
149    /// An endpoint with no `#[authorize]` rule.
150    pub const fn empty() -> Self {
151        Self { rules: &[] }
152    }
153
154    /// View over the endpoint's `#[authorize]` rules (usually
155    /// [`RouteEntry::authorization`](crate::route::scan::RouteEntry)).
156    pub const fn new(rules: &'a [crate::route::scan::AuthorizeRule]) -> Self {
157        Self { rules }
158    }
159
160    /// Whether the endpoint declares no `#[authorize]` rule at all.
161    pub fn is_empty(&self) -> bool {
162        self.rules.is_empty()
163    }
164
165    /// The raw rules — every item must hold (AND).
166    pub fn rules(&self) -> &'a [crate::route::scan::AuthorizeRule] {
167        self.rules
168    }
169
170    /// Declared role lists, one item per `#[authorize]` rule.
171    ///
172    /// Each item is an any-of list (`[]` = no role constraint); the items
173    /// themselves are ANDed.
174    pub fn roles(&self) -> impl Iterator<Item = &'a [&'a str]> + 'a {
175        self.rules.iter().map(|rule| rule.roles)
176    }
177
178    /// Declared permission lists, one item per `#[authorize]` rule.
179    ///
180    /// Each item is an any-of list (`[]` = no permission constraint); the items
181    /// themselves are ANDed.
182    pub fn permissions(&self) -> impl Iterator<Item = &'a [&'a str]> + 'a {
183        self.rules.iter().map(|rule| rule.permissions)
184    }
185}
186
187/// Dynamic authorizer interface — pluggable authorization for protected routes.
188///
189/// Implement this trait and register it in the DI container:
190///
191/// ```ignore
192/// svc.singleton::<dyn IDynamicAuthorizer>(|_| Arc::new(MyAuthorizer::default()))
193/// ```
194///
195/// The framework automatically detects registered `IDynamicAuthorizer` implementations
196/// and invokes them on every route that has `#[authorize]` — static and
197/// `#[authorize(dynamic)]` alike. If no implementations are registered,
198/// authorization is pass-through (no dynamic checks; a dynamic endpoint then
199/// only requires an authenticated principal, so the application must enforce
200/// its permissions elsewhere or register an authorizer).
201///
202/// # Methods
203///
204/// * `authorize_with_declared_rules` — the primary path since 1.3.0. Receives
205///   the endpoint's `&'static [AuthorizeRule]` slice directly (zero allocation,
206///   the same slice as `RouteEntry.authorization`). Override this to see *which*
207///   role/permission codes the endpoint declared.
208/// * `authorize_with_rules` — 1.2.0 signature taking a [`DeclaredRules`] view.
209///   Kept for source compatibility; the host no longer calls it. Scheduled for
210///   removal in 2.0. The default forwards to `authorize`.
211/// * `authorize` — receives the user's claims, matched route pattern, and HTTP
212///   method. Kept for source compatibility with 1.1; the default implementation
213///   of both methods above ends up here, so an implementation that only
214///   overrides `authorize` keeps working unchanged.
215///
216/// # Dynamic endpoints
217///
218/// On a route declared `#[authorize(dynamic)]` the static gate stops at
219/// "authenticated" (no claims → 401) and the declaration in
220/// `RouteEntry.authorization` is passed to the authorizers instead of being
221/// evaluated against `claims.permissions()`. That makes an authorizer backed by
222/// the database ("DB is the source of truth") authoritative per request, without
223/// shipping a permission snapshot in the token.
224#[async_trait::async_trait]
225pub trait IDynamicAuthorizer: Send + Sync {
226    /// Validate whether the authenticated user can access the resource.
227    async fn authorize(
228        &self,
229        claims: &dyn IClaims,
230        route_pattern: &str,
231        method: &str,
232    ) -> Result<()>;
233
234    /// Validate access with the endpoint's declared role / permission codes.
235    ///
236    /// **Legacy since 1.3.0** — the host now calls
237    /// [`IDynamicAuthorizer::authorize_with_declared_rules`], which passes the
238    /// raw slice. This method is kept for source compatibility (existing
239    /// implementations that override it still receive the rules through the new
240    /// method's default forwarding) and is scheduled for removal in 2.0.
241    ///
242    /// A default implementation forwarding to `authorize` keeps 1.1
243    /// implementations wire-compatible.
244    async fn authorize_with_rules(
245        &self,
246        claims: &dyn IClaims,
247        route_pattern: &str,
248        method: &str,
249        _declared: &DeclaredRules<'_>,
250    ) -> Result<()> {
251        self.authorize(claims, route_pattern, method).await
252    }
253
254    /// Validate access with the endpoint's declared `#[authorize]` rules.
255    ///
256    /// The zero-allocation primary path (new in 1.3.0): `declared` is the
257    /// endpoint's `RouteEntry.authorization` slice itself, borrowed for the
258    /// whole process. Every item is one `#[authorize]` attribute and all of
259    /// them must hold (AND); inside one item the role and permission lists are
260    /// any-of. On a `#[authorize(dynamic)]` endpoint the static gate does not
261    /// evaluate these rules — this method decides.
262    ///
263    /// The default forwards to [`IDynamicAuthorizer::authorize_with_rules`]
264    /// (which forwards to [`IDynamicAuthorizer::authorize`]), so an existing
265    /// implementation that overrides either legacy method works unchanged.
266    async fn authorize_with_declared_rules(
267        &self,
268        claims: &dyn IClaims,
269        route_pattern: &str,
270        method: &str,
271        declared: &'static [crate::route::scan::AuthorizeRule],
272    ) -> Result<()> {
273        let declared = DeclaredRules::new(declared);
274        self.authorize_with_rules(claims, route_pattern, method, &declared)
275            .await
276    }
277}
278
279/// The standard identity a request can carry.
280///
281/// `IdentityUser` is a **read-only projection of [`IClaims`]** for request
282/// handling — not a persistence entity. When an endpoint needs the raw claim
283/// map, read it through [`IClaimsExt`](crate::http::IClaimsExt) in middleware or
284/// infrastructure code; `IdentityUser` covers the identity fields almost every
285/// handler needs.
286///
287/// Put it in a request with `#[fromclaim]` and the binder fills it from the
288/// authenticated principal:
289///
290/// ```ignore
291/// #[derive(WebxRequestMeta)]
292/// pub struct CreateOrderRequest {
293///     #[fromclaim]
294///     pub user: IdentityUser,
295///     pub sku: String,
296/// }
297/// ```
298///
299/// The handler then reads `req.user.id`, `req.user.tenant_id` or
300/// `req.user.is_in_role("admin")` without touching the token or the context.
301/// `#[fromclaim(optional)] viewer: Option<IdentityUser>` makes the endpoint work
302/// for anonymous callers too; anonymous access must be declared explicitly.
303#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
304pub struct IdentityUser {
305    /// The subject (`sub`) — the stable user identifier.
306    pub id: String,
307    /// Display name (`name` claim), empty when the token has none.
308    pub name: String,
309    /// Email address (`email` claim), empty when the token has none.
310    pub email: String,
311    /// Tenant identifier (`tenant_id` / `tenant`).
312    ///
313    /// The **empty string means "no tenant claim"** — tokens from single-tenant
314    /// apps and tokens without the claim leave it empty. Use
315    /// [`IdentityUser::tenant`] when the difference between "no tenant claim"
316    /// and "declared empty" matters, or [`IdentityUser::require_tenant`] to
317    /// reject a principal without a tenant.
318    pub tenant_id: String,
319    /// Roles assigned to the user.
320    pub roles: Vec<String>,
321    /// Fine-grained permissions assigned to the user.
322    pub permissions: Vec<String>,
323}
324
325impl IdentityUser {
326    /// Build the identity from an authenticated principal.
327    ///
328    /// Absent `name` / `email` / `tenant_id` claims become empty strings, so an
329    /// empty [`IdentityUser::tenant_id`] means "the principal carries no tenant
330    /// claim" rather than a tenant literally named `""`.
331    pub fn from_claims(claims: &dyn IClaims) -> Self {
332        Self {
333            id: claims.subject().to_string(),
334            name: claims.get_username().unwrap_or_default().to_string(),
335            email: claims.get_email().unwrap_or_default().to_string(),
336            tenant_id: claims.get_tenantid().unwrap_or_default().to_string(),
337            roles: claims.roles().to_vec(),
338            permissions: claims.permissions().to_vec(),
339        }
340    }
341
342    /// The tenant identifier, or `None` when the principal carries no tenant
343    /// claim.
344    ///
345    /// [`IdentityUser::tenant_id`] stores "no claim" as the empty string for
346    /// backwards compatibility; this accessor makes that distinction explicit.
347    pub fn tenant(&self) -> Option<&str> {
348        if self.tenant_id.is_empty() {
349            None
350        } else {
351            Some(self.tenant_id.as_str())
352        }
353    }
354
355    /// The tenant identifier, or `Error::Forbidden` when the principal carries
356    /// no tenant claim.
357    ///
358    /// A missing tenant is **not** an authentication failure — the principal
359    /// already proved who it is — it is a scope problem: the token simply does
360    /// not reach any tenant. Reporting 403 instead of 401 keeps clients from
361    /// retrying with the same valid credentials.
362    pub fn require_tenant(&self) -> Result<&str> {
363        self.tenant().ok_or_else(|| {
364            Error::Forbidden("the authenticated principal has no tenant claim".to_string())
365        })
366    }
367
368    /// Whether the user has `role`.
369    ///
370    /// Boolean check only — for a 401/403 decision use
371    /// [`RequestUser::require_role`] instead.
372    pub fn is_in_role(&self, role: &str) -> bool {
373        self.roles.iter().any(|assigned| assigned == role)
374    }
375
376    /// Whether the user has `permission`.
377    pub fn has_permission(&self, permission: &str) -> bool {
378        self.permissions
379            .iter()
380            .any(|assigned| assigned == permission)
381    }
382
383    /// The display name, falling back to the identifier.
384    pub fn display_name(&self) -> &str {
385        if self.name.is_empty() {
386            &self.id
387        } else {
388            &self.name
389        }
390    }
391}
392
393/// Convenience accessors for a request that carries an [`IdentityUser`].
394///
395/// `#[derive(WebxRequestMeta)]` implements this automatically when the struct
396/// has a `#[fromclaim]` field of type `IdentityUser` (or
397/// `#[fromclaim(optional)]` of type `Option<IdentityUser>`).
398pub trait RequestUser {
399    /// The authenticated identity, or `None` for an anonymous request.
400    fn current_user(&self) -> Option<&IdentityUser>;
401
402    /// The subject identifier, or `None` when anonymous.
403    fn user_id(&self) -> Option<&str> {
404        self.current_user().map(|user| user.id.as_str())
405    }
406
407    /// The display name, or `None` when anonymous.
408    fn user_name(&self) -> Option<&str> {
409        self.current_user().map(|user| user.name.as_str())
410    }
411
412    /// The email address, or `None` when anonymous.
413    fn user_email(&self) -> Option<&str> {
414        self.current_user().map(|user| user.email.as_str())
415    }
416
417    /// The tenant identifier, or `None` when anonymous.
418    ///
419    /// Returns `Some("")` for an authenticated principal without a tenant claim
420    /// (that is how [`IdentityUser::tenant_id`] represents "no claim"); use
421    /// [`IdentityUser::tenant`] or [`RequestUser::require_tenant`] when the
422    /// difference matters.
423    fn tenant_id(&self) -> Option<&str> {
424        self.current_user().map(|user| user.tenant_id.as_str())
425    }
426
427    /// The roles of the authenticated user (empty when anonymous).
428    fn roles(&self) -> &[String] {
429        match self.current_user() {
430            Some(user) => &user.roles,
431            None => &[],
432        }
433    }
434
435    /// The permissions of the authenticated user (empty when anonymous).
436    fn permissions(&self) -> &[String] {
437        match self.current_user() {
438            Some(user) => &user.permissions,
439            None => &[],
440        }
441    }
442
443    /// Whether the authenticated user has `role`.
444    ///
445    /// Boolean branch only (anonymous → `false`); it cannot tell "not signed in"
446    /// from "signed in without the role". Use [`RequestUser::require_role`] for
447    /// a 401/403 decision.
448    fn is_in_role(&self, role: &str) -> bool {
449        self.current_user()
450            .map(|user| user.is_in_role(role))
451            .unwrap_or(false)
452    }
453
454    /// Whether the authenticated user has `permission` (anonymous → `false`).
455    fn has_permission(&self, permission: &str) -> bool {
456        self.current_user()
457            .map(|user| user.has_permission(permission))
458            .unwrap_or(false)
459    }
460
461    /// The identity, or `401 Unauthorized` when the request is anonymous.
462    fn require_user(&self) -> Result<&IdentityUser> {
463        self.current_user()
464            .ok_or_else(|| Error::Unauthorized("authentication required".to_string()))
465    }
466
467    /// Require `role`, separating "not signed in" from "not allowed".
468    ///
469    /// * anonymous → `401 Unauthorized` (`authentication required`)
470    /// * authenticated without the role → `403 Forbidden` (`{role} role required`)
471    ///
472    /// The 401/403 split lets clients react correctly: acquire a token, or stop
473    /// retrying because the token lacks the role.
474    fn require_role(&self, role: &str) -> Result<()> {
475        let user = self.require_user()?;
476        if user.is_in_role(role) {
477            Ok(())
478        } else {
479            Err(Error::Forbidden(format!("{role} role required")))
480        }
481    }
482
483    /// Require a tenant claim, separating "not signed in" from "no tenant
484    /// scope".
485    ///
486    /// * anonymous → `401 Unauthorized`
487    /// * authenticated without a tenant claim → delegates to
488    ///   [`IdentityUser::require_tenant`] (`403 Forbidden`)
489    fn require_tenant(&self) -> Result<&str> {
490        self.require_user()?.require_tenant()
491    }
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497
498    #[test]
499    fn declared_rules_empty_has_no_rules() {
500        let empty = DeclaredRules::empty();
501        assert!(empty.is_empty());
502        assert!(empty.rules().is_empty());
503        assert_eq!(empty.roles().count(), 0);
504        assert_eq!(empty.permissions().count(), 0);
505        assert_eq!(empty, DeclaredRules::default());
506    }
507
508    #[test]
509    fn declared_rules_keep_the_and_structure_of_multiple_authorize_attributes() {
510        const RULES: &[crate::route::scan::AuthorizeRule] = &[
511            crate::route::scan::AuthorizeRule {
512                roles: &["admin"],
513                permissions: &[],
514            },
515            crate::route::scan::AuthorizeRule {
516                roles: &[],
517                permissions: &["order.create"],
518            },
519        ];
520
521        let declared = DeclaredRules::new(RULES);
522        assert!(!declared.is_empty());
523        assert_eq!(declared.rules(), RULES);
524
525        let roles: Vec<&[&str]> = declared.roles().collect();
526        assert_eq!(roles, vec![&["admin"][..], &[][..]]);
527        let permissions: Vec<&[&str]> = declared.permissions().collect();
528        assert_eq!(permissions, vec![&[][..], &["order.create"][..]]);
529    }
530
531    /// Minimal `RequestUser` carrier: a request struct with
532    /// `#[fromclaim(optional)] viewer: Option<IdentityUser>` reduces to this.
533    struct TestRequest<'a>(Option<&'a IdentityUser>);
534
535    impl RequestUser for TestRequest<'_> {
536        fn current_user(&self) -> Option<&IdentityUser> {
537            self.0
538        }
539    }
540
541    fn multi_tenant_admin() -> IdentityUser {
542        IdentityUser {
543            id: "user-1".into(),
544            name: "Ada".into(),
545            roles: vec!["admin".into(), "user".into()],
546            permissions: vec!["orders:read".into()],
547            tenant_id: "acme".into(),
548            ..Default::default()
549        }
550    }
551
552    /// Authenticated principal from a token without a tenant claim.
553    fn single_tenant_user() -> IdentityUser {
554        IdentityUser {
555            id: "user-2".into(),
556            roles: Vec::new(),
557            permissions: Vec::new(),
558            tenant_id: String::new(),
559            ..Default::default()
560        }
561    }
562
563    #[test]
564    fn identity_tenant_is_none_when_claim_is_empty() {
565        assert_eq!(multi_tenant_admin().tenant(), Some("acme"));
566        assert_eq!(
567            single_tenant_user().tenant(),
568            None,
569            "empty tenant_id means 'no tenant claim', not a tenant named \"\""
570        );
571    }
572
573    #[test]
574    fn identity_require_tenant_is_forbidden_without_claim() {
575        let user = single_tenant_user();
576        let err = user.require_tenant().unwrap_err();
577        assert!(
578            matches!(&err, Error::Forbidden(detail) if detail.contains("no tenant claim")),
579            "expected Forbidden(no tenant claim), got {err:?}"
580        );
581        assert_eq!(err.status_code(), 403, "missing tenant scope is not 401");
582
583        assert_eq!(multi_tenant_admin().require_tenant().unwrap(), "acme");
584    }
585
586    #[test]
587    fn anonymous_request_user_is_empty_and_unauthorized() {
588        let anonymous = TestRequest(None);
589
590        assert!(anonymous.permissions().is_empty());
591        assert!(!anonymous.has_permission("orders:read"));
592        assert!(!anonymous.is_in_role("admin"));
593        assert!(anonymous.current_user().is_none());
594
595        let role_err = anonymous.require_role("admin").unwrap_err();
596        assert!(
597            matches!(&role_err, Error::Unauthorized(detail) if detail == "authentication required"),
598            "anonymous must be 401, got {role_err:?}"
599        );
600        assert_eq!(role_err.status_code(), 401);
601
602        let tenant_err = anonymous.require_tenant().unwrap_err();
603        assert_eq!(tenant_err.status_code(), 401);
604    }
605
606    #[test]
607    fn authenticated_request_user_exposes_roles_permissions_and_tenant() {
608        let user = multi_tenant_admin();
609        let request = TestRequest(Some(&user));
610
611        assert_eq!(request.permissions().len(), 1);
612        assert_eq!(request.permissions()[0], "orders:read");
613        assert!(request.has_permission("orders:read"));
614        assert!(!request.has_permission("orders:write"));
615        assert!(request.is_in_role("admin"));
616        assert!(request.require_role("admin").is_ok());
617        assert_eq!(request.require_tenant().unwrap(), "acme");
618    }
619
620    #[test]
621    fn require_role_is_forbidden_when_role_is_missing() {
622        let user = multi_tenant_admin();
623        let request = TestRequest(Some(&user));
624
625        let err = request.require_role("auditor").unwrap_err();
626        assert!(
627            matches!(&err, Error::Forbidden(detail) if detail == "auditor role required"),
628            "expected Forbidden(auditor role required), got {err:?}"
629        );
630        assert_eq!(err.status_code(), 403);
631    }
632
633    #[test]
634    fn require_tenant_is_forbidden_for_authenticated_principal_without_claim() {
635        let user = single_tenant_user();
636        let request = TestRequest(Some(&user));
637
638        // Still signed in: this is a scope problem, not a credentials problem.
639        assert!(request.current_user().is_some());
640        let err = request.require_tenant().unwrap_err();
641        assert!(matches!(&err, Error::Forbidden(_)), "got {err:?}");
642        assert_eq!(err.status_code(), 403);
643    }
644
645    // ── WEBX-19: `authorize_with_declared_rules` primary path ──────────────
646
647    const TWO_RULES: &[crate::route::scan::AuthorizeRule] = &[
648        crate::route::scan::AuthorizeRule {
649            roles: &["admin"],
650            permissions: &[],
651        },
652        crate::route::scan::AuthorizeRule {
653            roles: &[],
654            permissions: &["order.create"],
655        },
656    ];
657
658    struct TestClaims;
659
660    impl IClaims for TestClaims {
661        fn subject(&self) -> &str {
662            "user-1"
663        }
664        fn roles(&self) -> &[String] {
665            &[]
666        }
667        fn permissions(&self) -> &[String] {
668            &[]
669        }
670        fn claims(&self) -> &HashMap<String, String> {
671            static EMPTY: std::sync::OnceLock<HashMap<String, String>> = std::sync::OnceLock::new();
672            EMPTY.get_or_init(HashMap::new)
673        }
674        fn clone_box(&self) -> Box<dyn IClaims> {
675            Box::new(TestClaims)
676        }
677    }
678
679    /// 1.1-style implementation: overrides only `authorize`.
680    #[derive(Default)]
681    struct LegacyAuthorizeOnly {
682        calls: std::sync::Mutex<Vec<(String, String)>>,
683    }
684
685    #[async_trait::async_trait]
686    impl IDynamicAuthorizer for LegacyAuthorizeOnly {
687        async fn authorize(
688            &self,
689            _claims: &dyn IClaims,
690            route_pattern: &str,
691            method: &str,
692        ) -> Result<()> {
693            self.calls
694                .lock()
695                .unwrap()
696                .push((route_pattern.to_string(), method.to_string()));
697            Ok(())
698        }
699    }
700
701    #[tokio::test]
702    async fn declared_rules_default_forwards_to_authorize() {
703        let authorizer = LegacyAuthorizeOnly::default();
704        authorizer
705            .authorize_with_declared_rules(&TestClaims, "/api/orders", "POST", TWO_RULES)
706            .await
707            .expect("the default must reach a 1.1 `authorize` implementation");
708        assert_eq!(
709            authorizer.calls.lock().unwrap().as_slice(),
710            &[("/api/orders".to_string(), "POST".to_string())],
711            "the new method default keeps the 1.1 `authorize` implementation working"
712        );
713    }
714
715    /// 1.2.0-style implementation: overrides only `authorize_with_rules`.
716    #[derive(Default)]
717    struct LegacyRulesOnly {
718        seen: std::sync::Mutex<Vec<crate::route::scan::AuthorizeRule>>,
719    }
720
721    #[async_trait::async_trait]
722    impl IDynamicAuthorizer for LegacyRulesOnly {
723        async fn authorize(
724            &self,
725            _claims: &dyn IClaims,
726            _route_pattern: &str,
727            _method: &str,
728        ) -> Result<()> {
729            panic!("authorize() must not run when authorize_with_rules is overridden");
730        }
731
732        async fn authorize_with_rules(
733            &self,
734            _claims: &dyn IClaims,
735            _route_pattern: &str,
736            _method: &str,
737            declared: &DeclaredRules<'_>,
738        ) -> Result<()> {
739            self.seen
740                .lock()
741                .unwrap()
742                .extend_from_slice(declared.rules());
743            Ok(())
744        }
745    }
746
747    #[tokio::test]
748    async fn declared_rules_default_forwards_to_the_legacy_rules_method() {
749        let authorizer = LegacyRulesOnly::default();
750        authorizer
751            .authorize_with_declared_rules(&TestClaims, "/api/orders", "POST", TWO_RULES)
752            .await
753            .expect("the default must reach a 1.2.0 `authorize_with_rules` implementation");
754        assert_eq!(
755            authorizer.seen.lock().unwrap().as_slice(),
756            TWO_RULES,
757            "the raw declaration reaches the legacy method unchanged, AND structure included"
758        );
759    }
760}