Skip to main content

reqsign_google/
credential_access_boundary.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18use 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/// Typed Google Cloud Storage roles supported by Credential Access Boundaries.
48///
49/// The CAB schema names the field `availablePermissions`, but it does not accept
50/// individual `storage.objects.*` permission names. It accepts IAM role
51/// identifiers prefixed with `inRole:`. These constants deliberately expose only
52/// the well-known predefined Cloud Storage object roles, preventing callers from
53/// injecting raw role identifiers or binding a grant to a mutable custom role.
54#[derive(Clone, Copy, PartialEq, Eq)]
55pub struct CredentialAccessBoundaryPermissions(u8);
56
57impl CredentialAccessBoundaryPermissions {
58    /// The predefined Storage Object Viewer role.
59    pub const OBJECT_VIEWER: Self = Self(OBJECT_VIEWER_ROLE);
60    /// The predefined Storage Object Creator role.
61    pub const OBJECT_CREATOR: Self = Self(OBJECT_CREATOR_ROLE);
62    /// The predefined Storage Object User role.
63    pub const OBJECT_USER: Self = Self(OBJECT_USER_ROLE);
64    /// The predefined Storage Object Admin role.
65    pub const OBJECT_ADMIN: Self = Self(OBJECT_ADMIN_ROLE);
66
67    /// Return whether no role is selected.
68    pub const fn is_empty(self) -> bool {
69        self.0 == 0
70    }
71
72    /// Return whether all roles in `other` are selected.
73    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/// A typed, bound Google Cloud Storage Credential Access Boundary.
129///
130/// Use [`CredentialAccessBoundaryGrant::for_bucket`] for bucket-wide access or
131/// [`CredentialAccessBoundaryGrant::for_object_prefix`] for a non-empty object
132/// prefix. Prefix grants generate the CEL expression internally, including the
133/// `objectListPrefix` check required for safely listing objects. Bucket and
134/// prefix values are never normalized.
135///
136/// Additional rules can be added explicitly. Google evaluates CAB rules as a
137/// union and allows at most ten rules.
138#[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    /// Create a bucket-wide Credential Access Boundary.
153    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    /// Create a Credential Access Boundary for a non-empty object prefix.
167    ///
168    /// Prefix matching preserves the exact Unicode string. For directory-like
169    /// semantics, include the trailing `/` explicitly.
170    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    /// Add another bucket-wide rule.
185    ///
186    /// All rules are evaluated as a union.
187    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    /// Add another non-empty object-prefix rule.
201    ///
202    /// All rules are evaluated as a union.
203    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}