1use async_trait::async_trait;
14use chrono::{DateTime, Duration, Utc};
15use secrets_core::engine::{
16 CredentialShape, EngineDoc, EngineError, EngineResult, GeneratedCredential, PathDoc,
17 SecretsEngine, TtlDoc,
18};
19use secrets_core::lease::Lease;
20use secrets_core::mount::ConfigRoleStore;
21use secrets_core::storage::StorageBackend;
22use serde::{Deserialize, Serialize};
23use serde_json::json;
24use uuid::Uuid;
25
26const STORE: ConfigRoleStore = ConfigRoleStore::new("gcp/config/", "gcp/roles/");
27const MOUNT: &str = "gcp/creds/";
28
29const METADATA_TOKEN_URL: &str = "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token";
30const OAUTH_TOKEN_URL: &str = "https://oauth2.googleapis.com/token";
31const IAM_CREDENTIALS_URL: &str = "https://iamcredentials.googleapis.com/v1";
32const STS_URL: &str = "https://sts.googleapis.com/v1/token";
33const STORAGE_API_URL: &str = "https://storage.googleapis.com/storage/v1";
34
35const ASSERTION_TTL_SECONDS: i64 = 3600;
37const DEFAULT_TTL_SECONDS: i64 = 900;
38const DEFAULT_IAM_ROLE: &str = "roles/storage.objectViewer";
39const DEFAULT_SCOPE: &str = "https://www.googleapis.com/auth/devstorage.read_only";
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
44#[serde(rename_all = "snake_case")]
45pub enum GcpAuth {
46 #[default]
49 Metadata,
50 ServiceAccountKey,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct GcpConfig {
57 pub project_id: String,
58 #[serde(default)]
59 pub auth: GcpAuth,
60 #[serde(default)]
63 pub service_account_key_json: Option<String>,
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
69#[serde(rename_all = "snake_case")]
70pub enum CredentialType {
71 Impersonated,
74 #[default]
77 Downscoped,
78 Hmac,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct RoleConfig {
84 pub target: String,
86 #[serde(default)]
87 pub credential_type: CredentialType,
88 pub service_account: String,
90 #[serde(default = "default_scopes")]
91 pub scopes: Vec<String>,
92 #[serde(default)]
94 pub bucket: Option<String>,
95 #[serde(default)]
97 pub prefix: Option<String>,
98 #[serde(default = "default_iam_role")]
99 pub iam_role: String,
100 #[serde(default = "default_ttl_seconds")]
101 pub default_ttl_seconds: i64,
102}
103
104fn default_scopes() -> Vec<String> {
105 vec![DEFAULT_SCOPE.to_string()]
106}
107
108fn default_iam_role() -> String {
109 DEFAULT_IAM_ROLE.to_string()
110}
111
112fn default_ttl_seconds() -> i64 {
113 DEFAULT_TTL_SECONDS
114}
115
116#[derive(Debug, Deserialize)]
117struct ServiceAccountKey {
118 client_email: String,
119 private_key: String,
120}
121
122#[derive(Debug, Serialize)]
123struct AssertionClaims {
124 iss: String,
125 scope: String,
126 aud: String,
127 iat: i64,
128 exp: i64,
129}
130
131#[derive(Debug, Deserialize)]
132struct OauthTokenResponse {
133 access_token: String,
134}
135
136#[derive(Debug, Deserialize)]
137#[serde(rename_all = "camelCase")]
138struct GenerateAccessTokenResponse {
139 access_token: String,
140 expire_time: DateTime<Utc>,
141}
142
143#[derive(Debug, Deserialize)]
144struct StsTokenResponse {
145 access_token: String,
146 expires_in: i64,
147}
148
149#[derive(Debug, Deserialize)]
150#[serde(rename_all = "camelCase")]
151struct HmacKeyMetadata {
152 access_id: String,
153}
154
155#[derive(Debug, Deserialize)]
156struct HmacKeyResponse {
157 metadata: HmacKeyMetadata,
158 secret: String,
159}
160
161#[derive(Default)]
162pub struct GcpEngine {
163 http: reqwest::Client,
164}
165
166impl GcpEngine {
167 pub fn new() -> Self {
168 Self {
169 http: reqwest::Client::new(),
170 }
171 }
172
173 fn lifetime_string(seconds: i64) -> String {
175 format!("{seconds}s")
176 }
177
178 fn access_boundary(role: &RoleConfig) -> EngineResult<serde_json::Value> {
186 let bucket = role.bucket.as_deref().ok_or_else(|| {
187 EngineError::InvalidRequest(
188 "credential_type 'downscoped' requires a bucket — a Credential Access \
189 Boundary must name the resource it narrows to"
190 .into(),
191 )
192 })?;
193
194 let mut rule = json!({
195 "availableResource": format!("//storage.googleapis.com/projects/_/buckets/{bucket}"),
196 "availablePermissions": [format!("inRole:{}", role.iam_role)],
197 });
198
199 if let Some(prefix) = role.prefix.as_deref() {
200 let objects = format!("projects/_/buckets/{bucket}/objects/{prefix}");
201 rule["availabilityCondition"] = json!({
202 "expression": format!(
203 "resource.name.startsWith('{objects}') || \
204 api.getAttribute('storage.googleapis.com/objectListPrefix', '').startsWith('{prefix}')"
205 ),
206 });
207 }
208
209 Ok(json!({ "accessBoundary": { "accessBoundaryRules": [rule] } }))
210 }
211
212 fn revocation_override(
216 credential_type: CredentialType,
217 ) -> Option<(CredentialShape, &'static str)> {
218 match credential_type {
219 CredentialType::Hmac => Some((
220 CredentialShape::MintAndRevoke,
221 "deactivates the HMAC key (GCS requires INACTIVE before deletion) and \
222 then deletes it — the credential stops working immediately.",
223 )),
224 CredentialType::Impersonated | CredentialType::Downscoped => None,
225 }
226 }
227
228 fn scope_description(role: &RoleConfig) -> Vec<String> {
229 let mut scoped = vec![format!("service_account:{}", role.service_account)];
230 match role.credential_type {
231 CredentialType::Impersonated => {
232 scoped.extend(role.scopes.iter().map(|s| format!("oauth_scope:{s}")));
233 scoped.push(
234 "resources:ALL (a plain impersonated token carries every permission \
235 of the target service account — use downscoped to narrow it)"
236 .to_string(),
237 );
238 }
239 CredentialType::Downscoped => {
240 if let Some(bucket) = &role.bucket {
241 scoped.push(format!("bucket:{bucket}"));
242 }
243 match &role.prefix {
244 Some(prefix) => scoped.push(format!("prefix:{prefix}")),
245 None => scoped.push("prefix:ALL (the whole bucket)".to_string()),
246 }
247 scoped.push(format!("in_role:{}", role.iam_role));
248 }
249 CredentialType::Hmac => {
250 scoped.push("api:s3-interoperability (XML API only)".to_string());
251 scoped.push(
252 "resources:ALL (HMAC keys inherit the service account's permissions \
253 and cannot be narrowed to a bucket)"
254 .to_string(),
255 );
256 }
257 }
258 scoped
259 }
260
261 async fn caller_token(&self, config: &GcpConfig) -> EngineResult<String> {
264 match config.auth {
265 GcpAuth::Metadata => {
266 let response = self
267 .http
268 .get(METADATA_TOKEN_URL)
269 .header("Metadata-Flavor", "Google")
270 .send()
271 .await
272 .map_err(|e| {
273 EngineError::Provider(format!(
274 "metadata server unreachable — is this server running on GCP? {e}"
275 ))
276 })?;
277 Ok(Self::parse::<OauthTokenResponse>(response, "metadata server")
278 .await?
279 .access_token)
280 }
281 GcpAuth::ServiceAccountKey => {
282 let raw = config.service_account_key_json.as_deref().ok_or_else(|| {
283 EngineError::InvalidRequest(
284 "auth is 'service_account_key' but service_account_key_json is absent"
285 .into(),
286 )
287 })?;
288 let key: ServiceAccountKey = serde_json::from_str(raw).map_err(|e| {
289 EngineError::InvalidRequest(format!(
290 "service_account_key_json is not a service-account key file: {e}"
291 ))
292 })?;
293 self.jwt_bearer_token(&key).await
294 }
295 }
296 }
297
298 async fn jwt_bearer_token(&self, key: &ServiceAccountKey) -> EngineResult<String> {
299 let now = Utc::now().timestamp();
300 let claims = AssertionClaims {
301 iss: key.client_email.clone(),
302 scope: "https://www.googleapis.com/auth/cloud-platform".to_string(),
303 aud: OAUTH_TOKEN_URL.to_string(),
304 iat: now,
305 exp: now + ASSERTION_TTL_SECONDS,
306 };
307 let encoding_key =
308 jsonwebtoken::EncodingKey::from_rsa_pem(key.private_key.as_bytes()).map_err(|e| {
309 EngineError::InvalidRequest(format!(
310 "the private_key in service_account_key_json is not a valid RSA PEM: {e}"
311 ))
312 })?;
313 let assertion = jsonwebtoken::encode(
314 &jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256),
315 &claims,
316 &encoding_key,
317 )
318 .map_err(|e| EngineError::Other(format!("failed to sign the JWT assertion: {e}")))?;
319
320 let response = self
321 .http
322 .post(OAUTH_TOKEN_URL)
323 .form(&[
324 ("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"),
325 ("assertion", &assertion),
326 ])
327 .send()
328 .await
329 .map_err(|e| EngineError::Provider(format!("Google token exchange failed: {e}")))?;
330 Ok(Self::parse::<OauthTokenResponse>(response, OAUTH_TOKEN_URL)
331 .await?
332 .access_token)
333 }
334
335 async fn impersonate(
336 &self,
337 caller_token: &str,
338 role: &RoleConfig,
339 ) -> EngineResult<GenerateAccessTokenResponse> {
340 let url = format!(
341 "{IAM_CREDENTIALS_URL}/projects/-/serviceAccounts/{}:generateAccessToken",
342 role.service_account
343 );
344 let response = self
345 .http
346 .post(&url)
347 .bearer_auth(caller_token)
348 .json(&json!({
349 "scope": role.scopes,
350 "lifetime": Self::lifetime_string(role.default_ttl_seconds),
351 }))
352 .send()
353 .await
354 .map_err(|e| EngineError::Provider(format!("impersonation request failed: {e}")))?;
355 Self::parse(response, &url).await
356 }
357
358 async fn downscope(
359 &self,
360 access_token: &str,
361 role: &RoleConfig,
362 ) -> EngineResult<StsTokenResponse> {
363 let boundary = Self::access_boundary(role)?;
364 let options = serde_json::to_string(&boundary)
365 .map_err(|e| EngineError::Other(format!("failed to encode access boundary: {e}")))?;
366
367 let response = self
368 .http
369 .post(STS_URL)
370 .form(&[
371 ("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange"),
372 (
373 "subject_token_type",
374 "urn:ietf:params:oauth:token-type:access_token",
375 ),
376 (
377 "requested_token_type",
378 "urn:ietf:params:oauth:token-type:access_token",
379 ),
380 ("subject_token", access_token),
381 ("options", &options),
382 ])
383 .send()
384 .await
385 .map_err(|e| EngineError::Provider(format!("STS downscope failed: {e}")))?;
386 Self::parse(response, STS_URL).await
387 }
388
389 async fn create_hmac_key(
390 &self,
391 caller_token: &str,
392 config: &GcpConfig,
393 role: &RoleConfig,
394 ) -> EngineResult<HmacKeyResponse> {
395 let url = format!(
398 "{STORAGE_API_URL}/projects/{}/hmacKeys?serviceAccountEmail={}",
399 config.project_id, role.service_account
400 );
401 let response = self
402 .http
403 .post(&url)
404 .bearer_auth(caller_token)
405 .send()
406 .await
407 .map_err(|e| EngineError::Provider(format!("HMAC key creation failed: {e}")))?;
408 Self::parse(response, &url).await
409 }
410
411 async fn parse<T: serde::de::DeserializeOwned>(
412 response: reqwest::Response,
413 what: &str,
414 ) -> EngineResult<T> {
415 let status = response.status();
416 let body = response.text().await.unwrap_or_default();
417 if !status.is_success() {
418 return Err(EngineError::Provider(format!(
419 "Google returned {status} for {what}: {body}"
420 )));
421 }
422 serde_json::from_str(&body)
423 .map_err(|e| EngineError::Provider(format!("unexpected response from {what}: {e}")))
424 }
425}
426
427#[async_trait]
428impl SecretsEngine for GcpEngine {
429 fn doc(&self) -> EngineDoc {
430 EngineDoc {
431 provider: "Google Cloud".to_string(),
432 mechanism: "service-account impersonation via the IAM Credentials API, \
433 optionally narrowed to one bucket and prefix by a Credential \
434 Access Boundary, plus revocable Cloud Storage HMAC keys"
435 .to_string(),
436 shape: CredentialShape::MintExpiryOnly,
437 revocable: false,
438 revoke_effect: "nothing at Google. An issued service-account access token — \
439 downscoped or not — cannot be revoked: there is no endpoint \
440 for it and nothing tracks outstanding tokens. Revoking the \
441 lease deletes our record and stops renewal, and the \
442 credential keeps working until its expireTime. The only \
443 real levers are disabling the service account (which kills \
444 every consumer's tokens) or waiting out the TTL. Roles with \
445 credential_type 'hmac' are the exception and are genuinely \
446 revocable."
447 .to_string(),
448 ttl: TtlDoc::range(
449 60,
450 3600,
451 "default 900s. One hour is the maximum unless the \
452 constraints/iam.allowServiceAccountCredentialLifetimeExtension org \
453 policy lists the service account, which raises it to 12 hours — the \
454 wrong direction for a credential nobody can revoke. HMAC keys ignore \
455 this entirely: they never expire and live until the reaper deletes them.",
456 ),
457 scoping: "credential_type 'downscoped' narrows to a single bucket, an \
458 optional object prefix and one IAM role — the only per-bucket \
459 narrowing any cloud provider here offers, and the reason to \
460 prefer it. 'impersonated' does NOT narrow: it carries every \
461 permission of the target service account. 'hmac' cannot be \
462 narrowed at all."
463 .to_string(),
464 root_credential: "none, preferably: with auth 'metadata' the server uses its \
465 own attached service account and stores no key, needing \
466 only roles/iam.serviceAccountTokenCreator on each target \
467 service account. With auth 'service_account_key' it holds \
468 a key file whose private key never expires — Google's own \
469 guidance calls that the last resort."
470 .to_string(),
471 paths: vec![
472 PathDoc::new(
473 "gcp/config/{target}",
474 &["POST", "GET", "DELETE"],
475 "sudo",
476 "register the project and how the server authenticates. GET reports \
477 only whether it is configured — any key is never returned.",
478 ),
479 PathDoc::new(
480 "gcp/roles/{role}",
481 &["POST", "GET", "DELETE"],
482 "create / read / sudo",
483 "define one consumer's credential type, service account, bucket, \
484 prefix and TTL",
485 ),
486 PathDoc::new(
487 "gcp/creds/{role}",
488 &["GET"],
489 "read",
490 "mint a token (or HMAC key) and open a lease",
491 ),
492 PathDoc::new("gcp/help", &["GET"], "authenticated", "this document"),
493 ],
494 docs_url: Some("docs/delegation/gcp-storage.md".to_string()),
495 caveats: vec![
496 "Access tokens and downscoped tokens cannot be revoked, so the TTL is \
497 the entire containment story. Keep it short — 15 minutes is a sane \
498 default, and the one-hour maximum should be the exception."
499 .to_string(),
500 "Credential Access Boundaries work for Cloud Storage ONLY. No other \
501 GCP service supports downscoping, so this approach does not \
502 generalise to the rest of GCP."
503 .to_string(),
504 "A boundary can only subtract permissions. It never grants anything the \
505 target service account lacks."
506 .to_string(),
507 "The bucket must have uniform bucket-level access enabled for boundary \
508 conditions to behave."
509 .to_string(),
510 "A prefix condition needs two clauses: a bare resource.name.startsWith \
511 permits object reads but breaks list, because a list request carries no \
512 object name. This engine OR's in an objectListPrefix condition so \
513 listing works."
514 .to_string(),
515 "HMAC keys never expire on their own, are limited to 10 per service \
516 account, are scoped to the whole service account with no bucket \
517 narrowing, and work only against the S3-compatible XML API."
518 .to_string(),
519 "Signing permission for a signed URL is checked at mint time but the \
520 storage permission only when the URL is used, so a URL can be minted \
521 successfully and still 403."
522 .to_string(),
523 ],
524 }
525 }
526
527 async fn read(&self, storage: &dyn StorageBackend, path: &str) -> EngineResult<serde_json::Value> {
528 STORE.handle_read::<RoleConfig>(storage, path).await
529 }
530
531 async fn write(
532 &self,
533 storage: &dyn StorageBackend,
534 path: &str,
535 data: serde_json::Value,
536 ) -> EngineResult<()> {
537 STORE.handle_write::<GcpConfig, RoleConfig>(storage, path, data).await
538 }
539
540 async fn delete(&self, storage: &dyn StorageBackend, path: &str) -> EngineResult<()> {
541 STORE.handle_delete(storage, path).await
542 }
543
544 async fn list(&self, storage: &dyn StorageBackend, prefix: &str) -> EngineResult<Vec<String>> {
545 STORE.handle_list(storage, prefix).await
546 }
547
548 async fn generate(
549 &self,
550 storage: &dyn StorageBackend,
551 role_name: &str,
552 ) -> EngineResult<GeneratedCredential> {
553 let role: RoleConfig = STORE.require_role(storage, role_name).await?;
554 let config: GcpConfig = STORE.require_config(storage, &role.target).await?;
555 let caller_token = self.caller_token(&config).await?;
556 let now = Utc::now();
557
558 let (data, internal_data, expires_at) = match role.credential_type {
559 CredentialType::Impersonated => {
560 let token = self.impersonate(&caller_token, &role).await?;
561 (
562 json!({
563 "access_token": token.access_token,
564 "token_type": "Bearer",
565 "expires_at": token.expire_time,
566 }),
567 json!({ "credential_type": "impersonated", "target": role.target }),
568 token.expire_time,
569 )
570 }
571 CredentialType::Downscoped => {
572 let token = self.impersonate(&caller_token, &role).await?;
573 let downscoped = self.downscope(&token.access_token, &role).await?;
574 let sts_expiry = now + Duration::seconds(downscoped.expires_in);
577 (
578 json!({
579 "access_token": downscoped.access_token,
580 "token_type": "Bearer",
581 "expires_at": sts_expiry.min(token.expire_time),
582 }),
583 json!({ "credential_type": "downscoped", "target": role.target }),
584 sts_expiry.min(token.expire_time),
585 )
586 }
587 CredentialType::Hmac => {
588 let key = self.create_hmac_key(&caller_token, &config, &role).await?;
589 (
590 json!({
591 "access_id": key.metadata.access_id,
592 "secret": key.secret,
593 "endpoint": "https://storage.googleapis.com",
594 }),
595 json!({
596 "credential_type": "hmac",
597 "target": role.target,
598 "project_id": config.project_id,
599 "access_id": key.metadata.access_id,
600 }),
601 now + Duration::seconds(role.default_ttl_seconds),
604 )
605 }
606 };
607
608 let lease = Lease {
609 id: Uuid::new_v4(),
610 token_id_hash: String::new(),
612 engine_mount: MOUNT.to_string(),
613 internal_data,
614 issued_at: now,
615 expires_at,
616 };
617
618 let credential =
619 GeneratedCredential::new(data, lease, Self::scope_description(&role));
620 Ok(match Self::revocation_override(role.credential_type) {
621 Some((shape, effect)) => credential.with_shape(shape, effect),
622 None => credential,
623 })
624 }
625
626 async fn revoke(&self, storage: &dyn StorageBackend, lease: &Lease) -> EngineResult<()> {
627 let credential_type = lease.internal_data["credential_type"].as_str().unwrap_or("");
628 if credential_type != "hmac" {
629 tracing::warn!(
633 lease_id = %lease.id,
634 credential_type,
635 "gcp: lease record removed, but the access token keeps working until \
636 its expireTime — Google has no token revocation endpoint"
637 );
638 return Ok(());
639 }
640
641 let target = lease.internal_data["target"]
642 .as_str()
643 .ok_or_else(|| EngineError::Other("lease missing 'target'".into()))?;
644 let access_id = lease.internal_data["access_id"]
645 .as_str()
646 .ok_or_else(|| EngineError::Other("lease missing 'access_id'".into()))?;
647 let project_id = lease.internal_data["project_id"]
648 .as_str()
649 .ok_or_else(|| EngineError::Other("lease missing 'project_id'".into()))?;
650
651 let config: GcpConfig = STORE.require_config(storage, target).await?;
652 let caller_token = self.caller_token(&config).await?;
653 let url = format!("{STORAGE_API_URL}/projects/{project_id}/hmacKeys/{access_id}");
654
655 let deactivated = self
657 .http
658 .put(&url)
659 .bearer_auth(&caller_token)
660 .json(&json!({ "state": "INACTIVE" }))
661 .send()
662 .await
663 .map_err(|e| EngineError::Provider(format!("HMAC deactivation failed: {e}")))?;
664 if !deactivated.status().is_success() && deactivated.status() != reqwest::StatusCode::NOT_FOUND
665 {
666 return Err(EngineError::Provider(format!(
667 "Google returned {} when deactivating HMAC key {access_id}",
668 deactivated.status()
669 )));
670 }
671
672 let deleted = self
673 .http
674 .delete(&url)
675 .bearer_auth(&caller_token)
676 .send()
677 .await
678 .map_err(|e| EngineError::Provider(format!("HMAC deletion failed: {e}")))?;
679 if deleted.status().is_success() || deleted.status() == reqwest::StatusCode::NOT_FOUND {
682 Ok(())
683 } else {
684 Err(EngineError::Provider(format!(
685 "Google returned {} when deleting HMAC key {access_id}",
686 deleted.status()
687 )))
688 }
689 }
690}
691
692#[cfg(test)]
693mod tests {
694 use super::*;
695
696 fn role(credential_type: CredentialType, bucket: Option<&str>, prefix: Option<&str>) -> RoleConfig {
697 RoleConfig {
698 target: "production".to_string(),
699 credential_type,
700 service_account: "reports@acme.iam.gserviceaccount.com".to_string(),
701 scopes: default_scopes(),
702 bucket: bucket.map(|b| b.to_string()),
703 prefix: prefix.map(|p| p.to_string()),
704 iam_role: default_iam_role(),
705 default_ttl_seconds: DEFAULT_TTL_SECONDS,
706 }
707 }
708
709 #[test]
710 fn lifetime_is_formatted_as_a_duration_string() {
711 assert_eq!(GcpEngine::lifetime_string(900), "900s");
712 assert_eq!(GcpEngine::lifetime_string(3600), "3600s");
713 }
714
715 #[test]
716 fn access_boundary_without_a_prefix_covers_the_whole_bucket() {
717 let boundary =
718 GcpEngine::access_boundary(&role(CredentialType::Downscoped, Some("reports"), None))
719 .unwrap();
720 let rule = &boundary["accessBoundary"]["accessBoundaryRules"][0];
721 assert_eq!(
722 rule["availableResource"],
723 "//storage.googleapis.com/projects/_/buckets/reports"
724 );
725 assert_eq!(rule["availablePermissions"][0], "inRole:roles/storage.objectViewer");
726 assert!(
727 rule.get("availabilityCondition").is_none(),
728 "an unprefixed boundary should carry no condition"
729 );
730 }
731
732 #[test]
735 fn access_boundary_with_a_prefix_also_permits_listing() {
736 let boundary = GcpEngine::access_boundary(&role(
737 CredentialType::Downscoped,
738 Some("reports"),
739 Some("report-service/"),
740 ))
741 .unwrap();
742 let expression = boundary["accessBoundary"]["accessBoundaryRules"][0]
743 ["availabilityCondition"]["expression"]
744 .as_str()
745 .unwrap();
746 assert!(
747 expression.contains("resource.name.startsWith('projects/_/buckets/reports/objects/report-service/')"),
748 "{expression}"
749 );
750 assert!(expression.contains("objectListPrefix"), "{expression}");
751 assert!(expression.contains("||"), "{expression}");
752 }
753
754 #[test]
755 fn downscoping_without_a_bucket_is_rejected() {
756 let err = GcpEngine::access_boundary(&role(CredentialType::Downscoped, None, None))
757 .unwrap_err();
758 assert!(matches!(err, EngineError::InvalidRequest(_)), "got {err:?}");
759 }
760
761 #[test]
764 fn only_hmac_keys_claim_to_be_revocable() {
765 let (shape, effect) = GcpEngine::revocation_override(CredentialType::Hmac).unwrap();
766 assert_eq!(shape, CredentialShape::MintAndRevoke);
767 assert!(shape.revocable());
768 assert!(effect.contains("INACTIVE"));
769
770 assert!(GcpEngine::revocation_override(CredentialType::Impersonated).is_none());
771 assert!(GcpEngine::revocation_override(CredentialType::Downscoped).is_none());
772 }
773
774 #[test]
775 fn plain_impersonation_warns_that_it_is_unscoped() {
776 let scoped = GcpEngine::scope_description(&role(CredentialType::Impersonated, None, None));
777 assert!(
778 scoped.iter().any(|s| s.contains("resources:ALL")),
779 "{scoped:?}"
780 );
781 }
782
783 #[test]
784 fn downscoped_scope_names_the_bucket_and_prefix() {
785 let scoped = GcpEngine::scope_description(&role(
786 CredentialType::Downscoped,
787 Some("reports"),
788 Some("report-service/"),
789 ));
790 assert!(scoped.contains(&"bucket:reports".to_string()), "{scoped:?}");
791 assert!(scoped.contains(&"prefix:report-service/".to_string()), "{scoped:?}");
792 assert!(
793 scoped.contains(&"in_role:roles/storage.objectViewer".to_string()),
794 "{scoped:?}"
795 );
796 }
797
798 #[test]
799 fn downscoped_is_the_default_credential_type() {
800 assert_eq!(CredentialType::default(), CredentialType::Downscoped);
801 }
802
803 #[test]
805 fn metadata_auth_is_the_default() {
806 assert_eq!(GcpAuth::default(), GcpAuth::Metadata);
807 }
808
809 #[test]
810 fn doc_agrees_with_its_shape() {
811 let doc = GcpEngine::new().doc();
812 assert_eq!(doc.shape, CredentialShape::MintExpiryOnly);
813 assert_eq!(doc.revocable, doc.shape.revocable());
814 assert!(!doc.revocable, "GCP access tokens cannot be revoked");
815 assert!(doc.revoke_effect.contains("cannot be revoked"));
816 assert!(!doc.caveats.is_empty());
817 }
818}