switchyard/logging/
audit.rs

1/// replace this file with `StageLogger` facade — see `zrefactor/logging_audit_refactor.INSTRUCTIONS.md`
2// Audit helpers that emit Minimal Facts v1 across Switchyard stages.
3//
4// Side-effects:
5// - Emits JSON facts via `FactsEmitter` for the following stages:
6//   - `plan`, `preflight` (per-action rows and summary), `apply.attempt`, `apply.result`, and `rollback` steps.
7// - Ensures a minimal envelope is present on every fact: `schema_version`, `ts`, `plan_id`, `path`.
8// - Applies redaction in dry-run to zero timestamps and drop volatile fields.
9//
10// See `SPEC/SPEC.md` for field semantics and Minimal Facts v1 schema.
11use crate::logging::{redact_event, FactsEmitter};
12use serde_json::{json, Map, Value};
13use std::cell::Cell;
14use std::sync::atomic::{AtomicU64, Ordering};
15use std::time::{SystemTime, UNIX_EPOCH};
16use uuid::Uuid;
17
18pub(crate) const SCHEMA_VERSION: i64 = 2;
19
20#[derive(Clone, Debug, Default)]
21pub(crate) struct AuditMode {
22    pub dry_run: bool,
23    pub redact: bool,
24}
25
26#[derive(Debug)]
27pub(crate) struct AuditCtx<'a> {
28    pub facts: &'a dyn FactsEmitter,
29    pub plan_id: String,
30    pub run_id: String,
31    pub ts: String,
32    pub mode: AuditMode,
33    pub seq: Cell<u64>,
34}
35
36impl<'a> AuditCtx<'a> {
37    pub(crate) fn new(
38        facts: &'a dyn FactsEmitter,
39        plan_id: String,
40        run_id: String,
41        ts: String,
42        mode: AuditMode,
43    ) -> Self {
44        Self {
45            facts,
46            plan_id,
47            run_id,
48            ts,
49            mode,
50            seq: Cell::new(0),
51        }
52    }
53}
54
55/// Stage for typed audit emission.
56#[derive(Clone, Copy, Debug)]
57pub enum Stage {
58    Plan,
59    Preflight,
60    PreflightSummary,
61    ApplyAttempt,
62    ApplyResult,
63    Rollback,
64    RollbackSummary,
65    PruneResult,
66}
67
68impl Stage {
69    const fn as_event(self) -> &'static str {
70        match self {
71            Stage::Plan => "plan",
72            Stage::Preflight => "preflight",
73            Stage::PreflightSummary => "preflight.summary",
74            Stage::ApplyAttempt => "apply.attempt",
75            Stage::ApplyResult => "apply.result",
76            Stage::Rollback => "rollback",
77            Stage::RollbackSummary => "rollback.summary",
78            Stage::PruneResult => "prune.result",
79        }
80    }
81}
82
83/// Decision severity for audit events.
84#[derive(Clone, Copy, Debug)]
85pub enum Decision {
86    Success,
87    Failure,
88    Warn,
89}
90
91impl Decision {
92    const fn as_str(self) -> &'static str {
93        match self {
94            Decision::Success => "success",
95            Decision::Failure => "failure",
96            Decision::Warn => "warn",
97        }
98    }
99}
100
101/// Builder facade over audit emission with centralized envelope+redaction.
102#[derive(Debug)]
103pub struct StageLogger<'a> {
104    ctx: &'a AuditCtx<'a>,
105}
106
107impl<'a> StageLogger<'a> {
108    pub(crate) const fn new(ctx: &'a AuditCtx<'a>) -> Self {
109        Self { ctx }
110    }
111
112    #[must_use]
113    pub fn plan(&'a self) -> EventBuilder<'a> {
114        EventBuilder::new(self.ctx, Stage::Plan)
115    }
116    #[must_use]
117    pub fn preflight(&'a self) -> EventBuilder<'a> {
118        EventBuilder::new(self.ctx, Stage::Preflight)
119    }
120    #[must_use]
121    pub fn preflight_summary(&'a self) -> EventBuilder<'a> {
122        EventBuilder::new(self.ctx, Stage::PreflightSummary)
123    }
124    #[must_use]
125    pub fn apply_attempt(&'a self) -> EventBuilder<'a> {
126        EventBuilder::new(self.ctx, Stage::ApplyAttempt)
127    }
128    #[must_use]
129    pub fn apply_result(&'a self) -> EventBuilder<'a> {
130        EventBuilder::new(self.ctx, Stage::ApplyResult)
131    }
132    #[must_use]
133    pub fn rollback(&'a self) -> EventBuilder<'a> {
134        EventBuilder::new(self.ctx, Stage::Rollback)
135    }
136    #[must_use]
137    pub fn rollback_summary(&'a self) -> EventBuilder<'a> {
138        EventBuilder::new(self.ctx, Stage::RollbackSummary)
139    }
140    #[must_use]
141    pub fn prune_result(&'a self) -> EventBuilder<'a> {
142        EventBuilder::new(self.ctx, Stage::PruneResult)
143    }
144}
145
146#[derive(Debug)]
147pub struct EventBuilder<'a> {
148    ctx: &'a AuditCtx<'a>,
149    stage: Stage,
150    fields: Map<String, Value>,
151}
152
153impl<'a> EventBuilder<'a> {
154    fn new(ctx: &'a AuditCtx<'a>, stage: Stage) -> Self {
155        let mut fields = Map::new();
156        fields.insert("stage".to_string(), json!(stage.as_event()));
157        Self { ctx, stage, fields }
158    }
159
160    #[must_use]
161    pub fn action(mut self, action_id: impl Into<String>) -> Self {
162        self.fields
163            .insert("action_id".into(), json!(action_id.into()));
164        self
165    }
166
167    /// Thin wrapper for `.action(...)` to improve readability at call sites.
168    #[must_use]
169    pub fn action_id(self, aid: impl Into<String>) -> Self {
170        self.action(aid)
171    }
172
173    #[must_use]
174    pub fn path(mut self, path: impl Into<String>) -> Self {
175        self.fields.insert("path".into(), json!(path.into()));
176        self
177    }
178
179    /// Attach a nested perf object with hash/backup/swap timings in milliseconds.
180    #[must_use]
181    pub fn perf(mut self, hash_ms: u64, backup_ms: u64, swap_ms: u64) -> Self {
182        self.fields.insert(
183            "perf".to_string(),
184            json!({
185                "hash_ms": hash_ms,
186                "backup_ms": backup_ms,
187                "swap_ms": swap_ms,
188            }),
189        );
190        self
191    }
192
193    /// Set a stable error identifier as defined in `crate::api::errors`.
194    #[must_use]
195    pub fn error_id(mut self, id: crate::api::errors::ErrorId) -> Self {
196        self.fields.insert(
197            "error_id".to_string(),
198            json!(crate::api::errors::id_str(id)),
199        );
200        self
201    }
202
203    /// Set an exit code derived from the given error id.
204    #[must_use]
205    pub fn exit_code_for(mut self, id: crate::api::errors::ErrorId) -> Self {
206        self.fields.insert(
207            "exit_code".to_string(),
208            json!(crate::api::errors::exit_code_for(id)),
209        );
210        self
211    }
212
213    #[must_use]
214    pub fn field(mut self, key: &str, value: Value) -> Self {
215        self.fields.insert(key.to_string(), value);
216        self
217    }
218
219    #[must_use]
220    pub fn merge(mut self, extra: &Value) -> Self {
221        if let Some(obj) = extra.as_object() {
222            for (k, v) in obj {
223                self.fields.insert(k.clone(), v.clone());
224            }
225        }
226        self
227    }
228
229    pub fn emit(self, decision: Decision) {
230        let mut fields = Value::Object(self.fields);
231        // Ensure provenance object present by default
232        ensure_provenance(&mut fields);
233        if let Some(obj) = fields.as_object_mut() {
234            obj.entry("decision").or_insert(json!(decision.as_str()));
235        }
236        redact_and_emit(
237            self.ctx,
238            "switchyard",
239            self.stage.as_event(),
240            decision.as_str(),
241            fields,
242        );
243    }
244
245    pub fn emit_success(self) {
246        self.emit(Decision::Success);
247    }
248    pub fn emit_failure(self) {
249        self.emit(Decision::Failure);
250    }
251    pub fn emit_warn(self) {
252        self.emit(Decision::Warn);
253    }
254}
255
256fn redact_and_emit(
257    ctx: &AuditCtx<'_>,
258    subsystem: &str,
259    event: &str,
260    decision: &str,
261    mut fields: Value,
262) {
263    // Ensure minimal envelope fields
264    if let Some(obj) = fields.as_object_mut() {
265        obj.entry("schema_version").or_insert(json!(SCHEMA_VERSION));
266        obj.entry("ts").or_insert(json!(ctx.ts));
267        obj.entry("plan_id").or_insert(json!(ctx.plan_id));
268        obj.entry("run_id").or_insert(json!(ctx.run_id));
269        obj.entry("event_id").or_insert(json!(new_event_id()));
270        obj.entry("switchyard_version")
271            .or_insert(json!(env!("CARGO_PKG_VERSION")));
272        // Redaction metadata (lightweight)
273        obj.entry("redacted").or_insert(json!(ctx.mode.redact));
274        obj.entry("redaction")
275            .or_insert(json!({"applied": ctx.mode.redact}));
276
277        // Optional envmeta (host/process/actor/build)
278        #[cfg(feature = "envmeta")]
279        {
280            use serde_json::map::Entry;
281            // host
282            if let Entry::Vacant(e) = obj.entry("host".to_string()) {
283                let mut host_obj = Map::new();
284                if let Some(hostname) = std::env::var("HOSTNAME").ok() {
285                    host_obj.insert("hostname".to_string(), json!(hostname));
286                }
287                host_obj.insert("os".to_string(), json!(std::env::consts::OS.to_string()));
288                host_obj.insert(
289                    "arch".to_string(),
290                    json!(std::env::consts::ARCH.to_string()),
291                );
292                // Kernel best-effort: read from /proc/version if present
293                if let Some(kernel) = std::fs::read_to_string("/proc/version")
294                    .ok()
295                    .and_then(|s| s.split_whitespace().nth(2).map(ToString::to_string))
296                {
297                    host_obj.insert("kernel".to_string(), json!(kernel));
298                }
299                e.insert(json!(host_obj));
300            }
301            // process
302            if let Entry::Vacant(e) = obj.entry("process".to_string()) {
303                let process_id = std::process::id();
304                let parent_process_id = rustix::process::Pid::as_raw(rustix::process::getppid());
305                e.insert(json!({"pid": process_id, "ppid": parent_process_id}));
306            }
307            // actor (effective ids)
308            if let Entry::Vacant(e) = obj.entry("actor".to_string()) {
309                let effective_user_id = rustix::process::geteuid().as_raw();
310                let effective_group_id = rustix::process::getegid().as_raw();
311                e.insert(json!({"euid": effective_user_id, "egid": effective_group_id}));
312            }
313            // build
314            if let Entry::Vacant(e) = obj.entry("build".to_string()) {
315                let mut build_obj = Map::new();
316                if let Some(git_sha) = std::env::var("GIT_SHA").ok() {
317                    build_obj.insert("git_sha".to_string(), json!(git_sha));
318                }
319                if let Some(rustc) = std::env::var("RUSTC_VERSION").ok() {
320                    build_obj.insert("rustc".to_string(), json!(rustc));
321                }
322                e.insert(json!(build_obj));
323            }
324        }
325        // Monotonic per-run sequence
326        let cur = ctx.seq.get();
327        obj.entry("seq").or_insert(json!(cur));
328        ctx.seq.set(cur.saturating_add(1));
329        obj.entry("dry_run").or_insert(json!(ctx.mode.dry_run));
330    }
331    // Apply redaction policy in dry-run or when requested
332    let out = if ctx.mode.redact {
333        redact_event(fields)
334    } else {
335        fields
336    };
337    ctx.facts.emit(subsystem, event, decision, out);
338}
339
340fn new_event_id() -> String {
341    // Derive a name from (nanos_since_epoch, counter) for uniqueness, then build UUID v5
342    static NEXT_ID_COUNTER: AtomicU64 = AtomicU64::new(0);
343    let nanos = SystemTime::now()
344        .duration_since(UNIX_EPOCH)
345        .unwrap_or_default()
346        .as_nanos();
347    let c = NEXT_ID_COUNTER.fetch_add(1, Ordering::Relaxed);
348    let name = format!("{nanos}:{c}:event");
349    Uuid::new_v5(&Uuid::NAMESPACE_URL, name.as_bytes()).to_string()
350}
351
352pub(crate) fn new_run_id() -> String {
353    // Similar generation strategy as event_id, but with a different tag
354    static NEXT_RUN_COUNTER: AtomicU64 = AtomicU64::new(0);
355    let nanos = SystemTime::now()
356        .duration_since(UNIX_EPOCH)
357        .unwrap_or_default()
358        .as_nanos();
359    let c = NEXT_RUN_COUNTER.fetch_add(1, Ordering::Relaxed);
360    let name = format!("{nanos}:{c}:run");
361    Uuid::new_v5(&Uuid::NAMESPACE_URL, name.as_bytes()).to_string()
362}
363
364// Legacy emit_* helpers have been removed; use StageLogger facade exclusively.
365
366// Optional helper to ensure a provenance object is present; callers may extend as needed.
367/// Ensure `extra["provenance"]` is an object and contains `env_sanitized: true`.
368pub(crate) fn ensure_provenance(extra: &mut Value) {
369    if let Some(obj) = extra.as_object_mut() {
370        // Get or create the "provenance" field as an object
371        let prov = obj
372            .entry("provenance")
373            .or_insert_with(|| Value::Object(Map::new()));
374
375        // If it existed but wasn't an object, replace it with an empty object
376        if !prov.is_object() {
377            *prov = Value::Object(Map::new());
378        }
379
380        // Now safely insert the key
381        if let Value::Object(prov_obj) = prov {
382            prov_obj.entry("env_sanitized").or_insert(Value::Bool(true));
383        }
384    }
385}