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}