1use std::future::Future;
46use std::pin::Pin;
47use std::sync::Arc;
48
49use base64::Engine as _;
50use base64::engine::general_purpose::URL_SAFE_NO_PAD;
51use chrono::{DateTime, Utc};
52use greentic_deploy_spec::SecretRef;
53use zeroize::Zeroizing;
54
55use crate::credentials::{
56 BootstrapError, BootstrapInput, BootstrapOutcome, Capability, CapabilityCheck,
57 CapabilityStatus, DeployerCredentials, RequirementsReport, RulesPack, ValidationContext,
58};
59
60use super::async_bridge::run_k8s_async;
61use super::bootstrap::{
62 DEPLOYER_IDENTITY_SECRET_NAME, DEPLOYER_SERVICE_ACCOUNT, DEPLOYER_TOKEN_STORE_PATH,
63 K8S_RBAC_MANIFEST_FILENAME, K8sRulesPackInput, render_min_rbac_rules_pack,
64};
65use super::manifests::namespace_for_env;
66
67const BIND_TOKEN_EXPIRATION_SECONDS: i64 = 365 * 24 * 60 * 60;
75
76pub const K8S_API_REACHABLE_CAP: &str = "k8s.api.reachable";
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub struct K8sOperation {
83 pub group: &'static str,
85 pub resource: &'static str,
86 pub verb: &'static str,
87}
88
89impl K8sOperation {
90 pub fn capability_id(&self) -> String {
92 let group = if self.group.is_empty() {
93 "core"
94 } else {
95 self.group
96 };
97 format!("k8s.rbac.allow:{group}/{}:{}", self.resource, self.verb)
98 }
99}
100
101const fn op(group: &'static str, resource: &'static str, verb: &'static str) -> K8sOperation {
103 K8sOperation {
104 group,
105 resource,
106 verb,
107 }
108}
109
110pub const VALIDATED_K8S_OPERATIONS: &[K8sOperation] = &[
129 op("apps", "deployments", "get"),
130 op("apps", "deployments", "create"),
131 op("apps", "deployments", "patch"),
132 op("apps", "deployments", "delete"),
133 op("", "services", "get"),
134 op("", "services", "create"),
135 op("", "services", "patch"),
136 op("", "services", "delete"),
137 op("", "configmaps", "get"),
138 op("", "configmaps", "create"),
139 op("", "configmaps", "patch"),
140 op("", "secrets", "get"),
146 op("", "secrets", "create"),
147 op("", "secrets", "patch"),
148 op("", "secrets", "delete"),
149 op("", "serviceaccounts", "get"),
154 op("", "serviceaccounts", "create"),
155 op("", "serviceaccounts", "patch"),
156 op("policy", "poddisruptionbudgets", "get"),
157 op("policy", "poddisruptionbudgets", "create"),
158 op("policy", "poddisruptionbudgets", "patch"),
159 op("networking.k8s.io", "networkpolicies", "get"),
160 op("networking.k8s.io", "networkpolicies", "create"),
161 op("networking.k8s.io", "networkpolicies", "patch"),
162];
163
164#[derive(Debug, Clone, PartialEq, Eq)]
166pub struct ClusterIdentity {
167 pub user: String,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq)]
173pub enum AccessDecision {
174 Allowed,
175 Denied(String),
177}
178
179#[derive(Debug, Clone, PartialEq, Eq)]
181pub struct OperationDecision {
182 pub operation: K8sOperation,
183 pub decision: AccessDecision,
184}
185
186#[derive(Debug, thiserror::Error)]
191pub enum K8sClientError {
192 #[error("no usable Kubernetes credentials: {0}")]
193 NoClusterAccess(String),
194 #[error("Kubernetes API rejected the call: {0}")]
195 ApiRejected(String),
196 #[error("Kubernetes API transport error: {0}")]
197 Transport(String),
198 #[error("in-cluster identity mismatch: {0}")]
202 IdentityMismatch(String),
203}
204
205#[async_trait::async_trait]
208pub trait K8sValidatorClient: std::fmt::Debug + Send + Sync {
209 async fn who_am_i(&self) -> Result<ClusterIdentity, K8sClientError>;
211
212 async fn review_access<'a>(
218 &'a self,
219 namespace: &'a str,
220 operations: &'a [K8sOperation],
221 ) -> Result<Vec<OperationDecision>, K8sClientError>;
222}
223
224pub type K8sValidatorConnectFut =
226 Pin<Box<dyn Future<Output = Result<Arc<dyn K8sValidatorClient>, K8sClientError>> + Send>>;
227
228pub type K8sValidatorConnector = Arc<dyn Fn() -> K8sValidatorConnectFut + Send + Sync>;
239
240pub struct MintedToken {
246 pub token: String,
247 pub expiration: Option<chrono::DateTime<chrono::Utc>>,
248}
249
250impl std::fmt::Debug for MintedToken {
251 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
252 f.debug_struct("MintedToken")
254 .field("token", &"<redacted>")
255 .field("expiration", &self.expiration)
256 .finish()
257 }
258}
259
260pub type K8sBootstrapConnectFut =
262 Pin<Box<dyn Future<Output = Result<Arc<dyn K8sBootstrapClient>, K8sClientError>> + Send>>;
263
264pub type K8sBootstrapConnector = Arc<dyn Fn() -> K8sBootstrapConnectFut + Send + Sync>;
269
270#[async_trait::async_trait]
277pub trait K8sBootstrapClient: std::fmt::Debug + Send + Sync {
278 async fn apply_rbac(&self, manifest_yaml: &str) -> Result<(), K8sClientError>;
282
283 async fn mint_service_account_token(
287 &self,
288 namespace: &str,
289 service_account: &str,
290 expiration_seconds: i64,
291 ) -> Result<MintedToken, K8sClientError>;
292
293 async fn apply_identity_secret(
300 &self,
301 namespace: &str,
302 name: &str,
303 env_id: &str,
304 bearer: &str,
305 ) -> Result<(), K8sClientError>;
306
307 async fn delete_identity_secret(
311 &self,
312 namespace: &str,
313 name: &str,
314 ) -> Result<(), K8sClientError>;
315}
316
317#[derive(Default)]
325pub struct K8sDeployerCredentials {
326 connect: Option<K8sValidatorConnector>,
327 namespace: Option<String>,
333 bind: Option<K8sBootstrapConnector>,
340}
341
342impl std::fmt::Debug for K8sDeployerCredentials {
343 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
344 f.debug_struct("K8sDeployerCredentials")
347 .field("connect", &self.connect.is_some())
348 .field("namespace", &self.namespace)
349 .field("bind", &self.bind.is_some())
350 .finish()
351 }
352}
353
354impl K8sDeployerCredentials {
355 pub fn with_client(client: Arc<dyn K8sValidatorClient>) -> Self {
358 Self::with_connector(Arc::new(move || -> K8sValidatorConnectFut {
359 let client = client.clone();
360 Box::pin(async move { Ok(client) })
361 }))
362 }
363
364 pub fn with_connector(connect: K8sValidatorConnector) -> Self {
368 Self {
369 connect: Some(connect),
370 namespace: None,
371 bind: None,
372 }
373 }
374
375 pub fn with_bootstrap_connector(bind: K8sBootstrapConnector) -> Self {
381 Self {
382 connect: None,
383 namespace: None,
384 bind: Some(bind),
385 }
386 }
387
388 pub fn in_namespace(mut self, namespace: impl Into<String>) -> Self {
391 self.namespace = Some(namespace.into());
392 self
393 }
394
395 fn reachable_capability(&self) -> Capability {
396 Capability::new(
397 K8S_API_REACHABLE_CAP,
398 "Kubernetes API is reachable and the credential resolves to an identity \
399 (SelfSubjectReview)",
400 )
401 }
402
403 fn operation_capability(&self, operation: &K8sOperation) -> Capability {
404 Capability::new(
405 operation.capability_id(),
406 format!(
407 "RBAC allows `{}` on `{}` in the env namespace",
408 operation.verb, operation.resource
409 ),
410 )
411 }
412
413 fn reachable_pass_ops_failed(&self, reason: &str) -> RequirementsReport {
417 let mut checks = Vec::with_capacity(1 + VALIDATED_K8S_OPERATIONS.len());
418 checks.push(CapabilityCheck {
419 capability: self.reachable_capability(),
420 status: CapabilityStatus::Pass,
421 });
422 for operation in VALIDATED_K8S_OPERATIONS {
423 checks.push(CapabilityCheck {
424 capability: self.operation_capability(operation),
425 status: CapabilityStatus::Fail {
426 reason: reason.to_string(),
427 },
428 });
429 }
430 RequirementsReport::new(checks)
431 }
432}
433
434fn decode_token_lifetime(bearer: &str) -> Option<(DateTime<Utc>, DateTime<Utc>)> {
439 let payload_b64 = bearer.split('.').nth(1)?;
440 let bytes = URL_SAFE_NO_PAD.decode(payload_b64).ok()?;
441 let claims: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
442 let iat = DateTime::from_timestamp(claims.get("iat")?.as_i64()?, 0)?;
443 let exp = DateTime::from_timestamp(claims.get("exp")?.as_i64()?, 0)?;
444 Some((iat, exp))
445}
446
447impl DeployerCredentials for K8sDeployerCredentials {
448 fn requires_credentials_material(&self) -> bool {
449 true
450 }
451
452 fn rotate_at(&self, material: &str) -> Option<DateTime<Utc>> {
458 let (iat, exp) = decode_token_lifetime(material)?;
459 Some(crate::credentials::rotate::rotate_at_from_window(iat, exp))
460 }
461
462 fn required_capabilities(&self) -> Vec<Capability> {
463 let mut caps = Vec::with_capacity(1 + VALIDATED_K8S_OPERATIONS.len());
464 caps.push(self.reachable_capability());
465 for operation in VALIDATED_K8S_OPERATIONS {
466 caps.push(self.operation_capability(operation));
467 }
468 caps
469 }
470
471 fn validate(&self, ctx: &ValidationContext<'_>) -> RequirementsReport {
472 let caps = self.required_capabilities();
473
474 let Some(connect) = self.connect.as_ref() else {
475 return RequirementsReport::new(
483 caps.into_iter()
484 .map(|capability| CapabilityCheck {
485 capability,
486 status: CapabilityStatus::Fail {
487 reason: "no Kubernetes API client is bound to these \
488 credentials; `gtc op credentials requirements` \
489 connects a live client when built with the \
490 `k8s-client` feature — failing closed"
491 .to_string(),
492 },
493 })
494 .collect(),
495 );
496 };
497
498 let namespace = self
502 .namespace
503 .clone()
504 .unwrap_or_else(|| namespace_for_env(ctx.env_id));
505
506 let connector = Arc::clone(connect);
512 let decisions = match run_k8s_async(async move {
513 let connect_fn = connector.as_ref();
514 let client = connect_fn().await.map_err(K8sProbeError::Connect)?;
515 client.who_am_i().await.map_err(K8sProbeError::Identity)?;
516 client
517 .review_access(&namespace, VALIDATED_K8S_OPERATIONS)
518 .await
519 .map_err(K8sProbeError::Access)
520 }) {
521 Ok(v) => v,
522 Err(K8sProbeError::Connect(e)) => {
523 return all_failed(&caps, &format!("Kubernetes API unreachable: {e}"));
527 }
528 Err(K8sProbeError::Identity(e)) => {
529 return all_failed(&caps, &format!("SelfSubjectReview failed: {e}"));
533 }
534 Err(K8sProbeError::Access(e)) => {
535 return self
536 .reachable_pass_ops_failed(&format!("SelfSubjectAccessReview failed: {e}"));
537 }
538 };
539
540 if decisions.len() != VALIDATED_K8S_OPERATIONS.len() {
543 return self.reachable_pass_ops_failed(&format!(
544 "SelfSubjectAccessReview returned {} decisions for {} operations",
545 decisions.len(),
546 VALIDATED_K8S_OPERATIONS.len()
547 ));
548 }
549 for (i, (expected, actual)) in VALIDATED_K8S_OPERATIONS
550 .iter()
551 .zip(decisions.iter())
552 .enumerate()
553 {
554 if actual.operation != *expected {
555 return self.reachable_pass_ops_failed(&format!(
556 "SelfSubjectAccessReview decision[{i}] operation mismatch: \
557 expected `{}`, got `{}`",
558 expected.capability_id(),
559 actual.operation.capability_id()
560 ));
561 }
562 }
563
564 let mut checks = Vec::with_capacity(1 + decisions.len());
565 checks.push(CapabilityCheck {
566 capability: self.reachable_capability(),
567 status: CapabilityStatus::Pass,
568 });
569 for (operation, decision) in VALIDATED_K8S_OPERATIONS.iter().zip(decisions.iter()) {
570 let status = match &decision.decision {
571 AccessDecision::Allowed => CapabilityStatus::Pass,
572 AccessDecision::Denied(reason) => CapabilityStatus::Fail {
573 reason: format!(
574 "RBAC denied `{}` on `{}` ({reason})",
575 operation.verb, operation.resource
576 ),
577 },
578 };
579 checks.push(CapabilityCheck {
580 capability: self.operation_capability(operation),
581 status,
582 });
583 }
584 RequirementsReport::new(checks)
585 }
586
587 fn bootstrap(&self, input: &BootstrapInput<'_>) -> Result<BootstrapOutcome, BootstrapError> {
588 let admin_context = input.admin.profile();
592 if admin_context.is_empty() {
593 return Err(BootstrapError::AdminRejected(
594 "K8s bootstrap requires --admin-profile to identify the kubeconfig context \
595 (or admin identity) that will apply the rules pack."
596 .to_string(),
597 ));
598 }
599
600 let namespace = self
608 .namespace
609 .clone()
610 .unwrap_or_else(|| namespace_for_env(input.env_id));
611 let rules_pack = render_min_rbac_rules_pack(&K8sRulesPackInput {
612 env_id: input.env_id.as_str(),
613 namespace: &namespace,
614 admin_context_hint: admin_context,
615 operations: VALIDATED_K8S_OPERATIONS,
616 });
617
618 let Some(bind) = self.bind.as_ref() else {
622 return Ok(BootstrapOutcome {
623 rules_pack,
624 bound_credentials_ref: None,
625 bound_expiry: None,
626 bound_secret_material: None,
627 });
628 };
629
630 let manifest_yaml = rbac_manifest_from_pack(&rules_pack).ok_or_else(|| {
637 BootstrapError::ProvisioningFailed {
638 step: "render-rbac".to_string(),
639 message: format!(
640 "rendered rules pack is missing the `{K8S_RBAC_MANIFEST_FILENAME}` entry"
641 ),
642 }
643 })?;
644 let connector = Arc::clone(bind);
645 let env_id_label = input.env_id.as_str().to_string();
646 let minted = run_k8s_async(async move {
647 let client = connector().await?;
648 client.apply_rbac(&manifest_yaml).await?;
649 let minted = client
650 .mint_service_account_token(
651 &namespace,
652 DEPLOYER_SERVICE_ACCOUNT,
653 BIND_TOKEN_EXPIRATION_SECONDS,
654 )
655 .await?;
656 client
661 .apply_identity_secret(
662 &namespace,
663 DEPLOYER_IDENTITY_SECRET_NAME,
664 &env_id_label,
665 &minted.token,
666 )
667 .await?;
668 Ok(minted)
669 })
670 .map_err(|e: K8sClientError| BootstrapError::ProvisioningFailed {
671 step: "k8s-bind".to_string(),
672 message: e.to_string(),
673 })?;
674
675 let bound_ref = SecretRef::try_new(format!(
676 "secret://{}/{}",
677 input.env_id.as_str(),
678 DEPLOYER_TOKEN_STORE_PATH
679 ))
680 .map_err(|e| BootstrapError::ProvisioningFailed {
681 step: "bind-ref".to_string(),
682 message: format!("bound credentials ref is not well-formed: {e}"),
683 })?;
684
685 Ok(BootstrapOutcome {
686 rules_pack,
687 bound_credentials_ref: Some(bound_ref),
688 bound_expiry: minted.expiration,
689 bound_secret_material: Some(Zeroizing::new(minted.token)),
690 })
691 }
692
693 fn rollback_bound_material(&self, env_id: &greentic_deploy_spec::EnvId) {
694 let Some(bind) = self.bind.as_ref() else {
696 return;
697 };
698 let namespace = self
701 .namespace
702 .clone()
703 .unwrap_or_else(|| namespace_for_env(env_id));
704 let connector = Arc::clone(bind);
705 let _ = run_k8s_async(async move {
710 let client = connector().await?;
711 client
712 .delete_identity_secret(&namespace, DEPLOYER_IDENTITY_SECRET_NAME)
713 .await
714 });
715 }
716}
717
718fn rbac_manifest_from_pack(rules_pack: &RulesPack) -> Option<String> {
722 rules_pack
723 .entries
724 .iter()
725 .find(|entry| entry.filename == K8S_RBAC_MANIFEST_FILENAME)
726 .map(|entry| entry.content.clone())
727}
728
729enum K8sProbeError {
734 Connect(K8sClientError),
735 Identity(K8sClientError),
736 Access(K8sClientError),
737}
738
739fn all_failed(caps: &[Capability], reason: &str) -> RequirementsReport {
741 RequirementsReport::new(
742 caps.iter()
743 .map(|c| CapabilityCheck {
744 capability: c.clone(),
745 status: CapabilityStatus::Fail {
746 reason: reason.to_string(),
747 },
748 })
749 .collect(),
750 )
751}
752
753#[cfg(test)]
754mod tests {
755 use super::*;
756 use crate::credentials::ZeroizedAdmin;
757 use greentic_deploy_spec::{EnvId, EnvironmentHostConfig};
758 use std::path::Path;
759 use std::sync::Mutex;
760 use tempfile::tempdir;
761
762 fn default_host_config(env_id: &EnvId) -> EnvironmentHostConfig {
763 EnvironmentHostConfig {
764 env_id: env_id.clone(),
765 region: None,
766 tenant_org_id: None,
767 listen_addr: None,
768 public_base_url: None,
769 gui_enabled: None,
770 }
771 }
772
773 fn ctx<'a>(
774 env_root: &'a Path,
775 env_id: &'a EnvId,
776 host_config: &'a EnvironmentHostConfig,
777 ) -> ValidationContext<'a> {
778 ValidationContext {
779 env_id,
780 env_root,
781 host_config,
782 }
783 }
784
785 #[derive(Debug, Default)]
786 struct MockK8sClient {
787 identity_response: Mutex<Option<Result<ClusterIdentity, K8sClientError>>>,
788 review_response: Mutex<Option<Result<Vec<OperationDecision>, K8sClientError>>>,
789 review_calls: Mutex<Vec<(String, usize)>>,
790 }
791
792 impl MockK8sClient {
793 fn with_identity(self, r: Result<ClusterIdentity, K8sClientError>) -> Self {
794 *self.identity_response.lock().unwrap() = Some(r);
795 self
796 }
797 fn with_review(self, r: Result<Vec<OperationDecision>, K8sClientError>) -> Self {
798 *self.review_response.lock().unwrap() = Some(r);
799 self
800 }
801 }
802
803 #[async_trait::async_trait]
804 impl K8sValidatorClient for MockK8sClient {
805 async fn who_am_i(&self) -> Result<ClusterIdentity, K8sClientError> {
806 self.identity_response
807 .lock()
808 .unwrap()
809 .take()
810 .expect("test must wire identity_response")
811 }
812
813 async fn review_access<'a>(
814 &'a self,
815 namespace: &'a str,
816 operations: &'a [K8sOperation],
817 ) -> Result<Vec<OperationDecision>, K8sClientError> {
818 self.review_calls
819 .lock()
820 .unwrap()
821 .push((namespace.to_string(), operations.len()));
822 self.review_response
823 .lock()
824 .unwrap()
825 .take()
826 .expect("test must wire review_response")
827 }
828 }
829
830 fn identity() -> ClusterIdentity {
831 ClusterIdentity {
832 user: "system:serviceaccount:gtc-zain-prod:greentic-deployer".into(),
833 }
834 }
835
836 fn all_allowed() -> Vec<OperationDecision> {
837 VALIDATED_K8S_OPERATIONS
838 .iter()
839 .map(|operation| OperationDecision {
840 operation: *operation,
841 decision: AccessDecision::Allowed,
842 })
843 .collect()
844 }
845
846 #[test]
847 fn required_capabilities_cover_reachable_plus_every_operation() {
848 let creds = K8sDeployerCredentials::default();
849 let ids: Vec<String> = creds
850 .required_capabilities()
851 .into_iter()
852 .map(|c| c.id)
853 .collect();
854 assert_eq!(ids.len(), 1 + VALIDATED_K8S_OPERATIONS.len());
855 assert_eq!(ids[0], K8S_API_REACHABLE_CAP);
856 assert!(ids.contains(&"k8s.rbac.allow:core/services:create".to_string()));
858 assert!(ids.contains(&"k8s.rbac.allow:apps/deployments:delete".to_string()));
859 assert!(
860 ids.contains(&"k8s.rbac.allow:networking.k8s.io/networkpolicies:patch".to_string())
861 );
862 }
863
864 #[test]
869 fn validate_without_a_client_fails_closed() {
870 let creds = K8sDeployerCredentials::default();
871 let env_id = EnvId::try_from("zain-prod").unwrap();
872 let hc = default_host_config(&env_id);
873 let dir = tempdir().unwrap();
874 let report = creds.validate(&ctx(dir.path(), &env_id, &hc));
875 assert!(!report.passed(), "no-client report must NOT pass");
876 assert_eq!(
877 report.missing().len(),
878 creds.required_capabilities().len(),
879 "every Fail cap must be recorded as missing"
880 );
881 for check in &report.checks {
882 match &check.status {
883 CapabilityStatus::Fail { reason } => {
884 assert!(
885 reason.contains("no Kubernetes API client is bound"),
886 "reason must mention the missing client: {reason}"
887 );
888 }
889 other => panic!("expected Fail, got {other:?}"),
890 }
891 }
892 }
893
894 #[test]
895 fn validate_passes_when_identity_resolves_and_all_ops_allowed() {
896 let mock = Arc::new(
897 MockK8sClient::default()
898 .with_identity(Ok(identity()))
899 .with_review(Ok(all_allowed())),
900 );
901 let creds = K8sDeployerCredentials::with_client(mock.clone());
902 let env_id = EnvId::try_from("zain-prod").unwrap();
903 let hc = default_host_config(&env_id);
904 let dir = tempdir().unwrap();
905 let report = creds.validate(&ctx(dir.path(), &env_id, &hc));
906 assert!(report.passed(), "report: {report:?}");
907 assert!(report.missing().is_empty());
908 let calls = mock.review_calls.lock().unwrap();
911 assert_eq!(calls.len(), 1);
912 assert_eq!(calls[0].0, "gtc-zain-prod");
913 assert_eq!(calls[0].1, VALIDATED_K8S_OPERATIONS.len());
914 }
915
916 #[test]
920 fn validate_scopes_ssars_to_the_overridden_namespace() {
921 let mock = Arc::new(
922 MockK8sClient::default()
923 .with_identity(Ok(identity()))
924 .with_review(Ok(all_allowed())),
925 );
926 let creds = K8sDeployerCredentials::with_client(mock.clone()).in_namespace("custom-ns");
927 let env_id = EnvId::try_from("zain-prod").unwrap();
928 let hc = default_host_config(&env_id);
929 let dir = tempdir().unwrap();
930 let report = creds.validate(&ctx(dir.path(), &env_id, &hc));
931 assert!(report.passed(), "report: {report:?}");
932 let calls = mock.review_calls.lock().unwrap();
933 assert_eq!(calls.len(), 1);
934 assert_eq!(
935 calls[0].0, "custom-ns",
936 "SSARs must target the deploy namespace, not gtc-zain-prod"
937 );
938 }
939
940 #[test]
941 fn validate_fails_the_specific_denied_operation() {
942 let decisions: Vec<OperationDecision> = VALIDATED_K8S_OPERATIONS
943 .iter()
944 .map(|operation| OperationDecision {
945 operation: *operation,
946 decision: if operation.resource == "deployments" && operation.verb == "delete" {
947 AccessDecision::Denied("no RBAC rule matched".into())
948 } else {
949 AccessDecision::Allowed
950 },
951 })
952 .collect();
953 let mock = Arc::new(
954 MockK8sClient::default()
955 .with_identity(Ok(identity()))
956 .with_review(Ok(decisions)),
957 );
958 let creds = K8sDeployerCredentials::with_client(mock);
959 let env_id = EnvId::try_from("zain-prod").unwrap();
960 let hc = default_host_config(&env_id);
961 let dir = tempdir().unwrap();
962 let report = creds.validate(&ctx(dir.path(), &env_id, &hc));
963 assert!(!report.passed());
964 assert_eq!(
965 report.missing(),
966 vec!["k8s.rbac.allow:apps/deployments:delete".to_string()]
967 );
968 let denied = report
969 .checks
970 .iter()
971 .find(|c| c.capability.id == "k8s.rbac.allow:apps/deployments:delete")
972 .unwrap();
973 match &denied.status {
974 CapabilityStatus::Fail { reason } => {
975 assert!(reason.contains("no RBAC rule matched"), "reason: {reason}");
976 }
977 other => panic!("expected Fail, got {other:?}"),
978 }
979 }
980
981 #[test]
982 fn validate_fails_every_cap_when_identity_does_not_resolve() {
983 let mock = Arc::new(MockK8sClient::default().with_identity(Err(
984 K8sClientError::NoClusterAccess("kubeconfig has no current context".into()),
985 )));
986 let creds = K8sDeployerCredentials::with_client(mock);
987 let env_id = EnvId::try_from("zain-prod").unwrap();
988 let hc = default_host_config(&env_id);
989 let dir = tempdir().unwrap();
990 let report = creds.validate(&ctx(dir.path(), &env_id, &hc));
991 assert!(!report.passed());
992 for check in &report.checks {
993 match &check.status {
994 CapabilityStatus::Fail { reason } => {
995 assert!(reason.contains("kubeconfig has no current context"));
996 }
997 other => panic!("expected Fail, got {other:?}"),
998 }
999 }
1000 }
1001
1002 #[test]
1003 fn validate_passes_reachable_but_fails_ops_when_review_errors() {
1004 let mock = Arc::new(
1005 MockK8sClient::default()
1006 .with_identity(Ok(identity()))
1007 .with_review(Err(K8sClientError::Transport("connection reset".into()))),
1008 );
1009 let creds = K8sDeployerCredentials::with_client(mock);
1010 let env_id = EnvId::try_from("zain-prod").unwrap();
1011 let hc = default_host_config(&env_id);
1012 let dir = tempdir().unwrap();
1013 let report = creds.validate(&ctx(dir.path(), &env_id, &hc));
1014 assert!(!report.passed());
1015 let reachable = report
1016 .checks
1017 .iter()
1018 .find(|c| c.capability.id == K8S_API_REACHABLE_CAP)
1019 .unwrap();
1020 assert!(matches!(reachable.status, CapabilityStatus::Pass));
1021 for check in report.checks.iter().skip(1) {
1022 match &check.status {
1023 CapabilityStatus::Fail { reason } => {
1024 assert!(reason.contains("connection reset"), "reason: {reason}");
1025 }
1026 other => panic!("expected Fail, got {other:?}"),
1027 }
1028 }
1029 }
1030
1031 #[test]
1034 fn validate_fails_closed_on_truncated_review_response() {
1035 let mock = Arc::new(
1036 MockK8sClient::default()
1037 .with_identity(Ok(identity()))
1038 .with_review(Ok(vec![])),
1039 );
1040 let creds = K8sDeployerCredentials::with_client(mock);
1041 let env_id = EnvId::try_from("zain-prod").unwrap();
1042 let hc = default_host_config(&env_id);
1043 let dir = tempdir().unwrap();
1044 let report = creds.validate(&ctx(dir.path(), &env_id, &hc));
1045 assert!(!report.passed(), "truncated response must not pass");
1046 let reachable = report
1048 .checks
1049 .iter()
1050 .find(|c| c.capability.id == K8S_API_REACHABLE_CAP)
1051 .unwrap();
1052 assert!(matches!(reachable.status, CapabilityStatus::Pass));
1053 let op_checks: Vec<_> = report
1054 .checks
1055 .iter()
1056 .filter(|c| c.capability.id != K8S_API_REACHABLE_CAP)
1057 .collect();
1058 assert_eq!(op_checks.len(), VALIDATED_K8S_OPERATIONS.len());
1059 for check in &op_checks {
1060 match &check.status {
1061 CapabilityStatus::Fail { reason } => {
1062 assert!(
1063 reason.contains("0 decisions for"),
1064 "reason must mention the count mismatch: {reason}"
1065 );
1066 }
1067 other => panic!("expected Fail, got {other:?}"),
1068 }
1069 }
1070 assert_eq!(
1071 report.missing().len(),
1072 VALIDATED_K8S_OPERATIONS.len(),
1073 "every operation cap must be missing"
1074 );
1075 }
1076
1077 #[test]
1079 fn validate_fails_closed_on_mismatched_operation_order() {
1080 let mut decisions = all_allowed();
1081 decisions.swap(0, 1);
1083 let mock = Arc::new(
1084 MockK8sClient::default()
1085 .with_identity(Ok(identity()))
1086 .with_review(Ok(decisions)),
1087 );
1088 let creds = K8sDeployerCredentials::with_client(mock);
1089 let env_id = EnvId::try_from("zain-prod").unwrap();
1090 let hc = default_host_config(&env_id);
1091 let dir = tempdir().unwrap();
1092 let report = creds.validate(&ctx(dir.path(), &env_id, &hc));
1093 assert!(!report.passed(), "mismatched operations must not pass");
1094 let reachable = report
1095 .checks
1096 .iter()
1097 .find(|c| c.capability.id == K8S_API_REACHABLE_CAP)
1098 .unwrap();
1099 assert!(matches!(reachable.status, CapabilityStatus::Pass));
1100 let op_checks: Vec<_> = report
1102 .checks
1103 .iter()
1104 .filter(|c| c.capability.id != K8S_API_REACHABLE_CAP)
1105 .collect();
1106 assert_eq!(op_checks.len(), VALIDATED_K8S_OPERATIONS.len());
1107 for check in &op_checks {
1108 match &check.status {
1109 CapabilityStatus::Fail { reason } => {
1110 assert!(
1111 reason.contains("mismatch"),
1112 "reason must mention the mismatch: {reason}"
1113 );
1114 }
1115 other => panic!("expected Fail, got {other:?}"),
1116 }
1117 }
1118 }
1119
1120 #[test]
1121 fn bootstrap_rejects_empty_admin_profile() {
1122 let creds = K8sDeployerCredentials::default();
1123 let env_id = EnvId::try_from("zain-prod").unwrap();
1124 let dir = tempdir().unwrap();
1125 let admin = ZeroizedAdmin::new("", "irrelevant".to_string());
1126 let input = BootstrapInput {
1127 env_id: &env_id,
1128 env_root: dir.path(),
1129 admin: &admin,
1130 };
1131 let err = creds.bootstrap(&input).unwrap_err();
1132 match err {
1133 BootstrapError::AdminRejected(msg) => {
1134 assert!(msg.contains("--admin-profile"), "msg: {msg}");
1135 }
1136 other => panic!("expected AdminRejected, got {other:?}"),
1137 }
1138 }
1139
1140 #[test]
1141 fn bootstrap_returns_rules_pack_without_binding_credentials() {
1142 let creds = K8sDeployerCredentials::default();
1143 let env_id = EnvId::try_from("zain-prod").unwrap();
1144 let dir = tempdir().unwrap();
1145 let admin = ZeroizedAdmin::new("zain-admin@nonprod-cluster", String::new());
1146 let input = BootstrapInput {
1147 env_id: &env_id,
1148 env_root: dir.path(),
1149 admin: &admin,
1150 };
1151 let outcome = creds.bootstrap(&input).expect("bootstrap renders");
1152 assert!(
1153 outcome.bound_credentials_ref.is_none(),
1154 "K8s bootstrap must not bind credentials directly — the admin \
1155 applies the rules pack and binds via rotate"
1156 );
1157 assert!(!outcome.rules_pack.is_empty());
1158 let combined: String = outcome
1159 .rules_pack
1160 .entries
1161 .iter()
1162 .map(|e| e.content.as_str())
1163 .collect::<Vec<_>>()
1164 .join("\n");
1165 for operation in VALIDATED_K8S_OPERATIONS {
1168 assert!(
1169 combined.contains(operation.verb),
1170 "rules pack must mention verb `{}`",
1171 operation.verb
1172 );
1173 }
1174 assert!(combined.contains("zain-admin@nonprod-cluster"));
1175 assert!(combined.contains("gtc-zain-prod"));
1176 }
1177
1178 #[derive(Debug, Default)]
1181 struct MockBootstrapClient {
1182 applied_manifests: Mutex<Vec<String>>,
1183 minted_namespace: Mutex<Option<String>>,
1184 mint_response: Mutex<Option<Result<MintedToken, K8sClientError>>>,
1185 identity_secret: Mutex<Option<(String, String, String, String)>>,
1188 deleted_identity_secret: Mutex<Option<(String, String)>>,
1191 }
1192
1193 impl MockBootstrapClient {
1194 fn with_mint(self, r: Result<MintedToken, K8sClientError>) -> Self {
1195 *self.mint_response.lock().unwrap() = Some(r);
1196 self
1197 }
1198 }
1199
1200 #[async_trait::async_trait]
1201 impl K8sBootstrapClient for MockBootstrapClient {
1202 async fn apply_rbac(&self, manifest_yaml: &str) -> Result<(), K8sClientError> {
1203 self.applied_manifests
1204 .lock()
1205 .unwrap()
1206 .push(manifest_yaml.to_string());
1207 Ok(())
1208 }
1209
1210 async fn mint_service_account_token(
1211 &self,
1212 namespace: &str,
1213 _service_account: &str,
1214 _expiration_seconds: i64,
1215 ) -> Result<MintedToken, K8sClientError> {
1216 *self.minted_namespace.lock().unwrap() = Some(namespace.to_string());
1217 self.mint_response
1218 .lock()
1219 .unwrap()
1220 .take()
1221 .expect("test must wire mint_response")
1222 }
1223
1224 async fn apply_identity_secret(
1225 &self,
1226 namespace: &str,
1227 name: &str,
1228 env_id: &str,
1229 bearer: &str,
1230 ) -> Result<(), K8sClientError> {
1231 *self.identity_secret.lock().unwrap() = Some((
1232 namespace.to_string(),
1233 name.to_string(),
1234 env_id.to_string(),
1235 bearer.to_string(),
1236 ));
1237 Ok(())
1238 }
1239
1240 async fn delete_identity_secret(
1241 &self,
1242 namespace: &str,
1243 name: &str,
1244 ) -> Result<(), K8sClientError> {
1245 *self.deleted_identity_secret.lock().unwrap() =
1246 Some((namespace.to_string(), name.to_string()));
1247 Ok(())
1248 }
1249 }
1250
1251 fn bind_creds(mock: Arc<MockBootstrapClient>) -> K8sDeployerCredentials {
1252 let connector: K8sBootstrapConnector = Arc::new(move || -> K8sBootstrapConnectFut {
1253 let client = mock.clone();
1254 Box::pin(async move { Ok(client as Arc<dyn K8sBootstrapClient>) })
1255 });
1256 K8sDeployerCredentials::with_bootstrap_connector(connector)
1257 }
1258
1259 #[test]
1260 fn bind_applies_the_rendered_rbac_and_returns_the_minted_credential() {
1261 let expiry = chrono::DateTime::from_timestamp(2_000_000_000, 0).unwrap();
1262 let mock = Arc::new(MockBootstrapClient::default().with_mint(Ok(MintedToken {
1263 token: "MINTED_SA_TOKEN".to_string(),
1264 expiration: Some(expiry),
1265 })));
1266 let creds = bind_creds(mock.clone());
1267
1268 let env_id = EnvId::try_from("zain-prod").unwrap();
1269 let dir = tempdir().unwrap();
1270 let admin = ZeroizedAdmin::new("zain-admin@cluster", String::new());
1271 let input = BootstrapInput {
1272 env_id: &env_id,
1273 env_root: dir.path(),
1274 admin: &admin,
1275 };
1276 let outcome = creds.bootstrap(&input).expect("bind succeeds");
1277
1278 assert_eq!(
1280 outcome.bound_credentials_ref.as_ref().map(|r| r.as_str()),
1281 Some("secret://zain-prod/default/_/k8s-deployer/deployer_token")
1282 );
1283 assert_eq!(outcome.bound_expiry, Some(expiry));
1285 assert_eq!(
1286 outcome.bound_secret_material.as_ref().map(|m| m.as_str()),
1287 Some("MINTED_SA_TOKEN")
1288 );
1289
1290 let applied = mock.applied_manifests.lock().unwrap();
1293 assert_eq!(applied.len(), 1, "RBAC applied exactly once");
1294 assert_eq!(
1295 applied[0],
1296 rbac_manifest_from_pack(&outcome.rules_pack).expect("pack has the RBAC entry")
1297 );
1298 assert!(applied[0].contains("kind: ServiceAccount"));
1299 assert!(applied[0].contains(DEPLOYER_SERVICE_ACCOUNT));
1300 assert_eq!(
1302 mock.minted_namespace.lock().unwrap().as_deref(),
1303 Some("gtc-zain-prod")
1304 );
1305
1306 let identity = mock.identity_secret.lock().unwrap();
1310 let (ns, name, env_label, bearer) = identity.as_ref().expect("identity Secret was stored");
1311 assert_eq!(ns, "gtc-zain-prod");
1312 assert_eq!(name, DEPLOYER_IDENTITY_SECRET_NAME);
1313 assert_eq!(env_label, "zain-prod");
1314 assert_eq!(bearer, "MINTED_SA_TOKEN");
1315 }
1316
1317 #[test]
1318 fn rollback_bound_material_deletes_the_in_cluster_identity_secret() {
1319 let mock = Arc::new(MockBootstrapClient::default());
1320 let creds = bind_creds(mock.clone());
1321 let env_id = EnvId::try_from("zain-prod").unwrap();
1322 creds.rollback_bound_material(&env_id);
1326 let deleted = mock.deleted_identity_secret.lock().unwrap();
1327 let (ns, name) = deleted
1328 .as_ref()
1329 .expect("cleanup deleted the identity Secret");
1330 assert_eq!(ns, "gtc-zain-prod");
1331 assert_eq!(name, DEPLOYER_IDENTITY_SECRET_NAME);
1332 }
1333
1334 #[test]
1335 fn rollback_bound_material_is_a_noop_without_a_bind_connector() {
1336 let env_id = EnvId::try_from("zain-prod").unwrap();
1339 K8sDeployerCredentials::default().rollback_bound_material(&env_id);
1340 }
1341
1342 #[test]
1343 fn bind_scopes_rbac_and_mint_to_the_configured_namespace() {
1344 let mock = Arc::new(MockBootstrapClient::default().with_mint(Ok(MintedToken {
1345 token: "MINTED_SA_TOKEN".to_string(),
1346 expiration: None,
1347 })));
1348 let creds = bind_creds(mock.clone()).in_namespace("custom-ns");
1352
1353 let env_id = EnvId::try_from("zain-prod").unwrap();
1354 let dir = tempdir().unwrap();
1355 let admin = ZeroizedAdmin::new("zain-admin@cluster", String::new());
1356 let input = BootstrapInput {
1357 env_id: &env_id,
1358 env_root: dir.path(),
1359 admin: &admin,
1360 };
1361 let outcome = creds.bootstrap(&input).expect("bind succeeds");
1362
1363 let applied = mock.applied_manifests.lock().unwrap();
1365 assert!(
1366 applied[0].contains("namespace: custom-ns"),
1367 "applied: {}",
1368 applied[0]
1369 );
1370 assert!(!applied[0].contains("namespace: gtc-zain-prod"));
1371 assert_eq!(
1373 mock.minted_namespace.lock().unwrap().as_deref(),
1374 Some("custom-ns")
1375 );
1376 assert_eq!(
1378 outcome.bound_credentials_ref.as_ref().map(|r| r.as_str()),
1379 Some("secret://zain-prod/default/_/k8s-deployer/deployer_token")
1380 );
1381 }
1382
1383 #[test]
1384 fn bind_surfaces_a_mint_failure_as_provisioning_failed_without_binding() {
1385 let mock = Arc::new(MockBootstrapClient::default().with_mint(Err(
1386 K8sClientError::ApiRejected("forbidden: cannot create tokenrequests".to_string()),
1387 )));
1388 let creds = bind_creds(mock);
1389
1390 let env_id = EnvId::try_from("zain-prod").unwrap();
1391 let dir = tempdir().unwrap();
1392 let admin = ZeroizedAdmin::new("zain-admin@cluster", String::new());
1393 let input = BootstrapInput {
1394 env_id: &env_id,
1395 env_root: dir.path(),
1396 admin: &admin,
1397 };
1398 let err = creds.bootstrap(&input).unwrap_err();
1399 match err {
1400 BootstrapError::ProvisioningFailed { step, message } => {
1401 assert_eq!(step, "k8s-bind");
1402 assert!(message.contains("forbidden"), "message: {message}");
1403 }
1404 other => panic!("expected ProvisioningFailed, got {other:?}"),
1405 }
1406 }
1407
1408 fn fake_jwt(iat: i64, exp: i64) -> String {
1411 let payload = serde_json::json!({ "iat": iat, "exp": exp, "sub": "system:serviceaccount" });
1412 let body = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap());
1413 format!("aGVhZGVy.{body}.c2ln")
1414 }
1415
1416 #[test]
1417 fn rotate_at_decodes_the_jwt_and_lands_at_eighty_percent() {
1418 let iat = 1_000_000;
1420 let creds = K8sDeployerCredentials::default();
1421 let rotate_at = creds
1422 .rotate_at(&fake_jwt(iat, iat + 1000))
1423 .expect("decodable JWT");
1424 assert_eq!(rotate_at, DateTime::from_timestamp(iat + 800, 0).unwrap());
1425 }
1426
1427 #[test]
1428 fn rotation_due_tracks_the_eighty_percent_threshold() {
1429 let iat = 2_000_000;
1430 let creds = K8sDeployerCredentials::default();
1431 let bearer = fake_jwt(iat, iat + 1000); let before = DateTime::from_timestamp(iat + 799, 0).unwrap();
1433 let at = DateTime::from_timestamp(iat + 800, 0).unwrap();
1434 assert!(!creds.rotation_due(&bearer, before), "799s in: not due");
1435 assert!(
1436 creds.rotation_due(&bearer, at),
1437 "800s in: due (>= threshold)"
1438 );
1439 }
1440
1441 #[test]
1442 fn rotate_at_is_none_and_rotation_due_fails_open_for_opaque_material() {
1443 let creds = K8sDeployerCredentials::default();
1447 let now = Utc::now();
1448 for material in ["not-a-jwt", "", "a.b.c"] {
1449 assert!(
1450 creds.rotate_at(material).is_none(),
1451 "{material:?} is undecodable"
1452 );
1453 assert!(
1454 creds.rotation_due(material, now),
1455 "{material:?} fails open to due"
1456 );
1457 }
1458 }
1459}