Skip to main content

bitwarden_pam/access_requests/
validate.rs

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