use std::sync::Arc;
use chio_core_types::capability::GovernedApprovalToken;
use chio_core_types::crypto::PublicKey;
use chio_kernel::{
resume_with_decision, ApprovalDecision, ApprovalFilter, ApprovalOutcome, ApprovalRequest,
ApprovalStore, ApprovalStoreError, ApprovalToken, KernelError, ResolvedApproval,
};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone)]
pub enum ApprovalHandlerError {
BadRequest(String),
NotFound(String),
Conflict(String),
ReplayDetected(String),
Rejected(String),
Internal(String),
}
impl ApprovalHandlerError {
#[must_use]
pub fn status(&self) -> u16 {
match self {
Self::BadRequest(_) => 400,
Self::NotFound(_) => 404,
Self::Conflict(_) => 409,
Self::ReplayDetected(_) => 409,
Self::Rejected(_) => 403,
Self::Internal(_) => 500,
}
}
#[must_use]
pub fn code(&self) -> &'static str {
match self {
Self::BadRequest(_) => "bad_request",
Self::NotFound(_) => "not_found",
Self::Conflict(_) => "conflict",
Self::ReplayDetected(_) => "replay_detected",
Self::Rejected(_) => "approval_rejected",
Self::Internal(_) => "internal_error",
}
}
#[must_use]
pub fn message(&self) -> String {
match self {
Self::BadRequest(m)
| Self::NotFound(m)
| Self::Conflict(m)
| Self::ReplayDetected(m)
| Self::Rejected(m)
| Self::Internal(m) => m.clone(),
}
}
#[must_use]
pub fn body(&self) -> serde_json::Value {
serde_json::json!({
"error": self.code(),
"message": self.message(),
})
}
}
impl std::fmt::Display for ApprovalHandlerError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.code(), self.message())
}
}
impl std::error::Error for ApprovalHandlerError {}
impl From<ApprovalStoreError> for ApprovalHandlerError {
fn from(e: ApprovalStoreError) -> Self {
match e {
ApprovalStoreError::NotFound(m) => Self::NotFound(m),
ApprovalStoreError::AlreadyResolved(m) => {
Self::Conflict(format!("already resolved: {m}"))
}
ApprovalStoreError::Replay(m) => Self::ReplayDetected(m),
ApprovalStoreError::Backend(m) => Self::Internal(m),
ApprovalStoreError::Serialization(m) => Self::Internal(m),
}
}
}
impl From<KernelError> for ApprovalHandlerError {
fn from(e: KernelError) -> Self {
match e {
KernelError::ApprovalRejected(m) => {
if m.contains("replay") {
Self::ReplayDetected(m)
} else {
Self::Rejected(m)
}
}
other => Self::Internal(other.to_string()),
}
}
}
#[derive(Clone)]
pub struct ApprovalAdmin {
store: Arc<dyn ApprovalStore>,
}
impl std::fmt::Debug for ApprovalAdmin {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ApprovalAdmin").finish_non_exhaustive()
}
}
impl ApprovalAdmin {
#[must_use]
pub fn new(store: Arc<dyn ApprovalStore>) -> Self {
Self { store }
}
#[must_use]
pub fn store(&self) -> &Arc<dyn ApprovalStore> {
&self.store
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PendingQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subject_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_server: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub not_expired_at: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<usize>,
}
impl From<PendingQuery> for ApprovalFilter {
fn from(q: PendingQuery) -> Self {
Self {
subject_id: q.subject_id,
tool_server: q.tool_server,
tool_name: q.tool_name,
not_expired_at: q.not_expired_at,
limit: q.limit,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PendingListResponse {
pub approvals: Vec<ApprovalRequest>,
pub count: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RespondRequest {
pub outcome: ApprovalOutcome,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
pub approver: PublicKey,
pub token: GovernedApprovalToken,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RespondResponse {
pub approval_id: String,
pub outcome: ApprovalOutcome,
pub resolved_at: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatchRespondRequest {
pub decisions: Vec<BatchDecisionEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatchDecisionEntry {
pub approval_id: String,
pub outcome: ApprovalOutcome,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
pub approver: PublicKey,
pub token: GovernedApprovalToken,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatchRespondResponse {
pub results: Vec<BatchRespondResult>,
pub summary: BatchRespondSummary,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatchRespondResult {
pub approval_id: String,
pub status: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub outcome: Option<ApprovalOutcome>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatchRespondSummary {
pub total: usize,
pub approved: usize,
pub denied: usize,
pub rejected: usize,
}
pub fn handle_list_pending(
admin: &ApprovalAdmin,
query: PendingQuery,
) -> Result<PendingListResponse, ApprovalHandlerError> {
let filter: ApprovalFilter = query.into();
let approvals = admin.store.list_pending(&filter)?;
let count = approvals.len();
Ok(PendingListResponse { approvals, count })
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetApprovalResponse {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pending: Option<ApprovalRequest>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resolution: Option<ResolvedApproval>,
}
pub fn handle_get_approval(
admin: &ApprovalAdmin,
approval_id: &str,
) -> Result<GetApprovalResponse, ApprovalHandlerError> {
let pending = admin.store.get_pending(approval_id)?;
let resolution = admin.store.get_resolution(approval_id)?;
if pending.is_none() && resolution.is_none() {
return Err(ApprovalHandlerError::NotFound(approval_id.to_string()));
}
Ok(GetApprovalResponse {
pending,
resolution,
})
}
pub fn handle_respond(
admin: &ApprovalAdmin,
approval_id: &str,
body: RespondRequest,
now: u64,
) -> Result<RespondResponse, ApprovalHandlerError> {
if body.token.request_id != approval_id {
return Err(ApprovalHandlerError::BadRequest(format!(
"approval_id {approval_id} does not match signed token request_id {}",
body.token.request_id
)));
}
let decision = ApprovalDecision {
approval_id: approval_id.to_string(),
outcome: body.outcome.clone(),
reason: body.reason,
approver: body.approver.clone(),
token: body.token,
received_at: now,
};
let outcome = resume_with_decision(admin.store.as_ref(), &decision, now)?;
let approval_token = ApprovalToken::from_decision(&decision);
let _ = approval_token;
Ok(RespondResponse {
approval_id: approval_id.to_string(),
outcome,
resolved_at: now,
})
}
pub fn handle_batch_respond(
admin: &ApprovalAdmin,
body: BatchRespondRequest,
now: u64,
) -> Result<BatchRespondResponse, ApprovalHandlerError> {
if body.decisions.is_empty() {
return Err(ApprovalHandlerError::BadRequest(
"batch respond requires at least one decision".into(),
));
}
let mut results = Vec::with_capacity(body.decisions.len());
let mut approved = 0usize;
let mut denied = 0usize;
let mut rejected = 0usize;
for entry in body.decisions {
let approval_id = entry.approval_id.clone();
if entry.token.request_id != approval_id {
rejected += 1;
results.push(BatchRespondResult {
approval_id,
status: "rejected".into(),
outcome: None,
error: Some(format!(
"token request_id {} mismatches approval_id",
entry.token.request_id
)),
});
continue;
}
let decision = ApprovalDecision {
approval_id: approval_id.clone(),
outcome: entry.outcome.clone(),
reason: entry.reason,
approver: entry.approver,
token: entry.token,
received_at: now,
};
match resume_with_decision(admin.store.as_ref(), &decision, now) {
Ok(outcome) => {
match outcome {
ApprovalOutcome::Approved => approved += 1,
ApprovalOutcome::Denied => denied += 1,
}
results.push(BatchRespondResult {
approval_id,
status: "resolved".into(),
outcome: Some(outcome),
error: None,
});
}
Err(e) => {
rejected += 1;
let handler_err: ApprovalHandlerError = e.into();
results.push(BatchRespondResult {
approval_id,
status: "rejected".into(),
outcome: None,
error: Some(handler_err.message()),
});
}
}
}
let total = results.len();
Ok(BatchRespondResponse {
results,
summary: BatchRespondSummary {
total,
approved,
denied,
rejected,
},
})
}