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