use alien_error::AlienError;
use base64::{engine::general_purpose::STANDARD, Engine};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use url::Url;
use crate::{ErrorData, Result};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct GcpFederationTrust {
pub issuer: String,
pub subject: String,
}
impl GcpFederationTrust {
pub fn validate(&self) -> Result<()> {
let issuer = Url::parse(&self.issuer)
.map_err(|_| invalid("issuer", "must be an absolute HTTPS issuer URL"))?;
if issuer.scheme() != "https"
|| issuer.host_str().is_none()
|| !issuer.username().is_empty()
|| issuer.password().is_some()
|| issuer.query().is_some()
|| issuer.fragment().is_some()
{
return Err(invalid(
"issuer",
"must be HTTPS without credentials, query or fragment",
));
}
if self.subject.is_empty()
|| self.subject.len() > 127
|| self.subject.chars().any(char::is_control)
{
return Err(invalid(
"subject",
"must contain 1–127 bytes without control characters",
));
}
if self.issuer == "https://accounts.google.com"
&& !self.subject.bytes().all(|byte| byte.is_ascii_digit())
{
return Err(invalid(
"subject",
"Google trust requires the immutable numeric account ID",
));
}
Ok(())
}
pub fn subject_condition(&self) -> String {
format!(
"assertion.sub == {}",
serde_json::Value::String(self.subject.clone())
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct GcpFederationAccess {
pub project_number: String,
pub pool_id: String,
pub provider_id: String,
pub service_account_email: String,
pub assertion_audience: String,
pub trust: GcpFederationTrust,
}
impl GcpFederationAccess {
pub fn validate(&self) -> Result<()> {
self.trust.validate()?;
let Some(scope_hash) = self
.assertion_audience
.strip_prefix("urn:deployment-access:v1:")
else {
return Err(invalid(
"assertionAudience",
"must identify an installation-scoped audience",
));
};
if scope_hash.len() != 64
|| !scope_hash
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
{
return Err(invalid(
"assertionAudience",
"must contain a SHA-256 installation scope",
));
}
if self.project_number.is_empty()
|| self.project_number.len() > 30
|| !self
.project_number
.bytes()
.all(|byte| byte.is_ascii_digit())
{
return Err(invalid("projectNumber", "must be a numeric project number"));
}
validate_federation_id("poolId", &self.pool_id)?;
validate_federation_id("providerId", &self.provider_id)?;
let Some((account, domain)) = self.service_account_email.split_once('@') else {
return Err(invalid(
"serviceAccountEmail",
"must be a Google service-account email",
));
};
if account.is_empty()
|| !domain.ends_with(".iam.gserviceaccount.com")
|| !self.service_account_email.bytes().all(|byte| {
byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"@.-".contains(&byte)
})
|| domain.contains('@')
{
return Err(invalid(
"serviceAccountEmail",
"must be a Google service-account email",
));
}
Ok(())
}
pub fn provider_audience(&self) -> Result<String> {
self.validate()?;
Ok(format!("//iam.googleapis.com/projects/{}/locations/global/workloadIdentityPools/{}/providers/{}", self.project_number, self.pool_id, self.provider_id))
}
pub fn principal(&self) -> Result<String> {
self.validate()?;
let mut principal = Url::parse(&format!(
"https://iam.googleapis.com/projects/{}/locations/global/workloadIdentityPools/{}/subject/",
self.project_number, self.pool_id
))
.map_err(|_| invalid("projectNumber", "cannot construct principal URI"))?;
principal
.path_segments_mut()
.map_err(|_| invalid("subject", "cannot construct principal URI"))?
.pop_if_empty()
.push(&self.trust.subject);
Ok(principal.as_str().replacen("https://", "principal://", 1))
}
}
pub fn validate_federation_id(field: &str, value: &str) -> Result<()> {
if !(4..=32).contains(&value.len())
|| value.starts_with("gcp-")
|| !value
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
{
return Err(invalid(
field,
"must contain 4–32 lowercase letters, digits or hyphens and must not start with gcp-",
));
}
Ok(())
}
fn invalid(field: &str, reason: &str) -> AlienError<ErrorData> {
AlienError::new(ErrorData::GcpFederationInvalid {
field: field.to_string(),
reason: reason.to_string(),
})
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct GcpFederationScope {
pub deployment_id: String,
}
impl GcpFederationScope {
pub fn validate_access(
&self,
access: &GcpFederationAccess,
resource_type: &crate::ResourceType,
) -> Result<()> {
access.validate()?;
if access.assertion_audience != self.assertion_audience(resource_type)? {
return Err(invalid(
"assertionAudience",
"does not match the authorized installation and access purpose; rerun setup",
));
}
Ok(())
}
pub fn assertion_audience(&self, resource_type: &crate::ResourceType) -> Result<String> {
Ok(format!(
"urn:deployment-access:v1:{}",
self.scope_hash(resource_type)?
))
}
pub fn pool_id(&self, resource_type: &crate::ResourceType) -> Result<String> {
Ok(format!(
"deployment-{}",
&self.scope_hash(resource_type)?[..20]
))
}
fn scope_hash(&self, resource_type: &crate::ResourceType) -> Result<String> {
if self.deployment_id.is_empty()
|| self.deployment_id.len() > 128
|| !self
.deployment_id
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b"_-".contains(&byte))
{
return Err(invalid(
"scope",
"requires the immutable registered deployment ID",
));
}
if resource_type != &crate::RemoteStackManagement::RESOURCE_TYPE
&& resource_type != &crate::RemoteBindings::RESOURCE_TYPE
{
return Err(invalid(
"scope",
"requires a management or published-resource access purpose",
));
}
let encoded =
[self.deployment_id.as_str(), resource_type.as_ref()].map(|part| STANDARD.encode(part));
let hash = Sha256::digest(format!("gcp-deployment-access-v1:{}", encoded.join(":")));
Ok(format!("{hash:x}"))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase", tag = "type", deny_unknown_fields)]
pub enum GcpFederationSource {
GoogleServiceAccount {
#[cfg_attr(feature = "openapi", schema(value_type = Object))]
source: Box<crate::GcpClientConfig>,
service_account_email: String,
service_account_unique_id: String,
},
KubernetesServiceAccount {
issuer: String,
api_server: String,
api_token_file: String,
ca_file: String,
namespace: String,
service_account: String,
},
}
#[cfg(test)]
mod tests {
use super::*;
fn access() -> GcpFederationAccess {
GcpFederationAccess {
project_number: "123456789012".to_string(),
pool_id: "deployment-management".to_string(),
provider_id: "manager".to_string(),
service_account_email: "management@example-project.iam.gserviceaccount.com".to_string(),
assertion_audience: GcpFederationScope {
deployment_id: "deployment-production".to_string(),
}
.assertion_audience(&crate::RemoteStackManagement::RESOURCE_TYPE)
.unwrap(),
trust: GcpFederationTrust {
issuer: "https://accounts.google.com".to_string(),
subject: "123456789012345678901".to_string(),
},
}
}
#[test]
fn broker_rejects_foreign_installation_and_access_purpose() {
let scope = GcpFederationScope {
deployment_id: "deployment-production".to_string(),
};
let access = access();
scope
.validate_access(&access, &crate::RemoteStackManagement::RESOURCE_TYPE)
.unwrap();
assert!(scope
.validate_access(&access, &crate::RemoteBindings::RESOURCE_TYPE)
.is_err());
let other = GcpFederationScope {
deployment_id: "deployment-recreated".to_string(),
};
assert!(other
.validate_access(&access, &crate::RemoteStackManagement::RESOURCE_TYPE)
.is_err());
let mut substituted = access.clone();
substituted.assertion_audience = other
.assertion_audience(&crate::RemoteStackManagement::RESOURCE_TYPE)
.unwrap();
assert!(scope
.validate_access(&substituted, &crate::RemoteStackManagement::RESOURCE_TYPE)
.is_err());
}
#[test]
fn invalid_target_identifiers_cannot_construct_google_endpoints() {
for value in [
"",
"../other",
"123/../456",
"123?host=other",
"https://other.invalid",
] {
let mut config = access();
config.project_number = value.to_string();
assert!(
config.provider_audience().is_err(),
"accepted project {value}"
);
assert!(config.principal().is_err());
}
for value in [
"abc",
"UPPER",
"gcp-reserved",
"other/provider",
"other?query",
"other#fragment",
] {
let mut config = access();
config.pool_id = value.to_string();
assert!(config.provider_audience().is_err(), "accepted pool {value}");
config = access();
config.provider_id = value.to_string();
assert!(
config.provider_audience().is_err(),
"accepted provider {value}"
);
}
}
#[test]
fn issuer_and_subject_cannot_implicitly_broaden_trust() {
for issuer in [
"http://issuer.example",
"https://user:password@issuer.example",
"https://issuer.example?x=y",
"https://issuer.example/#fragment",
] {
let mut config = access();
config.trust.issuer = issuer.to_string();
assert!(config.provider_audience().is_err());
}
for subject in [
"",
"manager@example-project.iam.gserviceaccount.com",
"123\n456",
] {
let mut config = access();
config.trust.subject = subject.to_string();
assert!(config.provider_audience().is_err());
}
}
#[test]
fn kubernetes_subject_is_exact_and_pool_local() {
let mut config = access();
config.trust = GcpFederationTrust {
issuer: "https://oidc.eks.example/id/cluster".to_string(),
subject: "system:serviceaccount:runtime:manager".to_string(),
};
assert_eq!(config.provider_audience().unwrap(), "//iam.googleapis.com/projects/123456789012/locations/global/workloadIdentityPools/deployment-management/providers/manager");
assert_eq!(config.principal().unwrap(), "principal://iam.googleapis.com/projects/123456789012/locations/global/workloadIdentityPools/deployment-management/subject/system:serviceaccount:runtime:manager");
assert_eq!(
config.trust.subject_condition(),
"assertion.sub == \"system:serviceaccount:runtime:manager\""
);
let principal = config.principal().unwrap();
config.pool_id = "different-deployment".to_string();
assert_ne!(config.principal().unwrap(), principal);
}
#[test]
fn subject_quotes_cannot_turn_equality_into_a_cel_expression() {
let mut config = access();
config.trust.issuer = "https://issuer.example".to_string();
config.trust.subject = "subject\" || true || assertion.sub == \"other".to_string();
config.validate().unwrap();
let condition = config.trust.subject_condition();
let literal = condition.strip_prefix("assertion.sub == ").unwrap();
assert_eq!(
serde_json::from_str::<String>(literal).unwrap(),
config.trust.subject
);
}
#[test]
fn immutable_installations_and_access_purposes_have_distinct_audiences() {
let scope = GcpFederationScope {
deployment_id: "deployment-original".to_string(),
};
let management = &crate::RemoteStackManagement::RESOURCE_TYPE;
let original = scope.assertion_audience(management).unwrap();
let recreated = GcpFederationScope {
deployment_id: "deployment-recreated".to_string(),
};
assert_ne!(original, recreated.assertion_audience(management).unwrap());
assert_ne!(
scope.pool_id(management).unwrap(),
recreated.pool_id(management).unwrap()
);
assert_ne!(
scope.pool_id(management).unwrap(),
scope
.pool_id(&crate::RemoteBindings::RESOURCE_TYPE)
.unwrap()
);
assert!(scope.pool_id(management).unwrap().len() <= 32);
assert_ne!(
original,
scope
.assertion_audience(&crate::RemoteBindings::RESOURCE_TYPE)
.unwrap()
);
for id in [
"",
" deployment-original",
"deployment-original ",
"other/path",
"other:namespace",
] {
assert!(GcpFederationScope {
deployment_id: id.to_string()
}
.assertion_audience(management)
.is_err());
}
}
#[test]
fn plain_legacy_email_is_not_a_federation_descriptor() {
assert!(serde_json::from_str::<GcpFederationAccess>(
"\"management@example-project.iam.gserviceaccount.com\""
)
.is_err());
let mut value = serde_json::to_value(access()).unwrap();
value["tokenUrl"] = "https://other.invalid".into();
assert!(serde_json::from_value::<GcpFederationAccess>(value).is_err());
}
}