1use std::borrow::Cow;
16use std::collections::HashMap;
17use std::sync::Arc;
18
19use crate::auth::UserId;
20use crate::crypto::uuid::Uuid;
21use crate::storage::unified::{EntityData, EntityId, EntityKind, RowData, UnifiedEntity};
22use crate::storage::UnifiedStore;
23use crate::utils::now_unix_millis;
24use reddb_types::encoding::json_escape;
25use reddb_types::types::Value;
26
27pub const CONTROL_EVENTS_COLLECTION: &str = "red.control_events";
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
37pub enum EventKind {
38 PolicyCreate,
39 PolicyUpdate,
40 PolicyDelete,
41 PolicyAttach,
42 PolicyDetach,
43 ConfigWrite,
44 ConfigDelete,
45 UserCreate,
46 UserUpdate,
47 UserDelete,
48 UserDisable,
49 ApiKeyCreate,
50 ApiKeyRevoke,
51 VaultMetadataRead,
52 VaultRead,
53 VaultUnseal,
54 VaultReveal,
55 VaultUse,
56 VaultRotate,
57 VaultPurge,
58 SchemaDdl,
59 TenantGovernance,
60 RlsGovernance,
61 BackupRun,
62 RestoreRun,
63 FailoverPromotion,
64 ReplicationSafety,
65 EvidenceExport,
66 PolicyBreakGlass,
70 AuthLogin,
72 AuthLoginFailed,
75 AuthDisabled,
78}
79
80impl EventKind {
81 pub fn as_str(self) -> &'static str {
82 match self {
83 Self::PolicyCreate => "policy.create",
84 Self::PolicyUpdate => "policy.update",
85 Self::PolicyDelete => "policy.delete",
86 Self::PolicyAttach => "policy.attach",
87 Self::PolicyDetach => "policy.detach",
88 Self::ConfigWrite => "config.write",
89 Self::ConfigDelete => "config.delete",
90 Self::UserCreate => "user.create",
91 Self::UserUpdate => "user.update",
92 Self::UserDelete => "user.delete",
93 Self::UserDisable => "user.disable",
94 Self::ApiKeyCreate => "apikey.create",
95 Self::ApiKeyRevoke => "apikey.revoke",
96 Self::VaultMetadataRead => "vault.metadata_read",
97 Self::VaultRead => "vault.read",
98 Self::VaultUnseal => "vault.unseal",
99 Self::VaultReveal => "vault.reveal",
100 Self::VaultUse => "vault.use",
101 Self::VaultRotate => "vault.rotate",
102 Self::VaultPurge => "vault.purge",
103 Self::SchemaDdl => "schema.ddl",
104 Self::TenantGovernance => "tenant.governance",
105 Self::RlsGovernance => "rls.governance",
106 Self::BackupRun => "backup.run",
107 Self::RestoreRun => "restore.run",
108 Self::FailoverPromotion => "failover.promotion",
109 Self::ReplicationSafety => "replication.safety",
110 Self::EvidenceExport => "evidence.export",
111 Self::PolicyBreakGlass => "policy.break_glass",
112 Self::AuthLogin => "auth.login",
113 Self::AuthLoginFailed => "auth.login_failed",
114 Self::AuthDisabled => "auth.disabled",
115 }
116 }
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
124pub enum Outcome {
125 Allowed,
126 Denied,
127 Error,
128}
129
130impl Outcome {
131 pub fn as_str(self) -> &'static str {
132 match self {
133 Self::Allowed => "allowed",
134 Self::Denied => "denied",
135 Self::Error => "error",
136 }
137 }
138}
139
140#[derive(Debug)]
147pub enum ActorRef<'a> {
148 User(&'a UserId),
149 System(&'static str),
153 Anonymous,
154}
155
156impl<'a> ActorRef<'a> {
157 pub fn user_id(&self) -> Option<&UserId> {
158 match self {
159 Self::User(u) => Some(u),
160 _ => None,
161 }
162 }
163
164 pub fn kind_str(&self) -> &'static str {
165 match self {
166 Self::User(_) => "user",
167 Self::System(_) => "system",
168 Self::Anonymous => "anonymous",
169 }
170 }
171}
172
173pub struct ControlEventCtx<'a> {
176 pub actor: ActorRef<'a>,
177 pub scope: Option<Cow<'a, str>>,
178 pub request_id: Option<Cow<'a, str>>,
179 pub trace_id: Option<Cow<'a, str>>,
180}
181
182pub struct ControlEvent {
187 pub kind: EventKind,
188 pub outcome: Outcome,
189 pub action: Cow<'static, str>,
190 pub resource: Option<String>,
191 pub reason: Option<String>,
192 pub matched_policy_id: Option<String>,
193 pub fields: HashMap<String, Sensitivity>,
194}
195
196#[derive(Debug, Clone, PartialEq, Eq)]
204pub enum Sensitivity {
205 Raw(String),
207 Hashed { algo: &'static str, hex: String },
211 Redacted,
214}
215
216impl Sensitivity {
217 pub fn raw<S: Into<String>>(s: S) -> Self {
218 Self::Raw(s.into())
219 }
220
221 pub fn hashed(value: &[u8]) -> Self {
222 let hex = blake3::hash(value).to_hex().to_string();
223 Self::Hashed {
224 algo: "blake3",
225 hex,
226 }
227 }
228
229 pub fn redacted() -> Self {
230 Self::Redacted
231 }
232}
233
234#[derive(Debug, Clone, PartialEq, Eq)]
241pub struct EventId(pub String);
242
243#[derive(Debug)]
244pub enum ControlEventError {
245 Persistence(String),
246}
247
248impl std::fmt::Display for ControlEventError {
249 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
250 match self {
251 Self::Persistence(msg) => write!(f, "control-event persistence failed: {msg}"),
252 }
253 }
254}
255
256impl std::error::Error for ControlEventError {}
257
258pub trait ControlEventLedger: Send + Sync {
266 fn emit(
267 &self,
268 ctx: &ControlEventCtx<'_>,
269 event: ControlEvent,
270 ) -> Result<EventId, ControlEventError>;
271}
272
273#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
281pub struct ControlEventConfig {
282 pub compliance_mode: bool,
285}
286
287impl ControlEventConfig {
288 pub fn require_persistence(&self) -> bool {
291 self.compliance_mode
292 }
293}
294
295pub struct RuntimeLedger {
303 store: Arc<UnifiedStore>,
304}
305
306impl RuntimeLedger {
307 pub fn new(store: Arc<UnifiedStore>) -> Self {
308 let _ = store.get_or_create_collection(CONTROL_EVENTS_COLLECTION);
309 Self { store }
310 }
311}
312
313impl ControlEventLedger for RuntimeLedger {
314 fn emit(
315 &self,
316 ctx: &ControlEventCtx<'_>,
317 event: ControlEvent,
318 ) -> Result<EventId, ControlEventError> {
319 let ts_ms = now_unix_millis();
320 let ts_ns = (ts_ms as i128)
324 .saturating_mul(1_000_000)
325 .min(i64::MAX as i128) as i64;
326 let id = Uuid::new_v7().to_string();
330
331 let mut named: HashMap<String, Value> = HashMap::with_capacity(14);
332 named.insert("id".into(), Value::text(id.clone()));
333 named.insert("ts".into(), Value::Integer(ts_ns));
334 named.insert("kind".into(), Value::text(event.kind.as_str()));
335 named.insert("outcome".into(), Value::text(event.outcome.as_str()));
336 named.insert("actor_kind".into(), Value::text(ctx.actor.kind_str()));
337 named.insert(
338 "actor_user_id".into(),
339 ctx.actor
340 .user_id()
341 .map(|u| Value::text(u.to_string()))
342 .unwrap_or(Value::Null),
343 );
344 named.insert(
345 "scope".into(),
346 ctx.scope
347 .as_ref()
348 .map(|s| Value::text(s.to_string()))
349 .unwrap_or(Value::Null),
350 );
351 named.insert("action".into(), Value::text(event.action.to_string()));
352 named.insert(
353 "resource".into(),
354 event.resource.map(Value::text).unwrap_or(Value::Null),
355 );
356 named.insert(
357 "reason".into(),
358 event.reason.map(Value::text).unwrap_or(Value::Null),
359 );
360 named.insert(
361 "matched_policy_id".into(),
362 event
363 .matched_policy_id
364 .map(Value::text)
365 .unwrap_or(Value::Null),
366 );
367 named.insert(
368 "request_id".into(),
369 ctx.request_id
370 .as_ref()
371 .map(|s| Value::text(s.to_string()))
372 .unwrap_or(Value::Null),
373 );
374 named.insert(
375 "trace_id".into(),
376 ctx.trace_id
377 .as_ref()
378 .map(|s| Value::text(s.to_string()))
379 .unwrap_or(Value::Null),
380 );
381 named.insert(
382 "fields_json".into(),
383 Value::text(serialise_fields(&event.fields)),
384 );
385
386 let entity = UnifiedEntity::new(
387 EntityId::new(0),
388 EntityKind::TableRow {
389 table: Arc::from(CONTROL_EVENTS_COLLECTION),
390 row_id: 0,
391 },
392 EntityData::Row(RowData {
393 columns: Vec::new(),
394 named: Some(named),
395 schema: None,
396 }),
397 );
398 self.store
399 .insert_auto(CONTROL_EVENTS_COLLECTION, entity)
400 .map_err(|e| ControlEventError::Persistence(e.to_string()))?;
401 Ok(EventId(id))
402 }
403}
404
405fn serialise_fields(fields: &HashMap<String, Sensitivity>) -> String {
410 let mut keys: Vec<&String> = fields.keys().collect();
411 keys.sort();
412 let mut out = String::from("{");
413 for (i, k) in keys.iter().enumerate() {
414 if i > 0 {
415 out.push(',');
416 }
417 out.push('"');
418 out.push_str(&json_escape(k));
419 out.push_str("\":");
420 match &fields[*k] {
421 Sensitivity::Raw(s) => {
422 out.push_str(r#"{"kind":"raw","value":""#);
423 out.push_str(&json_escape(s));
424 out.push_str(r#""}"#);
425 }
426 Sensitivity::Hashed { algo, hex } => {
427 out.push_str(r#"{"kind":"hashed","algo":""#);
428 out.push_str(algo);
429 out.push_str(r#"","hex":""#);
430 out.push_str(hex);
431 out.push_str(r#""}"#);
432 }
433 Sensitivity::Redacted => {
434 out.push_str(r#"{"kind":"redacted"}"#);
435 }
436 }
437 }
438 out.push('}');
439 out
440}
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445
446 fn anon_ctx<'a>() -> ControlEventCtx<'a> {
447 ControlEventCtx {
448 actor: ActorRef::Anonymous,
449 scope: None,
450 request_id: None,
451 trace_id: None,
452 }
453 }
454
455 fn sample_event() -> ControlEvent {
456 ControlEvent {
457 kind: EventKind::PolicyCreate,
458 outcome: Outcome::Allowed,
459 action: Cow::Borrowed("policy.write"),
460 resource: Some("policy:test".into()),
461 reason: None,
462 matched_policy_id: Some("p-abc".into()),
463 fields: HashMap::new(),
464 }
465 }
466
467 #[test]
468 fn collection_is_created_on_first_open_and_reopen_is_idempotent() {
469 let store = Arc::new(UnifiedStore::new());
470 let _l1 = RuntimeLedger::new(store.clone());
471 assert!(store.get_collection(CONTROL_EVENTS_COLLECTION).is_some());
472 let _l2 = RuntimeLedger::new(store.clone());
476 assert!(store.get_collection(CONTROL_EVENTS_COLLECTION).is_some());
477 }
478
479 #[test]
480 fn runtime_ledger_emit_persists_row_with_every_schema_column() {
481 let store = Arc::new(UnifiedStore::new());
482 let ledger = RuntimeLedger::new(store.clone());
483 let id = ledger.emit(&anon_ctx(), sample_event()).expect("emit ok");
484 assert!(!id.0.is_empty(), "row id must be populated");
485
486 let manager = store
487 .get_collection(CONTROL_EVENTS_COLLECTION)
488 .expect("table exists");
489 let rows = manager.query_all(|_| true);
490 assert_eq!(rows.len(), 1);
491
492 match &rows[0].data {
493 EntityData::Row(row) => {
494 let named = row.named.as_ref().expect("named columns present");
495 for col in [
496 "id",
497 "ts",
498 "kind",
499 "outcome",
500 "actor_kind",
501 "actor_user_id",
502 "scope",
503 "action",
504 "resource",
505 "reason",
506 "matched_policy_id",
507 "request_id",
508 "trace_id",
509 "fields_json",
510 ] {
511 assert!(named.contains_key(col), "missing schema column {col}");
512 }
513 assert_eq!(named["kind"], Value::text("policy.create"));
514 assert_eq!(named["outcome"], Value::text("allowed"));
515 assert_eq!(named["actor_kind"], Value::text("anonymous"));
516 assert_eq!(named["actor_user_id"], Value::Null);
517 assert_eq!(named["scope"], Value::Null);
518 assert_eq!(named["resource"], Value::text("policy:test"));
519 assert_eq!(named["matched_policy_id"], Value::text("p-abc"));
520 }
521 other => panic!("expected Row, got {other:?}"),
522 }
523 }
524
525 #[test]
526 fn emit_with_user_actor_records_kind_and_label() {
527 let store = Arc::new(UnifiedStore::new());
528 let ledger = RuntimeLedger::new(store.clone());
529 let user = UserId::scoped("acme", "alice");
530 let ctx = ControlEventCtx {
531 actor: ActorRef::User(&user),
532 scope: Some(Cow::Borrowed("acme")),
533 request_id: Some(Cow::Borrowed("req-42")),
534 trace_id: None,
535 };
536 ledger.emit(&ctx, sample_event()).unwrap();
537 let manager = store.get_collection(CONTROL_EVENTS_COLLECTION).unwrap();
538 let rows = manager.query_all(|_| true);
539 let named = match &rows[0].data {
540 EntityData::Row(r) => r.named.as_ref().unwrap(),
541 _ => panic!(),
542 };
543 assert_eq!(named["actor_kind"], Value::text("user"));
544 assert_eq!(named["actor_user_id"], Value::text("acme/alice"));
545 assert_eq!(named["scope"], Value::text("acme"));
546 assert_eq!(named["request_id"], Value::text("req-42"));
547 assert_eq!(named["trace_id"], Value::Null);
548 }
549
550 #[test]
551 fn sensitivity_hashed_is_stable_blake3() {
552 let a = Sensitivity::hashed(b"hunter2");
553 let b = Sensitivity::hashed(b"hunter2");
554 assert_eq!(a, b);
555 match a {
556 Sensitivity::Hashed { algo, hex } => {
557 assert_eq!(algo, "blake3");
558 assert_eq!(hex.len(), 64);
559 assert_eq!(hex, blake3::hash(b"hunter2").to_hex().to_string(),);
562 }
563 other => panic!("expected Hashed, got {other:?}"),
564 }
565 }
566
567 #[test]
568 fn sensitivity_redacted_serialises_without_value() {
569 let mut fields = HashMap::new();
570 fields.insert("password".to_string(), Sensitivity::redacted());
571 let s = serialise_fields(&fields);
572 assert_eq!(s, r#"{"password":{"kind":"redacted"}}"#);
573 }
574
575 struct FailingLedger;
583 impl ControlEventLedger for FailingLedger {
584 fn emit(
585 &self,
586 _: &ControlEventCtx<'_>,
587 _: ControlEvent,
588 ) -> Result<EventId, ControlEventError> {
589 Err(ControlEventError::Persistence("simulated".into()))
590 }
591 }
592
593 fn caller_decides(
594 cfg: ControlEventConfig,
595 ledger: &dyn ControlEventLedger,
596 ) -> Result<(), &'static str> {
597 match ledger.emit(&anon_ctx(), sample_event()) {
598 Ok(_) => Ok(()),
599 Err(_) if cfg.require_persistence() => Err("aborted"),
600 Err(_) => Ok(()),
601 }
602 }
603
604 #[test]
605 fn compliance_mode_makes_callers_fail_closed_on_persistence_failure() {
606 let cfg = ControlEventConfig {
607 compliance_mode: true,
608 };
609 assert!(cfg.require_persistence());
610 assert_eq!(caller_decides(cfg, &FailingLedger), Err("aborted"));
611 }
612
613 #[test]
614 fn default_mode_lets_callers_continue_on_persistence_failure() {
615 let cfg = ControlEventConfig::default();
616 assert!(!cfg.require_persistence());
617 assert!(FailingLedger.emit(&anon_ctx(), sample_event()).is_err());
619 assert_eq!(caller_decides(cfg, &FailingLedger), Ok(()));
621 }
622}