greentic_deployer/environment/
audit.rs1use 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
32pub 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
61pub 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#[derive(Debug)]
120pub struct AuditLog {
121 path: PathBuf,
122}
123
124impl AuditLog {
125 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 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 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 let actor = current_local_actor();
296 assert_eq!(actor.kind, "local-user");
297 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 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}