Skip to main content

greentic_deployer/environment/
audit.rs

1//! Append-only audit log + local authorization policy (`A7`).
2//!
3//! Every mutating `op` verb passes through [`authorize_local_owner`] and
4//! emits an [`AuditEvent`] into `<store_root>/<env_id>/audit/events.jsonl`.
5//! Local-store posture: the local filesystem store is single-operator (its
6//! authorization boundary is OS filesystem ownership), so every env id is
7//! authorized — the `local` env under [`POLICY_LOCAL_ONLY`], any named env
8//! under [`POLICY_LOCAL_OWNER`]. Shared/production stores enforce RBAC
9//! server-side on the remote dispatch path, which never reaches this gate.
10//!
11//! The append uses a per-file `fs4` flock on the audit file itself (not the
12//! env's `.lock` sentinel), so emit can happen INSIDE a `transact` closure
13//! without deadlocking on the env flock.
14//!
15//! The serializable audit shapes ([`AuditEvent`] et al.) are owned by
16//! `greentic-deploy-spec` (the A8 remote-store contract reuses them) and
17//! re-exported here; this module keeps the local FS writer and authz gate.
18
19use std::fs::OpenOptions;
20use std::io::Write;
21use std::path::PathBuf;
22
23use fs4::fs_std::FileExt;
24use greentic_deploy_spec::{EnvId, SchemaVersion};
25use thiserror::Error;
26
27use super::file_lock::LockError;
28use super::store::{LocalFsStore, StoreError};
29
30pub use greentic_deploy_spec::{Actor, AuditDecision, AuditEvent, AuditResult, POLICY_LOCAL_ONLY};
31
32/// Audit policy string for a named (non-`local`) environment authorized on the
33/// local filesystem store. The local store's authorization boundary is OS
34/// filesystem ownership — whoever can write the store root owns every env in
35/// it, so there is no within-store tenant boundary to enforce. Named-env
36/// mutations are therefore allowed and recorded under this policy. This is NOT
37/// an A8 RBAC decision: shared/production stores gate via server-side RBAC on
38/// the remote dispatch path, which never reaches this gate. The constant is a
39/// local deployer concept (the A8 remote store never emits it), so it lives
40/// here rather than in the `greentic-deploy-spec` wire contract.
41pub const POLICY_LOCAL_OWNER: &str = "local-owner";
42
43pub const AUDIT_EVENT_SCHEMA_V1: &str = SchemaVersion::AUDIT_EVENT_V1;
44
45#[derive(Debug, Error)]
46pub enum AuditError {
47    #[error("audit io error on {path}: {source}")]
48    Io {
49        path: PathBuf,
50        #[source]
51        source: std::io::Error,
52    },
53    #[error("audit serialize: {0}")]
54    Serde(#[from] serde_json::Error),
55    #[error("audit lock: {0}")]
56    Lock(#[from] LockError),
57    #[error(transparent)]
58    Store(#[from] StoreError),
59}
60
61/// Local-store authorization gate. The local filesystem store is
62/// single-operator by construction — its authorization boundary is OS
63/// filesystem ownership — so every env id is authorized. The `local` env keeps
64/// the canonical [`POLICY_LOCAL_ONLY`] label for audit continuity; any named
65/// env is authorized under [`POLICY_LOCAL_OWNER`].
66///
67/// This gate runs ONLY on the local-FS path ([`crate::cli::audit_and_record`]).
68/// Shared/production stores enforce RBAC server-side on the remote dispatch
69/// path, which never passes through here — so allowing named envs locally does
70/// not weaken the fail-closed posture for shared stores.
71///
72/// Independent non-local data gates are unaffected and still apply: the
73/// `customer_id` billing-principal requirement in `bundles`/`deploy`, and the
74/// `env apply` fresh-non-local guard (create the env explicitly via
75/// `op env create <id>` first — apply reconciles a named env, it does not
76/// bootstrap one).
77pub fn authorize_local_owner(env_id: &EnvId) -> AuditDecision {
78    let (policy, reason) = if env_id.as_str() == crate::defaults::LOCAL_ENV_ID {
79        (
80            POLICY_LOCAL_ONLY,
81            format!("env `{env_id}` is the local env"),
82        )
83    } else {
84        (
85            POLICY_LOCAL_OWNER,
86            format!(
87                "named env `{env_id}` authorized by local store ownership \
88                 (single-operator; shared-store RBAC is enforced on the remote path)"
89            ),
90        )
91    };
92    AuditDecision::Allow {
93        policy: policy.to_string(),
94        reason,
95    }
96}
97
98pub fn current_local_actor() -> Actor {
99    Actor {
100        kind: "local-user".to_string(),
101        user: std::env::var("USER")
102            .or_else(|_| std::env::var("USERNAME"))
103            .ok(),
104        uid: current_uid(),
105    }
106}
107
108#[cfg(unix)]
109fn current_uid() -> Option<u32> {
110    Some(rustix::process::getuid().as_raw())
111}
112
113#[cfg(not(unix))]
114fn current_uid() -> Option<u32> {
115    None
116}
117
118/// Append-only writer for `<store_root>/<env_id>/audit/events.jsonl`.
119#[derive(Debug)]
120pub struct AuditLog {
121    path: PathBuf,
122}
123
124impl AuditLog {
125    /// Resolve the audit log path for `env_id` under `store`'s root.
126    ///
127    /// The path is built via the store's `env_dir` so it shares the same
128    /// safe-env-segment validation that the rest of the store uses (rejects
129    /// `.`, `..`, ids with separators).
130    pub fn for_env(store: &LocalFsStore, env_id: &EnvId) -> Result<Self, AuditError> {
131        let env_dir = store.env_dir(env_id)?;
132        Ok(Self {
133            path: env_dir.join("audit").join("events.jsonl"),
134        })
135    }
136
137    pub fn path(&self) -> &std::path::Path {
138        &self.path
139    }
140
141    /// Append one event as a single JSON line. Creates the parent dir on
142    /// demand. The fs4 file-level flock is independent of the env's `.lock`
143    /// sentinel — safe to call from inside [`LocalFsStore::transact`].
144    pub fn append(&self, event: &AuditEvent) -> Result<(), AuditError> {
145        if let Some(parent) = self.path.parent() {
146            std::fs::create_dir_all(parent).map_err(|source| AuditError::Io {
147                path: parent.to_path_buf(),
148                source,
149            })?;
150        }
151        let serialized = serde_json::to_string(event)?;
152        let file = OpenOptions::new()
153            .create(true)
154            .append(true)
155            .open(&self.path)
156            .map_err(|source| AuditError::Io {
157                path: self.path.clone(),
158                source,
159            })?;
160        file.lock_exclusive().map_err(|source| AuditError::Io {
161            path: self.path.clone(),
162            source,
163        })?;
164        let mut handle = &file;
165        handle
166            .write_all(serialized.as_bytes())
167            .and_then(|_| handle.write_all(b"\n"))
168            .and_then(|_| file.sync_data())
169            .map_err(|source| AuditError::Io {
170                path: self.path.clone(),
171                source,
172            })?;
173        FileExt::unlock(&file).ok();
174        Ok(())
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use chrono::Utc;
182    use tempfile::tempdir;
183
184    fn make_event(env_id: &str, verb: &str) -> AuditEvent {
185        AuditEvent {
186            schema: AUDIT_EVENT_SCHEMA_V1.into(),
187            event_id: ulid::Ulid::new().to_string(),
188            ts: Utc::now(),
189            actor: Actor {
190                kind: "local-user".to_string(),
191                user: Some("tester".to_string()),
192                uid: Some(1000),
193            },
194            env_id: env_id.to_string(),
195            noun: "env".to_string(),
196            verb: verb.to_string(),
197            target: serde_json::json!({"environment_id": env_id}),
198            previous_generation: None,
199            new_generation: None,
200            idempotency_key: None,
201            authorization: AuditDecision::Allow {
202                policy: POLICY_LOCAL_ONLY.to_string(),
203                reason: "test".to_string(),
204            },
205            result: AuditResult::Ok,
206        }
207    }
208
209    #[test]
210    fn authorize_local_env_id_allows() {
211        let env_id = EnvId::try_from("local").unwrap();
212        match authorize_local_owner(&env_id) {
213            AuditDecision::Allow { policy, reason } => {
214                assert_eq!(policy, POLICY_LOCAL_ONLY);
215                assert!(reason.contains("local"));
216            }
217            other => panic!("expected Allow, got {other:?}"),
218        }
219    }
220
221    #[test]
222    fn authorize_named_env_id_allows_under_local_owner() {
223        // The local FS store is single-operator (fs-ownership IS the authz
224        // boundary), so a named env is authorized — recorded under the honest
225        // `local-owner` policy, NOT the `local` env's `local-only` label.
226        // Shared-store RBAC lives on the remote dispatch path, not here.
227        for id in ["prod", "k8s", "staging"] {
228            let env_id = EnvId::try_from(id).unwrap();
229            match authorize_local_owner(&env_id) {
230                AuditDecision::Allow { policy, reason } => {
231                    assert_eq!(policy, POLICY_LOCAL_OWNER);
232                    assert!(reason.contains(id));
233                }
234                other => panic!("expected Allow for `{id}`, got {other:?}"),
235            }
236        }
237    }
238
239    #[test]
240    fn audit_log_append_creates_dir_and_writes_jsonl_line() {
241        let dir = tempdir().unwrap();
242        let store = LocalFsStore::new(dir.path());
243        let env_id = EnvId::try_from("local").unwrap();
244        let log = AuditLog::for_env(&store, &env_id).unwrap();
245        let event = make_event("local", "create");
246        log.append(&event).unwrap();
247        let raw = std::fs::read_to_string(log.path()).unwrap();
248        assert!(raw.ends_with('\n'));
249        let parsed: AuditEvent = serde_json::from_str(raw.trim_end()).unwrap();
250        assert_eq!(parsed.env_id, "local");
251        assert_eq!(parsed.verb, "create");
252    }
253
254    #[test]
255    fn audit_log_append_appends_subsequent_events() {
256        let dir = tempdir().unwrap();
257        let store = LocalFsStore::new(dir.path());
258        let env_id = EnvId::try_from("local").unwrap();
259        let log = AuditLog::for_env(&store, &env_id).unwrap();
260        log.append(&make_event("local", "create")).unwrap();
261        log.append(&make_event("local", "update")).unwrap();
262        let raw = std::fs::read_to_string(log.path()).unwrap();
263        let lines: Vec<&str> = raw.lines().collect();
264        assert_eq!(lines.len(), 2);
265        let first: AuditEvent = serde_json::from_str(lines[0]).unwrap();
266        let second: AuditEvent = serde_json::from_str(lines[1]).unwrap();
267        assert_eq!(first.verb, "create");
268        assert_eq!(second.verb, "update");
269        assert_ne!(first.event_id, second.event_id);
270    }
271
272    #[test]
273    fn audit_log_append_under_env_flock_does_not_deadlock() {
274        use crate::environment::EnvFlock;
275        let dir = tempdir().unwrap();
276        let store = LocalFsStore::new(dir.path());
277        let env_id = EnvId::try_from("local").unwrap();
278        let env_dir = store.env_dir(&env_id).unwrap();
279        std::fs::create_dir_all(&env_dir).unwrap();
280        let lock_path = env_dir.join(".lock");
281        let _held = EnvFlock::acquire(&lock_path).unwrap();
282        let log = AuditLog::for_env(&store, &env_id).unwrap();
283        log.append(&make_event("local", "create")).unwrap();
284    }
285
286    #[test]
287    fn actor_captures_user_env_var() {
288        let prev = std::env::var("USER").ok();
289        // SAFETY: Cargo test default thread-per-test isolation does not
290        // protect from env-var races. We avoid `unsafe { set_var }` (the
291        // crate forbids unsafe) and instead read whatever USER is and trust
292        // that std::env::var resolves it. This test is a smoke check that
293        // `current_local_actor` returns SOME user or none, not a specific
294        // value.
295        let actor = current_local_actor();
296        assert_eq!(actor.kind, "local-user");
297        // user is Some on Unix CI where $USER is set; tolerate either side.
298        let _ = prev;
299        let _ = actor.user;
300    }
301
302    #[test]
303    fn serialize_event_round_trips() {
304        let mut event = make_event("local", "set");
305        event.previous_generation = Some(3);
306        event.new_generation = Some(4);
307        event.idempotency_key = Some("k1".to_string());
308        event.authorization = AuditDecision::Deny {
309            policy: POLICY_LOCAL_ONLY.to_string(),
310            reason: "denied".to_string(),
311        };
312        event.result = AuditResult::Error {
313            kind: "unauthorized".to_string(),
314            message: "boom".to_string(),
315        };
316        let raw = serde_json::to_string(&event).unwrap();
317        let parsed: AuditEvent = serde_json::from_str(&raw).unwrap();
318        assert_eq!(parsed.previous_generation, Some(3));
319        assert_eq!(parsed.new_generation, Some(4));
320        assert_eq!(parsed.idempotency_key.as_deref(), Some("k1"));
321        matches!(parsed.authorization, AuditDecision::Deny { .. });
322        matches!(parsed.result, AuditResult::Error { .. });
323    }
324
325    #[test]
326    fn audit_log_for_env_rejects_unsafe_env_id() {
327        let dir = tempdir().unwrap();
328        let store = LocalFsStore::new(dir.path());
329        // EnvId itself allows "." and "..", but the store's safe_env_segment
330        // helper inside env_dir() rejects them.
331        let env_id = EnvId::try_from("..").unwrap();
332        let err = AuditLog::for_env(&store, &env_id).unwrap_err();
333        assert!(matches!(err, AuditError::Store(StoreError::UnsafeEnvId(_))));
334    }
335}