use std::sync::OnceLock;
use std::sync::RwLock;
use serde::{Deserialize, Serialize};
use tokio::sync::broadcast;
pub const APPROVAL_BROADCAST_CAPACITY: usize = 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Decision {
Approve,
Deny,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Remember {
Once,
Session,
Forever,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SyntheticPermissionRule {
pub action_type: String,
pub namespace: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_id: Option<String>,
pub decision: String,
pub recorded_at: String,
}
static SYNTHETIC_RULES: RwLock<Vec<SyntheticPermissionRule>> = RwLock::new(Vec::new());
pub fn record_synthetic_rule(rule: SyntheticPermissionRule) {
let mut guard = SYNTHETIC_RULES
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let already = guard.iter().any(|r| {
r.action_type == rule.action_type
&& r.namespace == rule.namespace
&& r.agent_id == rule.agent_id
&& r.decision == rule.decision
});
if !already {
guard.push(rule);
}
}
#[must_use]
pub fn list_synthetic_rules() -> Vec<SyntheticPermissionRule> {
SYNTHETIC_RULES
.read()
.map(|g| g.clone())
.unwrap_or_default()
}
#[doc(hidden)]
pub fn clear_synthetic_rules_for_test() {
if let Ok(mut g) = SYNTHETIC_RULES.write() {
g.clear();
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "snake_case")]
pub enum ApprovalEvent {
ApprovalRequested {
pending_id: String,
action_type: String,
namespace: String,
requested_by: String,
requested_at: String,
},
ApprovalDecided {
pending_id: String,
decision: String,
decided_by: String,
remember: String,
#[serde(default)]
namespace: String,
#[serde(default)]
requested_by: String,
},
}
impl ApprovalEvent {
#[must_use]
pub fn tenant_agent_id(&self) -> &str {
match self {
ApprovalEvent::ApprovalRequested { requested_by, .. }
| ApprovalEvent::ApprovalDecided { requested_by, .. } => requested_by.as_str(),
}
}
#[must_use]
pub fn tenant_namespace(&self) -> &str {
match self {
ApprovalEvent::ApprovalRequested { namespace, .. }
| ApprovalEvent::ApprovalDecided { namespace, .. } => namespace.as_str(),
}
}
}
static APPROVAL_BUS: OnceLock<broadcast::Sender<ApprovalEvent>> = OnceLock::new();
fn bus() -> &'static broadcast::Sender<ApprovalEvent> {
APPROVAL_BUS.get_or_init(|| {
let (tx, _rx) = broadcast::channel(APPROVAL_BROADCAST_CAPACITY);
tx
})
}
pub fn publish(event: ApprovalEvent) {
let _ = bus().send(event);
}
#[must_use]
pub fn subscribe() -> broadcast::Receiver<ApprovalEvent> {
bus().subscribe()
}
#[cfg(test)]
mod tests {
use super::*;
fn registry_lock() -> std::sync::MutexGuard<'static, ()> {
use std::sync::Mutex;
static LOCK: Mutex<()> = Mutex::new(());
LOCK.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[test]
fn record_and_list_round_trip() {
let _g = registry_lock();
clear_synthetic_rules_for_test();
let rule = SyntheticPermissionRule {
action_type: "store".into(),
namespace: "scratch".into(),
agent_id: Some("alice".into()),
decision: "approve".into(),
recorded_at: "2026-05-05T00:00:00Z".into(),
};
record_synthetic_rule(rule.clone());
let snap = list_synthetic_rules();
assert_eq!(snap.len(), 1);
assert_eq!(snap[0], rule);
}
#[test]
fn record_synthetic_rule_is_idempotent() {
let _g = registry_lock();
clear_synthetic_rules_for_test();
let rule = SyntheticPermissionRule {
action_type: "delete".into(),
namespace: "ns".into(),
agent_id: Some("bob".into()),
decision: "deny".into(),
recorded_at: "2026-05-05T00:00:00Z".into(),
};
record_synthetic_rule(rule.clone());
let mut later = rule.clone();
later.recorded_at = "2099-01-01T00:00:00Z".into();
record_synthetic_rule(later);
let snap = list_synthetic_rules();
assert_eq!(snap.len(), 1);
assert_eq!(snap[0].recorded_at, "2026-05-05T00:00:00Z");
}
#[tokio::test]
async fn publish_and_subscribe_round_trip() {
let mut rx = subscribe();
let evt = ApprovalEvent::ApprovalRequested {
pending_id: "pa-1".into(),
action_type: "store".into(),
namespace: "scratch".into(),
requested_by: "alice".into(),
requested_at: "2026-05-05T00:00:00Z".into(),
};
publish(evt.clone());
let received = rx.recv().await.expect("recv");
match received {
ApprovalEvent::ApprovalRequested { pending_id, .. } => assert_eq!(pending_id, "pa-1"),
_ => panic!("wrong variant"),
}
}
}