use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use chrono::{DateTime, Utc};
use greentic_deploy_spec::SecretRef;
use zeroize::Zeroizing;
use crate::credentials::{
BootstrapError, BootstrapInput, BootstrapOutcome, Capability, CapabilityCheck,
CapabilityStatus, DeployerCredentials, RequirementsReport, RulesPack, ValidationContext,
};
use super::async_bridge::run_k8s_async;
use super::bootstrap::{
DEPLOYER_IDENTITY_SECRET_NAME, DEPLOYER_SERVICE_ACCOUNT, DEPLOYER_TOKEN_STORE_PATH,
K8S_RBAC_MANIFEST_FILENAME, K8sRulesPackInput, render_min_rbac_rules_pack,
};
use super::manifests::namespace_for_env;
const BIND_TOKEN_EXPIRATION_SECONDS: i64 = 365 * 24 * 60 * 60;
pub const K8S_API_REACHABLE_CAP: &str = "k8s.api.reachable";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct K8sOperation {
pub group: &'static str,
pub resource: &'static str,
pub verb: &'static str,
}
impl K8sOperation {
pub fn capability_id(&self) -> String {
let group = if self.group.is_empty() {
"core"
} else {
self.group
};
format!("k8s.rbac.allow:{group}/{}:{}", self.resource, self.verb)
}
}
const fn op(group: &'static str, resource: &'static str, verb: &'static str) -> K8sOperation {
K8sOperation {
group,
resource,
verb,
}
}
pub const VALIDATED_K8S_OPERATIONS: &[K8sOperation] = &[
op("apps", "deployments", "get"),
op("apps", "deployments", "create"),
op("apps", "deployments", "patch"),
op("apps", "deployments", "delete"),
op("", "services", "get"),
op("", "services", "create"),
op("", "services", "patch"),
op("", "services", "delete"),
op("", "configmaps", "get"),
op("", "configmaps", "create"),
op("", "configmaps", "patch"),
op("", "secrets", "get"),
op("", "secrets", "create"),
op("", "secrets", "patch"),
op("", "secrets", "delete"),
op("", "serviceaccounts", "get"),
op("", "serviceaccounts", "create"),
op("", "serviceaccounts", "patch"),
op("policy", "poddisruptionbudgets", "get"),
op("policy", "poddisruptionbudgets", "create"),
op("policy", "poddisruptionbudgets", "patch"),
op("networking.k8s.io", "networkpolicies", "get"),
op("networking.k8s.io", "networkpolicies", "create"),
op("networking.k8s.io", "networkpolicies", "patch"),
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClusterIdentity {
pub user: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AccessDecision {
Allowed,
Denied(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OperationDecision {
pub operation: K8sOperation,
pub decision: AccessDecision,
}
#[derive(Debug, thiserror::Error)]
pub enum K8sClientError {
#[error("no usable Kubernetes credentials: {0}")]
NoClusterAccess(String),
#[error("Kubernetes API rejected the call: {0}")]
ApiRejected(String),
#[error("Kubernetes API transport error: {0}")]
Transport(String),
#[error("in-cluster identity mismatch: {0}")]
IdentityMismatch(String),
}
#[async_trait::async_trait]
pub trait K8sValidatorClient: std::fmt::Debug + Send + Sync {
async fn who_am_i(&self) -> Result<ClusterIdentity, K8sClientError>;
async fn review_access<'a>(
&'a self,
namespace: &'a str,
operations: &'a [K8sOperation],
) -> Result<Vec<OperationDecision>, K8sClientError>;
}
pub type K8sValidatorConnectFut =
Pin<Box<dyn Future<Output = Result<Arc<dyn K8sValidatorClient>, K8sClientError>> + Send>>;
pub type K8sValidatorConnector = Arc<dyn Fn() -> K8sValidatorConnectFut + Send + Sync>;
pub struct MintedToken {
pub token: String,
pub expiration: Option<chrono::DateTime<chrono::Utc>>,
}
impl std::fmt::Debug for MintedToken {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MintedToken")
.field("token", &"<redacted>")
.field("expiration", &self.expiration)
.finish()
}
}
pub type K8sBootstrapConnectFut =
Pin<Box<dyn Future<Output = Result<Arc<dyn K8sBootstrapClient>, K8sClientError>> + Send>>;
pub type K8sBootstrapConnector = Arc<dyn Fn() -> K8sBootstrapConnectFut + Send + Sync>;
#[async_trait::async_trait]
pub trait K8sBootstrapClient: std::fmt::Debug + Send + Sync {
async fn apply_rbac(&self, manifest_yaml: &str) -> Result<(), K8sClientError>;
async fn mint_service_account_token(
&self,
namespace: &str,
service_account: &str,
expiration_seconds: i64,
) -> Result<MintedToken, K8sClientError>;
async fn apply_identity_secret(
&self,
namespace: &str,
name: &str,
env_id: &str,
bearer: &str,
) -> Result<(), K8sClientError>;
async fn delete_identity_secret(
&self,
namespace: &str,
name: &str,
) -> Result<(), K8sClientError>;
}
#[derive(Default)]
pub struct K8sDeployerCredentials {
connect: Option<K8sValidatorConnector>,
namespace: Option<String>,
bind: Option<K8sBootstrapConnector>,
}
impl std::fmt::Debug for K8sDeployerCredentials {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("K8sDeployerCredentials")
.field("connect", &self.connect.is_some())
.field("namespace", &self.namespace)
.field("bind", &self.bind.is_some())
.finish()
}
}
impl K8sDeployerCredentials {
pub fn with_client(client: Arc<dyn K8sValidatorClient>) -> Self {
Self::with_connector(Arc::new(move || -> K8sValidatorConnectFut {
let client = client.clone();
Box::pin(async move { Ok(client) })
}))
}
pub fn with_connector(connect: K8sValidatorConnector) -> Self {
Self {
connect: Some(connect),
namespace: None,
bind: None,
}
}
pub fn with_bootstrap_connector(bind: K8sBootstrapConnector) -> Self {
Self {
connect: None,
namespace: None,
bind: Some(bind),
}
}
pub fn in_namespace(mut self, namespace: impl Into<String>) -> Self {
self.namespace = Some(namespace.into());
self
}
fn reachable_capability(&self) -> Capability {
Capability::new(
K8S_API_REACHABLE_CAP,
"Kubernetes API is reachable and the credential resolves to an identity \
(SelfSubjectReview)",
)
}
fn operation_capability(&self, operation: &K8sOperation) -> Capability {
Capability::new(
operation.capability_id(),
format!(
"RBAC allows `{}` on `{}` in the env namespace",
operation.verb, operation.resource
),
)
}
fn reachable_pass_ops_failed(&self, reason: &str) -> RequirementsReport {
let mut checks = Vec::with_capacity(1 + VALIDATED_K8S_OPERATIONS.len());
checks.push(CapabilityCheck {
capability: self.reachable_capability(),
status: CapabilityStatus::Pass,
});
for operation in VALIDATED_K8S_OPERATIONS {
checks.push(CapabilityCheck {
capability: self.operation_capability(operation),
status: CapabilityStatus::Fail {
reason: reason.to_string(),
},
});
}
RequirementsReport::new(checks)
}
}
fn decode_token_lifetime(bearer: &str) -> Option<(DateTime<Utc>, DateTime<Utc>)> {
let payload_b64 = bearer.split('.').nth(1)?;
let bytes = URL_SAFE_NO_PAD.decode(payload_b64).ok()?;
let claims: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
let iat = DateTime::from_timestamp(claims.get("iat")?.as_i64()?, 0)?;
let exp = DateTime::from_timestamp(claims.get("exp")?.as_i64()?, 0)?;
Some((iat, exp))
}
impl DeployerCredentials for K8sDeployerCredentials {
fn requires_credentials_material(&self) -> bool {
true
}
fn rotate_at(&self, material: &str) -> Option<DateTime<Utc>> {
let (iat, exp) = decode_token_lifetime(material)?;
Some(crate::credentials::rotate::rotate_at_from_window(iat, exp))
}
fn required_capabilities(&self) -> Vec<Capability> {
let mut caps = Vec::with_capacity(1 + VALIDATED_K8S_OPERATIONS.len());
caps.push(self.reachable_capability());
for operation in VALIDATED_K8S_OPERATIONS {
caps.push(self.operation_capability(operation));
}
caps
}
fn validate(&self, ctx: &ValidationContext<'_>) -> RequirementsReport {
let caps = self.required_capabilities();
let Some(connect) = self.connect.as_ref() else {
return RequirementsReport::new(
caps.into_iter()
.map(|capability| CapabilityCheck {
capability,
status: CapabilityStatus::Fail {
reason: "no Kubernetes API client is bound to these \
credentials; `gtc op credentials requirements` \
connects a live client when built with the \
`k8s-client` feature โ failing closed"
.to_string(),
},
})
.collect(),
);
};
let namespace = self
.namespace
.clone()
.unwrap_or_else(|| namespace_for_env(ctx.env_id));
let connector = Arc::clone(connect);
let decisions = match run_k8s_async(async move {
let connect_fn = connector.as_ref();
let client = connect_fn().await.map_err(K8sProbeError::Connect)?;
client.who_am_i().await.map_err(K8sProbeError::Identity)?;
client
.review_access(&namespace, VALIDATED_K8S_OPERATIONS)
.await
.map_err(K8sProbeError::Access)
}) {
Ok(v) => v,
Err(K8sProbeError::Connect(e)) => {
return all_failed(&caps, &format!("Kubernetes API unreachable: {e}"));
}
Err(K8sProbeError::Identity(e)) => {
return all_failed(&caps, &format!("SelfSubjectReview failed: {e}"));
}
Err(K8sProbeError::Access(e)) => {
return self
.reachable_pass_ops_failed(&format!("SelfSubjectAccessReview failed: {e}"));
}
};
if decisions.len() != VALIDATED_K8S_OPERATIONS.len() {
return self.reachable_pass_ops_failed(&format!(
"SelfSubjectAccessReview returned {} decisions for {} operations",
decisions.len(),
VALIDATED_K8S_OPERATIONS.len()
));
}
for (i, (expected, actual)) in VALIDATED_K8S_OPERATIONS
.iter()
.zip(decisions.iter())
.enumerate()
{
if actual.operation != *expected {
return self.reachable_pass_ops_failed(&format!(
"SelfSubjectAccessReview decision[{i}] operation mismatch: \
expected `{}`, got `{}`",
expected.capability_id(),
actual.operation.capability_id()
));
}
}
let mut checks = Vec::with_capacity(1 + decisions.len());
checks.push(CapabilityCheck {
capability: self.reachable_capability(),
status: CapabilityStatus::Pass,
});
for (operation, decision) in VALIDATED_K8S_OPERATIONS.iter().zip(decisions.iter()) {
let status = match &decision.decision {
AccessDecision::Allowed => CapabilityStatus::Pass,
AccessDecision::Denied(reason) => CapabilityStatus::Fail {
reason: format!(
"RBAC denied `{}` on `{}` ({reason})",
operation.verb, operation.resource
),
},
};
checks.push(CapabilityCheck {
capability: self.operation_capability(operation),
status,
});
}
RequirementsReport::new(checks)
}
fn bootstrap(&self, input: &BootstrapInput<'_>) -> Result<BootstrapOutcome, BootstrapError> {
let admin_context = input.admin.profile();
if admin_context.is_empty() {
return Err(BootstrapError::AdminRejected(
"K8s bootstrap requires --admin-profile to identify the kubeconfig context \
(or admin identity) that will apply the rules pack."
.to_string(),
));
}
let namespace = self
.namespace
.clone()
.unwrap_or_else(|| namespace_for_env(input.env_id));
let rules_pack = render_min_rbac_rules_pack(&K8sRulesPackInput {
env_id: input.env_id.as_str(),
namespace: &namespace,
admin_context_hint: admin_context,
operations: VALIDATED_K8S_OPERATIONS,
});
let Some(bind) = self.bind.as_ref() else {
return Ok(BootstrapOutcome {
rules_pack,
bound_credentials_ref: None,
bound_expiry: None,
bound_secret_material: None,
});
};
let manifest_yaml = rbac_manifest_from_pack(&rules_pack).ok_or_else(|| {
BootstrapError::ProvisioningFailed {
step: "render-rbac".to_string(),
message: format!(
"rendered rules pack is missing the `{K8S_RBAC_MANIFEST_FILENAME}` entry"
),
}
})?;
let connector = Arc::clone(bind);
let env_id_label = input.env_id.as_str().to_string();
let minted = run_k8s_async(async move {
let client = connector().await?;
client.apply_rbac(&manifest_yaml).await?;
let minted = client
.mint_service_account_token(
&namespace,
DEPLOYER_SERVICE_ACCOUNT,
BIND_TOKEN_EXPIRATION_SECONDS,
)
.await?;
client
.apply_identity_secret(
&namespace,
DEPLOYER_IDENTITY_SECRET_NAME,
&env_id_label,
&minted.token,
)
.await?;
Ok(minted)
})
.map_err(|e: K8sClientError| BootstrapError::ProvisioningFailed {
step: "k8s-bind".to_string(),
message: e.to_string(),
})?;
let bound_ref = SecretRef::try_new(format!(
"secret://{}/{}",
input.env_id.as_str(),
DEPLOYER_TOKEN_STORE_PATH
))
.map_err(|e| BootstrapError::ProvisioningFailed {
step: "bind-ref".to_string(),
message: format!("bound credentials ref is not well-formed: {e}"),
})?;
Ok(BootstrapOutcome {
rules_pack,
bound_credentials_ref: Some(bound_ref),
bound_expiry: minted.expiration,
bound_secret_material: Some(Zeroizing::new(minted.token)),
})
}
fn rollback_bound_material(&self, env_id: &greentic_deploy_spec::EnvId) {
let Some(bind) = self.bind.as_ref() else {
return;
};
let namespace = self
.namespace
.clone()
.unwrap_or_else(|| namespace_for_env(env_id));
let connector = Arc::clone(bind);
let _ = run_k8s_async(async move {
let client = connector().await?;
client
.delete_identity_secret(&namespace, DEPLOYER_IDENTITY_SECRET_NAME)
.await
});
}
}
fn rbac_manifest_from_pack(rules_pack: &RulesPack) -> Option<String> {
rules_pack
.entries
.iter()
.find(|entry| entry.filename == K8S_RBAC_MANIFEST_FILENAME)
.map(|entry| entry.content.clone())
}
enum K8sProbeError {
Connect(K8sClientError),
Identity(K8sClientError),
Access(K8sClientError),
}
fn all_failed(caps: &[Capability], reason: &str) -> RequirementsReport {
RequirementsReport::new(
caps.iter()
.map(|c| CapabilityCheck {
capability: c.clone(),
status: CapabilityStatus::Fail {
reason: reason.to_string(),
},
})
.collect(),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::credentials::ZeroizedAdmin;
use greentic_deploy_spec::{EnvId, EnvironmentHostConfig};
use std::path::Path;
use std::sync::Mutex;
use tempfile::tempdir;
fn default_host_config(env_id: &EnvId) -> EnvironmentHostConfig {
EnvironmentHostConfig {
env_id: env_id.clone(),
region: None,
tenant_org_id: None,
listen_addr: None,
public_base_url: None,
gui_enabled: None,
}
}
fn ctx<'a>(
env_root: &'a Path,
env_id: &'a EnvId,
host_config: &'a EnvironmentHostConfig,
) -> ValidationContext<'a> {
ValidationContext {
env_id,
env_root,
host_config,
}
}
#[derive(Debug, Default)]
struct MockK8sClient {
identity_response: Mutex<Option<Result<ClusterIdentity, K8sClientError>>>,
review_response: Mutex<Option<Result<Vec<OperationDecision>, K8sClientError>>>,
review_calls: Mutex<Vec<(String, usize)>>,
}
impl MockK8sClient {
fn with_identity(self, r: Result<ClusterIdentity, K8sClientError>) -> Self {
*self.identity_response.lock().unwrap() = Some(r);
self
}
fn with_review(self, r: Result<Vec<OperationDecision>, K8sClientError>) -> Self {
*self.review_response.lock().unwrap() = Some(r);
self
}
}
#[async_trait::async_trait]
impl K8sValidatorClient for MockK8sClient {
async fn who_am_i(&self) -> Result<ClusterIdentity, K8sClientError> {
self.identity_response
.lock()
.unwrap()
.take()
.expect("test must wire identity_response")
}
async fn review_access<'a>(
&'a self,
namespace: &'a str,
operations: &'a [K8sOperation],
) -> Result<Vec<OperationDecision>, K8sClientError> {
self.review_calls
.lock()
.unwrap()
.push((namespace.to_string(), operations.len()));
self.review_response
.lock()
.unwrap()
.take()
.expect("test must wire review_response")
}
}
fn identity() -> ClusterIdentity {
ClusterIdentity {
user: "system:serviceaccount:gtc-zain-prod:greentic-deployer".into(),
}
}
fn all_allowed() -> Vec<OperationDecision> {
VALIDATED_K8S_OPERATIONS
.iter()
.map(|operation| OperationDecision {
operation: *operation,
decision: AccessDecision::Allowed,
})
.collect()
}
#[test]
fn required_capabilities_cover_reachable_plus_every_operation() {
let creds = K8sDeployerCredentials::default();
let ids: Vec<String> = creds
.required_capabilities()
.into_iter()
.map(|c| c.id)
.collect();
assert_eq!(ids.len(), 1 + VALIDATED_K8S_OPERATIONS.len());
assert_eq!(ids[0], K8S_API_REACHABLE_CAP);
assert!(ids.contains(&"k8s.rbac.allow:core/services:create".to_string()));
assert!(ids.contains(&"k8s.rbac.allow:apps/deployments:delete".to_string()));
assert!(
ids.contains(&"k8s.rbac.allow:networking.k8s.io/networkpolicies:patch".to_string())
);
}
#[test]
fn validate_without_a_client_fails_closed() {
let creds = K8sDeployerCredentials::default();
let env_id = EnvId::try_from("zain-prod").unwrap();
let hc = default_host_config(&env_id);
let dir = tempdir().unwrap();
let report = creds.validate(&ctx(dir.path(), &env_id, &hc));
assert!(!report.passed(), "no-client report must NOT pass");
assert_eq!(
report.missing().len(),
creds.required_capabilities().len(),
"every Fail cap must be recorded as missing"
);
for check in &report.checks {
match &check.status {
CapabilityStatus::Fail { reason } => {
assert!(
reason.contains("no Kubernetes API client is bound"),
"reason must mention the missing client: {reason}"
);
}
other => panic!("expected Fail, got {other:?}"),
}
}
}
#[test]
fn validate_passes_when_identity_resolves_and_all_ops_allowed() {
let mock = Arc::new(
MockK8sClient::default()
.with_identity(Ok(identity()))
.with_review(Ok(all_allowed())),
);
let creds = K8sDeployerCredentials::with_client(mock.clone());
let env_id = EnvId::try_from("zain-prod").unwrap();
let hc = default_host_config(&env_id);
let dir = tempdir().unwrap();
let report = creds.validate(&ctx(dir.path(), &env_id, &hc));
assert!(report.passed(), "report: {report:?}");
assert!(report.missing().is_empty());
let calls = mock.review_calls.lock().unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].0, "gtc-zain-prod");
assert_eq!(calls[0].1, VALIDATED_K8S_OPERATIONS.len());
}
#[test]
fn validate_scopes_ssars_to_the_overridden_namespace() {
let mock = Arc::new(
MockK8sClient::default()
.with_identity(Ok(identity()))
.with_review(Ok(all_allowed())),
);
let creds = K8sDeployerCredentials::with_client(mock.clone()).in_namespace("custom-ns");
let env_id = EnvId::try_from("zain-prod").unwrap();
let hc = default_host_config(&env_id);
let dir = tempdir().unwrap();
let report = creds.validate(&ctx(dir.path(), &env_id, &hc));
assert!(report.passed(), "report: {report:?}");
let calls = mock.review_calls.lock().unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(
calls[0].0, "custom-ns",
"SSARs must target the deploy namespace, not gtc-zain-prod"
);
}
#[test]
fn validate_fails_the_specific_denied_operation() {
let decisions: Vec<OperationDecision> = VALIDATED_K8S_OPERATIONS
.iter()
.map(|operation| OperationDecision {
operation: *operation,
decision: if operation.resource == "deployments" && operation.verb == "delete" {
AccessDecision::Denied("no RBAC rule matched".into())
} else {
AccessDecision::Allowed
},
})
.collect();
let mock = Arc::new(
MockK8sClient::default()
.with_identity(Ok(identity()))
.with_review(Ok(decisions)),
);
let creds = K8sDeployerCredentials::with_client(mock);
let env_id = EnvId::try_from("zain-prod").unwrap();
let hc = default_host_config(&env_id);
let dir = tempdir().unwrap();
let report = creds.validate(&ctx(dir.path(), &env_id, &hc));
assert!(!report.passed());
assert_eq!(
report.missing(),
vec!["k8s.rbac.allow:apps/deployments:delete".to_string()]
);
let denied = report
.checks
.iter()
.find(|c| c.capability.id == "k8s.rbac.allow:apps/deployments:delete")
.unwrap();
match &denied.status {
CapabilityStatus::Fail { reason } => {
assert!(reason.contains("no RBAC rule matched"), "reason: {reason}");
}
other => panic!("expected Fail, got {other:?}"),
}
}
#[test]
fn validate_fails_every_cap_when_identity_does_not_resolve() {
let mock = Arc::new(MockK8sClient::default().with_identity(Err(
K8sClientError::NoClusterAccess("kubeconfig has no current context".into()),
)));
let creds = K8sDeployerCredentials::with_client(mock);
let env_id = EnvId::try_from("zain-prod").unwrap();
let hc = default_host_config(&env_id);
let dir = tempdir().unwrap();
let report = creds.validate(&ctx(dir.path(), &env_id, &hc));
assert!(!report.passed());
for check in &report.checks {
match &check.status {
CapabilityStatus::Fail { reason } => {
assert!(reason.contains("kubeconfig has no current context"));
}
other => panic!("expected Fail, got {other:?}"),
}
}
}
#[test]
fn validate_passes_reachable_but_fails_ops_when_review_errors() {
let mock = Arc::new(
MockK8sClient::default()
.with_identity(Ok(identity()))
.with_review(Err(K8sClientError::Transport("connection reset".into()))),
);
let creds = K8sDeployerCredentials::with_client(mock);
let env_id = EnvId::try_from("zain-prod").unwrap();
let hc = default_host_config(&env_id);
let dir = tempdir().unwrap();
let report = creds.validate(&ctx(dir.path(), &env_id, &hc));
assert!(!report.passed());
let reachable = report
.checks
.iter()
.find(|c| c.capability.id == K8S_API_REACHABLE_CAP)
.unwrap();
assert!(matches!(reachable.status, CapabilityStatus::Pass));
for check in report.checks.iter().skip(1) {
match &check.status {
CapabilityStatus::Fail { reason } => {
assert!(reason.contains("connection reset"), "reason: {reason}");
}
other => panic!("expected Fail, got {other:?}"),
}
}
}
#[test]
fn validate_fails_closed_on_truncated_review_response() {
let mock = Arc::new(
MockK8sClient::default()
.with_identity(Ok(identity()))
.with_review(Ok(vec![])),
);
let creds = K8sDeployerCredentials::with_client(mock);
let env_id = EnvId::try_from("zain-prod").unwrap();
let hc = default_host_config(&env_id);
let dir = tempdir().unwrap();
let report = creds.validate(&ctx(dir.path(), &env_id, &hc));
assert!(!report.passed(), "truncated response must not pass");
let reachable = report
.checks
.iter()
.find(|c| c.capability.id == K8S_API_REACHABLE_CAP)
.unwrap();
assert!(matches!(reachable.status, CapabilityStatus::Pass));
let op_checks: Vec<_> = report
.checks
.iter()
.filter(|c| c.capability.id != K8S_API_REACHABLE_CAP)
.collect();
assert_eq!(op_checks.len(), VALIDATED_K8S_OPERATIONS.len());
for check in &op_checks {
match &check.status {
CapabilityStatus::Fail { reason } => {
assert!(
reason.contains("0 decisions for"),
"reason must mention the count mismatch: {reason}"
);
}
other => panic!("expected Fail, got {other:?}"),
}
}
assert_eq!(
report.missing().len(),
VALIDATED_K8S_OPERATIONS.len(),
"every operation cap must be missing"
);
}
#[test]
fn validate_fails_closed_on_mismatched_operation_order() {
let mut decisions = all_allowed();
decisions.swap(0, 1);
let mock = Arc::new(
MockK8sClient::default()
.with_identity(Ok(identity()))
.with_review(Ok(decisions)),
);
let creds = K8sDeployerCredentials::with_client(mock);
let env_id = EnvId::try_from("zain-prod").unwrap();
let hc = default_host_config(&env_id);
let dir = tempdir().unwrap();
let report = creds.validate(&ctx(dir.path(), &env_id, &hc));
assert!(!report.passed(), "mismatched operations must not pass");
let reachable = report
.checks
.iter()
.find(|c| c.capability.id == K8S_API_REACHABLE_CAP)
.unwrap();
assert!(matches!(reachable.status, CapabilityStatus::Pass));
let op_checks: Vec<_> = report
.checks
.iter()
.filter(|c| c.capability.id != K8S_API_REACHABLE_CAP)
.collect();
assert_eq!(op_checks.len(), VALIDATED_K8S_OPERATIONS.len());
for check in &op_checks {
match &check.status {
CapabilityStatus::Fail { reason } => {
assert!(
reason.contains("mismatch"),
"reason must mention the mismatch: {reason}"
);
}
other => panic!("expected Fail, got {other:?}"),
}
}
}
#[test]
fn bootstrap_rejects_empty_admin_profile() {
let creds = K8sDeployerCredentials::default();
let env_id = EnvId::try_from("zain-prod").unwrap();
let dir = tempdir().unwrap();
let admin = ZeroizedAdmin::new("", "irrelevant".to_string());
let input = BootstrapInput {
env_id: &env_id,
env_root: dir.path(),
admin: &admin,
};
let err = creds.bootstrap(&input).unwrap_err();
match err {
BootstrapError::AdminRejected(msg) => {
assert!(msg.contains("--admin-profile"), "msg: {msg}");
}
other => panic!("expected AdminRejected, got {other:?}"),
}
}
#[test]
fn bootstrap_returns_rules_pack_without_binding_credentials() {
let creds = K8sDeployerCredentials::default();
let env_id = EnvId::try_from("zain-prod").unwrap();
let dir = tempdir().unwrap();
let admin = ZeroizedAdmin::new("zain-admin@nonprod-cluster", String::new());
let input = BootstrapInput {
env_id: &env_id,
env_root: dir.path(),
admin: &admin,
};
let outcome = creds.bootstrap(&input).expect("bootstrap renders");
assert!(
outcome.bound_credentials_ref.is_none(),
"K8s bootstrap must not bind credentials directly โ the admin \
applies the rules pack and binds via rotate"
);
assert!(!outcome.rules_pack.is_empty());
let combined: String = outcome
.rules_pack
.entries
.iter()
.map(|e| e.content.as_str())
.collect::<Vec<_>>()
.join("\n");
for operation in VALIDATED_K8S_OPERATIONS {
assert!(
combined.contains(operation.verb),
"rules pack must mention verb `{}`",
operation.verb
);
}
assert!(combined.contains("zain-admin@nonprod-cluster"));
assert!(combined.contains("gtc-zain-prod"));
}
#[derive(Debug, Default)]
struct MockBootstrapClient {
applied_manifests: Mutex<Vec<String>>,
minted_namespace: Mutex<Option<String>>,
mint_response: Mutex<Option<Result<MintedToken, K8sClientError>>>,
identity_secret: Mutex<Option<(String, String, String, String)>>,
deleted_identity_secret: Mutex<Option<(String, String)>>,
}
impl MockBootstrapClient {
fn with_mint(self, r: Result<MintedToken, K8sClientError>) -> Self {
*self.mint_response.lock().unwrap() = Some(r);
self
}
}
#[async_trait::async_trait]
impl K8sBootstrapClient for MockBootstrapClient {
async fn apply_rbac(&self, manifest_yaml: &str) -> Result<(), K8sClientError> {
self.applied_manifests
.lock()
.unwrap()
.push(manifest_yaml.to_string());
Ok(())
}
async fn mint_service_account_token(
&self,
namespace: &str,
_service_account: &str,
_expiration_seconds: i64,
) -> Result<MintedToken, K8sClientError> {
*self.minted_namespace.lock().unwrap() = Some(namespace.to_string());
self.mint_response
.lock()
.unwrap()
.take()
.expect("test must wire mint_response")
}
async fn apply_identity_secret(
&self,
namespace: &str,
name: &str,
env_id: &str,
bearer: &str,
) -> Result<(), K8sClientError> {
*self.identity_secret.lock().unwrap() = Some((
namespace.to_string(),
name.to_string(),
env_id.to_string(),
bearer.to_string(),
));
Ok(())
}
async fn delete_identity_secret(
&self,
namespace: &str,
name: &str,
) -> Result<(), K8sClientError> {
*self.deleted_identity_secret.lock().unwrap() =
Some((namespace.to_string(), name.to_string()));
Ok(())
}
}
fn bind_creds(mock: Arc<MockBootstrapClient>) -> K8sDeployerCredentials {
let connector: K8sBootstrapConnector = Arc::new(move || -> K8sBootstrapConnectFut {
let client = mock.clone();
Box::pin(async move { Ok(client as Arc<dyn K8sBootstrapClient>) })
});
K8sDeployerCredentials::with_bootstrap_connector(connector)
}
#[test]
fn bind_applies_the_rendered_rbac_and_returns_the_minted_credential() {
let expiry = chrono::DateTime::from_timestamp(2_000_000_000, 0).unwrap();
let mock = Arc::new(MockBootstrapClient::default().with_mint(Ok(MintedToken {
token: "MINTED_SA_TOKEN".to_string(),
expiration: Some(expiry),
})));
let creds = bind_creds(mock.clone());
let env_id = EnvId::try_from("zain-prod").unwrap();
let dir = tempdir().unwrap();
let admin = ZeroizedAdmin::new("zain-admin@cluster", String::new());
let input = BootstrapInput {
env_id: &env_id,
env_root: dir.path(),
admin: &admin,
};
let outcome = creds.bootstrap(&input).expect("bind succeeds");
assert_eq!(
outcome.bound_credentials_ref.as_ref().map(|r| r.as_str()),
Some("secret://zain-prod/default/_/k8s-deployer/deployer_token")
);
assert_eq!(outcome.bound_expiry, Some(expiry));
assert_eq!(
outcome.bound_secret_material.as_ref().map(|m| m.as_str()),
Some("MINTED_SA_TOKEN")
);
let applied = mock.applied_manifests.lock().unwrap();
assert_eq!(applied.len(), 1, "RBAC applied exactly once");
assert_eq!(
applied[0],
rbac_manifest_from_pack(&outcome.rules_pack).expect("pack has the RBAC entry")
);
assert!(applied[0].contains("kind: ServiceAccount"));
assert!(applied[0].contains(DEPLOYER_SERVICE_ACCOUNT));
assert_eq!(
mock.minted_namespace.lock().unwrap().as_deref(),
Some("gtc-zain-prod")
);
let identity = mock.identity_secret.lock().unwrap();
let (ns, name, env_label, bearer) = identity.as_ref().expect("identity Secret was stored");
assert_eq!(ns, "gtc-zain-prod");
assert_eq!(name, DEPLOYER_IDENTITY_SECRET_NAME);
assert_eq!(env_label, "zain-prod");
assert_eq!(bearer, "MINTED_SA_TOKEN");
}
#[test]
fn rollback_bound_material_deletes_the_in_cluster_identity_secret() {
let mock = Arc::new(MockBootstrapClient::default());
let creds = bind_creds(mock.clone());
let env_id = EnvId::try_from("zain-prod").unwrap();
creds.rollback_bound_material(&env_id);
let deleted = mock.deleted_identity_secret.lock().unwrap();
let (ns, name) = deleted
.as_ref()
.expect("cleanup deleted the identity Secret");
assert_eq!(ns, "gtc-zain-prod");
assert_eq!(name, DEPLOYER_IDENTITY_SECRET_NAME);
}
#[test]
fn rollback_bound_material_is_a_noop_without_a_bind_connector() {
let env_id = EnvId::try_from("zain-prod").unwrap();
K8sDeployerCredentials::default().rollback_bound_material(&env_id);
}
#[test]
fn bind_scopes_rbac_and_mint_to_the_configured_namespace() {
let mock = Arc::new(MockBootstrapClient::default().with_mint(Ok(MintedToken {
token: "MINTED_SA_TOKEN".to_string(),
expiration: None,
})));
let creds = bind_creds(mock.clone()).in_namespace("custom-ns");
let env_id = EnvId::try_from("zain-prod").unwrap();
let dir = tempdir().unwrap();
let admin = ZeroizedAdmin::new("zain-admin@cluster", String::new());
let input = BootstrapInput {
env_id: &env_id,
env_root: dir.path(),
admin: &admin,
};
let outcome = creds.bootstrap(&input).expect("bind succeeds");
let applied = mock.applied_manifests.lock().unwrap();
assert!(
applied[0].contains("namespace: custom-ns"),
"applied: {}",
applied[0]
);
assert!(!applied[0].contains("namespace: gtc-zain-prod"));
assert_eq!(
mock.minted_namespace.lock().unwrap().as_deref(),
Some("custom-ns")
);
assert_eq!(
outcome.bound_credentials_ref.as_ref().map(|r| r.as_str()),
Some("secret://zain-prod/default/_/k8s-deployer/deployer_token")
);
}
#[test]
fn bind_surfaces_a_mint_failure_as_provisioning_failed_without_binding() {
let mock = Arc::new(MockBootstrapClient::default().with_mint(Err(
K8sClientError::ApiRejected("forbidden: cannot create tokenrequests".to_string()),
)));
let creds = bind_creds(mock);
let env_id = EnvId::try_from("zain-prod").unwrap();
let dir = tempdir().unwrap();
let admin = ZeroizedAdmin::new("zain-admin@cluster", String::new());
let input = BootstrapInput {
env_id: &env_id,
env_root: dir.path(),
admin: &admin,
};
let err = creds.bootstrap(&input).unwrap_err();
match err {
BootstrapError::ProvisioningFailed { step, message } => {
assert_eq!(step, "k8s-bind");
assert!(message.contains("forbidden"), "message: {message}");
}
other => panic!("expected ProvisioningFailed, got {other:?}"),
}
}
fn fake_jwt(iat: i64, exp: i64) -> String {
let payload = serde_json::json!({ "iat": iat, "exp": exp, "sub": "system:serviceaccount" });
let body = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap());
format!("aGVhZGVy.{body}.c2ln")
}
#[test]
fn rotate_at_decodes_the_jwt_and_lands_at_eighty_percent() {
let iat = 1_000_000;
let creds = K8sDeployerCredentials::default();
let rotate_at = creds
.rotate_at(&fake_jwt(iat, iat + 1000))
.expect("decodable JWT");
assert_eq!(rotate_at, DateTime::from_timestamp(iat + 800, 0).unwrap());
}
#[test]
fn rotation_due_tracks_the_eighty_percent_threshold() {
let iat = 2_000_000;
let creds = K8sDeployerCredentials::default();
let bearer = fake_jwt(iat, iat + 1000); let before = DateTime::from_timestamp(iat + 799, 0).unwrap();
let at = DateTime::from_timestamp(iat + 800, 0).unwrap();
assert!(!creds.rotation_due(&bearer, before), "799s in: not due");
assert!(
creds.rotation_due(&bearer, at),
"800s in: due (>= threshold)"
);
}
#[test]
fn rotate_at_is_none_and_rotation_due_fails_open_for_opaque_material() {
let creds = K8sDeployerCredentials::default();
let now = Utc::now();
for material in ["not-a-jwt", "", "a.b.c"] {
assert!(
creds.rotate_at(material).is_none(),
"{material:?} is undecodable"
);
assert!(
creds.rotation_due(material, now),
"{material:?} fails open to due"
);
}
}
}