1use 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
39const ROTATE_AT_LIFETIME_FRACTION: f64 = 0.8;
44
45#[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 #[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
74pub 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 let active_ref = env
102 .credentials_ref
103 .clone()
104 .ok_or_else(|| RunRotateError::NotBootstrapped(env_id.clone()))?;
105
106 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 let outcome = bind_creds.bootstrap(&input)?;
121
122 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 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
159pub(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 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 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 #[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 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 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 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 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()), 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 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(); 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 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}