use chrono::{DateTime, Utc};
use greentic_deploy_spec::{CapabilitySlot, CredentialsExpiry, EnvId, SecretRef};
use thiserror::Error;
use crate::env_packs::{EnvPackRegistry, RegistryError};
use crate::environment::{LocalFsStore, StoreError};
use super::bootstrap::{BootstrapError, BootstrapInput, BoundSecretSink, ZeroizedAdmin};
const ROTATE_AT_LIFETIME_FRACTION: f64 = 0.8;
#[derive(Debug, Clone)]
pub struct RotateOutcome {
pub credentials_ref: SecretRef,
pub expiry: Option<CredentialsExpiry>,
}
#[derive(Debug, Error)]
pub enum RunRotateError {
#[error("env `{0}` has no deployer slot bound; bind one with `op env-packs add` first")]
NoDeployerBound(EnvId),
#[error("env `{0}` has no credentials_ref; run `op credentials bootstrap` first")]
NotBootstrapped(EnvId),
#[error("{0}")]
RotationUnsupported(String),
#[error(transparent)]
Store(#[from] StoreError),
#[error(transparent)]
Registry(#[from] RegistryError),
#[error(transparent)]
Bootstrap(#[from] BootstrapError),
#[error("failed to persist rotated credential material: {0}")]
SecretWrite(String),
}
pub fn run_rotate(
store: &LocalFsStore,
registry: &EnvPackRegistry,
env_id: &EnvId,
admin: &ZeroizedAdmin,
bind_creds: &dyn super::DeployerCredentials,
secret_sink: &BoundSecretSink<'_>,
) -> Result<RotateOutcome, RunRotateError> {
store.transact(env_id, |locked| {
let env = locked.load()?;
let deployer = env
.pack_for_slot(CapabilitySlot::Deployer)
.ok_or_else(|| RunRotateError::NoDeployerBound(env_id.clone()))?;
let active_ref = env
.credentials_ref
.clone()
.ok_or_else(|| RunRotateError::NotBootstrapped(env_id.clone()))?;
let _handler = registry.resolve_for_slot(CapabilitySlot::Deployer, &deployer.kind)?;
let env_root = store.env_dir(env_id)?;
let input = BootstrapInput {
env_id,
env_root: &env_root,
admin,
};
let outcome = bind_creds.bootstrap(&input)?;
let (Some(_minted_ref), Some(material)) = (
outcome.bound_credentials_ref.as_ref(),
outcome.bound_secret_material.as_ref(),
) else {
return Err(RunRotateError::RotationUnsupported(format!(
"deployer env-pack `{}` minted no bound material to rotate; live rotation is \
supported only for the K8s `--bind` credential",
deployer.kind.as_str()
)));
};
secret_sink(&env_root, &active_ref, material.as_str())
.map_err(RunRotateError::SecretWrite)?;
let expiry = outcome.bound_expiry.map(|expires_at| CredentialsExpiry {
expires_at,
rotate_at: bind_creds.rotate_at(material.as_str()),
});
Ok(RotateOutcome {
credentials_ref: active_ref,
expiry,
})
})
}
pub(crate) fn rotate_at_from_window(iat: DateTime<Utc>, exp: DateTime<Utc>) -> DateTime<Utc> {
let lifetime_secs = (exp - iat).num_seconds();
if lifetime_secs <= 0 {
return iat;
}
let offset = (lifetime_secs as f64 * ROTATE_AT_LIFETIME_FRACTION) as i64;
iat + chrono::Duration::seconds(offset)
}
#[cfg(test)]
mod tests {
use super::*;
fn ts(unix: i64) -> DateTime<Utc> {
DateTime::from_timestamp(unix, 0).unwrap()
}
#[test]
fn rotate_at_from_window_is_eighty_percent_through_the_lifetime() {
let iat = 1_000_000;
assert_eq!(
rotate_at_from_window(ts(iat), ts(iat + 1000)),
ts(iat + 800)
);
}
#[test]
fn rotate_at_from_window_degenerate_lifetime_is_iat() {
let iat = 3_000_000;
assert_eq!(rotate_at_from_window(ts(iat), ts(iat)), ts(iat));
assert_eq!(rotate_at_from_window(ts(iat), ts(iat - 500)), ts(iat));
}
use crate::credentials::{
BootstrapError, BootstrapInput, BootstrapOutcome, Capability, DeployerCredentials,
RequirementsReport, RulesPack, ValidationContext, ZeroizedAdmin,
};
use crate::environment::EnvironmentStore;
use greentic_deploy_spec::{CapabilitySlot, SecretRef};
#[derive(Debug)]
struct MintingCreds {
bearer: Option<String>,
expiry: Option<DateTime<Utc>>,
bound_ref: Option<String>,
rotate_at: Option<DateTime<Utc>>,
}
impl DeployerCredentials for MintingCreds {
fn required_capabilities(&self) -> Vec<Capability> {
vec![]
}
fn validate(&self, _ctx: &ValidationContext<'_>) -> RequirementsReport {
unreachable!("rotation never validates")
}
fn rotate_at(&self, _material: &str) -> Option<DateTime<Utc>> {
self.rotate_at
}
fn bootstrap(
&self,
_input: &BootstrapInput<'_>,
) -> Result<BootstrapOutcome, BootstrapError> {
Ok(BootstrapOutcome {
rules_pack: RulesPack::empty(),
bound_credentials_ref: self
.bound_ref
.as_ref()
.map(|u| SecretRef::try_new(u.as_str()).unwrap()),
bound_expiry: self.expiry,
bound_secret_material: self
.bearer
.as_ref()
.map(|b| zeroize::Zeroizing::new(b.clone())),
})
}
}
fn bootstrapped_env_store() -> (tempfile::TempDir, LocalFsStore, &'static str) {
use crate::cli::tests_common::{make_binding, make_env};
let dir = tempfile::tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let mut env = make_env("local");
env.packs.push(make_binding(
CapabilitySlot::Deployer,
"greentic.deployer.local-process@0.1.0",
));
let ref_uri = "secret://local/default/_/k8s-deployer/deployer_token";
env.credentials_ref = Some(SecretRef::try_new(ref_uri).unwrap());
store.save(&env).unwrap();
(dir, store, ref_uri)
}
#[test]
fn run_rotate_remints_persists_material_and_refreshes_expiry() {
use std::cell::RefCell;
let (_dir, store, ref_uri) = bootstrapped_env_store();
let iat = 5_000_000;
let bearer = "bound-token-material".to_string();
let creds = MintingCreds {
bearer: Some(bearer.clone()),
expiry: Some(ts(iat + 1000)),
bound_ref: Some(ref_uri.to_string()),
rotate_at: Some(ts(iat + 800)),
};
let written: RefCell<Option<(String, String)>> = RefCell::new(None);
let sink = |_root: &std::path::Path, r: &SecretRef, v: &str| -> Result<(), String> {
*written.borrow_mut() = Some((r.as_str().to_string(), v.to_string()));
Ok(())
};
let outcome = run_rotate(
&store,
&EnvPackRegistry::with_builtins(),
&"local".try_into().unwrap(),
&ZeroizedAdmin::new("admin-ctx", "material".to_string()),
&creds,
&sink,
)
.expect("rotate succeeds");
let (wrote_ref, wrote_val) = written.into_inner().expect("sink invoked");
assert_eq!(wrote_ref, ref_uri);
assert_eq!(wrote_val, bearer);
let expiry = outcome.expiry.expect("bounded expiry");
assert_eq!(
expiry.expires_at,
DateTime::from_timestamp(iat + 1000, 0).unwrap()
);
assert_eq!(
expiry.rotate_at,
Some(DateTime::from_timestamp(iat + 800, 0).unwrap())
);
assert_eq!(outcome.credentials_ref.as_str(), ref_uri);
let reloaded = store.load(&"local".try_into().unwrap()).unwrap();
assert_eq!(
reloaded.credentials_ref.as_ref().map(|r| r.as_str()),
Some(ref_uri)
);
}
#[test]
fn run_rotate_writes_to_the_active_ref_not_the_minted_ref() {
use crate::cli::tests_common::{make_binding, make_env};
use std::cell::RefCell;
let dir = tempfile::tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let mut env = make_env("local");
env.packs.push(make_binding(
CapabilitySlot::Deployer,
"greentic.deployer.local-process@0.1.0",
));
let active_ref = "secret://local/default/_/custom/active-token";
let minted_ref = "secret://local/default/_/k8s-deployer/deployer_token";
env.credentials_ref = Some(SecretRef::try_new(active_ref).unwrap());
store.save(&env).unwrap();
let iat = 7_000_000;
let bearer = "bound-token-material".to_string();
let creds = MintingCreds {
bearer: Some(bearer.clone()),
expiry: Some(ts(iat + 1000)),
bound_ref: Some(minted_ref.to_string()), rotate_at: None,
};
let written: RefCell<Option<(String, String)>> = RefCell::new(None);
let sink = |_root: &std::path::Path, r: &SecretRef, v: &str| -> Result<(), String> {
*written.borrow_mut() = Some((r.as_str().to_string(), v.to_string()));
Ok(())
};
let outcome = run_rotate(
&store,
&EnvPackRegistry::with_builtins(),
&"local".try_into().unwrap(),
&ZeroizedAdmin::new("admin-ctx", "material".to_string()),
&creds,
&sink,
)
.expect("rotate succeeds");
let (wrote_ref, wrote_val) = written.into_inner().expect("sink invoked");
assert_eq!(
wrote_ref, active_ref,
"fresh token must land at the env's active ref, not the deployer's minted ref"
);
assert_eq!(wrote_val, bearer);
assert_eq!(outcome.credentials_ref.as_str(), active_ref);
}
#[test]
fn run_rotate_rejects_a_render_only_deployer() {
let (_dir, store, _ref_uri) = bootstrapped_env_store();
let creds = MintingCreds {
bearer: None,
expiry: None,
bound_ref: None,
rotate_at: None,
};
let sink = |_root: &std::path::Path, _r: &SecretRef, _v: &str| -> Result<(), String> {
panic!("sink must not be called when there is no material to persist")
};
let err = run_rotate(
&store,
&EnvPackRegistry::with_builtins(),
&"local".try_into().unwrap(),
&ZeroizedAdmin::new("admin-ctx", "material".to_string()),
&creds,
&sink,
)
.unwrap_err();
assert!(
matches!(err, RunRotateError::RotationUnsupported(_)),
"got {err:?}"
);
}
#[test]
fn run_rotate_rejects_an_env_without_a_credentials_ref() {
use crate::cli::tests_common::{make_binding, make_env};
let dir = tempfile::tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let mut env = make_env("local");
env.packs.push(make_binding(
CapabilitySlot::Deployer,
"greentic.deployer.local-process@0.1.0",
));
store.save(&env).unwrap(); let creds = MintingCreds {
bearer: None,
expiry: None,
bound_ref: None,
rotate_at: None,
};
let sink = |_root: &std::path::Path, _r: &SecretRef, _v: &str| -> Result<(), String> {
unreachable!("precondition fails before any persist")
};
let err = run_rotate(
&store,
&EnvPackRegistry::with_builtins(),
&"local".try_into().unwrap(),
&ZeroizedAdmin::new("admin-ctx", "material".to_string()),
&creds,
&sink,
)
.unwrap_err();
assert!(
matches!(err, RunRotateError::NotBootstrapped(_)),
"got {err:?}"
);
}
#[test]
fn rotation_due_is_false_before_threshold_and_true_at_or_after() {
let creds = MintingCreds {
bearer: None,
expiry: None,
bound_ref: None,
rotate_at: Some(ts(2_000_800)),
};
assert!(
!creds.rotation_due("material", ts(2_000_799)),
"before threshold: not due"
);
assert!(
creds.rotation_due("material", ts(2_000_800)),
"at threshold: due"
);
assert!(
creds.rotation_due("material", ts(2_000_801)),
"after threshold: due"
);
}
#[test]
fn rotation_due_fails_open_when_rotate_at_is_none() {
let creds = MintingCreds {
bearer: None,
expiry: None,
bound_ref: None,
rotate_at: None,
};
assert!(creds.rotation_due("opaque", ts(0)));
assert!(creds.rotation_due("", ts(1_000_000)));
}
}