Skip to main content

gatekeep_axum/
response.rs

1use std::collections::BTreeMap;
2
3use axum::{
4    Json,
5    http::StatusCode,
6    response::{IntoResponse, Response},
7};
8use gatekeep::{DenialReason, DenyShape, Locale, ReasonCatalog, ReasonCode};
9use serde::{Deserialize, Serialize};
10
11/// HTTP denial category returned by the axum adapter.
12#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum DenialError {
15    /// The resource may exist, but the principal is not allowed to access it.
16    Forbidden,
17    /// The denial must be presented as a generic missing resource.
18    NotFound,
19}
20
21/// JSON body emitted for authorization denials.
22#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
23pub struct DenialBody {
24    /// Stable HTTP-facing denial category.
25    pub error: DenialError,
26    /// Human-facing localized or configured message.
27    pub message: String,
28    /// Specific stable reason code, omitted for hidden denials.
29    pub reason: Option<String>,
30}
31
32/// HTTP denial response produced by a policy denial.
33#[derive(Clone, Debug, PartialEq, Eq)]
34pub struct DenialResponse {
35    /// HTTP status selected from the denial shape.
36    pub status: StatusCode,
37    /// JSON response body.
38    pub body: DenialBody,
39}
40
41impl IntoResponse for DenialResponse {
42    fn into_response(self) -> Response {
43        (self.status, Json(self.body)).into_response()
44    }
45}
46
47/// Presentation settings for policy denials.
48#[derive(Clone, Debug, PartialEq, Eq)]
49pub struct DenialResponseConfig {
50    forbidden_fallback: String,
51    hidden_fallback: String,
52    hidden_reason: Option<ReasonCode>,
53}
54
55impl DenialResponseConfig {
56    /// Creates response settings with conservative default messages.
57    #[must_use]
58    pub fn new() -> Self {
59        Self::default()
60    }
61
62    /// Uses this message when a forbidden denial has no stable reason.
63    #[must_use]
64    pub fn with_forbidden_fallback(mut self, message: impl Into<String>) -> Self {
65        self.forbidden_fallback = message.into();
66        self
67    }
68
69    /// Uses this message for hidden denials unless a generic hidden reason is configured.
70    #[must_use]
71    pub fn with_hidden_fallback(mut self, message: impl Into<String>) -> Self {
72        self.hidden_fallback = message.into();
73        self
74    }
75
76    /// Renders hidden denials through the catalog using this generic reason code.
77    ///
78    /// The configured code is a replacement for the hidden policy reason, not
79    /// the hidden policy reason itself.
80    ///
81    /// # Errors
82    ///
83    /// Returns [`gatekeep::GatekeepError::EmptyIdentifier`] when `reason` is
84    /// empty or contains only whitespace.
85    pub fn try_with_hidden_reason(
86        mut self,
87        reason: impl Into<String>,
88    ) -> gatekeep::GatekeepResult<Self> {
89        self.hidden_reason = Some(ReasonCode::new(reason)?);
90        Ok(self)
91    }
92
93    pub(crate) fn denied<C: ReasonCatalog>(
94        &self,
95        shape: DenyShape,
96        reason: Option<&DenialReason>,
97        locale: &Locale,
98        catalog: &C,
99    ) -> DenialResponse {
100        match shape {
101            DenyShape::Forbidden => self.forbidden(reason, locale, catalog),
102            DenyShape::Hidden => self.hidden(locale, catalog),
103        }
104    }
105
106    fn forbidden<C: ReasonCatalog>(
107        &self,
108        reason: Option<&DenialReason>,
109        locale: &Locale,
110        catalog: &C,
111    ) -> DenialResponse {
112        let message = reason.map_or_else(
113            || self.forbidden_fallback.clone(),
114            |reason| catalog.render(reason, locale),
115        );
116        DenialResponse {
117            status: StatusCode::FORBIDDEN,
118            body: DenialBody {
119                error: DenialError::Forbidden,
120                message,
121                reason: reason.map(|reason| reason.code.as_str().to_owned()),
122            },
123        }
124    }
125
126    fn hidden<C: ReasonCatalog>(&self, locale: &Locale, catalog: &C) -> DenialResponse {
127        let message = self.hidden_reason.as_ref().map_or_else(
128            || self.hidden_fallback.clone(),
129            |code| {
130                let reason = DenialReason {
131                    code: code.clone(),
132                    params: BTreeMap::new(),
133                    shape: DenyShape::Forbidden,
134                };
135                catalog.render(&reason, locale)
136            },
137        );
138        DenialResponse {
139            status: StatusCode::NOT_FOUND,
140            body: DenialBody {
141                error: DenialError::NotFound,
142                message,
143                reason: None,
144            },
145        }
146    }
147}
148
149impl Default for DenialResponseConfig {
150    fn default() -> Self {
151        Self {
152            forbidden_fallback: "forbidden".to_owned(),
153            hidden_fallback: "not found".to_owned(),
154            hidden_reason: None,
155        }
156    }
157}