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