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::default_ns(),
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 obligations are **advisory** — the dispatcher SHOULD log them but is
116/// not required to enforce. Enforcement (real rate limiting, hard audit
117/// writes) is a follow-up.
118#[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    /// Escape hatch for policy-specific obligations. `value` accepts ARBITRARY
129    /// JSON (objects, arrays, scalars, null) — the struct-like variant shape
130    /// is required because serde's internally-tagged enums cannot merge the
131    /// `kind` discriminator into a non-object newtype payload.
132    Custom {
133        value: serde_json::Value,
134    },
135}
136
137// ---------- Decision ----------
138
139#[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// ---------- Error ----------
174
175#[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
185// ---------- Trait ----------
186
187/// Authorization gate consulted before each verb dispatch.
188///
189/// Implementations live downstream:
190/// - `AllowAllGate` (this crate) — permissive default
191/// - `RegoGate` (Apache-2.0 sibling crate `khive-gate-rego`, ADR-032) —
192///   regorus-backed Rego eval
193/// - `LionGate<G>` (khive-cloud, BUSL) — wraps any `Gate` with lion-core
194///   capability witnesses for verifiable enforcement.
195pub trait Gate: Send + Sync + std::fmt::Debug {
196    fn check(&self, req: &GateRequest) -> Result<GateDecision, GateError>;
197
198    /// Short name of this backend — surfaced in audit events (ADR-033) so
199    /// downstream tooling can tell `RegoGate` results apart from
200    /// `LionGate<RegoGate>` results without parsing the type.
201    fn impl_name(&self) -> &'static str {
202        "Gate"
203    }
204}
205
206// ---------- Audit event (ADR-033) ----------
207
208/// Structured audit record emitted once per gate consultation (ADR-033).
209///
210/// The JSON projection of this struct is the **public contract** — field names
211/// are stable. Adding fields is non-breaking; removing or renaming requires a
212/// new ADR.
213///
214/// In v0.2 events are emitted via `tracing::info!` as structured JSON. The
215/// `EventStore` write path is deferred to v0.3 when the `VerbRegistry` gains
216/// a runtime handle (see ADR-033 §"Implementation Status").
217#[derive(Clone, Debug, Serialize, Deserialize)]
218pub struct AuditEvent {
219    /// Wall-clock timestamp of the gate check (UTC, RFC3339 in JSON).
220    pub timestamp: DateTime<Utc>,
221    /// Caller identity as given to the gate.
222    pub actor: ActorRef,
223    /// Namespace in which the verb was invoked.
224    pub namespace: String,
225    /// Verb being dispatched.
226    pub verb: String,
227    /// Gate outcome — `"allow"` or `"deny"`.
228    pub decision: AuditDecision,
229    /// Deny reason, present only when `decision == "deny"`.
230    #[serde(default, skip_serializing_if = "Option::is_none")]
231    pub deny_reason: Option<String>,
232    /// Obligations attached by the policy on Allow (empty array on Deny).
233    /// Always serialized — `obligations: []` is the wire shape when there
234    /// are none, so non-Rust consumers do not need to special-case absence
235    /// vs. emptiness.
236    #[serde(default)]
237    pub obligations: Vec<Obligation>,
238    /// Name of the gate implementation that produced this decision.
239    pub gate_impl: String,
240    /// Correlation token — `GateContext::session_id` when present, else `None`.
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub session_id: Option<String>,
243}
244
245/// The outcome field of an [`AuditEvent`], serialised as `"allow"` / `"deny"`.
246#[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    /// Build an `AuditEvent` from the gate inputs and output.
255    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
278/// Shareable handle to a `Gate` impl.
279pub type GateRef = Arc<dyn Gate>;
280
281// ---------- Default impl ----------
282
283/// Permissive gate — every request is allowed with no obligations.
284///
285/// This is the runtime default. Replace it in `RuntimeConfig.gate` for any
286/// deployment that needs real authorization.
287#[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// ---------- Tests ----------
301
302#[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    // `Obligation::Custom` must carry arbitrary JSON per ADR-029. The
394    // struct-like variant shape is mandatory here because an internally-tagged
395    // newtype variant cannot merge the `kind` discriminator into a non-object
396    // payload — a previous newtype shape failed for scalar/array values at
397    // runtime instead of compile time, exactly the foot-gun this guards.
398    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    // ---- AuditEvent (ADR-033) ----
443
444    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        // All required fields present with correct values.
469        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        // deny_reason absent on Allow.
477        assert!(json.get("deny_reason").is_none() || json["deny_reason"].is_null());
478        // obligations populated.
479        assert_eq!(json["obligations"][0]["kind"], "audit");
480        assert_eq!(json["obligations"][0]["tag"], "create.attempt");
481        // timestamp present and non-null.
482        assert!(json["timestamp"].is_string());
483
484        // Full round-trip.
485        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(); // anonymous, no session
495        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        // obligations is always present on the wire, empty on Deny.
504        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        // session_id absent when not in context.
510        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        // obligations is always present on the wire as an empty array — the
522        // public JSON contract does not depend on Rust's `#[serde(default)]`
523        // behavior at the consumer side.
524        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}