Skip to main content

acme_proxy/webadmin/
error.rs

1//! The admin API's error type.
2//!
3//! Deliberately **not** [`crate::error::Problem`]. Every `Problem` type is a
4//! hardcoded `urn:ietf:params:acme:error:*` URN, and nothing on this listener
5//! is an ACME error — answering a failed admin login with an ACME problem
6//! document would be a category error that a client library might even try to
7//! interpret.
8//!
9//! ```json
10//! { "error": "not_found", "message": "no such account: acct-1" }
11//! ```
12//!
13//! `error` is a stable snake_case code a caller may branch on; `message` is
14//! for a human and may change.
15
16use axum::Json;
17use axum::http::{StatusCode, header};
18use axum::response::{IntoResponse, Response};
19use tracing::error;
20
21/// A failed admin request.
22#[derive(Debug, PartialEq, Eq, thiserror::Error)]
23#[error("{code}: {message}")]
24pub struct AdminError {
25    pub status: StatusCode,
26    /// Stable, machine-readable, snake_case. Never a URN.
27    pub code: &'static str,
28    pub message: String,
29    /// Seconds to wait, rendered as a `Retry-After` header. Only the rate
30    /// limiter sets it.
31    retry_after: Option<u64>,
32}
33
34impl AdminError {
35    fn new(status: StatusCode, code: &'static str, message: impl Into<String>) -> Self {
36        Self {
37            status,
38            code,
39            message: message.into(),
40            retry_after: None,
41        }
42    }
43
44    /// `400` — the request body or query string did not make sense.
45    pub fn bad_request(message: impl Into<String>) -> Self {
46        Self::new(StatusCode::BAD_REQUEST, "bad_request", message)
47    }
48
49    /// `401` — no usable session. Every reason a login can fail collapses to
50    /// [`AdminError::invalid_credentials`]; this one is for a request that
51    /// arrived without a live session.
52    pub fn session_invalid() -> Self {
53        Self::new(
54            StatusCode::UNAUTHORIZED,
55            "session_invalid",
56            "no valid session; sign in again",
57        )
58    }
59
60    /// `401` — the session passed its absolute deadline.
61    pub fn session_expired() -> Self {
62        Self::new(
63            StatusCode::UNAUTHORIZED,
64            "session_expired",
65            "the session has expired; sign in again",
66        )
67    }
68
69    /// `401` — the session went unused for longer than the idle timeout.
70    pub fn session_idle() -> Self {
71        Self::new(
72            StatusCode::UNAUTHORIZED,
73            "session_idle",
74            "the session timed out through inactivity; sign in again",
75        )
76    }
77
78    /// `401` — the login failed.
79    ///
80    /// **One code for every cause**: unknown username, wrong password, and a
81    /// disabled account are indistinguishable to the client on purpose, so the
82    /// endpoint cannot be used to enumerate operators. The log says which
83    /// (see [`crate::admin::users::AuthOutcome`]).
84    pub fn invalid_credentials() -> Self {
85        Self::new(
86            StatusCode::UNAUTHORIZED,
87            "invalid_credentials",
88            "invalid username or password",
89        )
90    }
91
92    /// `403` — the CSRF token was missing, wrong, or the request came from
93    /// another origin.
94    pub fn csrf_failed(message: impl Into<String>) -> Self {
95        Self::new(StatusCode::FORBIDDEN, "csrf_failed", message)
96    }
97
98    /// `404` — no such row.
99    pub fn not_found(message: impl Into<String>) -> Self {
100        Self::new(StatusCode::NOT_FOUND, "not_found", message)
101    }
102
103    /// `405`/`404` fallbacks and any other conflict-free refusal with its own
104    /// code.
105    pub fn with_code(status: StatusCode, code: &'static str, message: impl Into<String>) -> Self {
106        Self::new(status, code, message)
107    }
108
109    /// `409` — the request was well-formed but the row is in the wrong state.
110    pub fn conflict(code: &'static str, message: impl Into<String>) -> Self {
111        Self::new(StatusCode::CONFLICT, code, message)
112    }
113
114    /// `429` — too many failed logins from this address.
115    pub fn rate_limited(retry_after_seconds: u64) -> Self {
116        Self {
117            retry_after: Some(retry_after_seconds),
118            ..Self::new(
119                StatusCode::TOO_MANY_REQUESTS,
120                "rate_limited",
121                format!("too many failed sign-in attempts; retry in {retry_after_seconds}s"),
122            )
123        }
124    }
125
126    /// `502` — the signer backend refused or could not be reached.
127    pub fn signer_failed(message: impl Into<String>) -> Self {
128        Self::new(StatusCode::BAD_GATEWAY, "signer_failed", message)
129    }
130
131    /// `500` — anything the operator can only find in the log.
132    pub fn internal() -> Self {
133        Self::new(
134            StatusCode::INTERNAL_SERVER_ERROR,
135            "internal",
136            "internal error",
137        )
138    }
139}
140
141/// A database failure is logged and answered generically.
142///
143/// The `sqlx` message names tables and columns, which is schema disclosure on
144/// an authenticated-but-not-necessarily-trusted surface — it belongs in the
145/// log, where the operator can read it, and not in the body.
146impl From<sqlx::Error> for AdminError {
147    fn from(error: sqlx::Error) -> Self {
148        error!(event = "admin_db_error", outcome = "failure", error = %error);
149        AdminError::internal()
150    }
151}
152
153impl IntoResponse for AdminError {
154    fn into_response(self) -> Response {
155        let body = Json(serde_json::json!({
156            "error": self.code,
157            "message": self.message,
158        }));
159
160        let mut response = (self.status, body).into_response();
161        // Every admin response is `no-store` at the layer level, but an error
162        // can be produced by an extractor rejection that never reaches it.
163        response.headers_mut().insert(
164            header::CACHE_CONTROL,
165            header::HeaderValue::from_static("no-store"),
166        );
167        if let Some(seconds) = self.retry_after
168            && let Ok(value) = header::HeaderValue::from_str(&seconds.to_string())
169        {
170            response.headers_mut().insert(header::RETRY_AFTER, value);
171        }
172        response
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use axum::body::to_bytes;
180
181    async fn parts(error: AdminError) -> (StatusCode, serde_json::Value, axum::http::HeaderMap) {
182        let expected_status = error.status;
183        let response = error.into_response();
184        let status = response.status();
185        assert_eq!(status, expected_status);
186        let headers = response.headers().clone();
187        let bytes = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
188        (status, serde_json::from_slice(&bytes).unwrap(), headers)
189    }
190
191    #[tokio::test]
192    async fn every_constructor_has_its_own_status_and_code() {
193        let cases: Vec<(AdminError, u16, &str)> = vec![
194            (AdminError::bad_request("nope"), 400, "bad_request"),
195            (AdminError::session_invalid(), 401, "session_invalid"),
196            (AdminError::session_expired(), 401, "session_expired"),
197            (AdminError::session_idle(), 401, "session_idle"),
198            (
199                AdminError::invalid_credentials(),
200                401,
201                "invalid_credentials",
202            ),
203            (AdminError::csrf_failed("missing"), 403, "csrf_failed"),
204            (AdminError::not_found("no such thing"), 404, "not_found"),
205            (
206                AdminError::conflict("already_revoked", "already revoked"),
207                409,
208                "already_revoked",
209            ),
210            (AdminError::rate_limited(30), 429, "rate_limited"),
211            (AdminError::signer_failed("down"), 502, "signer_failed"),
212            (AdminError::internal(), 500, "internal"),
213            (
214                AdminError::with_code(StatusCode::METHOD_NOT_ALLOWED, "method_not_allowed", "no"),
215                405,
216                "method_not_allowed",
217            ),
218        ];
219
220        for (error, expected_status, expected_code) in cases {
221            let (status, body, headers) = parts(error).await;
222            assert_eq!(status.as_u16(), expected_status, "for {expected_code}");
223            assert_eq!(body["error"], expected_code);
224            assert!(
225                body["message"].as_str().is_some_and(|m| !m.is_empty()),
226                "{expected_code} must carry a human message"
227            );
228            // Never an ACME problem document.
229            assert_eq!(
230                headers[header::CONTENT_TYPE],
231                "application/json",
232                "{expected_code} must not be application/problem+json"
233            );
234            assert_eq!(headers[header::CACHE_CONTROL], "no-store");
235            assert!(
236                !body.to_string().contains("urn:ietf:params:acme"),
237                "{expected_code} must not carry an ACME error URN"
238            );
239        }
240    }
241
242    #[tokio::test]
243    async fn only_the_rate_limiter_sets_retry_after() {
244        let (_, body, headers) = parts(AdminError::rate_limited(42)).await;
245        assert_eq!(headers[header::RETRY_AFTER], "42");
246        assert!(body["message"].as_str().unwrap().contains("42s"));
247
248        let (_, _, headers) = parts(AdminError::internal()).await;
249        assert!(!headers.contains_key(header::RETRY_AFTER));
250    }
251
252    /// The three login failures a client can provoke must be byte-identical,
253    /// or the endpoint enumerates the operator table.
254    #[tokio::test]
255    async fn every_login_failure_looks_the_same_to_the_client() {
256        let (first_status, first_body, _) = parts(AdminError::invalid_credentials()).await;
257        let (second_status, second_body, _) = parts(AdminError::invalid_credentials()).await;
258        assert_eq!(first_status, second_status);
259        assert_eq!(first_body, second_body);
260        assert_eq!(first_body["error"], "invalid_credentials");
261        // Says nothing about which half was wrong.
262        let message = first_body["message"].as_str().unwrap().to_lowercase();
263        assert!(!message.contains("no such"));
264        assert!(!message.contains("disabled"));
265        assert!(!message.contains("unknown"));
266    }
267
268    #[tokio::test]
269    async fn a_database_error_is_logged_and_answered_generically() {
270        let error = AdminError::from(sqlx::Error::RowNotFound);
271        assert_eq!(error, AdminError::internal());
272        let (status, body, _) = parts(error).await;
273        assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
274        // The sqlx message names schema; it must not reach the client.
275        assert_eq!(body["message"], "internal error");
276    }
277
278    #[test]
279    fn display_names_the_code_and_the_message() {
280        assert_eq!(
281            AdminError::not_found("no such account: acct-1").to_string(),
282            "not_found: no such account: acct-1"
283        );
284    }
285}