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