Skip to main content

acme_proxy_admin/webadmin/
error.rs

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