Skip to main content

secrets_engine_gcp/
lib.rs

1//! Google Cloud credentials — service-account impersonation, downscoped
2//! Cloud Storage tokens, and HMAC keys.
3//!
4//! GCP can mint short-lived credentials and, uniquely among the clouds here,
5//! can narrow one to a single bucket and prefix at mint time via a Credential
6//! Access Boundary. What it cannot do is revoke one: an issued access token is
7//! valid until `expireTime` no matter what happens in IAM. So the headline
8//! shape is B — scope and TTL are the whole containment story — with HMAC keys
9//! as the one genuinely revocable option.
10//!
11//! See `docs/delegation/gcp-storage.md` for the mechanism.
12
13use 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
35/// Google caps a self-signed JWT assertion at one hour.
36const 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/// How the server proves its own identity to Google before it can impersonate
42/// anything.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
44#[serde(rename_all = "snake_case")]
45pub enum GcpAuth {
46    /// Ambient identity from the GCE/GKE metadata server. Stores no key, which
47    /// is why it is the default.
48    #[default]
49    Metadata,
50    /// A service-account key file. Google's own guidance calls this the last
51    /// resort, because the private key inside never expires.
52    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    /// The full contents of a service-account key JSON file. Required only when
61    /// `auth` is `service_account_key`; never read back out.
62    #[serde(default)]
63    pub service_account_key_json: Option<String>,
64}
65
66/// Which mechanism a role mints. The three differ in what they can be narrowed
67/// to and — more importantly — whether they can be revoked.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
69#[serde(rename_all = "snake_case")]
70pub enum CredentialType {
71    /// A plain impersonated token. Inherits *every* permission of the target
72    /// service account, so prefer `downscoped`.
73    Impersonated,
74    /// Impersonation followed by a Credential Access Boundary exchange,
75    /// narrowing to one bucket and optionally one prefix.
76    #[default]
77    Downscoped,
78    /// An S3-interoperability HMAC key. The only revocable credential here.
79    Hmac,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct RoleConfig {
84    /// Which `gcp/config/{name}` document to authenticate with.
85    pub target: String,
86    #[serde(default)]
87    pub credential_type: CredentialType,
88    /// The service account to impersonate, or to own the HMAC key.
89    pub service_account: String,
90    #[serde(default = "default_scopes")]
91    pub scopes: Vec<String>,
92    /// Required for `downscoped` — a boundary must name a resource.
93    #[serde(default)]
94    pub bucket: Option<String>,
95    /// Optional object-name prefix within the bucket.
96    #[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    /// `generateAccessToken` wants a duration string, not a number.
174    fn lifetime_string(seconds: i64) -> String {
175        format!("{seconds}s")
176    }
177
178    /// The Credential Access Boundary that narrows an impersonated token to one
179    /// bucket, and optionally one object prefix.
180    ///
181    /// The prefix condition is deliberately two clauses OR'd together: a bare
182    /// `resource.name.startsWith(...)` permits reading objects but silently
183    /// breaks `list`, because a list request carries no object name to test.
184    /// The `objectListPrefix` attribute is what makes listing work.
185    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    /// Where a credential type's guarantees differ from the engine's headline
213    /// shape. Only HMAC keys can actually be destroyed, so only they get to
214    /// claim it.
215    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    /// The server's own access token — the thing it needs before it can
262    /// impersonate anything else.
263    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        // Service-account emails are made of query-safe characters only, so
396        // interpolating beats pulling in a query-string encoder.
397        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                // A downscoped token cannot outlive the token it was derived
575                // from, so the lease takes whichever bound is tighter.
576                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                    // An HMAC key has no expiry of its own, so the lease is the
602                    // only clock — a missed revocation leaves it working.
603                    now + Duration::seconds(role.default_ttl_seconds),
604                )
605            }
606        };
607
608        let lease = Lease {
609            id: Uuid::new_v4(),
610            // Set by the HTTP handler, which knows the requesting token.
611            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            // Nothing to call. Google offers no way to invalidate an issued
630            // access token, so the credential outlives this lease record and
631            // saying otherwise would be a lie.
632            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        // GCS refuses to delete an ACTIVE key, so deactivation is not optional.
656        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        // An already-deleted key is not an error: the reaper must be able to
680        // retry without tripping over its own success.
681        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    /// The OR'd `objectListPrefix` clause is the whole point: without it the
733    /// credential can read objects but not list them.
734    #[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    /// Only HMAC keys may claim revocability. If a token type ever started
762    /// claiming it, the `_doc` a consumer receives would be a lie.
763    #[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    /// Storing no key is the preferred posture, so it must also be the default.
804    #[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}