Skip to main content

s4_server/
object_lock.rs

1//! Object Lock (WORM) enforcement layer (v0.5 #30).
2//!
3//! AWS S3 Object Lock holds objects in a "Write Once Read Many" state by
4//! attaching a *retention configuration* (mode + retain-until date) and/or a
5//! *legal hold* flag to each version. While locked, DELETE / overwrite must
6//! be refused with HTTP 403 `AccessDenied`. Two retention modes exist:
7//!
8//! * **Governance** — a privileged caller can override the lock by sending
9//!   `x-amz-bypass-governance-retention: true` (paired in real AWS with the
10//!   `s3:BypassGovernanceRetention` IAM permission; in S4 we honour the
11//!   header alone because policy gating is the operator's responsibility).
12//! * **Compliance** — never overridable until the retain-until date has
13//!   passed. Even root/admin cannot delete, including via the bypass header.
14//!
15//! Legal hold is independent of either mode: while `legal_hold_on == true`
16//! the object is locked, regardless of retain-until / mode. Setting it back
17//! to `false` is permitted at any time.
18//!
19//! ## scope (v0.5 #30)
20//!
21//! - in-memory only (single-instance scope) with optional JSON snapshot for
22//!   restart-recoverable state — same shape as `versioning.rs`'s
23//!   `--versioning-state-file`.
24//! - per-object lock state is keyed by `(bucket, key)` — version-id granular
25//!   locking is deferred (current behaviour: a lock on a key blocks DELETE
26//!   regardless of version-id; v0.6+ may attach state per (bucket, key,
27//!   version-id) to mirror AWS exactly).
28//! - per-bucket default config, when set, auto-applies to **new** objects on
29//!   PUT (existing key with state already present is left alone).
30
31use std::collections::HashMap;
32use std::sync::RwLock;
33
34use chrono::{DateTime, Duration, Utc};
35use serde::{Deserialize, Serialize};
36
37/// Retention mode for an object lock. Mirrors AWS S3 (`GOVERNANCE` /
38/// `COMPLIANCE`).
39#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
40pub enum LockMode {
41    /// Override-able with `x-amz-bypass-governance-retention: true`.
42    Governance,
43    /// Never overridable until `retain_until` expires (immutable: once set,
44    /// the mode cannot be downgraded to Governance and `retain_until` cannot
45    /// be shortened).
46    Compliance,
47}
48
49impl LockMode {
50    /// Wire format used by the S3 API (`"GOVERNANCE"` / `"COMPLIANCE"`).
51    #[must_use]
52    pub fn as_aws_str(self) -> &'static str {
53        match self {
54            Self::Governance => "GOVERNANCE",
55            Self::Compliance => "COMPLIANCE",
56        }
57    }
58
59    /// Parse the AWS wire string back into a [`LockMode`]. Case-insensitive
60    /// (AWS accepts both `GOVERNANCE` / `governance`).
61    #[must_use]
62    pub fn from_aws_str(s: &str) -> Option<Self> {
63        if s.eq_ignore_ascii_case("GOVERNANCE") {
64            Some(Self::Governance)
65        } else if s.eq_ignore_ascii_case("COMPLIANCE") {
66            Some(Self::Compliance)
67        } else {
68            None
69        }
70    }
71}
72
73/// Per-object lock state. All fields are optional so a "legal hold only"
74/// state (`mode = None`, `retain_until = None`, `legal_hold_on = true`) is
75/// representable, matching S3 semantics where a legal hold can exist without
76/// any retention.
77#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
78pub struct ObjectLockState {
79    pub mode: Option<LockMode>,
80    pub retain_until: Option<DateTime<Utc>>,
81    pub legal_hold_on: bool,
82}
83
84impl ObjectLockState {
85    /// `true` when the object is presently locked from delete / overwrite.
86    /// Legal hold flips this regardless of the retention clock; otherwise
87    /// `mode + retain_until` is what gates.
88    #[must_use]
89    pub fn is_locked(&self, now: DateTime<Utc>) -> bool {
90        if self.legal_hold_on {
91            return true;
92        }
93        match (self.mode, self.retain_until) {
94            (Some(_), Some(until)) => until > now,
95            _ => false,
96        }
97    }
98
99    /// `true` when the caller is permitted to DELETE / overwrite the object.
100    ///
101    /// - Legal hold ON → always denied (cannot be bypassed).
102    /// - Compliance + future retain → always denied (cannot be bypassed).
103    /// - Governance + future retain + `bypass_governance == true` → allowed.
104    /// - Governance + future retain + `bypass_governance == false` → denied.
105    /// - No mode, no retain, no legal hold → allowed.
106    /// - retain_until in the past → allowed (lock expired).
107    #[must_use]
108    pub fn can_delete(&self, now: DateTime<Utc>, bypass_governance: bool) -> bool {
109        if self.legal_hold_on {
110            return false;
111        }
112        match (self.mode, self.retain_until) {
113            (Some(LockMode::Compliance), Some(until)) if until > now => false,
114            (Some(LockMode::Governance), Some(until)) if until > now => bypass_governance,
115            _ => true,
116        }
117    }
118}
119
120/// Per-bucket default retention. Applied automatically to new objects on PUT
121/// (only when no explicit per-object retention was supplied and no state
122/// already exists for the (bucket, key)).
123#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
124pub struct BucketObjectLockDefault {
125    pub mode: LockMode,
126    pub retention_days: u32,
127}
128
129/// Snapshot wrapper used by [`ObjectLockManager::to_json`] /
130/// [`ObjectLockManager::from_json`].
131#[derive(Debug, Default, Serialize, Deserialize)]
132struct ObjectLockSnapshot {
133    /// `(bucket, key) → state` flattened into a `Vec` so JSON stays
134    /// human-readable (tuple keys can't roundtrip through `HashMap` JSON).
135    states: Vec<((String, String), ObjectLockState)>,
136    bucket_defaults: HashMap<String, BucketObjectLockDefault>,
137}
138
139/// Top-level manager. Owns per-(bucket, key) lock state and per-bucket
140/// default configuration. All read / write operations go through `RwLock`
141/// for thread safety; clones are cheap (`Arc<ObjectLockManager>` is the
142/// expected handle shape).
143#[derive(Debug, Default)]
144pub struct ObjectLockManager {
145    states: RwLock<HashMap<(String, String), ObjectLockState>>,
146    bucket_defaults: RwLock<HashMap<String, BucketObjectLockDefault>>,
147}
148
149impl ObjectLockManager {
150    /// Empty manager — no objects locked, no bucket defaults.
151    #[must_use]
152    pub fn new() -> Self {
153        Self::default()
154    }
155
156    /// Replace (or create) the lock state for `(bucket, key)`. `service.rs`'s
157    /// `put_object_retention` handler calls this directly after validating
158    /// the immutability rules (Compliance is one-way; once set, mode cannot
159    /// be downgraded and retain-until cannot be shortened — the caller
160    /// validates, this method just persists).
161    pub fn set(&self, bucket: &str, key: &str, state: ObjectLockState) {
162        self.states
163            .write()
164            .expect("object-lock state RwLock poisoned")
165            .insert((bucket.to_owned(), key.to_owned()), state);
166    }
167
168    /// Return a clone of the current state for `(bucket, key)`, if any.
169    #[must_use]
170    pub fn get(&self, bucket: &str, key: &str) -> Option<ObjectLockState> {
171        self.states
172            .read()
173            .expect("object-lock state RwLock poisoned")
174            .get(&(bucket.to_owned(), key.to_owned()))
175            .cloned()
176    }
177
178    /// Toggle the legal-hold flag on `(bucket, key)`. Creates a default-empty
179    /// state if no entry exists yet (legal hold is allowed even without
180    /// retention).
181    pub fn set_legal_hold(&self, bucket: &str, key: &str, on: bool) {
182        let mut guard = self
183            .states
184            .write()
185            .expect("object-lock state RwLock poisoned");
186        let entry = guard
187            .entry((bucket.to_owned(), key.to_owned()))
188            .or_default();
189        entry.legal_hold_on = on;
190    }
191
192    /// Install (or replace) the bucket-default retention config. New PUTs to
193    /// this bucket without explicit retention pick this up via
194    /// [`Self::apply_default_on_put`].
195    pub fn set_bucket_default(&self, bucket: &str, default: BucketObjectLockDefault) {
196        self.bucket_defaults
197            .write()
198            .expect("object-lock bucket-default RwLock poisoned")
199            .insert(bucket.to_owned(), default);
200    }
201
202    /// Look up the bucket-default retention config, if any.
203    #[must_use]
204    pub fn bucket_default(&self, bucket: &str) -> Option<BucketObjectLockDefault> {
205        self.bucket_defaults
206            .read()
207            .expect("object-lock bucket-default RwLock poisoned")
208            .get(bucket)
209            .copied()
210    }
211
212    /// On PUT: when the bucket has a default config and no per-object state
213    /// already exists for this key, materialise a fresh state with
214    /// `retain_until = now + retention_days`. Existing state (e.g. an
215    /// earlier explicit `put_object_retention`) is left unchanged so we
216    /// don't accidentally re-arm a cleared retention on overwrite.
217    pub fn apply_default_on_put(&self, bucket: &str, key: &str, now: DateTime<Utc>) {
218        let Some(default) = self.bucket_default(bucket) else {
219            return;
220        };
221        let mut guard = self
222            .states
223            .write()
224            .expect("object-lock state RwLock poisoned");
225        let key_pair = (bucket.to_owned(), key.to_owned());
226        // Skip if any retention is already in effect — auto-apply must not
227        // shorten an existing Compliance lock or wipe a legal hold.
228        if let Some(existing) = guard.get(&key_pair)
229            && (existing.mode.is_some() || existing.retain_until.is_some())
230        {
231            return;
232        }
233        let retain_until = now + Duration::days(i64::from(default.retention_days));
234        let entry = guard.entry(key_pair).or_default();
235        entry.mode = Some(default.mode);
236        entry.retain_until = Some(retain_until);
237    }
238
239    /// Drop any lock state attached to `(bucket, key)`. Called by
240    /// `service.rs` after a successful (= permitted) physical delete so the
241    /// freed key can be re-armed by a future PUT under the bucket default.
242    pub fn clear(&self, bucket: &str, key: &str) {
243        self.states
244            .write()
245            .expect("object-lock state RwLock poisoned")
246            .remove(&(bucket.to_owned(), key.to_owned()));
247    }
248
249    /// JSON snapshot for restart-recoverable state. Pair with
250    /// [`Self::from_json`].
251    pub fn to_json(&self) -> Result<String, serde_json::Error> {
252        let states: Vec<((String, String), ObjectLockState)> = self
253            .states
254            .read()
255            .expect("object-lock state RwLock poisoned")
256            .iter()
257            .map(|(k, v)| (k.clone(), v.clone()))
258            .collect();
259        let bucket_defaults = self
260            .bucket_defaults
261            .read()
262            .expect("object-lock bucket-default RwLock poisoned")
263            .clone();
264        let snap = ObjectLockSnapshot {
265            states,
266            bucket_defaults,
267        };
268        serde_json::to_string(&snap)
269    }
270
271    /// Restore from a JSON snapshot produced by [`Self::to_json`].
272    pub fn from_json(s: &str) -> Result<Self, serde_json::Error> {
273        let snap: ObjectLockSnapshot = serde_json::from_str(s)?;
274        let mut states = HashMap::with_capacity(snap.states.len());
275        for (k, v) in snap.states {
276            states.insert(k, v);
277        }
278        Ok(Self {
279            states: RwLock::new(states),
280            bucket_defaults: RwLock::new(snap.bucket_defaults),
281        })
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    fn now() -> DateTime<Utc> {
290        Utc::now()
291    }
292
293    #[test]
294    fn is_locked_future_retain_until() {
295        let s = ObjectLockState {
296            mode: Some(LockMode::Governance),
297            retain_until: Some(now() + Duration::hours(1)),
298            legal_hold_on: false,
299        };
300        assert!(s.is_locked(now()));
301    }
302
303    #[test]
304    fn is_locked_past_retain_until_is_unlocked() {
305        let s = ObjectLockState {
306            mode: Some(LockMode::Governance),
307            retain_until: Some(now() - Duration::hours(1)),
308            legal_hold_on: false,
309        };
310        assert!(!s.is_locked(now()));
311    }
312
313    #[test]
314    fn compliance_cannot_be_bypassed() {
315        let s = ObjectLockState {
316            mode: Some(LockMode::Compliance),
317            retain_until: Some(now() + Duration::days(7)),
318            legal_hold_on: false,
319        };
320        // Even with bypass=true, Compliance refuses delete until expiry.
321        assert!(!s.can_delete(now(), true));
322        assert!(!s.can_delete(now(), false));
323    }
324
325    #[test]
326    fn governance_can_be_bypassed_with_header() {
327        let s = ObjectLockState {
328            mode: Some(LockMode::Governance),
329            retain_until: Some(now() + Duration::days(7)),
330            legal_hold_on: false,
331        };
332        assert!(s.can_delete(now(), true), "bypass=true should permit delete");
333        assert!(
334            !s.can_delete(now(), false),
335            "bypass=false should refuse delete"
336        );
337    }
338
339    #[test]
340    fn legal_hold_blocks_delete_independent_of_retention() {
341        // No retention at all, just a legal hold → still locked.
342        let s = ObjectLockState {
343            mode: None,
344            retain_until: None,
345            legal_hold_on: true,
346        };
347        assert!(s.is_locked(now()));
348        assert!(!s.can_delete(now(), true), "legal hold cannot be bypassed");
349        assert!(!s.can_delete(now(), false));
350    }
351
352    #[test]
353    fn legal_hold_overrides_governance_bypass() {
354        // Governance retention with bypass=true would normally permit delete,
355        // but a legal hold present at the same time blocks it.
356        let s = ObjectLockState {
357            mode: Some(LockMode::Governance),
358            retain_until: Some(now() + Duration::days(7)),
359            legal_hold_on: true,
360        };
361        assert!(!s.can_delete(now(), true));
362    }
363
364    #[test]
365    fn no_lock_no_block() {
366        let s = ObjectLockState::default();
367        assert!(!s.is_locked(now()));
368        assert!(s.can_delete(now(), false));
369    }
370
371    #[test]
372    fn apply_default_materialises_state_on_first_put() {
373        let m = ObjectLockManager::new();
374        m.set_bucket_default(
375            "b",
376            BucketObjectLockDefault {
377                mode: LockMode::Governance,
378                retention_days: 30,
379            },
380        );
381        let now = now();
382        m.apply_default_on_put("b", "k", now);
383        let state = m.get("b", "k").expect("state must be materialised");
384        assert_eq!(state.mode, Some(LockMode::Governance));
385        let until = state.retain_until.expect("retain_until must be set");
386        let target = now + Duration::days(30);
387        // Allow 1s slack for clock granularity.
388        let diff = (until - target).num_seconds().abs();
389        assert!(diff <= 1, "retain_until off by {diff}s");
390    }
391
392    #[test]
393    fn apply_default_does_not_overwrite_existing_retention() {
394        let m = ObjectLockManager::new();
395        let custom_until = now() + Duration::days(365);
396        m.set(
397            "b",
398            "k",
399            ObjectLockState {
400                mode: Some(LockMode::Compliance),
401                retain_until: Some(custom_until),
402                legal_hold_on: false,
403            },
404        );
405        m.set_bucket_default(
406            "b",
407            BucketObjectLockDefault {
408                mode: LockMode::Governance,
409                retention_days: 1,
410            },
411        );
412        m.apply_default_on_put("b", "k", now());
413        let state = m.get("b", "k").unwrap();
414        // Existing Compliance + 365-day retain must be preserved.
415        assert_eq!(state.mode, Some(LockMode::Compliance));
416        assert_eq!(state.retain_until, Some(custom_until));
417    }
418
419    #[test]
420    fn apply_default_no_op_without_bucket_default() {
421        let m = ObjectLockManager::new();
422        m.apply_default_on_put("b", "k", now());
423        assert!(m.get("b", "k").is_none());
424    }
425
426    #[test]
427    fn set_legal_hold_creates_state_when_missing() {
428        let m = ObjectLockManager::new();
429        m.set_legal_hold("b", "k", true);
430        let s = m.get("b", "k").expect("state created");
431        assert!(s.legal_hold_on);
432        assert!(s.mode.is_none());
433        assert!(s.retain_until.is_none());
434        m.set_legal_hold("b", "k", false);
435        let s2 = m.get("b", "k").unwrap();
436        assert!(!s2.legal_hold_on);
437    }
438
439    #[test]
440    fn snapshot_roundtrip() {
441        let m = ObjectLockManager::new();
442        m.set(
443            "b1",
444            "k1",
445            ObjectLockState {
446                mode: Some(LockMode::Compliance),
447                retain_until: Some(Utc::now() + Duration::days(10)),
448                legal_hold_on: true,
449            },
450        );
451        m.set_bucket_default(
452            "b1",
453            BucketObjectLockDefault {
454                mode: LockMode::Governance,
455                retention_days: 7,
456            },
457        );
458        let json = m.to_json().expect("to_json");
459        let m2 = ObjectLockManager::from_json(&json).expect("from_json");
460        let s = m2.get("b1", "k1").expect("state survives roundtrip");
461        assert_eq!(s.mode, Some(LockMode::Compliance));
462        assert!(s.legal_hold_on);
463        let d = m2.bucket_default("b1").expect("default survives roundtrip");
464        assert_eq!(d.mode, LockMode::Governance);
465        assert_eq!(d.retention_days, 7);
466    }
467
468    #[test]
469    fn lock_mode_aws_string_roundtrip() {
470        assert_eq!(
471            LockMode::from_aws_str(LockMode::Governance.as_aws_str()),
472            Some(LockMode::Governance)
473        );
474        assert_eq!(
475            LockMode::from_aws_str(LockMode::Compliance.as_aws_str()),
476            Some(LockMode::Compliance)
477        );
478        assert_eq!(LockMode::from_aws_str("governance"), Some(LockMode::Governance));
479        assert!(LockMode::from_aws_str("nope").is_none());
480    }
481
482    #[test]
483    fn clear_removes_state() {
484        let m = ObjectLockManager::new();
485        m.set(
486            "b",
487            "k",
488            ObjectLockState {
489                mode: Some(LockMode::Governance),
490                retain_until: Some(Utc::now() + Duration::days(1)),
491                legal_hold_on: false,
492            },
493        );
494        assert!(m.get("b", "k").is_some());
495        m.clear("b", "k");
496        assert!(m.get("b", "k").is_none());
497    }
498}