use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use cheers_core::{Actor, PrincipalId, Scope, StoreError};
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum AuditValidationError {
#[error("audit record: `at` must be > 0; got {0}")]
NonPositiveAt(i64),
#[error("audit record: `aud` must not be empty")]
EmptyAud,
#[error("audit record: `method` must not be empty")]
EmptyMethod,
#[error("audit record: `result` must not be empty")]
EmptyResult,
#[error("audit record: `request_id` must not be empty")]
EmptyRequestId,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct AuditRecord {
pub at: i64,
pub sub: PrincipalId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub act: Option<Actor>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub camp_id: Option<String>,
pub aud: String,
pub method: String,
#[serde(default)]
pub scope: Vec<Scope>,
pub result: String,
pub request_id: String,
}
impl AuditRecord {
#[allow(clippy::too_many_arguments)]
pub fn new(
at: i64,
sub: PrincipalId,
act: Option<Actor>,
camp_id: Option<String>,
aud: impl Into<String>,
method: impl Into<String>,
scope: Vec<Scope>,
result: impl Into<String>,
request_id: impl Into<String>,
) -> Result<Self, AuditValidationError> {
let rec = Self {
at,
sub,
act,
camp_id,
aud: aud.into(),
method: method.into(),
scope,
result: result.into(),
request_id: request_id.into(),
};
rec.validate()?;
Ok(rec)
}
pub fn validate(&self) -> Result<(), AuditValidationError> {
if self.at <= 0 {
return Err(AuditValidationError::NonPositiveAt(self.at));
}
if self.aud.is_empty() {
return Err(AuditValidationError::EmptyAud);
}
if self.method.is_empty() {
return Err(AuditValidationError::EmptyMethod);
}
if self.result.is_empty() {
return Err(AuditValidationError::EmptyResult);
}
if self.request_id.is_empty() {
return Err(AuditValidationError::EmptyRequestId);
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct AuditRow {
pub id: String,
pub record: AuditRecord,
pub ingested_at: i64,
}
impl AuditRow {
pub fn new(id: String, record: AuditRecord, ingested_at: i64) -> Self {
Self {
id,
record,
ingested_at,
}
}
}
#[async_trait]
pub trait AuditStore: Send + Sync {
async fn insert_batch(
&self,
records: &[AuditRecord],
ingested_at: i64,
) -> Result<Vec<AuditRow>, StoreError>;
}
#[derive(Default, Clone)]
pub struct MemoryAuditStore {
inner: Arc<Mutex<MemoryAuditInner>>,
}
#[derive(Default)]
struct MemoryAuditInner {
next_seq: u64,
rows: VecDeque<AuditRow>,
}
impl MemoryAuditStore {
pub fn new() -> Self {
Self::default()
}
pub fn snapshot(&self) -> Vec<AuditRow> {
self.inner
.lock()
.expect("audit store mutex poisoned")
.rows
.iter()
.cloned()
.collect()
}
}
impl std::fmt::Debug for MemoryAuditStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MemoryAuditStore").finish_non_exhaustive()
}
}
#[async_trait]
impl AuditStore for MemoryAuditStore {
async fn insert_batch(
&self,
records: &[AuditRecord],
ingested_at: i64,
) -> Result<Vec<AuditRow>, StoreError> {
let mut g = self.inner.lock().expect("audit store mutex poisoned");
let mut out = Vec::with_capacity(records.len());
for rec in records {
g.next_seq += 1;
let id = format!("audit-{:016x}", g.next_seq);
let row = AuditRow::new(id, rec.clone(), ingested_at);
g.rows.push_back(row.clone());
out.push(row);
}
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
use cheers_core::PrincipalId;
use pollster::block_on;
fn rec(method: &str, request_id: &str) -> AuditRecord {
AuditRecord::new(
1_700_000_000,
PrincipalId::user("alice"),
None,
Some("camp-a".into()),
"https://kamaji.example",
method,
vec![Scope::CloudDeploy],
"allow",
request_id,
)
.unwrap()
}
#[test]
fn new_accepts_well_formed_record() {
let r = rec("POST /cloud/deploy", "req-1");
assert_eq!(r.sub, PrincipalId::user("alice"));
assert_eq!(r.method, "POST /cloud/deploy");
assert_eq!(r.result, "allow");
assert_eq!(r.scope, vec![Scope::CloudDeploy]);
}
#[test]
fn validate_rejects_each_empty_field() {
let mut r = rec("m", "rid");
r.at = 0;
assert_eq!(r.validate(), Err(AuditValidationError::NonPositiveAt(0)));
r.at = -1;
assert_eq!(r.validate(), Err(AuditValidationError::NonPositiveAt(-1)));
let mut r = rec("m", "rid");
r.aud = String::new();
assert_eq!(r.validate(), Err(AuditValidationError::EmptyAud));
let mut r = rec("m", "rid");
r.method = String::new();
assert_eq!(r.validate(), Err(AuditValidationError::EmptyMethod));
let mut r = rec("m", "rid");
r.result = String::new();
assert_eq!(r.validate(), Err(AuditValidationError::EmptyResult));
let mut r = rec("m", "rid");
r.request_id = String::new();
assert_eq!(r.validate(), Err(AuditValidationError::EmptyRequestId));
}
#[test]
fn validate_allows_empty_scope_list() {
let mut r = rec("m", "rid");
r.scope.clear();
r.validate().expect("empty scope is allowed");
}
#[test]
fn memory_store_insert_batch_preserves_order_and_stamps_metadata() {
let store = MemoryAuditStore::new();
let batch = vec![
rec("m1", "rid-1"),
rec("m2", "rid-2"),
rec("m3", "rid-3"),
];
let rows = block_on(store.insert_batch(&batch, 5_000)).unwrap();
assert_eq!(rows.len(), 3);
assert_eq!(rows[0].record.method, "m1");
assert_eq!(rows[1].record.method, "m2");
assert_eq!(rows[2].record.method, "m3");
for row in &rows {
assert_eq!(row.ingested_at, 5_000);
assert!(!row.id.is_empty());
}
let ids: std::collections::HashSet<_> = rows.iter().map(|r| &r.id).collect();
assert_eq!(ids.len(), 3);
let more = vec![rec("m4", "rid-4")];
block_on(store.insert_batch(&more, 6_000)).unwrap();
let snap = store.snapshot();
assert_eq!(snap.len(), 4);
assert_eq!(snap[3].record.method, "m4");
assert_eq!(snap[3].ingested_at, 6_000);
}
#[test]
fn memory_store_empty_batch_is_a_noop() {
let store = MemoryAuditStore::new();
let rows = block_on(store.insert_batch(&[], 1_000)).unwrap();
assert!(rows.is_empty());
assert!(store.snapshot().is_empty());
}
#[test]
fn audit_record_round_trips_through_serde() {
let r = rec("POST /x", "rid");
let json = serde_json::to_string(&r).unwrap();
let back: AuditRecord = serde_json::from_str(&json).unwrap();
assert_eq!(back, r);
}
#[test]
fn audit_record_serde_omits_optional_none_fields() {
let r = rec("m", "rid");
let json = serde_json::to_string(&r).unwrap();
assert!(!json.contains("\"act\""), "act:None must be skipped: {json}");
assert!(json.contains("\"camp_id\""), "camp_id:Some(_) must serialize: {json}");
}
#[test]
fn trait_is_dyn_compatible() {
fn _u(_: &dyn AuditStore) {}
}
}