use std::sync::Arc;
use chrono::{DateTime, Utc};
use khive_types::Namespace;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ActorRef {
pub kind: String,
pub id: String,
}
impl ActorRef {
pub fn new(kind: impl Into<String>, id: impl Into<String>) -> Self {
Self {
kind: kind.into(),
id: id.into(),
}
}
pub fn anonymous() -> Self {
Self {
kind: "anonymous".into(),
id: "local".into(),
}
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct GateContext {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timestamp: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct GateRequest {
pub actor: ActorRef,
pub namespace: Namespace,
pub verb: String,
pub args: serde_json::Value,
#[serde(default)]
pub context: GateContext,
}
impl GateRequest {
pub fn new(
actor: ActorRef,
namespace: Namespace,
verb: impl Into<String>,
args: serde_json::Value,
) -> Self {
Self {
actor,
namespace,
verb: verb.into(),
args,
context: GateContext::default(),
}
}
pub fn with_context(mut self, context: GateContext) -> Self {
self.context = context;
self
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Obligation {
Audit {
tag: String,
},
RateLimit {
window_secs: u64,
max: u32,
},
Custom {
value: serde_json::Value,
},
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "decision", rename_all = "snake_case")]
pub enum GateDecision {
Allow {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
obligations: Vec<Obligation>,
},
Deny {
reason: String,
},
}
impl GateDecision {
pub fn allow() -> Self {
Self::Allow {
obligations: Vec::new(),
}
}
pub fn allow_with(obligations: Vec<Obligation>) -> Self {
Self::Allow { obligations }
}
pub fn deny(reason: impl Into<String>) -> Self {
Self::Deny {
reason: reason.into(),
}
}
pub fn is_allow(&self) -> bool {
matches!(self, Self::Allow { .. })
}
}
#[derive(Error, Debug)]
pub enum GateError {
#[error("policy error: {0}")]
Policy(String),
#[error("evaluation error: {0}")]
Evaluation(String),
#[error("internal gate error: {0}")]
Internal(String),
}
pub trait Gate: Send + Sync + std::fmt::Debug {
fn check(&self, req: &GateRequest) -> Result<GateDecision, GateError>;
fn impl_name(&self) -> &'static str {
"Gate"
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AuditEvent {
pub timestamp: DateTime<Utc>,
pub actor: ActorRef,
pub namespace: String,
pub verb: String,
pub decision: AuditDecision,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deny_reason: Option<String>,
#[serde(default)]
pub obligations: Vec<Obligation>,
pub gate_impl: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuditDecision {
Allow,
Deny,
}
impl AuditEvent {
pub fn from_check(req: &GateRequest, decision: &GateDecision, gate_impl: &str) -> Self {
let (audit_decision, deny_reason, obligations) = match decision {
GateDecision::Allow { obligations } => {
(AuditDecision::Allow, None, obligations.clone())
}
GateDecision::Deny { reason } => {
(AuditDecision::Deny, Some(reason.clone()), Vec::new())
}
};
Self {
timestamp: req.context.timestamp.unwrap_or_else(chrono::Utc::now),
actor: req.actor.clone(),
namespace: req.namespace.as_str().to_string(),
verb: req.verb.clone(),
decision: audit_decision,
deny_reason,
obligations,
gate_impl: gate_impl.to_string(),
session_id: req.context.session_id.clone(),
}
}
}
pub type GateRef = Arc<dyn Gate>;
#[derive(Clone, Debug, Default)]
pub struct AllowAllGate;
impl Gate for AllowAllGate {
fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
Ok(GateDecision::allow())
}
fn impl_name(&self) -> &'static str {
"AllowAllGate"
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn sample_request() -> GateRequest {
GateRequest::new(
ActorRef::anonymous(),
Namespace::default_ns(),
"search",
json!({"query": "LoRA"}),
)
}
#[test]
fn allow_all_gate_allows() {
let gate = AllowAllGate;
let decision = gate.check(&sample_request()).unwrap();
assert!(decision.is_allow());
}
#[test]
fn allow_all_gate_through_dyn() {
let gate: GateRef = Arc::new(AllowAllGate);
let decision = gate.check(&sample_request()).unwrap();
assert!(decision.is_allow());
}
#[test]
fn actor_ref_anonymous() {
let a = ActorRef::anonymous();
assert_eq!(a.kind, "anonymous");
assert_eq!(a.id, "local");
}
#[test]
fn decision_helpers() {
assert!(GateDecision::allow().is_allow());
assert!(!GateDecision::deny("nope").is_allow());
}
#[test]
fn request_serializes_to_stable_shape() {
let req = sample_request();
let v = serde_json::to_value(&req).unwrap();
assert_eq!(v["actor"]["kind"], "anonymous");
assert_eq!(v["actor"]["id"], "local");
assert_eq!(v["namespace"], "local");
assert_eq!(v["verb"], "search");
assert_eq!(v["args"]["query"], "LoRA");
}
#[test]
fn decision_roundtrips_through_json() {
let allow = GateDecision::allow_with(vec![Obligation::Audit {
tag: "search.attempt".into(),
}]);
let s = serde_json::to_string(&allow).unwrap();
let back: GateDecision = serde_json::from_str(&s).unwrap();
match back {
GateDecision::Allow { obligations } => {
assert_eq!(obligations.len(), 1);
match &obligations[0] {
Obligation::Audit { tag } => assert_eq!(tag, "search.attempt"),
_ => panic!("expected Audit"),
}
}
_ => panic!("expected Allow"),
}
let deny = GateDecision::deny("forbidden");
let s = serde_json::to_string(&deny).unwrap();
let back: GateDecision = serde_json::from_str(&s).unwrap();
match back {
GateDecision::Deny { reason } => assert_eq!(reason, "forbidden"),
_ => panic!("expected Deny"),
}
}
#[test]
fn obligation_rate_limit_serializes_with_kind_tag() {
let o = Obligation::RateLimit {
window_secs: 60,
max: 100,
};
let v = serde_json::to_value(&o).unwrap();
assert_eq!(v["kind"], "rate_limit");
assert_eq!(v["window_secs"], 60);
assert_eq!(v["max"], 100);
}
fn assert_custom_round_trips(value: serde_json::Value) {
let original = Obligation::Custom {
value: value.clone(),
};
let json = serde_json::to_value(&original).expect("serialize");
assert_eq!(json["kind"], "custom");
assert_eq!(json["value"], value);
let back: Obligation = serde_json::from_value(json).expect("deserialize");
match back {
Obligation::Custom { value: got } => assert_eq!(got, value),
other => panic!("expected Custom, got {other:?}"),
}
}
#[test]
fn obligation_custom_round_trips_object() {
assert_custom_round_trips(serde_json::json!({"audit_tag": "billing", "weight": 1.5}));
}
#[test]
fn obligation_custom_round_trips_string() {
assert_custom_round_trips(serde_json::json!("just a string"));
}
#[test]
fn obligation_custom_round_trips_number() {
assert_custom_round_trips(serde_json::json!(42));
}
#[test]
fn obligation_custom_round_trips_array() {
assert_custom_round_trips(serde_json::json!(["a", "b", 3]));
}
#[test]
fn obligation_custom_round_trips_null() {
assert_custom_round_trips(serde_json::Value::Null);
}
#[test]
fn obligation_custom_round_trips_bool() {
assert_custom_round_trips(serde_json::json!(true));
}
fn sample_req_with_session() -> GateRequest {
GateRequest::new(
ActorRef::new("user", "ocean"),
Namespace::default_ns(),
"create",
json!({"kind": "concept"}),
)
.with_context(GateContext {
session_id: Some("sess-abc".into()),
timestamp: None,
source: Some("mcp".into()),
})
}
#[test]
fn audit_event_roundtrips_through_serde_stable_shape() {
let req = sample_req_with_session();
let decision = GateDecision::allow_with(vec![Obligation::Audit {
tag: "create.attempt".into(),
}]);
let ev = AuditEvent::from_check(&req, &decision, "AllowAllGate");
let json = serde_json::to_value(&ev).unwrap();
assert_eq!(json["actor"]["kind"], "user");
assert_eq!(json["actor"]["id"], "ocean");
assert_eq!(json["namespace"], "local");
assert_eq!(json["verb"], "create");
assert_eq!(json["decision"], "allow");
assert_eq!(json["gate_impl"], "AllowAllGate");
assert_eq!(json["session_id"], "sess-abc");
assert!(json.get("deny_reason").is_none() || json["deny_reason"].is_null());
assert_eq!(json["obligations"][0]["kind"], "audit");
assert_eq!(json["obligations"][0]["tag"], "create.attempt");
assert!(json["timestamp"].is_string());
let back: AuditEvent = serde_json::from_value(json).unwrap();
assert_eq!(back.verb, "create");
assert_eq!(back.decision, AuditDecision::Allow);
assert!(back.deny_reason.is_none());
assert_eq!(back.obligations.len(), 1);
}
#[test]
fn audit_event_deny_path_carries_reason() {
let req = sample_request(); let decision = GateDecision::deny("forbidden: no write for anonymous");
let ev = AuditEvent::from_check(&req, &decision, "RegoGate");
let json = serde_json::to_value(&ev).unwrap();
assert_eq!(json["decision"], "deny");
assert_eq!(json["deny_reason"], "forbidden: no write for anonymous");
assert_eq!(json["gate_impl"], "RegoGate");
assert_eq!(
json["obligations"],
serde_json::Value::Array(Vec::new()),
"obligations must be an empty array on Deny, not omitted"
);
assert!(json.get("session_id").is_none() || json["session_id"].is_null());
}
#[test]
fn audit_event_allow_no_obligations() {
let req = sample_request();
let decision = GateDecision::allow();
let ev = AuditEvent::from_check(&req, &decision, "AllowAllGate");
assert_eq!(ev.decision, AuditDecision::Allow);
assert!(ev.deny_reason.is_none());
assert!(ev.obligations.is_empty());
let json = serde_json::to_value(&ev).unwrap();
assert_eq!(
json["obligations"],
serde_json::Value::Array(Vec::new()),
"obligations must serialize as an empty array, not be omitted"
);
}
#[test]
fn audit_decision_serialises_as_snake_case() {
let allow = serde_json::to_value(AuditDecision::Allow).unwrap();
assert_eq!(allow, "allow");
let deny = serde_json::to_value(AuditDecision::Deny).unwrap();
assert_eq!(deny, "deny");
}
}