Skip to main content

bitwarden_pam/access_requests/
validate.rs

1use chrono::{DateTime, Duration, Utc};
2use thiserror::Error;
3
4use super::models::AccessRequestCreateRequest;
5
6/// Maximum span, in seconds, of an access request's activation window - mirrors the server's
7/// `LeaseDurationBounds.GlobalMaxSeconds` (one year). Applies to both the automatic path's
8/// [`duration_seconds`](AccessRequestCreateRequest::duration_seconds) and the human path's
9/// [`start`](AccessRequestCreateRequest::start)/[`end`](AccessRequestCreateRequest::end) span.
10///
11/// A backstop, not policy: lowering it refuses requests the server would accept.
12pub const MAX_REQUEST_ACCESS_WINDOW_SECONDS: u32 = 365 * 24 * 60 * 60;
13
14/// [`MAX_REQUEST_ACCESS_WINDOW_SECONDS`], for callers that cannot read a Rust `const`.
15#[bitwarden_ffi::wasm_export]
16pub fn max_request_access_window_seconds() -> u32 {
17    MAX_REQUEST_ACCESS_WINDOW_SECONDS
18}
19
20/// Duration, in seconds, to pre-select when the pre-check response names no default; mirrors the
21/// server's global default.
22pub const DEFAULT_REQUEST_ACCESS_DURATION_SECONDS: u32 = 3_600;
23
24/// [`DEFAULT_REQUEST_ACCESS_DURATION_SECONDS`], for callers that cannot read a Rust `const`.
25#[bitwarden_ffi::wasm_export]
26pub fn default_request_access_duration_seconds() -> u32 {
27    DEFAULT_REQUEST_ACCESS_DURATION_SECONDS
28}
29
30/// Errors from validating an [`AccessRequestCreateRequest`] before it is sent to the server.
31#[derive(Debug, Error, PartialEq, Eq)]
32pub enum AccessRequestWindowError {
33    /// `end` was not strictly after `start`.
34    #[error("end must be strictly after start")]
35    EndBeforeStart,
36    /// `end` had already passed, so the window could never be activated.
37    #[error("The requested window has already ended.")]
38    EndInPast,
39    /// The requested window was longer than the global ceiling. The rule's own cap is enforced
40    /// server-side.
41    #[error(
42        "The requested window exceeds the maximum of {MAX_REQUEST_ACCESS_WINDOW_SECONDS} seconds"
43    )]
44    ExceedsMaxWindow,
45}
46
47impl AccessRequestCreateRequest {
48    /// Validates the request's activation window before it is sent to the server.
49    pub(crate) fn validate(&self) -> Result<(), AccessRequestWindowError> {
50        self.validate_at(Utc::now())
51    }
52
53    /// [`validate`](Self::validate) against a caller-supplied instant, so the elapsed-window rule
54    /// is testable without a fake clock.
55    pub(crate) fn validate_at(&self, now: DateTime<Utc>) -> Result<(), AccessRequestWindowError> {
56        if let (Some(start), Some(end)) = (self.start, self.end) {
57            if end <= start {
58                return Err(AccessRequestWindowError::EndBeforeStart);
59            }
60            // Measured on the end, not the start: a window already under way is still usable.
61            // An ended window can never be activated.
62            if end <= now {
63                return Err(AccessRequestWindowError::EndInPast);
64            }
65            if end - start > Duration::seconds(MAX_REQUEST_ACCESS_WINDOW_SECONDS as i64) {
66                return Err(AccessRequestWindowError::ExceedsMaxWindow);
67            }
68        }
69
70        if let Some(duration_seconds) = self.duration_seconds
71            && duration_seconds.get() > MAX_REQUEST_ACCESS_WINDOW_SECONDS
72        {
73            return Err(AccessRequestWindowError::ExceedsMaxWindow);
74        }
75
76        Ok(())
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use std::num::NonZeroU32;
83
84    use super::*;
85
86    /// Pinned so the verdicts don't change as the wall clock passes the dates below.
87    fn now() -> DateTime<Utc> {
88        "2024-12-31T23:00:00Z".parse().unwrap()
89    }
90
91    /// Start instant for the span cases, after [`now`] so none trip the elapsed-window rule.
92    fn start() -> DateTime<Utc> {
93        "2025-01-01T00:00:00Z".parse().unwrap()
94    }
95
96    fn base_request() -> AccessRequestCreateRequest {
97        AccessRequestCreateRequest {
98            duration_seconds: None,
99            start: None,
100            end: None,
101            reason: None,
102        }
103    }
104
105    #[test]
106    fn neither_duration_nor_window_is_not_rejected_locally() {
107        // Only the server knows which path applies, so it rejects an incomplete request.
108        assert_eq!(base_request().validate_at(now()), Ok(()));
109    }
110
111    #[test]
112    fn end_equal_to_start_is_invalid() {
113        let request = AccessRequestCreateRequest {
114            start: Some(start()),
115            end: Some(start()),
116            ..base_request()
117        };
118
119        assert_eq!(
120            request.validate_at(now()),
121            Err(AccessRequestWindowError::EndBeforeStart)
122        );
123    }
124
125    #[test]
126    fn end_before_start_is_invalid() {
127        let request = AccessRequestCreateRequest {
128            start: Some("2025-01-01T01:00:00Z".parse().unwrap()),
129            end: Some("2025-01-01T00:00:00Z".parse().unwrap()),
130            ..base_request()
131        };
132
133        assert_eq!(
134            request.validate_at(now()),
135            Err(AccessRequestWindowError::EndBeforeStart)
136        );
137    }
138
139    #[test]
140    fn window_span_is_valid_up_to_the_ceiling_and_invalid_past_it() {
141        let ceiling = Duration::seconds(MAX_REQUEST_ACCESS_WINDOW_SECONDS as i64);
142        for (span, expected) in [
143            // A rule may cap leases at a week; refusing that here pre-empts the server.
144            (Duration::days(7), Ok(())),
145            (ceiling, Ok(())),
146            (
147                ceiling + Duration::seconds(1),
148                Err(AccessRequestWindowError::ExceedsMaxWindow),
149            ),
150        ] {
151            let request = AccessRequestCreateRequest {
152                start: Some(start()),
153                end: Some(start() + span),
154                ..base_request()
155            };
156
157            assert_eq!(request.validate_at(now()), expected, "span {span:?}");
158        }
159    }
160
161    #[test]
162    fn window_that_has_already_ended_is_invalid() {
163        // A window dated days before submission.
164        let request = AccessRequestCreateRequest {
165            start: Some("2024-12-23T07:00:00Z".parse().unwrap()),
166            end: Some("2024-12-23T08:00:00Z".parse().unwrap()),
167            ..base_request()
168        };
169
170        assert_eq!(
171            request.validate_at(now()),
172            Err(AccessRequestWindowError::EndInPast)
173        );
174    }
175
176    #[test]
177    fn window_ending_exactly_now_is_invalid() {
178        // Matches activation's own already-expired refusal.
179        let request = AccessRequestCreateRequest {
180            start: Some("2024-12-31T22:00:00Z".parse().unwrap()),
181            end: Some(now()),
182            ..base_request()
183        };
184
185        assert_eq!(
186            request.validate_at(now()),
187            Err(AccessRequestWindowError::EndInPast)
188        );
189    }
190
191    #[test]
192    fn window_already_under_way_is_valid() {
193        // Only the end is checked, so access starting immediately stays requestable.
194        let request = AccessRequestCreateRequest {
195            start: Some("2024-12-31T22:00:00Z".parse().unwrap()),
196            end: Some("2025-01-01T00:00:00Z".parse().unwrap()),
197            ..base_request()
198        };
199
200        assert_eq!(request.validate_at(now()), Ok(()));
201    }
202
203    #[test]
204    fn reversed_window_reports_end_before_start_rather_than_end_in_past() {
205        // Both rules fire on a reversed window in the past; ordering puts the reversal first.
206        let request = AccessRequestCreateRequest {
207            start: Some("2024-12-23T08:00:00Z".parse().unwrap()),
208            end: Some("2024-12-23T07:00:00Z".parse().unwrap()),
209            ..base_request()
210        };
211
212        assert_eq!(
213            request.validate_at(now()),
214            Err(AccessRequestWindowError::EndBeforeStart)
215        );
216    }
217
218    #[test]
219    fn validate_measures_against_the_real_clock() {
220        let request = AccessRequestCreateRequest {
221            start: Some("2020-01-01T00:00:00Z".parse().unwrap()),
222            end: Some("2020-01-01T01:00:00Z".parse().unwrap()),
223            ..base_request()
224        };
225
226        assert_eq!(request.validate(), Err(AccessRequestWindowError::EndInPast));
227    }
228
229    #[test]
230    fn duration_seconds_is_valid_up_to_the_ceiling_and_invalid_past_it() {
231        for (duration_seconds, expected) in [
232            // The automatic path faces the same ceiling, so a week has to survive here too.
233            (7 * 86_400, Ok(())),
234            (MAX_REQUEST_ACCESS_WINDOW_SECONDS, Ok(())),
235            (
236                MAX_REQUEST_ACCESS_WINDOW_SECONDS + 1,
237                Err(AccessRequestWindowError::ExceedsMaxWindow),
238            ),
239        ] {
240            let request = AccessRequestCreateRequest {
241                duration_seconds: NonZeroU32::new(duration_seconds),
242                ..base_request()
243            };
244
245            assert_eq!(request.validate_at(now()), expected, "{duration_seconds}s");
246        }
247    }
248
249    #[test]
250    fn small_duration_seconds_is_valid() {
251        let request = AccessRequestCreateRequest {
252            duration_seconds: NonZeroU32::new(3600),
253            ..base_request()
254        };
255
256        assert_eq!(request.validate_at(now()), Ok(()));
257    }
258
259    #[test]
260    fn only_start_without_end_is_not_window_checked_locally() {
261        // The server rejects an incomplete `start`/`end` pair.
262        let request = AccessRequestCreateRequest {
263            start: Some("2025-01-01T00:00:00Z".parse().unwrap()),
264            ..base_request()
265        };
266
267        assert_eq!(request.validate_at(now()), Ok(()));
268    }
269}