Skip to main content

autumn_web/
authorization.rs

1//! Policy-based record-level authorization.
2//!
3//! Spring Security's role checks (`#[secured("admin")]`) answer
4//! "are you allowed to call this *route*?" This module answers
5//! "are you allowed to act on *this specific record*?" — the
6//! question every multi-user CRUD app has to answer at every write
7//! endpoint.
8//!
9//! The shape mirrors Pundit (Rails) and Bodyguard (Phoenix): one
10//! [`Policy`] impl per resource, a [`Scope`] companion for list
11//! queries, default-deny semantics, and an `#[authorize]` attribute
12//! macro that wires the check declaratively.
13//!
14//! # Quick start
15//!
16//! ```rust,ignore
17//! use autumn_web::authorization::{Policy, PolicyContext};
18//! use autumn_web::AutumnResult;
19//!
20//! #[derive(Default)]
21//! pub struct PostPolicy;
22//!
23//! impl Policy<Post> for PostPolicy {
24//!     fn can_show<'a>(&'a self, _ctx: &'a PolicyContext, _post: &'a Post)
25//!         -> autumn_web::authorization::BoxFuture<'a, bool>
26//!     {
27//!         Box::pin(async { true }) // posts are public
28//!     }
29//!
30//!     fn can_update<'a>(&'a self, ctx: &'a PolicyContext, post: &'a Post)
31//!         -> autumn_web::authorization::BoxFuture<'a, bool>
32//!     {
33//!         Box::pin(async move {
34//!             ctx.has_role("admin")
35//!                 || ctx.user_id_i64() == Some(post.author_id)
36//!         })
37//!     }
38//!
39//!     fn can_delete<'a>(&'a self, ctx: &'a PolicyContext, post: &'a Post)
40//!         -> autumn_web::authorization::BoxFuture<'a, bool>
41//!     {
42//!         Box::pin(async move {
43//!             ctx.has_role("admin")
44//!                 || ctx.user_id_i64() == Some(post.author_id)
45//!         })
46//!     }
47//! }
48//! ```
49//!
50//! Register the policy on the app builder and reference it from
51//! `#[repository(api = "/posts", policy = PostPolicy)]` to enforce
52//! the same checks on auto-generated REST endpoints.
53
54use std::any::{Any, TypeId};
55use std::collections::HashMap;
56use std::pin::Pin;
57use std::sync::{Arc, RwLock};
58
59use http::StatusCode;
60
61use crate::session::Session;
62
63/// Provides the specific application state needed by the authorization subsystem.
64///
65/// This trait severs the dependency from the `authorization` module back to the
66/// `state` module, breaking a circular dependency (`state` -> `authorization` -> `session` -> `state`).
67pub trait ProvideAuthorizationState: Send + Sync {
68    fn policy_registry(&self) -> &PolicyRegistry;
69    fn auth_session_key(&self) -> &str;
70    fn forbidden_response(&self) -> &ForbiddenResponse;
71
72    #[cfg(feature = "db")]
73    fn pool(
74        &self,
75    ) -> Option<&diesel_async::pooled_connection::deadpool::Pool<crate::db::RuntimeConnection>>;
76}
77
78/// Boxed future returned by [`Policy`] and [`Scope`] methods so the
79/// traits remain object-safe (`dyn Policy<R>` works regardless of
80/// rust edition).
81pub type BoxFuture<'a, T> = Pin<Box<dyn std::future::Future<Output = T> + Send + 'a>>;
82
83// ── PolicyContext ────────────────────────────────────────────────
84
85/// Per-request context handed to every policy and scope check.
86///
87/// Carries the resolved [`Session`], the authenticated user id (when
88/// present), the active role set, the [`PolicyRegistry`] (so
89/// `Post::scope(&ctx)` can resolve a registered scope without
90/// re-threading state), and a clone of the database pool so
91/// policies can consult related rows. `Clone + Send + Sync` — flows
92/// freely across `.await` points.
93#[derive(Clone)]
94pub struct PolicyContext {
95    /// The full per-request [`Session`]. Read raw values via
96    /// [`Session::get`] when a policy needs data beyond the
97    /// canonical user-id and role keys.
98    pub session: Session,
99
100    /// The authenticated user id, if any. Mirrors the configured
101    /// session auth key (default: `"user_id"`).
102    pub user_id: Option<String>,
103
104    /// Active role set for the current user. Empty when the user
105    /// has no role or is anonymous.
106    pub roles: Vec<String>,
107
108    /// Scopes (token abilities) granted to the authenticating service
109    /// token, e.g. `posts:read`, `posts:write`. Empty for session-only
110    /// requests. Distinct from the record-level [`Scope`] trait: these
111    /// are flat permission strings carried by a scoped API token.
112    /// Surfaced via [`has_scope`](Self::has_scope) and friends.
113    pub scopes: Vec<String>,
114
115    /// Database connection pool, cloned from `AppState`. Policies
116    /// that need to consult related rows (e.g. group membership)
117    /// can borrow a connection here.
118    #[cfg(feature = "db")]
119    pub pool: Option<diesel_async::pooled_connection::deadpool::Pool<crate::db::RuntimeConnection>>,
120
121    /// Registered [`Policy`] / [`Scope`] map, cloned from
122    /// `AppState`. Lets the [`Scoped`] blanket trait resolve a
123    /// registered scope from `&ctx` alone — the
124    /// `Post::scope(&ctx).load(&mut db).await?` ergonomic the
125    /// authorization guide documents.
126    pub policy_registry: PolicyRegistry,
127}
128
129impl PolicyContext {
130    /// Build a [`PolicyContext`] from a session alone.
131    ///
132    /// The resulting context has an empty [`PolicyRegistry`] and no
133    /// pool — sufficient for hand-rolled policy unit tests that
134    /// don't go through `AppState`. Production code paths construct
135    /// a [`PolicyContext`] via [`from_request`](Self::from_request)
136    /// instead.
137    pub async fn from_session(session: &Session, auth_session_key: &str) -> Self {
138        let user_id = session.get(auth_session_key).await;
139
140        // Seed the ambient current actor (#1383) with the authenticated session
141        // user, mirroring how `auth.rs` publishes the principal next to
142        // `log::context::set_user_id`. This is the single seam that covers every
143        // `#[repository(policy = ...)]` route which gates on a
144        // session-authenticated policy check but is NOT also wrapped by
145        // `RequireAuth`/`#[secured]`/an API-token bearer (the three existing
146        // `set_actor` sites) — without this, such a route's versioned writes
147        // would fall back to `SYSTEM_ACTOR` even though a real user is in scope.
148        //
149        // This session seed is only a *fallback*: it never overrides an
150        // already-established principal. If a stronger actor is already in scope
151        // — an API-token bearer, `RequireAuth`/`#[secured]`, or an explicit
152        // `with_actor(...)` scope — `Current::actor()` is already `Some`, so we
153        // skip the seed and the stronger actor wins (a request carrying both a
154        // bearer token and a session cookie stays attributed to the token
155        // principal). It also never overrides an explicit `AuditEvent` actor or a
156        // `before_*` hook's `ctx.actor`, which are applied downstream.
157        //
158        // In-request but with nothing yet published, the empty `CURRENT_ACTOR`
159        // scope makes `Current::actor()` return `None` (an in-scope unset does not
160        // consult the process default), so a pure policy+session route still gets
161        // seeded here. `set_actor` is a no-op outside an established request scope,
162        // so this stays panic-safe for non-request callers (e.g. hand-rolled
163        // policy unit tests), and it never publishes for an anonymous session
164        // (guarded on `Some`).
165        //
166        // Impersonation (#1394): when the session carries an impersonator, that
167        // operator — not the user the request resolves as — is the principal
168        // responsible for the writes this policy check gates.
169        if crate::current::Current::actor().is_none()
170            && let Some(user_id) = &user_id
171        {
172            crate::current::Current::set_actor(
173                crate::auth::impersonation::audit_actor_id(session, user_id).await,
174            );
175        }
176
177        let role = session.get("role").await;
178        let roles = role.into_iter().collect();
179        Self {
180            session: session.clone(),
181            user_id,
182            roles,
183            scopes: Vec::new(),
184            #[cfg(feature = "db")]
185            pool: None,
186            policy_registry: PolicyRegistry::default(),
187        }
188    }
189
190    /// Build a fully-populated [`PolicyContext`] from state +
191    /// `Session`. Used by the `#[authorize]` macro and
192    /// `#[repository(policy = ...)]`-generated handlers.
193    pub async fn from_request(state: &impl ProvideAuthorizationState, session: &Session) -> Self {
194        let mut ctx = Self::from_session(session, state.auth_session_key()).await;
195        ctx.policy_registry = state.policy_registry().clone();
196        #[cfg(feature = "db")]
197        {
198            if let Some(pool) = state.pool() {
199                ctx.pool = Some(pool.clone());
200            }
201        }
202        ctx
203    }
204
205    /// Returns `true` when the request has a resolved authenticated user.
206    #[must_use]
207    pub const fn is_authenticated(&self) -> bool {
208        self.user_id.is_some()
209    }
210
211    /// Returns the user id parsed as an `i64`, when the session
212    /// stored it as a numeric string. Convenient for the common
213    /// case of `BIGSERIAL` primary keys.
214    #[must_use]
215    pub fn user_id_i64(&self) -> Option<i64> {
216        self.user_id.as_deref().and_then(|s| s.parse().ok())
217    }
218
219    /// Returns `true` when the active role set contains `role`.
220    #[must_use]
221    pub fn has_role(&self, role: &str) -> bool {
222        self.roles.iter().any(|r| r == role)
223    }
224
225    /// Returns `true` when the active role set contains any of the
226    /// supplied roles. Mirrors `#[secured("a", "b")]` semantics.
227    #[must_use]
228    pub fn has_any_role<I, S>(&self, candidates: I) -> bool
229    where
230        I: IntoIterator<Item = S>,
231        S: AsRef<str>,
232    {
233        candidates.into_iter().any(|c| self.has_role(c.as_ref()))
234    }
235
236    /// Returns `true` when the authenticating token granted `scope`.
237    ///
238    /// Mirrors [`has_role`](Self::has_role) for token abilities. Works for
239    /// non-user principals: a pure service token with no roles still
240    /// authorizes on its granted scopes.
241    #[must_use]
242    pub fn has_scope(&self, scope: &str) -> bool {
243        self.scopes.iter().any(|s| s == scope)
244    }
245
246    /// Returns `true` when the token granted **any** of the supplied scopes.
247    /// Mirrors [`has_any_role`](Self::has_any_role).
248    #[must_use]
249    pub fn has_any_scope<I, S>(&self, candidates: I) -> bool
250    where
251        I: IntoIterator<Item = S>,
252        S: AsRef<str>,
253    {
254        candidates.into_iter().any(|c| self.has_scope(c.as_ref()))
255    }
256
257    /// Returns `true` when the token granted **all** of the supplied scopes.
258    /// An empty candidate set is vacuously `true`.
259    #[must_use]
260    pub fn has_all_scopes<I, S>(&self, candidates: I) -> bool
261    where
262        I: IntoIterator<Item = S>,
263        S: AsRef<str>,
264    {
265        candidates.into_iter().all(|c| self.has_scope(c.as_ref()))
266    }
267
268    /// Attach token scopes to the context. Used by the framework when
269    /// authorizing a scoped-token request; tests and hand-written handlers
270    /// can also call this to inject scopes by hand.
271    #[must_use]
272    pub fn with_scopes(mut self, scopes: Vec<String>) -> Self {
273        self.scopes = scopes;
274        self
275    }
276
277    /// The verified mTLS client identity of the connection this request arrived
278    /// on (issue #1640), when one was presented and verified.
279    ///
280    /// Machine identity, alongside the session user and token scopes: an
281    /// `#[authorize]` policy can decide on the calling *service*, not just the
282    /// calling person. `None` for every request over a connection with no
283    /// verified client certificate, and outside a request entirely.
284    ///
285    /// Read from the ambient request scope the HTTPS listener establishes
286    /// rather than carried as a field, so `PolicyContext` keeps the shape user
287    /// code constructs by hand. Like
288    /// [`Current::actor`](crate::current::Current::actor), it is scoped to the
289    /// task serving the request: a policy check moved onto a `tokio::spawn`ed
290    /// task sees `None`. A test injects one with
291    /// [`with_client_identity`](crate::tls::client_auth::with_client_identity).
292    #[cfg(feature = "tls")]
293    #[must_use]
294    pub fn client_identity(
295        &self,
296    ) -> Option<std::sync::Arc<crate::tls::client_auth::ClientIdentity>> {
297        crate::tls::client_auth::current_client_identity()
298    }
299
300    /// Whether the request arrived over a connection with a verified client
301    /// certificate.
302    #[cfg(feature = "tls")]
303    #[must_use]
304    pub fn has_client_identity(&self) -> bool {
305        self.client_identity().is_some()
306    }
307
308    /// Whether the verified client certificate carries `san`, e.g.
309    /// `ctx.client_has_san("URI:spiffe://acme/svc/orders")`. `false` when there
310    /// is no verified identity.
311    #[cfg(feature = "tls")]
312    #[must_use]
313    pub fn client_has_san(&self, san: &str) -> bool {
314        self.client_identity().is_some_and(|id| id.has_san(san))
315    }
316
317    /// Build a fully-populated [`PolicyContext`] from `AppState` + `Session`,
318    /// additionally threading the authenticating token's granted scopes (from
319    /// the [`crate::auth::ApiTokenScopes`] request extension) into the context.
320    ///
321    /// Use this from hand-written handlers that authorize a service principal on
322    /// scopes: extract `Option<Extension<ApiTokenScopes>>` and pass it through.
323    pub async fn from_request_parts(
324        state: &crate::AppState,
325        session: &Session,
326        scopes: Option<&crate::auth::ApiTokenScopes>,
327    ) -> Self {
328        let ctx = Self::from_request(state, session).await;
329        match scopes {
330            Some(s) => ctx.with_scopes(s.0.clone()),
331            None => ctx,
332        }
333    }
334
335    /// Attach a database pool to the context. Used by the framework
336    /// when constructing the context inside extractors; tests can
337    /// also call this to inject a pool by hand.
338    #[cfg(feature = "db")]
339    #[must_use]
340    pub fn with_pool(
341        mut self,
342        pool: diesel_async::pooled_connection::deadpool::Pool<crate::db::RuntimeConnection>,
343    ) -> Self {
344        self.pool = Some(pool);
345        self
346    }
347}
348
349// ── Policy trait ────────────────────────────────────────────────
350
351/// Record-level authorization policy for a resource.
352///
353/// All four built-in actions (`can_show`, `can_create`,
354/// `can_update`, `can_delete`) default to **denied**, which makes
355/// opting into a policy safe-by-default: a freshly-introduced
356/// policy with no overrides forbids every action until the
357/// developer explicitly allows one.
358///
359/// # Object safety
360///
361/// Every method returns a [`BoxFuture`] so `dyn Policy<R>` is
362/// usable behind an `Arc`. Tests can swap implementations via
363/// `AppBuilder::policy::<R, P>(P::default())`.
364///
365/// # Custom verbs
366///
367/// Use [`Policy::can`] for verbs that are not one of the four
368/// built-ins (e.g. `"publish"`, `"feature"`, `"archive"`). The
369/// default impl dispatches the four built-ins and returns `false`
370/// for unknown verbs.
371pub trait Policy<R: Send + Sync + 'static>: Send + Sync + 'static {
372    /// Decide whether the current user may *show* the resource.
373    fn can_show<'a>(&'a self, _ctx: &'a PolicyContext, _resource: &'a R) -> BoxFuture<'a, bool> {
374        Box::pin(async { false })
375    }
376
377    /// Decide whether the current user may *create* a resource of
378    /// this type.
379    ///
380    /// `can_create` receives request context only. Policies that need
381    /// the proposed JSON payload before insert can override
382    /// [`Policy::can_create_payload`].
383    fn can_create<'a>(&'a self, _ctx: &'a PolicyContext) -> BoxFuture<'a, bool> {
384        Box::pin(async { false })
385    }
386
387    /// Decide whether the current user may create the proposed
388    /// payload. Default behavior preserves compatibility by
389    /// delegating to [`Policy::can_create`].
390    fn can_create_payload<'a>(
391        &'a self,
392        ctx: &'a PolicyContext,
393        _payload: &'a serde_json::Value,
394    ) -> BoxFuture<'a, bool> {
395        self.can_create(ctx)
396    }
397
398    /// Decide whether the current user may *update* the resource.
399    fn can_update<'a>(&'a self, _ctx: &'a PolicyContext, _resource: &'a R) -> BoxFuture<'a, bool> {
400        Box::pin(async { false })
401    }
402
403    /// Decide whether the current user may *delete* the resource.
404    fn can_delete<'a>(&'a self, _ctx: &'a PolicyContext, _resource: &'a R) -> BoxFuture<'a, bool> {
405        Box::pin(async { false })
406    }
407
408    /// Decide a custom verb. Defaults to dispatching the four
409    /// built-ins by name. The `resource` argument is ignored when
410    /// dispatching to `can_create`, since `can_create` operates
411    /// pre-insert and has no resource instance.
412    fn can<'a>(
413        &'a self,
414        action: &'a str,
415        ctx: &'a PolicyContext,
416        resource: &'a R,
417    ) -> BoxFuture<'a, bool> {
418        Box::pin(async move {
419            match action {
420                "show" | "read" => self.can_show(ctx, resource).await,
421                "create" => self.can_create(ctx).await,
422                "update" | "edit" => self.can_update(ctx, resource).await,
423                "delete" | "destroy" => self.can_delete(ctx, resource).await,
424                _ => false,
425            }
426        })
427    }
428}
429
430// ── Scope trait ─────────────────────────────────────────────────
431
432/// List-time companion to [`Policy`] for filtering record sets the
433/// current user is allowed to read.
434///
435/// Default implementations return an **empty** list — fail closed.
436/// `#[repository(scope = ...)]`-generated `GET /<api>` index
437/// endpoints invoke the registered scope automatically; hand-
438/// written list handlers can use the [`Scoped`] blanket trait to
439/// invoke `Post::scope(&ctx).load(&mut db).await?`.
440///
441/// The `db` feature gates the connection parameter — without it,
442/// the trait still exists but `list` takes no connection (use
443/// `ctx.pool` to acquire one if needed).
444#[cfg(feature = "db")]
445pub trait Scope<R: Send + Sync + 'static>: Send + Sync + 'static {
446    /// Return the records the current user is allowed to read.
447    ///
448    /// The default impl returns `Ok(Vec::new())` so a missing
449    /// scope opt-in fails closed. Implementations typically run a
450    /// Diesel query through `conn`, applying whatever filters the
451    /// active `ctx.user_id` / `ctx.roles` warrant.
452    fn list<'a>(
453        &'a self,
454        _ctx: &'a PolicyContext,
455        _conn: &'a mut crate::db::RuntimeConnection,
456    ) -> BoxFuture<'a, crate::AutumnResult<Vec<R>>> {
457        Box::pin(async { Ok(Vec::new()) })
458    }
459}
460
461/// `Scope` companion that compiles when the `db` feature is off.
462/// The `db`-gated form takes `&mut RuntimeConnection` (the runtime
463/// pool connection — `AsyncPgConnection` by default, the `SQLite`
464/// wrapper under `--features sqlite`); this one has no connection arg.
465#[cfg(not(feature = "db"))]
466pub trait Scope<R: Send + Sync + 'static>: Send + Sync + 'static {
467    fn list<'a>(&'a self, _ctx: &'a PolicyContext) -> BoxFuture<'a, crate::AutumnResult<Vec<R>>> {
468        Box::pin(async { Ok(Vec::new()) })
469    }
470}
471
472// ── `Post::scope(&ctx).load(&mut db).await?` ergonomics ─────────
473
474/// Deferred query handle returned by [`Scoped::scope`].
475///
476/// Holds a borrow on the [`PolicyContext`] so the registered
477/// [`Scope`] for `R` can be resolved at `.load()` time. The
478/// pattern mirrors Pundit's `policy_scope(Post)` and Phoenix's
479/// `Bodyguard.scope/4`: a query you can run when the connection
480/// is available.
481pub struct ScopeQuery<'a, R: Send + Sync + 'static> {
482    ctx: &'a PolicyContext,
483    _marker: std::marker::PhantomData<fn() -> R>,
484}
485
486#[cfg(feature = "db")]
487impl<R: Send + Sync + 'static> ScopeQuery<'_, R> {
488    /// Load the records the current user is allowed to read.
489    ///
490    /// Resolves the [`Scope`] registered on the app's
491    /// [`PolicyRegistry`] (carried in [`PolicyContext`]) and runs
492    /// its `list` method against `conn`.
493    ///
494    /// # Errors
495    ///
496    /// Returns a `500` when no scope is registered for `R`; the
497    /// scope's own errors otherwise.
498    pub async fn load(
499        self,
500        conn: &mut crate::db::RuntimeConnection,
501    ) -> crate::AutumnResult<Vec<R>> {
502        let scope = self.ctx.policy_registry.scope::<R>().ok_or_else(|| {
503            crate::AutumnError::from(std::io::Error::other(format!(
504                "no scope registered for resource type {}",
505                std::any::type_name::<R>()
506            )))
507            .with_status(StatusCode::INTERNAL_SERVER_ERROR)
508        })?;
509        scope.list(self.ctx, conn).await
510    }
511}
512
513#[cfg(not(feature = "db"))]
514impl<R: Send + Sync + 'static> ScopeQuery<'_, R> {
515    pub async fn load(self) -> crate::AutumnResult<Vec<R>> {
516        let scope = self.ctx.policy_registry.scope::<R>().ok_or_else(|| {
517            crate::AutumnError::from(std::io::Error::other(format!(
518                "no scope registered for resource type {}",
519                std::any::type_name::<R>()
520            )))
521            .with_status(StatusCode::INTERNAL_SERVER_ERROR)
522        })?;
523        scope.list(self.ctx).await
524    }
525}
526
527/// Blanket trait that adds `T::scope(&ctx)` to every type, so
528/// hand-written list handlers can mirror the
529/// `#[repository(scope = ...)]`-generated path:
530///
531/// ```rust,ignore
532/// use autumn_web::authorization::Scoped;
533///
534/// let posts = Post::scope(&ctx).load(&mut db).await?;
535/// ```
536///
537/// Auto-implemented for every `Send + Sync + 'static` type. Bring
538/// the trait into scope with `use autumn_web::authorization::Scoped;`
539/// (or via `autumn_web::prelude::*`) to use the syntax.
540pub trait Scoped: Send + Sync + Sized + 'static {
541    /// Open a deferred [`ScopeQuery`] for this type. Resolves the
542    /// registered scope at `.load()` time, not here.
543    #[must_use]
544    fn scope(ctx: &PolicyContext) -> ScopeQuery<'_, Self> {
545        ScopeQuery {
546            ctx,
547            _marker: std::marker::PhantomData,
548        }
549    }
550}
551
552impl<T: Send + Sync + 'static> Scoped for T {}
553
554// ── PolicyRegistry ──────────────────────────────────────────────
555
556/// Process-wide registry of resource → policy and resource →
557/// scope bindings.
558///
559/// Stored on application state so handlers and
560/// `#[repository]`-generated endpoints can resolve a policy by
561/// resource type.
562#[derive(Clone, Default)]
563pub struct PolicyRegistry {
564    inner: Arc<RwLock<RegistryInner>>,
565}
566
567#[derive(Default)]
568struct RegistryInner {
569    policies: HashMap<TypeId, Arc<dyn Any + Send + Sync>>,
570    scopes: HashMap<TypeId, Arc<dyn Any + Send + Sync>>,
571}
572
573impl PolicyRegistry {
574    /// Register the [`Policy`] implementation for resource `R`.
575    ///
576    /// # Panics
577    ///
578    /// Panics if a policy is already registered for `R`. The issue
579    /// spec is explicit: multiple policies per resource are not
580    /// supported in this slice; double-registration must surface
581    /// as a startup-time error rather than silent override.
582    pub fn register_policy<R, P>(&self, policy: P)
583    where
584        R: Send + Sync + 'static,
585        P: Policy<R>,
586    {
587        let mut inner = self.inner.write().expect("policy registry lock poisoned");
588        let key = TypeId::of::<R>();
589        assert!(
590            !inner.policies.contains_key(&key),
591            "Policy for {} already registered. Multiple policies per resource are not supported.",
592            std::any::type_name::<R>()
593        );
594        let boxed: Arc<dyn Policy<R>> = Arc::new(policy);
595        inner.policies.insert(key, Arc::new(boxed));
596    }
597
598    /// Register the [`Scope`] implementation for resource `R`.
599    ///
600    /// # Panics
601    ///
602    /// Panics if a scope is already registered for `R`.
603    pub fn register_scope<R, S>(&self, scope: S)
604    where
605        R: Send + Sync + 'static,
606        S: Scope<R>,
607    {
608        let mut inner = self.inner.write().expect("policy registry lock poisoned");
609        let key = TypeId::of::<R>();
610        assert!(
611            !inner.scopes.contains_key(&key),
612            "Scope for {} already registered. Multiple scopes per resource are not supported.",
613            std::any::type_name::<R>()
614        );
615        let boxed: Arc<dyn Scope<R>> = Arc::new(scope);
616        inner.scopes.insert(key, Arc::new(boxed));
617    }
618
619    /// Resolve the registered [`Policy`] for resource `R`.
620    ///
621    /// # Panics
622    ///
623    /// Panics if the registry's internal `RwLock` is poisoned (a
624    /// previous writer panicked while holding the lock).
625    #[must_use]
626    pub fn policy<R: Send + Sync + 'static>(&self) -> Option<Arc<dyn Policy<R>>> {
627        let inner = self.inner.read().expect("policy registry lock poisoned");
628        inner
629            .policies
630            .get(&TypeId::of::<R>())
631            .and_then(|a| a.downcast_ref::<Arc<dyn Policy<R>>>().cloned())
632    }
633
634    /// Resolve the registered [`Scope`] for resource `R`.
635    ///
636    /// # Panics
637    ///
638    /// Panics if the registry's internal `RwLock` is poisoned.
639    #[must_use]
640    pub fn scope<R: Send + Sync + 'static>(&self) -> Option<Arc<dyn Scope<R>>> {
641        let inner = self.inner.read().expect("policy registry lock poisoned");
642        inner
643            .scopes
644            .get(&TypeId::of::<R>())
645            .and_then(|a| a.downcast_ref::<Arc<dyn Scope<R>>>().cloned())
646    }
647
648    /// Returns `true` when a policy is registered for resource `R`.
649    ///
650    /// # Panics
651    ///
652    /// Panics if the registry's internal `RwLock` is poisoned.
653    #[must_use]
654    pub fn has_policy<R: Send + Sync + 'static>(&self) -> bool {
655        self.inner
656            .read()
657            .expect("policy registry lock poisoned")
658            .policies
659            .contains_key(&TypeId::of::<R>())
660    }
661}
662
663impl std::fmt::Debug for PolicyRegistry {
664    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
665        let inner = self.inner.read().expect("policy registry lock poisoned");
666        f.debug_struct("PolicyRegistry")
667            .field("policies", &inner.policies.len())
668            .field("scopes", &inner.scopes.len())
669            .finish()
670    }
671}
672
673// ── Forbidden response shape ────────────────────────────────────
674
675/// HTTP status the framework returns when a [`Policy`] denies an
676/// action.
677///
678/// Defaults to `404 Not Found` so unauthorized clients cannot
679/// distinguish "the record exists but you cannot touch it" from
680/// "the record does not exist." This mirrors Rails / Phoenix
681/// defaults; flip to `403` via
682/// `[security] forbidden_response = "403"` in `autumn.toml` when
683/// the leak is acceptable (e.g. internal admin tooling).
684#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
685pub enum ForbiddenResponse {
686    /// Return `403 Forbidden`.
687    Forbidden403,
688    /// Return `404 Not Found` (default, hides existence).
689    #[default]
690    NotFound404,
691}
692
693impl ForbiddenResponse {
694    /// HTTP status code for the deny response.
695    #[must_use]
696    pub const fn status(self) -> StatusCode {
697        match self {
698            Self::Forbidden403 => StatusCode::FORBIDDEN,
699            Self::NotFound404 => StatusCode::NOT_FOUND,
700        }
701    }
702
703    /// Human-readable message for the deny response body. Kept
704    /// generic so a `404`-mode response does not accidentally
705    /// reveal existence via the message text.
706    #[must_use]
707    pub const fn message(self) -> &'static str {
708        match self {
709            Self::Forbidden403 => "forbidden",
710            Self::NotFound404 => "not found",
711        }
712    }
713
714    /// Build the [`AutumnError`](crate::AutumnError) used by the
715    /// `#[authorize]` macro and `#[repository]`-generated
716    /// endpoints when a policy denies an action.
717    #[must_use]
718    pub fn into_error(self) -> crate::AutumnError {
719        crate::AutumnError::from(std::io::Error::other(self.message())).with_status(self.status())
720    }
721}
722
723impl std::str::FromStr for ForbiddenResponse {
724    type Err = String;
725
726    fn from_str(s: &str) -> Result<Self, Self::Err> {
727        match s.trim() {
728            "403" | "forbidden" | "Forbidden" => Ok(Self::Forbidden403),
729            "404" | "not_found" | "NotFound" | "" => Ok(Self::NotFound404),
730            other => Err(format!(
731                "invalid forbidden_response: {other:?} (expected \"403\" or \"404\")"
732            )),
733        }
734    }
735}
736
737impl<'de> serde::Deserialize<'de> for ForbiddenResponse {
738    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
739    where
740        D: serde::Deserializer<'de>,
741    {
742        let raw = String::deserialize(deserializer)?;
743        raw.parse().map_err(serde::de::Error::custom)
744    }
745}
746
747// ── Runtime authorization helpers ───────────────────────────────
748
749/// Resolve the registered [`Policy`] for resource `R`, run the
750/// named action, and return the configured deny response on
751/// failure.
752///
753/// This is the workhorse called by the `#[authorize]` attribute
754/// macro and by `#[repository(policy = ...)]`-generated handlers.
755/// Hand-written handlers can call it directly to short-circuit a
756/// route after loading the resource — the inline pattern that
757/// replaces the hand-rolled `if record.author_id != user_id { ... }`
758/// snippets the reddit-clone migration removes.
759///
760/// # Errors
761///
762/// Returns the configured [`ForbiddenResponse`] error when the
763/// policy denies the action, or a `500` when no policy is
764/// registered for `R`.
765///
766/// # Examples
767///
768/// ```rust,ignore
769/// use autumn_web::prelude::*;
770/// use autumn_web::authorization::authorize;
771///
772/// async fn delete_post(
773///     state: AppState,
774///     session: Session,
775///     mut db: Db,
776///     post: Post,
777/// ) -> AutumnResult<()> {
778///     authorize::<Post>(&state, &session, "delete", &post).await?;
779///     // ... actually delete
780///     Ok(())
781/// }
782/// ```
783pub async fn authorize<R>(
784    state: &impl ProvideAuthorizationState,
785    session: &Session,
786    action: &str,
787    resource: &R,
788) -> crate::AutumnResult<()>
789where
790    R: Send + Sync + 'static,
791{
792    let policy = state.policy_registry().policy::<R>().ok_or_else(|| {
793        crate::AutumnError::from(std::io::Error::other(format!(
794            "no policy registered for resource type {}",
795            std::any::type_name::<R>()
796        )))
797        .with_status(StatusCode::INTERNAL_SERVER_ERROR)
798    })?;
799
800    let ctx = PolicyContext::from_request(state, session).await;
801
802    if policy.can(action, &ctx, resource).await {
803        Ok(())
804    } else {
805        Err(state.forbidden_response().into_error())
806    }
807}
808
809/// Like [`authorize`], but additionally threads the authenticating token's
810/// granted scopes into the [`PolicyContext`] so the policy can decide on
811/// `ctx.has_scope(...)`.
812///
813/// Use from hand-written handlers that authorize a service principal: extract
814/// `Option<Extension<ApiTokenScopes>>` and pass it through. A pure service
815/// token (no session user) is authorized purely on its scopes.
816///
817/// # Errors
818///
819/// Returns the configured deny response when the policy denies. Returns `500`
820/// when no policy is registered for `R`.
821pub async fn authorize_with_scopes<R>(
822    state: &crate::AppState,
823    session: &Session,
824    scopes: Option<&crate::auth::ApiTokenScopes>,
825    action: &str,
826    resource: &R,
827) -> crate::AutumnResult<()>
828where
829    R: Send + Sync + 'static,
830{
831    let policy = state.policy_registry().policy::<R>().ok_or_else(|| {
832        crate::AutumnError::from(std::io::Error::other(format!(
833            "no policy registered for resource type {}",
834            std::any::type_name::<R>()
835        )))
836        .with_status(StatusCode::INTERNAL_SERVER_ERROR)
837    })?;
838
839    let ctx = PolicyContext::from_request_parts(state, session, scopes).await;
840
841    if policy.can(action, &ctx, resource).await {
842        Ok(())
843    } else {
844        Err(state.forbidden_response().into_error())
845    }
846}
847
848/// Internal alias used by the `#[authorize]` proc-macro and the
849/// `#[repository(policy = ...)]` generated handlers. **Not part of
850/// the public API** — call [`authorize`] from user code.
851#[doc(hidden)]
852pub async fn __check_policy<R>(
853    state: &impl ProvideAuthorizationState,
854    session: &Session,
855    action: &str,
856    resource: &R,
857) -> crate::AutumnResult<()>
858where
859    R: Send + Sync + 'static,
860{
861    authorize(state, session, action, resource).await
862}
863
864/// Scope-aware variant of [`__check_policy`] emitted by the `#[authorize]`
865/// proc-macro. Threads the authenticating token's granted scopes into the
866/// [`PolicyContext`] so policies can decide on `ctx.has_scope(...)`.
867///
868/// **Not part of the public API** — call [`authorize_with_scopes`] from user
869/// code.
870#[doc(hidden)]
871pub async fn __check_policy_scoped<R>(
872    state: &crate::AppState,
873    session: &Session,
874    scopes: Option<&crate::auth::ApiTokenScopes>,
875    action: &str,
876    resource: &R,
877) -> crate::AutumnResult<()>
878where
879    R: Send + Sync + 'static,
880{
881    authorize_with_scopes(state, session, scopes, action, resource).await
882}
883
884/// Pre-insert authorization helper for the
885/// `#[repository(policy = ...)]`-generated `POST` endpoint.
886///
887/// Resolves the registered [`Policy`] for `R` and calls
888/// [`Policy::can_create`] *before* the row is persisted, closing
889/// the "deny still wrote a row" hole that catches naive
890/// after-the-fact policy checks. Use [`authorize_create`] from user
891/// code; this is the framework's backward-compatible `__`-prefixed
892/// alias for older macro output.
893#[doc(hidden)]
894pub async fn __check_policy_create<R>(
895    state: &impl ProvideAuthorizationState,
896    session: &Session,
897) -> crate::AutumnResult<()>
898where
899    R: Send + Sync + 'static,
900{
901    authorize_create::<R>(state, session).await
902}
903
904/// Payload-aware pre-insert authorization helper for
905/// `#[repository(policy = ...)]`-generated `POST` endpoints.
906///
907/// Newer macro output uses this helper when it has the raw JSON
908/// request payload available. The two-argument
909/// [`__check_policy_create`] alias is kept so applications compiled
910/// with older `autumn-macros` output remain source-compatible when
911/// only `autumn-web` is upgraded.
912#[doc(hidden)]
913pub async fn __check_policy_create_payload<R>(
914    state: &impl ProvideAuthorizationState,
915    session: &Session,
916    payload: &serde_json::Value,
917) -> crate::AutumnResult<()>
918where
919    R: Send + Sync + 'static,
920{
921    authorize_create_payload::<R>(state, session, payload).await
922}
923
924/// Scope-aware variant of [`__check_policy_create_payload`] emitted by the
925/// `#[repository(policy = ...)]` proc-macro. Threads the authenticating token's
926/// granted scopes into the [`PolicyContext`] so policies can decide on
927/// `ctx.has_scope(...)`.
928///
929/// **Not part of the public API.**
930#[doc(hidden)]
931pub async fn __check_policy_create_payload_scoped<R>(
932    state: &crate::AppState,
933    session: &Session,
934    scopes: Option<&crate::auth::ApiTokenScopes>,
935    payload: &serde_json::Value,
936) -> crate::AutumnResult<()>
937where
938    R: Send + Sync + 'static,
939{
940    let policy = state.policy_registry().policy::<R>().ok_or_else(|| {
941        crate::AutumnError::from(std::io::Error::other(format!(
942            "no policy registered for resource type {}",
943            std::any::type_name::<R>()
944        )))
945        .with_status(StatusCode::INTERNAL_SERVER_ERROR)
946    })?;
947    let ctx = PolicyContext::from_request_parts(state, session, scopes).await;
948    if policy.can_create_payload(&ctx, payload).await {
949        Ok(())
950    } else {
951        Err(state.forbidden_response().into_error())
952    }
953}
954
955/// Run a policy's `can_create` check before persisting a new record.
956///
957/// Mirrors [`authorize`] but takes no resource argument: at create
958/// time, no record instance exists yet, so policies decide based on
959/// `ctx.user_id` and `ctx.roles` alone.
960///
961/// # Errors
962///
963/// Returns the configured deny response when the policy denies.
964/// Returns `500` when no policy is registered for `R`.
965pub async fn authorize_create<R>(
966    state: &impl ProvideAuthorizationState,
967    session: &Session,
968) -> crate::AutumnResult<()>
969where
970    R: Send + Sync + 'static,
971{
972    let policy = state.policy_registry().policy::<R>().ok_or_else(|| {
973        crate::AutumnError::from(std::io::Error::other(format!(
974            "no policy registered for resource type {}",
975            std::any::type_name::<R>()
976        )))
977        .with_status(StatusCode::INTERNAL_SERVER_ERROR)
978    })?;
979
980    let ctx = PolicyContext::from_request(state, session).await;
981
982    if policy.can_create(&ctx).await {
983        Ok(())
984    } else {
985        Err(state.forbidden_response().into_error())
986    }
987}
988
989/// Run a policy's payload-aware `can_create_payload` check before
990/// persisting a new record.
991///
992/// Use this when a create policy must inspect the proposed JSON
993/// payload before insert, such as tenant/owner invariants. Existing
994/// custom handlers that only need context-based create authorization
995/// should keep calling [`authorize_create`].
996///
997/// # Errors
998///
999/// Returns the configured deny response when the policy denies.
1000/// Returns `500` when no policy is registered for `R`.
1001pub async fn authorize_create_payload<R>(
1002    state: &impl ProvideAuthorizationState,
1003    session: &Session,
1004    payload: &serde_json::Value,
1005) -> crate::AutumnResult<()>
1006where
1007    R: Send + Sync + 'static,
1008{
1009    let policy = state.policy_registry().policy::<R>().ok_or_else(|| {
1010        crate::AutumnError::from(std::io::Error::other(format!(
1011            "no policy registered for resource type {}",
1012            std::any::type_name::<R>()
1013        )))
1014        .with_status(StatusCode::INTERNAL_SERVER_ERROR)
1015    })?;
1016
1017    let ctx = PolicyContext::from_request(state, session).await;
1018
1019    if policy.can_create_payload(&ctx, payload).await {
1020        Ok(())
1021    } else {
1022        Err(state.forbidden_response().into_error())
1023    }
1024}
1025
1026#[cfg(test)]
1027mod tests {
1028    use super::*;
1029    use std::collections::HashMap;
1030
1031    #[derive(Debug, Clone, PartialEq)]
1032    struct Note {
1033        author_id: i64,
1034    }
1035
1036    #[derive(Default)]
1037    struct AdminOrOwnerPolicy;
1038
1039    impl Policy<Note> for AdminOrOwnerPolicy {
1040        fn can_show<'a>(&'a self, _ctx: &'a PolicyContext, _note: &'a Note) -> BoxFuture<'a, bool> {
1041            Box::pin(async { true })
1042        }
1043        fn can_update<'a>(&'a self, ctx: &'a PolicyContext, note: &'a Note) -> BoxFuture<'a, bool> {
1044            Box::pin(
1045                async move { ctx.has_role("admin") || ctx.user_id_i64() == Some(note.author_id) },
1046            )
1047        }
1048        fn can_delete<'a>(
1049            &'a self,
1050            ctx: &'a PolicyContext,
1051            _note: &'a Note,
1052        ) -> BoxFuture<'a, bool> {
1053            Box::pin(async move { ctx.has_role("admin") })
1054        }
1055    }
1056
1057    fn ctx(user_id: Option<&str>, role: Option<&str>) -> PolicyContext {
1058        let session = Session::new_for_test(String::new(), HashMap::new());
1059        PolicyContext {
1060            session,
1061            user_id: user_id.map(str::to_owned),
1062            roles: role.into_iter().map(str::to_owned).collect(),
1063            scopes: Vec::new(),
1064            #[cfg(feature = "db")]
1065            pool: None,
1066            policy_registry: PolicyRegistry::default(),
1067        }
1068    }
1069
1070    // ── mTLS machine identity (#1640) ───────────────────────────────
1071
1072    #[cfg(feature = "tls")]
1073    #[tokio::test]
1074    async fn client_identity_is_absent_outside_an_mtls_request() {
1075        let c = ctx(Some("1"), None);
1076        assert!(c.client_identity().is_none());
1077        assert!(!c.has_client_identity());
1078        assert!(!c.client_has_san("URI:spiffe://acme/svc/orders"));
1079    }
1080
1081    #[cfg(feature = "tls")]
1082    #[tokio::test]
1083    async fn a_policy_can_decide_on_the_verified_machine_identity() {
1084        use std::sync::Arc;
1085
1086        use crate::tls::client_auth::{ClientIdentity, with_client_identity};
1087
1088        let identity = Arc::new(ClientIdentity::new_for_test(
1089            "svc-orders",
1090            vec!["URI:spiffe://acme/svc/orders".to_owned()],
1091        ));
1092
1093        with_client_identity(Some(identity), async {
1094            let c = ctx(None, None);
1095            assert!(c.has_client_identity());
1096            assert!(c.client_has_san("URI:spiffe://acme/svc/orders"));
1097            assert!(!c.client_has_san("URI:spiffe://acme/svc/billing"));
1098            assert_eq!(
1099                c.client_identity()
1100                    .expect("identity in scope")
1101                    .common_name(),
1102                Some("svc-orders")
1103            );
1104        })
1105        .await;
1106
1107        // The scope ends with the request: nothing leaks to the next one.
1108        assert!(!ctx(None, None).has_client_identity());
1109    }
1110
1111    #[tokio::test]
1112    async fn default_impls_deny() {
1113        struct EmptyPolicy;
1114        impl Policy<Note> for EmptyPolicy {}
1115        let policy = EmptyPolicy;
1116        let c = ctx(Some("1"), None);
1117        let n = Note { author_id: 1 };
1118        assert!(!policy.can_show(&c, &n).await);
1119        assert!(!policy.can_create(&c).await);
1120        assert!(!policy.can_update(&c, &n).await);
1121        assert!(!policy.can_delete(&c, &n).await);
1122        assert!(!policy.can("publish", &c, &n).await);
1123    }
1124
1125    #[tokio::test]
1126    async fn owner_can_update() {
1127        let policy = AdminOrOwnerPolicy;
1128        let c = ctx(Some("42"), None);
1129        let n = Note { author_id: 42 };
1130        assert!(policy.can_update(&c, &n).await);
1131        assert!(!policy.can_delete(&c, &n).await);
1132    }
1133
1134    #[tokio::test]
1135    async fn non_owner_cannot_update() {
1136        let policy = AdminOrOwnerPolicy;
1137        let c = ctx(Some("99"), None);
1138        let n = Note { author_id: 42 };
1139        assert!(!policy.can_update(&c, &n).await);
1140    }
1141
1142    #[tokio::test]
1143    async fn admin_can_delete() {
1144        let policy = AdminOrOwnerPolicy;
1145        let c = ctx(Some("99"), Some("admin"));
1146        let n = Note { author_id: 42 };
1147        assert!(policy.can_delete(&c, &n).await);
1148    }
1149
1150    #[tokio::test]
1151    async fn can_dispatches_named_actions() {
1152        let policy = AdminOrOwnerPolicy;
1153        let c = ctx(Some("42"), None);
1154        let n = Note { author_id: 42 };
1155        assert!(policy.can("show", &c, &n).await);
1156        assert!(policy.can("update", &c, &n).await);
1157        assert!(policy.can("edit", &c, &n).await);
1158        assert!(!policy.can("publish", &c, &n).await);
1159    }
1160
1161    #[test]
1162    fn policy_registry_stores_and_resolves() {
1163        let registry = PolicyRegistry::default();
1164        registry.register_policy::<Note, _>(AdminOrOwnerPolicy);
1165        assert!(registry.has_policy::<Note>());
1166        assert!(registry.policy::<Note>().is_some());
1167        assert!(registry.scope::<Note>().is_none());
1168    }
1169
1170    #[test]
1171    #[should_panic(expected = "already registered")]
1172    fn double_policy_registration_panics() {
1173        let registry = PolicyRegistry::default();
1174        registry.register_policy::<Note, _>(AdminOrOwnerPolicy);
1175        registry.register_policy::<Note, _>(AdminOrOwnerPolicy);
1176    }
1177
1178    #[test]
1179    fn forbidden_response_default_is_404() {
1180        let resp = ForbiddenResponse::default();
1181        assert_eq!(resp, ForbiddenResponse::NotFound404);
1182        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1183    }
1184
1185    #[test]
1186    fn forbidden_response_parses_strings() {
1187        assert_eq!(
1188            "403".parse::<ForbiddenResponse>().unwrap(),
1189            ForbiddenResponse::Forbidden403
1190        );
1191        assert_eq!(
1192            "404".parse::<ForbiddenResponse>().unwrap(),
1193            ForbiddenResponse::NotFound404
1194        );
1195        assert_eq!(
1196            "forbidden".parse::<ForbiddenResponse>().unwrap(),
1197            ForbiddenResponse::Forbidden403
1198        );
1199        assert!("418".parse::<ForbiddenResponse>().is_err());
1200    }
1201
1202    #[test]
1203    fn policy_context_helpers() {
1204        let c = ctx(Some("42"), Some("editor"));
1205        assert!(c.is_authenticated());
1206        assert_eq!(c.user_id_i64(), Some(42));
1207        assert!(c.has_role("editor"));
1208        assert!(!c.has_role("admin"));
1209        assert!(c.has_any_role(["admin", "editor"]));
1210        assert!(!c.has_any_role(["viewer", "guest"]));
1211    }
1212
1213    #[test]
1214    fn anonymous_context_is_not_authenticated() {
1215        let c = ctx(None, None);
1216        assert!(!c.is_authenticated());
1217        assert!(c.user_id_i64().is_none());
1218        assert!(!c.has_role("admin"));
1219        assert!(!c.has_any_role(["admin", "editor"]));
1220    }
1221
1222    #[test]
1223    fn user_id_i64_handles_non_numeric_session_value() {
1224        let c = ctx(Some("not-a-number"), None);
1225        assert!(c.user_id_i64().is_none());
1226    }
1227
1228    #[test]
1229    fn scope_accessors_mirror_roles() {
1230        let c = ctx(Some("42"), Some("editor"))
1231            .with_scopes(vec!["posts:read".to_owned(), "posts:write".to_owned()]);
1232        assert!(c.has_scope("posts:read"));
1233        assert!(!c.has_scope("posts:delete"));
1234        assert!(c.has_any_scope(["posts:delete", "posts:write"]));
1235        assert!(!c.has_any_scope(["a", "b"]));
1236        assert!(c.has_all_scopes(["posts:read", "posts:write"]));
1237        assert!(!c.has_all_scopes(["posts:read", "posts:delete"]));
1238        // Empty requirement is vacuously satisfied.
1239        assert!(c.has_all_scopes(std::iter::empty::<&str>()));
1240    }
1241
1242    #[test]
1243    fn non_user_principal_authorizes_purely_on_scopes() {
1244        // No user id, no roles — a pure service token — yet scopes authorize.
1245        let c = ctx(None, None).with_scopes(vec!["posts:write".to_owned()]);
1246        assert!(!c.is_authenticated());
1247        assert!(c.user_id_i64().is_none());
1248        assert!(!c.has_role("admin"));
1249        assert!(c.has_scope("posts:write"));
1250    }
1251
1252    #[tokio::test]
1253    async fn from_session_leaves_scopes_empty() {
1254        let session = session_with(Some("42"), Some("editor"));
1255        let c = PolicyContext::from_session(&session, "user_id").await;
1256        assert!(c.scopes.is_empty());
1257        assert!(!c.has_scope("posts:read"));
1258    }
1259
1260    #[tokio::test]
1261    async fn from_session_seeds_current_actor_for_authenticated_user() {
1262        // Mirrors the request path: the log-context middleware establishes an
1263        // empty current-actor scope, then a session-authenticated policy check
1264        // resolves the user via `from_session`. Even without RequireAuth /
1265        // #[secured] / an API-token bearer having fired, the resolved user must
1266        // become the ambient actor so versioned writes attribute to them rather
1267        // than falling back to SYSTEM_ACTOR (#1383).
1268        crate::current::scope_request(async {
1269            assert_eq!(crate::current::Current::actor(), None);
1270            let session = session_with(Some("42"), Some("editor"));
1271            let c = PolicyContext::from_session(&session, "user_id").await;
1272            assert_eq!(c.user_id.as_deref(), Some("42"));
1273            assert_eq!(crate::current::Current::actor(), Some("42".to_owned()));
1274        })
1275        .await;
1276    }
1277
1278    #[tokio::test]
1279    async fn from_session_leaves_current_actor_none_for_anonymous() {
1280        // An anonymous session must never publish an actor, so unauthenticated
1281        // requests behave exactly as before this feature existed.
1282        crate::current::scope_request(async {
1283            let session = session_with(None, None);
1284            let c = PolicyContext::from_session(&session, "user_id").await;
1285            assert!(c.user_id.is_none());
1286            assert_eq!(crate::current::Current::actor(), None);
1287        })
1288        .await;
1289    }
1290
1291    #[tokio::test]
1292    async fn from_session_does_not_override_existing_actor() {
1293        // The session seed is only a fallback. When a stronger principal is
1294        // already established — an API-token bearer, RequireAuth/#[secured], or an
1295        // explicit `with_actor(...)` scope — `from_session` must NOT clobber it,
1296        // even if the request also carries a session cookie for a different user.
1297        crate::current::scope_request(async {
1298            crate::current::Current::set_actor("token-principal".to_owned());
1299            let session = session_with(Some("42"), Some("editor"));
1300            let c = PolicyContext::from_session(&session, "user_id").await;
1301            // The session user is still resolved into the context...
1302            assert_eq!(c.user_id.as_deref(), Some("42"));
1303            // ...but the already-established principal wins as the ambient actor.
1304            assert_eq!(
1305                crate::current::Current::actor(),
1306                Some("token-principal".to_owned())
1307            );
1308        })
1309        .await;
1310    }
1311
1312    #[test]
1313    fn forbidden_response_status_and_message_round_trip() {
1314        assert_eq!(
1315            ForbiddenResponse::Forbidden403.status(),
1316            StatusCode::FORBIDDEN
1317        );
1318        assert_eq!(
1319            ForbiddenResponse::NotFound404.status(),
1320            StatusCode::NOT_FOUND
1321        );
1322        assert_eq!(ForbiddenResponse::Forbidden403.message(), "forbidden");
1323        assert_eq!(ForbiddenResponse::NotFound404.message(), "not found");
1324    }
1325
1326    #[test]
1327    fn forbidden_response_into_error_carries_status_and_message() {
1328        let err = ForbiddenResponse::NotFound404.into_error();
1329        assert_eq!(err.status(), StatusCode::NOT_FOUND);
1330        assert_eq!(err.to_string(), "not found");
1331
1332        let err = ForbiddenResponse::Forbidden403.into_error();
1333        assert_eq!(err.status(), StatusCode::FORBIDDEN);
1334        assert_eq!(err.to_string(), "forbidden");
1335    }
1336
1337    #[test]
1338    fn forbidden_response_parses_empty_string_as_default_404() {
1339        assert_eq!(
1340            "".parse::<ForbiddenResponse>().unwrap(),
1341            ForbiddenResponse::NotFound404
1342        );
1343        assert_eq!(
1344            "not_found".parse::<ForbiddenResponse>().unwrap(),
1345            ForbiddenResponse::NotFound404
1346        );
1347        assert_eq!(
1348            "NotFound".parse::<ForbiddenResponse>().unwrap(),
1349            ForbiddenResponse::NotFound404
1350        );
1351        assert_eq!(
1352            "Forbidden".parse::<ForbiddenResponse>().unwrap(),
1353            ForbiddenResponse::Forbidden403
1354        );
1355    }
1356
1357    #[test]
1358    fn forbidden_response_parse_error_carries_input_value() {
1359        let err = "418".parse::<ForbiddenResponse>().unwrap_err();
1360        assert!(err.contains("418"));
1361        assert!(err.contains("403"));
1362        assert!(err.contains("404"));
1363    }
1364
1365    #[test]
1366    fn forbidden_response_deserializes_from_toml() {
1367        #[derive(Debug, serde::Deserialize)]
1368        struct Holder {
1369            value: ForbiddenResponse,
1370        }
1371        let h: Holder = toml::from_str(r#"value = "403""#).unwrap();
1372        assert_eq!(h.value, ForbiddenResponse::Forbidden403);
1373        let h: Holder = toml::from_str(r#"value = "404""#).unwrap();
1374        assert_eq!(h.value, ForbiddenResponse::NotFound404);
1375        let err = toml::from_str::<Holder>(r#"value = "418""#).unwrap_err();
1376        assert!(err.to_string().contains("418"));
1377    }
1378
1379    #[test]
1380    fn registry_scope_double_registration_panics_with_clear_message() {
1381        let registry = PolicyRegistry::default();
1382        registry.register_scope::<Note, _>(EmptyScope);
1383        let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1384            registry.register_scope::<Note, _>(EmptyScope);
1385        }))
1386        .unwrap_err();
1387        let msg = panicked
1388            .downcast_ref::<String>()
1389            .map(String::as_str)
1390            .or_else(|| panicked.downcast_ref::<&'static str>().copied())
1391            .unwrap_or("");
1392        assert!(
1393            msg.contains("already registered"),
1394            "expected double-registration panic, got {msg:?}"
1395        );
1396    }
1397
1398    struct OtherResource;
1399    struct OtherPolicy;
1400    impl Policy<OtherResource> for OtherPolicy {}
1401    struct ThirdResource;
1402    struct EmptyScope;
1403    impl Scope<Note> for EmptyScope {}
1404
1405    #[test]
1406    fn registry_resolves_distinct_resource_types_independently() {
1407        let registry = PolicyRegistry::default();
1408        registry.register_policy::<Note, _>(AdminOrOwnerPolicy);
1409        registry.register_policy::<OtherResource, _>(OtherPolicy);
1410
1411        assert!(registry.has_policy::<Note>());
1412        assert!(registry.has_policy::<OtherResource>());
1413        // Resources without registrations don't false-positive.
1414        assert!(!registry.has_policy::<ThirdResource>());
1415        assert!(registry.scope::<Note>().is_none());
1416    }
1417
1418    #[test]
1419    fn registry_debug_shows_counts() {
1420        let registry = PolicyRegistry::default();
1421        registry.register_policy::<Note, _>(AdminOrOwnerPolicy);
1422        registry.register_scope::<Note, _>(EmptyScope);
1423        let dbg = format!("{registry:?}");
1424        assert!(dbg.contains("PolicyRegistry"));
1425        assert!(dbg.contains("policies"));
1426        assert!(dbg.contains("scopes"));
1427    }
1428
1429    #[derive(Clone)]
1430    struct MockState {
1431        policy_registry: PolicyRegistry,
1432        forbidden_response: ForbiddenResponse,
1433        auth_session_key: String,
1434    }
1435
1436    impl ProvideAuthorizationState for MockState {
1437        fn policy_registry(&self) -> &PolicyRegistry {
1438            &self.policy_registry
1439        }
1440
1441        fn auth_session_key(&self) -> &str {
1442            &self.auth_session_key
1443        }
1444
1445        fn forbidden_response(&self) -> &ForbiddenResponse {
1446            &self.forbidden_response
1447        }
1448
1449        #[cfg(feature = "db")]
1450        fn pool(
1451            &self,
1452        ) -> Option<&diesel_async::pooled_connection::deadpool::Pool<crate::db::RuntimeConnection>>
1453        {
1454            None
1455        }
1456    }
1457
1458    impl MockState {
1459        fn with_forbidden_response(mut self, forbidden: ForbiddenResponse) -> Self {
1460            self.forbidden_response = forbidden;
1461            self
1462        }
1463    }
1464
1465    fn detached_state() -> MockState {
1466        MockState {
1467            policy_registry: PolicyRegistry::default(),
1468            forbidden_response: ForbiddenResponse::default(),
1469            auth_session_key: "user_id".to_owned(),
1470        }
1471    }
1472
1473    fn detached_state_with(registry: PolicyRegistry, forbidden: ForbiddenResponse) -> MockState {
1474        let mut state = detached_state().with_forbidden_response(forbidden);
1475        state.policy_registry = registry;
1476        state
1477    }
1478
1479    fn session_with(user_id: Option<&str>, role: Option<&str>) -> Session {
1480        let mut data = HashMap::new();
1481        if let Some(u) = user_id {
1482            data.insert("user_id".to_owned(), u.to_owned());
1483        }
1484        if let Some(r) = role {
1485            data.insert("role".to_owned(), r.to_owned());
1486        }
1487        Session::new_for_test(String::new(), data)
1488    }
1489
1490    #[tokio::test]
1491    async fn authorize_returns_500_when_no_policy_registered() {
1492        let state = detached_state_with(PolicyRegistry::default(), ForbiddenResponse::default());
1493        let session = session_with(Some("42"), None);
1494        let n = Note { author_id: 42 };
1495        let err = authorize::<Note>(&state, &session, "update", &n)
1496            .await
1497            .unwrap_err();
1498        assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
1499    }
1500
1501    #[tokio::test]
1502    async fn authorize_returns_configured_deny_when_policy_denies() {
1503        let registry = PolicyRegistry::default();
1504        registry.register_policy::<Note, _>(AdminOrOwnerPolicy);
1505        let state = detached_state_with(registry.clone(), ForbiddenResponse::Forbidden403);
1506        // `state` already has the registry we passed in via `detached_state_with`.
1507
1508        let session = session_with(Some("99"), None); // not the owner, no role
1509        let n = Note { author_id: 42 };
1510        let err = authorize::<Note>(&state, &session, "update", &n)
1511            .await
1512            .unwrap_err();
1513        assert_eq!(err.status(), StatusCode::FORBIDDEN);
1514    }
1515
1516    #[tokio::test]
1517    async fn authorize_returns_ok_when_policy_allows() {
1518        let state = detached_state();
1519        state
1520            .policy_registry()
1521            .register_policy::<Note, _>(AdminOrOwnerPolicy);
1522        let session = session_with(Some("42"), None); // owner
1523        let n = Note { author_id: 42 };
1524        authorize::<Note>(&state, &session, "update", &n)
1525            .await
1526            .expect("owner is allowed to update");
1527    }
1528
1529    #[tokio::test]
1530    async fn authorize_create_returns_500_when_no_policy_registered() {
1531        let state = detached_state();
1532        let session = session_with(Some("42"), None);
1533        let err = authorize_create::<Note>(&state, &session)
1534            .await
1535            .unwrap_err();
1536        assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
1537    }
1538
1539    #[tokio::test]
1540    async fn authorize_create_dispatches_can_create() {
1541        struct AuthOnlyCreatePolicy;
1542        impl Policy<Note> for AuthOnlyCreatePolicy {
1543            fn can_create<'a>(&'a self, ctx: &'a PolicyContext) -> BoxFuture<'a, bool> {
1544                Box::pin(async move { ctx.is_authenticated() })
1545            }
1546        }
1547
1548        let state = detached_state().with_forbidden_response(ForbiddenResponse::Forbidden403);
1549        state
1550            .policy_registry()
1551            .register_policy::<Note, _>(AuthOnlyCreatePolicy);
1552
1553        let anon = session_with(None, None);
1554        let err = authorize_create::<Note>(&state, &anon).await.unwrap_err();
1555        assert_eq!(err.status(), StatusCode::FORBIDDEN);
1556
1557        let user = session_with(Some("1"), None);
1558        authorize_create::<Note>(&state, &user)
1559            .await
1560            .expect("authenticated user passes can_create");
1561    }
1562
1563    #[tokio::test]
1564    async fn authorize_create_payload_dispatches_can_create_payload() {
1565        struct OwnerPayloadPolicy;
1566        impl Policy<Note> for OwnerPayloadPolicy {
1567            fn can_create_payload<'a>(
1568                &'a self,
1569                ctx: &'a PolicyContext,
1570                payload: &'a serde_json::Value,
1571            ) -> BoxFuture<'a, bool> {
1572                Box::pin(async move {
1573                    payload.get("author_id").and_then(serde_json::Value::as_i64)
1574                        == ctx.user_id_i64()
1575                })
1576            }
1577        }
1578
1579        let state = detached_state().with_forbidden_response(ForbiddenResponse::Forbidden403);
1580        state
1581            .policy_registry()
1582            .register_policy::<Note, _>(OwnerPayloadPolicy);
1583
1584        let user = session_with(Some("1"), None);
1585        let own_payload = serde_json::json!({"author_id": 1});
1586        authorize_create_payload::<Note>(&state, &user, &own_payload)
1587            .await
1588            .expect("owner payload passes can_create_payload");
1589
1590        let other_payload = serde_json::json!({"author_id": 2});
1591        let err = authorize_create_payload::<Note>(&state, &user, &other_payload)
1592            .await
1593            .unwrap_err();
1594        assert_eq!(err.status(), StatusCode::FORBIDDEN);
1595    }
1596
1597    #[tokio::test]
1598    async fn check_policy_create_alias_preserves_two_arg_shape() {
1599        struct AuthOnlyCreatePolicy;
1600        impl Policy<Note> for AuthOnlyCreatePolicy {
1601            fn can_create<'a>(&'a self, ctx: &'a PolicyContext) -> BoxFuture<'a, bool> {
1602                Box::pin(async move { ctx.is_authenticated() })
1603            }
1604        }
1605
1606        let state = detached_state().with_forbidden_response(ForbiddenResponse::Forbidden403);
1607        state
1608            .policy_registry()
1609            .register_policy::<Note, _>(AuthOnlyCreatePolicy);
1610
1611        let anon = session_with(None, None);
1612        let err = __check_policy_create::<Note>(&state, &anon)
1613            .await
1614            .unwrap_err();
1615        assert_eq!(err.status(), StatusCode::FORBIDDEN);
1616
1617        let user = session_with(Some("1"), None);
1618        __check_policy_create::<Note>(&state, &user)
1619            .await
1620            .expect("old generated create policy alias remains compatible");
1621    }
1622
1623    #[tokio::test]
1624    async fn check_policy_create_payload_alias_dispatches_payload() {
1625        struct OwnerPayloadPolicy;
1626        impl Policy<Note> for OwnerPayloadPolicy {
1627            fn can_create_payload<'a>(
1628                &'a self,
1629                ctx: &'a PolicyContext,
1630                payload: &'a serde_json::Value,
1631            ) -> BoxFuture<'a, bool> {
1632                Box::pin(async move {
1633                    payload.get("author_id").and_then(serde_json::Value::as_i64)
1634                        == ctx.user_id_i64()
1635                })
1636            }
1637        }
1638
1639        let state = detached_state().with_forbidden_response(ForbiddenResponse::Forbidden403);
1640        state
1641            .policy_registry()
1642            .register_policy::<Note, _>(OwnerPayloadPolicy);
1643
1644        let user = session_with(Some("1"), None);
1645        let payload = serde_json::json!({"author_id": 1});
1646        __check_policy_create_payload::<Note>(&state, &user, &payload)
1647            .await
1648            .expect("new generated create policy alias passes payload");
1649    }
1650
1651    #[tokio::test]
1652    async fn check_policy_alias_round_trips() {
1653        let state = detached_state();
1654        state
1655            .policy_registry()
1656            .register_policy::<Note, _>(AdminOrOwnerPolicy);
1657        let session = session_with(Some("42"), None);
1658        let n = Note { author_id: 42 };
1659        // The macro-internal alias goes through `authorize` — exercise
1660        // the full round-trip.
1661        __check_policy::<Note>(&state, &session, "update", &n)
1662            .await
1663            .unwrap();
1664    }
1665
1666    #[tokio::test]
1667    async fn from_request_clones_pool_and_registry_from_state() {
1668        let state = detached_state();
1669        state
1670            .policy_registry()
1671            .register_policy::<Note, _>(AdminOrOwnerPolicy);
1672        let session = session_with(Some("7"), Some("admin"));
1673        let ctx = PolicyContext::from_request(&state, &session).await;
1674        assert_eq!(ctx.user_id.as_deref(), Some("7"));
1675        assert!(ctx.has_role("admin"));
1676        // The registry was cloned from state — `Note` resolves.
1677        assert!(ctx.policy_registry.has_policy::<Note>());
1678    }
1679
1680    #[tokio::test]
1681    async fn scoped_blanket_trait_constructible_without_registered_scope() {
1682        let state = detached_state();
1683        let session = session_with(Some("1"), None);
1684        let ctx = PolicyContext::from_request(&state, &session).await;
1685        // No scope registered for `Note`.
1686        let _query = Note::scope(&ctx);
1687        // The `db`-feature `load(&mut conn)` form is exercised by the
1688        // testcontainer suite; here we just confirm the registry-miss
1689        // surfaces only at `.load()` time, not at `scope(&ctx)` time.
1690        assert!(ctx.policy_registry.scope::<Note>().is_none());
1691    }
1692
1693    // ── authorize_with_scopes ──────────────────────────────────────────────────
1694
1695    #[tokio::test]
1696    async fn authorize_with_scopes_returns_500_when_no_policy_registered() {
1697        let state = crate::AppState::detached();
1698        let session = session_with(None, None);
1699        let err =
1700            authorize_with_scopes::<Note>(&state, &session, None, "update", &Note { author_id: 1 })
1701                .await
1702                .unwrap_err();
1703        assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
1704    }
1705
1706    #[tokio::test]
1707    async fn authorize_with_scopes_returns_deny_when_policy_denies() {
1708        let state =
1709            crate::AppState::detached().with_forbidden_response(ForbiddenResponse::Forbidden403);
1710        state
1711            .policy_registry()
1712            .register_policy::<Note, _>(AdminOrOwnerPolicy);
1713        let session = session_with(Some("99"), None); // not owner, no admin role
1714        let n = Note { author_id: 42 };
1715        let err = authorize_with_scopes::<Note>(&state, &session, None, "update", &n)
1716            .await
1717            .unwrap_err();
1718        assert_eq!(err.status(), StatusCode::FORBIDDEN);
1719    }
1720
1721    #[tokio::test]
1722    async fn authorize_with_scopes_threads_scopes_into_policy_context() {
1723        struct ScopeGatedPolicy;
1724        impl Policy<Note> for ScopeGatedPolicy {
1725            fn can_update<'a>(
1726                &'a self,
1727                ctx: &'a PolicyContext,
1728                _doc: &'a Note,
1729            ) -> BoxFuture<'a, bool> {
1730                Box::pin(async move { ctx.has_scope("posts:write") })
1731            }
1732        }
1733
1734        let state = crate::AppState::detached();
1735        state
1736            .policy_registry()
1737            .register_policy::<Note, _>(ScopeGatedPolicy);
1738        let session = session_with(None, None);
1739        let n = Note { author_id: 1 };
1740        let scopes = crate::auth::ApiTokenScopes(vec!["posts:write".to_owned()]);
1741
1742        // Scopes present → allow.
1743        authorize_with_scopes::<Note>(&state, &session, Some(&scopes), "update", &n)
1744            .await
1745            .expect("scope allows update");
1746
1747        // No scopes → deny (default 404).
1748        authorize_with_scopes::<Note>(&state, &session, None, "update", &n)
1749            .await
1750            .unwrap_err();
1751    }
1752
1753    #[tokio::test]
1754    async fn from_request_parts_propagates_scopes() {
1755        let state = crate::AppState::detached();
1756        let session = session_with(Some("7"), Some("admin"));
1757        let scopes = crate::auth::ApiTokenScopes(vec!["posts:write".to_owned()]);
1758        let ctx = PolicyContext::from_request_parts(&state, &session, Some(&scopes)).await;
1759        assert_eq!(ctx.user_id.as_deref(), Some("7"));
1760        assert!(ctx.has_role("admin"));
1761        assert!(ctx.has_scope("posts:write"));
1762        assert!(!ctx.has_scope("posts:read"));
1763    }
1764
1765    #[tokio::test]
1766    async fn from_request_parts_with_no_scopes_leaves_scopes_empty() {
1767        let state = crate::AppState::detached();
1768        let session = session_with(Some("7"), None);
1769        let ctx = PolicyContext::from_request_parts(&state, &session, None).await;
1770        assert!(ctx.scopes.is_empty());
1771    }
1772
1773    #[tokio::test]
1774    async fn check_policy_scoped_round_trips_through_authorize_with_scopes() {
1775        let state = crate::AppState::detached();
1776        state
1777            .policy_registry()
1778            .register_policy::<Note, _>(AdminOrOwnerPolicy);
1779        let session = session_with(Some("42"), None); // owner
1780        let n = Note { author_id: 42 };
1781        __check_policy_scoped::<Note>(&state, &session, None, "update", &n)
1782            .await
1783            .unwrap();
1784    }
1785
1786    #[tokio::test]
1787    async fn check_policy_create_payload_scoped_threads_scopes() {
1788        struct ScopedCreatePolicy;
1789        impl Policy<Note> for ScopedCreatePolicy {
1790            fn can_create_payload<'a>(
1791                &'a self,
1792                ctx: &'a PolicyContext,
1793                _payload: &'a serde_json::Value,
1794            ) -> BoxFuture<'a, bool> {
1795                Box::pin(async move { ctx.has_scope("posts:write") })
1796            }
1797        }
1798
1799        let state =
1800            crate::AppState::detached().with_forbidden_response(ForbiddenResponse::Forbidden403);
1801        state
1802            .policy_registry()
1803            .register_policy::<Note, _>(ScopedCreatePolicy);
1804        let session = session_with(None, None);
1805        let payload = serde_json::json!({"title": "Hello"});
1806        let scopes = crate::auth::ApiTokenScopes(vec!["posts:write".to_owned()]);
1807
1808        __check_policy_create_payload_scoped::<Note>(&state, &session, Some(&scopes), &payload)
1809            .await
1810            .expect("scope grants create");
1811
1812        let err = __check_policy_create_payload_scoped::<Note>(&state, &session, None, &payload)
1813            .await
1814            .unwrap_err();
1815        assert_eq!(err.status(), StatusCode::FORBIDDEN);
1816    }
1817
1818    #[tokio::test]
1819    async fn check_policy_create_payload_scoped_returns_500_when_no_policy() {
1820        let state = crate::AppState::detached();
1821        let session = session_with(None, None);
1822        let err = __check_policy_create_payload_scoped::<Note>(
1823            &state,
1824            &session,
1825            None,
1826            &serde_json::json!({}),
1827        )
1828        .await
1829        .unwrap_err();
1830        assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
1831    }
1832}