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