Skip to main content

alien_core/
gcp_federation.rs

1//! Non-secret trust and access descriptions for customer-owned GCP federation.
2
3use 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/// The exact manager identity trusted by a deployment-owned identity pool.
12#[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    /// OIDC issuer; Google or the manager's Kubernetes issuer.
18    pub issuer: String,
19    /// Exact immutable Google account ID or Kubernetes service-account subject.
20    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    /// CEL equality against the exact subject, with string-literal escaping.
60    pub fn subject_condition(&self) -> String {
61        format!(
62            "assertion.sub == {}",
63            serde_json::Value::String(self.subject.clone())
64        )
65    }
66}
67
68/// Setup-owned federation access. Contains no assertions, keys or access tokens.
69#[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    /// Installation- and purpose-specific assertion audience, independently
79    /// checked by the manager against its authorized deployment record.
80    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    /// Validates before constructing the canonical Google STS audience.
139    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    /// Pool-local exact principal; providers do not introduce a subject namespace.
145    pub fn principal(&self) -> Result<String> {
146        self.validate()?;
147        // Encode the subject as one URI path segment, including slashes.
148        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
162/// Google pool and provider IDs share the same documented restrictions.
163pub 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/// Identity of one installation. The runtime constructs this from its
186/// authenticated deployment record, never names or imported target metadata.
187#[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    /// Immutable ID allocated by the control plane before cloud setup.
192    /// Names, display metadata and imported resource identifiers cannot replace it.
193    pub deployment_id: String,
194}
195
196impl GcpFederationScope {
197    /// Check setup-owned access against the runtime's authenticated installation.
198    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    /// Binds an assertion to one immutable installation and access purpose.
214    /// Base64 components keep the cross-language hash unambiguous without
215    /// depending on JSON escaping rules in setup engines.
216    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    /// A fresh installation gets a fresh pool name even while a deleted
224    /// installation's pool remains reserved during Google's recovery period.
225    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/// Trusted manager-local assertion source. Never read this from target setup outputs.
261#[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    /// The container caller uses its existing scoped grant on a linked account.
266    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    /// Audience-specific TokenRequest using rotating in-cluster API credentials.
273    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        // Recreating the same logical name allocates a new ID. Old cloud trust
432        // cannot be selected by copying an orphaned installation's descriptor.
433        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}