use super::trait_::{ApprovalError, ApprovalOutcome, Gate, GateDecision, GateRequest};
use crate::approver_registry::{ApproverId, ApproverRegistry};
use async_trait::async_trait;
use dashmap::DashMap;
use ed25519_dalek::{Signature, Verifier};
use std::collections::HashSet;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Notify;
use ulid::Ulid;
pub type DualControlClassifier = Arc<dyn Fn(&str, &serde_json::Value) -> bool + Send + Sync>;
pub struct FourEyesGate {
registry: Arc<dyn ApproverRegistry>,
quorum: u8,
classifier: DualControlClassifier,
pending: Arc<DashMap<String, Arc<PendingApproval>>>,
}
struct PendingApproval {
notify: Notify,
state: parking_lot::Mutex<ApprovalState>,
}
#[derive(Default)]
struct ApprovalState {
submissions: Vec<(ApproverId, Signature, Vec<u8>)>,
outcome: Option<ApprovalOutcome>,
}
impl FourEyesGate {
#[must_use]
pub fn new(
registry: Arc<dyn ApproverRegistry>,
quorum: u8,
classifier: DualControlClassifier,
) -> Self {
Self {
registry,
quorum,
classifier,
pending: Arc::new(DashMap::new()),
}
}
#[must_use]
pub fn dual_control_tools(tools: &[&str]) -> DualControlClassifier {
let set: HashSet<String> = tools.iter().map(|s| (*s).to_string()).collect();
Arc::new(move |name: &str, _args: &serde_json::Value| set.contains(name))
}
pub fn submit_approval(
&self,
ticket: &str,
approver: ApproverId,
signature: Signature,
signed_payload: Vec<u8>,
) -> Result<(), ApprovalError> {
let entry = self
.pending
.get(ticket)
.ok_or_else(|| ApprovalError::VerificationFailed("unknown ticket".into()))?
.clone();
let mut state = entry.state.lock();
state
.submissions
.push((approver, signature, signed_payload));
let unique_count = state
.submissions
.iter()
.map(|(a, _, _)| a)
.collect::<HashSet<_>>()
.len();
if unique_count < self.quorum as usize {
return Ok(());
}
let mut verified_ids: HashSet<ApproverId> = HashSet::new();
for (approver, sig, payload) in &state.submissions {
let Some(vk) = self.registry.lookup(approver) else {
let reason = format!("approver `{approver}` not in registry");
state.outcome = Some(ApprovalOutcome::Deny {
code: "approver.unknown".into(),
reason: reason.clone(),
});
entry.notify.notify_waiters();
return Err(ApprovalError::VerificationFailed(reason));
};
if vk.verify(payload, sig).is_err() {
let reason = format!("approver `{approver}` signature math-invalid");
state.outcome = Some(ApprovalOutcome::Deny {
code: "approver.bad_signature".into(),
reason: reason.clone(),
});
entry.notify.notify_waiters();
return Err(ApprovalError::VerificationFailed(reason));
}
verified_ids.insert(approver.clone());
}
if verified_ids.len() < self.quorum as usize {
return Err(ApprovalError::VerificationFailed(format!(
"quorum {} not met after unique-identity dedup; got {}",
self.quorum,
verified_ids.len()
)));
}
state.outcome = Some(ApprovalOutcome::Allow);
entry.notify.notify_waiters();
Ok(())
}
pub fn deny_approval(&self, ticket: &str, reason: impl Into<String>) {
if let Some(entry) = self.pending.get(ticket) {
let mut state = entry.state.lock();
state.outcome = Some(ApprovalOutcome::Deny {
code: "operator.deny".into(),
reason: reason.into(),
});
entry.notify.notify_waiters();
}
}
}
#[async_trait]
impl Gate for FourEyesGate {
async fn evaluate(&self, req: GateRequest) -> GateDecision {
if !(self.classifier)(&req.tool_name, &req.args) {
return GateDecision::Allow;
}
let ticket = format!("esc_{}", Ulid::new());
self.pending.insert(
ticket.clone(),
Arc::new(PendingApproval {
notify: Notify::new(),
state: parking_lot::Mutex::new(ApprovalState::default()),
}),
);
GateDecision::RequireApproval {
ticket,
quorum: self.quorum,
}
}
fn name(&self) -> &'static str {
"FourEyesGate"
}
fn may_require_approval(&self) -> bool {
true
}
async fn wait_for_approval(
&self,
ticket: String,
timeout: Duration,
) -> Result<ApprovalOutcome, ApprovalError> {
let entry = match self.pending.get(&ticket) {
Some(e) => e.clone(),
None => return Err(ApprovalError::VerificationFailed("unknown ticket".into())),
};
let outcome = tokio::time::timeout(timeout, async {
loop {
if let Some(out) = entry.state.lock().outcome.clone() {
return out;
}
entry.notify.notified().await;
}
})
.await
.map_err(|_| ApprovalError::TimedOut {
millis: timeout.as_millis() as u64,
})?;
self.pending.remove(&ticket);
match &outcome {
ApprovalOutcome::Allow => Ok(outcome),
ApprovalOutcome::Deny { reason, .. } => Err(ApprovalError::Denied(reason.clone())),
}
}
}