use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AuthzDenied;
impl IntoResponse for AuthzDenied {
fn into_response(self) -> Response {
(
StatusCode::FORBIDDEN,
axum::Json(serde_json::json!({ "error": "Access denied" })),
)
.into_response()
}
}
#[derive(Debug, thiserror::Error)]
pub enum AuthzError {
#[error("Failed to parse Cedar policy: {0}")]
PolicyParse(String),
#[error("Failed to parse Cedar schema: {0}")]
SchemaParse(String),
#[error("Invalid entity UID: {0}")]
InvalidEntityUid(String),
#[error("Failed to build Cedar entities: {0}")]
EntityBuild(String),
#[error("Principal not established; session is not authenticated")]
NoPrincipal,
#[error("Entity provider error: {0}")]
Provider(Box<dyn std::error::Error + Send + Sync>),
#[error("Request context error: {0}")]
Context(String),
}
impl AuthzError {
pub fn provider(err: impl std::error::Error + Send + Sync + 'static) -> Self {
Self::Provider(Box::new(err))
}
}
#[cfg(test)]
mod authz_error_tests {
use super::*;
#[test]
fn authz_denied_into_response_is_403_forbidden() {
let response = AuthzDenied.into_response();
assert_eq!(
response.status(),
StatusCode::FORBIDDEN,
"AuthzDenied must surface as 403, not Default::default() (which would return 200)"
);
}
#[test]
fn authz_error_provider_wraps_into_provider_variant() {
use std::io;
let inner = io::Error::other("downstream");
let err = AuthzError::provider(inner);
assert!(
matches!(err, AuthzError::Provider(_)),
"provider() must yield AuthzError::Provider, not any other variant"
);
let msg = err.to_string();
assert!(
msg.contains("Entity provider error") && msg.contains("downstream"),
"Provider Display must surface the wrapped error (got: {msg})"
);
}
}