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    pub fn try_with_hidden_reason(
81        mut self,
82        reason: impl Into<String>,
83    ) -> gatekeep::GatekeepResult<Self> {
84        self.hidden_reason = Some(ReasonCode::new(reason)?);
85        Ok(self)
86    }
87
88    pub(crate) fn denied<C: ReasonCatalog>(
89        &self,
90        shape: DenyShape,
91        reason: Option<&DenialReason>,
92        locale: &Locale,
93        catalog: &C,
94    ) -> DenialResponse {
95        match shape {
96            DenyShape::Forbidden => self.forbidden(reason, locale, catalog),
97            DenyShape::Hidden => self.hidden(locale, catalog),
98        }
99    }
100
101    fn forbidden<C: ReasonCatalog>(
102        &self,
103        reason: Option<&DenialReason>,
104        locale: &Locale,
105        catalog: &C,
106    ) -> DenialResponse {
107        let message = reason.map_or_else(
108            || self.forbidden_fallback.clone(),
109            |reason| catalog.render(reason, locale),
110        );
111        DenialResponse {
112            status: StatusCode::FORBIDDEN,
113            body: DenialBody {
114                error: DenialError::Forbidden,
115                message,
116                reason: reason.map(|reason| reason.code.as_str().to_owned()),
117            },
118        }
119    }
120
121    fn hidden<C: ReasonCatalog>(&self, locale: &Locale, catalog: &C) -> DenialResponse {
122        let message = self.hidden_reason.as_ref().map_or_else(
123            || self.hidden_fallback.clone(),
124            |code| {
125                let reason = DenialReason {
126                    code: code.clone(),
127                    params: BTreeMap::new(),
128                    shape: DenyShape::Forbidden,
129                };
130                catalog.render(&reason, locale)
131            },
132        );
133        DenialResponse {
134            status: StatusCode::NOT_FOUND,
135            body: DenialBody {
136                error: DenialError::NotFound,
137                message,
138                reason: None,
139            },
140        }
141    }
142}
143
144impl Default for DenialResponseConfig {
145    fn default() -> Self {
146        Self {
147            forbidden_fallback: "forbidden".to_owned(),
148            hidden_fallback: "not found".to_owned(),
149            hidden_reason: None,
150        }
151    }
152}