Skip to main content

axess_core/authz/
error.rs

1//! Authorization error types.
2
3use axum::http::StatusCode;
4use axum::response::{IntoResponse, Response};
5
6/// Returned (and converted to a 403 response) when a Cedar policy check denies access.
7///
8/// Implements [`IntoResponse`] so handlers can use `?` directly:
9/// ```text
10/// authz.require("ViewLedger", &ledger_id).await?;
11/// ```
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub struct AuthzDenied;
14
15impl IntoResponse for AuthzDenied {
16    fn into_response(self) -> Response {
17        (
18            StatusCode::FORBIDDEN,
19            axum::Json(serde_json::json!({ "error": "Access denied" })),
20        )
21            .into_response()
22    }
23}
24
25/// Authorization infrastructure errors.
26///
27/// These indicate misconfiguration or programming errors, not denied access.
28/// Denied access is represented by [`AuthzDenied`].
29///
30/// ## HTTP status mapping
31///
32/// | Variant | Suggested HTTP status | Rationale |
33/// |---------|----------------------|-----------|
34/// | `PolicyParse` | 500 | Server misconfiguration |
35/// | `SchemaParse` | 500 | Server misconfiguration |
36/// | `InvalidEntityUid` | 500 | Programming error |
37/// | `EntityBuild` | 500 | Entity graph construction failed |
38/// | `NoPrincipal` | 401 | Unauthenticated, no session |
39/// | `Provider` | 502 | Upstream entity provider failure |
40/// | `Context` | 500 | Request context build error |
41#[derive(Debug, thiserror::Error)]
42pub enum AuthzError {
43    /// Cedar policy text failed to parse at startup.
44    #[error("Failed to parse Cedar policy: {0}")]
45    PolicyParse(String),
46
47    /// Cedar schema text failed to parse at startup.
48    #[error("Failed to parse Cedar schema: {0}")]
49    SchemaParse(String),
50
51    /// Constructed entity UID is not valid Cedar syntax.
52    #[error("Invalid entity UID: {0}")]
53    InvalidEntityUid(String),
54
55    /// Building the Cedar entity graph from provider data failed.
56    #[error("Failed to build Cedar entities: {0}")]
57    EntityBuild(String),
58
59    /// Authorization called on a session that has no authenticated principal.
60    #[error("Principal not established; session is not authenticated")]
61    NoPrincipal,
62
63    /// The pluggable [`AuthzEntityProvider`](super::provider::AuthzEntityProvider) returned an error.
64    #[error("Entity provider error: {0}")]
65    Provider(Box<dyn std::error::Error + Send + Sync>),
66
67    /// Constructing the per-request Cedar context failed.
68    #[error("Request context error: {0}")]
69    Context(String),
70}
71
72impl AuthzError {
73    /// Create a provider error from any error type.
74    ///
75    /// The original error is boxed so callers can downcast to the concrete
76    /// type if needed: `err.downcast_ref::<MyStoreError>()`.
77    pub fn provider(err: impl std::error::Error + Send + Sync + 'static) -> Self {
78        Self::Provider(Box::new(err))
79    }
80}
81
82#[cfg(test)]
83mod authz_error_tests {
84    use super::*;
85
86    /// `AuthzDenied` surfaces as `403 Forbidden` with a JSON
87    /// body. Pins the `into_response -> Default::default()` body
88    /// mutation, which would emit an empty `200 OK` and silently
89    /// authorize denied requests at the handler boundary.
90    #[test]
91    fn authz_denied_into_response_is_403_forbidden() {
92        let response = AuthzDenied.into_response();
93        assert_eq!(
94            response.status(),
95            StatusCode::FORBIDDEN,
96            "AuthzDenied must surface as 403, not Default::default() (which would return 200)"
97        );
98    }
99
100    /// `AuthzError::provider` wraps the supplied error in the
101    /// `Provider` variant. Pins the `provider -> Default::default()`
102    /// body mutation (correctly classified unviable since `AuthzError`
103    /// has no `Default` impl); the test still asserts the
104    /// constructor's contract so future refactors can't silently
105    /// strip the wrapper.
106    #[test]
107    fn authz_error_provider_wraps_into_provider_variant() {
108        use std::io;
109        let inner = io::Error::other("downstream");
110        let err = AuthzError::provider(inner);
111        assert!(
112            matches!(err, AuthzError::Provider(_)),
113            "provider() must yield AuthzError::Provider, not any other variant"
114        );
115        // Display surfaces the wrapped error.
116        let msg = err.to_string();
117        assert!(
118            msg.contains("Entity provider error") && msg.contains("downstream"),
119            "Provider Display must surface the wrapped error (got: {msg})"
120        );
121    }
122}