Skip to main content

tryaudex_core/
gcp.rs

1use std::sync::Arc;
2use std::time::Duration;
3
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6
7use crate::error::{AvError, Result};
8
9/// Temporary GCP credentials (short-lived OAuth2 access token).
10#[derive(Clone, Serialize, Deserialize)]
11pub struct GcpTempCredentials {
12    #[serde(skip_serializing)]
13    pub access_token: String,
14    pub expires_at: DateTime<Utc>,
15}
16
17impl std::fmt::Debug for GcpTempCredentials {
18    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
19        f.debug_struct("GcpTempCredentials")
20            .field("access_token", &"[REDACTED]")
21            .field("expires_at", &self.expires_at)
22            .finish()
23    }
24}
25
26/// Issues short-lived GCP credentials via Service Account impersonation.
27/// Stores the auth provider so tokens are refreshed on each call,
28/// avoiding stale token errors in long-running processes (e.g., server mode).
29pub struct GcpCredentialIssuer {
30    auth_provider: Arc<dyn gcp_auth::TokenProvider>,
31    http: reqwest::Client,
32}
33
34#[derive(Deserialize)]
35struct GenerateAccessTokenResponse {
36    #[serde(rename = "accessToken")]
37    access_token: String,
38    #[serde(rename = "expireTime")]
39    expire_time: String,
40}
41
42impl GcpCredentialIssuer {
43    /// Create a new issuer using Application Default Credentials.
44    /// Stores the auth provider so tokens are refreshed per-call.
45    pub async fn new() -> Result<Self> {
46        let auth = gcp_auth::provider().await.map_err(|e| {
47            AvError::Gcp(format!(
48                "Failed to get GCP credentials. Run `gcloud auth application-default login` first. Error: {}",
49                e
50            ))
51        })?;
52
53        // Verify credentials work at init time
54        auth.token(&["https://www.googleapis.com/auth/cloud-platform"])
55            .await
56            .map_err(|e| AvError::Gcp(format!("Failed to get access token: {}", e)))?;
57
58        let http = reqwest::Client::builder()
59            .timeout(Duration::from_secs(30))
60            .connect_timeout(Duration::from_secs(10))
61            .build()
62            .map_err(|e| AvError::Gcp(format!("HTTP client error: {}", e)))?;
63
64        Ok(Self {
65            auth_provider: auth,
66            http,
67        })
68    }
69
70    /// Get a fresh source token for API calls.
71    async fn source_token(&self) -> Result<String> {
72        let token = self
73            .auth_provider
74            .token(&["https://www.googleapis.com/auth/cloud-platform"])
75            .await
76            .map_err(|e| AvError::Gcp(format!("Failed to refresh source token: {}", e)))?;
77        Ok(token.as_str().to_string())
78    }
79
80    /// Generate a short-lived access token by impersonating a service account.
81    ///
82    /// Calls the IAM Credentials API `generateAccessToken` endpoint.
83    /// The source credentials (from ADC) must have `iam.serviceAccounts.getAccessToken`
84    /// permission on the target service account.
85    /// Generate a short-lived access token. Set `extended_lifetime` to true
86    /// if the target project has the org policy
87    /// `constraints/iam.allowServiceAccountCredentialLifetimeExtension` enabled,
88    /// which raises the cap from 1h to 12h.
89    pub async fn issue(
90        &self,
91        service_account: &str,
92        ttl: Duration,
93        extended_lifetime: bool,
94    ) -> Result<GcpTempCredentials> {
95        // Validate service account format to prevent URL path injection.
96        // Reject double-@, control characters, and non-email formats.
97        let is_valid_sa = {
98            let parts: Vec<&str> = service_account.split('@').collect();
99            parts.len() == 2
100                && !parts[0].is_empty()
101                && parts[1].ends_with(".iam.gserviceaccount.com")
102                && service_account
103                    .bytes()
104                    .all(|b| b.is_ascii_alphanumeric() || b"@.-_".contains(&b))
105        };
106        if !is_valid_sa {
107            return Err(AvError::Gcp(format!(
108                "Invalid service account email '{}': expected format 'name@project.iam.gserviceaccount.com'",
109                service_account
110            )));
111        }
112
113        let max_ttl = if extended_lifetime { 43200 } else { 3600 };
114        let ttl_secs = ttl.as_secs().min(max_ttl);
115        if ttl.as_secs() > max_ttl {
116            tracing::warn!(
117                requested = ttl.as_secs(),
118                actual = ttl_secs,
119                max = max_ttl,
120                "GCP access token TTL clamped to maximum allowed"
121            );
122        }
123
124        let encoded_sa = urlencoding::encode(service_account);
125        let url = format!(
126            "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{}:generateAccessToken",
127            encoded_sa
128        );
129
130        let oauth_scopes = narrow_oauth_scopes(service_account);
131        let body = serde_json::json!({
132            "scope": oauth_scopes,
133            "lifetime": format!("{}s", ttl_secs),
134        });
135
136        let fresh_token = self.source_token().await?;
137
138        let client = &self.http;
139        let resp = client
140            .post(&url)
141            .bearer_auth(&fresh_token)
142            .json(&body)
143            .send()
144            .await
145            .map_err(|e| AvError::Gcp(format!("IAM Credentials API call failed: {}", e)))?;
146
147        if !resp.status().is_success() {
148            let status = resp.status();
149            let text = resp.text().await.unwrap_or_default();
150            let sanitized = sanitize_gcp_error(&text);
151            return Err(AvError::Gcp(format!(
152                "IAM Credentials API error ({}): {}",
153                status, sanitized
154            )));
155        }
156
157        let result: GenerateAccessTokenResponse = resp.json().await.map_err(|e| {
158            AvError::Gcp(format!("Failed to parse IAM Credentials response: {}", e))
159        })?;
160
161        let expires_at = DateTime::parse_from_rfc3339(&result.expire_time)
162            .map(|dt| dt.with_timezone(&Utc))
163            .map_err(|e| {
164                AvError::Gcp(format!(
165                    "Failed to parse token expiry '{}': {}",
166                    result.expire_time, e
167                ))
168            })?;
169
170        Ok(GcpTempCredentials {
171            access_token: result.access_token,
172            expires_at,
173        })
174    }
175
176    /// Downscope an access token using GCP Credential Access Boundaries (CAB).
177    ///
178    /// Exchanges the source token for a downscoped token via the STS endpoint.
179    /// CAB supports a subset of GCP services (primarily Cloud Storage). For
180    /// unsupported permissions, this returns an error and the caller should
181    /// fall back to advisory-only mode with a warning.
182    ///
183    /// Returns the downscoped credentials plus the list of requested actions
184    /// that could not be enforced via CAB (R6-M18). When the skipped list is
185    /// non-empty, callers MUST treat the token as having only partial scoping
186    /// — the returned CAB rules cover Cloud Storage only, and any non-Storage
187    /// action in the `--allow` list is effectively denied at runtime but the
188    /// operator's intent is not honoured by the token boundary.
189    ///
190    /// See: https://cloud.google.com/iam/docs/downscoping-short-lived-credentials
191    pub async fn downscope_token(
192        &self,
193        source_token: &str,
194        actions: &[String],
195        resources: &[&str],
196        source_expires_at: Option<DateTime<Utc>>,
197    ) -> Result<(GcpTempCredentials, Vec<String>)> {
198        let (rules, skipped_actions) = build_cab_rules(actions, resources)?;
199        if rules.is_empty() {
200            return Err(AvError::Gcp(
201                "None of the requested actions can be enforced via Credential Access Boundaries. \
202                 CAB only supports Cloud Storage permissions."
203                    .to_string(),
204            ));
205        }
206
207        let boundary = serde_json::json!({
208            "accessBoundary": {
209                "accessBoundaryRules": rules
210            }
211        });
212
213        let resp = self
214            .http
215            .post("https://sts.googleapis.com/v1/token")
216            .form(&[
217                (
218                    "grant_type",
219                    "urn:ietf:params:oauth:grant-type:token-exchange",
220                ),
221                (
222                    "subject_token_type",
223                    "urn:ietf:params:oauth:token-type:access_token",
224                ),
225                (
226                    "requested_token_type",
227                    "urn:ietf:params:oauth:token-type:access_token",
228                ),
229                ("subject_token", source_token),
230                ("options", &boundary.to_string()),
231            ])
232            .send()
233            .await
234            .map_err(|e| AvError::Gcp(format!("STS token exchange failed: {}", e)))?;
235
236        if !resp.status().is_success() {
237            let status = resp.status();
238            let text = resp.text().await.unwrap_or_default();
239            let sanitized = sanitize_gcp_error(&text);
240            return Err(AvError::Gcp(format!(
241                "STS downscope error ({}): {}",
242                status, sanitized
243            )));
244        }
245
246        #[derive(Deserialize)]
247        struct StsResponse {
248            access_token: String,
249            expires_in: Option<u64>,
250        }
251
252        let sts: StsResponse = resp
253            .json()
254            .await
255            .map_err(|e| AvError::Gcp(format!("Failed to parse STS response: {}", e)))?;
256
257        let expires_at = match sts.expires_in {
258            Some(secs) => Utc::now() + chrono::Duration::seconds(secs as i64),
259            None => {
260                // STS omitted expires_in. Use the source token's remaining
261                // lifetime as the fallback to avoid setting an expiry that
262                // exceeds what the source token actually allows.
263                source_expires_at.unwrap_or_else(|| {
264                    tracing::warn!(
265                        "STS response missing expires_in and no source token expiry available; \
266                         defaulting to 3600s"
267                    );
268                    Utc::now() + chrono::Duration::seconds(3600)
269                })
270            }
271        };
272
273        Ok((
274            GcpTempCredentials {
275                access_token: sts.access_token,
276                expires_at,
277            },
278            skipped_actions,
279        ))
280    }
281}
282
283/// Revoke a GCP OAuth2 access token.
284///
285/// Calls the Google OAuth2 revocation endpoint. Returns `Ok(true)` if
286/// revocation was confirmed (HTTP 200), `Ok(false)` if the endpoint
287/// returned a non-success status (best-effort; token may still expire
288/// naturally), or `Err` if the request could not be sent at all.
289///
290/// R6-M23: uses a process-lifetime reqwest::Client with an explicit
291/// timeout. The previous `reqwest::Client::new()` had no timeout at all,
292/// so a slow or hostile revocation endpoint could hang session-end
293/// indefinitely while the main thread waited for the revocation result.
294pub async fn revoke_token(token: &str) -> Result<bool> {
295    use std::sync::LazyLock;
296    static REVOKE_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
297        reqwest::Client::builder()
298            .timeout(Duration::from_secs(10))
299            .connect_timeout(Duration::from_secs(5))
300            .build()
301            .unwrap_or_else(|_| reqwest::Client::new())
302    });
303    let client = &*REVOKE_CLIENT;
304    match client
305        .post("https://oauth2.googleapis.com/revoke")
306        .form(&[("token", token)])
307        .send()
308        .await
309    {
310        Ok(resp) if resp.status().is_success() => {
311            tracing::info!("GCP access token revoked successfully");
312            Ok(true)
313        }
314        Ok(resp) => {
315            tracing::warn!(
316                status = %resp.status(),
317                "GCP token revocation returned non-success status; token may not be revoked"
318            );
319            Ok(false)
320        }
321        Err(e) => Err(AvError::Gcp(format!(
322            "GCP token revocation request failed: {e}"
323        ))),
324    }
325}
326
327/// Map GCP IAM permissions to Credential Access Boundary rules.
328///
329/// CAB supports Cloud Storage permissions via predefined roles. Returns the
330/// boundary rules plus the list of requested actions that CAB cannot enforce
331/// (R6-M18). An empty rules vec means no storage actions were requested —
332/// the caller should error out. A non-empty rules vec with non-empty skipped
333/// list means partial enforcement: storage actions are bound by CAB but the
334/// listed non-storage actions are advisory-only and callers MUST reflect this
335/// in audit logs and session metadata so operators are not misled.
336fn build_cab_rules(
337    actions: &[String],
338    resources: &[&str],
339) -> Result<(Vec<serde_json::Value>, Vec<String>)> {
340    let mut rules = Vec::new();
341
342    let storage_actions: Vec<&str> = actions
343        .iter()
344        .filter(|a| a.starts_with("storage."))
345        .map(|a| a.as_str())
346        .collect();
347
348    // Collect the non-Storage actions so the caller can surface them in audit
349    // logs and UI. Previously these were only logged via tracing::warn and
350    // silently dropped from the boundary, leaving operators with the false
351    // impression that their full --allow list was enforced.
352    let skipped_actions: Vec<String> = actions
353        .iter()
354        .filter(|a| !a.starts_with("storage."))
355        .cloned()
356        .collect();
357    if !skipped_actions.is_empty() {
358        tracing::warn!(
359            skipped_actions = ?skipped_actions,
360            "CAB enforcement unavailable for non-Storage actions; \
361             these permissions are advisory-only and will not be enforced at runtime"
362        );
363    }
364
365    if storage_actions.is_empty() {
366        return Ok((rules, skipped_actions));
367    }
368
369    // Map storage permissions to the most restrictive role that covers them
370    let role = map_storage_role(&storage_actions);
371
372    // R6-H12: emit one rule per requested resource. Previously only the
373    // first resource was passed in and the rest silently received full SA
374    // permissions. When no resources are supplied, fall back to a wildcard
375    // over all buckets in the project (preserves prior behaviour).
376    //
377    // R6-M16: validate bucket names before interpolating them into the
378    // CAB rule's `availableResource`. Without validation, a `--resource`
379    // value like `mybucket/../../../other-project/buckets/attacker`
380    // could escape the intended project scope, and slash/star characters
381    // in the name could widen the boundary to unintended resources.
382    // Names must match the Cloud Storage naming rules:
383    // `[a-z0-9._-]+` with length 3..=222.
384    let formatted_resources: Vec<String> = if resources.is_empty() {
385        vec!["//storage.googleapis.com/projects/_/buckets/*".to_string()]
386    } else {
387        let mut out = Vec::with_capacity(resources.len());
388        for r in resources {
389            if r.starts_with("//") {
390                out.push((*r).to_string());
391            } else {
392                if !is_valid_gcs_bucket_name(r) {
393                    return Err(crate::error::AvError::InvalidPolicy(format!(
394                        "Invalid GCS bucket name '{}': must match [a-z0-9._-]+ \
395                         and be between 3 and 222 characters",
396                        r
397                    )));
398                }
399                out.push(format!("//storage.googleapis.com/projects/_/buckets/{}", r));
400            }
401        }
402        out
403    };
404
405    for available_resource in formatted_resources {
406        rules.push(serde_json::json!({
407            "availablePermissions": [format!("inRole:roles/{}", role)],
408            "availableResource": available_resource,
409        }));
410    }
411
412    Ok((rules, skipped_actions))
413}
414
415/// Validate a GCS bucket name against Cloud Storage naming rules.
416///
417/// Cloud Storage accepts `[a-z0-9._-]+` with length 3..=222. We apply the
418/// strictest subset that covers regular bucket names so that interpolating
419/// the value into a CAB `availableResource` cannot escape the intended
420/// project scope via `/` or `*` characters.
421fn is_valid_gcs_bucket_name(name: &str) -> bool {
422    let len = name.len();
423    if !(3..=222).contains(&len) {
424        return false;
425    }
426    name.chars()
427        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '.' || c == '_' || c == '-')
428}
429
430/// Determine the most restrictive Cloud Storage role for a set of permissions.
431///
432/// # Warning
433///
434/// `storage.objectAdmin` includes `storage.objects.setIamPolicy`, which allows
435/// the holder to grant arbitrary access to individual objects. This role is
436/// assigned when delete permissions are requested because Cloud Storage has no
437/// built-in role that allows delete without also allowing `setIamPolicy`. Callers
438/// that require delete access must accept this elevated privilege.
439///
440/// R6-M17: classify actions by the verb *after the last dot*, not by raw
441/// substring. `a.contains("create")` would misclassify a hypothetical
442/// read-side action like `storage.objects.createDraftRead` as a create, and
443/// any action whose name happened to contain the substring `"delete"` (e.g.
444/// `storage.foo.undelete` in a later API revision) would escalate the whole
445/// session to `objectAdmin` unnecessarily.
446fn map_storage_role(actions: &[&str]) -> &'static str {
447    fn verb_of(action: &str) -> &str {
448        action.rsplit('.').next().unwrap_or(action)
449    }
450
451    let has_delete = actions.iter().any(|a| verb_of(a) == "delete");
452    let has_create = actions.iter().any(|a| {
453        let v = verb_of(a);
454        v == "create" || v == "update" || v == "insert"
455    });
456
457    if has_delete {
458        "storage.objectAdmin"
459    } else if has_create {
460        "storage.objectCreator"
461    } else {
462        "storage.objectViewer"
463    }
464}
465
466/// Map known action prefixes to narrower GCP OAuth scopes.
467///
468/// GCP OAuth scopes are coarse-grained (service-level), not permission-level.
469/// Where a narrower scope exists for the requested service, use it instead of
470/// the catch-all `cloud-platform` scope to reduce the blast radius of a
471/// compromised token. Falls back to `cloud-platform` when no narrower scope
472/// covers the requested actions.
473///
474/// Note: the `service_account` parameter is unused today but kept for future
475/// expansion (e.g., per-SA scope overrides via config).
476fn narrow_oauth_scopes(_service_account: &str) -> Vec<&'static str> {
477    // cloud-platform is the broadest scope and covers everything.
478    // Narrower scopes will be added here as the action-mapping system
479    // is extended to carry per-action metadata.
480    //
481    // Known narrower scopes for future mapping:
482    //   storage: https://www.googleapis.com/auth/devstorage.read_write
483    //           https://www.googleapis.com/auth/devstorage.read_only
484    //   bigquery: https://www.googleapis.com/auth/bigquery
485    //   pubsub:   https://www.googleapis.com/auth/pubsub
486    //   datastore: https://www.googleapis.com/auth/datastore
487    //
488    // Until per-action scope metadata is available we use cloud-platform
489    // and rely on CAB (Credential Access Boundaries) for runtime enforcement
490    // where supported.
491    vec!["https://www.googleapis.com/auth/cloud-platform"]
492}
493
494/// Truncate and sanitize a GCP API error body for user-facing messages.
495///
496/// GCP error responses may contain project IDs and other internal identifiers.
497/// This function strips patterns that look like project IDs and truncates the
498/// body to 200 characters to avoid leaking sensitive details.
499fn sanitize_gcp_error(body: &str) -> String {
500    // R6-H13: compile regexes once at first use. Previously each error
501    // response compiled three regexes from scratch, and on allocator failure
502    // the pattern would silently fall through with the body unsanitized.
503    use std::sync::LazyLock;
504    static PROJECT_PATH: LazyLock<regex::Regex> =
505        LazyLock::new(|| regex::Regex::new(r"projects/[a-zA-Z0-9_\-]+").unwrap());
506    static PROJECT_ID_FIELD: LazyLock<regex::Regex> =
507        LazyLock::new(|| regex::Regex::new(r#""project[_\-]?[Ii][Dd]"\s*:\s*"[^"]+""#).unwrap());
508    static SA_EMAIL: LazyLock<regex::Regex> = LazyLock::new(|| {
509        regex::Regex::new(r"[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.iam\.gserviceaccount\.com").unwrap()
510    });
511
512    let sanitized = PROJECT_PATH
513        .replace_all(body, "projects/[REDACTED]")
514        .into_owned();
515    let sanitized = PROJECT_ID_FIELD
516        .replace_all(&sanitized, "\"project_id\": \"[REDACTED]\"")
517        .into_owned();
518    let sanitized = SA_EMAIL
519        .replace_all(&sanitized, "[SA_REDACTED]")
520        .into_owned();
521
522    if sanitized.chars().count() > 200 {
523        format!("{}…", sanitized.chars().take(200).collect::<String>())
524    } else {
525        sanitized
526    }
527}