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