bitwarden_pam/rotation/
validate.rs1use bitwarden_pam_sealed::PasswordPolicyError;
2use thiserror::Error;
3
4use super::{configs::RotationConfigCreateRequest, schedule::is_likely_quartz_cron};
5
6const MAX_NAME_LENGTH: usize = 200;
8const MAX_ACCOUNT_IDENTITY_LENGTH: usize = 500;
10
11#[derive(Debug, Error, PartialEq, Eq)]
13pub enum RotationValidationError {
14 #[error("Name must be between 1 and {MAX_NAME_LENGTH} characters")]
16 InvalidName,
17 #[error("Account identity must be between 1 and {MAX_ACCOUNT_IDENTITY_LENGTH} characters")]
19 InvalidAccountIdentity,
20 #[error("Password length bounds are outside the generator's supported range")]
22 InvalidPasswordLengthBounds,
23 #[error("A password policy must allow at least one character class")]
25 NoCharacterClasses,
26 #[error("Schedule must be a 6- or 7-field Quartz cron expression")]
28 InvalidCron,
29 #[error("The target system does not confirm it supports session termination")]
32 SessionTerminationUnsupported,
33 #[error("The target system's sealed data is invalid")]
35 TargetSystemSealInvalid,
36 #[error("Target system method cannot be changed")]
38 TargetSystemMethodMismatch,
39 #[error("Target system kind cannot be changed")]
41 TargetSystemKindMismatch,
42 #[error("Rotation config cipher or target system cannot be changed")]
44 RotationConfigImmutableFieldMismatch,
45}
46
47impl From<PasswordPolicyError> for RotationValidationError {
48 fn from(error: PasswordPolicyError) -> Self {
49 match error {
50 PasswordPolicyError::NoCharacterClasses => Self::NoCharacterClasses,
51 PasswordPolicyError::InvalidLengthBounds => Self::InvalidPasswordLengthBounds,
52 }
53 }
54}
55
56pub(super) fn validate_name(name: &str) -> Result<(), RotationValidationError> {
59 let trimmed = name.trim();
60 if trimmed.is_empty() || trimmed.encode_utf16().count() > MAX_NAME_LENGTH {
61 return Err(RotationValidationError::InvalidName);
62 }
63
64 Ok(())
65}
66
67fn validate_account_identity(identity: &str) -> Result<(), RotationValidationError> {
68 let trimmed = identity.trim();
69 if trimmed.is_empty() || trimmed.encode_utf16().count() > MAX_ACCOUNT_IDENTITY_LENGTH {
70 return Err(RotationValidationError::InvalidAccountIdentity);
71 }
72
73 Ok(())
74}
75
76fn validate_schedule_cron(cron: Option<&String>) -> Result<(), RotationValidationError> {
79 match cron {
80 Some(cron) if !is_likely_quartz_cron(cron) => Err(RotationValidationError::InvalidCron),
81 _ => Ok(()),
82 }
83}
84
85pub(super) fn validate_config_request(
88 request: &RotationConfigCreateRequest,
89) -> Result<(), RotationValidationError> {
90 validate_account_identity(&request.account_identity)?;
91 validate_schedule_cron(request.schedule_cron.as_ref())
92}
93
94#[cfg(test)]
95mod tests {
96 use bitwarden_pam_sealed::{GENERATOR_MAX_LENGTH, GENERATOR_MIN_LENGTH};
97 use bitwarden_vault::CipherId;
98 use uuid::uuid;
99
100 use super::*;
101 use crate::{TargetSystemId, rotation::models::PasswordPolicy};
102
103 fn policy() -> PasswordPolicy {
104 PasswordPolicy {
105 min_length: 14,
106 max_length: 64,
107 include_uppercase: true,
108 include_lowercase: true,
109 include_digits: true,
110 include_symbols: true,
111 }
112 }
113
114 fn config_create(account_identity: &str, cron: Option<&str>) -> RotationConfigCreateRequest {
115 RotationConfigCreateRequest {
116 cipher_id: CipherId::new(uuid!("11111111-1111-1111-1111-111111111111")),
117 target_system_id: TargetSystemId::new(uuid!("22222222-2222-2222-2222-222222222222")),
118 account_identity: account_identity.to_string(),
119 terminate_sessions: false,
120 schedule_cron: cron.map(ToString::to_string),
121 rotate_on_access_end: false,
122 }
123 }
124
125 #[test]
126 fn a_well_formed_name_and_policy_pass() {
127 assert!(validate_name("Prod SQL").is_ok());
128 assert!(policy().validate().is_ok());
129 }
130
131 #[test]
132 fn a_blank_name_is_rejected() {
133 for name in ["", " ", "\t\n"] {
134 assert_eq!(
135 validate_name(name),
136 Err(RotationValidationError::InvalidName),
137 "for {name:?}"
138 );
139 }
140 }
141
142 #[test]
143 fn a_name_at_the_limit_passes_and_one_over_is_rejected() {
144 let at_limit = "a".repeat(MAX_NAME_LENGTH);
145 assert!(validate_name(&at_limit).is_ok());
146
147 let over_limit = "a".repeat(MAX_NAME_LENGTH + 1);
148 assert_eq!(
149 validate_name(&over_limit),
150 Err(RotationValidationError::InvalidName)
151 );
152 }
153
154 #[test]
157 fn name_length_is_counted_in_utf16_code_units() {
158 let at_limit = "😀".repeat(MAX_NAME_LENGTH / 2);
159 assert!(validate_name(&at_limit).is_ok());
160
161 let over_limit = "😀".repeat(MAX_NAME_LENGTH / 2 + 1);
162 assert_eq!(
163 validate_name(&over_limit),
164 Err(RotationValidationError::InvalidName)
165 );
166 }
167
168 #[test]
171 fn surrounding_whitespace_does_not_count_towards_the_limit() {
172 let padded = format!(" {} ", "a".repeat(MAX_NAME_LENGTH));
173 assert!(validate_name(&padded).is_ok());
174 }
175
176 #[test]
177 fn inverted_length_bounds_are_rejected() {
178 let policy = PasswordPolicy {
179 min_length: 64,
180 max_length: 14,
181 ..policy()
182 };
183
184 assert_eq!(
185 policy.validate().map_err(RotationValidationError::from),
186 Err(RotationValidationError::InvalidPasswordLengthBounds)
187 );
188 }
189
190 #[test]
191 fn length_bounds_outside_the_generator_range_are_rejected() {
192 for (min_length, max_length) in [
193 (0, 64),
194 (14, GENERATOR_MAX_LENGTH + 1),
195 (GENERATOR_MIN_LENGTH - 1, GENERATOR_MAX_LENGTH),
196 ] {
197 let policy = PasswordPolicy {
198 min_length,
199 max_length,
200 ..policy()
201 };
202
203 assert_eq!(
204 policy.validate().map_err(RotationValidationError::from),
205 Err(RotationValidationError::InvalidPasswordLengthBounds),
206 "for {min_length}..{max_length}"
207 );
208 }
209 }
210
211 #[test]
212 fn equal_length_bounds_are_accepted() {
213 let policy = PasswordPolicy {
214 min_length: 32,
215 max_length: 32,
216 ..policy()
217 };
218
219 assert!(
220 policy
221 .validate()
222 .map_err(RotationValidationError::from)
223 .is_ok()
224 );
225 }
226
227 #[test]
230 fn a_policy_with_no_character_classes_is_rejected() {
231 let policy = PasswordPolicy {
232 include_uppercase: false,
233 include_lowercase: false,
234 include_digits: false,
235 include_symbols: false,
236 ..policy()
237 };
238
239 assert_eq!(
240 policy.validate().map_err(RotationValidationError::from),
241 Err(RotationValidationError::NoCharacterClasses)
242 );
243 }
244
245 #[test]
246 fn a_single_character_class_is_enough() {
247 let policy = PasswordPolicy {
248 include_uppercase: false,
249 include_lowercase: true,
250 include_digits: false,
251 include_symbols: false,
252 ..policy()
253 };
254
255 assert!(
256 policy
257 .validate()
258 .map_err(RotationValidationError::from)
259 .is_ok()
260 );
261 }
262
263 #[test]
264 fn a_config_with_no_schedule_is_valid() {
265 assert!(validate_config_request(&config_create("svc_rotation", None)).is_ok());
266 }
267
268 #[test]
269 fn a_config_with_a_well_formed_cron_is_valid() {
270 assert!(
271 validate_config_request(&config_create("svc_rotation", Some("0 0 0 * * ?"))).is_ok()
272 );
273 }
274
275 #[test]
276 fn a_malformed_cron_is_rejected() {
277 assert_eq!(
279 validate_config_request(&config_create("svc_rotation", Some("0 0 * * *"))),
280 Err(RotationValidationError::InvalidCron)
281 );
282 }
283
284 #[test]
285 fn a_blank_account_identity_is_rejected() {
286 assert_eq!(
287 validate_config_request(&config_create(" ", None)),
288 Err(RotationValidationError::InvalidAccountIdentity)
289 );
290 }
291
292 #[test]
293 fn an_account_identity_at_the_limit_passes_and_one_over_is_rejected() {
294 let at_limit = "a".repeat(MAX_ACCOUNT_IDENTITY_LENGTH);
295 assert!(validate_config_request(&config_create(&at_limit, None)).is_ok());
296
297 let over_limit = "a".repeat(MAX_ACCOUNT_IDENTITY_LENGTH + 1);
298 assert_eq!(
299 validate_config_request(&config_create(&over_limit, None)),
300 Err(RotationValidationError::InvalidAccountIdentity)
301 );
302 }
303}