1use std::fmt::{self, Debug};
19use std::net::Ipv4Addr;
20use std::ops::{BitOr, BitOrAssign};
21
22use reqsign_core::{Error, Result};
23use serde::Serialize;
24
25mod server_side;
26pub use server_side::ServerSideCredentialAccessBoundaryGranter;
27
28mod sts;
29
30#[cfg(feature = "credential-access-boundary-client-side")]
31mod client_side;
32#[cfg(feature = "credential-access-boundary-client-side")]
33pub use client_side::ClientSideCredentialAccessBoundaryGranter;
34
35const MAX_ACCESS_BOUNDARY_RULES: usize = 10;
36const MAX_ACCESS_BOUNDARY_CHARACTERS: usize = 2048;
37const MAX_CONDITION_CHARACTERS: usize = 2048;
38const MAX_OPTIONS_CHARACTERS: usize = 4 * 1024 * 1024;
39
40const OBJECT_VIEWER_ROLE: u8 = 1 << 0;
41const OBJECT_CREATOR_ROLE: u8 = 1 << 1;
42const OBJECT_USER_ROLE: u8 = 1 << 2;
43const OBJECT_ADMIN_ROLE: u8 = 1 << 3;
44const ALL_ROLES: u8 =
45 OBJECT_VIEWER_ROLE | OBJECT_CREATOR_ROLE | OBJECT_USER_ROLE | OBJECT_ADMIN_ROLE;
46
47#[derive(Clone, Copy, PartialEq, Eq)]
55pub struct CredentialAccessBoundaryPermissions(u8);
56
57impl CredentialAccessBoundaryPermissions {
58 pub const OBJECT_VIEWER: Self = Self(OBJECT_VIEWER_ROLE);
60 pub const OBJECT_CREATOR: Self = Self(OBJECT_CREATOR_ROLE);
62 pub const OBJECT_USER: Self = Self(OBJECT_USER_ROLE);
64 pub const OBJECT_ADMIN: Self = Self(OBJECT_ADMIN_ROLE);
66
67 pub const fn is_empty(self) -> bool {
69 self.0 == 0
70 }
71
72 pub const fn contains(self, other: Self) -> bool {
74 self.0 & other.0 == other.0
75 }
76
77 fn roles(self) -> Result<Vec<&'static str>> {
78 if self.is_empty() || self.0 & !ALL_ROLES != 0 {
79 return Err(Error::request_invalid(
80 "credential access boundary permissions must contain supported roles",
81 ));
82 }
83
84 let mut roles = Vec::with_capacity(self.0.count_ones() as usize);
85 if self.contains(Self::OBJECT_VIEWER) {
86 roles.push("inRole:roles/storage.objectViewer");
87 }
88 if self.contains(Self::OBJECT_CREATOR) {
89 roles.push("inRole:roles/storage.objectCreator");
90 }
91 if self.contains(Self::OBJECT_USER) {
92 roles.push("inRole:roles/storage.objectUser");
93 }
94 if self.contains(Self::OBJECT_ADMIN) {
95 roles.push("inRole:roles/storage.objectAdmin");
96 }
97 Ok(roles)
98 }
99}
100
101impl Debug for CredentialAccessBoundaryPermissions {
102 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103 f.write_str("CredentialAccessBoundaryPermissions(REDACTED)")
104 }
105}
106
107impl BitOr for CredentialAccessBoundaryPermissions {
108 type Output = Self;
109
110 fn bitor(self, rhs: Self) -> Self::Output {
111 Self(self.0 | rhs.0)
112 }
113}
114
115impl BitOrAssign for CredentialAccessBoundaryPermissions {
116 fn bitor_assign(&mut self, rhs: Self) {
117 self.0 |= rhs.0;
118 }
119}
120
121#[derive(Clone)]
122struct CredentialAccessBoundaryRule {
123 bucket: String,
124 object_prefix: Option<String>,
125 permissions: CredentialAccessBoundaryPermissions,
126}
127
128#[derive(Clone)]
139pub struct CredentialAccessBoundaryGrant {
140 rules: Vec<CredentialAccessBoundaryRule>,
141}
142
143impl Debug for CredentialAccessBoundaryGrant {
144 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145 f.debug_struct("CredentialAccessBoundaryGrant")
146 .field("rules", &"REDACTED")
147 .finish()
148 }
149}
150
151impl CredentialAccessBoundaryGrant {
152 pub fn for_bucket(
154 bucket: impl Into<String>,
155 permissions: CredentialAccessBoundaryPermissions,
156 ) -> Self {
157 Self {
158 rules: vec![CredentialAccessBoundaryRule {
159 bucket: bucket.into(),
160 object_prefix: None,
161 permissions,
162 }],
163 }
164 }
165
166 pub fn for_object_prefix(
171 bucket: impl Into<String>,
172 object_prefix: impl Into<String>,
173 permissions: CredentialAccessBoundaryPermissions,
174 ) -> Self {
175 Self {
176 rules: vec![CredentialAccessBoundaryRule {
177 bucket: bucket.into(),
178 object_prefix: Some(object_prefix.into()),
179 permissions,
180 }],
181 }
182 }
183
184 pub fn with_bucket_rule(
188 mut self,
189 bucket: impl Into<String>,
190 permissions: CredentialAccessBoundaryPermissions,
191 ) -> Self {
192 self.rules.push(CredentialAccessBoundaryRule {
193 bucket: bucket.into(),
194 object_prefix: None,
195 permissions,
196 });
197 self
198 }
199
200 pub fn with_object_prefix_rule(
204 mut self,
205 bucket: impl Into<String>,
206 object_prefix: impl Into<String>,
207 permissions: CredentialAccessBoundaryPermissions,
208 ) -> Self {
209 self.rules.push(CredentialAccessBoundaryRule {
210 bucket: bucket.into(),
211 object_prefix: Some(object_prefix.into()),
212 permissions,
213 });
214 self
215 }
216
217 fn access_boundary(&self) -> Result<AccessBoundary> {
218 if self.rules.is_empty() || self.rules.len() > MAX_ACCESS_BOUNDARY_RULES {
219 return Err(Error::request_invalid(
220 "credential access boundary must contain between one and ten rules",
221 ));
222 }
223
224 let rules = self
225 .rules
226 .iter()
227 .enumerate()
228 .map(|(index, rule)| {
229 rule.to_wire()
230 .map_err(|err| err.with_context(format!("rule_index: {index}")))
231 })
232 .collect::<Result<Vec<_>>>()?;
233
234 let access_boundary = AccessBoundary {
235 access_boundary_rules: rules,
236 };
237 let access_boundary_json = serde_json::to_string(&access_boundary).map_err(|err| {
238 Error::unexpected("failed to serialize credential access boundary").with_source(err)
239 })?;
240 if access_boundary_json.chars().count() > MAX_ACCESS_BOUNDARY_CHARACTERS {
241 return Err(Error::request_invalid(
242 "credential access boundary exceeds the size limit",
243 ));
244 }
245 Ok(access_boundary)
246 }
247
248 #[cfg(any(feature = "credential-access-boundary-client-side", test))]
249 fn validate(&self) -> Result<()> {
250 self.access_boundary().map(drop)
251 }
252
253 fn options_json(&self) -> Result<String> {
254 let access_boundary = self.access_boundary()?;
255 let json = serde_json::to_string(&StsOptions { access_boundary }).map_err(|err| {
256 Error::unexpected("failed to serialize credential access boundary").with_source(err)
257 })?;
258 if json.chars().count() > MAX_OPTIONS_CHARACTERS {
259 return Err(Error::request_invalid(
260 "credential access boundary options exceed the STS size limit",
261 ));
262 }
263 Ok(json)
264 }
265}
266
267impl CredentialAccessBoundaryRule {
268 fn to_wire(&self) -> Result<AccessBoundaryRule> {
269 validate_bucket_name(&self.bucket)?;
270 let available_permissions = self.permissions.roles()?;
271 let available_resource = format!(
272 "//storage.googleapis.com/projects/_/buckets/{}",
273 self.bucket
274 );
275
276 let availability_condition = self
277 .object_prefix
278 .as_deref()
279 .map(|prefix| build_prefix_condition(&self.bucket, prefix))
280 .transpose()?;
281
282 Ok(AccessBoundaryRule {
283 available_resource,
284 available_permissions,
285 availability_condition,
286 })
287 }
288}
289
290#[derive(Serialize)]
291#[serde(rename_all = "camelCase")]
292struct StsOptions {
293 access_boundary: AccessBoundary,
294}
295
296#[derive(Serialize)]
297#[serde(rename_all = "camelCase")]
298struct AccessBoundary {
299 access_boundary_rules: Vec<AccessBoundaryRule>,
300}
301
302#[derive(Serialize)]
303#[serde(rename_all = "camelCase")]
304struct AccessBoundaryRule {
305 available_resource: String,
306 available_permissions: Vec<&'static str>,
307 #[serde(skip_serializing_if = "Option::is_none")]
308 availability_condition: Option<AvailabilityCondition>,
309}
310
311#[derive(Serialize)]
312struct AvailabilityCondition {
313 expression: String,
314}
315
316fn validate_bucket_name(bucket: &str) -> Result<()> {
317 let length = bucket.len();
318 let valid_length = if bucket.contains('.') {
319 (3..=222).contains(&length)
320 } else {
321 (3..=63).contains(&length)
322 };
323 let valid_characters = bucket
324 .bytes()
325 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"-_.".contains(&byte));
326 let starts_and_ends_with_alphanumeric = bucket
327 .as_bytes()
328 .first()
329 .is_some_and(u8::is_ascii_alphanumeric)
330 && bucket
331 .as_bytes()
332 .last()
333 .is_some_and(u8::is_ascii_alphanumeric);
334 let valid_components = bucket.split('.').all(|component| {
335 !component.is_empty()
336 && component.len() <= 63
337 && (!bucket.contains('.')
338 || (component.bytes().all(|byte| {
339 byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-'
340 }) && component
341 .as_bytes()
342 .first()
343 .is_some_and(u8::is_ascii_alphanumeric)
344 && component
345 .as_bytes()
346 .last()
347 .is_some_and(u8::is_ascii_alphanumeric)))
348 });
349 let reserved =
350 bucket.starts_with("goog") || bucket.contains("google") || bucket.contains("g00gle");
351
352 if !valid_length
353 || !valid_characters
354 || !starts_and_ends_with_alphanumeric
355 || !valid_components
356 || reserved
357 || bucket.parse::<Ipv4Addr>().is_ok()
358 {
359 return Err(Error::request_invalid(
360 "credential access boundary bucket name is invalid",
361 ));
362 }
363 Ok(())
364}
365
366fn build_prefix_condition(bucket: &str, prefix: &str) -> Result<AvailabilityCondition> {
367 if prefix.is_empty() || prefix.len() > 1024 || prefix.contains('\r') || prefix.contains('\n') {
368 return Err(Error::request_invalid(
369 "credential access boundary object prefix is invalid",
370 ));
371 }
372
373 let object_resource_prefix = format!("projects/_/buckets/{bucket}/objects/{prefix}");
374 let object_resource_literal =
375 serde_json::to_string(&object_resource_prefix).map_err(|err| {
376 Error::unexpected("failed to encode object resource prefix").with_source(err)
377 })?;
378 let list_prefix_literal = serde_json::to_string(prefix)
379 .map_err(|err| Error::unexpected("failed to encode object prefix").with_source(err))?;
380
381 let expression = format!(
382 "resource.name.startsWith({object_resource_literal}) || \
383 api.getAttribute(\"storage.googleapis.com/objectListPrefix\", \"\")\
384 .startsWith({list_prefix_literal})"
385 );
386 if expression.chars().count() > MAX_CONDITION_CHARACTERS {
387 return Err(Error::request_invalid(
388 "credential access boundary condition exceeds the size limit",
389 ));
390 }
391
392 Ok(AvailabilityCondition { expression })
393}
394
395#[cfg(test)]
396mod tests {
397 use reqsign_core::ErrorKind;
398
399 use super::*;
400
401 #[test]
402 fn validates_bucket_prefix_permission_and_rule_limits() {
403 let permissions = CredentialAccessBoundaryPermissions::OBJECT_VIEWER;
404 let invalid = [
405 CredentialAccessBoundaryGrant::for_bucket("ab", permissions),
406 CredentialAccessBoundaryGrant::for_bucket("UPPER", permissions),
407 CredentialAccessBoundaryGrant::for_bucket("bucket/name", permissions),
408 CredentialAccessBoundaryGrant::for_bucket("-bucket", permissions),
409 CredentialAccessBoundaryGrant::for_bucket("bucket-", permissions),
410 CredentialAccessBoundaryGrant::for_bucket("192.168.0.1", permissions),
411 CredentialAccessBoundaryGrant::for_bucket("goog-reserved", permissions),
412 CredentialAccessBoundaryGrant::for_bucket("bucket..name", permissions),
413 CredentialAccessBoundaryGrant::for_bucket(
414 "example-bucket",
415 CredentialAccessBoundaryPermissions(0),
416 ),
417 CredentialAccessBoundaryGrant::for_object_prefix("example-bucket", "", permissions),
418 CredentialAccessBoundaryGrant::for_object_prefix(
419 "example-bucket",
420 "line\nbreak",
421 permissions,
422 ),
423 CredentialAccessBoundaryGrant::for_object_prefix(
424 "example-bucket",
425 "x".repeat(1025),
426 permissions,
427 ),
428 ];
429 for grant in invalid {
430 let err = grant.validate().expect_err("invalid grant must fail");
431 assert_eq!(err.kind(), ErrorKind::RequestInvalid);
432 }
433
434 let mut maximum = CredentialAccessBoundaryGrant::for_bucket("bucket-0", permissions);
435 for index in 1..MAX_ACCESS_BOUNDARY_RULES {
436 maximum = maximum.with_bucket_rule(format!("bucket-{index}"), permissions);
437 }
438 maximum
439 .validate()
440 .expect("ten valid rules must be accepted");
441
442 let too_many = maximum.with_bucket_rule("bucket-10", permissions);
443 let err = too_many
444 .validate()
445 .expect_err("more than ten rules must fail");
446 assert_eq!(err.kind(), ErrorKind::RequestInvalid);
447 }
448
449 #[test]
450 fn serializes_typed_roles_and_escapes_prefix_without_widening() {
451 let permissions = CredentialAccessBoundaryPermissions::OBJECT_ADMIN
452 | CredentialAccessBoundaryPermissions::OBJECT_VIEWER
453 | CredentialAccessBoundaryPermissions::OBJECT_CREATOR;
454 let bucket_json = CredentialAccessBoundaryGrant::for_bucket("bucket_123", permissions)
455 .options_json()
456 .expect("bucket grant must serialize");
457 let bucket: serde_json::Value =
458 serde_json::from_str(&bucket_json).expect("options must be JSON");
459 let rule = &bucket["accessBoundary"]["accessBoundaryRules"][0];
460 assert_eq!(
461 rule["availableResource"],
462 "//storage.googleapis.com/projects/_/buckets/bucket_123"
463 );
464 assert_eq!(
465 rule["availablePermissions"],
466 serde_json::json!([
467 "inRole:roles/storage.objectViewer",
468 "inRole:roles/storage.objectCreator",
469 "inRole:roles/storage.objectAdmin"
470 ])
471 );
472 assert!(rule.get("availabilityCondition").is_none());
473
474 let prefix = r#"tenant/") || true || (""#;
475 let prefix_json = CredentialAccessBoundaryGrant::for_object_prefix(
476 "example-bucket",
477 prefix,
478 CredentialAccessBoundaryPermissions::OBJECT_USER,
479 )
480 .options_json()
481 .expect("prefix grant must serialize");
482 let prefix_value: serde_json::Value =
483 serde_json::from_str(&prefix_json).expect("options must be JSON");
484 assert_eq!(
485 prefix_value["accessBoundary"]["accessBoundaryRules"][0]["availabilityCondition"]["expression"],
486 r#"resource.name.startsWith("projects/_/buckets/example-bucket/objects/tenant/\") || true || (\"") || api.getAttribute("storage.googleapis.com/objectListPrefix", "").startsWith("tenant/\") || true || (\"")"#
487 );
488 }
489
490 #[test]
491 fn preserves_prefix_semantics_without_normalization() {
492 let prefix = "/leading//nested/";
493 let json = CredentialAccessBoundaryGrant::for_object_prefix(
494 "example-bucket",
495 prefix,
496 CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
497 )
498 .options_json()
499 .expect("prefix grant must serialize");
500 let options: serde_json::Value = serde_json::from_str(&json).expect("options must be JSON");
501 let expression = options["accessBoundary"]["accessBoundaryRules"][0]
502 ["availabilityCondition"]["expression"]
503 .as_str()
504 .expect("condition must be a string");
505 assert!(expression.contains("objects//leading//nested/"));
506 assert!(expression.ends_with(r#".startsWith("/leading//nested/")"#));
507 }
508
509 #[test]
510 fn enforces_access_boundary_size_limit() {
511 let permissions = CredentialAccessBoundaryPermissions::OBJECT_VIEWER;
512 CredentialAccessBoundaryGrant::for_object_prefix(
513 "example-bucket",
514 "x".repeat(772),
515 permissions,
516 )
517 .with_bucket_rule("aaa", permissions)
518 .validate()
519 .expect("boundary at the documented size limit must be accepted");
520
521 let err = CredentialAccessBoundaryGrant::for_object_prefix(
522 "example-bucket",
523 "x".repeat(773),
524 permissions,
525 )
526 .with_bucket_rule("aaa", permissions)
527 .validate()
528 .expect_err("boundary over the documented size limit must fail");
529 assert_eq!(err.kind(), ErrorKind::RequestInvalid);
530 }
531}