1use std::sync::Arc;
7
8use awaken_server_contract::AuditAction;
9use awaken_server_contract::AuditEvent;
10use awaken_server_contract::contract::config_store::ConfigStore;
11use awaken_server_contract::contract::storage::StorageError;
12use axum::http::HeaderMap;
13use chrono::{DateTime, Utc};
14use serde_json::Value;
15use sha2::Digest;
16
17pub const AUDIT_NAMESPACE: &str = "_audit";
19
20#[derive(Debug, Clone, serde::Deserialize)]
22pub struct AuditQuery {
23 #[serde(default)]
25 pub since: Option<DateTime<Utc>>,
26 #[serde(default)]
28 pub until: Option<DateTime<Utc>>,
29 #[serde(default)]
31 pub action: Option<AuditAction>,
32 #[serde(default)]
34 pub resource: Option<String>,
35 #[serde(default)]
37 pub actor: Option<String>,
38 #[serde(default = "default_audit_limit")]
40 pub limit: usize,
41 #[serde(default)]
43 pub cursor: Option<String>,
44}
45
46impl Default for AuditQuery {
47 fn default() -> Self {
48 Self {
49 since: None,
50 until: None,
51 action: None,
52 resource: None,
53 actor: None,
54 limit: default_audit_limit(),
55 cursor: None,
56 }
57 }
58}
59
60fn default_audit_limit() -> usize {
61 100
62}
63
64#[derive(Debug, serde::Serialize)]
66pub struct AuditPage {
67 pub items: Vec<AuditEvent>,
68 pub next_cursor: Option<String>,
69}
70
71#[derive(Debug, thiserror::Error)]
73pub enum AuditQueryError {
74 #[error("invalid cursor")]
75 InvalidCursor,
76 #[error("storage error: {0}")]
77 Storage(#[from] StorageError),
78}
79
80pub struct AuditLogger {
82 store: Arc<dyn ConfigStore>,
83}
84
85impl AuditLogger {
86 pub fn new(store: Arc<dyn ConfigStore>) -> Self {
87 Self { store }
88 }
89
90 pub async fn emit(
93 &self,
94 action: AuditAction,
95 resource: &str,
96 before: Option<Value>,
97 after: Option<Value>,
98 headers: &HeaderMap,
99 ) {
100 let id = ulid::Ulid::new().to_string();
101 let ts = Utc::now().to_rfc3339();
102 let actor = derive_actor(headers);
103 let ip = extract_client_ip(headers);
104 let request_id = headers
105 .get("x-request-id")
106 .and_then(|v| v.to_str().ok())
107 .map(str::to_string);
108
109 let before = before.map(redact_secrets);
110 let after = after.map(redact_secrets);
111
112 let event = AuditEvent {
113 id: id.clone(),
114 ts,
115 actor,
116 action,
117 resource: resource.to_string(),
118 before,
119 after,
120 ip,
121 request_id,
122 restored_from: None,
123 error: None,
124 };
125
126 let value = match serde_json::to_value(&event) {
127 Ok(v) => v,
128 Err(error) => {
129 tracing::warn!(error = %error, "audit: failed to serialize event");
130 metrics::counter!("awaken_audit_write_failures_total").increment(1);
131 return;
132 }
133 };
134
135 if let Err(error) = self.store.put(AUDIT_NAMESPACE, &id, &value).await {
136 tracing::warn!(error = %error, "audit: failed to write event");
137 metrics::counter!("awaken_audit_write_failures_total").increment(1);
138 return;
139 }
140
141 let action_label = serde_json::to_value(&event.action)
142 .ok()
143 .and_then(|v| v.as_str().map(str::to_string))
144 .unwrap_or_else(|| "unknown".to_string());
145 metrics::counter!("awaken_audit_events_total", "action" => action_label).increment(1);
146 }
147
148 pub async fn emit_apply_failed(
152 &self,
153 resource: &str,
154 before: Option<Value>,
155 after: Option<Value>,
156 error_msg: String,
157 headers: &HeaderMap,
158 ) {
159 let id = ulid::Ulid::new().to_string();
160 let ts = Utc::now().to_rfc3339();
161 let actor = derive_actor(headers);
162 let ip = extract_client_ip(headers);
163 let request_id = headers
164 .get("x-request-id")
165 .and_then(|v| v.to_str().ok())
166 .map(str::to_string);
167
168 let before = before.map(redact_secrets);
169 let after = after.map(redact_secrets);
170
171 let event = AuditEvent {
172 id: id.clone(),
173 ts,
174 actor,
175 action: AuditAction::ApplyFailed,
176 resource: resource.to_string(),
177 before,
178 after,
179 ip,
180 request_id,
181 restored_from: None,
182 error: Some(error_msg),
183 };
184
185 let value = match serde_json::to_value(&event) {
186 Ok(v) => v,
187 Err(error) => {
188 tracing::warn!(error = %error, "audit: failed to serialize apply_failed event");
189 metrics::counter!("awaken_audit_write_failures_total").increment(1);
190 return;
191 }
192 };
193
194 if let Err(error) = self.store.put(AUDIT_NAMESPACE, &id, &value).await {
195 tracing::warn!(error = %error, "audit: failed to write apply_failed event");
196 metrics::counter!("awaken_audit_write_failures_total").increment(1);
197 return;
198 }
199
200 metrics::counter!("awaken_audit_events_total", "action" => "apply_failed").increment(1);
201 }
202
203 pub async fn get_event(&self, id: &str) -> Result<Option<AuditEvent>, StorageError> {
207 let value = self.store.get(AUDIT_NAMESPACE, id).await?;
208 value.map(|value| decode_audit_event(id, value)).transpose()
209 }
210
211 pub async fn emit_restore(
215 &self,
216 resource: &str,
217 before: Option<Value>,
218 after: Option<Value>,
219 restored_from: String,
220 headers: &HeaderMap,
221 ) {
222 let id = ulid::Ulid::new().to_string();
223 let ts = Utc::now().to_rfc3339();
224 let actor = derive_actor(headers);
225 let ip = extract_client_ip(headers);
226 let request_id = headers
227 .get("x-request-id")
228 .and_then(|v| v.to_str().ok())
229 .map(str::to_string);
230
231 let before = before.map(redact_secrets);
232 let after = after.map(redact_secrets);
233
234 let event = AuditEvent {
235 id: id.clone(),
236 ts,
237 actor,
238 action: AuditAction::Restore,
239 resource: resource.to_string(),
240 before,
241 after,
242 ip,
243 request_id,
244 restored_from: Some(restored_from),
245 error: None,
246 };
247
248 let value = match serde_json::to_value(&event) {
249 Ok(v) => v,
250 Err(error) => {
251 tracing::warn!(error = %error, "audit: failed to serialize restore event");
252 metrics::counter!("awaken_audit_write_failures_total").increment(1);
253 return;
254 }
255 };
256
257 if let Err(error) = self.store.put(AUDIT_NAMESPACE, &id, &value).await {
258 tracing::warn!(error = %error, "audit: failed to write restore event");
259 metrics::counter!("awaken_audit_write_failures_total").increment(1);
260 return;
261 }
262
263 metrics::counter!("awaken_audit_events_total", "action" => "restore").increment(1);
264 }
265
266 pub async fn emit_seed_report(&self, report: &crate::services::builtin_seed::SeedReport) {
274 use awaken_server_contract::AuditAction;
275 let buckets: [(&str, &[crate::services::builtin_seed::RecordRef]); 3] = [
276 ("created", &report.created),
277 ("updated", &report.updated),
278 ("deleted", &report.deleted),
279 ];
280 let mut ulid_gen = ulid::Generator::new();
281 for (label, entries) in buckets {
282 if entries.is_empty() {
283 continue;
284 }
285 let id = ulid_gen
286 .generate()
287 .unwrap_or_else(|_| ulid::Ulid::new())
288 .to_string();
289 let ts = Utc::now().to_rfc3339();
290 let after_payload = serde_json::json!({
291 "bucket": label,
292 "count": entries.len(),
293 "sample": entries
295 .iter()
296 .take(20)
297 .map(|r| format!("{}/{}", r.namespace, r.id))
298 .collect::<Vec<_>>(),
299 "truncated": entries.len() > 20,
300 });
301
302 let event = AuditEvent {
303 id: id.clone(),
304 ts,
305 actor: "system:seed".to_string(),
306 action: AuditAction::SeedApply,
307 resource: format!("seed:{label}"),
308 before: None,
309 after: Some(after_payload),
310 ip: None,
311 request_id: None,
312 restored_from: None,
313 error: None,
314 };
315
316 let value = match serde_json::to_value(&event) {
317 Ok(v) => v,
318 Err(error) => {
319 tracing::warn!(error = %error, "audit: failed to serialize seed event");
320 metrics::counter!("awaken_audit_write_failures_total").increment(1);
321 continue;
322 }
323 };
324
325 if let Err(error) = self.store.put(AUDIT_NAMESPACE, &id, &value).await {
326 tracing::warn!(error = %error, "audit: failed to write seed event");
327 metrics::counter!("awaken_audit_write_failures_total").increment(1);
328 continue;
329 }
330
331 metrics::counter!("awaken_audit_events_total", "action" => "seed_apply").increment(1);
332 }
333 }
334
335 pub async fn query(&self, filter: AuditQuery) -> Result<AuditPage, AuditQueryError> {
339 let effective_limit = filter.limit.clamp(1, 1000);
340
341 let cursor_id = filter
343 .cursor
344 .as_deref()
345 .map(decode_cursor)
346 .transpose()
347 .map_err(|_| AuditQueryError::InvalidCursor)?;
348
349 let all = self
352 .store
353 .list(AUDIT_NAMESPACE, 0, usize::MAX)
354 .await
355 .map_err(AuditQueryError::Storage)?;
356
357 let mut events = Vec::new();
358 for (id, value) in all {
359 if cursor_id.as_deref().is_some_and(|cid| id.as_str() >= cid) {
362 continue;
363 }
364 events.push(decode_audit_event(&id, value).map_err(AuditQueryError::Storage)?);
365 }
366
367 let mut events: Vec<AuditEvent> = events
369 .into_iter()
370 .filter(|event| {
371 if let Some(ref since) = filter.since
372 && let Ok(ts) = event.ts.parse::<DateTime<Utc>>()
373 && ts < *since
374 {
375 return false;
376 }
377 if let Some(ref until) = filter.until
378 && let Ok(ts) = event.ts.parse::<DateTime<Utc>>()
379 && ts >= *until
380 {
381 return false;
382 }
383 if let Some(ref action) = filter.action
384 && &event.action != action
385 {
386 return false;
387 }
388 if let Some(ref resource) = filter.resource
389 && &event.resource != resource
390 && !event.resource.starts_with(&format!("{resource}/"))
391 {
392 return false;
393 }
394 if let Some(ref actor) = filter.actor
395 && !event.actor.starts_with(actor.as_str())
396 {
397 return false;
398 }
399 true
400 })
401 .collect();
402
403 events.sort_by(|a, b| b.id.cmp(&a.id));
405
406 let next_cursor = if events.len() > effective_limit {
407 events.truncate(effective_limit);
408 events.last().map(|e| encode_cursor(&e.id))
409 } else {
410 None
411 };
412
413 Ok(AuditPage {
414 items: events,
415 next_cursor,
416 })
417 }
418
419 pub async fn prune_before(&self, cutoff: DateTime<Utc>) -> Result<usize, StorageError> {
422 let all = self.store.list(AUDIT_NAMESPACE, 0, usize::MAX).await?;
423
424 let mut pruned = 0usize;
425 for (id, _) in all {
426 if let Ok(ulid) = id.parse::<ulid::Ulid>() {
428 let ms = ulid.timestamp_ms();
429 let event_ts =
430 DateTime::from_timestamp_millis(ms as i64).unwrap_or(DateTime::UNIX_EPOCH);
431 if event_ts < cutoff {
432 self.store.delete(AUDIT_NAMESPACE, &id).await?;
433 pruned += 1;
434 }
435 }
436 }
437
438 if pruned > 0 {
439 metrics::counter!("awaken_audit_sweep_pruned_total").increment(pruned as u64);
440 tracing::info!(pruned, "audit sweep pruned events");
441 }
442 Ok(pruned)
443 }
444}
445
446fn decode_audit_event(id: &str, value: Value) -> Result<AuditEvent, StorageError> {
447 serde_json::from_value::<AuditEvent>(value)
448 .map_err(|error| StorageError::Serialization(format!("corrupt audit event {id}: {error}")))
449}
450
451pub fn derive_actor(headers: &HeaderMap) -> String {
457 let base = headers
458 .get(axum::http::header::AUTHORIZATION)
459 .and_then(|v| v.to_str().ok())
460 .and_then(crate::auth::strip_bearer_prefix)
461 .map(|token| {
462 let hash = sha2::Sha256::digest(token.as_bytes());
463 let hex = format!("{hash:x}");
464 hex[..16].to_string()
465 })
466 .unwrap_or_else(|| "anonymous".to_string());
467
468 if let Some(label) = headers
470 .get("x-awaken-actor")
471 .and_then(|v| v.to_str().ok())
472 .map(|s| s.trim())
473 .filter(|s| !s.is_empty())
474 .filter(|s| s.len() <= 64)
475 .filter(|s| s.bytes().all(|b| b.is_ascii() && !b.is_ascii_control()))
476 {
477 format!("{base}/{label}")
478 } else {
479 base
480 }
481}
482
483pub fn redact_secrets(value: Value) -> Value {
485 match value {
486 Value::Object(map) => {
487 let mut out = serde_json::Map::new();
488 for (key, val) in map {
489 if should_redact_secret_key(&key) {
490 out.insert(key, Value::String("***".to_string()));
491 } else {
492 out.insert(key, redact_secrets(val));
493 }
494 }
495 Value::Object(out)
496 }
497 Value::Array(arr) => Value::Array(arr.into_iter().map(redact_secrets).collect()),
498 other => other,
499 }
500}
501
502fn should_redact_secret_key(key: &str) -> bool {
503 let lower = key.to_ascii_lowercase();
504 let compact = lower
505 .chars()
506 .filter(|ch| ch.is_ascii_alphanumeric())
507 .collect::<String>();
508
509 lower.contains("api_key")
510 || compact.contains("apikey")
511 || lower.contains("bearer")
512 || lower.contains("credential")
513 || lower.contains("private_key")
514 || compact.contains("privatekey")
515 || lower.contains("password")
516 || lower.contains("secret")
517 || compact == "token"
518 || compact.ends_with("token")
519}
520
521pub fn extract_client_ip(headers: &HeaderMap) -> Option<String> {
524 if let Some(xff) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) {
525 let first = xff.split(',').next().map(str::trim).unwrap_or("");
526 if !first.is_empty() {
527 return Some(first.to_string());
528 }
529 }
530 headers
531 .get("x-real-ip")
532 .and_then(|v| v.to_str().ok())
533 .filter(|s| !s.is_empty())
534 .map(str::to_string)
535}
536
537fn encode_cursor(id: &str) -> String {
538 base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, id)
539}
540
541fn decode_cursor(cursor: &str) -> Result<String, ()> {
542 base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, cursor)
543 .ok()
544 .and_then(|bytes| String::from_utf8(bytes).ok())
545 .ok_or(())
546}
547
548#[cfg(test)]
549mod tests {
550 use std::collections::HashMap;
551 use std::sync::Arc;
552
553 use async_trait::async_trait;
554 use awaken_server_contract::AuditAction;
555 use awaken_server_contract::contract::config_store::ConfigStore;
556 use awaken_server_contract::contract::storage::StorageError;
557 use axum::http::{HeaderMap, HeaderValue};
558 use chrono::Utc;
559 use serde_json::{Value, json};
560 use tokio::sync::RwLock;
561
562 use super::*;
563
564 #[derive(Default)]
567 struct MemStore {
568 data: RwLock<HashMap<String, HashMap<String, Value>>>,
569 }
570
571 #[async_trait]
572 impl ConfigStore for MemStore {
573 async fn get(&self, ns: &str, id: &str) -> Result<Option<Value>, StorageError> {
574 Ok(self
575 .data
576 .read()
577 .await
578 .get(ns)
579 .and_then(|m| m.get(id))
580 .cloned())
581 }
582
583 async fn list(
584 &self,
585 ns: &str,
586 _offset: usize,
587 _limit: usize,
588 ) -> Result<Vec<(String, Value)>, StorageError> {
589 let data = self.data.read().await;
590 let mut items: Vec<_> = data
591 .get(ns)
592 .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
593 .unwrap_or_default();
594 items.sort_by(|a, b| a.0.cmp(&b.0));
595 Ok(items)
596 }
597
598 async fn put(&self, ns: &str, id: &str, value: &Value) -> Result<(), StorageError> {
599 self.data
600 .write()
601 .await
602 .entry(ns.to_string())
603 .or_default()
604 .insert(id.to_string(), value.clone());
605 Ok(())
606 }
607
608 async fn delete(&self, ns: &str, id: &str) -> Result<(), StorageError> {
609 if let Some(m) = self.data.write().await.get_mut(ns) {
610 m.remove(id);
611 }
612 Ok(())
613 }
614 }
615
616 fn make_logger() -> AuditLogger {
617 AuditLogger::new(Arc::new(MemStore::default()))
618 }
619
620 fn empty_headers() -> HeaderMap {
621 HeaderMap::new()
622 }
623
624 #[test]
627 fn derive_actor_anonymous_when_no_auth() {
628 let headers = empty_headers();
629 assert_eq!(derive_actor(&headers), "anonymous");
630 }
631
632 #[test]
633 fn derive_actor_hash_only_with_bearer() {
634 let mut headers = HeaderMap::new();
635 headers.insert(
636 axum::http::header::AUTHORIZATION,
637 HeaderValue::from_static("Bearer mysecrettoken"),
638 );
639 let actor = derive_actor(&headers);
640 assert!(
641 !actor.contains("mysecrettoken"),
642 "raw token must not appear"
643 );
644 assert_eq!(actor.len(), 16, "hash prefix must be 16 hex chars");
645 assert!(actor.chars().all(|c| c.is_ascii_hexdigit()));
646 }
647
648 #[test]
649 fn derive_actor_hash_plus_valid_label() {
650 let mut headers = HeaderMap::new();
651 headers.insert(
652 axum::http::header::AUTHORIZATION,
653 HeaderValue::from_static("Bearer tok"),
654 );
655 headers.insert("x-awaken-actor", HeaderValue::from_static("ci/deploy-prod"));
656 let actor = derive_actor(&headers);
657 assert!(actor.contains("/ci/deploy-prod"), "label must be appended");
658 }
659
660 #[test]
661 fn derive_actor_invalid_label_dropped() {
662 let mut headers = HeaderMap::new();
668 headers.insert(
669 axum::http::header::AUTHORIZATION,
670 HeaderValue::from_static("Bearer tok"),
671 );
672 headers.insert("x-awaken-actor", HeaderValue::from_static(" "));
673 let actor = derive_actor(&headers);
674 assert!(
676 !actor.contains('/'),
677 "empty/whitespace label must not be appended"
678 );
679 assert_eq!(actor.len(), 16);
680 }
681
682 #[test]
683 fn derive_actor_label_too_long_dropped() {
684 let mut headers = HeaderMap::new();
685 headers.insert(
686 axum::http::header::AUTHORIZATION,
687 HeaderValue::from_static("Bearer tok"),
688 );
689 let long_label = "a".repeat(65);
690 headers.insert(
691 "x-awaken-actor",
692 HeaderValue::from_str(&long_label).unwrap(),
693 );
694 let actor = derive_actor(&headers);
695 assert!(
696 !actor.contains('/'),
697 "over-length label must not be appended"
698 );
699 }
700
701 #[test]
704 fn redact_secrets_top_level() {
705 let input = json!({"api_key": "sk-1234", "name": "test"});
706 let output = redact_secrets(input);
707 assert_eq!(output["api_key"], "***");
708 assert_eq!(output["name"], "test");
709 }
710
711 #[test]
712 fn redact_secrets_nested_objects() {
713 let input = json!({"provider": {"api_key": "sk-1234", "model": "gpt-4"}});
714 let output = redact_secrets(input);
715 assert_eq!(output["provider"]["api_key"], "***");
716 assert_eq!(output["provider"]["model"], "gpt-4");
717 }
718
719 #[test]
720 fn redact_secrets_arrays_of_objects() {
721 let input = json!([{"password": "hunter2", "user": "alice"}]);
722 let output = redact_secrets(input);
723 assert_eq!(output[0]["password"], "***");
724 assert_eq!(output[0]["user"], "alice");
725 }
726
727 #[test]
728 fn redact_secrets_mixed_primitives() {
729 let input = json!({"count": 42, "flag": true, "nothing": null, "secret": "x"});
730 let output = redact_secrets(input);
731 assert_eq!(output["count"], 42);
732 assert_eq!(output["flag"], true);
733 assert_eq!(output["nothing"], Value::Null);
734 assert_eq!(output["secret"], "***");
735 }
736
737 #[test]
738 fn redact_secrets_credential_corpus_is_case_insensitive_and_recursive() {
739 let input = json!({
740 "adapter_options": {
741 "credentials_kind": "service_account_json",
742 "nested": [{
743 "PRIVATE_KEY": "-----BEGIN PRIVATE KEY-----\nraw\n-----END PRIVATE KEY-----",
744 "refreshToken": "rt-123",
745 "client_secret": "client-secret",
746 "safe_label": "visible"
747 }]
748 },
749 "env": {
750 "GOOGLE_APPLICATION_CREDENTIALS": "/tmp/key.json",
751 "PASSWORD": "p",
752 "TOKEN": "t"
753 }
754 });
755
756 let output = redact_secrets(input);
757 let rendered = serde_json::to_string(&output).unwrap();
758 for leaked in [
759 "raw",
760 "rt-123",
761 "client-secret",
762 "/tmp/key.json",
763 "\"p\"",
764 "\"t\"",
765 ] {
766 assert!(
767 !rendered.contains(leaked),
768 "redacted audit payload leaked {leaked:?}: {rendered}"
769 );
770 }
771 assert_eq!(
772 output["adapter_options"]["credentials_kind"], "***",
773 "credential discriminator should be redacted in audit payloads"
774 );
775 assert_eq!(
776 output["adapter_options"]["nested"][0]["safe_label"], "visible",
777 "non-secret fields should remain useful"
778 );
779 }
780
781 #[test]
782 fn redact_secrets_preserves_token_budget_fields() {
783 let input = json!({
784 "context_policy": {
785 "max_context_tokens": 123456,
786 "max_output_tokens": 8192
787 },
788 "usage": {
789 "input_tokens": 100,
790 "output_tokens": 42,
791 "total_tokens": 142
792 },
793 "auth": {
794 "token": "secret-token",
795 "refreshToken": "refresh-token"
796 }
797 });
798
799 let output = redact_secrets(input);
800
801 assert_eq!(output["context_policy"]["max_context_tokens"], 123456);
802 assert_eq!(output["context_policy"]["max_output_tokens"], 8192);
803 assert_eq!(output["usage"]["input_tokens"], 100);
804 assert_eq!(output["usage"]["output_tokens"], 42);
805 assert_eq!(output["usage"]["total_tokens"], 142);
806 assert_eq!(output["auth"]["token"], "***");
807 assert_eq!(output["auth"]["refreshToken"], "***");
808 }
809
810 #[tokio::test]
813 async fn emit_happy_path_stores_event() {
814 let logger = make_logger();
815 let headers = empty_headers();
816 logger
817 .emit(
818 AuditAction::Create,
819 "agents/my-agent",
820 None,
821 Some(json!({"id": "my-agent"})),
822 &headers,
823 )
824 .await;
825
826 let page = logger.query(AuditQuery::default()).await.unwrap();
827 assert_eq!(page.items.len(), 1);
828 let event = &page.items[0];
829 assert_eq!(event.action, AuditAction::Create);
830 assert_eq!(event.resource, "agents/my-agent");
831 assert_eq!(event.actor, "anonymous");
832 }
833
834 #[tokio::test]
835 async fn corrupt_audit_event_fails_closed_on_read() {
836 let store = Arc::new(MemStore::default());
837 store
838 .put(AUDIT_NAMESPACE, "bad-event", &json!({"id": 1}))
839 .await
840 .unwrap();
841 let logger = AuditLogger::new(store);
842
843 let get_error = logger
844 .get_event("bad-event")
845 .await
846 .expect_err("corrupt audit event must not look missing");
847 assert!(matches!(get_error, StorageError::Serialization(_)));
848 assert!(get_error.to_string().contains("bad-event"));
849
850 let query_error = logger
851 .query(AuditQuery::default())
852 .await
853 .expect_err("corrupt audit event must not be skipped");
854 match query_error {
855 AuditQueryError::Storage(StorageError::Serialization(message)) => {
856 assert!(message.contains("bad-event"));
857 }
858 other => panic!("expected serialization storage error, got {other:?}"),
859 }
860 }
861
862 #[tokio::test]
863 async fn emit_failure_does_not_propagate() {
864 struct FailStore;
866
867 #[async_trait]
868 impl ConfigStore for FailStore {
869 async fn get(&self, _ns: &str, _id: &str) -> Result<Option<Value>, StorageError> {
870 Ok(None)
871 }
872 async fn list(
873 &self,
874 _ns: &str,
875 _offset: usize,
876 _limit: usize,
877 ) -> Result<Vec<(String, Value)>, StorageError> {
878 Ok(vec![])
879 }
880 async fn put(&self, _ns: &str, _id: &str, _value: &Value) -> Result<(), StorageError> {
881 Err(StorageError::Io("simulated failure".into()))
882 }
883 async fn delete(&self, _ns: &str, _id: &str) -> Result<(), StorageError> {
884 Ok(())
885 }
886 }
887
888 let logger = AuditLogger::new(Arc::new(FailStore));
889 logger
891 .emit(
892 AuditAction::Delete,
893 "agents/x",
894 None,
895 None,
896 &empty_headers(),
897 )
898 .await;
899 }
900
901 #[tokio::test]
904 async fn query_filters_by_resource() {
905 let logger = make_logger();
906 let h = empty_headers();
907 logger
908 .emit(AuditAction::Create, "agents/a", None, None, &h)
909 .await;
910 logger
911 .emit(AuditAction::Create, "agents/b", None, None, &h)
912 .await;
913
914 let page = logger
915 .query(AuditQuery {
916 resource: Some("agents/a".to_string()),
917 ..Default::default()
918 })
919 .await
920 .unwrap();
921 assert_eq!(page.items.len(), 1);
922 assert_eq!(page.items[0].resource, "agents/a");
923 }
924
925 #[tokio::test]
926 async fn query_filters_by_action() {
927 let logger = make_logger();
928 let h = empty_headers();
929 logger
930 .emit(AuditAction::Create, "agents/c", None, None, &h)
931 .await;
932 logger
933 .emit(AuditAction::Delete, "agents/c", None, None, &h)
934 .await;
935
936 let page = logger
937 .query(AuditQuery {
938 action: Some(AuditAction::Delete),
939 ..Default::default()
940 })
941 .await
942 .unwrap();
943 assert_eq!(page.items.len(), 1);
944 assert_eq!(page.items[0].action, AuditAction::Delete);
945 }
946
947 #[tokio::test]
950 async fn cursor_pagination_round_trip() {
951 let logger = make_logger();
952 let h = empty_headers();
953
954 for i in 0..5 {
956 logger
957 .emit(
958 AuditAction::Create,
959 &format!("agents/agent-{i}"),
960 None,
961 None,
962 &h,
963 )
964 .await;
965 tokio::time::sleep(std::time::Duration::from_millis(2)).await;
967 }
968
969 let page1 = logger
971 .query(AuditQuery {
972 limit: 3,
973 ..Default::default()
974 })
975 .await
976 .unwrap();
977 assert_eq!(page1.items.len(), 3);
978 assert!(page1.next_cursor.is_some());
979
980 let page2 = logger
982 .query(AuditQuery {
983 limit: 3,
984 cursor: page1.next_cursor,
985 ..Default::default()
986 })
987 .await
988 .unwrap();
989 assert_eq!(page2.items.len(), 2);
990 assert!(page2.next_cursor.is_none());
991 }
992
993 fn make_record_ref(namespace: &str, id: &str) -> crate::services::builtin_seed::RecordRef {
996 crate::services::builtin_seed::RecordRef {
997 namespace: namespace.to_string(),
998 id: id.to_string(),
999 }
1000 }
1001
1002 fn make_seed_report(
1003 created: Vec<crate::services::builtin_seed::RecordRef>,
1004 updated: Vec<crate::services::builtin_seed::RecordRef>,
1005 deleted: Vec<crate::services::builtin_seed::RecordRef>,
1006 ) -> crate::services::builtin_seed::SeedReport {
1007 crate::services::builtin_seed::SeedReport {
1008 created,
1009 updated,
1010 unchanged: vec![],
1011 deleted,
1012 preserved_user: vec![],
1013 preserved_overridden: vec![],
1014 }
1015 }
1016
1017 #[tokio::test]
1018 async fn seed_apply_emits_event_per_non_empty_bucket() {
1019 let logger = make_logger();
1020 let report = make_seed_report(
1021 vec![
1022 make_record_ref("agents", "agent-a"),
1023 make_record_ref("agents", "agent-b"),
1024 ],
1025 vec![],
1026 vec![make_record_ref("models", "old-model")],
1027 );
1028 logger.emit_seed_report(&report).await;
1029
1030 let page = logger
1031 .query(AuditQuery {
1032 limit: 100,
1033 ..Default::default()
1034 })
1035 .await
1036 .unwrap();
1037 assert_eq!(page.items.len(), 2, "one event per non-empty bucket");
1038
1039 for event in &page.items {
1041 assert_eq!(event.action, AuditAction::SeedApply);
1042 assert_eq!(event.actor, "system:seed");
1043 }
1044
1045 let resources: std::collections::HashSet<_> =
1047 page.items.iter().map(|e| e.resource.as_str()).collect();
1048 assert!(resources.contains("seed:created"));
1049 assert!(resources.contains("seed:deleted"));
1050 }
1051
1052 #[tokio::test]
1053 async fn seed_apply_idempotent_run_emits_no_audit() {
1054 let logger = make_logger();
1055 let report = make_seed_report(vec![], vec![], vec![]);
1056 logger.emit_seed_report(&report).await;
1057
1058 let page = logger.query(AuditQuery::default()).await.unwrap();
1059 assert_eq!(page.items.len(), 0, "empty report must emit no events");
1060 }
1061
1062 #[tokio::test]
1063 async fn seed_apply_truncates_sample_at_20() {
1064 let logger = make_logger();
1065 let created: Vec<_> = (0..25)
1066 .map(|i| make_record_ref("agents", &format!("agent-{i}")))
1067 .collect();
1068 let report = make_seed_report(created, vec![], vec![]);
1069 logger.emit_seed_report(&report).await;
1070
1071 let page = logger.query(AuditQuery::default()).await.unwrap();
1072 assert_eq!(page.items.len(), 1);
1073
1074 let after = page.items[0].after.as_ref().unwrap();
1075 let sample = after["sample"].as_array().unwrap();
1076 assert_eq!(sample.len(), 20, "sample must be capped at 20");
1077 assert_eq!(after["truncated"], true);
1078 assert_eq!(after["count"], 25);
1079 }
1080
1081 #[tokio::test]
1084 async fn prune_before_removes_old_events() {
1085 let logger = make_logger();
1086 let h = empty_headers();
1087 logger
1088 .emit(AuditAction::Create, "agents/old", None, None, &h)
1089 .await;
1090
1091 let cutoff = Utc::now();
1092 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1093
1094 logger
1095 .emit(AuditAction::Create, "agents/new", None, None, &h)
1096 .await;
1097
1098 let pruned = logger.prune_before(cutoff).await.unwrap();
1099 assert_eq!(pruned, 1, "one old event should be pruned");
1100
1101 let page = logger.query(AuditQuery::default()).await.unwrap();
1102 assert_eq!(page.items.len(), 1);
1103 assert_eq!(page.items[0].resource, "agents/new");
1104 }
1105}