Skip to main content

greentic_deployer/cli/
credentials.rs

1//! `gtc op credentials {requirements,bootstrap,rotate}` (`C1`).
2//!
3//! Per `plans/next-gen-deployment.md` §P5, credentials are first-class with
4//! two modes:
5//!
6//! - **requirements**: validate user-supplied minimum credentials against
7//!   the deployer env-pack's declared requirements.
8//! - **bootstrap**: run the deployer env-pack's bootstrap pack against
9//!   ephemeral admin credentials; produce low-privilege output + a
10//!   reviewable rules pack.
11//! - **rotate**: re-mint the env's bound deployer credential in place
12//!   (K8s `--bind` only this round) and re-persist the fresh material,
13//!   leaving `credentials_ref` unchanged. `--if-needed` makes it the
14//!   idempotent, schedulable form — a no-op until the current token is at
15//!   80% of its lifetime — so a cron/CronJob refreshes the bound token
16//!   before it lapses instead of a full re-bind.
17//!
18//! All three resolve the env's bound deployer env-pack through the A9
19//! registry and invoke the
20//! [`DeployerCredentials`](crate::credentials::DeployerCredentials) contract
21//! shipped with that handler (C1). Deployer handlers that have not yet
22//! registered a credentials contract surface as `HandlerNotRegistered`
23//! (a structured CLI conflict, not a silent pass).
24//!
25//! Preconditions enforced at this layer (before audit, before delegating
26//! to the registry):
27//!
28//! - The env must exist.
29//! - The env must have a `Deployer` slot bound.
30//! - For `requirements`/`rotate`, the env must already have a
31//!   `credentials_ref` (the user supplied creds somewhere). For
32//!   `bootstrap`, `credentials_ref` MUST be absent (bootstrap creates it).
33//!
34//! ## Admin credentials posture
35//!
36//! `bootstrap` reads the admin material via
37//! [`load_admin_credential`](self::load_admin_credential), wraps it in
38//! [`ZeroizedAdmin`], and never writes it to the env's storage. The
39//! wrapper zeroizes the in-process buffer on drop where the language /
40//! runtime allows it. The CLI does NOT claim process-wide memory erasure
41//! is guaranteed — that's impossible (OS paging, cloud SDK internal
42//! copies, ambient profile chains live outside this process). Operators
43//! needing stronger guarantees should run bootstrap on a short-lived
44//! process (CI runner, dedicated VM).
45
46use std::path::{Path, PathBuf};
47
48use greentic_deploy_spec::EnvId;
49use serde::{Deserialize, Serialize};
50use serde_json::{Value, json};
51use thiserror::Error;
52use zeroize::Zeroizing;
53
54use crate::credentials::{
55    DeployerCredentials, RunBootstrapError, ValidateError, ZeroizedAdmin, run_bootstrap,
56    validate_requirements,
57};
58use crate::env_packs::EnvPackRegistry;
59use crate::env_packs::k8s::K8sDeployerCredentials;
60use crate::environment::{EnvironmentStore, LocalFsStore};
61
62use super::{AuditCtx, AuditGens, OpError, OpFlags, OpOutcome, audit_and_record};
63
64const NOUN: &str = "credentials";
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct CredentialsRequirementsPayload {
68    pub environment_id: String,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct CredentialsBootstrapPayload {
73    pub environment_id: String,
74    /// Local profile name (e.g. AWS named profile) used for the one-time
75    /// admin run. Never written to the env's storage.
76    pub admin_profile: String,
77    /// Path to a file holding the admin credential material. The
78    /// contents are loaded into a [`ZeroizedAdmin`] wrapper and dropped
79    /// (zeroized) before this call returns. Mutually exclusive with
80    /// `admin_material_inline`.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub admin_material_path: Option<PathBuf>,
83    /// Inline admin credential material — convenient for piping (e.g.
84    /// `gtc op credentials bootstrap … --answers <(...)`). Never
85    /// persisted by the CLI. Mutually exclusive with `admin_material_path`.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub admin_material_inline: Option<String>,
88    /// When `true`, the K8s deployer connects AS THE ADMIN (the
89    /// `admin_profile` kubeconfig context), applies the rendered RBAC live,
90    /// mints the deployer ServiceAccount's token, and binds it — instead of
91    /// emitting a render-only rules pack for offline `kubectl apply`. K8s
92    /// only this round; rejected for other deployers and for builds without
93    /// the `k8s-client` feature.
94    #[serde(default)]
95    pub bind: bool,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct CredentialsRotatePayload {
100    pub environment_id: String,
101    /// Local profile / kubeconfig context for the one-time admin connection
102    /// that re-mints the bound credential. Rotation always re-mints AS THE
103    /// ADMIN (the bound identity itself cannot create its own tokens by
104    /// design). Never written to the env's storage.
105    pub admin_profile: String,
106    /// Path to a file holding the admin credential material (loaded into a
107    /// [`ZeroizedAdmin`] and zeroized before this call returns). Mutually
108    /// exclusive with `admin_material_inline`.
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub admin_material_path: Option<PathBuf>,
111    /// Inline admin credential material — never persisted. Mutually exclusive
112    /// with `admin_material_path`.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub admin_material_inline: Option<String>,
115    /// When `true`, rotate ONLY if the current bound token is at/past 80% of
116    /// its lifetime (kubelet's projected-token refresh point). A no-op
117    /// otherwise — the idempotent form a scheduler calls on a cadence
118    /// shorter than the token lifetime. When `false` (default), rotate
119    /// unconditionally.
120    #[serde(default)]
121    pub if_needed: bool,
122}
123
124#[derive(Debug, Error)]
125enum AdminLoadError {
126    #[error("no admin material supplied: provide `admin_material_path` or `admin_material_inline`")]
127    Missing,
128    #[error("cannot supply both `admin_material_path` and `admin_material_inline`")]
129    Both,
130    #[error("read admin material from `{path}`: {source}")]
131    Io {
132        path: PathBuf,
133        #[source]
134        source: std::io::Error,
135    },
136    #[error("admin material at `{path}` is not valid UTF-8")]
137    NonUtf8 { path: PathBuf },
138    #[error("admin material is empty")]
139    Empty,
140}
141
142/// `op credentials requirements`. Resolves the env's deployer handler
143/// through the registry, runs the C1 contract's probes, returns the
144/// per-check report.
145pub fn requirements(
146    store: &LocalFsStore,
147    registry: &EnvPackRegistry,
148    flags: &OpFlags,
149    payload: Option<CredentialsRequirementsPayload>,
150) -> Result<OpOutcome, OpError> {
151    if flags.schema_only {
152        return Ok(OpOutcome::new(NOUN, "requirements", req_schema()));
153    }
154    let payload = resolve_payload::<CredentialsRequirementsPayload>(flags, payload)?;
155    let env_id = parse_env_id(&payload.environment_id)?;
156
157    // For a K8s-bound env, connect a live validator client so the SSAR
158    // probes run against the cluster the deployer actually targets. Other
159    // deployers (and `--no-default-features` builds) get `None` and fall
160    // back to the handler's own credentials probe inside the runner.
161    let connected = connected_k8s_credentials(store, &env_id)?;
162    let (doc, report) = validate_requirements(
163        store,
164        registry,
165        &env_id,
166        connected.as_ref().map(|c| c as &dyn DeployerCredentials),
167    )
168    .map_err(map_validate_err)?;
169
170    Ok(OpOutcome::new(
171        NOUN,
172        "requirements",
173        json!({
174            "environment_id": env_id.as_str(),
175            "deployer_kind": doc.deployer_kind.as_str(),
176            "credentials_ref": doc.provided_credentials_ref.as_str(),
177            "mode": "requirements",
178            "result": result_label(&doc.validation.result),
179            "missing_capabilities": doc.validation.missing_capabilities,
180            "checks": report.checks,
181            "last_run_at": doc.validation.last_run_at,
182        }),
183    ))
184}
185
186pub fn bootstrap(
187    store: &LocalFsStore,
188    registry: &EnvPackRegistry,
189    flags: &OpFlags,
190    payload: Option<CredentialsBootstrapPayload>,
191) -> Result<OpOutcome, OpError> {
192    if flags.schema_only {
193        return Ok(OpOutcome::new(NOUN, "bootstrap", bootstrap_schema()));
194    }
195    let mut payload = resolve_payload::<CredentialsBootstrapPayload>(flags, payload)?;
196    let env_id = parse_env_id(&payload.environment_id)?;
197    let bind_requested = payload.bind;
198
199    let admin = load_admin_credential(
200        &payload.admin_profile,
201        payload.admin_material_path.as_deref(),
202        &mut payload.admin_material_inline,
203    )
204    .map_err(|e| {
205        // Keep the admin-loader's specific error visible to the operator
206        // — `Conflict` is the right kind for "you supplied the wrong
207        // combination of options" / "the file you pointed at is empty".
208        OpError::Conflict(e.to_string())
209    })?;
210
211    // `bind: true` ⇒ connect AS THE ADMIN and mint+bind the deployer credential
212    // live. Dispatched by the bound deployer kind: K8s mints a ServiceAccount
213    // token, AWS assumes the scoped deployer role for an STS session. Each
214    // builder returns `None` for a non-matching deployer; both `None` ⇒ the
215    // deployer has no bind path. Built before the audit scope so the override
216    // outlives the `run_bootstrap` call.
217    let bind_creds: Option<Box<dyn DeployerCredentials>> = if bind_requested {
218        let creds = match admin_bind_k8s_credentials(store, &env_id, admin.profile())? {
219            Some(c) => Some(c),
220            None => admin_bind_aws_credentials(store, &env_id, admin.profile())?,
221        };
222        Some(creds.ok_or_else(|| {
223            OpError::Conflict(
224                "`bind: true` is supported only for K8s- and AWS-bound environments this round; \
225                 other deployers still bootstrap a render-only rules pack — drop `bind` and \
226                 apply the pack offline, then `op credentials rotate`"
227                    .to_string(),
228            )
229        })?)
230    } else {
231        None
232    };
233
234    let ctx = AuditCtx {
235        env_id: env_id.clone(),
236        noun: NOUN,
237        verb: "bootstrap",
238        // admin material is NEVER recorded — only the user-supplied
239        // profile handle (which is not a secret) and the bind mode.
240        target: json!({"admin_profile": payload.admin_profile, "bind": bind_requested}),
241        idempotency_key: None,
242    };
243    audit_and_record(store, ctx, |committed| {
244        // `run_bootstrap` holds the env flock for the entire flow:
245        // load → absence check → handler.bootstrap → rules-pack write →
246        // secret-material write → credentials_ref persist. No separate
247        // `transact` needed here.
248        let doc = match run_bootstrap(
249            store,
250            registry,
251            &env_id,
252            &admin,
253            bind_creds.as_deref(),
254            &dev_store_secret_sink,
255        ) {
256            Ok(d) => d,
257            Err(e) => {
258                // Compensating cleanup on the bootstrap error path: undo any
259                // durable bound material written before a later step failed
260                // (idempotent; no-op for non-bind deployers). See
261                // `DeployerCredentials::rollback_bound_material`.
262                if let Some(creds) = bind_creds.as_deref() {
263                    creds.rollback_bound_material(&env_id);
264                }
265                return Err(map_bootstrap_err(e));
266            }
267        };
268        committed.mark_committed();
269
270        Ok((
271            OpOutcome::new(
272                NOUN,
273                "bootstrap",
274                json!({
275                    "environment_id": env_id.as_str(),
276                    "deployer_kind": doc.deployer_kind.as_str(),
277                    "mode": "bootstrap",
278                    "bound": bind_requested,
279                    "credentials_ref": doc.provided_credentials_ref.as_str(),
280                    "expires_at": doc.expiry.as_ref().map(|e| e.expires_at),
281                    "rules_pack_ref": doc.bootstrap.as_ref().map(|b| b.rules_pack_ref.display().to_string()),
282                    "admin_credential_consumed_at": doc.bootstrap.as_ref().map(|b| b.admin_credential_consumed_at),
283                }),
284            ),
285            AuditGens::NONE,
286        ))
287    })
288}
289
290pub fn rotate(
291    store: &LocalFsStore,
292    registry: &EnvPackRegistry,
293    flags: &OpFlags,
294    payload: Option<CredentialsRotatePayload>,
295) -> Result<OpOutcome, OpError> {
296    if flags.schema_only {
297        return Ok(OpOutcome::new(NOUN, "rotate", rotate_schema()));
298    }
299    let mut payload = resolve_payload::<CredentialsRotatePayload>(flags, payload)?;
300    let env_id = parse_env_id(&payload.environment_id)?;
301    let if_needed = payload.if_needed;
302
303    // Pre-flight (before consuming admin material): the env must exist, have a
304    // deployer bound, and already be bootstrapped — rotation refreshes an
305    // existing bound credential, it never creates one.
306    let env = store.load(&env_id).map_err(|e| match e {
307        crate::environment::StoreError::NotFound(_) => {
308            OpError::NotFound(format!("environment `{env_id}`"))
309        }
310        other => OpError::Store(other),
311    })?;
312    if env
313        .pack_for_slot(greentic_deploy_spec::CapabilitySlot::Deployer)
314        .is_none()
315    {
316        return Err(OpError::Conflict(format!(
317            "env `{env_id}` has no deployer env-pack bound; bind one with `op env-packs add` first"
318        )));
319    }
320    if env.credentials_ref.is_none() {
321        return Err(OpError::Conflict(format!(
322            "env `{env_id}` has no credentials_ref; run `op credentials bootstrap` first"
323        )));
324    }
325
326    // Build the admin-connected re-mint path. Rotation always re-mints AS THE
327    // ADMIN — the bound identity cannot create its own tokens by design — so a
328    // non-K8s (render-only) deployer has nothing to rotate live. This only
329    // constructs the connector (no connection yet), so it is cheap to run
330    // before the `--if-needed` short-circuit.
331    let bind_creds = admin_bind_k8s_credentials(store, &env_id, &payload.admin_profile)?
332        .ok_or_else(|| {
333            OpError::Conflict(
334                "live rotation is only supported for K8s-bound environments this round; \
335                 other deployers re-bind via `op credentials bootstrap` against a freshly \
336                 applied rules pack"
337                    .to_string(),
338            )
339        })?;
340
341    let ctx = AuditCtx {
342        env_id: env_id.clone(),
343        noun: NOUN,
344        verb: "rotate",
345        // Admin material is NEVER recorded — only the (non-secret) profile
346        // handle and the mode.
347        target: json!({"admin_profile": payload.admin_profile, "if_needed": if_needed}),
348        idempotency_key: None,
349    };
350    // EVERY outcome — including the `--if-needed` no-op — runs inside
351    // `audit_and_record` so the local-only authorization gate AND the audit
352    // append cover all of them. Returning the no-op before this boundary would
353    // let a non-local env probe token freshness unauthorized and unaudited.
354    audit_and_record(store, ctx, |committed| {
355        // `--if-needed`: skip the re-mint (and never load admin material)
356        // unless the current bound token is at/past 80% of its lifetime.
357        // Resolves the bearer from env-var / dev-store (no cluster round-trip);
358        // an unresolvable token falls through to rotate (fail-open).
359        if if_needed
360            && let Ok(Some(bearer)) =
361                super::secrets::resolve_credentials_token(store, &env, &env_id)
362            && !bind_creds.rotation_due(&bearer, chrono::Utc::now())
363        {
364            return Ok((
365                OpOutcome::new(
366                    NOUN,
367                    "rotate",
368                    json!({
369                        "environment_id": env_id.as_str(),
370                        "rotated": false,
371                        "reason": "current token has not reached its rotation threshold (80% of lifetime)",
372                    }),
373                ),
374                AuditGens::NONE,
375            ));
376        }
377
378        let admin = load_admin_credential(
379            &payload.admin_profile,
380            payload.admin_material_path.as_deref(),
381            &mut payload.admin_material_inline,
382        )
383        .map_err(|e| OpError::Conflict(e.to_string()))?;
384
385        let outcome = crate::credentials::run_rotate(
386            store,
387            registry,
388            &env_id,
389            &admin,
390            bind_creds.as_ref(),
391            &dev_store_secret_sink,
392        )
393        .map_err(map_rotate_err)?;
394        committed.mark_committed();
395        Ok((
396            OpOutcome::new(
397                NOUN,
398                "rotate",
399                json!({
400                    "environment_id": env_id.as_str(),
401                    "rotated": true,
402                    "credentials_ref": outcome.credentials_ref.as_str(),
403                    "expires_at": outcome.expiry.as_ref().map(|e| e.expires_at),
404                    "rotate_at": outcome.expiry.as_ref().and_then(|e| e.rotate_at),
405                }),
406            ),
407            AuditGens::NONE,
408        ))
409    })
410}
411
412// --- internals -----------------------------------------------------------
413
414fn result_label(r: &greentic_deploy_spec::CredentialsValidationResult) -> &'static str {
415    match r {
416        greentic_deploy_spec::CredentialsValidationResult::Pass => "pass",
417        greentic_deploy_spec::CredentialsValidationResult::Fail => "fail",
418    }
419}
420
421/// Load admin credential material into a [`ZeroizedAdmin`] wrapper.
422///
423/// Takes `&mut` so the inline path can `std::mem::take` the material out
424/// of the payload, leaving `None` behind — explicit single-use semantics
425/// that prevents the caller from accidentally retaining the cleartext.
426///
427/// File path: reads into `Zeroizing<Vec<u8>>`, then converts via strict
428/// `String::from_utf8` (not lossy — lossy substitution silently corrupts
429/// credentials). The intermediate `Vec<u8>` is zeroized on drop.
430fn load_admin_credential(
431    admin_profile: &str,
432    admin_material_path: Option<&Path>,
433    admin_material_inline: &mut Option<String>,
434) -> Result<ZeroizedAdmin, AdminLoadError> {
435    match (admin_material_path, admin_material_inline.as_ref()) {
436        (None, None) => Err(AdminLoadError::Missing),
437        (Some(_), Some(_)) => Err(AdminLoadError::Both),
438        (Some(path), None) => {
439            let mut bytes =
440                Zeroizing::new(std::fs::read(path).map_err(|source| AdminLoadError::Io {
441                    path: path.to_path_buf(),
442                    source,
443                })?);
444            // Take the raw bytes out of the Zeroizing wrapper. The
445            // wrapper drops with an empty Vec (no-op zeroize); the
446            // bytes move into String::from_utf8. On UTF-8 success the
447            // String takes ownership of the same allocation; on failure
448            // the bytes are returned inside the error and dropped here.
449            let raw = std::mem::take(&mut *bytes);
450            let material = String::from_utf8(raw).map_err(|_| AdminLoadError::NonUtf8 {
451                path: path.to_path_buf(),
452            })?;
453            if material.trim().is_empty() {
454                return Err(AdminLoadError::Empty);
455            }
456            Ok(ZeroizedAdmin::new(admin_profile, material))
457        }
458        (None, Some(_)) => {
459            // Take the inline material out of the option — it becomes
460            // `None` after extraction, enforcing single-use.
461            let taken = admin_material_inline
462                .take()
463                .expect("matched Some branch; take cannot be None");
464            if taken.trim().is_empty() {
465                return Err(AdminLoadError::Empty);
466            }
467            Ok(ZeroizedAdmin::new(admin_profile, taken))
468        }
469    }
470}
471
472// Shared message helpers — both mappers below enrich the library-level
473// error with operator-action guidance (e.g. "bind one with `op env-packs
474// add` first"). The source `#[error(...)]` strings on `ValidateError` /
475// `RunBootstrapError` are deliberately library-shaped (no CLI verb hints),
476// so the CLI mapper layer owns the operator-facing wording.
477fn no_deployer_bound_msg(env_id: &EnvId) -> String {
478    format!("env `{env_id}` has no deployer env-pack bound; bind one with `op env-packs add` first")
479}
480
481fn handler_not_registered_msg(kind: &str) -> String {
482    format!(
483        "deployer env-pack `{kind}` has no native credentials handler registered (Phase D plug-in)"
484    )
485}
486
487fn map_validate_err(e: ValidateError) -> OpError {
488    match e {
489        ValidateError::NoDeployerBound(env_id) => OpError::Conflict(no_deployer_bound_msg(&env_id)),
490        ValidateError::NoCredentialsRef(env_id) => OpError::Conflict(format!(
491            "env `{env_id}` has no credentials_ref; run `op credentials bootstrap` first"
492        )),
493        ValidateError::HandlerNotRegistered { kind } => {
494            OpError::Conflict(handler_not_registered_msg(&kind))
495        }
496        ValidateError::Store(s) => OpError::Store(s),
497        ValidateError::Registry(r) => OpError::Conflict(r.to_string()),
498    }
499}
500
501/// Connect a live [`K8sValidatorClient`](crate::env_packs::k8s::K8sValidatorClient)
502/// for a K8s-bound env so `op credentials requirements` runs its
503/// `SelfSubjectAccessReview` probes against the cluster the deployer
504/// actually targets.
505///
506/// Returns `Ok(None)` for any non-K8s deployer (the runner falls back to
507/// the handler's own credentials) and for `--no-default-features` builds
508/// that lack the `k8s-client` feature. Fails closed (`Conflict`) when the
509/// binding's answers are unreadable or the cluster cannot be reached — the
510/// same posture as `op env reconcile`. The env's `credentials_ref` is
511/// resolved to a ServiceAccount bearer (identical to `reconcile` /
512/// `apply-revision`): a bound token authenticates the probe as that
513/// ServiceAccount, so the SSAR sweep reflects the deployer's real RBAC, not
514/// the ambient admin's; `None` → the ambient kubeconfig / in-cluster identity.
515#[cfg(feature = "k8s-client")]
516fn connected_k8s_credentials(
517    store: &LocalFsStore,
518    env_id: &EnvId,
519) -> Result<Option<K8sDeployerCredentials>, OpError> {
520    use crate::cli::env::load_render_answers;
521    use crate::env_packs::k8s::K8sDeployerHandler;
522    use crate::env_packs::k8s::credentials::{
523        K8sValidatorClient, K8sValidatorConnectFut, K8sValidatorConnector,
524    };
525    use crate::env_packs::k8s::kube_client::{KubeValidatorClient, connect};
526    use crate::env_packs::k8s::manifests::{K8sParams, kubeconfig_context_from_answers};
527    use std::sync::Arc;
528
529    // If the env can't even be loaded, leave it to the runner to surface
530    // the proper NotFound / store error.
531    let Ok(env) = store.load(env_id) else {
532        return Ok(None);
533    };
534    let Some(binding) = env.pack_for_slot(greentic_deploy_spec::CapabilitySlot::Deployer) else {
535        return Ok(None);
536    };
537    if binding.kind.path() != K8sDeployerHandler::DESCRIPTOR_PATH {
538        return Ok(None);
539    }
540
541    // Fail closed if the recorded answers are broken (mirrors render /
542    // reconcile); a K8s env with no answers connects to the ambient
543    // context.
544    let (answers, _wire) = load_render_answers(store, &env, &binding.kind)?;
545    let kubeconfig_context = kubeconfig_context_from_answers(answers.as_ref());
546    // Probe the SAME namespace reconcile / apply-revision deploy into — the
547    // answers may override the env-derived default.
548    let namespace = K8sParams::from_answers(&env, answers.as_ref())
549        .map_err(|e| OpError::Conflict(format!("invalid K8s answers: {e}")))?
550        .namespace;
551    // Resolve the env's bound deployer credential to a ServiceAccount bearer so
552    // the probe authenticates as the deployer's identity, not the ambient
553    // admin. `None` → ambient (no bound credential); fail-closed if a ref is
554    // bound but unresolvable. Beyond env-var / dev-store, the resolver reads
555    // the durable in-cluster identity Secret (ambient) as a last resort.
556    let bound_token =
557        crate::env_packs::k8s::resolve_bound_identity(store, &env, env_id, answers.as_ref())?;
558    // Defer the connect into the probe runtime instead of connecting here: a
559    // kube::Client's tower Buffer worker is bound to the runtime that spawned
560    // it, and `run_k8s_async` drops its runtime after each call, so a client
561    // connected in this (separate) bridge call would reach `validate` with a
562    // dead worker. The connector runs connect + probes on one runtime.
563    let connector: K8sValidatorConnector = Arc::new(move || -> K8sValidatorConnectFut {
564        let kubeconfig_context = kubeconfig_context.clone();
565        let bound_token = bound_token.clone();
566        Box::pin(async move {
567            let client = connect(kubeconfig_context.as_deref(), bound_token.as_deref()).await?;
568            Ok(Arc::new(KubeValidatorClient::new(client)) as Arc<dyn K8sValidatorClient>)
569        })
570    });
571    Ok(Some(
572        K8sDeployerCredentials::with_connector(connector).in_namespace(namespace),
573    ))
574}
575
576/// `k8s-client`-less builds cannot connect a validator; the runner falls
577/// back to the handler's (fail-closed) default credentials.
578#[cfg(not(feature = "k8s-client"))]
579fn connected_k8s_credentials(
580    _store: &LocalFsStore,
581    _env_id: &EnvId,
582) -> Result<Option<K8sDeployerCredentials>, OpError> {
583    Ok(None)
584}
585
586/// Build admin-connected K8s credentials for the `--bind` bootstrap path:
587/// a bootstrap connector that authenticates AS THE ADMIN (the
588/// `admin_profile` kubeconfig context, no bound SA token) and applies the
589/// rendered RBAC + mints the deployer ServiceAccount's token. Returns
590/// `None` when the env is not K8s-bound (the caller rejects `--bind` for
591/// other deployers). Boxed as `dyn DeployerCredentials` so the runner's
592/// `creds_override` seam stays deployer-agnostic.
593#[cfg(feature = "k8s-client")]
594fn admin_bind_k8s_credentials(
595    store: &LocalFsStore,
596    env_id: &EnvId,
597    admin_profile: &str,
598) -> Result<Option<Box<dyn DeployerCredentials>>, OpError> {
599    use crate::cli::env::load_render_answers;
600    use crate::env_packs::k8s::K8sDeployerHandler;
601    use crate::env_packs::k8s::credentials::{
602        K8sBootstrapClient, K8sBootstrapConnectFut, K8sBootstrapConnector, K8sDeployerCredentials,
603    };
604    use crate::env_packs::k8s::kube_client::{KubeBootstrapClient, connect};
605    use crate::env_packs::k8s::manifests::K8sParams;
606    use std::sync::Arc;
607
608    // Not loadable / no deployer bound / non-K8s ⇒ `None`; the runner (or
609    // the caller's `--bind` guard) surfaces the proper error.
610    let Ok(env) = store.load(env_id) else {
611        return Ok(None);
612    };
613    let Some(binding) = env.pack_for_slot(greentic_deploy_spec::CapabilitySlot::Deployer) else {
614        return Ok(None);
615    };
616    if binding.kind.path() != K8sDeployerHandler::DESCRIPTOR_PATH {
617        return Ok(None);
618    }
619
620    // Resolve the namespace reconcile / requirements actually use (the
621    // binding answers' `K8sParams::namespace`, falling back to the
622    // env-derived default) and scope the bind there — applying RBAC + minting
623    // the token in `gtc-<env>` while reconcile deploys into a custom namespace
624    // would RoleBind the token in the wrong place. Same resolution as
625    // `connected_k8s_credentials`.
626    let (answers, _wire) = load_render_answers(store, &env, &binding.kind)?;
627    let namespace = K8sParams::from_answers(&env, answers.as_ref())
628        .map_err(|e| OpError::Conflict(format!("invalid K8s answers: {e}")))?
629        .namespace;
630
631    let admin_context = admin_profile.to_string();
632    let connector: K8sBootstrapConnector = Arc::new(move || -> K8sBootstrapConnectFut {
633        let admin_context = admin_context.clone();
634        Box::pin(async move {
635            // Authenticate as the admin kubeconfig context (no bound token);
636            // that identity must hold rights to create the SA/Role/
637            // RoleBinding and call the TokenRequest subresource.
638            let client = connect(Some(&admin_context), None).await?;
639            Ok(Arc::new(KubeBootstrapClient::new(client)) as Arc<dyn K8sBootstrapClient>)
640        })
641    });
642    Ok(Some(Box::new(
643        K8sDeployerCredentials::with_bootstrap_connector(connector).in_namespace(namespace),
644    )))
645}
646
647/// `k8s-client`-less builds cannot connect a bind client — `--bind` is a
648/// hard error rather than a silent fall-through to render-only.
649#[cfg(not(feature = "k8s-client"))]
650fn admin_bind_k8s_credentials(
651    _store: &LocalFsStore,
652    _env_id: &EnvId,
653    _admin_profile: &str,
654) -> Result<Option<Box<dyn DeployerCredentials>>, OpError> {
655    Err(OpError::Conflict(
656        "`bind: true` requires a build with the `k8s-client` feature".to_string(),
657    ))
658}
659
660/// Build admin-connected AWS credentials for the `--bind` bootstrap path: a
661/// connector that resolves the `admin_profile` chain and assumes the scoped
662/// deployer role (the binding answers' `assume_role_arn`, created by the
663/// rules-pack Terraform the admin already applied) to mint a short-lived STS
664/// session. Returns `None` when the env is not AWS-bound (the caller then tries
665/// the next deployer / rejects `--bind`). Unlike K8s, nothing is applied live
666/// here — the role must pre-exist, so `assume_role_arn` is required.
667#[cfg(feature = "creds-aws")]
668fn admin_bind_aws_credentials(
669    store: &LocalFsStore,
670    env_id: &EnvId,
671    admin_profile: &str,
672) -> Result<Option<Box<dyn DeployerCredentials>>, OpError> {
673    use crate::cli::env::load_render_answers;
674    use crate::env_packs::aws::AwsEcsDeployerHandler;
675    use crate::env_packs::aws::credentials::{
676        AwsBootstrapClient, AwsBootstrapConnectFut, AwsBootstrapConnector, AwsDeployerCredentials,
677        RealAwsBootstrapClient,
678    };
679    use crate::env_packs::aws::deployer::AwsEcsParams;
680    use std::sync::Arc;
681
682    let Ok(env) = store.load(env_id) else {
683        return Ok(None);
684    };
685    let Some(binding) = env.pack_for_slot(greentic_deploy_spec::CapabilitySlot::Deployer) else {
686        return Ok(None);
687    };
688    if binding.kind.path() != AwsEcsDeployerHandler::DESCRIPTOR_PATH {
689        return Ok(None);
690    }
691
692    // The role to assume is the binding's `assume_role_arn`. Without it there
693    // is nothing to bind — fail loudly rather than minting an over-broad
694    // session from the base chain.
695    let (answers, _wire) = load_render_answers(store, &env, &binding.kind)?;
696    let role_arn = AwsEcsParams::from_answers(&env, answers.as_ref())
697        .map_err(|e| OpError::Conflict(format!("invalid AWS answers: {e}")))?
698        .assume_role_arn
699        .ok_or_else(|| {
700            OpError::Conflict(
701                "AWS `bind: true` requires the binding's `assume_role_arn` answer (the deployer \
702                 role the rules-pack Terraform creates); set it via the env wizard, or drop \
703                 `bind` and apply the pack offline, then `op credentials rotate`"
704                    .to_string(),
705            )
706        })?;
707
708    let profile = admin_profile.to_string();
709    let connector: AwsBootstrapConnector = Arc::new(move || -> AwsBootstrapConnectFut {
710        let profile = profile.clone();
711        Box::pin(async move {
712            // Authenticate as the admin profile (the identity allowed to
713            // assume the deployer role).
714            let client = RealAwsBootstrapClient::resolve(&profile).await?;
715            Ok(Arc::new(client) as Arc<dyn AwsBootstrapClient>)
716        })
717    });
718    Ok(Some(Box::new(
719        AwsDeployerCredentials::with_bootstrap_assume(role_arn, connector),
720    )))
721}
722
723/// `creds-aws`-less builds cannot assume a deployer role — `--bind` is a hard
724/// error rather than a silent fall-through to render-only.
725#[cfg(not(feature = "creds-aws"))]
726fn admin_bind_aws_credentials(
727    _store: &LocalFsStore,
728    _env_id: &EnvId,
729    _admin_profile: &str,
730) -> Result<Option<Box<dyn DeployerCredentials>>, OpError> {
731    Err(OpError::Conflict(
732        "`bind: true` for AWS requires a build with the `creds-aws` feature".to_string(),
733    ))
734}
735
736/// Dev-store sink shared by `bootstrap` and `rotate`: persists minted bearer
737/// material at the location `resolve_credentials_token` reads it back from.
738/// Passed as the [`BoundSecretSink`](crate::credentials::BoundSecretSink) the
739/// runner invokes inside the env flock.
740fn dev_store_secret_sink(
741    env_root: &Path,
742    secret_ref: &greentic_deploy_spec::SecretRef,
743    value: &str,
744) -> Result<(), String> {
745    super::secrets::put_credential_material(env_root, secret_ref, value).map_err(|e| e.to_string())
746}
747
748fn map_bootstrap_err(e: RunBootstrapError) -> OpError {
749    use crate::credentials::BootstrapError;
750    match e {
751        RunBootstrapError::NoDeployerBound(env_id) => {
752            OpError::Conflict(no_deployer_bound_msg(&env_id))
753        }
754        RunBootstrapError::AlreadyBootstrapped(env_id) => OpError::Conflict(format!(
755            "env `{env_id}` already has credentials_ref; use `rotate` instead of `bootstrap`"
756        )),
757        RunBootstrapError::HandlerNotRegistered { kind } => {
758            OpError::Conflict(handler_not_registered_msg(&kind))
759        }
760        RunBootstrapError::Store(s) => OpError::Store(s),
761        RunBootstrapError::Registry(r) => OpError::Conflict(r.to_string()),
762        RunBootstrapError::Bootstrap(BootstrapError::NotApplicable(msg)) => OpError::Conflict(msg),
763        RunBootstrapError::Bootstrap(BootstrapError::AdminRejected(msg)) => {
764            OpError::Conflict(format!("admin credential rejected: {msg}"))
765        }
766        RunBootstrapError::Bootstrap(BootstrapError::ProvisioningFailed { step, message }) => {
767            OpError::Conflict(format!("bootstrap failed during {step}: {message}"))
768        }
769        RunBootstrapError::RulesExport(r) => OpError::Conflict(format!("rules export: {r}")),
770        RunBootstrapError::SecretWrite(msg) => OpError::Conflict(format!(
771            "failed to persist bound credential material: {msg}"
772        )),
773    }
774}
775
776fn map_rotate_err(e: crate::credentials::RunRotateError) -> OpError {
777    use crate::credentials::{BootstrapError, RunRotateError as E};
778    match e {
779        E::NoDeployerBound(env_id) => OpError::Conflict(no_deployer_bound_msg(&env_id)),
780        E::NotBootstrapped(env_id) => OpError::Conflict(format!(
781            "env `{env_id}` has no credentials_ref; run `op credentials bootstrap` first"
782        )),
783        E::RotationUnsupported(msg) => OpError::Conflict(msg),
784        E::Store(s) => OpError::Store(s),
785        E::Registry(r) => OpError::Conflict(r.to_string()),
786        E::Bootstrap(BootstrapError::NotApplicable(msg)) => OpError::Conflict(msg),
787        E::Bootstrap(BootstrapError::AdminRejected(msg)) => {
788            OpError::Conflict(format!("admin credential rejected: {msg}"))
789        }
790        E::Bootstrap(BootstrapError::ProvisioningFailed { step, message }) => {
791            OpError::Conflict(format!("rotation failed during {step}: {message}"))
792        }
793        E::SecretWrite(msg) => OpError::Conflict(format!(
794            "failed to persist rotated credential material: {msg}"
795        )),
796    }
797}
798
799fn resolve_payload<T: serde::de::DeserializeOwned>(
800    flags: &OpFlags,
801    payload: Option<T>,
802) -> Result<T, OpError> {
803    if let Some(p) = payload {
804        return Ok(p);
805    }
806    if let Some(path) = &flags.answers {
807        return super::load_answers::<T>(path);
808    }
809    Err(OpError::InvalidArgument(
810        "no payload provided: pass --answers <path> or supply the payload directly".to_string(),
811    ))
812}
813
814fn parse_env_id(raw: &str) -> Result<EnvId, OpError> {
815    EnvId::try_from(raw).map_err(|e| OpError::InvalidArgument(format!("environment_id: {e}")))
816}
817
818fn req_schema() -> Value {
819    json!({
820        "$schema": "https://json-schema.org/draft/2020-12/schema",
821        "title": "CredentialsRequirementsPayload",
822        "type": "object",
823        "required": ["environment_id"],
824        "additionalProperties": false,
825        "properties": {"environment_id": {"type": "string"}}
826    })
827}
828
829fn bootstrap_schema() -> Value {
830    json!({
831        "$schema": "https://json-schema.org/draft/2020-12/schema",
832        "title": "CredentialsBootstrapPayload",
833        "type": "object",
834        "required": ["environment_id", "admin_profile"],
835        "additionalProperties": false,
836        "properties": {
837            "environment_id": {"type": "string"},
838            "admin_profile": {"type": "string"},
839            "admin_material_path": {"type": "string", "description": "path to a file holding the admin credential material (zeroized on drop)"},
840            "admin_material_inline": {"type": "string", "description": "inline admin credential material (zeroized on drop); mutually exclusive with admin_material_path"},
841            "bind": {"type": "boolean", "description": "K8s only: connect as the admin (admin_profile kubeconfig context), apply the RBAC live, mint + bind the ServiceAccount token instead of emitting a render-only rules pack"}
842        }
843    })
844}
845
846fn rotate_schema() -> Value {
847    json!({
848        "$schema": "https://json-schema.org/draft/2020-12/schema",
849        "title": "CredentialsRotatePayload",
850        "type": "object",
851        "required": ["environment_id", "admin_profile"],
852        "additionalProperties": false,
853        "properties": {
854            "environment_id": {"type": "string"},
855            "admin_profile": {"type": "string", "description": "kubeconfig context / admin identity that re-mints the bound credential (never persisted)"},
856            "admin_material_path": {"type": "string", "description": "path to a file holding the admin credential material (zeroized on drop); mutually exclusive with admin_material_inline"},
857            "admin_material_inline": {"type": "string", "description": "inline admin credential material (zeroized on drop); mutually exclusive with admin_material_path"},
858            "if_needed": {"type": "boolean", "description": "rotate only if the current bound token is at/past 80% of its lifetime; a no-op otherwise (the idempotent form a scheduler calls)"}
859        }
860    })
861}
862
863// Backwards-compat shim so existing dispatch sites that call into the
864// 3-arg form (without an explicit registry) still build. Callers that
865// don't yet pass a registry get the built-in set (5 default `local`
866// handlers); Phase D registers more through `EnvPackRegistry::register`.
867//
868// Kept private — `dispatch::dispatch_credentials` is updated to pass a
869// registry explicitly. Tests use this shim for convenience.
870#[cfg(test)]
871pub(crate) fn requirements_default(
872    store: &LocalFsStore,
873    flags: &OpFlags,
874    payload: Option<CredentialsRequirementsPayload>,
875) -> Result<OpOutcome, OpError> {
876    requirements(store, &EnvPackRegistry::with_builtins(), flags, payload)
877}
878
879#[cfg(test)]
880pub(crate) fn bootstrap_default(
881    store: &LocalFsStore,
882    flags: &OpFlags,
883    payload: Option<CredentialsBootstrapPayload>,
884) -> Result<OpOutcome, OpError> {
885    bootstrap(store, &EnvPackRegistry::with_builtins(), flags, payload)
886}
887
888#[cfg(test)]
889pub(crate) fn rotate_default(
890    store: &LocalFsStore,
891    flags: &OpFlags,
892    payload: Option<CredentialsRotatePayload>,
893) -> Result<OpOutcome, OpError> {
894    rotate(store, &EnvPackRegistry::with_builtins(), flags, payload)
895}
896
897#[cfg(test)]
898mod tests {
899    use super::*;
900    use crate::cli::tests_common::{make_binding, make_env};
901    use crate::environment::EnvironmentStore;
902    use greentic_deploy_spec::{CapabilitySlot, SecretRef};
903    use tempfile::tempdir;
904
905    #[test]
906    fn requirements_rejects_env_without_deployer() {
907        let dir = tempdir().unwrap();
908        let store = LocalFsStore::new(dir.path());
909        store.save(&make_env("local")).unwrap();
910        let err = requirements_default(
911            &store,
912            &OpFlags::default(),
913            Some(CredentialsRequirementsPayload {
914                environment_id: "local".to_string(),
915            }),
916        )
917        .unwrap_err();
918        assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
919    }
920
921    /// A no-material deployer (like local-process) passes requirements
922    /// even without a `credentials_ref` on the env.
923    #[test]
924    fn requirements_passes_for_no_material_deployer_without_credentials_ref() {
925        let dir = tempdir().unwrap();
926        let store = LocalFsStore::new(dir.path());
927        let mut env = make_env("local");
928        env.packs.push(make_binding(
929            CapabilitySlot::Deployer,
930            "greentic.deployer.local-process@0.1.0",
931        ));
932        store.save(&env).unwrap();
933        let outcome = requirements_default(
934            &store,
935            &OpFlags::default(),
936            Some(CredentialsRequirementsPayload {
937                environment_id: "local".to_string(),
938            }),
939        )
940        .unwrap();
941        assert_eq!(outcome.result["mode"], "requirements");
942        assert_eq!(outcome.result["result"], "pass");
943    }
944
945    /// The live-validator wiring only fires for a K8s-bound deployer: any
946    /// other deployer yields `None` and the runner falls back to the
947    /// handler's own probe (so this path never opens a socket for, e.g.,
948    /// local-process).
949    #[cfg(feature = "k8s-client")]
950    #[test]
951    fn connected_k8s_credentials_is_none_for_non_k8s_deployer() {
952        let dir = tempdir().unwrap();
953        let store = LocalFsStore::new(dir.path());
954        let mut env = make_env("local");
955        env.packs.push(make_binding(
956            CapabilitySlot::Deployer,
957            "greentic.deployer.local-process@0.1.0",
958        ));
959        store.save(&env).unwrap();
960        let got = connected_k8s_credentials(&store, &EnvId::try_from("local").unwrap())
961            .expect("non-K8s detection never errors");
962        assert!(
963            got.is_none(),
964            "non-K8s deployer must not connect a validator client"
965        );
966    }
967
968    /// With no native credentials handler registered for the bound
969    /// deployer, the CLI surfaces a structured `Conflict` (not a silent
970    /// pass-through). C2 wires the local-process handler so this case
971    /// passes; here we exercise an arbitrary deployer kind to confirm
972    /// the "no handler" path.
973    #[test]
974    fn requirements_with_unregistered_deployer_kind_yields_conflict() {
975        let dir = tempdir().unwrap();
976        let store = LocalFsStore::new(dir.path());
977        let mut env = make_env("local");
978        env.packs.push(make_binding(
979            CapabilitySlot::Deployer,
980            // No handler is registered for this fictional kind — the
981            // registry's built-in set covers local-process (C2) and (when
982            // `creds-aws` is on) aws-ecs (C3). A bare fictional kind
983            // exercises the resolve-failure path without depending on
984            // which env-packs are or aren't registered today.
985            "acme.deployer.fictional@1.0.0",
986        ));
987        env.credentials_ref = Some(SecretRef::try_new("secret://local/credentials/aws").unwrap());
988        store.save(&env).unwrap();
989        let err = requirements_default(
990            &store,
991            &OpFlags::default(),
992            Some(CredentialsRequirementsPayload {
993                environment_id: "local".to_string(),
994            }),
995        )
996        .unwrap_err();
997        // The deployer kind isn't registered → registry resolve fails
998        // with `Unknown(kind)`; map_validate_err converts that to a
999        // Conflict carrying the registry message.
1000        assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
1001    }
1002
1003    #[test]
1004    fn bootstrap_rejects_when_creds_already_set() {
1005        let dir = tempdir().unwrap();
1006        let store = LocalFsStore::new(dir.path());
1007        let mut env = make_env("local");
1008        env.packs.push(make_binding(
1009            CapabilitySlot::Deployer,
1010            "greentic.deployer.aws-ecs@1.0.0",
1011        ));
1012        env.credentials_ref = Some(SecretRef::try_new("secret://local/credentials/aws").unwrap());
1013        store.save(&env).unwrap();
1014        let err = bootstrap_default(
1015            &store,
1016            &OpFlags::default(),
1017            Some(CredentialsBootstrapPayload {
1018                environment_id: "local".to_string(),
1019                admin_profile: "admin".to_string(),
1020                admin_material_path: None,
1021                admin_material_inline: Some("ADMIN_TOKEN".to_string()),
1022                bind: false,
1023            }),
1024        )
1025        .unwrap_err();
1026        assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
1027    }
1028
1029    #[test]
1030    fn bootstrap_requires_admin_material() {
1031        let dir = tempdir().unwrap();
1032        let store = LocalFsStore::new(dir.path());
1033        let mut env = make_env("local");
1034        env.packs.push(make_binding(
1035            CapabilitySlot::Deployer,
1036            "greentic.deployer.aws-ecs@1.0.0",
1037        ));
1038        store.save(&env).unwrap();
1039        let err = bootstrap_default(
1040            &store,
1041            &OpFlags::default(),
1042            Some(CredentialsBootstrapPayload {
1043                environment_id: "local".to_string(),
1044                admin_profile: "admin".to_string(),
1045                admin_material_path: None,
1046                admin_material_inline: None,
1047                bind: false,
1048            }),
1049        )
1050        .unwrap_err();
1051        assert!(
1052            matches!(err, OpError::Conflict(ref m) if m.contains("no admin material")),
1053            "got {err:?}"
1054        );
1055    }
1056
1057    #[test]
1058    fn bootstrap_rejects_both_path_and_inline() {
1059        let dir = tempdir().unwrap();
1060        let store = LocalFsStore::new(dir.path());
1061        let mut env = make_env("local");
1062        env.packs.push(make_binding(
1063            CapabilitySlot::Deployer,
1064            "greentic.deployer.aws-ecs@1.0.0",
1065        ));
1066        store.save(&env).unwrap();
1067        let err = bootstrap_default(
1068            &store,
1069            &OpFlags::default(),
1070            Some(CredentialsBootstrapPayload {
1071                environment_id: "local".to_string(),
1072                admin_profile: "admin".to_string(),
1073                admin_material_path: Some("/tmp/x".into()),
1074                admin_material_inline: Some("y".into()),
1075                bind: false,
1076            }),
1077        )
1078        .unwrap_err();
1079        assert!(
1080            matches!(err, OpError::Conflict(ref m) if m.contains("cannot supply both")),
1081            "got {err:?}"
1082        );
1083    }
1084
1085    /// C2 end-to-end: a fully-configured `local` env with the C2
1086    /// LocalProcessDeployerHandler bound returns a structured pass
1087    /// report for both capabilities. Exercises the full chain:
1088    /// CLI → registry → DeployerCredentials → probes → OpOutcome.
1089    #[test]
1090    fn requirements_against_c2_local_process_handler_returns_pass() {
1091        use crate::defaults::LOCAL_DEPLOYER_PACK;
1092
1093        let dir = tempdir().unwrap();
1094        let store = LocalFsStore::new(dir.path());
1095        let mut env = make_env("local");
1096        env.packs
1097            .push(make_binding(CapabilitySlot::Deployer, LOCAL_DEPLOYER_PACK));
1098        env.credentials_ref =
1099            Some(SecretRef::try_new("secret://local/credentials/local-process").unwrap());
1100        store.save(&env).unwrap();
1101
1102        // Pick a high port range that's almost certainly free on a test
1103        // runner — same trick the C2 unit tests use.
1104        let registry = {
1105            let mut r = EnvPackRegistry::new();
1106            for h in crate::env_packs::BUILTIN_HANDLERS {
1107                r.register(Box::new(*h)).unwrap();
1108            }
1109            r.register(Box::new(
1110                crate::env_packs::LocalProcessDeployerHandler::with_port_range(49000..=49100),
1111            ))
1112            .unwrap();
1113            r
1114        };
1115
1116        let outcome = requirements(
1117            &store,
1118            &registry,
1119            &OpFlags::default(),
1120            Some(CredentialsRequirementsPayload {
1121                environment_id: "local".to_string(),
1122            }),
1123        )
1124        .expect("requirements should succeed against c2 local-process");
1125        assert_eq!(outcome.op, "requirements");
1126        assert_eq!(outcome.noun, NOUN);
1127        assert_eq!(outcome.result["result"], "pass");
1128        assert!(
1129            outcome.result["missing_capabilities"]
1130                .as_array()
1131                .map(|a| a.is_empty())
1132                .unwrap_or(false),
1133            "no missing caps; got {outcome:?}"
1134        );
1135        let checks = outcome.result["checks"].as_array().unwrap();
1136        assert_eq!(checks.len(), 2);
1137    }
1138
1139    /// C2 end-to-end: bootstrap against local-process refuses with
1140    /// `NotApplicable`, surfaced as `OpError::Conflict` and recorded in
1141    /// the audit log.
1142    #[test]
1143    fn bootstrap_against_c2_local_process_handler_refuses_as_not_applicable() {
1144        use crate::defaults::LOCAL_DEPLOYER_PACK;
1145
1146        let dir = tempdir().unwrap();
1147        let store = LocalFsStore::new(dir.path());
1148        let mut env = make_env("local");
1149        env.packs
1150            .push(make_binding(CapabilitySlot::Deployer, LOCAL_DEPLOYER_PACK));
1151        store.save(&env).unwrap();
1152
1153        let err = bootstrap_default(
1154            &store,
1155            &OpFlags::default(),
1156            Some(CredentialsBootstrapPayload {
1157                environment_id: "local".to_string(),
1158                admin_profile: "admin".to_string(),
1159                admin_material_path: None,
1160                admin_material_inline: Some("any-admin-token".to_string()),
1161                bind: false,
1162            }),
1163        )
1164        .unwrap_err();
1165        match err {
1166            OpError::Conflict(msg) => {
1167                assert!(
1168                    msg.contains("no admin escalation") || msg.contains("requirements"),
1169                    "Conflict message should point user at `requirements`, got: {msg}"
1170                );
1171            }
1172            other => panic!("expected Conflict (NotApplicable mapped), got {other:?}"),
1173        }
1174        // The reload-into-env step never happened: credentials_ref stays None.
1175        let reloaded = store.load(&"local".try_into().unwrap()).unwrap();
1176        assert!(reloaded.credentials_ref.is_none());
1177    }
1178
1179    /// Build a rotate payload with the admin handle filled in (rotation
1180    /// always re-mints as the admin); material is omitted because the
1181    /// rejection paths under test short-circuit before it is consumed.
1182    fn rotate_payload(env_id: &str) -> CredentialsRotatePayload {
1183        CredentialsRotatePayload {
1184            environment_id: env_id.to_string(),
1185            admin_profile: "admin-ctx".to_string(),
1186            admin_material_path: None,
1187            admin_material_inline: None,
1188            if_needed: false,
1189        }
1190    }
1191
1192    #[test]
1193    fn rotate_rejects_env_without_credentials_ref() {
1194        let dir = tempdir().unwrap();
1195        let store = LocalFsStore::new(dir.path());
1196        let mut env = make_env("local");
1197        env.packs.push(make_binding(
1198            CapabilitySlot::Deployer,
1199            "greentic.deployer.aws-ecs@1.0.0",
1200        ));
1201        store.save(&env).unwrap();
1202        let err =
1203            rotate_default(&store, &OpFlags::default(), Some(rotate_payload("local"))).unwrap_err();
1204        assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
1205    }
1206
1207    /// Live rotation is K8s-only: a non-K8s (render-only) deployer that IS
1208    /// bootstrapped is rejected with a Conflict directing the operator to
1209    /// re-bind via `bootstrap` (no misleading success, no re-mint attempt).
1210    #[test]
1211    fn rotate_rejects_non_k8s_deployer() {
1212        let dir = tempdir().unwrap();
1213        let store = LocalFsStore::new(dir.path());
1214        let mut env = make_env("local");
1215        env.packs.push(make_binding(
1216            CapabilitySlot::Deployer,
1217            "greentic.deployer.local-process@0.1.0",
1218        ));
1219        env.credentials_ref = Some(SecretRef::try_new("secret://local/credentials/test").unwrap());
1220        store.save(&env).unwrap();
1221        let err =
1222            rotate_default(&store, &OpFlags::default(), Some(rotate_payload("local"))).unwrap_err();
1223        assert!(
1224            matches!(err, OpError::Conflict(_)),
1225            "non-K8s rotation must be a Conflict, got {err:?}"
1226        );
1227    }
1228
1229    /// A no-material deployer handler works against an env with no
1230    /// `credentials_ref` — the validate_requirements runner uses a
1231    /// sentinel ref instead of rejecting with NoCredentialsRef.
1232    #[test]
1233    fn no_material_deployer_requirements_without_credentials_ref() {
1234        use crate::credentials::{
1235            BootstrapError, BootstrapInput, BootstrapOutcome, Capability, DeployerCredentials,
1236            RequirementsReport, ValidationContext,
1237        };
1238        use crate::env_packs::EnvPackHandler;
1239
1240        #[derive(Debug)]
1241        struct NoMaterialHandler;
1242
1243        impl EnvPackHandler for NoMaterialHandler {
1244            fn slot(&self) -> CapabilitySlot {
1245                CapabilitySlot::Deployer
1246            }
1247            fn descriptor_path(&self) -> &str {
1248                "test.deployer.no-material"
1249            }
1250            fn supported_versions(&self) -> semver::VersionReq {
1251                "^0.1.0".parse().unwrap()
1252            }
1253            fn deployer_credentials(&self) -> Option<&dyn DeployerCredentials> {
1254                Some(&NoMaterialCreds)
1255            }
1256        }
1257
1258        #[derive(Debug)]
1259        struct NoMaterialCreds;
1260
1261        impl DeployerCredentials for NoMaterialCreds {
1262            fn requires_credentials_material(&self) -> bool {
1263                false
1264            }
1265            fn required_capabilities(&self) -> Vec<Capability> {
1266                Vec::new()
1267            }
1268            fn validate(&self, _ctx: &ValidationContext<'_>) -> RequirementsReport {
1269                RequirementsReport::new(Vec::new())
1270            }
1271            fn bootstrap(
1272                &self,
1273                _input: &BootstrapInput<'_>,
1274            ) -> Result<BootstrapOutcome, BootstrapError> {
1275                Err(BootstrapError::NotApplicable(
1276                    "no admin escalation".to_string(),
1277                ))
1278            }
1279        }
1280
1281        let dir = tempdir().unwrap();
1282        let store = LocalFsStore::new(dir.path());
1283        let mut env = make_env("local");
1284        env.packs.push(make_binding(
1285            CapabilitySlot::Deployer,
1286            "test.deployer.no-material@0.1.0",
1287        ));
1288        // No credentials_ref set.
1289        store.save(&env).unwrap();
1290
1291        let mut registry = EnvPackRegistry::new();
1292        registry.register(Box::new(NoMaterialHandler)).unwrap();
1293
1294        let outcome = requirements(
1295            &store,
1296            &registry,
1297            &OpFlags::default(),
1298            Some(CredentialsRequirementsPayload {
1299                environment_id: "local".to_string(),
1300            }),
1301        )
1302        .unwrap();
1303        assert_eq!(outcome.result["mode"], "requirements");
1304        assert_eq!(outcome.result["result"], "pass");
1305        // The sentinel ref is used when no real ref is present.
1306        assert!(
1307            outcome.result["credentials_ref"]
1308                .as_str()
1309                .unwrap()
1310                .contains("no-material-required"),
1311            "expected sentinel credentials_ref, got {:?}",
1312            outcome.result["credentials_ref"]
1313        );
1314    }
1315
1316    /// Fix 1 (Codex C2 #1): the stock default registry
1317    /// (`EnvPackRegistry::with_builtins()`) must pass requirements for a
1318    /// `local` env bound to the default `LOCAL_DEPLOYER_PACK` WITHOUT a
1319    /// `credentials_ref` set. This proves the dead-end circular flow
1320    /// (requirements → "run bootstrap" → "use requirements instead") is
1321    /// closed on the operator-facing path.
1322    #[test]
1323    fn requirements_default_passes_for_local_deployer_without_credentials_ref() {
1324        use crate::defaults::LOCAL_DEPLOYER_PACK;
1325
1326        let dir = tempdir().unwrap();
1327        let store = LocalFsStore::new(dir.path());
1328        let mut env = make_env("local");
1329        env.packs
1330            .push(make_binding(CapabilitySlot::Deployer, LOCAL_DEPLOYER_PACK));
1331        // No credentials_ref set — this is the exact operator-facing scenario.
1332        store.save(&env).unwrap();
1333
1334        let outcome = requirements_default(
1335            &store,
1336            &OpFlags::default(),
1337            Some(CredentialsRequirementsPayload {
1338                environment_id: "local".to_string(),
1339            }),
1340        )
1341        .expect("default registry requirements should pass for local-process");
1342        assert_eq!(outcome.result["result"], "pass");
1343        let checks = outcome.result["checks"].as_array().unwrap();
1344        assert_eq!(
1345            checks.len(),
1346            2,
1347            "local-process declares 2 capabilities (fs + port)"
1348        );
1349        // Sentinel ref used when no material is needed.
1350        assert!(
1351            outcome.result["credentials_ref"]
1352                .as_str()
1353                .unwrap()
1354                .contains("no-material-required"),
1355            "expected sentinel ref, got {:?}",
1356            outcome.result["credentials_ref"]
1357        );
1358    }
1359
1360    /// A minimal resolvable deployer so `run_bootstrap`'s A9 resolve passes;
1361    /// its own credentials are never used — these tests pass an override.
1362    #[derive(Debug)]
1363    struct BindResolveHandler;
1364
1365    impl crate::env_packs::EnvPackHandler for BindResolveHandler {
1366        fn slot(&self) -> CapabilitySlot {
1367            CapabilitySlot::Deployer
1368        }
1369        fn descriptor_path(&self) -> &str {
1370            "test.deployer.bind"
1371        }
1372        fn supported_versions(&self) -> semver::VersionReq {
1373            "^0.1.0".parse().unwrap()
1374        }
1375        fn deployer_credentials(&self) -> Option<&dyn crate::credentials::DeployerCredentials> {
1376            // Registration requires a deployer handler to declare credentials;
1377            // this placeholder is never invoked — `run_bootstrap` is called
1378            // with an override that replaces it.
1379            Some(&BindPlaceholderCreds)
1380        }
1381    }
1382
1383    /// Placeholder so `BindResolveHandler` registers; its `bootstrap` is never
1384    /// reached because the tests pass a `creds_override`.
1385    #[derive(Debug)]
1386    struct BindPlaceholderCreds;
1387
1388    impl crate::credentials::DeployerCredentials for BindPlaceholderCreds {
1389        fn required_capabilities(&self) -> Vec<crate::credentials::Capability> {
1390            Vec::new()
1391        }
1392        fn validate(
1393            &self,
1394            _ctx: &crate::credentials::ValidationContext<'_>,
1395        ) -> crate::credentials::RequirementsReport {
1396            crate::credentials::RequirementsReport::new(Vec::new())
1397        }
1398        fn bootstrap(
1399            &self,
1400            _input: &crate::credentials::BootstrapInput<'_>,
1401        ) -> Result<crate::credentials::BootstrapOutcome, crate::credentials::BootstrapError>
1402        {
1403            Err(crate::credentials::BootstrapError::NotApplicable(
1404                "placeholder creds are never invoked".to_string(),
1405            ))
1406        }
1407    }
1408
1409    /// `creds_override` that "mints" a credential: returns material + granted
1410    /// expiry + the store-aligned bound ref, mirroring the K8s `--bind` path
1411    /// without a live cluster.
1412    #[derive(Debug)]
1413    struct MintingCreds {
1414        secret_ref: String,
1415        token: String,
1416        expiry: chrono::DateTime<chrono::Utc>,
1417    }
1418
1419    impl crate::credentials::DeployerCredentials for MintingCreds {
1420        fn required_capabilities(&self) -> Vec<crate::credentials::Capability> {
1421            Vec::new()
1422        }
1423        fn validate(
1424            &self,
1425            _ctx: &crate::credentials::ValidationContext<'_>,
1426        ) -> crate::credentials::RequirementsReport {
1427            crate::credentials::RequirementsReport::new(Vec::new())
1428        }
1429        fn bootstrap(
1430            &self,
1431            _input: &crate::credentials::BootstrapInput<'_>,
1432        ) -> Result<crate::credentials::BootstrapOutcome, crate::credentials::BootstrapError>
1433        {
1434            Ok(crate::credentials::BootstrapOutcome {
1435                rules_pack: crate::credentials::RulesPack {
1436                    entries: Vec::new(),
1437                },
1438                bound_credentials_ref: Some(SecretRef::try_new(self.secret_ref.clone()).unwrap()),
1439                bound_expiry: Some(self.expiry),
1440                bound_secret_material: Some(zeroize::Zeroizing::new(self.token.clone())),
1441            })
1442        }
1443    }
1444
1445    fn bind_fixture(dir: &std::path::Path) -> (LocalFsStore, EnvPackRegistry, EnvId) {
1446        let store = LocalFsStore::new(dir);
1447        let mut env = make_env("local");
1448        env.packs.push(make_binding(
1449            CapabilitySlot::Deployer,
1450            "test.deployer.bind@0.1.0",
1451        ));
1452        store.save(&env).unwrap();
1453        let mut registry = EnvPackRegistry::new();
1454        registry.register(Box::new(BindResolveHandler)).unwrap();
1455        (store, registry, EnvId::try_from("local").unwrap())
1456    }
1457
1458    #[cfg(feature = "creds-aws")]
1459    #[test]
1460    fn aws_bind_requires_assume_role_arn() {
1461        let dir = tempdir().unwrap();
1462        let store = LocalFsStore::new(dir.path());
1463        let mut env = make_env("prod-eu");
1464        env.packs.push(make_binding(
1465            CapabilitySlot::Deployer,
1466            "greentic.deployer.aws-ecs@1.0.0",
1467        ));
1468        store.save(&env).unwrap();
1469        let env_id = EnvId::try_from("prod-eu").unwrap();
1470
1471        // AWS-bound but no `assume_role_arn` answer ⇒ a clear Conflict, not a
1472        // silent fall-through to render-only nor an over-broad base-chain
1473        // session.
1474        let err = admin_bind_aws_credentials(&store, &env_id, "admin-profile").unwrap_err();
1475        match err {
1476            OpError::Conflict(msg) => assert!(
1477                msg.contains("assume_role_arn"),
1478                "expected an assume_role_arn hint, got: {msg}"
1479            ),
1480            other => panic!("expected Conflict, got {other:?}"),
1481        }
1482    }
1483
1484    #[cfg(feature = "creds-aws")]
1485    #[test]
1486    fn aws_bind_returns_none_for_a_non_aws_deployer() {
1487        use crate::defaults::LOCAL_DEPLOYER_PACK;
1488        let dir = tempdir().unwrap();
1489        let store = LocalFsStore::new(dir.path());
1490        let mut env = make_env("local");
1491        env.packs
1492            .push(make_binding(CapabilitySlot::Deployer, LOCAL_DEPLOYER_PACK));
1493        store.save(&env).unwrap();
1494        let env_id = EnvId::try_from("local").unwrap();
1495
1496        // Not AWS-bound ⇒ None, so the bootstrap dispatch falls through to the
1497        // next builder / the unsupported-deployer rejection.
1498        assert!(
1499            admin_bind_aws_credentials(&store, &env_id, "admin-profile")
1500                .unwrap()
1501                .is_none()
1502        );
1503    }
1504
1505    #[test]
1506    fn run_bootstrap_writes_material_then_persists_ref_and_expiry() {
1507        let dir = tempdir().unwrap();
1508        let (store, registry, env_id) = bind_fixture(dir.path());
1509        let admin = crate::credentials::ZeroizedAdmin::new("admin-ctx", String::new());
1510        let expiry = chrono::DateTime::from_timestamp(2_000_000_000, 0).unwrap();
1511        let bind = MintingCreds {
1512            secret_ref: "secret://local/default/_/k8s-deployer/deployer_token".to_string(),
1513            token: "MINTED".to_string(),
1514            expiry,
1515        };
1516
1517        let captured = std::sync::Mutex::new(None);
1518        let sink =
1519            |_root: &std::path::Path, secret_ref: &SecretRef, value: &str| -> Result<(), String> {
1520                *captured.lock().unwrap() =
1521                    Some((secret_ref.as_str().to_string(), value.to_string()));
1522                Ok(())
1523            };
1524
1525        let doc = crate::credentials::run_bootstrap(
1526            &store,
1527            &registry,
1528            &env_id,
1529            &admin,
1530            Some(&bind),
1531            &sink,
1532        )
1533        .expect("bind succeeds");
1534
1535        // Granted expiry stamped into the doc.
1536        assert_eq!(doc.expiry.as_ref().map(|e| e.expires_at), Some(expiry));
1537        // Material written to the sink at the bound ref, BEFORE persisting.
1538        let (uri, value) = captured.lock().unwrap().clone().expect("sink invoked");
1539        assert_eq!(uri, "secret://local/default/_/k8s-deployer/deployer_token");
1540        assert_eq!(value, "MINTED");
1541        // credentials_ref persisted on the env.
1542        let reloaded = store.load(&env_id).unwrap();
1543        assert_eq!(
1544            reloaded.credentials_ref.as_ref().map(|r| r.as_str()),
1545            Some("secret://local/default/_/k8s-deployer/deployer_token")
1546        );
1547    }
1548
1549    #[test]
1550    fn run_bootstrap_aborts_without_persisting_ref_when_the_sink_fails() {
1551        let dir = tempdir().unwrap();
1552        let (store, registry, env_id) = bind_fixture(dir.path());
1553        let admin = crate::credentials::ZeroizedAdmin::new("admin-ctx", String::new());
1554        let bind = MintingCreds {
1555            secret_ref: "secret://local/default/_/k8s-deployer/deployer_token".to_string(),
1556            token: "MINTED".to_string(),
1557            expiry: chrono::DateTime::from_timestamp(2_000_000_000, 0).unwrap(),
1558        };
1559        let sink = |_root: &std::path::Path, _r: &SecretRef, _v: &str| -> Result<(), String> {
1560            Err("backend unavailable".to_string())
1561        };
1562
1563        let err = crate::credentials::run_bootstrap(
1564            &store,
1565            &registry,
1566            &env_id,
1567            &admin,
1568            Some(&bind),
1569            &sink,
1570        )
1571        .unwrap_err();
1572        assert!(
1573            matches!(err, RunBootstrapError::SecretWrite(_)),
1574            "got {err:?}"
1575        );
1576        // Re-runnable: credentials_ref must NOT persist when the write fails.
1577        let reloaded = store.load(&env_id).unwrap();
1578        assert!(
1579            reloaded.credentials_ref.is_none(),
1580            "credentials_ref must not persist on sink failure"
1581        );
1582    }
1583}