diff --git a/src/auth/src/access_boundary.rs b/src/auth/src/access_boundary.rs new file mode 100644 index 0000000000..a7f0b9accb --- /dev/null +++ b/src/auth/src/access_boundary.rs @@ -0,0 +1,283 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::credentials::CacheableResource; +use crate::errors::CredentialsError; +use crate::headers_util::AuthHeadersBuilder; +use crate::mds::client::Client as MDSClient; +use crate::token::CachedTokenProvider; +use http::Extensions; +use reqwest::Client; +use std::clone::Clone; +use std::fmt::Debug; +use tokio::sync::watch; +use tokio::time::{Duration, sleep}; + +const REGIONAL_ACCESS_BOUNDARIES_ENV_VAR: &str = "GOOGLE_AUTH_ENABLE_TRUST_BOUNDARIES"; +const NO_OP_ENCODED_LOCATIONS: &str = "0x0"; + +// Refresh interval: 1 hour +const REFRESH_INTERVAL: Duration = Duration::from_secs(3600); +// Retry interval on error: 1 minute +const ERROR_RETRY_INTERVAL: Duration = Duration::from_secs(60); + +#[derive(Debug)] +pub(crate) struct AccessBoundary { + rx_header: watch::Receiver>, +} + +impl AccessBoundary { + pub(crate) fn new(token_provider: T, url: String) -> Self + where + T: CachedTokenProvider + 'static, + { + let enabled = Self::is_regional_access_boundaries_enabled(); + let (tx_header, rx_header) = watch::channel(None); + + if enabled { + tokio::spawn(refresh_task(token_provider, url, tx_header)); + } + + Self { rx_header } + } + + pub(crate) fn new_for_mds(token_provider: T, mds_client: MDSClient) -> Self + where + T: CachedTokenProvider + 'static, + { + let enabled = Self::is_regional_access_boundaries_enabled(); + let (tx_header, rx_header) = watch::channel(None); + + if enabled { + tokio::spawn(refresh_task_mds(token_provider, mds_client, tx_header)); + } + + Self { rx_header } + } + + #[allow(dead_code)] + pub(crate) fn new_with_override(val: String) -> Self { + let (_tx, rx_header) = watch::channel(Some(val)); + Self { rx_header } + } + + fn is_regional_access_boundaries_enabled() -> bool { + std::env::var(REGIONAL_ACCESS_BOUNDARIES_ENV_VAR) + .map(|v| v.to_lowercase() == "true") + .unwrap_or(false) + } + + pub(crate) fn header_value(&self) -> Option { + let val = self.rx_header.borrow().clone(); + if let Some(ref v) = val { + if v == NO_OP_ENCODED_LOCATIONS { + return None; + } + } + val + } +} + +#[derive(serde::Deserialize)] +struct AllowedLocationsResponse { + #[allow(dead_code)] + locations: Vec, + #[serde(rename = "encodedLocations")] + encoded_locations: String, +} + +async fn fetch_access_boundary( + token_provider: &T, + url: &str, +) -> Result, CredentialsError> +where + T: CachedTokenProvider, +{ + let token = token_provider.token(Extensions::new()).await?; + let headers = AuthHeadersBuilder::new(&token).build()?; + let headers = match headers { + CacheableResource::New { data, .. } => data, + CacheableResource::NotModified => { + unreachable!("requested access boundary without a caching etag") + } + }; + + let client = Client::new(); + + // TODO: retries ? + let resp = client + .get(url) + .headers(headers) + .send() + .await + .map_err(|e| CredentialsError::from_msg(true, e.to_string()))?; + + // TODO: add error handling - default fallback ? + if !resp.status().is_success() { + return Err(CredentialsError::from_msg( + true, + format!("Failed to fetch access boundary: {}", resp.status()), + )); + } + + let response: AllowedLocationsResponse = resp + .json() + .await + .map_err(|e| CredentialsError::from_msg(true, e.to_string()))?; + + if !response.encoded_locations.is_empty() { + return Ok(Some(response.encoded_locations)); + } + + Ok(None) +} + +async fn refresh_task_mds( + token_provider: T, + mds_client: MDSClient, + tx_header: watch::Sender>, +) where + T: CachedTokenProvider, +{ + let mut url: Option = None; + + loop { + if url.is_none() { + let res = mds_client.email().await; + match res { + Ok(email) => { + url = Some(service_account_lookup_url(&email)); + } + Err(_e) => { + sleep(ERROR_RETRY_INTERVAL).await; + continue; + } + } + } + + if let Some(ref url) = url { + fetch_and_update(&token_provider, url, &tx_header).await; + } + } +} + +async fn refresh_task(token_provider: T, url: String, tx_header: watch::Sender>) +where + T: CachedTokenProvider, +{ + loop { + fetch_and_update(&token_provider, &url, &tx_header).await; + } +} + +async fn fetch_and_update( + token_provider: &T, + url: &str, + tx_header: &watch::Sender>, +) where + T: CachedTokenProvider, +{ + match fetch_access_boundary(token_provider, url).await { + Ok(val) => { + let _ = tx_header.send(val); + sleep(REFRESH_INTERVAL).await; + } + Err(_e) => { + // TODO: better error handling - default fallback ? + sleep(ERROR_RETRY_INTERVAL).await; + } + } +} + +pub(crate) fn service_account_lookup_url(email: &str) -> String { + format!( + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{}/allowedLocations", + email + ) +} + +pub(crate) fn external_account_lookup_url(audience: &str) -> Option { + let path = audience + .trim_start_matches("//iam.googleapis.com/") + .trim_start_matches("https://iam.googleapis.com/") + .trim_start_matches('/'); + + let parts: Vec<&str> = path.split('/').collect(); + + // Workload: projects/{project}/locations/global/workloadIdentityPools/{pool}/providers/{provider} (6 parts) + if parts.len() >= 6 + && parts[0] == "projects" + && parts[2] == "locations" + && parts[4] == "workloadIdentityPools" + { + let project = parts[1]; + let pool = parts[5]; + return Some(format!( + "https://iamcredentials.googleapis.com/v1/projects/{}/locations/global/workloadIdentityPools/{}/allowedLocations", + project, pool + )); + } + + // Workforce: locations/global/workforcePools/{pool}/providers/{provider} (4 parts) + if parts.len() >= 4 && parts[0] == "locations" && parts[2] == "workforcePools" { + let pool = parts[3]; + return Some(format!( + "https://iamcredentials.googleapis.com/v1/locations/global/workforcePools/{}/allowedLocations", + pool + )); + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_service_account_url() { + assert_eq!( + service_account_lookup_url("sa@project.iam.gserviceaccount.com"), + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/sa@project.iam.gserviceaccount.com/allowedLocations" + ); + } + + #[test] + fn test_external_account_url_workload() { + let aud = "//iam.googleapis.com/projects/12345/locations/global/workloadIdentityPools/my-pool/providers/my-provider"; + assert_eq!( + external_account_lookup_url(aud).unwrap(), + "https://iamcredentials.googleapis.com/v1/projects/12345/locations/global/workloadIdentityPools/my-pool/allowedLocations" + ); + } + + #[test] + fn test_external_account_url_workforce() { + let aud = + "//iam.googleapis.com/locations/global/workforcePools/my-pool/providers/my-provider"; + assert_eq!( + external_account_lookup_url(aud).unwrap(), + "https://iamcredentials.googleapis.com/v1/locations/global/workforcePools/my-pool/allowedLocations" + ); + } + + #[test] + fn test_external_account_url_invalid() { + assert!(external_account_lookup_url("invalid").is_none()); + assert!( + external_account_lookup_url("//iam.googleapis.com/projects/123/locations/global/wrong") + .is_none() + ); + } +} diff --git a/src/auth/src/credentials/external_account.rs b/src/auth/src/credentials/external_account.rs index f1a74aa1bb..2d05579eb2 100644 --- a/src/auth/src/credentials/external_account.rs +++ b/src/auth/src/credentials/external_account.rs @@ -114,6 +114,7 @@ use super::external_account_sources::url_sourced::UrlSourcedCredentials; use super::impersonated; use super::internal::sts_exchange::{ClientAuthentication, ExchangeTokenRequest, STSHandler}; use super::{CacheableResource, Credentials}; +use crate::access_boundary::{AccessBoundary, external_account_lookup_url}; use crate::build_errors::Error as BuilderError; use crate::constants::{DEFAULT_SCOPE, STS_TOKEN_URL}; use crate::credentials::dynamic::AccessTokenCredentialsProvider; @@ -359,16 +360,20 @@ impl ExternalAccountConfig { where T: dynamic::SubjectTokenProvider + 'static, { + let access_boundary_url = external_account_lookup_url(&config.audience); let token_provider = ExternalAccountTokenProvider { subject_token_provider, config, }; let token_provider_with_retry = retry_builder.build(token_provider); let cache = TokenCache::new(token_provider_with_retry); + let access_boundary = + access_boundary_url.map(|url| Arc::new(AccessBoundary::new(cache.clone(), url))); AccessTokenCredentials { inner: Arc::new(ExternalAccountCredentials { token_provider: cache, quota_project_id, + access_boundary, }), } } @@ -457,6 +462,7 @@ where { token_provider: T, quota_project_id: Option, + access_boundary: Option>, } /// A builder for external account [Credentials] instances. @@ -1279,9 +1285,14 @@ where { async fn headers(&self, extensions: Extensions) -> Result> { let token = self.token_provider.token(extensions).await?; + let access_boundary = self + .access_boundary + .as_ref() + .and_then(|tb| tb.header_value()); AuthHeadersBuilder::new(&token) .maybe_quota_project_id(self.quota_project_id.as_deref()) + .maybe_access_boundary(access_boundary.as_deref()) .build() } } diff --git a/src/auth/src/credentials/impersonated.rs b/src/auth/src/credentials/impersonated.rs index 7bf2695b5f..c8d3a3a7fc 100644 --- a/src/auth/src/credentials/impersonated.rs +++ b/src/auth/src/credentials/impersonated.rs @@ -91,6 +91,7 @@ //! [Service Account]: https://cloud.google.com/iam/docs/service-account-overview //! [Service Account Token Creator Role]: https://cloud.google.com/docs/authentication/use-service-account-impersonation#required-roles +use crate::access_boundary::AccessBoundary; use crate::build_errors::Error as BuilderError; use crate::constants::DEFAULT_SCOPE; use crate::credentials::dynamic::{AccessTokenCredentialsProvider, CredentialsProvider}; @@ -468,11 +469,21 @@ impl Builder { /// /// [application-default credentials]: https://cloud.google.com/docs/authentication/application-default-credentials pub fn build_access_token_credentials(self) -> BuildResult { - let (token_provider, quota_project_id) = self.build_components()?; + let (token_provider, quota_project_id, service_account_impersonation_url) = + self.build_components()?; + let client_email = extract_client_email(&service_account_impersonation_url)?; + let access_boundary_url = crate::access_boundary::service_account_lookup_url(&client_email); + let token_provider = TokenCache::new(token_provider); + let access_boundary = Arc::new(AccessBoundary::new( + token_provider.clone(), + access_boundary_url, + )); + Ok(AccessTokenCredentials { inner: Arc::new(ImpersonatedServiceAccount { - token_provider: TokenCache::new(token_provider), + token_provider, quota_project_id, + access_boundary, }), }) } @@ -556,6 +567,7 @@ impl Builder { ) -> BuildResult<( TokenProviderWithRetry, Option, + String, )> { let components = match self.source { BuilderSource::FromJson(json) => build_components_from_json(json)?, @@ -574,16 +586,21 @@ impl Builder { let quota_project_id = self.quota_project_id.or(components.quota_project_id); let delegates = self.delegates.or(components.delegates); + let service_account_impersonation_url = components.service_account_impersonation_url; let token_provider = ImpersonatedTokenProvider { source_credentials: components.source_credentials, - service_account_impersonation_url: components.service_account_impersonation_url, + service_account_impersonation_url: service_account_impersonation_url.clone(), delegates, scopes, lifetime: self.lifetime.unwrap_or(DEFAULT_LIFETIME), }; let token_provider = self.retry_builder.build(token_provider); - Ok((token_provider, quota_project_id)) + Ok(( + token_provider, + quota_project_id, + service_account_impersonation_url, + )) } } @@ -697,6 +714,7 @@ where { token_provider: T, quota_project_id: Option, + access_boundary: Arc, } #[async_trait::async_trait] @@ -706,9 +724,11 @@ where { async fn headers(&self, extensions: Extensions) -> Result> { let token = self.token_provider.token(extensions).await?; + let access_boundary = self.access_boundary.header_value(); AuthHeadersBuilder::new(&token) .maybe_quota_project_id(self.quota_project_id.as_deref()) + .maybe_access_boundary(access_boundary.as_deref()) .build() } } @@ -999,7 +1019,7 @@ mod tests { "token_uri": server.url("/token").to_string() } }); - let (token_provider, _) = Builder::new(impersonated_credential) + let (token_provider, _, _) = Builder::new(impersonated_credential) .with_scopes(vec!["scope1", "scope2"]) .build_components()?; @@ -1057,7 +1077,7 @@ mod tests { "token_uri": server.url("/token").to_string() } }); - let (token_provider, _) = Builder::new(impersonated_credential).build_components()?; + let (token_provider, _, _) = Builder::new(impersonated_credential).build_components()?; let token = token_provider.token().await?; assert_eq!(token.token, "test-impersonated-token"); @@ -1113,7 +1133,7 @@ mod tests { "token_uri": server.url("/token").to_string() } }); - let (token_provider, _) = Builder::new(impersonated_credential) + let (token_provider, _, _) = Builder::new(impersonated_credential) .with_scopes(vec!["scope1", "scope2"]) .with_lifetime(Duration::from_secs_f32(3.5)) .build_components()?; @@ -1172,7 +1192,7 @@ mod tests { "token_uri": server.url("/token").to_string() } }); - let (token_provider, _) = Builder::new(impersonated_credential) + let (token_provider, _, _) = Builder::new(impersonated_credential) .with_delegates(vec!["delegate1", "delegate2"]) .build_components()?; @@ -1214,7 +1234,7 @@ mod tests { "token_uri": server.url("/token").to_string() } }); - let (token_provider, _) = Builder::new(impersonated_credential).build_components()?; + let (token_provider, _, _) = Builder::new(impersonated_credential).build_components()?; let err = token_provider.token().await.unwrap_err(); let original_err = find_source_error::(&err).unwrap(); @@ -1453,7 +1473,7 @@ mod tests { "token_uri": server.url("/token").to_string() } }); - let (token_provider, _) = Builder::new(impersonated_credential).build_components()?; + let (token_provider, _, _) = Builder::new(impersonated_credential).build_components()?; let err = token_provider.token().await.unwrap_err(); assert!(!err.is_transient()); @@ -1495,7 +1515,7 @@ mod tests { "token_uri": server.url("/token").to_string() } }); - let (token_provider, _) = Builder::new(impersonated_credential).build_components()?; + let (token_provider, _, _) = Builder::new(impersonated_credential).build_components()?; let err = token_provider.token().await.unwrap_err(); assert!(!err.is_transient()); @@ -1534,7 +1554,7 @@ mod tests { "token_uri": server.url("/token").to_string() } }); - let (token_provider, _) = Builder::new(impersonated_credential).build_components()?; + let (token_provider, _, _) = Builder::new(impersonated_credential).build_components()?; let e = token_provider.token().await.err().unwrap(); assert!(!e.is_transient(), "{e}"); @@ -1573,7 +1593,7 @@ mod tests { "token_uri": server.url("/token").to_string() } }); - let (token_provider, _) = Builder::new(impersonated_credential).build_components()?; + let (token_provider, _, _) = Builder::new(impersonated_credential).build_components()?; let err = token_provider.token().await.unwrap_err(); assert!(!err.is_transient()); @@ -1689,7 +1709,7 @@ mod tests { .build() .unwrap(); - let (token_provider, _) = Builder::from_source_credentials(source_credentials) + let (token_provider, _, _) = Builder::from_source_credentials(source_credentials) .with_target_principal("test-principal@example.iam.gserviceaccount.com") .build_components() .unwrap(); @@ -1870,7 +1890,7 @@ mod tests { "token_uri": server.url("/token").to_string() } }); - let (token_provider, _) = Builder::new(impersonated_credential).build_components()?; + let (token_provider, _, _) = Builder::new(impersonated_credential).build_components()?; let token = token_provider.token().await?; assert_eq!(token.token, "test-impersonated-token"); @@ -1930,7 +1950,7 @@ mod tests { } }); - let (token_provider, _) = Builder::new(impersonated_credential) + let (token_provider, _, _) = Builder::new(impersonated_credential) .with_retry_policy(get_mock_auth_retry_policy(3)) .with_backoff_policy(get_mock_backoff_policy()) .with_retry_throttler(get_mock_retry_throttler()) @@ -1987,7 +2007,7 @@ mod tests { "token_uri": server.url("/token").to_string() } }); - let (token_provider, _) = Builder::new(impersonated_credential).build_components()?; + let (token_provider, _, _) = Builder::new(impersonated_credential).build_components()?; let token = token_provider.token().await?; assert_eq!(token.token, "test-impersonated-token"); @@ -2039,7 +2059,7 @@ mod tests { "token_uri": server.url("/token").to_string() } }); - let (token_provider, _) = Builder::new(impersonated_credential) + let (token_provider, _, _) = Builder::new(impersonated_credential) .with_scopes(vec!["scope-from-with-scopes"]) .build_components()?; @@ -2084,7 +2104,7 @@ mod tests { } }); - let (token_provider, _) = Builder::new(impersonated_credential) + let (token_provider, _, _) = Builder::new(impersonated_credential) .with_retry_policy(get_mock_auth_retry_policy(3)) .with_backoff_policy(get_mock_backoff_policy()) .with_retry_throttler(get_mock_retry_throttler()) diff --git a/src/auth/src/credentials/mds.rs b/src/auth/src/credentials/mds.rs index 864f0e8c01..cb84717951 100644 --- a/src/auth/src/credentials/mds.rs +++ b/src/auth/src/credentials/mds.rs @@ -74,6 +74,7 @@ //! [gke-link]: https://cloud.google.com/kubernetes-engine //! [Metadata Service]: https://cloud.google.com/compute/docs/metadata/overview +use crate::access_boundary::AccessBoundary; use crate::credentials::dynamic::{AccessTokenCredentialsProvider, CredentialsProvider}; use crate::credentials::{AccessToken, AccessTokenCredentials, CacheableResource, Credentials}; use crate::headers_util::AuthHeadersBuilder; @@ -107,6 +108,7 @@ where { quota_project_id: Option, token_provider: T, + access_boundary: Arc, } /// Creates [Credentials] instances backed by the [Metadata Service]. @@ -282,9 +284,19 @@ impl Builder { /// # }); /// ``` pub fn build_access_token_credentials(self) -> BuildResult { + let quota_project_id = self.quota_project_id.clone(); + let mds_client = MDSClient::new(self.endpoint.clone()); + let token_provider = TokenCache::new(self.build_token_provider()); + + let access_boundary = Arc::new(AccessBoundary::new_for_mds( + token_provider.clone(), + mds_client.clone(), + )); + let mdsc = MDSCredentials { - quota_project_id: self.quota_project_id.clone(), - token_provider: TokenCache::new(self.build_token_provider()), + quota_project_id, + token_provider, + access_boundary, }; Ok(AccessTokenCredentials { inner: Arc::new(mdsc), @@ -338,9 +350,11 @@ where { async fn headers(&self, extensions: Extensions) -> Result> { let token = self.token_provider.token(extensions).await?; + let access_boundary = self.access_boundary.header_value(); AuthHeadersBuilder::new(&token) .maybe_quota_project_id(self.quota_project_id.as_deref()) + .maybe_access_boundary(access_boundary.as_deref()) .build() } } @@ -573,9 +587,11 @@ mod tests { let mut mock = MockTokenProvider::new(); mock.expect_token().times(1).return_once(|| Ok(token)); + let cache = TokenCache::new(mock); let mdsc = MDSCredentials { quota_project_id: None, - token_provider: TokenCache::new(mock), + token_provider: cache.clone(), + trust_boundary: Arc::new(TrustBoundary::new(cache, "http://localhost".to_string())), }; let mut extensions = Extensions::new(); @@ -633,9 +649,11 @@ mod tests { .times(1) .return_once(|| Err(errors::non_retryable_from_str("fail"))); + let cache = TokenCache::new(mock); let mdsc = MDSCredentials { quota_project_id: None, - token_provider: TokenCache::new(mock), + token_provider: cache.clone(), + trust_boundary: Arc::new(TrustBoundary::new(cache, "http://localhost".to_string())), }; let result = mdsc.headers(Extensions::new()).await; assert!(result.is_err(), "{result:?}"); diff --git a/src/auth/src/credentials/service_account.rs b/src/auth/src/credentials/service_account.rs index e108963917..fb1f8f75dc 100644 --- a/src/auth/src/credentials/service_account.rs +++ b/src/auth/src/credentials/service_account.rs @@ -72,6 +72,7 @@ pub(crate) mod jws; +use crate::access_boundary::AccessBoundary; use crate::build_errors::Error as BuilderError; use crate::constants::DEFAULT_SCOPE; use crate::credentials::dynamic::{AccessTokenCredentialsProvider, CredentialsProvider}; @@ -328,10 +329,21 @@ impl Builder { /// /// [service account keys]: https://cloud.google.com/iam/docs/keys-create-delete#creating pub fn build_access_token_credentials(self) -> BuildResult { + let quota_project_id = self.quota_project_id.clone(); + let token_provider = self.build_token_provider()?; + let client_email = token_provider.service_account_key.client_email.clone(); + + let token_provider = TokenCache::new(token_provider); + let access_boundary_url = crate::access_boundary::service_account_lookup_url(&client_email); + let access_boundary = Arc::new(AccessBoundary::new( + token_provider.clone(), + access_boundary_url, + )); Ok(AccessTokenCredentials { inner: Arc::new(ServiceAccountCredentials { - quota_project_id: self.quota_project_id.clone(), - token_provider: TokenCache::new(self.build_token_provider()?), + quota_project_id, + token_provider, + access_boundary, }), }) } @@ -462,6 +474,7 @@ where { token_provider: T, quota_project_id: Option, + access_boundary: Arc, } #[derive(Debug)] @@ -573,9 +586,11 @@ where { async fn headers(&self, extensions: Extensions) -> Result> { let token = self.token_provider.token(extensions).await?; + let access_boundary = self.access_boundary.header_value(); AuthHeadersBuilder::new(&token) .maybe_quota_project_id(self.quota_project_id.as_deref()) + .maybe_access_boundary(access_boundary.as_deref()) .build() } } @@ -655,9 +670,11 @@ mod tests { let mut mock = MockTokenProvider::new(); mock.expect_token().times(1).return_once(|| Ok(token)); + let cache = TokenCache::new(mock); let sac = ServiceAccountCredentials { - token_provider: TokenCache::new(mock), + token_provider: cache.clone(), quota_project_id: None, + trust_boundary: Arc::new(TrustBoundary::new(cache, "http://localhost".to_string())), }; let mut extensions = Extensions::new(); @@ -697,9 +714,11 @@ mod tests { let mut mock = MockTokenProvider::new(); mock.expect_token().times(1).return_once(|| Ok(token)); + let cache = TokenCache::new(mock); let sac = ServiceAccountCredentials { - token_provider: TokenCache::new(mock), + token_provider: cache.clone(), quota_project_id: Some(quota_project.to_string()), + trust_boundary: Arc::new(TrustBoundary::new(cache, "http://localhost".to_string())), }; let headers = get_headers_from_cache(sac.headers(Extensions::new()).await.unwrap())?; @@ -724,9 +743,11 @@ mod tests { .times(1) .return_once(|| Err(errors::non_retryable_from_str("fail"))); + let cache = TokenCache::new(mock); let sac = ServiceAccountCredentials { - token_provider: TokenCache::new(mock), + token_provider: cache.clone(), quota_project_id: None, + trust_boundary: Arc::new(TrustBoundary::new(cache, "http://localhost".to_string())), }; let result = sac.headers(Extensions::new()).await; assert!(result.is_err(), "{result:?}"); diff --git a/src/auth/src/lib.rs b/src/auth/src/lib.rs index c68433f4cf..60e2b7bc69 100644 --- a/src/auth/src/lib.rs +++ b/src/auth/src/lib.rs @@ -52,6 +52,7 @@ //! [Tokens]: https://cloud.google.com/docs/authentication#token //! [Credentials]: https://cloud.google.com/docs/authentication#credentials +pub(crate) mod access_boundary; pub mod build_errors; pub(crate) mod constants; pub mod credentials;