Skip to main content

awaken_server/services/
audit_log.rs

1//! Audit log service — stores, queries, and prunes audit events.
2//!
3//! Events are stored in the `_audit` namespace of the existing `ConfigStore`
4//! using ULID keys so time-ordering is preserved without a secondary index.
5
6use 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
17/// Storage namespace for all audit events.
18pub const AUDIT_NAMESPACE: &str = "_audit";
19
20/// Query parameters for listing audit events.
21#[derive(Debug, Clone, serde::Deserialize)]
22pub struct AuditQuery {
23    /// Include only events at or after this timestamp (RFC 3339).
24    #[serde(default)]
25    pub since: Option<DateTime<Utc>>,
26    /// Include only events strictly before this timestamp (RFC 3339).
27    #[serde(default)]
28    pub until: Option<DateTime<Utc>>,
29    /// Filter by action type.
30    #[serde(default)]
31    pub action: Option<AuditAction>,
32    /// Filter by exact resource path (`namespace/id`).
33    #[serde(default)]
34    pub resource: Option<String>,
35    /// Filter by actor hash prefix.
36    #[serde(default)]
37    pub actor: Option<String>,
38    /// Max results, default 100, capped at 1000.
39    #[serde(default = "default_audit_limit")]
40    pub limit: usize,
41    /// Opaque keyset cursor from a previous response.
42    #[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/// Paginated result set for audit event queries.
65#[derive(Debug, serde::Serialize)]
66pub struct AuditPage {
67    pub items: Vec<AuditEvent>,
68    pub next_cursor: Option<String>,
69}
70
71/// Error returned by [`AuditLogger::query`].
72#[derive(Debug, thiserror::Error)]
73pub enum AuditQueryError {
74    #[error("invalid cursor")]
75    InvalidCursor,
76    #[error("storage error: {0}")]
77    Storage(#[from] StorageError),
78}
79
80/// Stateless service that records and queries audit events.
81pub 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    /// Record an audit event.  Best-effort: failures emit a warning and
91    /// increment the write-failure metric but are never propagated to callers.
92    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    /// Emit an `ApplyFailed` audit event with an attached error string.
149    ///
150    /// Best-effort — same failure semantics as [`AuditLogger::emit`].
151    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    /// Look up a single audit event by its ULID id.
204    ///
205    /// Returns `Ok(None)` when the event is not found (either never existed or was pruned).
206    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    /// Emit a restore audit event with the `restored_from` field set.
212    ///
213    /// Best-effort — same semantics as [`AuditLogger::emit`].
214    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    /// Emit one audit event per non-empty bucket of a seed-apply report.
267    ///
268    /// Boot-time emission has no HeaderMap; actor is recorded as "system:seed".
269    /// Best-effort — same failure semantics as [`AuditLogger::emit`].
270    ///
271    /// Skips entirely if the report has no created/updated/deleted entries
272    /// (idempotent re-runs produce no audit noise).
273    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                // Cap the sample list so very large seeds don't bloat the audit log.
294                "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    /// Query audit events with optional filters and keyset pagination.
336    ///
337    /// Returns `Err` if `filter.cursor` is present but not valid base64.
338    pub async fn query(&self, filter: AuditQuery) -> Result<AuditPage, AuditQueryError> {
339        let effective_limit = filter.limit.clamp(1, 1000);
340
341        // Decode cursor to get the last-seen id (exclusive upper bound).
342        let cursor_id = filter
343            .cursor
344            .as_deref()
345            .map(decode_cursor)
346            .transpose()
347            .map_err(|_| AuditQueryError::InvalidCursor)?;
348
349        // Fetch all entries in the namespace (sorted ascending by ULID).
350        // Linear scan per ADR D3 footnote.
351        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            // Cursor: only include entries with id < cursor_id (older pages).
360            // We're serving newest-first so we reverse later.
361            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        // Filter decoded events.
368        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        // Newest first.
404        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    /// Delete all events whose ULID timestamp is before `cutoff`.
420    /// Returns the number of pruned entries.
421    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            // Decode ULID timestamp.
427            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
451/// Derive the actor string from request headers.
452///
453/// - `Authorization: Bearer <token>` → first 16 hex chars of SHA-256(token)
454/// - Otherwise → `"anonymous"`
455/// - If `X-Awaken-Actor` is also present and valid → append `"/<label>"`
456pub 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    // Optional advisory label.
469    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
483/// Recursively replace values whose key matches common secret patterns with `"***"`.
484pub 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
521/// Extract the client IP from request headers.
522/// Prefers `x-forwarded-for` (first address); falls back to `x-real-ip`.
523pub 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    // ── minimal in-memory store ───────────────────────────────────────────
565
566    #[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    // ── derive_actor ──────────────────────────────────────────────────────
625
626    #[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        // Verify that a label containing non-printable ASCII (control chars) is
663        // dropped at the derive_actor logic level. HTTP headers cannot carry
664        // control bytes, so we test the filter predicate directly by injecting
665        // a mock value that passes header parsing but fails our check.
666        // We approximate this by testing empty label (trimmed to empty → dropped).
667        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        // A whitespace-only label trims to empty → dropped.
675        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    // ── redact_secrets ────────────────────────────────────────────────────
702
703    #[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    // ── emit ──────────────────────────────────────────────────────────────
811
812    #[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        // Use a store that always fails writes.
865        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        // Must not panic or propagate.
890        logger
891            .emit(
892                AuditAction::Delete,
893                "agents/x",
894                None,
895                None,
896                &empty_headers(),
897            )
898            .await;
899    }
900
901    // ── query filters ─────────────────────────────────────────────────────
902
903    #[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    // ── cursor pagination ─────────────────────────────────────────────────
948
949    #[tokio::test]
950    async fn cursor_pagination_round_trip() {
951        let logger = make_logger();
952        let h = empty_headers();
953
954        // Emit 5 events.
955        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            // Tiny sleep to ensure distinct ULIDs.
966            tokio::time::sleep(std::time::Duration::from_millis(2)).await;
967        }
968
969        // Page 1: limit 3.
970        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        // Page 2: continue with cursor.
981        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    // ── emit_seed_report ─────────────────────────────────────────────────
994
995    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        // Both events must have the SeedApply action and system:seed actor.
1040        for event in &page.items {
1041            assert_eq!(event.action, AuditAction::SeedApply);
1042            assert_eq!(event.actor, "system:seed");
1043        }
1044
1045        // Check that resources match the expected bucket names (order may vary).
1046        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    // ── prune_before ──────────────────────────────────────────────────────
1082
1083    #[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}