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}