1use std::sync::Arc;
27
28use chrono::{DateTime, Utc};
29use khive_types::Namespace;
30use serde::{Deserialize, Serialize};
31use thiserror::Error;
32
33#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
37pub struct ActorRef {
38 pub kind: String,
39 pub id: String,
40}
41
42impl ActorRef {
43 pub fn new(kind: impl Into<String>, id: impl Into<String>) -> Self {
44 Self {
45 kind: kind.into(),
46 id: id.into(),
47 }
48 }
49
50 pub fn anonymous() -> Self {
52 Self {
53 kind: "anonymous".into(),
54 id: "local".into(),
55 }
56 }
57}
58
59#[derive(Clone, Debug, Default, Serialize, Deserialize)]
63pub struct GateContext {
64 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub session_id: Option<String>,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub timestamp: Option<DateTime<Utc>>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub source: Option<String>,
70}
71
72#[derive(Clone, Debug, Serialize, Deserialize)]
80pub struct GateRequest {
81 pub actor: ActorRef,
82 pub namespace: Namespace,
83 pub verb: String,
84 pub args: serde_json::Value,
85 #[serde(default)]
86 pub context: GateContext,
87}
88
89impl GateRequest {
90 pub fn new(
91 actor: ActorRef,
92 namespace: Namespace,
93 verb: impl Into<String>,
94 args: serde_json::Value,
95 ) -> Self {
96 Self {
97 actor,
98 namespace,
99 verb: verb.into(),
100 args,
101 context: GateContext::default(),
102 }
103 }
104
105 pub fn with_context(mut self, context: GateContext) -> Self {
106 self.context = context;
107 self
108 }
109}
110
111#[derive(Clone, Debug, Serialize, Deserialize)]
119#[serde(tag = "kind", rename_all = "snake_case")]
120pub enum Obligation {
121 Audit {
122 tag: String,
123 },
124 RateLimit {
125 window_secs: u64,
126 max: u32,
127 },
128 Custom {
133 value: serde_json::Value,
134 },
135}
136
137#[derive(Clone, Debug, Serialize, Deserialize)]
140#[serde(tag = "decision", rename_all = "snake_case")]
141pub enum GateDecision {
142 Allow {
143 #[serde(default, skip_serializing_if = "Vec::is_empty")]
144 obligations: Vec<Obligation>,
145 },
146 Deny {
147 reason: String,
148 },
149}
150
151impl GateDecision {
152 pub fn allow() -> Self {
153 Self::Allow {
154 obligations: Vec::new(),
155 }
156 }
157
158 pub fn allow_with(obligations: Vec<Obligation>) -> Self {
159 Self::Allow { obligations }
160 }
161
162 pub fn deny(reason: impl Into<String>) -> Self {
163 Self::Deny {
164 reason: reason.into(),
165 }
166 }
167
168 pub fn is_allow(&self) -> bool {
169 matches!(self, Self::Allow { .. })
170 }
171}
172
173#[derive(Error, Debug)]
176pub enum GateError {
177 #[error("policy error: {0}")]
178 Policy(String),
179 #[error("evaluation error: {0}")]
180 Evaluation(String),
181 #[error("internal gate error: {0}")]
182 Internal(String),
183}
184
185pub trait Gate: Send + Sync + std::fmt::Debug {
196 fn check(&self, req: &GateRequest) -> Result<GateDecision, GateError>;
197
198 fn impl_name(&self) -> &'static str {
202 "Gate"
203 }
204}
205
206#[derive(Clone, Debug, Serialize, Deserialize)]
218pub struct AuditEvent {
219 pub timestamp: DateTime<Utc>,
221 pub actor: ActorRef,
223 pub namespace: String,
225 pub verb: String,
227 pub decision: AuditDecision,
229 #[serde(default, skip_serializing_if = "Option::is_none")]
231 pub deny_reason: Option<String>,
232 #[serde(default)]
237 pub obligations: Vec<Obligation>,
238 pub gate_impl: String,
240 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub session_id: Option<String>,
243}
244
245#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
247#[serde(rename_all = "snake_case")]
248pub enum AuditDecision {
249 Allow,
250 Deny,
251}
252
253impl AuditEvent {
254 pub fn from_check(req: &GateRequest, decision: &GateDecision, gate_impl: &str) -> Self {
256 let (audit_decision, deny_reason, obligations) = match decision {
257 GateDecision::Allow { obligations } => {
258 (AuditDecision::Allow, None, obligations.clone())
259 }
260 GateDecision::Deny { reason } => {
261 (AuditDecision::Deny, Some(reason.clone()), Vec::new())
262 }
263 };
264 Self {
265 timestamp: req.context.timestamp.unwrap_or_else(chrono::Utc::now),
266 actor: req.actor.clone(),
267 namespace: req.namespace.as_str().to_string(),
268 verb: req.verb.clone(),
269 decision: audit_decision,
270 deny_reason,
271 obligations,
272 gate_impl: gate_impl.to_string(),
273 session_id: req.context.session_id.clone(),
274 }
275 }
276}
277
278pub type GateRef = Arc<dyn Gate>;
280
281#[derive(Clone, Debug, Default)]
288pub struct AllowAllGate;
289
290impl Gate for AllowAllGate {
291 fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
292 Ok(GateDecision::allow())
293 }
294
295 fn impl_name(&self) -> &'static str {
296 "AllowAllGate"
297 }
298}
299
300#[cfg(test)]
303mod tests {
304 use super::*;
305 use serde_json::json;
306
307 fn sample_request() -> GateRequest {
308 GateRequest::new(
309 ActorRef::anonymous(),
310 Namespace::default_ns(),
311 "search",
312 json!({"query": "LoRA"}),
313 )
314 }
315
316 #[test]
317 fn allow_all_gate_allows() {
318 let gate = AllowAllGate;
319 let decision = gate.check(&sample_request()).unwrap();
320 assert!(decision.is_allow());
321 }
322
323 #[test]
324 fn allow_all_gate_through_dyn() {
325 let gate: GateRef = Arc::new(AllowAllGate);
326 let decision = gate.check(&sample_request()).unwrap();
327 assert!(decision.is_allow());
328 }
329
330 #[test]
331 fn actor_ref_anonymous() {
332 let a = ActorRef::anonymous();
333 assert_eq!(a.kind, "anonymous");
334 assert_eq!(a.id, "local");
335 }
336
337 #[test]
338 fn decision_helpers() {
339 assert!(GateDecision::allow().is_allow());
340 assert!(!GateDecision::deny("nope").is_allow());
341 }
342
343 #[test]
344 fn request_serializes_to_stable_shape() {
345 let req = sample_request();
346 let v = serde_json::to_value(&req).unwrap();
347 assert_eq!(v["actor"]["kind"], "anonymous");
348 assert_eq!(v["actor"]["id"], "local");
349 assert_eq!(v["namespace"], "local");
350 assert_eq!(v["verb"], "search");
351 assert_eq!(v["args"]["query"], "LoRA");
352 }
353
354 #[test]
355 fn decision_roundtrips_through_json() {
356 let allow = GateDecision::allow_with(vec![Obligation::Audit {
357 tag: "search.attempt".into(),
358 }]);
359 let s = serde_json::to_string(&allow).unwrap();
360 let back: GateDecision = serde_json::from_str(&s).unwrap();
361 match back {
362 GateDecision::Allow { obligations } => {
363 assert_eq!(obligations.len(), 1);
364 match &obligations[0] {
365 Obligation::Audit { tag } => assert_eq!(tag, "search.attempt"),
366 _ => panic!("expected Audit"),
367 }
368 }
369 _ => panic!("expected Allow"),
370 }
371
372 let deny = GateDecision::deny("forbidden");
373 let s = serde_json::to_string(&deny).unwrap();
374 let back: GateDecision = serde_json::from_str(&s).unwrap();
375 match back {
376 GateDecision::Deny { reason } => assert_eq!(reason, "forbidden"),
377 _ => panic!("expected Deny"),
378 }
379 }
380
381 #[test]
382 fn obligation_rate_limit_serializes_with_kind_tag() {
383 let o = Obligation::RateLimit {
384 window_secs: 60,
385 max: 100,
386 };
387 let v = serde_json::to_value(&o).unwrap();
388 assert_eq!(v["kind"], "rate_limit");
389 assert_eq!(v["window_secs"], 60);
390 assert_eq!(v["max"], 100);
391 }
392
393 fn assert_custom_round_trips(value: serde_json::Value) {
399 let original = Obligation::Custom {
400 value: value.clone(),
401 };
402 let json = serde_json::to_value(&original).expect("serialize");
403 assert_eq!(json["kind"], "custom");
404 assert_eq!(json["value"], value);
405 let back: Obligation = serde_json::from_value(json).expect("deserialize");
406 match back {
407 Obligation::Custom { value: got } => assert_eq!(got, value),
408 other => panic!("expected Custom, got {other:?}"),
409 }
410 }
411
412 #[test]
413 fn obligation_custom_round_trips_object() {
414 assert_custom_round_trips(serde_json::json!({"audit_tag": "billing", "weight": 1.5}));
415 }
416
417 #[test]
418 fn obligation_custom_round_trips_string() {
419 assert_custom_round_trips(serde_json::json!("just a string"));
420 }
421
422 #[test]
423 fn obligation_custom_round_trips_number() {
424 assert_custom_round_trips(serde_json::json!(42));
425 }
426
427 #[test]
428 fn obligation_custom_round_trips_array() {
429 assert_custom_round_trips(serde_json::json!(["a", "b", 3]));
430 }
431
432 #[test]
433 fn obligation_custom_round_trips_null() {
434 assert_custom_round_trips(serde_json::Value::Null);
435 }
436
437 #[test]
438 fn obligation_custom_round_trips_bool() {
439 assert_custom_round_trips(serde_json::json!(true));
440 }
441
442 fn sample_req_with_session() -> GateRequest {
445 GateRequest::new(
446 ActorRef::new("user", "ocean"),
447 Namespace::default_ns(),
448 "create",
449 json!({"kind": "concept"}),
450 )
451 .with_context(GateContext {
452 session_id: Some("sess-abc".into()),
453 timestamp: None,
454 source: Some("mcp".into()),
455 })
456 }
457
458 #[test]
459 fn audit_event_roundtrips_through_serde_stable_shape() {
460 let req = sample_req_with_session();
461 let decision = GateDecision::allow_with(vec![Obligation::Audit {
462 tag: "create.attempt".into(),
463 }]);
464 let ev = AuditEvent::from_check(&req, &decision, "AllowAllGate");
465
466 let json = serde_json::to_value(&ev).unwrap();
467
468 assert_eq!(json["actor"]["kind"], "user");
470 assert_eq!(json["actor"]["id"], "ocean");
471 assert_eq!(json["namespace"], "local");
472 assert_eq!(json["verb"], "create");
473 assert_eq!(json["decision"], "allow");
474 assert_eq!(json["gate_impl"], "AllowAllGate");
475 assert_eq!(json["session_id"], "sess-abc");
476 assert!(json.get("deny_reason").is_none() || json["deny_reason"].is_null());
478 assert_eq!(json["obligations"][0]["kind"], "audit");
480 assert_eq!(json["obligations"][0]["tag"], "create.attempt");
481 assert!(json["timestamp"].is_string());
483
484 let back: AuditEvent = serde_json::from_value(json).unwrap();
486 assert_eq!(back.verb, "create");
487 assert_eq!(back.decision, AuditDecision::Allow);
488 assert!(back.deny_reason.is_none());
489 assert_eq!(back.obligations.len(), 1);
490 }
491
492 #[test]
493 fn audit_event_deny_path_carries_reason() {
494 let req = sample_request(); let decision = GateDecision::deny("forbidden: no write for anonymous");
496 let ev = AuditEvent::from_check(&req, &decision, "RegoGate");
497
498 let json = serde_json::to_value(&ev).unwrap();
499
500 assert_eq!(json["decision"], "deny");
501 assert_eq!(json["deny_reason"], "forbidden: no write for anonymous");
502 assert_eq!(json["gate_impl"], "RegoGate");
503 assert_eq!(
505 json["obligations"],
506 serde_json::Value::Array(Vec::new()),
507 "obligations must be an empty array on Deny, not omitted"
508 );
509 assert!(json.get("session_id").is_none() || json["session_id"].is_null());
511 }
512
513 #[test]
514 fn audit_event_allow_no_obligations() {
515 let req = sample_request();
516 let decision = GateDecision::allow();
517 let ev = AuditEvent::from_check(&req, &decision, "AllowAllGate");
518 assert_eq!(ev.decision, AuditDecision::Allow);
519 assert!(ev.deny_reason.is_none());
520 assert!(ev.obligations.is_empty());
521 let json = serde_json::to_value(&ev).unwrap();
525 assert_eq!(
526 json["obligations"],
527 serde_json::Value::Array(Vec::new()),
528 "obligations must serialize as an empty array, not be omitted"
529 );
530 }
531
532 #[test]
533 fn audit_decision_serialises_as_snake_case() {
534 let allow = serde_json::to_value(AuditDecision::Allow).unwrap();
535 assert_eq!(allow, "allow");
536 let deny = serde_json::to_value(AuditDecision::Deny).unwrap();
537 assert_eq!(deny, "deny");
538 }
539}