From a01978cea39831c570ea37637451faaab835f759 Mon Sep 17 00:00:00 2001 From: chrischeng-c4 Date: Sat, 24 Oct 2026 09:19:03 +0800 Subject: [PATCH] feat(service-k8s): add typed ServiceAccountIdentityPlan with Workload Identity support --- crates/service-k8s/src/api/render.rs | 17 +- .../infrastructure/manifest/workload_plan.rs | 13 + .../workload_plan/service_account_identity.rs | 117 +++++++ crates/service-k8s/tests/it/workload_plan.rs | 300 +++++++++++++++++- 4 files changed, 437 insertions(+), 10 deletions(-) create mode 100644 crates/service-k8s/src/infrastructure/manifest/workload_plan/service_account_identity.rs diff --git a/crates/service-k8s/src/api/render.rs b/crates/service-k8s/src/api/render.rs index eb1c6ae4..03db2ca3 100644 --- a/crates/service-k8s/src/api/render.rs +++ b/crates/service-k8s/src/api/render.rs @@ -18,6 +18,7 @@ //! read the root as legacy, and do not "finish" the split without moving the //! callers in the same change. +pub use crate::infrastructure::manifest::projected_token::ProjectedServiceAccountToken; pub use crate::infrastructure::manifest::{ client_service, client_service_with_ports, cron_job, dedicated_node_affinity, guaranteed_resources, headless_service, headless_service_with_ports, horizontal_pod_autoscaler, @@ -28,11 +29,11 @@ pub use crate::infrastructure::manifest::{ DaemonSetPlan, DeploymentPlan, FqdnMatchPlan, FqdnNetworkPolicyPlan, HorizontalPodAutoscaler, LabelSet, NetworkPeerPlan, NetworkPolicyPlan, NetworkPortPlan, NetworkRulePlan, PersistentVolumeClaimPlan, PodDisruptionBudgetPlan, PodPlan, PodRuntimePolicy, RbacRulePlan, - RenderCtx, RoleBindingPlan, RolePlan, ServiceAccountPlan, ServiceAccountSubjectPlan, - ServicePlan, ServicePortPlan, ServiceStatefulSet, ShardedStatefulSet, StatefulSetPlan, - TokenRegistryProjection, TokenRegistrySource, WorkloadPlan, WorkloadPlanError, - WorkloadVolumeClaim, DEFAULT_TOKEN_REGISTRY_CSI_DRIVER, ENV_POD_NAME, ENV_POD_NAMESPACE, - ENV_REPLICAS_PER_SHARD, ENV_SHARD_COUNT, ENV_VOTER_COUNT, + RenderCtx, RoleBindingPlan, RolePlan, ServiceAccountIdentityPlan, ServiceAccountPlan, + ServiceAccountSubjectPlan, ServicePlan, ServicePortPlan, ServiceStatefulSet, + ShardedStatefulSet, StatefulSetPlan, TokenRegistryProjection, TokenRegistrySource, + WorkloadPlan, WorkloadPlanError, WorkloadVolumeClaim, DEFAULT_TOKEN_REGISTRY_CSI_DRIVER, + ENV_POD_NAME, ENV_POD_NAMESPACE, ENV_REPLICAS_PER_SHARD, ENV_SHARD_COUNT, ENV_VOTER_COUNT, }; pub mod common { @@ -74,8 +75,8 @@ pub mod workload_plan { ClusterRoleBindingPlan, ContainerPlan, CronJobPlan, DaemonSetPlan, DeploymentPlan, FqdnMatchPlan, FqdnNetworkPolicyPlan, LabelSet, NetworkPeerPlan, NetworkPolicyPlan, NetworkPortPlan, NetworkRulePlan, PersistentVolumeClaimPlan, PodDisruptionBudgetPlan, - PodPlan, PodRuntimePolicy, RbacRulePlan, RoleBindingPlan, RolePlan, ServiceAccountPlan, - ServiceAccountSubjectPlan, ServicePlan, ServicePortPlan, StatefulSetPlan, WorkloadPlan, - WorkloadPlanError, + PodPlan, PodRuntimePolicy, RbacRulePlan, RoleBindingPlan, RolePlan, + ServiceAccountIdentityPlan, ServiceAccountPlan, ServiceAccountSubjectPlan, ServicePlan, + ServicePortPlan, StatefulSetPlan, WorkloadPlan, WorkloadPlanError, }; } diff --git a/crates/service-k8s/src/infrastructure/manifest/workload_plan.rs b/crates/service-k8s/src/infrastructure/manifest/workload_plan.rs index b8aef322..60d8a29b 100644 --- a/crates/service-k8s/src/infrastructure/manifest/workload_plan.rs +++ b/crates/service-k8s/src/infrastructure/manifest/workload_plan.rs @@ -14,6 +14,7 @@ mod access; mod network; mod pod; mod service; +mod service_account_identity; mod workload; pub use access::{ @@ -25,6 +26,7 @@ pub use network::{ }; pub use pod::{ContainerPlan, PodPlan, PodRuntimePolicy}; pub use service::{ServiceAccountPlan, ServicePlan, ServicePortPlan}; +pub use service_account_identity::ServiceAccountIdentityPlan; pub use workload::{ CronJobPlan, DaemonSetPlan, DeploymentPlan, PersistentVolumeClaimPlan, PodDisruptionBudgetPlan, StatefulSetPlan, @@ -42,6 +44,7 @@ fn merge_string_labels(mut base: Value, extra: &LabelSet) -> Value { enum PlannedObject { ServiceAccount(ServiceAccountPlan), + ServiceAccountIdentity(ServiceAccountIdentityPlan), Service(ServicePlan), StatefulSet(StatefulSetPlan), Deployment(DeploymentPlan), @@ -59,6 +62,8 @@ enum PlannedObject { pub enum WorkloadPlanError { #[error("workload {0} must have at least one replica")] ZeroReplicas(String), + #[error("service account {0} has invalid workload identity email '{1}': must match @.iam.gserviceaccount.com")] + InvalidWorkloadIdentity(String, String), } /// One ordered set of typed Kubernetes children for a managed service. @@ -79,6 +84,11 @@ impl<'a> WorkloadPlan<'a> { self.objects.push(PlannedObject::ServiceAccount(plan)); } + pub fn add_service_account_identity(&mut self, plan: ServiceAccountIdentityPlan) { + self.objects + .push(PlannedObject::ServiceAccountIdentity(plan)); + } + pub fn add_service(&mut self, plan: ServicePlan) { self.objects.push(PlannedObject::Service(plan)); } @@ -133,6 +143,9 @@ impl<'a> WorkloadPlan<'a> { "metadata": self.cx.meta(&plan.name, &plan.component), "automountServiceAccountToken": plan.automount_service_account_token, })), + PlannedObject::ServiceAccountIdentity(plan) => { + rendered.push(plan.render(self.cx)?); + } PlannedObject::Service(plan) => rendered.push(plan.render(self.cx)), PlannedObject::StatefulSet(plan) => rendered.push(plan.render(self.cx)?), PlannedObject::Deployment(plan) => rendered.extend(plan.render(self.cx)?), diff --git a/crates/service-k8s/src/infrastructure/manifest/workload_plan/service_account_identity.rs b/crates/service-k8s/src/infrastructure/manifest/workload_plan/service_account_identity.rs new file mode 100644 index 00000000..32a4edf1 --- /dev/null +++ b/crates/service-k8s/src/infrastructure/manifest/workload_plan/service_account_identity.rs @@ -0,0 +1,117 @@ +//! Typed ServiceAccount plan with metadata annotations (e.g. Workload Identity). + +use std::collections::BTreeMap; + +use serde_json::{json, Value}; + +use super::{RenderCtx, WorkloadPlanError}; + +/// The annotation key for Google Cloud Workload Identity on Kubernetes ServiceAccounts. +pub const WORKLOAD_IDENTITY_ANNOTATION: &str = "iam.gke.io/gcp-service-account"; + +/// Validate that a GCP service account email matches `@.iam.gserviceaccount.com`. +pub fn is_valid_workload_identity(email: &str) -> bool { + if email.is_empty() { + return false; + } + let Some((name, domain)) = email.split_once('@') else { + return false; + }; + if name.is_empty() || name.contains('@') { + return false; + } + let Some(project) = domain.strip_suffix(".iam.gserviceaccount.com") else { + return false; + }; + if project.is_empty() || project.contains('@') { + return false; + } + true +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ServiceAccountIdentityPlan { + pub name: String, + pub component: String, + pub annotations: BTreeMap, + pub automount_service_account_token: bool, +} + +impl ServiceAccountIdentityPlan { + pub fn new(name: impl Into, component: impl Into) -> Self { + Self { + name: name.into(), + component: component.into(), + annotations: BTreeMap::new(), + automount_service_account_token: false, + } + } + + pub fn with_annotation(mut self, key: impl Into, value: impl Into) -> Self { + self.annotations.insert(key.into(), value.into()); + self + } + + pub fn with_workload_identity(mut self, gcp_service_account_email: impl Into) -> Self { + let email = gcp_service_account_email.into(); + self.annotations + .insert(WORKLOAD_IDENTITY_ANNOTATION.into(), email); + self + } + + pub fn try_with_workload_identity( + mut self, + gcp_service_account_email: impl Into, + ) -> Result { + let email = gcp_service_account_email.into(); + if !is_valid_workload_identity(&email) { + return Err(WorkloadPlanError::InvalidWorkloadIdentity(self.name, email)); + } + self.annotations + .insert(WORKLOAD_IDENTITY_ANNOTATION.into(), email); + Ok(self) + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn component(&self) -> &str { + &self.component + } + + pub fn annotations(&self) -> &BTreeMap { + &self.annotations + } + + pub fn automount_service_account_token(&self) -> bool { + self.automount_service_account_token + } + + pub fn workload_identity(&self) -> Option<&str> { + self.annotations + .get(WORKLOAD_IDENTITY_ANNOTATION) + .map(String::as_str) + } + + pub(super) fn render(self, cx: &RenderCtx<'_>) -> Result { + for (key, value) in &self.annotations { + if key == WORKLOAD_IDENTITY_ANNOTATION && !is_valid_workload_identity(value) { + return Err(WorkloadPlanError::InvalidWorkloadIdentity( + self.name, + value.clone(), + )); + } + } + let mut metadata = cx.meta(&self.name, &self.component); + if !self.annotations.is_empty() { + metadata["annotations"] = json!(self.annotations); + } + Ok(json!({ + "apiVersion": "v1", + "kind": "ServiceAccount", + "metadata": metadata, + "automountServiceAccountToken": self.automount_service_account_token, + })) + } +} diff --git a/crates/service-k8s/tests/it/workload_plan.rs b/crates/service-k8s/tests/it/workload_plan.rs index 52eb1828..ad579dde 100644 --- a/crates/service-k8s/tests/it/workload_plan.rs +++ b/crates/service-k8s/tests/it/workload_plan.rs @@ -4,8 +4,9 @@ use serde_json::json; use service_k8s::render::{ ClusterRoleBindingPlan, ContainerPlan, CronJobPlan, DeploymentPlan, FqdnMatchPlan, FqdnNetworkPolicyPlan, NetworkPeerPlan, NetworkPolicyPlan, NetworkPortPlan, NetworkRulePlan, - PodPlan, PodRuntimePolicy, RbacRulePlan, RenderCtx, RoleBindingPlan, RolePlan, - ServiceAccountSubjectPlan, ServicePlan, ServicePortPlan, WorkloadPlan, + PodPlan, PodRuntimePolicy, ProjectedServiceAccountToken, RbacRulePlan, RenderCtx, + RoleBindingPlan, RolePlan, ServiceAccountIdentityPlan, ServiceAccountPlan, + ServiceAccountSubjectPlan, ServicePlan, ServicePortPlan, WorkloadPlan, WorkloadPlanError, }; fn context() -> RenderCtx<'static> { @@ -227,3 +228,298 @@ fn typed_plan_renders_role_role_binding_and_cron_job_from_constructors() { "OnFailure" ); } + +#[test] +fn typed_plan_renders_identity_separated_backup_and_restore_service_accounts() { + let cx = context(); + + let backup_sa = ServiceAccountIdentityPlan::new("sample-backup-sa", "backup") + .with_workload_identity("backup-agent@demo-project.iam.gserviceaccount.com"); + assert_eq!(backup_sa.name(), "sample-backup-sa"); + assert_eq!(backup_sa.component(), "backup"); + assert_eq!( + backup_sa.workload_identity(), + Some("backup-agent@demo-project.iam.gserviceaccount.com") + ); + assert_eq!(backup_sa.automount_service_account_token(), false); + + let restore_sa = ServiceAccountIdentityPlan::new("sample-restore-sa", "restore") + .with_workload_identity("restore-agent@demo-project.iam.gserviceaccount.com"); + assert_eq!(restore_sa.name(), "sample-restore-sa"); + assert_eq!(restore_sa.component(), "restore"); + assert_eq!( + restore_sa.workload_identity(), + Some("restore-agent@demo-project.iam.gserviceaccount.com") + ); + assert_eq!(restore_sa.automount_service_account_token(), false); + + let backup_role = RolePlan::new("sample-backup-role", "backup").with_rule(RbacRulePlan::new( + vec!["".into()], + vec!["pods".into()], + vec!["get".into(), "list".into()], + )); + let restore_role = + RolePlan::new("sample-restore-role", "restore").with_rule(RbacRulePlan::new( + vec!["apps".into()], + vec!["statefulsets".into()], + vec!["get".into(), "patch".into()], + )); + + // Assert the two Roles share no rule + let backup_rules = backup_role.rules(); + let restore_rules = restore_role.rules(); + for b_rule in backup_rules { + for r_rule in restore_rules { + assert_ne!(b_rule.api_groups(), r_rule.api_groups()); + assert_ne!(b_rule.resources(), r_rule.resources()); + } + } + + let backup_binding = + RoleBindingPlan::new("sample-backup-binding", "backup", "sample-backup-role") + .with_service_account(ServiceAccountSubjectPlan::new( + "observability", + "sample-backup-sa", + )); + + let restore_binding = + RoleBindingPlan::new("sample-restore-binding", "restore", "sample-restore-role") + .with_service_account(ServiceAccountSubjectPlan::new( + "observability", + "sample-restore-sa", + )); + + let mut plan = WorkloadPlan::new(&cx); + plan.add_service_account_identity(backup_sa); + plan.add_role(backup_role); + plan.add_role_binding(backup_binding); + plan.add_service_account_identity(restore_sa); + plan.add_role(restore_role); + plan.add_role_binding(restore_binding); + + let objects = plan.render().expect("render typed workload plan"); + assert_eq!(objects.len(), 6); + + // Backup ServiceAccount + assert_eq!(objects[0]["kind"], "ServiceAccount"); + assert_eq!(objects[0]["metadata"]["name"], "sample-backup-sa"); + assert_eq!( + objects[0]["metadata"]["annotations"]["iam.gke.io/gcp-service-account"], + "backup-agent@demo-project.iam.gserviceaccount.com" + ); + assert_eq!(objects[0]["automountServiceAccountToken"], false); + + // Backup Role & Binding + assert_eq!(objects[1]["kind"], "Role"); + assert_eq!(objects[1]["metadata"]["name"], "sample-backup-role"); + assert_eq!(objects[2]["kind"], "RoleBinding"); + assert_eq!(objects[2]["roleRef"]["name"], "sample-backup-role"); + assert_eq!( + objects[2]["subjects"], + json!([{ + "kind": "ServiceAccount", + "name": "sample-backup-sa", + "namespace": "observability" + }]) + ); + + // Restore ServiceAccount + assert_eq!(objects[3]["kind"], "ServiceAccount"); + assert_eq!(objects[3]["metadata"]["name"], "sample-restore-sa"); + assert_eq!( + objects[3]["metadata"]["annotations"]["iam.gke.io/gcp-service-account"], + "restore-agent@demo-project.iam.gserviceaccount.com" + ); + assert_eq!(objects[3]["automountServiceAccountToken"], false); + + // Restore Role & Binding + assert_eq!(objects[4]["kind"], "Role"); + assert_eq!(objects[4]["metadata"]["name"], "sample-restore-role"); + assert_eq!(objects[5]["kind"], "RoleBinding"); + assert_eq!(objects[5]["roleRef"]["name"], "sample-restore-role"); + assert_eq!( + objects[5]["subjects"], + json!([{ + "kind": "ServiceAccount", + "name": "sample-restore-sa", + "namespace": "observability" + }]) + ); +} + +#[test] +fn typed_plan_renders_pod_with_projected_service_account_token_and_no_secret_volume() { + let cx = context(); + let caller_audience = "https://admin.service.observability.svc.cluster.local"; + let token = ProjectedServiceAccountToken::new( + "admin-api-token", + "/var/run/secrets/service/tokens", + caller_audience, + ); + + let mut container = ContainerPlan::new("backup-agent", "backup:1", vec!["run-backup".into()]); + container.volume_mounts.push(token.mount()); + + let mut runtime = PodRuntimePolicy::restricted("sample-backup-sa", json!({})); + runtime.volumes.push(token.volume()); + + let pod = PodPlan::new("backup", container, runtime); + + let mut plan = WorkloadPlan::new(&cx); + plan.add_deployment(DeploymentPlan::new("sample-backup", 1, pod)); + + let objects = plan.render().expect("render typed workload plan"); + assert_eq!(objects.len(), 1); + let deployment = &objects[0]; + assert_eq!(deployment["kind"], "Deployment"); + + let pod_spec = &deployment["spec"]["template"]["spec"]; + + // Verify volumes: projected volume present, no Secret volume + let volumes = pod_spec["volumes"].as_array().expect("volumes array"); + assert_eq!(volumes.len(), 1); + let vol = &volumes[0]; + assert_eq!(vol["name"], "admin-api-token"); + assert!(vol.get("projected").is_some(), "volume must be projected"); + assert!( + vol.get("secret").is_none(), + "no Secret volume must be rendered" + ); + + let sources = vol["projected"]["sources"] + .as_array() + .expect("sources array"); + assert_eq!(sources.len(), 1); + let sa_token = &sources[0]["serviceAccountToken"]; + assert_eq!(sa_token["audience"], caller_audience); + let expiration_seconds = sa_token["expirationSeconds"].as_u64().expect("expiration"); + assert!( + expiration_seconds >= 600, + "expirationSeconds must be >= 600, got {expiration_seconds}" + ); + + // Verify container volume mounts + let mounts = pod_spec["containers"][0]["volumeMounts"] + .as_array() + .expect("volumeMounts"); + assert_eq!(mounts.len(), 1); + assert_eq!(mounts[0]["name"], "admin-api-token"); + assert_eq!(mounts[0]["mountPath"], "/var/run/secrets/service/tokens"); + assert_eq!(mounts[0]["readOnly"], true); +} + +#[test] +fn service_account_identity_without_annotations_omits_annotations_key_and_equals_service_account_plan( +) { + let cx = context(); + + let mut identity_plan = WorkloadPlan::new(&cx); + identity_plan.add_service_account_identity(ServiceAccountIdentityPlan::new( + "sample-identity-sa", + "agent", + )); + let identity_objects = identity_plan.render().expect("render identity plan"); + assert_eq!(identity_objects.len(), 1); + + let identity_rendered = &identity_objects[0]; + assert_eq!(identity_rendered["kind"], "ServiceAccount"); + assert_eq!(identity_rendered["metadata"]["name"], "sample-identity-sa"); + assert!( + identity_rendered["metadata"].get("annotations").is_none(), + "metadata.annotations must be omitted when no annotations are set" + ); + assert_eq!(identity_rendered["automountServiceAccountToken"], false); + + // Verify it equals the existing ServiceAccountPlan output + let mut baseline_plan = WorkloadPlan::new(&cx); + baseline_plan.add_service_account(ServiceAccountPlan::new("sample-identity-sa", "agent")); + let baseline_objects = baseline_plan.render().expect("render baseline plan"); + assert_eq!(baseline_objects.len(), 1); + + assert_eq!(identity_rendered, &baseline_objects[0]); +} + +#[test] +fn service_account_identity_rejects_empty_or_invalid_workload_identity_shape() { + let cx = context(); + + // Rejection of empty value via WorkloadPlan::render + let mut plan1 = WorkloadPlan::new(&cx); + plan1.add_service_account_identity( + ServiceAccountIdentityPlan::new("sample-sa", "backup").with_workload_identity(""), + ); + assert_eq!( + plan1.render(), + Err(WorkloadPlanError::InvalidWorkloadIdentity( + "sample-sa".into(), + "".into(), + )) + ); + + // Rejection of missing @ / invalid shape via WorkloadPlan::render + let mut plan2 = WorkloadPlan::new(&cx); + plan2.add_service_account_identity( + ServiceAccountIdentityPlan::new("sample-sa", "backup") + .with_workload_identity("not-an-email"), + ); + assert_eq!( + plan2.render(), + Err(WorkloadPlanError::InvalidWorkloadIdentity( + "sample-sa".into(), + "not-an-email".into(), + )) + ); + + // Rejection of invalid domain suffix via WorkloadPlan::render + let mut plan3 = WorkloadPlan::new(&cx); + plan3.add_service_account_identity( + ServiceAccountIdentityPlan::new("sample-sa", "backup") + .with_workload_identity("user@otherdomain.com"), + ); + assert_eq!( + plan3.render(), + Err(WorkloadPlanError::InvalidWorkloadIdentity( + "sample-sa".into(), + "user@otherdomain.com".into(), + )) + ); + + // Rejection of empty name via WorkloadPlan::render + let mut plan4 = WorkloadPlan::new(&cx); + plan4.add_service_account_identity( + ServiceAccountIdentityPlan::new("sample-sa", "backup") + .with_workload_identity("@demo-project.iam.gserviceaccount.com"), + ); + assert_eq!( + plan4.render(), + Err(WorkloadPlanError::InvalidWorkloadIdentity( + "sample-sa".into(), + "@demo-project.iam.gserviceaccount.com".into(), + )) + ); + + // Rejection of empty project via WorkloadPlan::render + let mut plan5 = WorkloadPlan::new(&cx); + plan5.add_service_account_identity( + ServiceAccountIdentityPlan::new("sample-sa", "backup") + .with_workload_identity("backup@.iam.gserviceaccount.com"), + ); + assert_eq!( + plan5.render(), + Err(WorkloadPlanError::InvalidWorkloadIdentity( + "sample-sa".into(), + "backup@.iam.gserviceaccount.com".into(), + )) + ); + + // Also covers builder return Result via try_with_workload_identity + let builder_err = ServiceAccountIdentityPlan::new("sample-sa", "backup") + .try_with_workload_identity("invalid-shape"); + assert_eq!( + builder_err, + Err(WorkloadPlanError::InvalidWorkloadIdentity( + "sample-sa".into(), + "invalid-shape".into(), + )) + ); +}