Skip to main content

vti_common/
error.rs

1use affinidi_tdk::secrets_resolver::errors::SecretsResolverError;
2use axum::http::StatusCode;
3use axum::response::{IntoResponse, Response};
4use tracing::{debug, warn};
5
6#[derive(Debug, thiserror::Error)]
7pub enum AppError {
8    #[error("configuration error: {0}")]
9    Config(String),
10
11    #[error("io error: {0}")]
12    Io(#[from] std::io::Error),
13
14    #[error("store error: {0}")]
15    Store(#[from] fjall::Error),
16
17    #[error("serialization error: {0}")]
18    Serialization(#[from] serde_json::Error),
19
20    #[error("internal error: {0}")]
21    Internal(String),
22
23    #[error("secret store error: {0}")]
24    SecretStore(String),
25
26    #[error("not found: {0}")]
27    NotFound(String),
28
29    #[error("conflict: {0}")]
30    Conflict(String),
31
32    /// The resource existed but has been permanently, irreversibly consumed
33    /// or removed — rendered as **410 Gone**, distinct from [`Self::NotFound`]
34    /// (never existed / not visible to this caller) and [`Self::Conflict`]
35    /// (a transient state mismatch a retry or different request could
36    /// resolve). Canonical use: the TEE Mode B bootstrap carve-out after it
37    /// has been claimed — a second `/bootstrap/request` cannot succeed no
38    /// matter what the caller sends, ever again for this VTA. The SDK's
39    /// `vta_sdk::error::VtaError::Gone` mirrors this on the client side
40    /// (`from_http` maps 410 → `Gone`) with an operator-facing hint.
41    #[error("gone: {0}")]
42    Gone(String),
43
44    #[error("secrets error: {0}")]
45    Secrets(#[from] SecretsResolverError),
46
47    #[error("authentication error: {0}")]
48    Authentication(String),
49
50    #[error("unauthorized: {0}")]
51    Unauthorized(String),
52
53    #[error("forbidden: {0}")]
54    Forbidden(String),
55
56    /// Operation requires a stepped-up (`acr=aal2`) session, but
57    /// the caller's JWT carries a lower acr (typically `aal1`).
58    /// Distinct from [`Self::Forbidden`] so wallets can react —
59    /// `step_up_required` is the operator-friendly signal to
60    /// trigger a passkey-login or VTA-approval ceremony, not a
61    /// hard rejection.
62    ///
63    /// Rendered as **403 Forbidden** with body
64    /// `{ "error": "step_up_required", "message": "...",
65    ///   "requiredAcr": "aal2" }` so clients can distinguish it
66    /// from a role-based rejection without parsing English.
67    #[error("step-up required: {0}")]
68    StepUpRequired(String),
69
70    /// The Policy Decision Point refused the request until an approval is
71    /// obtained, and is handing back what obtaining it requires.
72    ///
73    /// Distinct from [`Self::StepUpRequired`] because that variant can only say
74    /// *that* elevation is needed — it renders `{error, message, requiredAcr}`
75    /// and has nowhere to put the `approveRequest` the caller must get signed,
76    /// nor any way to express a consent requirement (approver set, threshold,
77    /// challenge). A REST caller receiving it could learn it was blocked but
78    /// not what to do about it, while the trust-task caller for the very same
79    /// decision received the full document. That asymmetry is what this closes.
80    ///
81    /// `code` is the stable machine-readable reason (`auth:step_up_required`,
82    /// `auth:consent_required`) — the field a client keys on, rather than the
83    /// HTTP status or the English message. `details` is merged into the body,
84    /// so the REST response carries exactly what the trust-task reject's
85    /// `details` carries.
86    ///
87    /// Rendered as **403 Forbidden**: the request was understood and the caller
88    /// authenticated; it is refused pending a decision they can still obtain.
89    #[error("approval required: {code}")]
90    ApprovalRequired {
91        code: &'static str,
92        details: serde_json::Value,
93    },
94
95    #[error("validation error: {0}")]
96    Validation(String),
97
98    /// The request did not carry a required `Trust-Task` header. Routes
99    /// registered via [`crate::trust_task::TrustTaskRouter::route_with_task`]
100    /// reject missing headers with this variant (400). Only `/health` is
101    /// allowed to omit it.
102    #[error("request is missing required Trust-Task header")]
103    TrustTaskMissing,
104
105    /// The request's `Trust-Task` header did not match the handler's
106    /// registered task. Returned as 415 per spec §16.2; the response body
107    /// carries the expected + received task URLs so clients can diagnose
108    /// without re-reading the route table.
109    #[error("Trust-Task header does not match handler (expected {expected})")]
110    TrustTaskMismatch {
111        expected: String,
112        received: Option<String>,
113    },
114
115    /// The supplied Trust-Task value was not a well-formed identifier
116    /// (empty, non-`https://`, or contained header-injection control
117    /// characters). Returned as 400.
118    #[error("malformed Trust-Task identifier: {0}")]
119    TrustTaskMalformed(String),
120
121    /// A request reused an `Idempotency-Key` it had previously sent
122    /// with a *different* body hash. The cached response is preserved
123    /// for the original requester; the conflicting retry is rejected
124    /// with 422 so clients don't silently get a stale response from a
125    /// drifting payload.
126    #[error("Idempotency-Key conflict: same key, different request body")]
127    IdempotencyKeyConflict,
128
129    /// A pagination cursor failed integrity verification — either the
130    /// HMAC tag didn't validate (tampered, forged, or signed under a
131    /// different community's audit_key) or the encoded form was
132    /// malformed. Returned as 400 with no extra detail so an attacker
133    /// can't learn whether their guessed cursor was structurally
134    /// close to a valid one.
135    #[error("invalid pagination cursor")]
136    InvalidCursor,
137
138    /// A bounded computation aborted because it hit a resource ceiling
139    /// (time/instruction budget or an input-size cap) before completing.
140    /// Used by the Rego policy evaluator to refuse pathological policies
141    /// or adversarial inputs on the unauthenticated join path rather than
142    /// burning CPU unbounded. Rendered as **503 Service Unavailable** — the
143    /// evaluation did not complete, and the message is generic so an
144    /// attacker can't probe the exact limits.
145    #[error("resource limit exceeded: {0}")]
146    ResourceExhausted(String),
147
148    /// Catch-all for service-specific errors (e.g., KeyDerivation, BadGateway, TeeAttestation).
149    /// Services create helper functions to construct these with appropriate status codes.
150    #[error("{message}")]
151    ServiceError { status: StatusCode, message: String },
152
153    /// An I/O failure in a vsock operation. Preserves the underlying
154    /// `std::io::Error` via `#[source]` while adding a human-readable
155    /// label of which operation failed (connect / read / write / flush).
156    ///
157    /// Construct via [`AppError::vsock`] for ergonomic `.map_err(...)`.
158    #[error("{operation} failed: {source}")]
159    Vsock {
160        operation: &'static str,
161        #[source]
162        source: std::io::Error,
163    },
164}
165
166impl AppError {
167    /// Build a closure suitable for `.map_err(...)` that wraps an
168    /// `std::io::Error` into [`AppError::Vsock`] with the given operation
169    /// label. Keeps the source chain intact for downstream error walkers
170    /// while giving log readers the operation name.
171    pub fn vsock(operation: &'static str) -> impl FnOnce(std::io::Error) -> AppError {
172        move |source| AppError::Vsock { operation, source }
173    }
174}
175
176/// Convert the canonical auth-flow errors into [`AppError`] so
177/// the route layer's existing `IntoResponse` plumbing renders
178/// them without backend-specific glue. Each variant lands on the
179/// HTTP status reflected in the [`crate::auth::AuthError`]
180/// doc-comments:
181///
182/// - `Forbidden`, `DidMethodRejected` → 403
183/// - `PendingChallengeLimitReached` → 429 via the Validation arm
184///   (route layer can return a typed 429 if needed; the canonical
185///   variant carries the rate-limit signal in the message).
186/// - `SessionNotFound`, `SessionStateMismatch`, `ChallengeMismatch`,
187///   `ChallengeExpired`, `SignerMismatch`, `StaleMessage`,
188///   `RefreshTokenInvalid`, `RefreshTokenExpired` → 401
189/// - `AttestationFailed` → 503 via Internal (TEE outages are not
190///   the caller's fault).
191/// - `Internal` → 500.
192impl From<crate::auth::backend::AuthError> for AppError {
193    fn from(e: crate::auth::backend::AuthError) -> Self {
194        use crate::auth::backend::AuthError as A;
195        match e {
196            A::Forbidden | A::DidMethodRejected => AppError::Forbidden(e.to_string()),
197            A::PendingChallengeLimitReached => AppError::Validation(e.to_string()),
198            A::SessionNotFound
199            | A::SessionStateMismatch
200            | A::ChallengeMismatch
201            | A::ChallengeExpired
202            | A::SignerMismatch
203            | A::StaleMessage
204            | A::RefreshTokenInvalid
205            | A::RefreshTokenExpired => AppError::Authentication(e.to_string()),
206            A::AttestationFailed(msg) => AppError::Internal(format!("tee attestation: {msg}")),
207            A::Internal(msg) => AppError::Internal(msg),
208        }
209    }
210}
211
212impl IntoResponse for AppError {
213    fn into_response(self) -> Response {
214        let status = match &self {
215            AppError::Config(_) => StatusCode::INTERNAL_SERVER_ERROR,
216            AppError::Io(_) => StatusCode::INTERNAL_SERVER_ERROR,
217            AppError::Store(_) => StatusCode::INTERNAL_SERVER_ERROR,
218            AppError::Serialization(_) => StatusCode::INTERNAL_SERVER_ERROR,
219            AppError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
220            AppError::SecretStore(_) => StatusCode::INTERNAL_SERVER_ERROR,
221            AppError::NotFound(_) => StatusCode::NOT_FOUND,
222            AppError::Conflict(_) => StatusCode::CONFLICT,
223            AppError::Gone(_) => StatusCode::GONE,
224            AppError::Secrets(_) => StatusCode::INTERNAL_SERVER_ERROR,
225            AppError::Authentication(_) => StatusCode::UNAUTHORIZED,
226            AppError::Unauthorized(_) => StatusCode::UNAUTHORIZED,
227            AppError::Forbidden(_) => StatusCode::FORBIDDEN,
228            AppError::StepUpRequired(_) => StatusCode::FORBIDDEN,
229            AppError::ApprovalRequired { .. } => StatusCode::FORBIDDEN,
230            AppError::Validation(_) => StatusCode::BAD_REQUEST,
231            AppError::TrustTaskMissing => StatusCode::BAD_REQUEST,
232            AppError::TrustTaskMismatch { .. } => StatusCode::UNSUPPORTED_MEDIA_TYPE,
233            AppError::TrustTaskMalformed(_) => StatusCode::BAD_REQUEST,
234            AppError::IdempotencyKeyConflict => StatusCode::UNPROCESSABLE_ENTITY,
235            AppError::InvalidCursor => StatusCode::BAD_REQUEST,
236            AppError::ResourceExhausted(_) => StatusCode::SERVICE_UNAVAILABLE,
237            AppError::ServiceError { status, .. } => *status,
238            AppError::Vsock { .. } => StatusCode::INTERNAL_SERVER_ERROR,
239        };
240
241        if status.is_server_error() {
242            warn!(status = %status.as_u16(), error = %self, "server error");
243        } else {
244            debug!(status = %status.as_u16(), error = %self, "client error");
245        }
246
247        // Trust-Task variants get structured payloads so clients can
248        // diagnose without re-reading the route table. Every other
249        // variant retains the existing `{ "error": "<display>" }` shape
250        // for backwards-compat with the workspace's existing consumers.
251        let body = match &self {
252            AppError::TrustTaskMissing => serde_json::json!({
253                "error": "TrustTaskMissing",
254                "message": self.to_string(),
255            }),
256            AppError::TrustTaskMismatch { expected, received } => serde_json::json!({
257                "error": "TrustTaskMismatch",
258                "message": self.to_string(),
259                "expected": expected,
260                "received": received,
261            }),
262            AppError::TrustTaskMalformed(value) => serde_json::json!({
263                "error": "TrustTaskMalformed",
264                "message": self.to_string(),
265                "received": value,
266            }),
267            AppError::IdempotencyKeyConflict => serde_json::json!({
268                "error": "IdempotencyKeyConflict",
269                "message": self.to_string(),
270            }),
271            AppError::StepUpRequired(msg) => serde_json::json!({
272                "error": "step_up_required",
273                "message": msg,
274                "requiredAcr": "aal2",
275            }),
276            // `details` is merged at the top level rather than nested, so the
277            // body reads the same as the trust-task reject's `details` object
278            // and a client can key on one shape across both transports. `error`
279            // is written last so a `details` carrying that key cannot displace
280            // the code the caller switches on.
281            AppError::ApprovalRequired { code, details } => {
282                let mut body = match details {
283                    serde_json::Value::Object(map) => map.clone(),
284                    _ => serde_json::Map::new(),
285                };
286                body.insert("error".to_string(), serde_json::json!(code));
287                serde_json::Value::Object(body)
288            }
289            _ => serde_json::json!({ "error": self.to_string() }),
290        };
291        (status, axum::Json(body)).into_response()
292    }
293}
294
295/// Helper to create a service-specific error for key derivation failures.
296pub fn key_derivation_error(msg: impl Into<String>) -> AppError {
297    AppError::ServiceError {
298        status: StatusCode::BAD_REQUEST,
299        message: format!("key derivation error: {}", msg.into()),
300    }
301}
302
303/// Helper to create a service-specific error for bad gateway responses.
304pub fn bad_gateway_error(msg: impl Into<String>) -> AppError {
305    AppError::ServiceError {
306        status: StatusCode::BAD_GATEWAY,
307        message: format!("bad gateway: {}", msg.into()),
308    }
309}
310
311/// Helper to create a service-specific error for TEE attestation failures.
312pub fn tee_attestation_error(msg: impl Into<String>) -> AppError {
313    AppError::ServiceError {
314        status: StatusCode::SERVICE_UNAVAILABLE,
315        message: format!("TEE attestation error: {}", msg.into()),
316    }
317}
318
319#[cfg(test)]
320mod approval_required_tests {
321    use super::*;
322    use axum::body::to_bytes;
323    use axum::response::IntoResponse;
324
325    async fn body_of(err: AppError) -> serde_json::Value {
326        let resp = err.into_response();
327        let bytes = to_bytes(resp.into_body(), usize::MAX).await.expect("body");
328        serde_json::from_slice(&bytes).expect("json body")
329    }
330
331    /// The point of the variant: a REST caller must receive the same actionable
332    /// payload the trust-task caller gets, not merely "you were blocked".
333    #[tokio::test]
334    async fn details_are_merged_alongside_the_code() {
335        let body = body_of(AppError::ApprovalRequired {
336            code: "auth:step_up_required",
337            details: serde_json::json!({
338                "requiredAcr": "aal2",
339                "approveRequest": { "id": "urn:uuid:abc" },
340            }),
341        })
342        .await;
343
344        assert_eq!(body["error"], "auth:step_up_required");
345        assert_eq!(body["requiredAcr"], "aal2");
346        assert_eq!(body["approveRequest"]["id"], "urn:uuid:abc");
347    }
348
349    #[tokio::test]
350    async fn consent_details_survive_the_round_trip() {
351        let body = body_of(AppError::ApprovalRequired {
352            code: "auth:consent_required",
353            details: serde_json::json!({
354                "approverSet": "ops",
355                "minApprovals": 2,
356                "excludeRequester": true,
357            }),
358        })
359        .await;
360
361        assert_eq!(body["error"], "auth:consent_required");
362        assert_eq!(body["minApprovals"], 2);
363        assert_eq!(body["excludeRequester"], true);
364    }
365
366    /// `details` is assembled from a policy decision, so a stray `error` key in
367    /// it must not be able to displace the code a client switches on.
368    #[tokio::test]
369    async fn details_cannot_overwrite_the_code() {
370        let body = body_of(AppError::ApprovalRequired {
371            code: "auth:consent_required",
372            details: serde_json::json!({ "error": "allow", "approverSet": "ops" }),
373        })
374        .await;
375
376        assert_eq!(body["error"], "auth:consent_required");
377        assert_eq!(body["approverSet"], "ops");
378    }
379
380    /// A non-object `details` must still yield a well-formed body rather than
381    /// panicking or emitting a bare scalar.
382    #[tokio::test]
383    async fn a_non_object_details_still_renders_the_code() {
384        let body = body_of(AppError::ApprovalRequired {
385            code: "auth:step_up_required",
386            details: serde_json::Value::Null,
387        })
388        .await;
389
390        assert_eq!(body["error"], "auth:step_up_required");
391        assert!(body.as_object().is_some_and(|m| m.len() == 1));
392    }
393
394    #[tokio::test]
395    async fn renders_as_forbidden() {
396        let resp = AppError::ApprovalRequired {
397            code: "auth:consent_required",
398            details: serde_json::json!({}),
399        }
400        .into_response();
401        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
402    }
403}
404
405#[cfg(test)]
406mod gone_tests {
407    use super::*;
408
409    /// The whole point of the variant: a consumed single-use resource (the
410    /// canonical example being the TEE Mode B bootstrap carve-out) must
411    /// render 410, not 403/409 — the SDK's `VtaError::from_http` keys its
412    /// `Gone` mapping off exactly this status code.
413    #[test]
414    fn renders_as_410_gone() {
415        let resp =
416            AppError::Gone("TEE first-boot carve-out has already been used".into()).into_response();
417        assert_eq!(resp.status(), StatusCode::GONE);
418    }
419
420    #[tokio::test]
421    async fn body_carries_the_message() {
422        use axum::body::to_bytes;
423
424        let resp = AppError::Gone("carve-out closed".into()).into_response();
425        let bytes = to_bytes(resp.into_body(), usize::MAX).await.expect("body");
426        let body: serde_json::Value = serde_json::from_slice(&bytes).expect("json body");
427        assert_eq!(body["error"], "gone: carve-out closed");
428    }
429}