Skip to main content

greentic_deployer/credentials/
bootstrap.rs

1//! Bootstrap-flow driver for [`super::DeployerCredentials`].
2//!
3//! `run_bootstrap` consumes a one-shot admin credential via
4//! [`ZeroizedAdmin`], delegates to the bound deployer handler, and
5//! persists:
6//!
7//! 1. A reviewable rules-pack under `rules/<env_id>/` so the customer's
8//!    admin can apply the equivalent IaC offline (see
9//!    [`rules_export`](super::rules_export)).
10//! 2. A [`Credentials`] doc with `mode = Bootstrap`,
11//!    `admin_credential_consumed_at` stamped to the call time, and — when
12//!    the handler bound material directly — the env's `credentials_ref`.
13//!
14//! When the handler returns `bound_credentials_ref = None` (e.g. AWS C3
15//! where the admin runs Terraform offline), the env stays uncredentialed:
16//! `credentials_ref` is NOT written, so a follow-up `op credentials
17//! bootstrap` is not locked out by `AlreadyBootstrapped`, and downstream
18//! `op credentials requirements` will correctly reject with
19//! `NoCredentialsRef` until the admin binds the real value via `op
20//! credentials rotate`.
21//!
22//! ## Admin credentials posture
23//!
24//! Admin credentials are received in a [`ZeroizedAdmin`] wrapper whose
25//! `Drop` zeroizes the in-process buffer. This is best-effort: the OS
26//! may have paged the buffer, the cloud SDK may hold its own copy, and
27//! ambient profile chains (e.g. `~/.aws/credentials`) live outside this
28//! process. The contract is honest about that and does NOT claim
29//! process-wide memory erasure. Operators that need strong guarantees
30//! should run bootstrap on a short-lived process (CI runner, dedicated
31//! VM).
32//!
33//! ## Phase A constraint
34//!
35//! Same constraint as [`validate`](super::validate): Phase A's secrets
36//! handler is metadata-only, so the runner cannot actually *write* the
37//! generated secret material to a real backend yet. The
38//! [`BootstrapOutcome`] returned by the handler is honored and stamped
39//! into the persisted `Credentials` doc; the secret-backend write is a
40//! Phase D follow-on. Today, deployers that have no admin escalation
41//! (local-process, C2) cleanly return
42//! [`BootstrapError::NotApplicable`].
43
44use std::path::Path;
45
46use chrono::{DateTime, Utc};
47use greentic_deploy_spec::{
48    CapabilitySlot, Credentials, CredentialsBootstrap, CredentialsExpiry, CredentialsMode,
49    CredentialsValidation, CredentialsValidationResult, EnvId, SchemaVersion, SecretRef,
50};
51use serde::{Deserialize, Serialize};
52use thiserror::Error;
53use zeroize::Zeroizing;
54
55use crate::env_packs::{EnvPackRegistry, RegistryError};
56use crate::environment::{LocalFsStore, StoreError};
57
58use super::rules_export::{RulesExportError, RulesPack, write_rules_pack};
59
60/// One-shot admin credential material. `Drop` zeroizes the in-process
61/// buffer; see the module docstring for the limits of that guarantee.
62///
63/// Wrap the credential at the boundary closest to where it was supplied
64/// (e.g. read from an interactive prompt or a one-time ENV var) and pass
65/// the wrapper through to [`run_bootstrap`]. Do NOT clone the inner
66/// string out — `as_str()` borrows for the duration of the call.
67pub struct ZeroizedAdmin {
68    inner: Zeroizing<String>,
69    /// Profile / handle the user named (e.g. an AWS named profile or a
70    /// kubeconfig context). Not sensitive itself, but kept alongside the
71    /// material for diagnostics.
72    profile: String,
73}
74
75impl std::fmt::Debug for ZeroizedAdmin {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        f.debug_struct("ZeroizedAdmin")
78            .field("profile", &self.profile)
79            .field("inner", &"<redacted>")
80            .finish()
81    }
82}
83
84impl ZeroizedAdmin {
85    /// Wrap admin credential material. The caller is responsible for
86    /// ensuring `material` was not copied through an intermediate
87    /// allocation that survives this call.
88    pub fn new(profile: impl Into<String>, material: String) -> Self {
89        Self {
90            inner: Zeroizing::new(material),
91            profile: profile.into(),
92        }
93    }
94
95    /// Sentinel "I have no real admin material" wrapper for deployers
96    /// whose [`super::DeployerCredentials::bootstrap`] does not need any
97    /// (currently none — every real deployer needs *something* — but C2
98    /// uses this to test the rejection path).
99    pub fn sentinel(profile: impl Into<String>) -> Self {
100        Self {
101            inner: Zeroizing::new(String::new()),
102            profile: profile.into(),
103        }
104    }
105
106    pub fn as_str(&self) -> &str {
107        self.inner.as_str()
108    }
109
110    pub fn profile(&self) -> &str {
111        &self.profile
112    }
113}
114
115/// Input passed to [`super::DeployerCredentials::bootstrap`].
116///
117/// Borrows the admin credential and the env context. Handlers MUST NOT
118/// store the `admin` reference past the call return — by the time
119/// `run_bootstrap` returns to the operator, the `ZeroizedAdmin` has
120/// dropped and its buffer is zeroized.
121#[derive(Debug)]
122pub struct BootstrapInput<'a> {
123    pub env_id: &'a EnvId,
124    pub env_root: &'a Path,
125    pub admin: &'a ZeroizedAdmin,
126}
127
128/// Successful bootstrap output.
129///
130/// The handler returns this; the runner persists the rules pack, writes any
131/// bound secret material to the secret backend, and stamps the env's
132/// [`Credentials`] doc. The handler does NOT touch the env store directly.
133#[derive(Clone, Serialize, Deserialize)]
134pub struct BootstrapOutcome {
135    /// Rules-pack content the customer's admin can review and apply.
136    /// Empty for deployers that need no offline IaC step (e.g.
137    /// local-process — though those should use
138    /// [`BootstrapError::NotApplicable`] rather than reach here).
139    pub rules_pack: RulesPack,
140    /// When `Some`, the runner sets `env.credentials_ref` to this value
141    /// — the env is now credentialed and downstream validates will run
142    /// the deployer's real probes against it. When `None`, the bootstrap
143    /// emitted only an IaC rules pack; the env stays uncredentialed
144    /// until `op credentials rotate <env> --provided-credentials-ref
145    /// <uri>` binds the real value the customer's admin produces by
146    /// applying the rules pack offline.
147    ///
148    /// AWS C3 stub returns `None` (admin runs Terraform); the K8s
149    /// `--bind` path returns `Some` once it has minted a ServiceAccount
150    /// token directly. Local-process never reaches here (returns
151    /// `BootstrapError::NotApplicable`).
152    pub bound_credentials_ref: Option<SecretRef>,
153    /// When the handler minted a credential with a bounded lifetime (e.g.
154    /// a K8s `TokenRequest` token), the absolute expiry the cluster
155    /// granted. The runner stamps it into [`Credentials::expiry`] so the
156    /// re-bind deadline is visible; `None` for non-expiring or render-only
157    /// bootstraps.
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub bound_expiry: Option<DateTime<Utc>>,
160    /// Secret material the handler minted that the runner must persist to
161    /// the env's secret backend (the location `resolve_credentials_token`
162    /// reads it back from) BEFORE recording `credentials_ref`.
163    ///
164    /// `#[serde(skip)]` — material is never serialized into the persisted
165    /// [`Credentials`] doc or any audit record; it lives only for the
166    /// in-process handler→runner handoff and is zeroized on drop. `None`
167    /// for render-only bootstraps (the admin supplies the material
168    /// out-of-band and binds it via `op credentials rotate`).
169    #[serde(skip)]
170    pub bound_secret_material: Option<Zeroizing<String>>,
171}
172
173impl std::fmt::Debug for BootstrapOutcome {
174    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175        // Never render `bound_secret_material` — it carries live token
176        // material (and `Zeroizing<String>`'s own Debug would print it).
177        // Surface only its presence.
178        f.debug_struct("BootstrapOutcome")
179            .field("rules_pack", &self.rules_pack)
180            .field("bound_credentials_ref", &self.bound_credentials_ref)
181            .field("bound_expiry", &self.bound_expiry)
182            .field(
183                "bound_secret_material",
184                &self.bound_secret_material.as_ref().map(|_| "<redacted>"),
185            )
186            .finish()
187    }
188}
189
190/// Sink the runner calls to persist bound secret material into the env's
191/// secret backend. Inverted dependency: the runner owns the env flock and
192/// the write-before-persist ordering, while the caller (the CLI) owns the
193/// dev-store specifics. Receives the env root, the ref the material binds
194/// to, and the material; `Ok(())` ⇒ durably stored, `Err(msg)` aborts the
195/// bootstrap before `credentials_ref` is recorded.
196pub type BoundSecretSink<'a> = dyn Fn(&Path, &SecretRef, &str) -> Result<(), String> + 'a;
197
198#[derive(Debug, Error)]
199pub enum BootstrapError {
200    /// The deployer has no admin escalation path — the user should run
201    /// `requirements` instead. Returned by local-process (C2).
202    #[error("{0}")]
203    NotApplicable(String),
204    /// Admin credential rejected by the cloud provider (wrong account,
205    /// expired session, insufficient privileges).
206    #[error("admin credential rejected: {0}")]
207    AdminRejected(String),
208    /// Bootstrap ran but a downstream provisioning step failed.
209    #[error("bootstrap failed during {step}: {message}")]
210    ProvisioningFailed { step: String, message: String },
211}
212
213#[derive(Debug, Error)]
214pub enum RunBootstrapError {
215    #[error("env `{0}` has no deployer slot bound; bind one with `op env-packs add` first")]
216    NoDeployerBound(EnvId),
217    #[error("env `{0}` already has credentials_ref; use `rotate` instead of `bootstrap`")]
218    AlreadyBootstrapped(EnvId),
219    #[error(
220        "deployer env-pack `{kind}` has no native credentials handler registered (Phase D plug-in)"
221    )]
222    HandlerNotRegistered { kind: String },
223    #[error(transparent)]
224    Store(#[from] StoreError),
225    #[error(transparent)]
226    Registry(#[from] RegistryError),
227    #[error(transparent)]
228    Bootstrap(#[from] BootstrapError),
229    #[error(transparent)]
230    RulesExport(#[from] RulesExportError),
231    #[error("failed to persist bound credential material: {0}")]
232    SecretWrite(String),
233}
234
235/// Drive a `bootstrap` flow against the env's bound deployer env-pack,
236/// inside a single transactional scope.
237///
238/// The entire flow — load env, assert `credentials_ref` absent, invoke the
239/// handler's bootstrap path, write the rules pack, persist the new
240/// `credentials_ref` — runs while holding the env's exclusive flock. This
241/// prevents two concurrent invocations from both passing the absence check,
242/// consuming admin credentials, and racing on the final write.
243///
244/// **Trade-off:** Bootstrap may be long-running (handler calls out to a
245/// cloud provider), so the env flock is held for the full duration of the
246/// call. This is acceptable because (a) bootstrap is a one-shot admin
247/// operation, not a hot path, and (b) other verbs that need the flock
248/// (requirements, rotate, traffic mutations) will simply block until
249/// bootstrap completes, which is the correct serialization.
250pub fn run_bootstrap(
251    store: &LocalFsStore,
252    registry: &EnvPackRegistry,
253    env_id: &EnvId,
254    admin: &ZeroizedAdmin,
255    creds_override: Option<&dyn super::DeployerCredentials>,
256    secret_sink: &BoundSecretSink<'_>,
257) -> Result<Credentials, RunBootstrapError> {
258    store.transact(env_id, |locked| {
259        let mut env = locked.load()?;
260        let deployer = env
261            .pack_for_slot(CapabilitySlot::Deployer)
262            .ok_or_else(|| RunBootstrapError::NoDeployerBound(env_id.clone()))?;
263        if env.credentials_ref.is_some() {
264            return Err(RunBootstrapError::AlreadyBootstrapped(env_id.clone()));
265        }
266
267        // Resolve the handler even when an override is supplied: this is
268        // where the A9 slot/version match is enforced. The override (the
269        // CLI's admin-connected K8s credentials for `--bind`) then replaces
270        // the handler's own bootstrap path — mirrors the `creds_override`
271        // seam in `validate_requirements`.
272        let handler = registry.resolve_for_slot(CapabilitySlot::Deployer, &deployer.kind)?;
273        let creds: &dyn super::DeployerCredentials = match creds_override {
274            Some(c) => c,
275            None => handler.deployer_credentials().ok_or_else(|| {
276                RunBootstrapError::HandlerNotRegistered {
277                    kind: deployer.kind.as_str().to_string(),
278                }
279            })?,
280        };
281
282        let env_root = store.env_dir(env_id)?;
283        let input = BootstrapInput {
284            env_id,
285            env_root: &env_root,
286            admin,
287        };
288        let outcome = creds.bootstrap(&input)?;
289
290        let consumed_at = Utc::now();
291        let rules_pack_ref = write_rules_pack(&env_root, &deployer.kind, &outcome.rules_pack)?;
292        // Snapshot the deployer kind before the &env borrow ends below.
293        let deployer_kind = deployer.kind.clone();
294
295        // When the handler bound credentials directly (e.g. Phase D AWS
296        // mints a session token), the env is immediately credentialed.
297        // When `None` (e.g. C3 AWS where the admin runs Terraform
298        // offline), use a doc-only sentinel so the returned Credentials
299        // doc is honest about the incomplete state — but do NOT write it
300        // to env.credentials_ref.
301        let (doc_ref, validation_result, missing_caps) =
302            if let Some(ref bound) = outcome.bound_credentials_ref {
303                (bound.clone(), CredentialsValidationResult::Pass, Vec::new())
304            } else {
305                let sentinel = SecretRef::try_new(format!(
306                    "secret://{}/{}/bootstrap-incomplete",
307                    env_id.as_str(),
308                    deployer_kind.as_str()
309                ))
310                .expect("sentinel SecretRef is well-formed");
311                (
312                    sentinel,
313                    CredentialsValidationResult::Fail,
314                    vec!["credentials.bind-pending".to_string()],
315                )
316            };
317
318        let doc = Credentials {
319            schema: SchemaVersion::new(SchemaVersion::CREDENTIALS_V1),
320            env_id: env_id.clone(),
321            deployer_kind,
322            mode: CredentialsMode::Bootstrap,
323            provided_credentials_ref: doc_ref.clone(),
324            validation: CredentialsValidation {
325                last_run_at: consumed_at,
326                result: validation_result,
327                missing_capabilities: missing_caps,
328            },
329            bootstrap: Some(CredentialsBootstrap {
330                admin_credential_consumed_at: consumed_at,
331                rules_pack_ref,
332                generated_credentials_ref: doc_ref,
333            }),
334            expiry: outcome.bound_expiry.map(|expires_at| CredentialsExpiry {
335                expires_at,
336                rotate_at: None,
337            }),
338        };
339
340        // Persist bound secret material (when the handler minted it) to the
341        // secret backend BEFORE recording credentials_ref: the env must
342        // never point at a credential whose material isn't there. On write
343        // failure, return WITHOUT persisting credentials_ref so bootstrap
344        // stays re-runnable (no AlreadyBootstrapped lockout).
345        if let (Some(bound_ref), Some(material)) = (
346            outcome.bound_credentials_ref.as_ref(),
347            outcome.bound_secret_material.as_ref(),
348        ) {
349            secret_sink(&env_root, bound_ref, material.as_str())
350                .map_err(RunBootstrapError::SecretWrite)?;
351        }
352
353        // Only persist credentials_ref when the handler actually bound
354        // material. When `None`, the env stays uncredentialed — bootstrap
355        // can be re-run after the admin applies the rules pack and binds
356        // credentials via `op credentials rotate`.
357        if outcome.bound_credentials_ref.is_some() {
358            env.credentials_ref = Some(doc.provided_credentials_ref.clone());
359            locked.save(&env)?;
360        }
361
362        Ok(doc)
363    })
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369
370    #[test]
371    fn zeroized_admin_redacts_in_debug_output() {
372        let admin = ZeroizedAdmin::new("p1", "AKIASUPERSECRET".to_string());
373        let dbg = format!("{admin:?}");
374        assert!(dbg.contains("<redacted>"));
375        assert!(!dbg.contains("AKIASUPERSECRET"));
376    }
377
378    #[test]
379    fn zeroized_admin_as_str_returns_material() {
380        let admin = ZeroizedAdmin::new("p1", "x".to_string());
381        assert_eq!(admin.as_str(), "x");
382        assert_eq!(admin.profile(), "p1");
383    }
384
385    #[test]
386    fn sentinel_has_empty_material() {
387        let admin = ZeroizedAdmin::sentinel("p1");
388        assert!(admin.as_str().is_empty());
389    }
390}