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}