Skip to main content

chio_http_core/
approvals.rs

1//! Phase 3.4-3.6 HITL approval HTTP surface.
2//!
3//! Substrate-agnostic handlers for the four approval endpoints:
4//!
5//! | Method | Path                            | Handler |
6//! |--------|---------------------------------|---------|
7//! | GET    | `/approvals/pending`            | [`handle_list_pending`] |
8//! | GET    | `/approvals/{id}`               | [`handle_get_approval`] |
9//! | POST   | `/approvals/{id}/respond`       | [`handle_respond`] |
10//! | POST   | `/approvals/batch/respond`      | [`handle_batch_respond`] |
11//!
12//! Each handler accepts parsed inputs and returns a typed response so
13//! `chio-tower`, `chio-api-protect`, and hosted sidecars can serve them
14//! without agreeing on a framework. Errors carry HTTP status codes via
15//! [`ApprovalHandlerError::status`] for predictable mapping.
16
17use std::sync::Arc;
18
19use chio_core_types::capability::GovernedApprovalToken;
20use chio_core_types::crypto::PublicKey;
21use chio_kernel::{
22    resume_with_decision, ApprovalDecision, ApprovalFilter, ApprovalOutcome, ApprovalRequest,
23    ApprovalStore, ApprovalStoreError, ApprovalToken, KernelError, ResolvedApproval,
24};
25use serde::{Deserialize, Serialize};
26
27/// Errors returned by the approval handlers. Each variant maps onto a
28/// stable HTTP status so substrate adapters can relay the code without
29/// re-interpreting the semantics.
30#[derive(Debug, Clone)]
31pub enum ApprovalHandlerError {
32    /// Request body could not be parsed into the expected JSON shape.
33    BadRequest(String),
34    /// Target approval id does not exist in the store.
35    NotFound(String),
36    /// Approval was already resolved (single-response rule).
37    Conflict(String),
38    /// Replay detected: the signed token has already been consumed.
39    ReplayDetected(String),
40    /// Approval token failed binding / signature / time checks.
41    Rejected(String),
42    /// Backend store surfaced an internal error.
43    Internal(String),
44}
45
46impl ApprovalHandlerError {
47    #[must_use]
48    pub fn status(&self) -> u16 {
49        match self {
50            Self::BadRequest(_) => 400,
51            Self::NotFound(_) => 404,
52            Self::Conflict(_) => 409,
53            Self::ReplayDetected(_) => 409,
54            Self::Rejected(_) => 403,
55            Self::Internal(_) => 500,
56        }
57    }
58
59    #[must_use]
60    pub fn code(&self) -> &'static str {
61        match self {
62            Self::BadRequest(_) => "bad_request",
63            Self::NotFound(_) => "not_found",
64            Self::Conflict(_) => "conflict",
65            Self::ReplayDetected(_) => "replay_detected",
66            Self::Rejected(_) => "approval_rejected",
67            Self::Internal(_) => "internal_error",
68        }
69    }
70
71    #[must_use]
72    pub fn message(&self) -> String {
73        match self {
74            Self::BadRequest(m)
75            | Self::NotFound(m)
76            | Self::Conflict(m)
77            | Self::ReplayDetected(m)
78            | Self::Rejected(m)
79            | Self::Internal(m) => m.clone(),
80        }
81    }
82
83    #[must_use]
84    pub fn body(&self) -> serde_json::Value {
85        serde_json::json!({
86            "error": self.code(),
87            "message": self.message(),
88        })
89    }
90}
91
92impl std::fmt::Display for ApprovalHandlerError {
93    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        write!(f, "{}: {}", self.code(), self.message())
95    }
96}
97
98impl std::error::Error for ApprovalHandlerError {}
99
100impl From<ApprovalStoreError> for ApprovalHandlerError {
101    fn from(e: ApprovalStoreError) -> Self {
102        match e {
103            ApprovalStoreError::NotFound(m) => Self::NotFound(m),
104            ApprovalStoreError::AlreadyResolved(m) => {
105                Self::Conflict(format!("already resolved: {m}"))
106            }
107            ApprovalStoreError::Replay(m) => Self::ReplayDetected(m),
108            ApprovalStoreError::Backend(m) => Self::Internal(m),
109            ApprovalStoreError::Serialization(m) => Self::Internal(m),
110        }
111    }
112}
113
114impl From<KernelError> for ApprovalHandlerError {
115    fn from(e: KernelError) -> Self {
116        match e {
117            KernelError::ApprovalRejected(m) => {
118                if m.contains("replay") {
119                    Self::ReplayDetected(m)
120                } else {
121                    Self::Rejected(m)
122                }
123            }
124            other => Self::Internal(other.to_string()),
125        }
126    }
127}
128
129/// Admin handle bound to the kernel's approval store.
130#[derive(Clone)]
131pub struct ApprovalAdmin {
132    store: Arc<dyn ApprovalStore>,
133}
134
135impl std::fmt::Debug for ApprovalAdmin {
136    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137        f.debug_struct("ApprovalAdmin").finish_non_exhaustive()
138    }
139}
140
141impl ApprovalAdmin {
142    #[must_use]
143    pub fn new(store: Arc<dyn ApprovalStore>) -> Self {
144        Self { store }
145    }
146
147    #[must_use]
148    pub fn store(&self) -> &Arc<dyn ApprovalStore> {
149        &self.store
150    }
151}
152
153// ----- Wire shapes --------------------------------------------------
154
155/// Query parameters for `GET /approvals/pending`.
156#[derive(Debug, Clone, Default, Serialize, Deserialize)]
157pub struct PendingQuery {
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub subject_id: Option<String>,
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub tool_server: Option<String>,
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub tool_name: Option<String>,
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub not_expired_at: Option<u64>,
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub limit: Option<usize>,
168}
169
170impl From<PendingQuery> for ApprovalFilter {
171    fn from(q: PendingQuery) -> Self {
172        Self {
173            subject_id: q.subject_id,
174            tool_server: q.tool_server,
175            tool_name: q.tool_name,
176            not_expired_at: q.not_expired_at,
177            limit: q.limit,
178        }
179    }
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct PendingListResponse {
184    pub approvals: Vec<ApprovalRequest>,
185    pub count: usize,
186}
187
188/// Body for `POST /approvals/{id}/respond`.
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct RespondRequest {
191    pub outcome: ApprovalOutcome,
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub reason: Option<String>,
194    pub approver: PublicKey,
195    pub token: GovernedApprovalToken,
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct RespondResponse {
200    pub approval_id: String,
201    pub outcome: ApprovalOutcome,
202    pub resolved_at: u64,
203}
204
205/// Body for `POST /approvals/batch/respond`.
206#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct BatchRespondRequest {
208    pub decisions: Vec<BatchDecisionEntry>,
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct BatchDecisionEntry {
213    pub approval_id: String,
214    pub outcome: ApprovalOutcome,
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub reason: Option<String>,
217    pub approver: PublicKey,
218    pub token: GovernedApprovalToken,
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize)]
222pub struct BatchRespondResponse {
223    pub results: Vec<BatchRespondResult>,
224    pub summary: BatchRespondSummary,
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct BatchRespondResult {
229    pub approval_id: String,
230    pub status: String,
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub outcome: Option<ApprovalOutcome>,
233    #[serde(default, skip_serializing_if = "Option::is_none")]
234    pub error: Option<String>,
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct BatchRespondSummary {
239    pub total: usize,
240    pub approved: usize,
241    pub denied: usize,
242    pub rejected: usize,
243}
244
245// ----- Handlers -----------------------------------------------------
246
247/// `GET /approvals/pending` -- list pending approvals matching the
248/// filter. Returns a stable JSON shape.
249pub fn handle_list_pending(
250    admin: &ApprovalAdmin,
251    query: PendingQuery,
252) -> Result<PendingListResponse, ApprovalHandlerError> {
253    let filter: ApprovalFilter = query.into();
254    let approvals = admin.store.list_pending(&filter)?;
255    let count = approvals.len();
256    Ok(PendingListResponse { approvals, count })
257}
258
259/// `GET /approvals/{id}`.
260///
261/// Returns the pending record if still outstanding; otherwise returns
262/// the resolved record. Adapters may encode "resolved" via the
263/// `resolution` field so callers can tell the two states apart without
264/// extra round trips.
265#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct GetApprovalResponse {
267    #[serde(default, skip_serializing_if = "Option::is_none")]
268    pub pending: Option<ApprovalRequest>,
269    #[serde(default, skip_serializing_if = "Option::is_none")]
270    pub resolution: Option<ResolvedApproval>,
271}
272
273pub fn handle_get_approval(
274    admin: &ApprovalAdmin,
275    approval_id: &str,
276) -> Result<GetApprovalResponse, ApprovalHandlerError> {
277    let pending = admin.store.get_pending(approval_id)?;
278    let resolution = admin.store.get_resolution(approval_id)?;
279    if pending.is_none() && resolution.is_none() {
280        return Err(ApprovalHandlerError::NotFound(approval_id.to_string()));
281    }
282    Ok(GetApprovalResponse {
283        pending,
284        resolution,
285    })
286}
287
288/// `POST /approvals/{id}/respond` -- submit an approval decision.
289pub fn handle_respond(
290    admin: &ApprovalAdmin,
291    approval_id: &str,
292    body: RespondRequest,
293    now: u64,
294) -> Result<RespondResponse, ApprovalHandlerError> {
295    // The approval_id in the URL must agree with the token the human
296    // signed, otherwise the signed binding is wrong and we cannot
297    // authorize resume.
298    if body.token.request_id != approval_id {
299        return Err(ApprovalHandlerError::BadRequest(format!(
300            "approval_id {approval_id} does not match signed token request_id {}",
301            body.token.request_id
302        )));
303    }
304
305    let decision = ApprovalDecision {
306        approval_id: approval_id.to_string(),
307        outcome: body.outcome.clone(),
308        reason: body.reason,
309        approver: body.approver.clone(),
310        token: body.token,
311        received_at: now,
312    };
313
314    let outcome = resume_with_decision(admin.store.as_ref(), &decision, now)?;
315
316    // Defense-in-depth: the ApprovalToken is now consumed; exercise
317    // the replay guard immediately so operators can trust the store
318    // wrote the record.
319    let approval_token = ApprovalToken::from_decision(&decision);
320    let _ = approval_token; // consumed; flagged via resume_with_decision.
321
322    Ok(RespondResponse {
323        approval_id: approval_id.to_string(),
324        outcome,
325        resolved_at: now,
326    })
327}
328
329/// `POST /approvals/batch/respond` -- apply decisions to multiple
330/// approvals in one call.
331pub fn handle_batch_respond(
332    admin: &ApprovalAdmin,
333    body: BatchRespondRequest,
334    now: u64,
335) -> Result<BatchRespondResponse, ApprovalHandlerError> {
336    if body.decisions.is_empty() {
337        return Err(ApprovalHandlerError::BadRequest(
338            "batch respond requires at least one decision".into(),
339        ));
340    }
341
342    let mut results = Vec::with_capacity(body.decisions.len());
343    let mut approved = 0usize;
344    let mut denied = 0usize;
345    let mut rejected = 0usize;
346
347    for entry in body.decisions {
348        let approval_id = entry.approval_id.clone();
349        if entry.token.request_id != approval_id {
350            rejected += 1;
351            results.push(BatchRespondResult {
352                approval_id,
353                status: "rejected".into(),
354                outcome: None,
355                error: Some(format!(
356                    "token request_id {} mismatches approval_id",
357                    entry.token.request_id
358                )),
359            });
360            continue;
361        }
362
363        let decision = ApprovalDecision {
364            approval_id: approval_id.clone(),
365            outcome: entry.outcome.clone(),
366            reason: entry.reason,
367            approver: entry.approver,
368            token: entry.token,
369            received_at: now,
370        };
371
372        match resume_with_decision(admin.store.as_ref(), &decision, now) {
373            Ok(outcome) => {
374                match outcome {
375                    ApprovalOutcome::Approved => approved += 1,
376                    ApprovalOutcome::Denied => denied += 1,
377                }
378                results.push(BatchRespondResult {
379                    approval_id,
380                    status: "resolved".into(),
381                    outcome: Some(outcome),
382                    error: None,
383                });
384            }
385            Err(e) => {
386                rejected += 1;
387                let handler_err: ApprovalHandlerError = e.into();
388                results.push(BatchRespondResult {
389                    approval_id,
390                    status: "rejected".into(),
391                    outcome: None,
392                    error: Some(handler_err.message()),
393                });
394            }
395        }
396    }
397
398    let total = results.len();
399    Ok(BatchRespondResponse {
400        results,
401        summary: BatchRespondSummary {
402            total,
403            approved,
404            denied,
405            rejected,
406        },
407    })
408}