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)]
120#[serde(tag = "kind", rename_all = "snake_case")]
121pub enum Obligation {
122 Audit {
123 tag: String,
124 },
125 RateLimit {
126 window_secs: u64,
127 max: u32,
128 },
129 Custom {
134 value: serde_json::Value,
135 },
136}
137
138#[derive(Clone, Debug, Serialize, Deserialize)]
141#[serde(tag = "decision", rename_all = "snake_case")]
142pub enum GateDecision {
143 Allow {
144 #[serde(default, skip_serializing_if = "Vec::is_empty")]
145 obligations: Vec<Obligation>,
146 },
147 Deny {
148 reason: String,
149 },
150}
151
152impl GateDecision {
153 pub fn allow() -> Self {
154 Self::Allow {
155 obligations: Vec::new(),
156 }
157 }
158
159 pub fn allow_with(obligations: Vec<Obligation>) -> Self {
160 Self::Allow { obligations }
161 }
162
163 pub fn deny(reason: impl Into<String>) -> Self {
164 Self::Deny {
165 reason: reason.into(),
166 }
167 }
168
169 pub fn is_allow(&self) -> bool {
170 matches!(self, Self::Allow { .. })
171 }
172}
173
174#[derive(Error, Debug)]
177pub enum GateError {
178 #[error("policy error: {0}")]
179 Policy(String),
180 #[error("evaluation error: {0}")]
181 Evaluation(String),
182 #[error("internal gate error: {0}")]
183 Internal(String),
184}
185
186pub trait Gate: Send + Sync + std::fmt::Debug {
197 fn check(&self, req: &GateRequest) -> Result<GateDecision, GateError>;
198
199 fn impl_name(&self) -> &'static str {
203 "Gate"
204 }
205}
206
207#[derive(Clone, Debug, Serialize, Deserialize)]
219pub struct AuditEvent {
220 pub timestamp: DateTime<Utc>,
222 pub actor: ActorRef,
224 pub namespace: String,
226 pub verb: String,
228 pub decision: AuditDecision,
230 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub deny_reason: Option<String>,
233 #[serde(default)]
238 pub obligations: Vec<Obligation>,
239 pub gate_impl: String,
241 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub session_id: Option<String>,
244}
245
246#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
248#[serde(rename_all = "snake_case")]
249pub enum AuditDecision {
250 Allow,
251 Deny,
252}
253
254impl AuditEvent {
255 pub fn from_check(req: &GateRequest, decision: &GateDecision, gate_impl: &str) -> Self {
257 let (audit_decision, deny_reason, obligations) = match decision {
258 GateDecision::Allow { obligations } => {
259 (AuditDecision::Allow, None, obligations.clone())
260 }
261 GateDecision::Deny { reason } => {
262 (AuditDecision::Deny, Some(reason.clone()), Vec::new())
263 }
264 };
265 Self {
266 timestamp: req.context.timestamp.unwrap_or_else(chrono::Utc::now),
267 actor: req.actor.clone(),
268 namespace: req.namespace.as_str().to_string(),
269 verb: req.verb.clone(),
270 decision: audit_decision,
271 deny_reason,
272 obligations,
273 gate_impl: gate_impl.to_string(),
274 session_id: req.context.session_id.clone(),
275 }
276 }
277}
278
279pub type GateRef = Arc<dyn Gate>;
281
282#[derive(Clone, Debug, Default)]
289pub struct AllowAllGate;
290
291impl Gate for AllowAllGate {
292 fn check(&self, _req: &GateRequest) -> Result<GateDecision, GateError> {
293 Ok(GateDecision::allow())
294 }
295
296 fn impl_name(&self) -> &'static str {
297 "AllowAllGate"
298 }
299}
300
301#[cfg(test)]
304mod tests {
305 use super::*;
306 use serde_json::json;
307
308 fn sample_request() -> GateRequest {
309 GateRequest::new(
310 ActorRef::anonymous(),
311 Namespace::local(),
312 "search",
313 json!({"query": "LoRA"}),
314 )
315 }
316
317 #[test]
318 fn allow_all_gate_allows() {
319 let gate = AllowAllGate;
320 let decision = gate.check(&sample_request()).unwrap();
321 assert!(decision.is_allow());
322 }
323
324 #[test]
325 fn allow_all_gate_through_dyn() {
326 let gate: GateRef = Arc::new(AllowAllGate);
327 let decision = gate.check(&sample_request()).unwrap();
328 assert!(decision.is_allow());
329 }
330
331 #[test]
332 fn actor_ref_anonymous() {
333 let a = ActorRef::anonymous();
334 assert_eq!(a.kind, "anonymous");
335 assert_eq!(a.id, "local");
336 }
337
338 #[test]
339 fn decision_helpers() {
340 assert!(GateDecision::allow().is_allow());
341 assert!(!GateDecision::deny("nope").is_allow());
342 }
343
344 #[test]
345 fn request_serializes_to_stable_shape() {
346 let req = sample_request();
347 let v = serde_json::to_value(&req).unwrap();
348 assert_eq!(v["actor"]["kind"], "anonymous");
349 assert_eq!(v["actor"]["id"], "local");
350 assert_eq!(v["namespace"], "local");
351 assert_eq!(v["verb"], "search");
352 assert_eq!(v["args"]["query"], "LoRA");
353 }
354
355 #[test]
356 fn decision_roundtrips_through_json() {
357 let allow = GateDecision::allow_with(vec![Obligation::Audit {
358 tag: "search.attempt".into(),
359 }]);
360 let s = serde_json::to_string(&allow).unwrap();
361 let back: GateDecision = serde_json::from_str(&s).unwrap();
362 match back {
363 GateDecision::Allow { obligations } => {
364 assert_eq!(obligations.len(), 1);
365 match &obligations[0] {
366 Obligation::Audit { tag } => assert_eq!(tag, "search.attempt"),
367 _ => panic!("expected Audit"),
368 }
369 }
370 _ => panic!("expected Allow"),
371 }
372
373 let deny = GateDecision::deny("forbidden");
374 let s = serde_json::to_string(&deny).unwrap();
375 let back: GateDecision = serde_json::from_str(&s).unwrap();
376 match back {
377 GateDecision::Deny { reason } => assert_eq!(reason, "forbidden"),
378 _ => panic!("expected Deny"),
379 }
380 }
381
382 #[test]
383 fn obligation_rate_limit_serializes_with_kind_tag() {
384 let o = Obligation::RateLimit {
385 window_secs: 60,
386 max: 100,
387 };
388 let v = serde_json::to_value(&o).unwrap();
389 assert_eq!(v["kind"], "rate_limit");
390 assert_eq!(v["window_secs"], 60);
391 assert_eq!(v["max"], 100);
392 }
393
394 fn assert_custom_round_trips(value: serde_json::Value) {
400 let original = Obligation::Custom {
401 value: value.clone(),
402 };
403 let json = serde_json::to_value(&original).expect("serialize");
404 assert_eq!(json["kind"], "custom");
405 assert_eq!(json["value"], value);
406 let back: Obligation = serde_json::from_value(json).expect("deserialize");
407 match back {
408 Obligation::Custom { value: got } => assert_eq!(got, value),
409 other => panic!("expected Custom, got {other:?}"),
410 }
411 }
412
413 #[test]
414 fn obligation_custom_round_trips_object() {
415 assert_custom_round_trips(serde_json::json!({"audit_tag": "billing", "weight": 1.5}));
416 }
417
418 #[test]
419 fn obligation_custom_round_trips_string() {
420 assert_custom_round_trips(serde_json::json!("just a string"));
421 }
422
423 #[test]
424 fn obligation_custom_round_trips_number() {
425 assert_custom_round_trips(serde_json::json!(42));
426 }
427
428 #[test]
429 fn obligation_custom_round_trips_array() {
430 assert_custom_round_trips(serde_json::json!(["a", "b", 3]));
431 }
432
433 #[test]
434 fn obligation_custom_round_trips_null() {
435 assert_custom_round_trips(serde_json::Value::Null);
436 }
437
438 #[test]
439 fn obligation_custom_round_trips_bool() {
440 assert_custom_round_trips(serde_json::json!(true));
441 }
442
443 fn sample_req_with_session() -> GateRequest {
446 GateRequest::new(
447 ActorRef::new("user", "ocean"),
448 Namespace::local(),
449 "create",
450 json!({"kind": "concept"}),
451 )
452 .with_context(GateContext {
453 session_id: Some("sess-abc".into()),
454 timestamp: None,
455 source: Some("mcp".into()),
456 })
457 }
458
459 #[test]
460 fn audit_event_roundtrips_through_serde_stable_shape() {
461 let req = sample_req_with_session();
462 let decision = GateDecision::allow_with(vec![Obligation::Audit {
463 tag: "create.attempt".into(),
464 }]);
465 let ev = AuditEvent::from_check(&req, &decision, "AllowAllGate");
466
467 let json = serde_json::to_value(&ev).unwrap();
468
469 assert_eq!(json["actor"]["kind"], "user");
471 assert_eq!(json["actor"]["id"], "ocean");
472 assert_eq!(json["namespace"], "local");
473 assert_eq!(json["verb"], "create");
474 assert_eq!(json["decision"], "allow");
475 assert_eq!(json["gate_impl"], "AllowAllGate");
476 assert_eq!(json["session_id"], "sess-abc");
477 assert!(json.get("deny_reason").is_none() || json["deny_reason"].is_null());
479 assert_eq!(json["obligations"][0]["kind"], "audit");
481 assert_eq!(json["obligations"][0]["tag"], "create.attempt");
482 assert!(json["timestamp"].is_string());
484
485 let back: AuditEvent = serde_json::from_value(json).unwrap();
487 assert_eq!(back.verb, "create");
488 assert_eq!(back.decision, AuditDecision::Allow);
489 assert!(back.deny_reason.is_none());
490 assert_eq!(back.obligations.len(), 1);
491 }
492
493 #[test]
494 fn audit_event_deny_path_carries_reason() {
495 let req = sample_request(); let decision = GateDecision::deny("forbidden: no write for anonymous");
497 let ev = AuditEvent::from_check(&req, &decision, "RegoGate");
498
499 let json = serde_json::to_value(&ev).unwrap();
500
501 assert_eq!(json["decision"], "deny");
502 assert_eq!(json["deny_reason"], "forbidden: no write for anonymous");
503 assert_eq!(json["gate_impl"], "RegoGate");
504 assert_eq!(
506 json["obligations"],
507 serde_json::Value::Array(Vec::new()),
508 "obligations must be an empty array on Deny, not omitted"
509 );
510 assert!(json.get("session_id").is_none() || json["session_id"].is_null());
512 }
513
514 #[test]
515 fn audit_event_allow_no_obligations() {
516 let req = sample_request();
517 let decision = GateDecision::allow();
518 let ev = AuditEvent::from_check(&req, &decision, "AllowAllGate");
519 assert_eq!(ev.decision, AuditDecision::Allow);
520 assert!(ev.deny_reason.is_none());
521 assert!(ev.obligations.is_empty());
522 let json = serde_json::to_value(&ev).unwrap();
526 assert_eq!(
527 json["obligations"],
528 serde_json::Value::Array(Vec::new()),
529 "obligations must serialize as an empty array, not be omitted"
530 );
531 }
532
533 #[test]
534 fn audit_decision_serialises_as_snake_case() {
535 let allow = serde_json::to_value(AuditDecision::Allow).unwrap();
536 assert_eq!(allow, "allow");
537 let deny = serde_json::to_value(AuditDecision::Deny).unwrap();
538 assert_eq!(deny, "deny");
539 }
540}