use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::{ActorRef, GateDecision, Obligation};
#[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>,
#[serde(default)]
pub op_index: Option<u32>,
#[serde(default)]
pub ref_resolution: Option<khive_types::RefResolution>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuditDecision {
Allow,
Deny,
GateUnavailable,
}
impl AuditEvent {
pub fn with_operation_attribution(
mut self,
operation: Option<khive_types::OperationAttribution>,
) -> Self {
self.op_index = operation.map(|operation| operation.op_index);
self.ref_resolution = operation.map(|operation| operation.ref_resolution);
self
}
pub fn from_check(req: &crate::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(),
op_index: None,
ref_resolution: None,
}
}
pub fn gate_unavailable(req: &crate::GateRequest, gate_impl: &str) -> Self {
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: AuditDecision::GateUnavailable,
deny_reason: None,
obligations: Vec::new(),
gate_impl: gate_impl.to_string(),
session_id: req.context.session_id.clone(),
op_index: None,
ref_resolution: None,
}
}
}
#[cfg(test)]
mod operation_tests {
use super::*;
use khive_types::{OperationAttribution, RefResolution};
#[test]
fn audit_operation_fields_are_closed_and_legacy_absence_stays_unknown() {
let request = crate::GateRequest::new(
crate::ActorRef::anonymous(),
khive_types::Namespace::local(),
"get",
serde_json::json!({}),
);
let direct = AuditEvent::from_check(
&request,
&GateDecision::Allow {
obligations: vec![],
},
"test",
);
assert_eq!((direct.op_index, direct.ref_resolution), (None, None));
let mut old = serde_json::to_value(&direct).unwrap();
old.as_object_mut().unwrap().remove("op_index");
old.as_object_mut().unwrap().remove("ref_resolution");
let legacy: AuditEvent = serde_json::from_value(old).unwrap();
assert_eq!((legacy.op_index, legacy.ref_resolution), (None, None));
let attributed = direct.with_operation_attribution(Some(OperationAttribution {
op_index: 3,
ref_resolution: RefResolution::Resolved,
}));
let mut value = serde_json::to_value(attributed).unwrap();
assert_eq!(value["op_index"], 3);
assert_eq!(value["ref_resolution"], "resolved");
value["ref_resolution"] = serde_json::json!("unknown");
assert!(serde_json::from_value::<AuditEvent>(value).is_err());
}
}