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/// Boxed future returned by [`Policy`] and [`Scope`] methods so the
64/// traits remain object-safe (`dyn Policy<R>` works regardless of
65/// rust edition).
66pub type BoxFuture<'a, T> = Pin<Box<dyn std::future::Future<Output = T> + Send + 'a>>;
67
68// ── PolicyContext ────────────────────────────────────────────────
69
70/// Per-request context handed to every policy and scope check.
71///
72/// Carries the resolved [`Session`], the authenticated user id (when
73/// present), the active role set, the [`PolicyRegistry`] (so
74/// `Post::scope(&ctx)` can resolve a registered scope without
75/// re-threading state), and a clone of the database pool so
76/// policies can consult related rows. `Clone + Send + Sync` — flows
77/// freely across `.await` points.
78#[derive(Clone)]
79pub struct PolicyContext {
80    /// The full per-request [`Session`]. Read raw values via
81    /// [`Session::get`] when a policy needs data beyond the
82    /// canonical user-id and role keys.
83    pub session: Session,
84
85    /// The authenticated user id, if any. Mirrors the configured
86    /// session auth key (default: `"user_id"`).
87    pub user_id: Option<String>,
88
89    /// Active role set for the current user. Empty when the user
90    /// has no role or is anonymous.
91    pub roles: Vec<String>,
92
93    /// Database connection pool, cloned from `AppState`. Policies
94    /// that need to consult related rows (e.g. group membership)
95    /// can borrow a connection here.
96    #[cfg(feature = "db")]
97    pub pool:
98        Option<diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>>,
99
100    /// Registered [`Policy`] / [`Scope`] map, cloned from
101    /// `AppState`. Lets the [`Scoped`] blanket trait resolve a
102    /// registered scope from `&ctx` alone — the
103    /// `Post::scope(&ctx).load(&mut db).await?` ergonomic the
104    /// authorization guide documents.
105    pub policy_registry: PolicyRegistry,
106}
107
108impl PolicyContext {
109    /// Build a [`PolicyContext`] from a session alone.
110    ///
111    /// The resulting context has an empty [`PolicyRegistry`] and no
112    /// pool — sufficient for hand-rolled policy unit tests that
113    /// don't go through `AppState`. Production code paths construct
114    /// a [`PolicyContext`] via [`from_request`](Self::from_request)
115    /// instead.
116    pub async fn from_session(session: &Session, auth_session_key: &str) -> Self {
117        let user_id = session.get(auth_session_key).await;
118        let role = session.get("role").await;
119        let roles = role.into_iter().collect();
120        Self {
121            session: session.clone(),
122            user_id,
123            roles,
124            #[cfg(feature = "db")]
125            pool: None,
126            policy_registry: PolicyRegistry::default(),
127        }
128    }
129
130    /// Build a fully-populated [`PolicyContext`] from `AppState` +
131    /// `Session`. Used by the `#[authorize]` macro and
132    /// `#[repository(policy = ...)]`-generated handlers.
133    pub async fn from_request(state: &crate::AppState, session: &Session) -> Self {
134        let mut ctx = Self::from_session(session, state.auth_session_key()).await;
135        ctx.policy_registry = state.policy_registry().clone();
136        #[cfg(feature = "db")]
137        {
138            if let Some(pool) = state.pool() {
139                ctx.pool = Some(pool.clone());
140            }
141        }
142        ctx
143    }
144
145    /// Returns `true` when the request has a resolved authenticated user.
146    #[must_use]
147    pub const fn is_authenticated(&self) -> bool {
148        self.user_id.is_some()
149    }
150
151    /// Returns the user id parsed as an `i64`, when the session
152    /// stored it as a numeric string. Convenient for the common
153    /// case of `BIGSERIAL` primary keys.
154    #[must_use]
155    pub fn user_id_i64(&self) -> Option<i64> {
156        self.user_id.as_deref().and_then(|s| s.parse().ok())
157    }
158
159    /// Returns `true` when the active role set contains `role`.
160    #[must_use]
161    pub fn has_role(&self, role: &str) -> bool {
162        self.roles.iter().any(|r| r == role)
163    }
164
165    /// Returns `true` when the active role set contains any of the
166    /// supplied roles. Mirrors `#[secured("a", "b")]` semantics.
167    #[must_use]
168    pub fn has_any_role<I, S>(&self, candidates: I) -> bool
169    where
170        I: IntoIterator<Item = S>,
171        S: AsRef<str>,
172    {
173        candidates.into_iter().any(|c| self.has_role(c.as_ref()))
174    }
175
176    /// Attach a database pool to the context. Used by the framework
177    /// when constructing the context inside extractors; tests can
178    /// also call this to inject a pool by hand.
179    #[cfg(feature = "db")]
180    #[must_use]
181    pub fn with_pool(
182        mut self,
183        pool: diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
184    ) -> Self {
185        self.pool = Some(pool);
186        self
187    }
188}
189
190// ── Policy trait ────────────────────────────────────────────────
191
192/// Record-level authorization policy for a resource.
193///
194/// All four built-in actions (`can_show`, `can_create`,
195/// `can_update`, `can_delete`) default to **denied**, which makes
196/// opting into a policy safe-by-default: a freshly-introduced
197/// policy with no overrides forbids every action until the
198/// developer explicitly allows one.
199///
200/// # Object safety
201///
202/// Every method returns a [`BoxFuture`] so `dyn Policy<R>` is
203/// usable behind an `Arc`. Tests can swap implementations via
204/// `AppBuilder::policy::<R, P>(P::default())`.
205///
206/// # Custom verbs
207///
208/// Use [`Policy::can`] for verbs that are not one of the four
209/// built-ins (e.g. `"publish"`, `"feature"`, `"archive"`). The
210/// default impl dispatches the four built-ins and returns `false`
211/// for unknown verbs.
212pub trait Policy<R: Send + Sync + 'static>: Send + Sync + 'static {
213    /// Decide whether the current user may *show* the resource.
214    fn can_show<'a>(&'a self, _ctx: &'a PolicyContext, _resource: &'a R) -> BoxFuture<'a, bool> {
215        Box::pin(async { false })
216    }
217
218    /// Decide whether the current user may *create* a resource of
219    /// this type.
220    ///
221    /// `can_create` receives request context only. Policies that need
222    /// the proposed JSON payload before insert can override
223    /// [`Policy::can_create_payload`].
224    fn can_create<'a>(&'a self, _ctx: &'a PolicyContext) -> BoxFuture<'a, bool> {
225        Box::pin(async { false })
226    }
227
228    /// Decide whether the current user may create the proposed
229    /// payload. Default behavior preserves compatibility by
230    /// delegating to [`Policy::can_create`].
231    fn can_create_payload<'a>(
232        &'a self,
233        ctx: &'a PolicyContext,
234        _payload: &'a serde_json::Value,
235    ) -> BoxFuture<'a, bool> {
236        self.can_create(ctx)
237    }
238
239    /// Decide whether the current user may *update* the resource.
240    fn can_update<'a>(&'a self, _ctx: &'a PolicyContext, _resource: &'a R) -> BoxFuture<'a, bool> {
241        Box::pin(async { false })
242    }
243
244    /// Decide whether the current user may *delete* the resource.
245    fn can_delete<'a>(&'a self, _ctx: &'a PolicyContext, _resource: &'a R) -> BoxFuture<'a, bool> {
246        Box::pin(async { false })
247    }
248
249    /// Decide a custom verb. Defaults to dispatching the four
250    /// built-ins by name. The `resource` argument is ignored when
251    /// dispatching to `can_create`, since `can_create` operates
252    /// pre-insert and has no resource instance.
253    fn can<'a>(
254        &'a self,
255        action: &'a str,
256        ctx: &'a PolicyContext,
257        resource: &'a R,
258    ) -> BoxFuture<'a, bool> {
259        Box::pin(async move {
260            match action {
261                "show" | "read" => self.can_show(ctx, resource).await,
262                "create" => self.can_create(ctx).await,
263                "update" | "edit" => self.can_update(ctx, resource).await,
264                "delete" | "destroy" => self.can_delete(ctx, resource).await,
265                _ => false,
266            }
267        })
268    }
269}
270
271// ── Scope trait ─────────────────────────────────────────────────
272
273/// List-time companion to [`Policy`] for filtering record sets the
274/// current user is allowed to read.
275///
276/// Default implementations return an **empty** list — fail closed.
277/// `#[repository(scope = ...)]`-generated `GET /<api>` index
278/// endpoints invoke the registered scope automatically; hand-
279/// written list handlers can use the [`Scoped`] blanket trait to
280/// invoke `Post::scope(&ctx).load(&mut db).await?`.
281///
282/// The `db` feature gates the connection parameter — without it,
283/// the trait still exists but `list` takes no connection (use
284/// `ctx.pool` to acquire one if needed).
285#[cfg(feature = "db")]
286pub trait Scope<R: Send + Sync + 'static>: Send + Sync + 'static {
287    /// Return the records the current user is allowed to read.
288    ///
289    /// The default impl returns `Ok(Vec::new())` so a missing
290    /// scope opt-in fails closed. Implementations typically run a
291    /// Diesel query through `conn`, applying whatever filters the
292    /// active `ctx.user_id` / `ctx.roles` warrant.
293    fn list<'a>(
294        &'a self,
295        _ctx: &'a PolicyContext,
296        _conn: &'a mut diesel_async::AsyncPgConnection,
297    ) -> BoxFuture<'a, crate::AutumnResult<Vec<R>>> {
298        Box::pin(async { Ok(Vec::new()) })
299    }
300}
301
302/// `Scope` companion that compiles when the `db` feature is off.
303/// The `db`-gated form takes `&mut AsyncPgConnection`; this one
304/// has no connection arg.
305#[cfg(not(feature = "db"))]
306pub trait Scope<R: Send + Sync + 'static>: Send + Sync + 'static {
307    fn list<'a>(&'a self, _ctx: &'a PolicyContext) -> BoxFuture<'a, crate::AutumnResult<Vec<R>>> {
308        Box::pin(async { Ok(Vec::new()) })
309    }
310}
311
312// ── `Post::scope(&ctx).load(&mut db).await?` ergonomics ─────────
313
314/// Deferred query handle returned by [`Scoped::scope`].
315///
316/// Holds a borrow on the [`PolicyContext`] so the registered
317/// [`Scope`] for `R` can be resolved at `.load()` time. The
318/// pattern mirrors Pundit's `policy_scope(Post)` and Phoenix's
319/// `Bodyguard.scope/4`: a query you can run when the connection
320/// is available.
321pub struct ScopeQuery<'a, R: Send + Sync + 'static> {
322    ctx: &'a PolicyContext,
323    _marker: std::marker::PhantomData<fn() -> R>,
324}
325
326#[cfg(feature = "db")]
327impl<R: Send + Sync + 'static> ScopeQuery<'_, R> {
328    /// Load the records the current user is allowed to read.
329    ///
330    /// Resolves the [`Scope`] registered on the app's
331    /// [`PolicyRegistry`] (carried in [`PolicyContext`]) and runs
332    /// its `list` method against `conn`.
333    ///
334    /// # Errors
335    ///
336    /// Returns a `500` when no scope is registered for `R`; the
337    /// scope's own errors otherwise.
338    pub async fn load(
339        self,
340        conn: &mut diesel_async::AsyncPgConnection,
341    ) -> crate::AutumnResult<Vec<R>> {
342        let scope = self.ctx.policy_registry.scope::<R>().ok_or_else(|| {
343            crate::AutumnError::from(std::io::Error::other(format!(
344                "no scope registered for resource type {}",
345                std::any::type_name::<R>()
346            )))
347            .with_status(StatusCode::INTERNAL_SERVER_ERROR)
348        })?;
349        scope.list(self.ctx, conn).await
350    }
351}
352
353#[cfg(not(feature = "db"))]
354impl<R: Send + Sync + 'static> ScopeQuery<'_, R> {
355    pub async fn load(self) -> crate::AutumnResult<Vec<R>> {
356        let scope = self.ctx.policy_registry.scope::<R>().ok_or_else(|| {
357            crate::AutumnError::from(std::io::Error::other(format!(
358                "no scope registered for resource type {}",
359                std::any::type_name::<R>()
360            )))
361            .with_status(StatusCode::INTERNAL_SERVER_ERROR)
362        })?;
363        scope.list(self.ctx).await
364    }
365}
366
367/// Blanket trait that adds `T::scope(&ctx)` to every type, so
368/// hand-written list handlers can mirror the
369/// `#[repository(scope = ...)]`-generated path:
370///
371/// ```rust,ignore
372/// use autumn_web::authorization::Scoped;
373///
374/// let posts = Post::scope(&ctx).load(&mut db).await?;
375/// ```
376///
377/// Auto-implemented for every `Send + Sync + 'static` type. Bring
378/// the trait into scope with `use autumn_web::authorization::Scoped;`
379/// (or via `autumn_web::prelude::*`) to use the syntax.
380pub trait Scoped: Send + Sync + Sized + 'static {
381    /// Open a deferred [`ScopeQuery`] for this type. Resolves the
382    /// registered scope at `.load()` time, not here.
383    #[must_use]
384    fn scope(ctx: &PolicyContext) -> ScopeQuery<'_, Self> {
385        ScopeQuery {
386            ctx,
387            _marker: std::marker::PhantomData,
388        }
389    }
390}
391
392impl<T: Send + Sync + 'static> Scoped for T {}
393
394// ── PolicyRegistry ──────────────────────────────────────────────
395
396/// Process-wide registry of resource → policy and resource →
397/// scope bindings.
398///
399/// Stored on [`AppState`](crate::AppState) so handlers and
400/// `#[repository]`-generated endpoints can resolve a policy by
401/// resource type via [`AppState::policy::<R>`](crate::AppState::policy).
402#[derive(Clone, Default)]
403pub struct PolicyRegistry {
404    inner: Arc<RwLock<RegistryInner>>,
405}
406
407#[derive(Default)]
408struct RegistryInner {
409    policies: HashMap<TypeId, Arc<dyn Any + Send + Sync>>,
410    scopes: HashMap<TypeId, Arc<dyn Any + Send + Sync>>,
411}
412
413impl PolicyRegistry {
414    /// Register the [`Policy`] implementation for resource `R`.
415    ///
416    /// # Panics
417    ///
418    /// Panics if a policy is already registered for `R`. The issue
419    /// spec is explicit: multiple policies per resource are not
420    /// supported in this slice; double-registration must surface
421    /// as a startup-time error rather than silent override.
422    pub fn register_policy<R, P>(&self, policy: P)
423    where
424        R: Send + Sync + 'static,
425        P: Policy<R>,
426    {
427        let mut inner = self.inner.write().expect("policy registry lock poisoned");
428        let key = TypeId::of::<R>();
429        assert!(
430            !inner.policies.contains_key(&key),
431            "Policy for {} already registered. Multiple policies per resource are not supported.",
432            std::any::type_name::<R>()
433        );
434        let boxed: Arc<dyn Policy<R>> = Arc::new(policy);
435        inner.policies.insert(key, Arc::new(boxed));
436    }
437
438    /// Register the [`Scope`] implementation for resource `R`.
439    ///
440    /// # Panics
441    ///
442    /// Panics if a scope is already registered for `R`.
443    pub fn register_scope<R, S>(&self, scope: S)
444    where
445        R: Send + Sync + 'static,
446        S: Scope<R>,
447    {
448        let mut inner = self.inner.write().expect("policy registry lock poisoned");
449        let key = TypeId::of::<R>();
450        assert!(
451            !inner.scopes.contains_key(&key),
452            "Scope for {} already registered. Multiple scopes per resource are not supported.",
453            std::any::type_name::<R>()
454        );
455        let boxed: Arc<dyn Scope<R>> = Arc::new(scope);
456        inner.scopes.insert(key, Arc::new(boxed));
457    }
458
459    /// Resolve the registered [`Policy`] for resource `R`.
460    ///
461    /// # Panics
462    ///
463    /// Panics if the registry's internal `RwLock` is poisoned (a
464    /// previous writer panicked while holding the lock).
465    #[must_use]
466    pub fn policy<R: Send + Sync + 'static>(&self) -> Option<Arc<dyn Policy<R>>> {
467        let inner = self.inner.read().expect("policy registry lock poisoned");
468        inner
469            .policies
470            .get(&TypeId::of::<R>())
471            .and_then(|a| a.downcast_ref::<Arc<dyn Policy<R>>>().cloned())
472    }
473
474    /// Resolve the registered [`Scope`] for resource `R`.
475    ///
476    /// # Panics
477    ///
478    /// Panics if the registry's internal `RwLock` is poisoned.
479    #[must_use]
480    pub fn scope<R: Send + Sync + 'static>(&self) -> Option<Arc<dyn Scope<R>>> {
481        let inner = self.inner.read().expect("policy registry lock poisoned");
482        inner
483            .scopes
484            .get(&TypeId::of::<R>())
485            .and_then(|a| a.downcast_ref::<Arc<dyn Scope<R>>>().cloned())
486    }
487
488    /// Returns `true` when a policy is registered for resource `R`.
489    ///
490    /// # Panics
491    ///
492    /// Panics if the registry's internal `RwLock` is poisoned.
493    #[must_use]
494    pub fn has_policy<R: Send + Sync + 'static>(&self) -> bool {
495        self.inner
496            .read()
497            .expect("policy registry lock poisoned")
498            .policies
499            .contains_key(&TypeId::of::<R>())
500    }
501}
502
503impl std::fmt::Debug for PolicyRegistry {
504    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
505        let inner = self.inner.read().expect("policy registry lock poisoned");
506        f.debug_struct("PolicyRegistry")
507            .field("policies", &inner.policies.len())
508            .field("scopes", &inner.scopes.len())
509            .finish()
510    }
511}
512
513// ── Forbidden response shape ────────────────────────────────────
514
515/// HTTP status the framework returns when a [`Policy`] denies an
516/// action.
517///
518/// Defaults to `404 Not Found` so unauthorized clients cannot
519/// distinguish "the record exists but you cannot touch it" from
520/// "the record does not exist." This mirrors Rails / Phoenix
521/// defaults; flip to `403` via
522/// `[security] forbidden_response = "403"` in `autumn.toml` when
523/// the leak is acceptable (e.g. internal admin tooling).
524#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
525pub enum ForbiddenResponse {
526    /// Return `403 Forbidden`.
527    Forbidden403,
528    /// Return `404 Not Found` (default, hides existence).
529    #[default]
530    NotFound404,
531}
532
533impl ForbiddenResponse {
534    /// HTTP status code for the deny response.
535    #[must_use]
536    pub const fn status(self) -> StatusCode {
537        match self {
538            Self::Forbidden403 => StatusCode::FORBIDDEN,
539            Self::NotFound404 => StatusCode::NOT_FOUND,
540        }
541    }
542
543    /// Human-readable message for the deny response body. Kept
544    /// generic so a `404`-mode response does not accidentally
545    /// reveal existence via the message text.
546    #[must_use]
547    pub const fn message(self) -> &'static str {
548        match self {
549            Self::Forbidden403 => "forbidden",
550            Self::NotFound404 => "not found",
551        }
552    }
553
554    /// Build the [`AutumnError`](crate::AutumnError) used by the
555    /// `#[authorize]` macro and `#[repository]`-generated
556    /// endpoints when a policy denies an action.
557    #[must_use]
558    pub fn into_error(self) -> crate::AutumnError {
559        crate::AutumnError::from(std::io::Error::other(self.message())).with_status(self.status())
560    }
561}
562
563impl std::str::FromStr for ForbiddenResponse {
564    type Err = String;
565
566    fn from_str(s: &str) -> Result<Self, Self::Err> {
567        match s.trim() {
568            "403" | "forbidden" | "Forbidden" => Ok(Self::Forbidden403),
569            "404" | "not_found" | "NotFound" | "" => Ok(Self::NotFound404),
570            other => Err(format!(
571                "invalid forbidden_response: {other:?} (expected \"403\" or \"404\")"
572            )),
573        }
574    }
575}
576
577impl<'de> serde::Deserialize<'de> for ForbiddenResponse {
578    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
579    where
580        D: serde::Deserializer<'de>,
581    {
582        let raw = String::deserialize(deserializer)?;
583        raw.parse().map_err(serde::de::Error::custom)
584    }
585}
586
587// ── Runtime authorization helpers ───────────────────────────────
588
589/// Resolve the registered [`Policy`] for resource `R`, run the
590/// named action, and return the configured deny response on
591/// failure.
592///
593/// This is the workhorse called by the `#[authorize]` attribute
594/// macro and by `#[repository(policy = ...)]`-generated handlers.
595/// Hand-written handlers can call it directly to short-circuit a
596/// route after loading the resource — the inline pattern that
597/// replaces the hand-rolled `if record.author_id != user_id { ... }`
598/// snippets the reddit-clone migration removes.
599///
600/// # Errors
601///
602/// Returns the configured [`ForbiddenResponse`] error when the
603/// policy denies the action, or a `500` when no policy is
604/// registered for `R`.
605///
606/// # Examples
607///
608/// ```rust,ignore
609/// use autumn_web::prelude::*;
610/// use autumn_web::authorization::authorize;
611///
612/// async fn delete_post(
613///     state: AppState,
614///     session: Session,
615///     mut db: Db,
616///     post: Post,
617/// ) -> AutumnResult<()> {
618///     authorize::<Post>(&state, &session, "delete", &post).await?;
619///     // ... actually delete
620///     Ok(())
621/// }
622/// ```
623pub async fn authorize<R>(
624    state: &crate::AppState,
625    session: &Session,
626    action: &str,
627    resource: &R,
628) -> crate::AutumnResult<()>
629where
630    R: Send + Sync + 'static,
631{
632    let policy = state.policy_registry().policy::<R>().ok_or_else(|| {
633        crate::AutumnError::from(std::io::Error::other(format!(
634            "no policy registered for resource type {}",
635            std::any::type_name::<R>()
636        )))
637        .with_status(StatusCode::INTERNAL_SERVER_ERROR)
638    })?;
639
640    let ctx = PolicyContext::from_request(state, session).await;
641
642    if policy.can(action, &ctx, resource).await {
643        Ok(())
644    } else {
645        Err(state.forbidden_response().into_error())
646    }
647}
648
649/// Internal alias used by the `#[authorize]` proc-macro and the
650/// `#[repository(policy = ...)]` generated handlers. **Not part of
651/// the public API** — call [`authorize`] from user code.
652#[doc(hidden)]
653pub async fn __check_policy<R>(
654    state: &crate::AppState,
655    session: &Session,
656    action: &str,
657    resource: &R,
658) -> crate::AutumnResult<()>
659where
660    R: Send + Sync + 'static,
661{
662    authorize(state, session, action, resource).await
663}
664
665/// Pre-insert authorization helper for the
666/// `#[repository(policy = ...)]`-generated `POST` endpoint.
667///
668/// Resolves the registered [`Policy`] for `R` and calls
669/// [`Policy::can_create`] *before* the row is persisted, closing
670/// the "deny still wrote a row" hole that catches naive
671/// after-the-fact policy checks. Use [`authorize_create`] from user
672/// code; this is the framework's backward-compatible `__`-prefixed
673/// alias for older macro output.
674#[doc(hidden)]
675pub async fn __check_policy_create<R>(
676    state: &crate::AppState,
677    session: &Session,
678) -> crate::AutumnResult<()>
679where
680    R: Send + Sync + 'static,
681{
682    authorize_create::<R>(state, session).await
683}
684
685/// Payload-aware pre-insert authorization helper for
686/// `#[repository(policy = ...)]`-generated `POST` endpoints.
687///
688/// Newer macro output uses this helper when it has the raw JSON
689/// request payload available. The two-argument
690/// [`__check_policy_create`] alias is kept so applications compiled
691/// with older `autumn-macros` output remain source-compatible when
692/// only `autumn-web` is upgraded.
693#[doc(hidden)]
694pub async fn __check_policy_create_payload<R>(
695    state: &crate::AppState,
696    session: &Session,
697    payload: &serde_json::Value,
698) -> crate::AutumnResult<()>
699where
700    R: Send + Sync + 'static,
701{
702    authorize_create_payload::<R>(state, session, payload).await
703}
704
705/// Run a policy's `can_create` check before persisting a new record.
706///
707/// Mirrors [`authorize`] but takes no resource argument: at create
708/// time, no record instance exists yet, so policies decide based on
709/// `ctx.user_id` and `ctx.roles` alone.
710///
711/// # Errors
712///
713/// Returns the configured deny response when the policy denies.
714/// Returns `500` when no policy is registered for `R`.
715pub async fn authorize_create<R>(
716    state: &crate::AppState,
717    session: &Session,
718) -> crate::AutumnResult<()>
719where
720    R: Send + Sync + 'static,
721{
722    let policy = state.policy_registry().policy::<R>().ok_or_else(|| {
723        crate::AutumnError::from(std::io::Error::other(format!(
724            "no policy registered for resource type {}",
725            std::any::type_name::<R>()
726        )))
727        .with_status(StatusCode::INTERNAL_SERVER_ERROR)
728    })?;
729
730    let ctx = PolicyContext::from_request(state, session).await;
731
732    if policy.can_create(&ctx).await {
733        Ok(())
734    } else {
735        Err(state.forbidden_response().into_error())
736    }
737}
738
739/// Run a policy's payload-aware `can_create_payload` check before
740/// persisting a new record.
741///
742/// Use this when a create policy must inspect the proposed JSON
743/// payload before insert, such as tenant/owner invariants. Existing
744/// custom handlers that only need context-based create authorization
745/// should keep calling [`authorize_create`].
746///
747/// # Errors
748///
749/// Returns the configured deny response when the policy denies.
750/// Returns `500` when no policy is registered for `R`.
751pub async fn authorize_create_payload<R>(
752    state: &crate::AppState,
753    session: &Session,
754    payload: &serde_json::Value,
755) -> crate::AutumnResult<()>
756where
757    R: Send + Sync + 'static,
758{
759    let policy = state.policy_registry().policy::<R>().ok_or_else(|| {
760        crate::AutumnError::from(std::io::Error::other(format!(
761            "no policy registered for resource type {}",
762            std::any::type_name::<R>()
763        )))
764        .with_status(StatusCode::INTERNAL_SERVER_ERROR)
765    })?;
766
767    let ctx = PolicyContext::from_request(state, session).await;
768
769    if policy.can_create_payload(&ctx, payload).await {
770        Ok(())
771    } else {
772        Err(state.forbidden_response().into_error())
773    }
774}
775
776#[cfg(test)]
777mod tests {
778    use super::*;
779    use std::collections::HashMap;
780
781    #[derive(Debug, Clone, PartialEq)]
782    struct Note {
783        author_id: i64,
784    }
785
786    #[derive(Default)]
787    struct AdminOrOwnerPolicy;
788
789    impl Policy<Note> for AdminOrOwnerPolicy {
790        fn can_show<'a>(&'a self, _ctx: &'a PolicyContext, _note: &'a Note) -> BoxFuture<'a, bool> {
791            Box::pin(async { true })
792        }
793        fn can_update<'a>(&'a self, ctx: &'a PolicyContext, note: &'a Note) -> BoxFuture<'a, bool> {
794            Box::pin(
795                async move { ctx.has_role("admin") || ctx.user_id_i64() == Some(note.author_id) },
796            )
797        }
798        fn can_delete<'a>(
799            &'a self,
800            ctx: &'a PolicyContext,
801            _note: &'a Note,
802        ) -> BoxFuture<'a, bool> {
803            Box::pin(async move { ctx.has_role("admin") })
804        }
805    }
806
807    fn ctx(user_id: Option<&str>, role: Option<&str>) -> PolicyContext {
808        let session = Session::new_for_test(String::new(), HashMap::new());
809        PolicyContext {
810            session,
811            user_id: user_id.map(str::to_owned),
812            roles: role.into_iter().map(str::to_owned).collect(),
813            #[cfg(feature = "db")]
814            pool: None,
815            policy_registry: PolicyRegistry::default(),
816        }
817    }
818
819    #[tokio::test]
820    async fn default_impls_deny() {
821        struct EmptyPolicy;
822        impl Policy<Note> for EmptyPolicy {}
823        let policy = EmptyPolicy;
824        let c = ctx(Some("1"), None);
825        let n = Note { author_id: 1 };
826        assert!(!policy.can_show(&c, &n).await);
827        assert!(!policy.can_create(&c).await);
828        assert!(!policy.can_update(&c, &n).await);
829        assert!(!policy.can_delete(&c, &n).await);
830        assert!(!policy.can("publish", &c, &n).await);
831    }
832
833    #[tokio::test]
834    async fn owner_can_update() {
835        let policy = AdminOrOwnerPolicy;
836        let c = ctx(Some("42"), None);
837        let n = Note { author_id: 42 };
838        assert!(policy.can_update(&c, &n).await);
839        assert!(!policy.can_delete(&c, &n).await);
840    }
841
842    #[tokio::test]
843    async fn non_owner_cannot_update() {
844        let policy = AdminOrOwnerPolicy;
845        let c = ctx(Some("99"), None);
846        let n = Note { author_id: 42 };
847        assert!(!policy.can_update(&c, &n).await);
848    }
849
850    #[tokio::test]
851    async fn admin_can_delete() {
852        let policy = AdminOrOwnerPolicy;
853        let c = ctx(Some("99"), Some("admin"));
854        let n = Note { author_id: 42 };
855        assert!(policy.can_delete(&c, &n).await);
856    }
857
858    #[tokio::test]
859    async fn can_dispatches_named_actions() {
860        let policy = AdminOrOwnerPolicy;
861        let c = ctx(Some("42"), None);
862        let n = Note { author_id: 42 };
863        assert!(policy.can("show", &c, &n).await);
864        assert!(policy.can("update", &c, &n).await);
865        assert!(policy.can("edit", &c, &n).await);
866        assert!(!policy.can("publish", &c, &n).await);
867    }
868
869    #[test]
870    fn policy_registry_stores_and_resolves() {
871        let registry = PolicyRegistry::default();
872        registry.register_policy::<Note, _>(AdminOrOwnerPolicy);
873        assert!(registry.has_policy::<Note>());
874        assert!(registry.policy::<Note>().is_some());
875        assert!(registry.scope::<Note>().is_none());
876    }
877
878    #[test]
879    #[should_panic(expected = "already registered")]
880    fn double_policy_registration_panics() {
881        let registry = PolicyRegistry::default();
882        registry.register_policy::<Note, _>(AdminOrOwnerPolicy);
883        registry.register_policy::<Note, _>(AdminOrOwnerPolicy);
884    }
885
886    #[test]
887    fn forbidden_response_default_is_404() {
888        let resp = ForbiddenResponse::default();
889        assert_eq!(resp, ForbiddenResponse::NotFound404);
890        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
891    }
892
893    #[test]
894    fn forbidden_response_parses_strings() {
895        assert_eq!(
896            "403".parse::<ForbiddenResponse>().unwrap(),
897            ForbiddenResponse::Forbidden403
898        );
899        assert_eq!(
900            "404".parse::<ForbiddenResponse>().unwrap(),
901            ForbiddenResponse::NotFound404
902        );
903        assert_eq!(
904            "forbidden".parse::<ForbiddenResponse>().unwrap(),
905            ForbiddenResponse::Forbidden403
906        );
907        assert!("418".parse::<ForbiddenResponse>().is_err());
908    }
909
910    #[test]
911    fn policy_context_helpers() {
912        let c = ctx(Some("42"), Some("editor"));
913        assert!(c.is_authenticated());
914        assert_eq!(c.user_id_i64(), Some(42));
915        assert!(c.has_role("editor"));
916        assert!(!c.has_role("admin"));
917        assert!(c.has_any_role(["admin", "editor"]));
918        assert!(!c.has_any_role(["viewer", "guest"]));
919    }
920
921    #[test]
922    fn anonymous_context_is_not_authenticated() {
923        let c = ctx(None, None);
924        assert!(!c.is_authenticated());
925        assert!(c.user_id_i64().is_none());
926        assert!(!c.has_role("admin"));
927        assert!(!c.has_any_role(["admin", "editor"]));
928    }
929
930    #[test]
931    fn user_id_i64_handles_non_numeric_session_value() {
932        let c = ctx(Some("not-a-number"), None);
933        assert!(c.user_id_i64().is_none());
934    }
935
936    #[test]
937    fn forbidden_response_status_and_message_round_trip() {
938        assert_eq!(
939            ForbiddenResponse::Forbidden403.status(),
940            StatusCode::FORBIDDEN
941        );
942        assert_eq!(
943            ForbiddenResponse::NotFound404.status(),
944            StatusCode::NOT_FOUND
945        );
946        assert_eq!(ForbiddenResponse::Forbidden403.message(), "forbidden");
947        assert_eq!(ForbiddenResponse::NotFound404.message(), "not found");
948    }
949
950    #[test]
951    fn forbidden_response_into_error_carries_status_and_message() {
952        let err = ForbiddenResponse::NotFound404.into_error();
953        assert_eq!(err.status(), StatusCode::NOT_FOUND);
954        assert_eq!(err.to_string(), "not found");
955
956        let err = ForbiddenResponse::Forbidden403.into_error();
957        assert_eq!(err.status(), StatusCode::FORBIDDEN);
958        assert_eq!(err.to_string(), "forbidden");
959    }
960
961    #[test]
962    fn forbidden_response_parses_empty_string_as_default_404() {
963        assert_eq!(
964            "".parse::<ForbiddenResponse>().unwrap(),
965            ForbiddenResponse::NotFound404
966        );
967        assert_eq!(
968            "not_found".parse::<ForbiddenResponse>().unwrap(),
969            ForbiddenResponse::NotFound404
970        );
971        assert_eq!(
972            "NotFound".parse::<ForbiddenResponse>().unwrap(),
973            ForbiddenResponse::NotFound404
974        );
975        assert_eq!(
976            "Forbidden".parse::<ForbiddenResponse>().unwrap(),
977            ForbiddenResponse::Forbidden403
978        );
979    }
980
981    #[test]
982    fn forbidden_response_parse_error_carries_input_value() {
983        let err = "418".parse::<ForbiddenResponse>().unwrap_err();
984        assert!(err.contains("418"));
985        assert!(err.contains("403"));
986        assert!(err.contains("404"));
987    }
988
989    #[test]
990    fn forbidden_response_deserializes_from_toml() {
991        #[derive(Debug, serde::Deserialize)]
992        struct Holder {
993            value: ForbiddenResponse,
994        }
995        let h: Holder = toml::from_str(r#"value = "403""#).unwrap();
996        assert_eq!(h.value, ForbiddenResponse::Forbidden403);
997        let h: Holder = toml::from_str(r#"value = "404""#).unwrap();
998        assert_eq!(h.value, ForbiddenResponse::NotFound404);
999        let err = toml::from_str::<Holder>(r#"value = "418""#).unwrap_err();
1000        assert!(err.to_string().contains("418"));
1001    }
1002
1003    #[test]
1004    fn registry_scope_double_registration_panics_with_clear_message() {
1005        let registry = PolicyRegistry::default();
1006        registry.register_scope::<Note, _>(EmptyScope);
1007        let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1008            registry.register_scope::<Note, _>(EmptyScope);
1009        }))
1010        .unwrap_err();
1011        let msg = panicked
1012            .downcast_ref::<String>()
1013            .map(String::as_str)
1014            .or_else(|| panicked.downcast_ref::<&'static str>().copied())
1015            .unwrap_or("");
1016        assert!(
1017            msg.contains("already registered"),
1018            "expected double-registration panic, got {msg:?}"
1019        );
1020    }
1021
1022    struct OtherResource;
1023    struct OtherPolicy;
1024    impl Policy<OtherResource> for OtherPolicy {}
1025    struct ThirdResource;
1026    struct EmptyScope;
1027    impl Scope<Note> for EmptyScope {}
1028
1029    #[test]
1030    fn registry_resolves_distinct_resource_types_independently() {
1031        let registry = PolicyRegistry::default();
1032        registry.register_policy::<Note, _>(AdminOrOwnerPolicy);
1033        registry.register_policy::<OtherResource, _>(OtherPolicy);
1034
1035        assert!(registry.has_policy::<Note>());
1036        assert!(registry.has_policy::<OtherResource>());
1037        // Resources without registrations don't false-positive.
1038        assert!(!registry.has_policy::<ThirdResource>());
1039        assert!(registry.scope::<Note>().is_none());
1040    }
1041
1042    #[test]
1043    fn registry_debug_shows_counts() {
1044        let registry = PolicyRegistry::default();
1045        registry.register_policy::<Note, _>(AdminOrOwnerPolicy);
1046        registry.register_scope::<Note, _>(EmptyScope);
1047        let dbg = format!("{registry:?}");
1048        assert!(dbg.contains("PolicyRegistry"));
1049        assert!(dbg.contains("policies"));
1050        assert!(dbg.contains("scopes"));
1051    }
1052
1053    fn detached_state_with(
1054        _registry: PolicyRegistry,
1055        forbidden: ForbiddenResponse,
1056    ) -> crate::AppState {
1057        crate::AppState::detached()
1058            .with_forbidden_response(forbidden)
1059            .with_auth_session_key("user_id")
1060    }
1061
1062    fn session_with(user_id: Option<&str>, role: Option<&str>) -> Session {
1063        let mut data = HashMap::new();
1064        if let Some(u) = user_id {
1065            data.insert("user_id".to_owned(), u.to_owned());
1066        }
1067        if let Some(r) = role {
1068            data.insert("role".to_owned(), r.to_owned());
1069        }
1070        Session::new_for_test(String::new(), data)
1071    }
1072
1073    #[tokio::test]
1074    async fn authorize_returns_500_when_no_policy_registered() {
1075        let state = detached_state_with(PolicyRegistry::default(), ForbiddenResponse::default());
1076        let session = session_with(Some("42"), None);
1077        let n = Note { author_id: 42 };
1078        let err = authorize::<Note>(&state, &session, "update", &n)
1079            .await
1080            .unwrap_err();
1081        assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
1082    }
1083
1084    #[tokio::test]
1085    async fn authorize_returns_configured_deny_when_policy_denies() {
1086        let registry = PolicyRegistry::default();
1087        registry.register_policy::<Note, _>(AdminOrOwnerPolicy);
1088        let state = detached_state_with(registry.clone(), ForbiddenResponse::Forbidden403);
1089        // Inject the registry into the live state's registry.
1090        let live = state.policy_registry();
1091        // Move registrations from `registry` into the state's registry.
1092        // (`detached()` starts with an empty registry; we copy in.)
1093        live.register_policy::<Note, _>(AdminOrOwnerPolicy);
1094
1095        let session = session_with(Some("99"), None); // not the owner, no role
1096        let n = Note { author_id: 42 };
1097        let err = authorize::<Note>(&state, &session, "update", &n)
1098            .await
1099            .unwrap_err();
1100        assert_eq!(err.status(), StatusCode::FORBIDDEN);
1101    }
1102
1103    #[tokio::test]
1104    async fn authorize_returns_ok_when_policy_allows() {
1105        let state = crate::AppState::detached();
1106        state
1107            .policy_registry()
1108            .register_policy::<Note, _>(AdminOrOwnerPolicy);
1109        let session = session_with(Some("42"), None); // owner
1110        let n = Note { author_id: 42 };
1111        authorize::<Note>(&state, &session, "update", &n)
1112            .await
1113            .expect("owner is allowed to update");
1114    }
1115
1116    #[tokio::test]
1117    async fn authorize_create_returns_500_when_no_policy_registered() {
1118        let state = crate::AppState::detached();
1119        let session = session_with(Some("42"), None);
1120        let err = authorize_create::<Note>(&state, &session)
1121            .await
1122            .unwrap_err();
1123        assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
1124    }
1125
1126    #[tokio::test]
1127    async fn authorize_create_dispatches_can_create() {
1128        struct AuthOnlyCreatePolicy;
1129        impl Policy<Note> for AuthOnlyCreatePolicy {
1130            fn can_create<'a>(&'a self, ctx: &'a PolicyContext) -> BoxFuture<'a, bool> {
1131                Box::pin(async move { ctx.is_authenticated() })
1132            }
1133        }
1134
1135        let state =
1136            crate::AppState::detached().with_forbidden_response(ForbiddenResponse::Forbidden403);
1137        state
1138            .policy_registry()
1139            .register_policy::<Note, _>(AuthOnlyCreatePolicy);
1140
1141        let anon = session_with(None, None);
1142        let err = authorize_create::<Note>(&state, &anon).await.unwrap_err();
1143        assert_eq!(err.status(), StatusCode::FORBIDDEN);
1144
1145        let user = session_with(Some("1"), None);
1146        authorize_create::<Note>(&state, &user)
1147            .await
1148            .expect("authenticated user passes can_create");
1149    }
1150
1151    #[tokio::test]
1152    async fn authorize_create_payload_dispatches_can_create_payload() {
1153        struct OwnerPayloadPolicy;
1154        impl Policy<Note> for OwnerPayloadPolicy {
1155            fn can_create_payload<'a>(
1156                &'a self,
1157                ctx: &'a PolicyContext,
1158                payload: &'a serde_json::Value,
1159            ) -> BoxFuture<'a, bool> {
1160                Box::pin(async move {
1161                    payload.get("author_id").and_then(serde_json::Value::as_i64)
1162                        == ctx.user_id_i64()
1163                })
1164            }
1165        }
1166
1167        let state =
1168            crate::AppState::detached().with_forbidden_response(ForbiddenResponse::Forbidden403);
1169        state
1170            .policy_registry()
1171            .register_policy::<Note, _>(OwnerPayloadPolicy);
1172
1173        let user = session_with(Some("1"), None);
1174        let own_payload = serde_json::json!({"author_id": 1});
1175        authorize_create_payload::<Note>(&state, &user, &own_payload)
1176            .await
1177            .expect("owner payload passes can_create_payload");
1178
1179        let other_payload = serde_json::json!({"author_id": 2});
1180        let err = authorize_create_payload::<Note>(&state, &user, &other_payload)
1181            .await
1182            .unwrap_err();
1183        assert_eq!(err.status(), StatusCode::FORBIDDEN);
1184    }
1185
1186    #[tokio::test]
1187    async fn check_policy_create_alias_preserves_two_arg_shape() {
1188        struct AuthOnlyCreatePolicy;
1189        impl Policy<Note> for AuthOnlyCreatePolicy {
1190            fn can_create<'a>(&'a self, ctx: &'a PolicyContext) -> BoxFuture<'a, bool> {
1191                Box::pin(async move { ctx.is_authenticated() })
1192            }
1193        }
1194
1195        let state =
1196            crate::AppState::detached().with_forbidden_response(ForbiddenResponse::Forbidden403);
1197        state
1198            .policy_registry()
1199            .register_policy::<Note, _>(AuthOnlyCreatePolicy);
1200
1201        let anon = session_with(None, None);
1202        let err = __check_policy_create::<Note>(&state, &anon)
1203            .await
1204            .unwrap_err();
1205        assert_eq!(err.status(), StatusCode::FORBIDDEN);
1206
1207        let user = session_with(Some("1"), None);
1208        __check_policy_create::<Note>(&state, &user)
1209            .await
1210            .expect("old generated create policy alias remains compatible");
1211    }
1212
1213    #[tokio::test]
1214    async fn check_policy_create_payload_alias_dispatches_payload() {
1215        struct OwnerPayloadPolicy;
1216        impl Policy<Note> for OwnerPayloadPolicy {
1217            fn can_create_payload<'a>(
1218                &'a self,
1219                ctx: &'a PolicyContext,
1220                payload: &'a serde_json::Value,
1221            ) -> BoxFuture<'a, bool> {
1222                Box::pin(async move {
1223                    payload.get("author_id").and_then(serde_json::Value::as_i64)
1224                        == ctx.user_id_i64()
1225                })
1226            }
1227        }
1228
1229        let state =
1230            crate::AppState::detached().with_forbidden_response(ForbiddenResponse::Forbidden403);
1231        state
1232            .policy_registry()
1233            .register_policy::<Note, _>(OwnerPayloadPolicy);
1234
1235        let user = session_with(Some("1"), None);
1236        let payload = serde_json::json!({"author_id": 1});
1237        __check_policy_create_payload::<Note>(&state, &user, &payload)
1238            .await
1239            .expect("new generated create policy alias passes payload");
1240    }
1241
1242    #[tokio::test]
1243    async fn check_policy_alias_round_trips() {
1244        let state = crate::AppState::detached();
1245        state
1246            .policy_registry()
1247            .register_policy::<Note, _>(AdminOrOwnerPolicy);
1248        let session = session_with(Some("42"), None);
1249        let n = Note { author_id: 42 };
1250        // The macro-internal alias goes through `authorize` — exercise
1251        // the full round-trip.
1252        __check_policy::<Note>(&state, &session, "update", &n)
1253            .await
1254            .unwrap();
1255    }
1256
1257    #[tokio::test]
1258    async fn from_request_clones_pool_and_registry_from_state() {
1259        let state = crate::AppState::detached();
1260        state
1261            .policy_registry()
1262            .register_policy::<Note, _>(AdminOrOwnerPolicy);
1263        let session = session_with(Some("7"), Some("admin"));
1264        let ctx = PolicyContext::from_request(&state, &session).await;
1265        assert_eq!(ctx.user_id.as_deref(), Some("7"));
1266        assert!(ctx.has_role("admin"));
1267        // The registry was cloned from state — `Note` resolves.
1268        assert!(ctx.policy_registry.has_policy::<Note>());
1269    }
1270
1271    #[tokio::test]
1272    async fn scoped_blanket_trait_constructible_without_registered_scope() {
1273        let state = crate::AppState::detached();
1274        let session = session_with(Some("1"), None);
1275        let ctx = PolicyContext::from_request(&state, &session).await;
1276        // No scope registered for `Note`.
1277        let _query = Note::scope(&ctx);
1278        // The `db`-feature `load(&mut conn)` form is exercised by the
1279        // testcontainer suite; here we just confirm the registry-miss
1280        // surfaces only at `.load()` time, not at `scope(&ctx)` time.
1281        assert!(ctx.policy_registry.scope::<Note>().is_none());
1282    }
1283}