Skip to main content

khive_gate/
lib.rs

1//! khive-gate — pluggable authorization gate for verb dispatch.
2//!
3//! The runtime consults a `Gate` impl before dispatching each verb. The default
4//! `AllowAllGate` is permissive (suitable for personal/local deployments). For
5//! production policy enforcement, plug a Rego-backed or capability-witness-backed
6//! impl into `RuntimeConfig.gate`.
7//!
8//! # Quick start
9//!
10//! ```
11//! use std::sync::Arc;
12//! use khive_gate::{AllowAllGate, Gate, GateRef, GateRequest, ActorRef};
13//! use khive_types::Namespace;
14//! use serde_json::json;
15//!
16//! let gate: GateRef = Arc::new(AllowAllGate);
17//! let req = GateRequest::new(
18//!     ActorRef::anonymous(),
19//!     Namespace::local(),
20//!     "search",
21//!     json!({"query": "LoRA"}),
22//! );
23//! assert!(gate.check(&req).unwrap().is_allow());
24//! ```
25
26use std::sync::Arc;
27
28use chrono::{DateTime, Utc};
29use khive_types::Namespace;
30use serde::{Deserialize, Serialize};
31use thiserror::Error;
32
33// ---------- Actor ----------
34
35/// Caller identity. `kind` distinguishes user vs agent vs lambda etc.
36#[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    /// The implicit caller for unauthenticated local usage.
51    pub fn anonymous() -> Self {
52        Self {
53            kind: "anonymous".into(),
54            id: "local".into(),
55        }
56    }
57}
58
59// ---------- Context ----------
60
61/// Per-request context — session, timing, transport source.
62#[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// ---------- Request ----------
73
74/// What the gate sees on every verb invocation.
75///
76/// The JSON projection of this struct is the input shape policies receive
77/// (e.g. Rego's `input.actor`, `input.verb`, `input.args`). The shape is a
78/// public contract — changing field names is a breaking change.
79#[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// ---------- Obligation ----------
112
113/// Side-effects a policy may attach to an `Allow` decision.
114///
115/// v0 obligation handling is intentionally narrow:
116/// - `Audit` obligations are persisted inside the dispatch `AuditEvent` when an
117///   `EventStore` is wired; otherwise they are emitted through tracing only.
118/// - `RateLimit` and `Custom` obligations are NOT enforced in v0.
119#[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    /// Escape hatch for policy-specific obligations. `value` accepts ARBITRARY
130    /// JSON (objects, arrays, scalars, null) — the struct-like variant shape
131    /// is required because serde's internally-tagged enums cannot merge the
132    /// `kind` discriminator into a non-object newtype payload.
133    Custom {
134        value: serde_json::Value,
135    },
136}
137
138// ---------- Decision ----------
139
140#[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// ---------- Error ----------
175
176#[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
186// ---------- Trait ----------
187
188/// Authorization gate consulted before each verb dispatch.
189///
190/// Implementations live downstream:
191/// - `AllowAllGate` (this crate) — permissive default
192/// - `RegoGate` (Apache-2.0 sibling crate `khive-gate-rego`, ADR-032) —
193///   regorus-backed Rego eval
194/// - `LionGate<G>` (khive-cloud, BUSL) — wraps any `Gate` with lion-core
195///   capability witnesses for verifiable enforcement.
196pub trait Gate: Send + Sync + std::fmt::Debug {
197    fn check(&self, req: &GateRequest) -> Result<GateDecision, GateError>;
198
199    /// Short name of this backend — surfaced in audit events (ADR-018) so
200    /// downstream tooling can tell `RegoGate` results apart from
201    /// `LionGate<RegoGate>` results without parsing the type.
202    fn impl_name(&self) -> &'static str {
203        "Gate"
204    }
205}
206
207// ---------- Audit event (ADR-018) ----------
208
209/// Structured audit record emitted once per gate consultation (ADR-018).
210///
211/// The JSON projection of this struct is the **public contract** — field names
212/// are stable. Adding fields is non-breaking; removing or renaming requires a
213/// new ADR.
214///
215/// Events are emitted via `tracing::info!` as structured JSON. When the
216/// dispatch registry is configured with an `EventStore`, the same envelope is
217/// also persisted as an audit event.
218#[derive(Clone, Debug, Serialize, Deserialize)]
219pub struct AuditEvent {
220    /// Wall-clock timestamp of the gate check (UTC, RFC3339 in JSON).
221    pub timestamp: DateTime<Utc>,
222    /// Caller identity as given to the gate.
223    pub actor: ActorRef,
224    /// Namespace in which the verb was invoked.
225    pub namespace: String,
226    /// Verb being dispatched.
227    pub verb: String,
228    /// Gate outcome — `"allow"` or `"deny"`.
229    pub decision: AuditDecision,
230    /// Deny reason, present only when `decision == "deny"`.
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub deny_reason: Option<String>,
233    /// Obligations attached by the policy on Allow (empty array on Deny).
234    /// Always serialized — `obligations: []` is the wire shape when there
235    /// are none, so non-Rust consumers do not need to special-case absence
236    /// vs. emptiness.
237    #[serde(default)]
238    pub obligations: Vec<Obligation>,
239    /// Name of the gate implementation that produced this decision.
240    pub gate_impl: String,
241    /// Correlation token — `GateContext::session_id` when present, else `None`.
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub session_id: Option<String>,
244}
245
246/// The outcome field of an [`AuditEvent`], serialised as `"allow"` / `"deny"`.
247#[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    /// Build an `AuditEvent` from the gate inputs and output.
256    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
279/// Shareable handle to a `Gate` impl.
280pub type GateRef = Arc<dyn Gate>;
281
282// ---------- Default impl ----------
283
284/// Permissive gate — every request is allowed with no obligations.
285///
286/// This is the runtime default. Replace it in `RuntimeConfig.gate` for any
287/// deployment that needs real authorization.
288#[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// ---------- Tests ----------
302
303#[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    // `Obligation::Custom` must carry arbitrary JSON per ADR-018. The
395    // struct-like variant shape is mandatory here because an internally-tagged
396    // newtype variant cannot merge the `kind` discriminator into a non-object
397    // payload — a previous newtype shape failed for scalar/array values at
398    // runtime instead of compile time, exactly the foot-gun this guards.
399    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    // ---- AuditEvent (ADR-018) ----
444
445    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        // All required fields present with correct values.
470        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        // deny_reason absent on Allow.
478        assert!(json.get("deny_reason").is_none() || json["deny_reason"].is_null());
479        // obligations populated.
480        assert_eq!(json["obligations"][0]["kind"], "audit");
481        assert_eq!(json["obligations"][0]["tag"], "create.attempt");
482        // timestamp present and non-null.
483        assert!(json["timestamp"].is_string());
484
485        // Full round-trip.
486        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(); // anonymous, no session
496        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        // obligations is always present on the wire, empty on Deny.
505        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        // session_id absent when not in context.
511        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        // obligations is always present on the wire as an empty array — the
523        // public JSON contract does not depend on Rust's `#[serde(default)]`
524        // behavior at the consumer side.
525        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}