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    #[error("validation error: {0}")]
59    Validation(String),
60
61    /// The request did not carry a required `Trust-Task` header. Routes
62    /// registered via [`crate::trust_task::TrustTaskRouter::route_with_task`]
63    /// reject missing headers with this variant (400). Only `/health` is
64    /// allowed to omit it.
65    #[error("request is missing required Trust-Task header")]
66    TrustTaskMissing,
67
68    /// The request's `Trust-Task` header did not match the handler's
69    /// registered task. Returned as 415 per spec §16.2; the response body
70    /// carries the expected + received task URLs so clients can diagnose
71    /// without re-reading the route table.
72    #[error("Trust-Task header does not match handler (expected {expected})")]
73    TrustTaskMismatch {
74        expected: String,
75        received: Option<String>,
76    },
77
78    /// The supplied Trust-Task value was not a well-formed identifier
79    /// (empty, non-`https://`, or contained header-injection control
80    /// characters). Returned as 400.
81    #[error("malformed Trust-Task identifier: {0}")]
82    TrustTaskMalformed(String),
83
84    /// A request reused an `Idempotency-Key` it had previously sent
85    /// with a *different* body hash. The cached response is preserved
86    /// for the original requester; the conflicting retry is rejected
87    /// with 422 so clients don't silently get a stale response from a
88    /// drifting payload.
89    #[error("Idempotency-Key conflict: same key, different request body")]
90    IdempotencyKeyConflict,
91
92    /// A pagination cursor failed integrity verification — either the
93    /// HMAC tag didn't validate (tampered, forged, or signed under a
94    /// different community's audit_key) or the encoded form was
95    /// malformed. Returned as 400 with no extra detail so an attacker
96    /// can't learn whether their guessed cursor was structurally
97    /// close to a valid one.
98    #[error("invalid pagination cursor")]
99    InvalidCursor,
100
101    /// A bounded computation aborted because it hit a resource ceiling
102    /// (time/instruction budget or an input-size cap) before completing.
103    /// Used by the Rego policy evaluator to refuse pathological policies
104    /// or adversarial inputs on the unauthenticated join path rather than
105    /// burning CPU unbounded. Rendered as **503 Service Unavailable** — the
106    /// evaluation did not complete, and the message is generic so an
107    /// attacker can't probe the exact limits.
108    #[error("resource limit exceeded: {0}")]
109    ResourceExhausted(String),
110
111    /// Catch-all for service-specific errors (e.g., KeyDerivation, BadGateway, TeeAttestation).
112    /// Services create helper functions to construct these with appropriate status codes.
113    #[error("{message}")]
114    ServiceError { status: StatusCode, message: String },
115
116    /// An I/O failure in a vsock operation. Preserves the underlying
117    /// `std::io::Error` via `#[source]` while adding a human-readable
118    /// label of which operation failed (connect / read / write / flush).
119    ///
120    /// Construct via [`AppError::vsock`] for ergonomic `.map_err(...)`.
121    #[error("{operation} failed: {source}")]
122    Vsock {
123        operation: &'static str,
124        #[source]
125        source: std::io::Error,
126    },
127}
128
129impl AppError {
130    /// Build a closure suitable for `.map_err(...)` that wraps an
131    /// `std::io::Error` into [`AppError::Vsock`] with the given operation
132    /// label. Keeps the source chain intact for downstream error walkers
133    /// while giving log readers the operation name.
134    pub fn vsock(operation: &'static str) -> impl FnOnce(std::io::Error) -> AppError {
135        move |source| AppError::Vsock { operation, source }
136    }
137}
138
139/// Convert the canonical auth-flow errors into [`AppError`] so
140/// the route layer's existing `IntoResponse` plumbing renders
141/// them without backend-specific glue. Each variant lands on the
142/// HTTP status reflected in the [`crate::auth::AuthError`]
143/// doc-comments:
144///
145/// - `Forbidden`, `DidMethodRejected` → 403
146/// - `PendingChallengeLimitReached` → 429 via the Validation arm
147///   (route layer can return a typed 429 if needed; the canonical
148///   variant carries the rate-limit signal in the message).
149/// - `SessionNotFound`, `SessionStateMismatch`, `ChallengeMismatch`,
150///   `ChallengeExpired`, `SignerMismatch`, `StaleMessage`,
151///   `RefreshTokenInvalid`, `RefreshTokenExpired` → 401
152/// - `AttestationFailed` → 503 via Internal (TEE outages are not
153///   the caller's fault).
154/// - `Internal` → 500.
155impl From<crate::auth::backend::AuthError> for AppError {
156    fn from(e: crate::auth::backend::AuthError) -> Self {
157        use crate::auth::backend::AuthError as A;
158        match e {
159            A::Forbidden | A::DidMethodRejected => AppError::Forbidden(e.to_string()),
160            A::PendingChallengeLimitReached => AppError::Validation(e.to_string()),
161            A::SessionNotFound
162            | A::SessionStateMismatch
163            | A::ChallengeMismatch
164            | A::ChallengeExpired
165            | A::SignerMismatch
166            | A::StaleMessage
167            | A::RefreshTokenInvalid
168            | A::RefreshTokenExpired => AppError::Authentication(e.to_string()),
169            A::AttestationFailed(msg) => AppError::Internal(format!("tee attestation: {msg}")),
170            A::Internal(msg) => AppError::Internal(msg),
171        }
172    }
173}
174
175impl IntoResponse for AppError {
176    fn into_response(self) -> Response {
177        let status = match &self {
178            AppError::Config(_) => StatusCode::INTERNAL_SERVER_ERROR,
179            AppError::Io(_) => StatusCode::INTERNAL_SERVER_ERROR,
180            AppError::Store(_) => StatusCode::INTERNAL_SERVER_ERROR,
181            AppError::Serialization(_) => StatusCode::INTERNAL_SERVER_ERROR,
182            AppError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
183            AppError::SecretStore(_) => StatusCode::INTERNAL_SERVER_ERROR,
184            AppError::NotFound(_) => StatusCode::NOT_FOUND,
185            AppError::Conflict(_) => StatusCode::CONFLICT,
186            AppError::Secrets(_) => StatusCode::INTERNAL_SERVER_ERROR,
187            AppError::Authentication(_) => StatusCode::UNAUTHORIZED,
188            AppError::Unauthorized(_) => StatusCode::UNAUTHORIZED,
189            AppError::Forbidden(_) => StatusCode::FORBIDDEN,
190            AppError::StepUpRequired(_) => StatusCode::FORBIDDEN,
191            AppError::Validation(_) => StatusCode::BAD_REQUEST,
192            AppError::TrustTaskMissing => StatusCode::BAD_REQUEST,
193            AppError::TrustTaskMismatch { .. } => StatusCode::UNSUPPORTED_MEDIA_TYPE,
194            AppError::TrustTaskMalformed(_) => StatusCode::BAD_REQUEST,
195            AppError::IdempotencyKeyConflict => StatusCode::UNPROCESSABLE_ENTITY,
196            AppError::InvalidCursor => StatusCode::BAD_REQUEST,
197            AppError::ResourceExhausted(_) => StatusCode::SERVICE_UNAVAILABLE,
198            AppError::ServiceError { status, .. } => *status,
199            AppError::Vsock { .. } => StatusCode::INTERNAL_SERVER_ERROR,
200        };
201
202        if status.is_server_error() {
203            warn!(status = %status.as_u16(), error = %self, "server error");
204        } else {
205            debug!(status = %status.as_u16(), error = %self, "client error");
206        }
207
208        // Trust-Task variants get structured payloads so clients can
209        // diagnose without re-reading the route table. Every other
210        // variant retains the existing `{ "error": "<display>" }` shape
211        // for backwards-compat with the workspace's existing consumers.
212        let body = match &self {
213            AppError::TrustTaskMissing => serde_json::json!({
214                "error": "TrustTaskMissing",
215                "message": self.to_string(),
216            }),
217            AppError::TrustTaskMismatch { expected, received } => serde_json::json!({
218                "error": "TrustTaskMismatch",
219                "message": self.to_string(),
220                "expected": expected,
221                "received": received,
222            }),
223            AppError::TrustTaskMalformed(value) => serde_json::json!({
224                "error": "TrustTaskMalformed",
225                "message": self.to_string(),
226                "received": value,
227            }),
228            AppError::IdempotencyKeyConflict => serde_json::json!({
229                "error": "IdempotencyKeyConflict",
230                "message": self.to_string(),
231            }),
232            AppError::StepUpRequired(msg) => serde_json::json!({
233                "error": "step_up_required",
234                "message": msg,
235                "requiredAcr": "aal2",
236            }),
237            _ => serde_json::json!({ "error": self.to_string() }),
238        };
239        (status, axum::Json(body)).into_response()
240    }
241}
242
243/// Helper to create a service-specific error for key derivation failures.
244pub fn key_derivation_error(msg: impl Into<String>) -> AppError {
245    AppError::ServiceError {
246        status: StatusCode::BAD_REQUEST,
247        message: format!("key derivation error: {}", msg.into()),
248    }
249}
250
251/// Helper to create a service-specific error for bad gateway responses.
252pub fn bad_gateway_error(msg: impl Into<String>) -> AppError {
253    AppError::ServiceError {
254        status: StatusCode::BAD_GATEWAY,
255        message: format!("bad gateway: {}", msg.into()),
256    }
257}
258
259/// Helper to create a service-specific error for TEE attestation failures.
260pub fn tee_attestation_error(msg: impl Into<String>) -> AppError {
261    AppError::ServiceError {
262        status: StatusCode::SERVICE_UNAVAILABLE,
263        message: format!("TEE attestation error: {}", msg.into()),
264    }
265}