Skip to main content

greentic_deployer/credentials/
rotate.rs

1//! Rotation-flow driver for [`super::DeployerCredentials`].
2//!
3//! `run_rotate` re-mints an env's bound deployer credential in place and
4//! re-persists the fresh material, WITHOUT changing `env.credentials_ref`
5//! (the ref URI is stable; only the material behind it changes). It is the
6//! systemic fix for the bound-token-expiry residual: an operator (or a
7//! scheduled job calling `op credentials rotate --if-needed`) refreshes the
8//! token before it lapses instead of running a full `--bind` bootstrap again.
9//!
10//! ## Re-mint engine
11//!
12//! For the K8s `--bind` path, re-running the handler's
13//! [`bootstrap`](super::DeployerCredentials::bootstrap) IS rotation: it
14//! re-applies the (idempotent) RBAC, mints a fresh `TokenRequest` token, and
15//! overwrites the in-cluster identity Secret in place. `run_rotate` reuses
16//! that path rather than duplicating the mint/persist machinery — it just
17//! inverts the precondition (`credentials_ref` MUST already exist) and
18//! overwrites the dev-store material instead of binding a new ref.
19//!
20//! ## Failure posture
21//!
22//! Unlike bootstrap, rotation does NOT roll back on a partial failure.
23//! Re-mint overwrites material in place, so a failure after the in-cluster
24//! Secret is written but before the dev-store sink commits leaves BOTH the
25//! old and the new token valid (independent `TokenRequest` tokens); the
26//! resolver prefers the still-valid dev-store entry and the next run retries.
27//! Calling [`rollback_bound_material`](super::DeployerCredentials::rollback_bound_material)
28//! here would wrongly DELETE the live identity Secret.
29
30use chrono::{DateTime, Utc};
31use greentic_deploy_spec::{CapabilitySlot, CredentialsExpiry, EnvId, SecretRef};
32use thiserror::Error;
33
34use crate::env_packs::{EnvPackRegistry, RegistryError};
35use crate::environment::{LocalFsStore, StoreError};
36
37use super::bootstrap::{BootstrapError, BootstrapInput, BoundSecretSink, ZeroizedAdmin};
38
39/// Fraction of a bound token's lifetime after which `--if-needed` rotates.
40/// Mirrors kubelet's projected-token refresh (rotate at 80% of lifetime),
41/// so a token the API server clamped short (e.g. to 1h) still rotates
42/// proportionally rather than churning every run or lapsing.
43const ROTATE_AT_LIFETIME_FRACTION: f64 = 0.8;
44
45/// Result of a successful rotation — the (unchanged) ref plus the refreshed
46/// expiry window for the CLI to surface. `expiry` is `None` only for a
47/// rotated credential the handler minted with no bounded lifetime.
48#[derive(Debug, Clone)]
49pub struct RotateOutcome {
50    pub credentials_ref: SecretRef,
51    pub expiry: Option<CredentialsExpiry>,
52}
53
54#[derive(Debug, Error)]
55pub enum RunRotateError {
56    #[error("env `{0}` has no deployer slot bound; bind one with `op env-packs add` first")]
57    NoDeployerBound(EnvId),
58    #[error("env `{0}` has no credentials_ref; run `op credentials bootstrap` first")]
59    NotBootstrapped(EnvId),
60    /// The deployer minted no bound material (e.g. a render-only bootstrap),
61    /// so there is no live token to re-mint.
62    #[error("{0}")]
63    RotationUnsupported(String),
64    #[error(transparent)]
65    Store(#[from] StoreError),
66    #[error(transparent)]
67    Registry(#[from] RegistryError),
68    #[error(transparent)]
69    Bootstrap(#[from] BootstrapError),
70    #[error("failed to persist rotated credential material: {0}")]
71    SecretWrite(String),
72}
73
74/// Re-mint the env's bound deployer credential and re-persist the fresh
75/// material, holding the env's exclusive flock for the whole flow (so a
76/// concurrent reconcile / bootstrap / rotate serializes).
77///
78/// `bind_creds` is the admin-connected override the CLI builds for the K8s
79/// `--bind` path — rotation always re-mints AS THE ADMIN (the bound
80/// identity itself lacks `serviceaccounts/token` create rights by design),
81/// so a render-only / no-override handler surfaces as
82/// [`RunRotateError::RotationUnsupported`].
83pub fn run_rotate(
84    store: &LocalFsStore,
85    registry: &EnvPackRegistry,
86    env_id: &EnvId,
87    admin: &ZeroizedAdmin,
88    bind_creds: &dyn super::DeployerCredentials,
89    secret_sink: &BoundSecretSink<'_>,
90) -> Result<RotateOutcome, RunRotateError> {
91    store.transact(env_id, |locked| {
92        let env = locked.load()?;
93        let deployer = env
94            .pack_for_slot(CapabilitySlot::Deployer)
95            .ok_or_else(|| RunRotateError::NoDeployerBound(env_id.clone()))?;
96        // Authoritative inside-the-flock precondition: rotation refreshes an
97        // EXISTING bound credential (the inverse of bootstrap's absence
98        // check). The env resolves its credential from `credentials_ref`, so
99        // that — not the deployer's default minted ref — is where the fresh
100        // material must land for live verbs to pick it up.
101        let active_ref = env
102            .credentials_ref
103            .clone()
104            .ok_or_else(|| RunRotateError::NotBootstrapped(env_id.clone()))?;
105
106        // Resolve the handler unconditionally so the A9 slot/version match
107        // still runs; the admin-connected override then provides the actual
108        // re-mint path (mirrors `run_bootstrap`).
109        let _handler = registry.resolve_for_slot(CapabilitySlot::Deployer, &deployer.kind)?;
110
111        let env_root = store.env_dir(env_id)?;
112        let input = BootstrapInput {
113            env_id,
114            env_root: &env_root,
115            admin,
116        };
117        // Re-mint: for the K8s bind path this re-applies idempotent RBAC,
118        // mints a fresh TokenRequest token, and overwrites the in-cluster
119        // identity Secret in place.
120        let outcome = bind_creds.bootstrap(&input)?;
121
122        // `bound_credentials_ref` being `Some` is the proof the deployer
123        // actually minted material (render-only bootstraps return `None`).
124        let (Some(_minted_ref), Some(material)) = (
125            outcome.bound_credentials_ref.as_ref(),
126            outcome.bound_secret_material.as_ref(),
127        ) else {
128            return Err(RunRotateError::RotationUnsupported(format!(
129                "deployer env-pack `{}` minted no bound material to rotate; live rotation is \
130                 supported only for the K8s `--bind` credential",
131                deployer.kind.as_str()
132            )));
133        };
134
135        // Overwrite the material at the env's ACTIVE `credentials_ref` — the
136        // location the resolver reads — BEFORE returning success (the
137        // in-cluster identity Secret was already overwritten inside
138        // `bootstrap`). In the canonical `bootstrap --bind` flow the active ref
139        // equals the deployer's minted ref, so this is unchanged; when an
140        // operator bound a different same-env ref out of band, writing here
141        // (not at the minted ref) is what keeps live verbs from resolving the
142        // stale token. `env.credentials_ref` itself is NOT rewritten (the URI
143        // is stable; only the material behind it changes).
144        secret_sink(&env_root, &active_ref, material.as_str())
145            .map_err(RunRotateError::SecretWrite)?;
146
147        let expiry = outcome.bound_expiry.map(|expires_at| CredentialsExpiry {
148            expires_at,
149            rotate_at: bind_creds.rotate_at(material.as_str()),
150        });
151
152        Ok(RotateOutcome {
153            credentials_ref: active_ref,
154            expiry,
155        })
156    })
157}
158
159/// The absolute time a bound credential should be rotated, from its lifetime
160/// window: `iat + (exp - iat) * 0.8`. A degenerate / already-expired window
161/// (`exp <= iat`) returns `iat` (rotate now).
162///
163/// Shared rotation *policy* across backends: each
164/// [`super::DeployerCredentials::rotate_at`] impl decodes its own material
165/// into the `(iat, exp)` window, then calls this. Keeping the 80% fraction in
166/// one place means K8s and any future backend rotate on the same schedule.
167pub(crate) fn rotate_at_from_window(iat: DateTime<Utc>, exp: DateTime<Utc>) -> DateTime<Utc> {
168    let lifetime_secs = (exp - iat).num_seconds();
169    if lifetime_secs <= 0 {
170        return iat;
171    }
172    let offset = (lifetime_secs as f64 * ROTATE_AT_LIFETIME_FRACTION) as i64;
173    iat + chrono::Duration::seconds(offset)
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    fn ts(unix: i64) -> DateTime<Utc> {
181        DateTime::from_timestamp(unix, 0).unwrap()
182    }
183
184    #[test]
185    fn rotate_at_from_window_is_eighty_percent_through_the_lifetime() {
186        // 1000s lifetime ⇒ rotate at iat + 800s.
187        let iat = 1_000_000;
188        assert_eq!(
189            rotate_at_from_window(ts(iat), ts(iat + 1000)),
190            ts(iat + 800)
191        );
192    }
193
194    #[test]
195    fn rotate_at_from_window_degenerate_lifetime_is_iat() {
196        // Zero / negative window ⇒ rotate now (at iat).
197        let iat = 3_000_000;
198        assert_eq!(rotate_at_from_window(ts(iat), ts(iat)), ts(iat));
199        assert_eq!(rotate_at_from_window(ts(iat), ts(iat - 500)), ts(iat));
200    }
201
202    use crate::credentials::{
203        BootstrapError, BootstrapInput, BootstrapOutcome, Capability, DeployerCredentials,
204        RequirementsReport, RulesPack, ValidationContext, ZeroizedAdmin,
205    };
206    use crate::environment::EnvironmentStore;
207    use greentic_deploy_spec::{CapabilitySlot, SecretRef};
208
209    /// A deployer credentials fake that mints a (configurable) bearer +
210    /// expiry on `bootstrap` — the re-mint engine `run_rotate` drives. Its
211    /// `rotate_at` is a plain field, decoupling the engine's rotate-point
212    /// wiring from any backend's material-decode format.
213    #[derive(Debug)]
214    struct MintingCreds {
215        bearer: Option<String>,
216        expiry: Option<DateTime<Utc>>,
217        bound_ref: Option<String>,
218        rotate_at: Option<DateTime<Utc>>,
219    }
220
221    impl DeployerCredentials for MintingCreds {
222        fn required_capabilities(&self) -> Vec<Capability> {
223            vec![]
224        }
225        fn validate(&self, _ctx: &ValidationContext<'_>) -> RequirementsReport {
226            unreachable!("rotation never validates")
227        }
228        fn rotate_at(&self, _material: &str) -> Option<DateTime<Utc>> {
229            self.rotate_at
230        }
231        fn bootstrap(
232            &self,
233            _input: &BootstrapInput<'_>,
234        ) -> Result<BootstrapOutcome, BootstrapError> {
235            Ok(BootstrapOutcome {
236                rules_pack: RulesPack::empty(),
237                bound_credentials_ref: self
238                    .bound_ref
239                    .as_ref()
240                    .map(|u| SecretRef::try_new(u.as_str()).unwrap()),
241                bound_expiry: self.expiry,
242                bound_secret_material: self
243                    .bearer
244                    .as_ref()
245                    .map(|b| zeroize::Zeroizing::new(b.clone())),
246            })
247        }
248    }
249
250    fn bootstrapped_env_store() -> (tempfile::TempDir, LocalFsStore, &'static str) {
251        use crate::cli::tests_common::{make_binding, make_env};
252        let dir = tempfile::tempdir().unwrap();
253        let store = LocalFsStore::new(dir.path());
254        let mut env = make_env("local");
255        env.packs.push(make_binding(
256            CapabilitySlot::Deployer,
257            "greentic.deployer.local-process@0.1.0",
258        ));
259        let ref_uri = "secret://local/default/_/k8s-deployer/deployer_token";
260        env.credentials_ref = Some(SecretRef::try_new(ref_uri).unwrap());
261        store.save(&env).unwrap();
262        (dir, store, ref_uri)
263    }
264
265    #[test]
266    fn run_rotate_remints_persists_material_and_refreshes_expiry() {
267        use std::cell::RefCell;
268
269        let (_dir, store, ref_uri) = bootstrapped_env_store();
270        let iat = 5_000_000;
271        let bearer = "bound-token-material".to_string();
272        let creds = MintingCreds {
273            bearer: Some(bearer.clone()),
274            expiry: Some(ts(iat + 1000)),
275            bound_ref: Some(ref_uri.to_string()),
276            rotate_at: Some(ts(iat + 800)),
277        };
278        let written: RefCell<Option<(String, String)>> = RefCell::new(None);
279        let sink = |_root: &std::path::Path, r: &SecretRef, v: &str| -> Result<(), String> {
280            *written.borrow_mut() = Some((r.as_str().to_string(), v.to_string()));
281            Ok(())
282        };
283
284        let outcome = run_rotate(
285            &store,
286            &EnvPackRegistry::with_builtins(),
287            &"local".try_into().unwrap(),
288            &ZeroizedAdmin::new("admin-ctx", "material".to_string()),
289            &creds,
290            &sink,
291        )
292        .expect("rotate succeeds");
293
294        // Fresh bearer persisted to the sink at the (unchanged) bound ref.
295        let (wrote_ref, wrote_val) = written.into_inner().expect("sink invoked");
296        assert_eq!(wrote_ref, ref_uri);
297        assert_eq!(wrote_val, bearer);
298        // Expiry surfaces with rotate_at at 80% of the minted token's lifetime.
299        let expiry = outcome.expiry.expect("bounded expiry");
300        assert_eq!(
301            expiry.expires_at,
302            DateTime::from_timestamp(iat + 1000, 0).unwrap()
303        );
304        assert_eq!(
305            expiry.rotate_at,
306            Some(DateTime::from_timestamp(iat + 800, 0).unwrap())
307        );
308        assert_eq!(outcome.credentials_ref.as_str(), ref_uri);
309        // The env's credentials_ref is unchanged — rotation overwrites the
310        // material in place, it does not re-point the ref.
311        let reloaded = store.load(&"local".try_into().unwrap()).unwrap();
312        assert_eq!(
313            reloaded.credentials_ref.as_ref().map(|r| r.as_str()),
314            Some(ref_uri)
315        );
316    }
317
318    #[test]
319    fn run_rotate_writes_to_the_active_ref_not_the_minted_ref() {
320        use crate::cli::tests_common::{make_binding, make_env};
321        use std::cell::RefCell;
322
323        // Env's active `credentials_ref` differs from the deployer's default
324        // minted ref (an out-of-band binding). Rotation must write the fresh
325        // token where the RESOLVER reads (the active ref), not where the
326        // deployer minted — otherwise live verbs keep the stale token.
327        let dir = tempfile::tempdir().unwrap();
328        let store = LocalFsStore::new(dir.path());
329        let mut env = make_env("local");
330        env.packs.push(make_binding(
331            CapabilitySlot::Deployer,
332            "greentic.deployer.local-process@0.1.0",
333        ));
334        let active_ref = "secret://local/default/_/custom/active-token";
335        let minted_ref = "secret://local/default/_/k8s-deployer/deployer_token";
336        env.credentials_ref = Some(SecretRef::try_new(active_ref).unwrap());
337        store.save(&env).unwrap();
338
339        let iat = 7_000_000;
340        let bearer = "bound-token-material".to_string();
341        let creds = MintingCreds {
342            bearer: Some(bearer.clone()),
343            expiry: Some(ts(iat + 1000)),
344            bound_ref: Some(minted_ref.to_string()), // deployer mints at its default
345            rotate_at: None,
346        };
347        let written: RefCell<Option<(String, String)>> = RefCell::new(None);
348        let sink = |_root: &std::path::Path, r: &SecretRef, v: &str| -> Result<(), String> {
349            *written.borrow_mut() = Some((r.as_str().to_string(), v.to_string()));
350            Ok(())
351        };
352
353        let outcome = run_rotate(
354            &store,
355            &EnvPackRegistry::with_builtins(),
356            &"local".try_into().unwrap(),
357            &ZeroizedAdmin::new("admin-ctx", "material".to_string()),
358            &creds,
359            &sink,
360        )
361        .expect("rotate succeeds");
362
363        let (wrote_ref, wrote_val) = written.into_inner().expect("sink invoked");
364        assert_eq!(
365            wrote_ref, active_ref,
366            "fresh token must land at the env's active ref, not the deployer's minted ref"
367        );
368        assert_eq!(wrote_val, bearer);
369        assert_eq!(outcome.credentials_ref.as_str(), active_ref);
370    }
371
372    #[test]
373    fn run_rotate_rejects_a_render_only_deployer() {
374        let (_dir, store, _ref_uri) = bootstrapped_env_store();
375        // No bound material minted ⇒ nothing to rotate.
376        let creds = MintingCreds {
377            bearer: None,
378            expiry: None,
379            bound_ref: None,
380            rotate_at: None,
381        };
382        let sink = |_root: &std::path::Path, _r: &SecretRef, _v: &str| -> Result<(), String> {
383            panic!("sink must not be called when there is no material to persist")
384        };
385        let err = run_rotate(
386            &store,
387            &EnvPackRegistry::with_builtins(),
388            &"local".try_into().unwrap(),
389            &ZeroizedAdmin::new("admin-ctx", "material".to_string()),
390            &creds,
391            &sink,
392        )
393        .unwrap_err();
394        assert!(
395            matches!(err, RunRotateError::RotationUnsupported(_)),
396            "got {err:?}"
397        );
398    }
399
400    #[test]
401    fn run_rotate_rejects_an_env_without_a_credentials_ref() {
402        use crate::cli::tests_common::{make_binding, make_env};
403        let dir = tempfile::tempdir().unwrap();
404        let store = LocalFsStore::new(dir.path());
405        let mut env = make_env("local");
406        env.packs.push(make_binding(
407            CapabilitySlot::Deployer,
408            "greentic.deployer.local-process@0.1.0",
409        ));
410        store.save(&env).unwrap(); // no credentials_ref
411        let creds = MintingCreds {
412            bearer: None,
413            expiry: None,
414            bound_ref: None,
415            rotate_at: None,
416        };
417        let sink = |_root: &std::path::Path, _r: &SecretRef, _v: &str| -> Result<(), String> {
418            unreachable!("precondition fails before any persist")
419        };
420        let err = run_rotate(
421            &store,
422            &EnvPackRegistry::with_builtins(),
423            &"local".try_into().unwrap(),
424            &ZeroizedAdmin::new("admin-ctx", "material".to_string()),
425            &creds,
426            &sink,
427        )
428        .unwrap_err();
429        assert!(
430            matches!(err, RunRotateError::NotBootstrapped(_)),
431            "got {err:?}"
432        );
433    }
434
435    #[test]
436    fn rotation_due_is_false_before_threshold_and_true_at_or_after() {
437        let creds = MintingCreds {
438            bearer: None,
439            expiry: None,
440            bound_ref: None,
441            rotate_at: Some(ts(2_000_800)),
442        };
443        assert!(
444            !creds.rotation_due("material", ts(2_000_799)),
445            "before threshold: not due"
446        );
447        assert!(
448            creds.rotation_due("material", ts(2_000_800)),
449            "at threshold: due"
450        );
451        assert!(
452            creds.rotation_due("material", ts(2_000_801)),
453            "after threshold: due"
454        );
455    }
456
457    #[test]
458    fn rotation_due_fails_open_when_rotate_at_is_none() {
459        // A deployer whose material carries no decodable lifetime is always
460        // treated as due, so `--if-needed` never silently skips it.
461        let creds = MintingCreds {
462            bearer: None,
463            expiry: None,
464            bound_ref: None,
465            rotate_at: None,
466        };
467        assert!(creds.rotation_due("opaque", ts(0)));
468        assert!(creds.rotation_due("", ts(1_000_000)));
469    }
470}