1use alien_error::AlienError;
4use base64::{engine::general_purpose::STANDARD, Engine};
5use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256};
7use url::Url;
8
9use crate::{ErrorData, Result};
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
14#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
15#[serde(rename_all = "camelCase", deny_unknown_fields)]
16pub struct GcpFederationTrust {
17 pub issuer: String,
19 pub subject: String,
21}
22
23impl GcpFederationTrust {
24 pub fn validate(&self) -> Result<()> {
25 let issuer = Url::parse(&self.issuer)
26 .map_err(|_| invalid("issuer", "must be an absolute HTTPS issuer URL"))?;
27 if issuer.scheme() != "https"
28 || issuer.host_str().is_none()
29 || !issuer.username().is_empty()
30 || issuer.password().is_some()
31 || issuer.query().is_some()
32 || issuer.fragment().is_some()
33 {
34 return Err(invalid(
35 "issuer",
36 "must be HTTPS without credentials, query or fragment",
37 ));
38 }
39 if self.subject.is_empty()
40 || self.subject.len() > 127
41 || self.subject.chars().any(char::is_control)
42 {
43 return Err(invalid(
44 "subject",
45 "must contain 1–127 bytes without control characters",
46 ));
47 }
48 if self.issuer == "https://accounts.google.com"
49 && !self.subject.bytes().all(|byte| byte.is_ascii_digit())
50 {
51 return Err(invalid(
52 "subject",
53 "Google trust requires the immutable numeric account ID",
54 ));
55 }
56 Ok(())
57 }
58
59 pub fn subject_condition(&self) -> String {
61 format!(
62 "assertion.sub == {}",
63 serde_json::Value::String(self.subject.clone())
64 )
65 }
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
71#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
72#[serde(rename_all = "camelCase", deny_unknown_fields)]
73pub struct GcpFederationAccess {
74 pub project_number: String,
75 pub pool_id: String,
76 pub provider_id: String,
77 pub service_account_email: String,
78 pub assertion_audience: String,
81 pub trust: GcpFederationTrust,
82}
83
84impl GcpFederationAccess {
85 pub fn validate(&self) -> Result<()> {
86 self.trust.validate()?;
87 let Some(scope_hash) = self
88 .assertion_audience
89 .strip_prefix("urn:deployment-access:v1:")
90 else {
91 return Err(invalid(
92 "assertionAudience",
93 "must identify an installation-scoped audience",
94 ));
95 };
96 if scope_hash.len() != 64
97 || !scope_hash
98 .bytes()
99 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
100 {
101 return Err(invalid(
102 "assertionAudience",
103 "must contain a SHA-256 installation scope",
104 ));
105 }
106 if self.project_number.is_empty()
107 || self.project_number.len() > 30
108 || !self
109 .project_number
110 .bytes()
111 .all(|byte| byte.is_ascii_digit())
112 {
113 return Err(invalid("projectNumber", "must be a numeric project number"));
114 }
115 validate_federation_id("poolId", &self.pool_id)?;
116 validate_federation_id("providerId", &self.provider_id)?;
117 let Some((account, domain)) = self.service_account_email.split_once('@') else {
118 return Err(invalid(
119 "serviceAccountEmail",
120 "must be a Google service-account email",
121 ));
122 };
123 if account.is_empty()
124 || !domain.ends_with(".iam.gserviceaccount.com")
125 || !self.service_account_email.bytes().all(|byte| {
126 byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"@.-".contains(&byte)
127 })
128 || domain.contains('@')
129 {
130 return Err(invalid(
131 "serviceAccountEmail",
132 "must be a Google service-account email",
133 ));
134 }
135 Ok(())
136 }
137
138 pub fn provider_audience(&self) -> Result<String> {
140 self.validate()?;
141 Ok(format!("//iam.googleapis.com/projects/{}/locations/global/workloadIdentityPools/{}/providers/{}", self.project_number, self.pool_id, self.provider_id))
142 }
143
144 pub fn principal(&self) -> Result<String> {
146 self.validate()?;
147 let mut principal = Url::parse(&format!(
149 "https://iam.googleapis.com/projects/{}/locations/global/workloadIdentityPools/{}/subject/",
150 self.project_number, self.pool_id
151 ))
152 .map_err(|_| invalid("projectNumber", "cannot construct principal URI"))?;
153 principal
154 .path_segments_mut()
155 .map_err(|_| invalid("subject", "cannot construct principal URI"))?
156 .pop_if_empty()
157 .push(&self.trust.subject);
158 Ok(principal.as_str().replacen("https://", "principal://", 1))
159 }
160}
161
162pub fn validate_federation_id(field: &str, value: &str) -> Result<()> {
164 if !(4..=32).contains(&value.len())
165 || value.starts_with("gcp-")
166 || !value
167 .bytes()
168 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
169 {
170 return Err(invalid(
171 field,
172 "must contain 4–32 lowercase letters, digits or hyphens and must not start with gcp-",
173 ));
174 }
175 Ok(())
176}
177
178fn invalid(field: &str, reason: &str) -> AlienError<ErrorData> {
179 AlienError::new(ErrorData::GcpFederationInvalid {
180 field: field.to_string(),
181 reason: reason.to_string(),
182 })
183}
184
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
189#[serde(rename_all = "camelCase", deny_unknown_fields)]
190pub struct GcpFederationScope {
191 pub deployment_id: String,
194}
195
196impl GcpFederationScope {
197 pub fn validate_access(
199 &self,
200 access: &GcpFederationAccess,
201 resource_type: &crate::ResourceType,
202 ) -> Result<()> {
203 access.validate()?;
204 if access.assertion_audience != self.assertion_audience(resource_type)? {
205 return Err(invalid(
206 "assertionAudience",
207 "does not match the authorized installation and access purpose; rerun setup",
208 ));
209 }
210 Ok(())
211 }
212
213 pub fn assertion_audience(&self, resource_type: &crate::ResourceType) -> Result<String> {
217 Ok(format!(
218 "urn:deployment-access:v1:{}",
219 self.scope_hash(resource_type)?
220 ))
221 }
222
223 pub fn pool_id(&self, resource_type: &crate::ResourceType) -> Result<String> {
226 Ok(format!(
227 "deployment-{}",
228 &self.scope_hash(resource_type)?[..20]
229 ))
230 }
231
232 fn scope_hash(&self, resource_type: &crate::ResourceType) -> Result<String> {
233 if self.deployment_id.is_empty()
234 || self.deployment_id.len() > 128
235 || !self
236 .deployment_id
237 .bytes()
238 .all(|byte| byte.is_ascii_alphanumeric() || b"_-".contains(&byte))
239 {
240 return Err(invalid(
241 "scope",
242 "requires the immutable registered deployment ID",
243 ));
244 }
245 if resource_type != &crate::RemoteStackManagement::RESOURCE_TYPE
246 && resource_type != &crate::RemoteBindings::RESOURCE_TYPE
247 {
248 return Err(invalid(
249 "scope",
250 "requires a management or published-resource access purpose",
251 ));
252 }
253 let encoded =
254 [self.deployment_id.as_str(), resource_type.as_ref()].map(|part| STANDARD.encode(part));
255 let hash = Sha256::digest(format!("gcp-deployment-access-v1:{}", encoded.join(":")));
256 Ok(format!("{hash:x}"))
257 }
258}
259
260#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
262#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
263#[serde(rename_all = "camelCase", tag = "type", deny_unknown_fields)]
264pub enum GcpFederationSource {
265 GoogleServiceAccount {
267 #[cfg_attr(feature = "openapi", schema(value_type = Object))]
268 source: Box<crate::GcpClientConfig>,
269 service_account_email: String,
270 service_account_unique_id: String,
271 },
272 KubernetesServiceAccount {
274 issuer: String,
275 api_server: String,
276 api_token_file: String,
277 ca_file: String,
278 namespace: String,
279 service_account: String,
280 },
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286
287 fn access() -> GcpFederationAccess {
288 GcpFederationAccess {
289 project_number: "123456789012".to_string(),
290 pool_id: "deployment-management".to_string(),
291 provider_id: "manager".to_string(),
292 service_account_email: "management@example-project.iam.gserviceaccount.com".to_string(),
293 assertion_audience: GcpFederationScope {
294 deployment_id: "deployment-production".to_string(),
295 }
296 .assertion_audience(&crate::RemoteStackManagement::RESOURCE_TYPE)
297 .unwrap(),
298 trust: GcpFederationTrust {
299 issuer: "https://accounts.google.com".to_string(),
300 subject: "123456789012345678901".to_string(),
301 },
302 }
303 }
304
305 #[test]
306 fn broker_rejects_foreign_installation_and_access_purpose() {
307 let scope = GcpFederationScope {
308 deployment_id: "deployment-production".to_string(),
309 };
310 let access = access();
311 scope
312 .validate_access(&access, &crate::RemoteStackManagement::RESOURCE_TYPE)
313 .unwrap();
314 assert!(scope
315 .validate_access(&access, &crate::RemoteBindings::RESOURCE_TYPE)
316 .is_err());
317 let other = GcpFederationScope {
318 deployment_id: "deployment-recreated".to_string(),
319 };
320 assert!(other
321 .validate_access(&access, &crate::RemoteStackManagement::RESOURCE_TYPE)
322 .is_err());
323 let mut substituted = access.clone();
324 substituted.assertion_audience = other
325 .assertion_audience(&crate::RemoteStackManagement::RESOURCE_TYPE)
326 .unwrap();
327 assert!(scope
328 .validate_access(&substituted, &crate::RemoteStackManagement::RESOURCE_TYPE)
329 .is_err());
330 }
331
332 #[test]
333 fn invalid_target_identifiers_cannot_construct_google_endpoints() {
334 for value in [
335 "",
336 "../other",
337 "123/../456",
338 "123?host=other",
339 "https://other.invalid",
340 ] {
341 let mut config = access();
342 config.project_number = value.to_string();
343 assert!(
344 config.provider_audience().is_err(),
345 "accepted project {value}"
346 );
347 assert!(config.principal().is_err());
348 }
349 for value in [
350 "abc",
351 "UPPER",
352 "gcp-reserved",
353 "other/provider",
354 "other?query",
355 "other#fragment",
356 ] {
357 let mut config = access();
358 config.pool_id = value.to_string();
359 assert!(config.provider_audience().is_err(), "accepted pool {value}");
360 config = access();
361 config.provider_id = value.to_string();
362 assert!(
363 config.provider_audience().is_err(),
364 "accepted provider {value}"
365 );
366 }
367 }
368
369 #[test]
370 fn issuer_and_subject_cannot_implicitly_broaden_trust() {
371 for issuer in [
372 "http://issuer.example",
373 "https://user:password@issuer.example",
374 "https://issuer.example?x=y",
375 "https://issuer.example/#fragment",
376 ] {
377 let mut config = access();
378 config.trust.issuer = issuer.to_string();
379 assert!(config.provider_audience().is_err());
380 }
381 for subject in [
382 "",
383 "manager@example-project.iam.gserviceaccount.com",
384 "123\n456",
385 ] {
386 let mut config = access();
387 config.trust.subject = subject.to_string();
388 assert!(config.provider_audience().is_err());
389 }
390 }
391
392 #[test]
393 fn kubernetes_subject_is_exact_and_pool_local() {
394 let mut config = access();
395 config.trust = GcpFederationTrust {
396 issuer: "https://oidc.eks.example/id/cluster".to_string(),
397 subject: "system:serviceaccount:runtime:manager".to_string(),
398 };
399 assert_eq!(config.provider_audience().unwrap(), "//iam.googleapis.com/projects/123456789012/locations/global/workloadIdentityPools/deployment-management/providers/manager");
400 assert_eq!(config.principal().unwrap(), "principal://iam.googleapis.com/projects/123456789012/locations/global/workloadIdentityPools/deployment-management/subject/system:serviceaccount:runtime:manager");
401 assert_eq!(
402 config.trust.subject_condition(),
403 "assertion.sub == \"system:serviceaccount:runtime:manager\""
404 );
405 let principal = config.principal().unwrap();
406 config.pool_id = "different-deployment".to_string();
407 assert_ne!(config.principal().unwrap(), principal);
408 }
409
410 #[test]
411 fn subject_quotes_cannot_turn_equality_into_a_cel_expression() {
412 let mut config = access();
413 config.trust.issuer = "https://issuer.example".to_string();
414 config.trust.subject = "subject\" || true || assertion.sub == \"other".to_string();
415 config.validate().unwrap();
416 let condition = config.trust.subject_condition();
417 let literal = condition.strip_prefix("assertion.sub == ").unwrap();
418 assert_eq!(
419 serde_json::from_str::<String>(literal).unwrap(),
420 config.trust.subject
421 );
422 }
423
424 #[test]
425 fn immutable_installations_and_access_purposes_have_distinct_audiences() {
426 let scope = GcpFederationScope {
427 deployment_id: "deployment-original".to_string(),
428 };
429 let management = &crate::RemoteStackManagement::RESOURCE_TYPE;
430 let original = scope.assertion_audience(management).unwrap();
431 let recreated = GcpFederationScope {
434 deployment_id: "deployment-recreated".to_string(),
435 };
436 assert_ne!(original, recreated.assertion_audience(management).unwrap());
437 assert_ne!(
438 scope.pool_id(management).unwrap(),
439 recreated.pool_id(management).unwrap()
440 );
441 assert_ne!(
442 scope.pool_id(management).unwrap(),
443 scope
444 .pool_id(&crate::RemoteBindings::RESOURCE_TYPE)
445 .unwrap()
446 );
447 assert!(scope.pool_id(management).unwrap().len() <= 32);
448 assert_ne!(
449 original,
450 scope
451 .assertion_audience(&crate::RemoteBindings::RESOURCE_TYPE)
452 .unwrap()
453 );
454 for id in [
455 "",
456 " deployment-original",
457 "deployment-original ",
458 "other/path",
459 "other:namespace",
460 ] {
461 assert!(GcpFederationScope {
462 deployment_id: id.to_string()
463 }
464 .assertion_audience(management)
465 .is_err());
466 }
467 }
468
469 #[test]
470 fn plain_legacy_email_is_not_a_federation_descriptor() {
471 assert!(serde_json::from_str::<GcpFederationAccess>(
472 "\"management@example-project.iam.gserviceaccount.com\""
473 )
474 .is_err());
475 let mut value = serde_json::to_value(access()).unwrap();
476 value["tokenUrl"] = "https://other.invalid".into();
477 assert!(serde_json::from_value::<GcpFederationAccess>(value).is_err());
478 }
479}