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        crate::lock_recovery::recover_write(&self.states, "object_lock.states")
163            .insert((bucket.to_owned(), key.to_owned()), state);
164    }
165
166    /// Return a clone of the current state for `(bucket, key)`, if any.
167    #[must_use]
168    pub fn get(&self, bucket: &str, key: &str) -> Option<ObjectLockState> {
169        crate::lock_recovery::recover_read(&self.states, "object_lock.states")
170            .get(&(bucket.to_owned(), key.to_owned()))
171            .cloned()
172    }
173
174    /// Toggle the legal-hold flag on `(bucket, key)`. Creates a default-empty
175    /// state if no entry exists yet (legal hold is allowed even without
176    /// retention).
177    pub fn set_legal_hold(&self, bucket: &str, key: &str, on: bool) {
178        let mut guard = crate::lock_recovery::recover_write(&self.states, "object_lock.states");
179        let entry = guard
180            .entry((bucket.to_owned(), key.to_owned()))
181            .or_default();
182        entry.legal_hold_on = on;
183    }
184
185    /// Install (or replace) the bucket-default retention config. New PUTs to
186    /// this bucket without explicit retention pick this up via
187    /// [`Self::apply_default_on_put`].
188    pub fn set_bucket_default(&self, bucket: &str, default: BucketObjectLockDefault) {
189        crate::lock_recovery::recover_write(&self.bucket_defaults, "object_lock.bucket_defaults")
190            .insert(bucket.to_owned(), default);
191    }
192
193    /// Look up the bucket-default retention config, if any.
194    #[must_use]
195    pub fn bucket_default(&self, bucket: &str) -> Option<BucketObjectLockDefault> {
196        crate::lock_recovery::recover_read(&self.bucket_defaults, "object_lock.bucket_defaults")
197            .get(bucket)
198            .copied()
199    }
200
201    /// On PUT: when the bucket has a default config and no per-object state
202    /// already exists for this key, materialise a fresh state with
203    /// `retain_until = now + retention_days`. Existing state (e.g. an
204    /// earlier explicit `put_object_retention`) is left unchanged so we
205    /// don't accidentally re-arm a cleared retention on overwrite.
206    pub fn apply_default_on_put(&self, bucket: &str, key: &str, now: DateTime<Utc>) {
207        let Some(default) = self.bucket_default(bucket) else {
208            return;
209        };
210        let mut guard = crate::lock_recovery::recover_write(&self.states, "object_lock.states");
211        let key_pair = (bucket.to_owned(), key.to_owned());
212        // Skip if any prior protection is already in effect — auto-apply
213        // must not shorten an existing Compliance lock, wipe a legal hold,
214        // or *add* a fresh retention layer on a key that the operator
215        // explicitly cleared down to "legal hold only".
216        //
217        // v0.8.15 M-5 fix: the pre-v0.8.15 predicate only checked
218        // `mode.is_some() || retain_until.is_some()`. A key in the
219        // `{legal_hold_on = true}` state with no retention slots set
220        // would silently pick up the bucket default on the next PUT,
221        // implicitly adding a Governance / Compliance clock the
222        // operator never asked for. Including `legal_hold_on` in the
223        // predicate keeps the auto-apply additive only on truly fresh
224        // keys.
225        if let Some(existing) = guard.get(&key_pair)
226            && (existing.mode.is_some()
227                || existing.retain_until.is_some()
228                || existing.legal_hold_on)
229        {
230            return;
231        }
232        let retain_until = now + Duration::days(i64::from(default.retention_days));
233        let entry = guard.entry(key_pair).or_default();
234        entry.mode = Some(default.mode);
235        entry.retain_until = Some(retain_until);
236    }
237
238    /// Drop any lock state attached to `(bucket, key)`. Called by
239    /// `service.rs` after a successful (= permitted) physical delete so the
240    /// freed key can be re-armed by a future PUT under the bucket default.
241    pub fn clear(&self, bucket: &str, key: &str) {
242        crate::lock_recovery::recover_write(&self.states, "object_lock.states")
243            .remove(&(bucket.to_owned(), key.to_owned()));
244    }
245
246    /// JSON snapshot for restart-recoverable state. Pair with
247    /// [`Self::from_json`].
248    pub fn to_json(&self) -> Result<String, serde_json::Error> {
249        let states: Vec<((String, String), ObjectLockState)> =
250            crate::lock_recovery::recover_read(&self.states, "object_lock.states")
251                .iter()
252                .map(|(k, v)| (k.clone(), v.clone()))
253                .collect();
254        let bucket_defaults = crate::lock_recovery::recover_read(
255            &self.bucket_defaults,
256            "object_lock.bucket_defaults",
257        )
258        .clone();
259        let snap = ObjectLockSnapshot {
260            states,
261            bucket_defaults,
262        };
263        serde_json::to_string(&snap)
264    }
265
266    /// Restore from a JSON snapshot produced by [`Self::to_json`].
267    pub fn from_json(s: &str) -> Result<Self, serde_json::Error> {
268        let snap: ObjectLockSnapshot = serde_json::from_str(s)?;
269        let mut states = HashMap::with_capacity(snap.states.len());
270        for (k, v) in snap.states {
271            states.insert(k, v);
272        }
273        Ok(Self {
274            states: RwLock::new(states),
275            bucket_defaults: RwLock::new(snap.bucket_defaults),
276        })
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    fn now() -> DateTime<Utc> {
285        Utc::now()
286    }
287
288    #[test]
289    fn is_locked_future_retain_until() {
290        let s = ObjectLockState {
291            mode: Some(LockMode::Governance),
292            retain_until: Some(now() + Duration::hours(1)),
293            legal_hold_on: false,
294        };
295        assert!(s.is_locked(now()));
296    }
297
298    #[test]
299    fn is_locked_past_retain_until_is_unlocked() {
300        let s = ObjectLockState {
301            mode: Some(LockMode::Governance),
302            retain_until: Some(now() - Duration::hours(1)),
303            legal_hold_on: false,
304        };
305        assert!(!s.is_locked(now()));
306    }
307
308    #[test]
309    fn compliance_cannot_be_bypassed() {
310        let s = ObjectLockState {
311            mode: Some(LockMode::Compliance),
312            retain_until: Some(now() + Duration::days(7)),
313            legal_hold_on: false,
314        };
315        // Even with bypass=true, Compliance refuses delete until expiry.
316        assert!(!s.can_delete(now(), true));
317        assert!(!s.can_delete(now(), false));
318    }
319
320    #[test]
321    fn governance_can_be_bypassed_with_header() {
322        let s = ObjectLockState {
323            mode: Some(LockMode::Governance),
324            retain_until: Some(now() + Duration::days(7)),
325            legal_hold_on: false,
326        };
327        assert!(
328            s.can_delete(now(), true),
329            "bypass=true should permit delete"
330        );
331        assert!(
332            !s.can_delete(now(), false),
333            "bypass=false should refuse delete"
334        );
335    }
336
337    #[test]
338    fn legal_hold_blocks_delete_independent_of_retention() {
339        // No retention at all, just a legal hold → still locked.
340        let s = ObjectLockState {
341            mode: None,
342            retain_until: None,
343            legal_hold_on: true,
344        };
345        assert!(s.is_locked(now()));
346        assert!(!s.can_delete(now(), true), "legal hold cannot be bypassed");
347        assert!(!s.can_delete(now(), false));
348    }
349
350    #[test]
351    fn legal_hold_overrides_governance_bypass() {
352        // Governance retention with bypass=true would normally permit delete,
353        // but a legal hold present at the same time blocks it.
354        let s = ObjectLockState {
355            mode: Some(LockMode::Governance),
356            retain_until: Some(now() + Duration::days(7)),
357            legal_hold_on: true,
358        };
359        assert!(!s.can_delete(now(), true));
360    }
361
362    #[test]
363    fn no_lock_no_block() {
364        let s = ObjectLockState::default();
365        assert!(!s.is_locked(now()));
366        assert!(s.can_delete(now(), false));
367    }
368
369    #[test]
370    fn apply_default_materialises_state_on_first_put() {
371        let m = ObjectLockManager::new();
372        m.set_bucket_default(
373            "b",
374            BucketObjectLockDefault {
375                mode: LockMode::Governance,
376                retention_days: 30,
377            },
378        );
379        let now = now();
380        m.apply_default_on_put("b", "k", now);
381        let state = m.get("b", "k").expect("state must be materialised");
382        assert_eq!(state.mode, Some(LockMode::Governance));
383        let until = state.retain_until.expect("retain_until must be set");
384        let target = now + Duration::days(30);
385        // Allow 1s slack for clock granularity.
386        let diff = (until - target).num_seconds().abs();
387        assert!(diff <= 1, "retain_until off by {diff}s");
388    }
389
390    #[test]
391    fn apply_default_does_not_overwrite_existing_retention() {
392        let m = ObjectLockManager::new();
393        let custom_until = now() + Duration::days(365);
394        m.set(
395            "b",
396            "k",
397            ObjectLockState {
398                mode: Some(LockMode::Compliance),
399                retain_until: Some(custom_until),
400                legal_hold_on: false,
401            },
402        );
403        m.set_bucket_default(
404            "b",
405            BucketObjectLockDefault {
406                mode: LockMode::Governance,
407                retention_days: 1,
408            },
409        );
410        m.apply_default_on_put("b", "k", now());
411        let state = m.get("b", "k").unwrap();
412        // Existing Compliance + 365-day retain must be preserved.
413        assert_eq!(state.mode, Some(LockMode::Compliance));
414        assert_eq!(state.retain_until, Some(custom_until));
415    }
416
417    #[test]
418    fn apply_default_no_op_without_bucket_default() {
419        let m = ObjectLockManager::new();
420        m.apply_default_on_put("b", "k", now());
421        assert!(m.get("b", "k").is_none());
422    }
423
424    #[test]
425    fn set_legal_hold_creates_state_when_missing() {
426        let m = ObjectLockManager::new();
427        m.set_legal_hold("b", "k", true);
428        let s = m.get("b", "k").expect("state created");
429        assert!(s.legal_hold_on);
430        assert!(s.mode.is_none());
431        assert!(s.retain_until.is_none());
432        m.set_legal_hold("b", "k", false);
433        let s2 = m.get("b", "k").unwrap();
434        assert!(!s2.legal_hold_on);
435    }
436
437    #[test]
438    fn snapshot_roundtrip() {
439        let m = ObjectLockManager::new();
440        m.set(
441            "b1",
442            "k1",
443            ObjectLockState {
444                mode: Some(LockMode::Compliance),
445                retain_until: Some(Utc::now() + Duration::days(10)),
446                legal_hold_on: true,
447            },
448        );
449        m.set_bucket_default(
450            "b1",
451            BucketObjectLockDefault {
452                mode: LockMode::Governance,
453                retention_days: 7,
454            },
455        );
456        let json = m.to_json().expect("to_json");
457        let m2 = ObjectLockManager::from_json(&json).expect("from_json");
458        let s = m2.get("b1", "k1").expect("state survives roundtrip");
459        assert_eq!(s.mode, Some(LockMode::Compliance));
460        assert!(s.legal_hold_on);
461        let d = m2.bucket_default("b1").expect("default survives roundtrip");
462        assert_eq!(d.mode, LockMode::Governance);
463        assert_eq!(d.retention_days, 7);
464    }
465
466    #[test]
467    fn lock_mode_aws_string_roundtrip() {
468        assert_eq!(
469            LockMode::from_aws_str(LockMode::Governance.as_aws_str()),
470            Some(LockMode::Governance)
471        );
472        assert_eq!(
473            LockMode::from_aws_str(LockMode::Compliance.as_aws_str()),
474            Some(LockMode::Compliance)
475        );
476        assert_eq!(
477            LockMode::from_aws_str("governance"),
478            Some(LockMode::Governance)
479        );
480        assert!(LockMode::from_aws_str("nope").is_none());
481    }
482
483    #[test]
484    fn clear_removes_state() {
485        let m = ObjectLockManager::new();
486        m.set(
487            "b",
488            "k",
489            ObjectLockState {
490                mode: Some(LockMode::Governance),
491                retain_until: Some(Utc::now() + Duration::days(1)),
492                legal_hold_on: false,
493            },
494        );
495        assert!(m.get("b", "k").is_some());
496        m.clear("b", "k");
497        assert!(m.get("b", "k").is_none());
498    }
499
500    /// v0.8.4 #77 (audit H-8): a panic inside the `states` write guard
501    /// poisons the lock. `to_json` must recover via
502    /// [`crate::lock_recovery::recover_read`] and surface the data
503    /// instead of re-panicking.
504    #[test]
505    fn object_lock_to_json_after_panic_recovers_via_poison() {
506        let m = ObjectLockManager::new();
507        m.set(
508            "b",
509            "k",
510            ObjectLockState {
511                mode: Some(LockMode::Compliance),
512                retain_until: Some(Utc::now() + Duration::days(7)),
513                legal_hold_on: false,
514            },
515        );
516        let m = std::sync::Arc::new(m);
517        let m_cl = std::sync::Arc::clone(&m);
518        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
519            let mut g = m_cl.states.write().expect("clean lock");
520            g.entry(("b".into(), "k2".into())).or_default();
521            panic!("force-poison");
522        }));
523        assert!(
524            m.states.is_poisoned(),
525            "write panic must poison states lock"
526        );
527        let json = m.to_json().expect("to_json after poison must succeed");
528        let m2 = ObjectLockManager::from_json(&json).expect("from_json");
529        assert!(
530            m2.get("b", "k").is_some(),
531            "recovered snapshot keeps original entry"
532        );
533    }
534}