Skip to main content

greentic_deployer/env_packs/k8s/
credentials.rs

1//! [`DeployerCredentials`] impl for the K8s deployer env-pack (Phase D
2//! plan §6 step 6).
3//!
4//! Three trust boundaries stay separate (Q3): human cluster access,
5//! deployer API access, pod workload identity. This contract covers the
6//! middle one — the identity the DEPLOYER calls the Kubernetes API with.
7//!
8//! Validation is `SelfSubjectAccessReview` against the EXACT operations
9//! the deployer performs ([`VALIDATED_K8S_OPERATIONS`]) — the same list
10//! the bootstrap rules pack derives its Role rules from, so the
11//! bootstrap-then-validate loop converges exactly like the AWS-ECS
12//! `VALIDATED_IAM_VERBS` precedent.
13//!
14//! ## Default posture: probes FAIL CLOSED until a client is bound
15//!
16//! The typed Kubernetes API client exists
17//! ([`KubeValidatorClient`](super::kube_client::KubeValidatorClient),
18//! `k8s-client` feature); the `op credentials requirements` CLI connects
19//! it from the binding's answers (ambient identity, like `reconcile`) and
20//! injects it via [`with_client`](K8sDeployerCredentials::with_client) — the
21//! runner's `creds_override` seam. [`K8sDeployerCredentials::default`] holds
22//! no client (the fail-closed posture for any caller that doesn't inject one,
23//! e.g. a `--no-default-features` build) and
24//! every probe reports [`CapabilityStatus::Fail`] — NOT `Skipped`,
25//! because `RequirementsReport::passed()` treats `Skipped` as non-
26//! blocking (it only checks for `Fail`), so an all-`Skipped` report
27//! would persist `CredentialsValidationResult::Pass` with zero actual
28//! checks. Failing closed matches the AWS credential-chain-missing
29//! precedent and ensures `gtc op credentials requirements` never
30//! reports a false pass. The decision logic (reachable → per-op SSAR →
31//! Pass/Fail mapping) is fully implemented and pinned by mock-client
32//! tests, so the real client plugs into [`K8sValidatorClient`] without
33//! touching `validate`.
34//!
35//! ## Bootstrap
36//!
37//! [`bootstrap`](DeployerCredentials::bootstrap) emits a minimum-privilege
38//! Namespace + ServiceAccount + Role + RoleBinding rules pack
39//! ([`super::bootstrap`]) for the customer's cluster admin to review and
40//! `kubectl apply` offline. `bound_credentials_ref: None` — the admin
41//! applies the pack, mints a short-lived token for the ServiceAccount
42//! (`kubectl create token`, per the plan's no-long-lived-bearer-token
43//! rule), and binds it via `op credentials rotate`.
44
45use 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
67/// Lifetime requested for the bound ServiceAccount token (1 year). The API
68/// server clamps this to its `--service-account-max-token-expiration`, so
69/// the GRANTED expiry (read back from the TokenRequest status) may be
70/// sooner — the runner records it as the re-bind deadline. `op credentials
71/// rotate --if-needed` re-mints in place once the token passes 80% of its
72/// (clamped) lifetime, so a scheduled job keeps it fresh without a full
73/// re-bind.
74const BIND_TOKEN_EXPIRATION_SECONDS: i64 = 365 * 24 * 60 * 60;
75
76/// Stable ID for the API-reachability probe (identity resolves against
77/// the cluster — the K8s analogue of `aws.sts.caller-identity`).
78pub const K8S_API_REACHABLE_CAP: &str = "k8s.api.reachable";
79
80/// One namespaced Kubernetes operation the deployer performs.
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub struct K8sOperation {
83    /// API group (`""` = core).
84    pub group: &'static str,
85    pub resource: &'static str,
86    pub verb: &'static str,
87}
88
89impl K8sOperation {
90    /// Canonical capability ID: `k8s.rbac.allow:<group|core>/<resource>:<verb>`.
91    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
101/// Convenience constructor keeping the operations table readable.
102const fn op(group: &'static str, resource: &'static str, verb: &'static str) -> K8sOperation {
103    K8sOperation {
104        group,
105        resource,
106        verb,
107    }
108}
109
110/// The exact namespaced operations the deployer's verbs + renderer touch.
111/// Order is stable — `required_capabilities` renders them in this order,
112/// the bootstrap Role rules aggregate from this list, and validate's
113/// SSAR probes iterate it 1:1.
114///
115/// Deployments/Services carry `delete` (archive tears workers down); Secrets
116/// also carry `delete` (a DevStore→Vault reconcile removes the stale dev-store
117/// Secret). ConfigMaps/PDBs/NetworkPolicies/ServiceAccounts are env-lifetime
118/// objects the deployer only upserts.
119///
120/// This list is namespaced-only and aggregates exactly the verbs a bound
121/// (namespace-scoped) deployer ServiceAccount needs to drive `op env reconcile`.
122/// The one object reconcile would otherwise apply outside this scope — the
123/// cluster-scoped Namespace — is dropped from the applied set for a bound
124/// identity (`K8sDeployerHandler::reconcile`'s `manage_namespace`), since
125/// `bootstrap --bind` already creates the namespace. So passing `requirements`
126/// now implies a bound identity can also reconcile; the earlier namespace-apply
127/// trust gap is closed.
128pub 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    // The dev-store Secret (env-lifetime) delivers the operator's
141    // `.dev.secrets.env` so workers resolve `secret://` refs in-pod. `delete`
142    // lets a DevStore→Vault reconcile remove the stale Secret so no secret
143    // material lingers once the env resolves under workload identity. Without
144    // these verbs a bound identity 403s on reconcile.
145    op("", "secrets", "get"),
146    op("", "secrets", "create"),
147    op("", "secrets", "patch"),
148    op("", "secrets", "delete"),
149    // The worker ServiceAccount the Vault backend authenticates as
150    // (env-lifetime, upsert-only). Rendered only for Vault envs, but the bound
151    // Role grants these unconditionally so a Vault reconcile never 403s
152    // applying it.
153    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/// Identity the cluster resolved for the deployer's credential.
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub struct ClusterIdentity {
167    /// e.g. `system:serviceaccount:gtc-zain-prod:greentic-deployer`.
168    pub user: String,
169}
170
171/// One `SelfSubjectAccessReview` outcome.
172#[derive(Debug, Clone, PartialEq, Eq)]
173pub enum AccessDecision {
174    Allowed,
175    /// Carries the API server's reason when it supplies one.
176    Denied(String),
177}
178
179/// Per-operation review result, in request order.
180#[derive(Debug, Clone, PartialEq, Eq)]
181pub struct OperationDecision {
182    pub operation: K8sOperation,
183    pub decision: AccessDecision,
184}
185
186/// Errors the validator client can surface. All variants flow into
187/// `CapabilityStatus::Fail { reason }` — transport vs. auth is not
188/// distinguished because the operator's fix path is the same (fix
189/// kubeconfig / cluster access, re-run requirements).
190#[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    /// A cluster object claimed to hold a credential but its env-ownership
199    /// label does not match the env resolving it — refuse to trust a
200    /// foreign/stale identity Secret rather than silently binding to it.
201    #[error("in-cluster identity mismatch: {0}")]
202    IdentityMismatch(String),
203}
204
205/// Pluggable validator client. Unit tests mock this; the production
206/// impl is [`KubeValidatorClient`](super::kube_client::KubeValidatorClient).
207#[async_trait::async_trait]
208pub trait K8sValidatorClient: std::fmt::Debug + Send + Sync {
209    /// Resolve the credential's cluster identity (SelfSubjectReview).
210    async fn who_am_i(&self) -> Result<ClusterIdentity, K8sClientError>;
211
212    /// Run one `SelfSubjectAccessReview` per operation, scoped to
213    /// `namespace`. Returns one decision per operation, in the SAME order
214    /// as the input `operations` slice — `validate()` enforces both the
215    /// count and per-index operation identity defensively (a partial or
216    /// re-ordered response must never authorize).
217    async fn review_access<'a>(
218        &'a self,
219        namespace: &'a str,
220        operations: &'a [K8sOperation],
221    ) -> Result<Vec<OperationDecision>, K8sClientError>;
222}
223
224/// Future yielded by a [`K8sValidatorConnector`].
225pub type K8sValidatorConnectFut =
226    Pin<Box<dyn Future<Output = Result<Arc<dyn K8sValidatorClient>, K8sClientError>> + Send>>;
227
228/// Lazily produces a connected validator client INSIDE the probe runtime.
229///
230/// A `kube::Client`'s tower `Buffer` worker is spawned on whichever runtime
231/// first drives the connect future and is aborted when that runtime is
232/// dropped. [`run_k8s_async`] runs each future on a dedicated thread with its
233/// own current-thread runtime and drops it on return, so the connect and the
234/// probes MUST share a single `run_k8s_async` call — a client connected in an
235/// earlier bridge call would hand `validate` a dead worker (`buffer's worker
236/// closed unexpectedly`). The connector defers the connect into the probe
237/// runtime; tests supply one that just returns a mock.
238pub type K8sValidatorConnector = Arc<dyn Fn() -> K8sValidatorConnectFut + Send + Sync>;
239
240/// A freshly minted ServiceAccount token and the absolute expiry the
241/// cluster granted. The API server clamps the requested lifetime to its
242/// `--service-account-max-token-expiration`, so `expiration` (read back
243/// from the TokenRequest status) is the authoritative re-bind deadline —
244/// it may be sooner than requested.
245pub 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        // Never render `token` — it is live bearer material.
253        f.debug_struct("MintedToken")
254            .field("token", &"<redacted>")
255            .field("expiration", &self.expiration)
256            .finish()
257    }
258}
259
260/// Future yielded by a [`K8sBootstrapConnector`].
261pub type K8sBootstrapConnectFut =
262    Pin<Box<dyn Future<Output = Result<Arc<dyn K8sBootstrapClient>, K8sClientError>> + Send>>;
263
264/// Lazily produces a connected bootstrap client INSIDE the bind runtime —
265/// same `kube::Client` Buffer-worker runtime constraint as
266/// [`K8sValidatorConnector`] (connect + apply + mint MUST share one
267/// [`run_k8s_async`] call).
268pub type K8sBootstrapConnector = Arc<dyn Fn() -> K8sBootstrapConnectFut + Send + Sync>;
269
270/// Provider-side client for the `--bind` bootstrap path: applies the
271/// rendered RBAC and mints the deployer ServiceAccount's token, connected
272/// AS THE ADMIN identity (the one with rights to create the SA/Role/
273/// RoleBinding and call TokenRequest). The production impl is
274/// [`KubeBootstrapClient`](super::kube_client::KubeBootstrapClient); unit
275/// tests mock it.
276#[async_trait::async_trait]
277pub trait K8sBootstrapClient: std::fmt::Debug + Send + Sync {
278    /// Server-side-apply the rendered RBAC manifest (a multi-document YAML
279    /// of Namespace + ServiceAccount + Role + RoleBinding). Idempotent —
280    /// re-applying the same manifest converges, so a retried bind is safe.
281    async fn apply_rbac(&self, manifest_yaml: &str) -> Result<(), K8sClientError>;
282
283    /// Mint a ServiceAccount token via the TokenRequest subresource,
284    /// scoped to `namespace`/`service_account`, requesting
285    /// `expiration_seconds` of lifetime (the cluster may grant less).
286    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    /// Server-side-apply a `core/v1 Secret` (named `name`, env-ownership
294    /// labelled `env_id`) holding the minted `bearer` in `namespace` — the
295    /// durable in-cluster home for the bound identity. Goes through the SAME
296    /// guarded `KubeCluster::apply` as `apply_rbac`, so the ownership guard
297    /// fail-closes against a different env's Secret of this name and a
298    /// re-bind overwrites the bearer in place (idempotent).
299    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    /// Delete the bound identity Secret — the compensating-cleanup path when a
308    /// `--bind` bootstrap fails after the Secret was written. Idempotent: a
309    /// missing Secret (404) is `Ok(())`.
310    async fn delete_identity_secret(
311        &self,
312        namespace: &str,
313        name: &str,
314    ) -> Result<(), K8sClientError>;
315}
316
317/// K8s deployer credentials handler.
318///
319/// `Default` holds no connector (every probe fails closed);
320/// [`with_client`](Self::with_client) injects a mock in tests and
321/// [`with_connector`](Self::with_connector) defers a live
322/// [`KubeValidatorClient`](super::kube_client::KubeValidatorClient) connect
323/// into the probe runtime.
324#[derive(Default)]
325pub struct K8sDeployerCredentials {
326    connect: Option<K8sValidatorConnector>,
327    /// Namespace the SSAR sweep is scoped to. `None` ⇒ the env-derived
328    /// `namespace_for_env`; the CLI sets it from the binding answers'
329    /// resolved `K8sParams::namespace` so requirements probes the EXACT
330    /// namespace reconcile / apply-revision deploy into (a custom
331    /// `namespace` answer would otherwise be probed at `gtc-<env>`).
332    namespace: Option<String>,
333    /// Bootstrap (`--bind`) connector: when `Some`, `bootstrap` applies the
334    /// rendered RBAC live and mints the ServiceAccount token instead of
335    /// emitting a render-only pack. The CLI wires this (admin-connected)
336    /// only for `op credentials bootstrap … bind: true`; it is independent
337    /// of `connect` (the validator probe seam) — the two paths never run
338    /// together.
339    bind: Option<K8sBootstrapConnector>,
340}
341
342impl std::fmt::Debug for K8sDeployerCredentials {
343    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
344        // The connector is a closure (no `Debug`); surface only whether one
345        // is bound — that's all any caller diagnoses on.
346        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    /// Inject an already-connected validator (tests / mock). The connector
356    /// simply hands it back inside the probe runtime.
357    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    /// Connect a live validator lazily, inside the probe runtime. See
365    /// [`K8sValidatorConnector`] for why connect + probes must share a
366    /// runtime.
367    pub fn with_connector(connect: K8sValidatorConnector) -> Self {
368        Self {
369            connect: Some(connect),
370            namespace: None,
371            bind: None,
372        }
373    }
374
375    /// Build credentials wired for the `--bind` bootstrap path: a connector
376    /// that connects AS THE ADMIN (no bound SA token) and applies the
377    /// rendered RBAC + mints the deployer ServiceAccount's token. Holds no
378    /// validator connector — `validate` is not the bind path's concern, and
379    /// the two never run on the same instance.
380    pub fn with_bootstrap_connector(bind: K8sBootstrapConnector) -> Self {
381        Self {
382            connect: None,
383            namespace: None,
384            bind: Some(bind),
385        }
386    }
387
388    /// Scope the SSAR sweep to `namespace` instead of the env-derived
389    /// default — the namespace reconcile actually deploys into.
390    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    /// Reachability passed but the SSAR sweep itself errored: the
414    /// reachable cap passes, every operation cap fails with the same
415    /// reason (mirror of the AWS `sts_pass_verbs_failed` shape).
416    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
434/// Decode a JWT bearer's `iat`/`exp` claims (the projected ServiceAccount
435/// token is a signed JWT). Signature is NOT verified — this reads the token's
436/// self-reported lifetime to schedule a proactive re-mint, never to authorize
437/// anything. Returns `None` on any structural failure.
438fn 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    /// The projected ServiceAccount token is a JWT; decode its self-reported
453    /// `iat`/`exp` and schedule the re-mint at 80% of lifetime (the shared
454    /// policy in [`rotate_at_from_window`](crate::credentials::rotate)).
455    /// `None` for material that isn't a JWT with both claims — the caller then
456    /// fails open and rotates.
457    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            // No connector bound — Fail, not Skipped.
476            // `RequirementsReport::passed()` treats Skipped as
477            // non-blocking, so an all-Skipped report would persist
478            // `result: pass` even though zero capabilities were actually
479            // verified. Failing closed matches the AWS precedent (chain-
480            // missing → every cap Fail) and ensures `gtc op credentials
481            // requirements` never reports a false pass.
482            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        // Scope the SSARs to the namespace reconcile actually deploys into
499        // (the binding answers' resolved namespace), falling back to the
500        // env-derived default when the caller did not supply one.
501        let namespace = self
502            .namespace
503            .clone()
504            .unwrap_or_else(|| namespace_for_env(ctx.env_id));
505
506        // Connect + identity + access probes all run on ONE runtime. A
507        // kube::Client's tower Buffer worker is bound to the runtime that
508        // spawned it, and `run_k8s_async` drops its runtime after each call —
509        // connecting in a separate bridge call would hand us a dead worker
510        // (`buffer's worker closed unexpectedly`). See `K8sValidatorConnector`.
511        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                // Couldn't even reach/authenticate — the reachability cap
524                // (and every op) fails closed, same posture as a failed
525                // identity probe.
526                return all_failed(&caps, &format!("Kubernetes API unreachable: {e}"));
527            }
528            Err(K8sProbeError::Identity(e)) => {
529                // No usable identity — fail every cap (a deployer that
530                // requires credential material treats this as auth failure,
531                // not a skip), mirroring the AWS chain-missing posture.
532                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        // Validate response shape BEFORE building per-op checks: a
541        // partial or mis-ordered response must never authorize.
542        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        // The admin "profile" is the kubeconfig context / admin identity
589        // hint recorded in the rules pack's README — and, on the `--bind`
590        // path, the context the bind connector authenticates as.
591        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        // Scope the RBAC + token mint to the namespace the deployer actually
601        // deploys into — the binding answers' resolved `K8sParams::namespace`
602        // when set (the CLI threads it via `in_namespace`), else the
603        // env-derived default. This MUST match the namespace `requirements`
604        // probes and `reconcile` deploys into (both use `K8sParams`), or the
605        // bound token would be RoleBound in the wrong namespace. Mirrors the
606        // `self.namespace` fallback in `validate`.
607        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        // Render-only (default): no live cluster calls. The admin reviews
619        // and applies the pack offline, mints a token for the
620        // ServiceAccount, and binds it via `op credentials rotate`.
621        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        // `--bind`: apply the SAME rendered RBAC live (one source of truth —
631        // the live apply and the offline `kubectl apply -f` can never
632        // diverge) AS THE ADMIN, then mint the deployer ServiceAccount's
633        // token. Connect + apply + mint share one `run_k8s_async` call (the
634        // `kube::Client` Buffer-worker runtime constraint — see
635        // `K8sBootstrapConnector`).
636        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            // Persist the bearer into its durable in-cluster Secret on the
657            // same connection (so a fresh operator machine can resolve it
658            // from the cluster). The dev-store write the CLI sink performs
659            // stays as the bootstrapping machine's fast local cache.
660            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        // Only the `--bind` path writes a remote Secret; nothing else to undo.
695        let Some(bind) = self.bind.as_ref() else {
696            return;
697        };
698        // The namespace the bind path wrote into: the binding's resolved
699        // override, else the env-derived default (mirrors `bootstrap`).
700        let namespace = self
701            .namespace
702            .clone()
703            .unwrap_or_else(|| namespace_for_env(env_id));
704        let connector = Arc::clone(bind);
705        // Best-effort: reconnect (the bootstrap connection is long gone) and
706        // delete. EVERY error is swallowed — the caller already has a bootstrap
707        // failure to report, and cleanup must never mask it or panic. The
708        // delete is idempotent (a never-written Secret 404s harmlessly).
709        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
718/// Extract the RBAC YAML entry from a rendered rules pack — the exact bytes
719/// the `--bind` path applies live, so the live apply and the offline
720/// `kubectl apply -f` stay byte-identical.
721fn 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
729/// Where the live K8s probe sequence failed. `Connect`/`Identity` mean the
730/// API was unreachable or the credential resolves to no identity → every cap
731/// fails closed; `Access` means reachability passed but the SSAR sweep itself
732/// errored → reachability passes, ops fail.
733enum K8sProbeError {
734    Connect(K8sClientError),
735    Identity(K8sClientError),
736    Access(K8sClientError),
737}
738
739/// Every-capability-failed report with one shared reason.
740fn 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        // Core-group ops render `core`, named groups render verbatim.
857        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    /// Scaffold posture: no client wired — every probe Fail (fail closed).
865    /// `RequirementsReport::passed()` treats Skipped as non-blocking, so
866    /// an all-Skipped report would persist `result: pass` — Fail prevents
867    /// that.
868    #[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        // The SSAR sweep scoped to the env's derived namespace, one
909        // review per validated operation.
910        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    /// `in_namespace` scopes the SSAR sweep to the binding answers' resolved
917    /// namespace (what reconcile deploys into), not the env-derived default —
918    /// otherwise a custom-namespace env probes the wrong namespace.
919    #[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    /// FIX 3: a truncated (empty) decisions vec must fail closed — zip
1032    /// would silently skip every operation.
1033    #[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        // Reachable passes (identity resolved), every op fails.
1047        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    /// FIX 3: decisions returned in wrong order must fail closed.
1078    #[test]
1079    fn validate_fails_closed_on_mismatched_operation_order() {
1080        let mut decisions = all_allowed();
1081        // Swap the first two decisions so the operations don't match.
1082        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        // Every operation cap fails with a reason mentioning the mismatch.
1101        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        // Every validated verb shows up in the Role rules, and the admin
1166        // hint shows up in the README.
1167        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    /// Records what `apply_rbac` was handed and the namespace the mint
1179    /// targeted, and hands back a canned token.
1180    #[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        /// `(namespace, name, env_id, bearer)` captured from
1186        /// `apply_identity_secret` — `None` until the bind path stores it.
1187        identity_secret: Mutex<Option<(String, String, String, String)>>,
1188        /// `(namespace, name)` captured from `delete_identity_secret` — `None`
1189        /// until the compensating-cleanup path deletes it.
1190        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        // The credential is bound at the store-aligned deployer token path.
1279        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        // Granted expiry + minted material flow back to the runner.
1284        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        // The bytes applied live are EXACTLY the rules-pack RBAC entry — no
1291        // drift between the live apply and the offline `kubectl apply -f`.
1292        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        // With no namespace override, the mint targets the env-derived default.
1301        assert_eq!(
1302            mock.minted_namespace.lock().unwrap().as_deref(),
1303            Some("gtc-zain-prod")
1304        );
1305
1306        // The minted bearer is ALSO persisted into its durable in-cluster
1307        // Secret: same namespace as the mint, the env-ownership label, and the
1308        // bearer verbatim — so a fresh operator machine resolves it.
1309        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        // The CLI calls this on a FAILED `--bind` bootstrap; it must delete the
1323        // durable Secret (in the env-derived namespace, no override here) so no
1324        // live bearer is left behind.
1325        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        // A non-bind K8s credentials instance (the validator path) wrote no
1337        // remote Secret — cleanup must do nothing and never panic.
1338        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        // The CLI threads the answers-resolved namespace via `in_namespace`;
1349        // bind must apply RBAC + mint THERE, not in the env-derived default —
1350        // else the token is RoleBound where reconcile never deploys.
1351        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        // RBAC rendered + applied in the custom namespace, not `gtc-zain-prod`.
1364        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        // Token minted in the same namespace.
1372        assert_eq!(
1373            mock.minted_namespace.lock().unwrap().as_deref(),
1374            Some("custom-ns")
1375        );
1376        // The bound secret ref stays env-scoped (its store path is env-derived).
1377        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    /// Build a JWT-shaped bearer carrying the given `iat`/`exp` unix seconds
1409    /// (header + signature are inert — only the payload is decoded).
1410    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        // 1000s lifetime ⇒ rotate at iat + 800s, read from the token's claims.
1419        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); // rotate_at = iat + 800
1432        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        // Not a JWT, empty, or a non-base64 payload — `rotate_at` can't decode
1444        // a lifetime, so `rotation_due` treats every shape as due and
1445        // `--if-needed` never silently skips an undecodable token.
1446        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}