use super::trait_::{ApprovalError, ApprovalOutcome, Gate, GateDecision, GateRequest};
use crate::approver_registry::{ApproverId, ApproverRegistry};
use crate::escalation::Severity;
use async_trait::async_trait;
use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine as _;
use bytes::Bytes;
use dashmap::DashMap;
use ed25519_dalek::{Signature, Verifier};
use klieo_core::error::BusError;
use klieo_core::KvStore;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Notify;
use ulid::Ulid;
const KV_BUCKET: &str = "ops.four_eyes.pending";
const CAS_MAX_RETRIES: u8 = 5;
const POLL_INTERVAL_MS: u64 = 250;
const POLL_JITTER_MS: u64 = 50;
const SYNC_BRIDGE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
pub type DualControlClassifier = Arc<dyn Fn(&str, &serde_json::Value) -> bool + Send + Sync>;
#[derive(Clone, Debug, Serialize, Deserialize)]
struct Submission {
approver: String,
signature_hex: String,
payload_b64: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
enum KvOutcome {
Allow,
Deny { code: String, reason: String },
}
#[derive(Clone, Debug, Serialize, Deserialize)]
struct PendingApprovalState {
severity: Severity,
submissions: Vec<Submission>,
outcome: Option<KvOutcome>,
}
impl PendingApprovalState {
fn new(severity: Severity) -> Self {
Self {
severity,
submissions: Vec::new(),
outcome: None,
}
}
}
struct LocalNotify {
notify: Notify,
}
impl LocalNotify {
fn new() -> Arc<Self> {
Arc::new(Self {
notify: Notify::new(),
})
}
}
pub struct FourEyesGate {
kv: Arc<dyn KvStore>,
registry: Arc<dyn ApproverRegistry>,
quorum: u8,
classifier: DualControlClassifier,
local_notifies: Arc<DashMap<String, Arc<LocalNotify>>>,
severity: Severity,
sync_bridge_timeout: Duration,
}
impl FourEyesGate {
#[must_use]
pub fn new(
kv: Arc<dyn KvStore>,
registry: Arc<dyn ApproverRegistry>,
quorum: u8,
classifier: DualControlClassifier,
) -> Self {
Self {
kv,
registry,
quorum,
classifier,
local_notifies: Arc::new(DashMap::new()),
severity: Severity::High,
sync_bridge_timeout: SYNC_BRIDGE_TIMEOUT,
}
}
#[cfg(any(test, feature = "test-utils"))]
#[must_use]
pub fn with_sync_bridge_timeout(mut self, d: Duration) -> Self {
self.sync_bridge_timeout = d;
self
}
#[must_use]
pub fn with_severity(mut self, severity: Severity) -> Self {
self.severity = severity;
self
}
pub fn severity(&self) -> Severity {
self.severity
}
pub fn cancel_pending(&self, ticket: &str) {
self.local_notifies.remove(ticket);
let kv = self.kv.clone();
let ticket = ticket.to_string();
tokio::spawn(async move {
if let Err(err) = kv.delete(KV_BUCKET, &ticket).await {
tracing::warn!(
target: "klieo.ops.four_eyes",
ticket = %ticket,
error = %err,
"cancel_pending KV delete failed; ticket may leak into stale pending state"
);
}
});
}
#[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 kv = self.kv.clone();
let registry = self.registry.clone();
let quorum = self.quorum;
let ticket_str = ticket.to_string();
let local_notifies = self.local_notifies.clone();
run_sync(
submit_approval_async(SubmitParams {
kv,
registry,
quorum,
ticket: ticket_str,
approver,
signature,
signed_payload,
local_notifies,
}),
self.sync_bridge_timeout,
)
}
pub fn deny_approval(&self, ticket: &str, reason: impl Into<String>) {
let kv = self.kv.clone();
let ticket_str = ticket.to_string();
let reason = reason.into();
let local_notifies = self.local_notifies.clone();
let timeout = self.sync_bridge_timeout;
let outcome_result = run_sync(
async move {
if let Err(err) = cas_write_outcome(
&kv,
&ticket_str,
KvOutcome::Deny {
code: "operator.deny".into(),
reason,
},
)
.await
{
tracing::warn!(
target: "klieo.ops.four_eyes",
error = %err,
"deny_approval cas_write_outcome failed; KV state may diverge from local notify"
);
}
if let Some(n) = local_notifies.get(&ticket_str) {
n.notify.notify_waiters();
}
Ok::<(), ApprovalError>(())
},
timeout,
);
if let Err(err) = outcome_result {
tracing::warn!(
target: "klieo.ops.four_eyes",
error = %err,
"deny_approval run_sync bridge failed"
);
}
}
}
fn run_sync<F>(fut: F, timeout: Duration) -> Result<(), ApprovalError>
where
F: std::future::Future<Output = Result<(), ApprovalError>> + Send + 'static,
{
let timed = async move {
tokio::time::timeout(timeout, fut)
.await
.unwrap_or_else(|_| {
tracing::warn!(
target: "klieo.ops.four_eyes.sync_bridge_timeout",
timeout_ms = timeout.as_millis(),
"sync bridge to async KV exceeded timeout; KV may be wedged"
);
Err(ApprovalError::VerificationFailed(format!(
"kv timeout: sync bridge exceeded {}ms",
timeout.as_millis()
)))
})
};
match tokio::runtime::Handle::try_current() {
Ok(handle) => {
if handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread {
tokio::task::block_in_place(|| handle.block_on(timed))
} else {
std::thread::scope(|s| {
s.spawn(|| {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("mini runtime")
.block_on(timed)
})
.join()
.expect("sync bridge thread")
})
}
}
Err(_) => {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("mini runtime")
.block_on(timed)
}
}
}
async fn read_kv_state(
kv: &Arc<dyn KvStore>,
ticket: &str,
) -> Result<Option<(PendingApprovalState, u64)>, ApprovalError> {
match kv.get(KV_BUCKET, ticket).await {
Ok(Some(entry)) => {
let state: PendingApprovalState = serde_json::from_slice(&entry.value)
.map_err(|e| ApprovalError::VerificationFailed(format!("kv state corrupt: {e}")))?;
Ok(Some((state, entry.revision)))
}
Ok(None) => Ok(None),
Err(e) => Err(ApprovalError::VerificationFailed(format!(
"kv read error: {e}"
))),
}
}
async fn cas_write_state(
kv: &Arc<dyn KvStore>,
ticket: &str,
state: PendingApprovalState,
expected_revision: u64,
) -> Result<(), BusError> {
let bytes = Bytes::from(serde_json::to_vec(&state).expect("serializable"));
kv.cas(KV_BUCKET, ticket, bytes, Some(expected_revision))
.await
.map(|_| ())
}
async fn cas_write_outcome(
kv: &Arc<dyn KvStore>,
ticket: &str,
outcome: KvOutcome,
) -> Result<(), ApprovalError> {
for _ in 0..CAS_MAX_RETRIES {
let Some((mut state, rev)) = read_kv_state(kv, ticket).await? else {
return Ok(());
};
if state.outcome.is_some() {
return Ok(());
}
state.outcome = Some(outcome.clone());
match cas_write_state(kv, ticket, state, rev).await {
Ok(()) => return Ok(()),
Err(BusError::CasConflict { .. }) => continue,
Err(e) => {
return Err(ApprovalError::VerificationFailed(format!(
"kv write error: {e}"
)))
}
}
}
Ok(())
}
struct SubmitParams {
kv: Arc<dyn KvStore>,
registry: Arc<dyn ApproverRegistry>,
quorum: u8,
ticket: String,
approver: ApproverId,
signature: Signature,
signed_payload: Vec<u8>,
local_notifies: Arc<DashMap<String, Arc<LocalNotify>>>,
}
async fn submit_approval_async(p: SubmitParams) -> Result<(), ApprovalError> {
let SubmitParams {
kv,
registry,
quorum,
ticket,
approver,
signature,
signed_payload,
local_notifies,
} = p;
let new_submission = Submission {
approver: approver.0.clone(),
signature_hex: hex::encode(signature.to_bytes()),
payload_b64: B64.encode(&signed_payload),
};
for _ in 0..CAS_MAX_RETRIES {
let Some((mut state, rev)) = read_kv_state(&kv, &ticket).await? else {
return Err(ApprovalError::VerificationFailed("unknown ticket".into()));
};
if state.outcome.is_some() {
return Ok(());
}
state.submissions.push(new_submission.clone());
let unique_count = state
.submissions
.iter()
.map(|s| s.approver.as_str())
.collect::<HashSet<_>>()
.len();
if unique_count < quorum as usize {
match cas_write_state(&kv, &ticket, state, rev).await {
Ok(()) => return Ok(()),
Err(BusError::CasConflict { .. }) => continue,
Err(e) => {
return Err(ApprovalError::VerificationFailed(format!(
"kv write error: {e}"
)))
}
}
}
let outcome = verify_quorum(&state.submissions, ®istry, quorum)?;
state.outcome = Some(outcome);
match cas_write_state(&kv, &ticket, state, rev).await {
Ok(()) => {
if let Some(n) = local_notifies.get(&ticket) {
n.notify.notify_waiters();
}
return Ok(());
}
Err(BusError::CasConflict { .. }) => continue,
Err(e) => {
return Err(ApprovalError::VerificationFailed(format!(
"kv write error: {e}"
)))
}
}
}
Ok(())
}
fn verify_quorum(
submissions: &[Submission],
registry: &Arc<dyn ApproverRegistry>,
quorum: u8,
) -> Result<KvOutcome, ApprovalError> {
let mut verified_ids: HashSet<String> = HashSet::new();
for sub in submissions {
let id = ApproverId(sub.approver.clone());
let Some(vk) = registry.lookup(&id) else {
let reason = format!("approver `{}` not in registry", sub.approver);
return Err(ApprovalError::VerificationFailed(reason));
};
let sig_bytes = hex::decode(&sub.signature_hex)
.map_err(|e| ApprovalError::VerificationFailed(format!("bad signature hex: {e}")))?;
let sig = Signature::from_slice(&sig_bytes)
.map_err(|e| ApprovalError::VerificationFailed(format!("malformed signature: {e}")))?;
let payload = B64
.decode(&sub.payload_b64)
.map_err(|e| ApprovalError::VerificationFailed(format!("bad payload b64: {e}")))?;
if vk.verify(&payload, &sig).is_err() {
let reason = format!("approver `{}` signature math-invalid", sub.approver);
return Err(ApprovalError::VerificationFailed(reason));
}
verified_ids.insert(sub.approver.clone());
}
if verified_ids.len() < quorum as usize {
return Err(ApprovalError::VerificationFailed(format!(
"quorum {} not met after unique-identity dedup; got {}",
quorum,
verified_ids.len()
)));
}
Ok(KvOutcome::Allow)
}
async fn poll_for_outcome(
kv: Arc<dyn KvStore>,
ticket: String,
notify: Arc<LocalNotify>,
) -> Result<ApprovalOutcome, ApprovalError> {
loop {
match read_kv_state(&kv, &ticket).await? {
Some((state, _)) => {
if let Some(outcome) = state.outcome {
return kv_outcome_to_result(outcome);
}
}
None => return Err(ApprovalError::VerificationFailed("unknown ticket".into())),
}
let jitter = rand_jitter_ms();
let sleep = tokio::time::sleep(Duration::from_millis(POLL_INTERVAL_MS + jitter));
tokio::select! {
_ = notify.notify.notified() => {}
_ = sleep => {}
}
}
}
fn kv_outcome_to_result(outcome: KvOutcome) -> Result<ApprovalOutcome, ApprovalError> {
match outcome {
KvOutcome::Allow => Ok(ApprovalOutcome::Allow),
KvOutcome::Deny { reason, .. } => Err(ApprovalError::Denied(reason)),
}
}
fn rand_jitter_ms() -> u64 {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::time::SystemTime;
let mut h = DefaultHasher::new();
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.subsec_nanos()
.hash(&mut h);
h.finish() % (POLL_JITTER_MS * 2)
}
#[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());
let state = PendingApprovalState::new(self.severity);
let bytes = Bytes::from(serde_json::to_vec(&state).expect("serializable"));
if let Err(e) = self.kv.cas(KV_BUCKET, &ticket, bytes, None).await {
tracing::error!(
target: "klieo.ops.four_eyes",
ticket = %ticket,
error = %e,
"failed to create pending state in KV; failing CLOSED"
);
return GateDecision::Deny {
code: "four_eyes.kv_unavailable".into(),
reason: format!("KV write failed, cannot create approval ticket: {e}"),
};
}
self.local_notifies
.insert(ticket.clone(), LocalNotify::new());
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 notify = self
.local_notifies
.get(&ticket)
.map(|e| e.clone())
.unwrap_or_else(LocalNotify::new);
let kv = self.kv.clone();
let ticket_clone = ticket.clone();
let poll_result =
tokio::time::timeout(timeout, poll_for_outcome(kv, ticket_clone, notify)).await;
match poll_result {
Ok(Ok(outcome)) => {
self.local_notifies.remove(&ticket);
Ok(outcome)
}
Ok(Err(e)) => {
self.local_notifies.remove(&ticket);
Err(e)
}
Err(_elapsed) => {
Err(ApprovalError::TimedOut {
millis: timeout.as_millis() as u64,
})
}
}
}
}