// Copyright 2024 RustFS Team // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //! Credential plumbing shared by the Vault KV2 and Transit backends. //! //! [`VaultCredentialProvider`] owns the authenticated [`VaultClient`] and hands //! out per-request snapshots. Backends take a fresh snapshot via //! [`VaultCredentialProvider::current`] for every Vault call instead of holding //! a client for their own lifetime: a credential rotation applies to the next //! call, while calls already in flight finish on the generation they captured //! (their `Arc` keeps it alive). //! //! Lease-bound tokens (AppRole) are kept fresh by a background renewal task //! (see [`VaultCredentialProvider::spawn_renewal_task`]): it renews at half the //! lease TTL, falls back to a fresh login when renewal is not possible, and //! keeps retrying after failures. If the token still reaches the configured //! safety window before expiry, [`VaultCredentialProvider::current`] fails //! closed rather than handing out a token that may lapse mid-request. use std::fmt; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; use arc_swap::ArcSwap; use async_trait::async_trait; use sha2::Digest; use tokio::time::Instant; use tokio_util::sync::CancellationToken; use tracing::{info, warn}; use vaultrs::client::{VaultClient, VaultClientSettingsBuilder}; use zeroize::{Zeroize, ZeroizeOnDrop}; use crate::config::{KmsConfig, VaultAuthMethod, redacted_secret}; use crate::error::{KmsError, Result}; use crate::policy::{self, AttemptError, ErrorClass, OpClass, RetryPolicy}; /// Result of a single authentication attempt, classified for the Auth retry /// policy. type AttemptResult = std::result::Result; /// Cadence for refresh retries after a failed cycle (on top of the bounded /// retries inside one [`policy::execute`] call). const DEFAULT_REFRESH_RETRY_INTERVAL: Duration = Duration::from_secs(5); /// Default seconds between token file re-reads for [`TokenFileSource`]. const DEFAULT_TOKEN_FILE_POLL_INTERVAL_SECS: u64 = 30; // --------------------------------------------------------------------------- // Metrics // // Both gauges describe the one credential generation currently installed, so // they carry no labels: the Vault address, mount, auth path and token are all // off limits as label values, and there is exactly one generation to describe. // The renewal loop republishes them on a bounded cadence while it waits, so a // scrape landing between refresh cycles never reads a TTL frozen at the last // refresh, or a fail-closed state that flipped after it. // --------------------------------------------------------------------------- /// Gauge: seconds left before the active Vault token expires; `0` once it has. const METRIC_TOKEN_TTL_SECONDS: &str = "rustfs_kms_vault_token_ttl_seconds"; /// Gauge: `1` while [`VaultCredentialProvider::current`] refuses to hand out /// the token because it is inside the fail-closed safety window, `0` otherwise. const METRIC_CREDENTIALS_FAIL_CLOSED: &str = "rustfs_kms_vault_credentials_fail_closed"; /// How often the renewal loop republishes the credential gauges while waiting. /// Bounds how stale a scrape can be, without any additional Vault traffic. const CREDENTIAL_GAUGE_INTERVAL: Duration = Duration::from_secs(10); /// Register metric descriptions once per process. fn describe_credential_metrics() { static DESCRIBE: std::sync::Once = std::sync::Once::new(); DESCRIBE.call_once(|| { metrics::describe_gauge!( METRIC_TOKEN_TTL_SECONDS, "Seconds remaining before the Vault token backing the KMS backend expires" ); metrics::describe_gauge!( METRIC_CREDENTIALS_FAIL_CLOSED, "1 while the Vault credential provider refuses to serve its token because the token is inside the fail-closed safety window" ); }); } /// A crate-owned secret value, zeroized on drop and redacted in Debug output. #[derive(Clone, Zeroize, ZeroizeOnDrop)] pub(crate) struct SecretString(String); impl SecretString { pub(crate) fn new(value: String) -> Self { Self(value) } pub(crate) fn expose(&self) -> &str { &self.0 } } impl fmt::Debug for SecretString { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(redacted_secret(&self.0)) } } /// Expiry attributes of a lease-bound token. #[derive(Debug, Clone, Copy)] pub(crate) struct LeaseInfo { /// Time-to-live granted at issue or renewal. pub(crate) ttl: Duration, /// Whether `renew-self` can extend this token. pub(crate) renewable: bool, } /// A Vault token handed out by a [`TokenSource`]. /// /// The crate's copy of the token is zeroized on drop. This cannot cover the /// copy `vaultrs` keeps inside its client settings (or the HTTP headers built /// from it); it bounds how long the token lingers in memory owned by this /// module. #[derive(Clone, Zeroize, ZeroizeOnDrop)] pub(crate) struct TokenLease { token: String, /// `None` for tokens without an expiry (static configuration tokens and /// non-expiring root-like tokens). #[zeroize(skip)] lease: Option, } impl TokenLease { pub(crate) fn new(token: String, lease: Option) -> Self { Self { token, lease } } /// Map a Vault auth response onto a lease. A `lease_duration` of zero /// means the token never expires, so no lease is tracked and no renewal is /// scheduled. fn from_auth(auth: vaultrs::api::AuthInfo) -> Self { let lease = (auth.lease_duration > 0).then_some(LeaseInfo { ttl: Duration::from_secs(auth.lease_duration), renewable: auth.renewable, }); Self { token: auth.client_token, lease, } } /// Expose the raw token for handing to the Vault client builder. pub(crate) fn expose(&self) -> &str { &self.token } pub(crate) fn lease_info(&self) -> Option { self.lease } } impl fmt::Debug for TokenLease { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("TokenLease") .field("token", &redacted_secret(&self.token)) .field("lease", &self.lease) .finish() } } /// Wrap a `vaultrs` failure as a classified attempt failure. fn attempt_error(operation: &str, error: vaultrs::error::ClientError) -> AttemptError { AttemptError { class: policy::classify_vaultrs(&error), error: KmsError::backend_error(format!("Vault {operation} failed: {error}")), } } /// Source of Vault authentication tokens. /// /// Implementations perform one attempt per call; bounded retries and backoff /// are owned by the caller through [`policy::execute`] with [`OpClass::Auth`]. /// Current sources are [`StaticToken`], [`AppRoleLogin`], and the agent-managed /// [`TokenFileSource`]. #[async_trait] pub(crate) trait TokenSource: fmt::Debug + Send + Sync { /// One login attempt yielding a token for a new client generation. async fn acquire(&self) -> AttemptResult; /// One `renew-self` attempt for the token held by `client`. /// /// Sources whose tokens cannot be renewed fail fatally; callers fall back /// to [`TokenSource::acquire`]. async fn renew(&self, _client: &VaultClient) -> AttemptResult { Err(AttemptError { class: ErrorClass::Fatal, error: KmsError::invalid_operation("this token source does not support renewal"), }) } } /// Token source for [`VaultAuthMethod::Token`]: always yields the token fixed /// at configuration time. The token carries no lease, so it is never renewed /// and never expires from the provider's point of view. pub(crate) struct StaticToken { token: TokenLease, } impl StaticToken { pub(crate) fn new(token: String) -> Self { Self { token: TokenLease::new(token, None), } } } #[async_trait] impl TokenSource for StaticToken { async fn acquire(&self) -> AttemptResult { Ok(self.token.clone()) } } impl fmt::Debug for StaticToken { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { // TokenLease::fmt already redacts the token value. f.debug_struct("StaticToken").field("token", &self.token).finish() } } /// Token source for [`VaultAuthMethod::AppRole`]: exchanges `role_id` + /// `secret_id` for a lease-bound token via the AppRole auth engine. pub(crate) struct AppRoleLogin { /// Unauthenticated client used only for the login exchange. login_client: VaultClient, mount: String, role_id: String, /// Inline secret_id fallback, used when no file is configured. secret_id: SecretString, /// Secret-id file, re-read on every login so external rotation of the /// secret_id is picked up without a restart. Takes precedence over the /// inline value. secret_id_file: Option, } impl AppRoleLogin { pub(crate) fn new( settings: &VaultConnectionSettings, mount: String, role_id: String, secret_id: String, secret_id_file: Option, ) -> Result { Ok(Self { login_client: settings.build_login_client()?, mount, role_id, secret_id: SecretString::new(secret_id), secret_id_file, }) } /// Resolve the secret_id for one login attempt. /// /// File problems are fatal for the attempt (replaying the same read within /// one retry cycle cannot help), but the renewal loop keeps retrying on /// its cadence, so repairing the file heals the source without a restart. async fn resolve_secret_id(&self) -> AttemptResult { let Some(path) = &self.secret_id_file else { return Ok(self.secret_id.clone()); }; let mut raw = tokio::fs::read_to_string(path).await.map_err(|error| AttemptError { class: ErrorClass::Fatal, error: KmsError::configuration_error(format!("Failed to read AppRole secret_id file {}: {error}", path.display())), })?; let trimmed = raw.trim(); if trimmed.is_empty() { raw.zeroize(); return Err(AttemptError { class: ErrorClass::Fatal, error: KmsError::configuration_error(format!("AppRole secret_id file {} is empty", path.display())), }); } let secret_id = SecretString::new(trimmed.to_string()); raw.zeroize(); Ok(secret_id) } } #[async_trait] impl TokenSource for AppRoleLogin { async fn acquire(&self) -> AttemptResult { let secret_id = self.resolve_secret_id().await?; let auth = vaultrs::auth::approle::login(&self.login_client, &self.mount, &self.role_id, secret_id.expose()) .await .map_err(|error| attempt_error("AppRole login", error))?; Ok(TokenLease::from_auth(auth)) } async fn renew(&self, client: &VaultClient) -> AttemptResult { let auth = vaultrs::token::renew_self(client, None) .await .map_err(|error| attempt_error("token renewal", error))?; Ok(TokenLease::from_auth(auth)) } } impl fmt::Debug for AppRoleLogin { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { // The login client embeds Vault client settings and must stay out of // Debug output; role_id is not a secret in Vault's AppRole model. f.debug_struct("AppRoleLogin") .field("mount", &self.mount) .field("role_id", &self.role_id) .field("secret_id", &self.secret_id) .field("secret_id_file", &self.secret_id_file) .finish_non_exhaustive() } } /// Token source for [`VaultAuthMethod::Kubernetes`]: exchanges the pod's /// projected ServiceAccount token for a lease-bound Vault token. /// /// The JWT is re-read on every login because the kubelet rotates a projected /// token well inside the pod's lifetime; caching it would strand the source on /// an expired assertion once the current Vault token can no longer be renewed. /// /// Unlike [`TokenFileSource`], the file mode is not checked: the kubelet owns /// the projected token and mounts it world-readable by default, so rejecting /// group/other bits would refuse every standard pod rather than catch a /// deployment error. pub(crate) struct KubernetesLogin { /// Unauthenticated client used only for the login exchange. login_client: VaultClient, mount: String, role: String, jwt_path: PathBuf, } impl KubernetesLogin { pub(crate) fn new(settings: &VaultConnectionSettings, mount: String, role: String, jwt_path: PathBuf) -> Result { Ok(Self { login_client: settings.build_login_client()?, mount, role, jwt_path, }) } /// Read the ServiceAccount token for one login attempt. /// /// Mirrors [`AppRoleLogin::resolve_secret_id`]: a read failure is fatal for /// the attempt but the refresh loop keeps retrying, so a token the kubelet /// has not projected yet heals the source without a restart. async fn resolve_jwt(&self) -> AttemptResult { let mut raw = tokio::fs::read_to_string(&self.jwt_path) .await .map_err(|error| AttemptError { class: ErrorClass::Fatal, error: KmsError::configuration_error(format!( "Failed to read Kubernetes ServiceAccount token {}: {error}", self.jwt_path.display() )), })?; let trimmed = raw.trim(); if trimmed.is_empty() { raw.zeroize(); return Err(AttemptError { class: ErrorClass::Fatal, error: KmsError::configuration_error(format!( "Kubernetes ServiceAccount token {} is empty", self.jwt_path.display() )), }); } let jwt = SecretString::new(trimmed.to_string()); raw.zeroize(); Ok(jwt) } } #[async_trait] impl TokenSource for KubernetesLogin { async fn acquire(&self) -> AttemptResult { let jwt = self.resolve_jwt().await?; let auth = vaultrs::auth::kubernetes::login(&self.login_client, &self.mount, &self.role, jwt.expose()) .await .map_err(|error| attempt_error("Kubernetes login", error))?; Ok(TokenLease::from_auth(auth)) } async fn renew(&self, client: &VaultClient) -> AttemptResult { let auth = vaultrs::token::renew_self(client, None) .await .map_err(|error| attempt_error("token renewal", error))?; Ok(TokenLease::from_auth(auth)) } } impl fmt::Debug for KubernetesLogin { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { // The login client embeds Vault client settings and must stay out of // Debug output; the role name is not a secret, and the JWT is never held. f.debug_struct("KubernetesLogin") .field("mount", &self.mount) .field("role", &self.role) .field("jwt_path", &self.jwt_path) .finish_non_exhaustive() } } /// Token source for [`VaultAuthMethod::TokenFile`]: reads an agent-managed /// token file (for example a Vault Agent auto-auth sink). /// /// The agent owns the token's lifecycle; this source only tracks the file. /// Every read grants the token an observed validity of `validity`, so the /// renewal loop re-reads the file at half that interval and installs a new /// client generation from whatever the file holds. A file that disappears or /// turns empty keeps failing the refresh until the fail-closed window trips, /// and heals the provider as soon as it is restored. pub(crate) struct TokenFileSource { path: PathBuf, /// Observed validity granted per successful read; the renewal loop /// re-reads at half this value. validity: Duration, /// Digest of the last token read, to tell agent-driven rotations apart /// from plain refreshes in the logs. Never holds the token itself. last_digest: std::sync::Mutex>, } impl TokenFileSource { pub(crate) fn new(path: PathBuf, validity: Duration) -> Self { Self { path, validity, last_digest: std::sync::Mutex::new(None), } } fn fatal(error: KmsError) -> AttemptError { AttemptError { class: ErrorClass::Fatal, error, } } /// Reject a token file readable by group or other, mirroring the SFTP /// host-key rule: a Vault token grants use of the KMS keys, so a mode /// wider than owner-only is a deployment error, not a warning. #[cfg(unix)] fn check_permissions(&self, metadata: &std::fs::Metadata) -> AttemptResult<()> { use std::os::unix::fs::PermissionsExt; let mode = metadata.permissions().mode() & 0o777; if mode & 0o077 != 0 { return Err(Self::fatal(KmsError::configuration_error(format!( "Vault token file {} has insecure permissions {mode:#o} (group and other permission bits must be unset)", self.path.display() )))); } Ok(()) } } #[async_trait] impl TokenSource for TokenFileSource { async fn acquire(&self) -> AttemptResult { // Synchronous reads on purpose: the token file is tiny, this runs at // the renewal cadence, and staying off the blocking pool keeps the // paused-clock tests of the renewal timing deterministic. let metadata = std::fs::metadata(&self.path).map_err(|error| { Self::fatal(KmsError::configuration_error(format!( "Failed to read Vault token file {}: {error}", self.path.display() ))) })?; #[cfg(unix)] self.check_permissions(&metadata)?; let mut raw = std::fs::read_to_string(&self.path).map_err(|error| { Self::fatal(KmsError::configuration_error(format!( "Failed to read Vault token file {}: {error}", self.path.display() ))) })?; let trimmed = raw.trim(); if trimmed.is_empty() { raw.zeroize(); return Err(Self::fatal(KmsError::configuration_error(format!( "Vault token file {} is empty", self.path.display() )))); } let digest: [u8; 32] = sha2::Sha256::digest(trimmed.as_bytes()).into(); let previous = self .last_digest .lock() .expect("token file digest mutex poisoned") .replace(digest); if previous.is_some_and(|previous| previous != digest) { info!( path = %self.path.display(), modified = ?metadata.modified().ok(), "Vault token file rotated; installing a new client generation" ); } let token = trimmed.to_string(); raw.zeroize(); Ok(TokenLease::new( token, Some(LeaseInfo { ttl: self.validity, renewable: false, }), )) } } impl fmt::Debug for TokenFileSource { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { // The token itself is never stored on the source; only its digest is. f.debug_struct("TokenFileSource") .field("path", &self.path) .field("validity", &self.validity) .finish_non_exhaustive() } } /// Map the configured auth method onto a token source. pub(crate) fn token_source_for( auth_method: &VaultAuthMethod, settings: &VaultConnectionSettings, ) -> Result> { match auth_method { VaultAuthMethod::Token { token } => Ok(Box::new(StaticToken::new(token.clone()))), VaultAuthMethod::AppRole { role_id, secret_id, secret_id_file, mount, .. } => Ok(Box::new(AppRoleLogin::new( settings, mount.clone(), role_id.clone(), secret_id.clone(), secret_id_file.clone(), )?)), VaultAuthMethod::Kubernetes { role, mount, jwt_path, .. } => Ok(Box::new(KubernetesLogin::new(settings, mount.clone(), role.clone(), jwt_path.clone())?)), VaultAuthMethod::TokenFile { path, poll_interval_secs, .. } => { let poll_interval = Duration::from_secs(poll_interval_secs.unwrap_or(DEFAULT_TOKEN_FILE_POLL_INTERVAL_SECS).max(1)); // Validity is twice the poll interval because the renewal loop // fires at half the lease TTL: the file is then re-read once per // poll interval, and a file that stops being readable expires the // token after roughly two missed polls minus the safety window. Ok(Box::new(TokenFileSource::new(path.clone(), poll_interval * 2))) } } } /// Connection parameters shared by every client generation. #[derive(Debug, Clone)] pub(crate) struct VaultConnectionSettings { pub(crate) address: String, pub(crate) namespace: Option, /// Per-attempt HTTP timeout applied to the underlying reqwest client. pub(crate) attempt_timeout: Duration, /// Whether to accept an unverified Vault server certificate. Gated on /// `allow_insecure_dev_defaults` by `KmsConfig::validate`. pub(crate) skip_tls_verify: bool, } impl VaultConnectionSettings { /// Build an authenticated client for one generation. fn build_client(&self, token: &str) -> Result { let mut settings_builder = VaultClientSettingsBuilder::default(); settings_builder.address(&self.address); // Defense in depth against stalled connections: vaultrs leaves the // underlying reqwest client without any timeout by default, so a hung // request would otherwise wait forever regardless of the // operation-level retry policy. settings_builder.timeout(Some(self.attempt_timeout)); settings_builder.token(token); // Always set explicitly: left unset, vaultrs derives this from its own // VAULT_SKIP_VERIFY variable, so a stray value in the environment would // disable certificate verification behind the KMS configuration and its // insecure-defaults gate. settings_builder.verify(!self.skip_tls_verify); if let Some(namespace) = &self.namespace { settings_builder.namespace(Some(namespace.clone())); } let settings = settings_builder .build() .map_err(|e| KmsError::backend_error(format!("Failed to build Vault client settings: {e}")))?; VaultClient::new(settings).map_err(|e| KmsError::backend_error(format!("Failed to create Vault client: {e}"))) } /// Build the tokenless client used for login exchanges. fn build_login_client(&self) -> Result { self.build_client("") } } /// Refresh and fail-closed tuning for a [`VaultCredentialProvider`]. #[derive(Debug, Clone)] pub(crate) struct VaultCredentialPolicy { /// Retry budget for one login cycle. pub(crate) retry: RetryPolicy, /// Retry budget for one token-renewal cycle. pub(crate) renew_retry: RetryPolicy, /// Fail-closed margin: once the current token is within this window of /// expiry without a successful refresh, [`VaultCredentialProvider::current`] /// refuses to hand it out. pub(crate) safety_window: Duration, /// Pause between refresh cycles after a failed one. pub(crate) retry_interval: Duration, } impl VaultCredentialPolicy { /// Derive the policy from the KMS configuration. /// /// The default safety window equals the per-attempt timeout: a request /// issued now can stay in flight for up to one attempt timeout, so the /// token must outlive at least that. pub(crate) fn from_kms_config( config: &KmsConfig, auth_method: &VaultAuthMethod, backend: &'static str, endpoint: &str, namespace: Option<&str>, ) -> Self { let retry = RetryPolicy::for_credentials(config, backend, endpoint, namespace, "credentials-login"); let safety_window = match auth_method { VaultAuthMethod::AppRole { refresh_safety_window_secs: Some(secs), .. } | VaultAuthMethod::Kubernetes { refresh_safety_window_secs: Some(secs), .. } | VaultAuthMethod::TokenFile { refresh_safety_window_secs: Some(secs), .. } => Duration::from_secs(*secs), _ => retry.attempt_timeout, }; Self { retry, renew_retry: RetryPolicy::for_credentials(config, backend, endpoint, namespace, "credentials-renew"), safety_window, retry_interval: DEFAULT_REFRESH_RETRY_INTERVAL, } } } /// One authenticated client generation. /// /// Request paths hold the handle (via `Arc`) for the duration of a single /// Vault call, so a rotation that swaps in a newer generation never tears the /// client out from under an in-flight request. pub(crate) struct VaultClientHandle { /// Monotonic counter identifying the credential generation this client was /// built from; bumped on every successful refresh. pub(crate) generation: u64, pub(crate) client: VaultClient, /// When this generation's token was issued (or last renewed). issued_at: Instant, /// Lease of this generation's token; `None` when it never expires. lease: Option, } impl VaultClientHandle { /// Absolute expiry of this generation's token. /// /// `lease.ttl` is built from the `lease_duration` the Vault server sent, so /// a value too large to add to `issued_at` would panic on the bare `+`. A /// TTL that cannot be represented is indistinguishable from no expiry, so it /// collapses to `None` — the same answer already given for the zero-lease /// tokens Vault issues, which keeps the token in use and still fully /// validated by Vault on every call. fn expires_at(&self) -> Option { self.lease.and_then(|lease| self.issued_at.checked_add(lease.ttl)) } /// When the renewal task should refresh this generation: half the TTL, /// leaving the second half as budget for retries before the fail-closed /// window is reached. /// /// Unrepresentable TTLs collapse to `None` as in [`Self::expires_at`], /// leaving a token that never expires with nothing to renew. fn renew_at(&self) -> Option { self.lease.and_then(|lease| self.issued_at.checked_add(lease.ttl / 2)) } } impl fmt::Debug for VaultClientHandle { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { // `VaultClient` embeds its settings, including the token, so it must // never appear in Debug output. f.debug_struct("VaultClientHandle") .field("generation", &self.generation) .field("lease", &self.lease) .finish_non_exhaustive() } } /// Owns the authenticated Vault client for a backend and hands out /// per-request snapshots. pub(crate) struct VaultCredentialProvider { settings: VaultConnectionSettings, source: Box, policy: VaultCredentialPolicy, current: ArcSwap, /// Serializes refreshes so concurrent triggers coalesce into one login. refresh_lock: tokio::sync::Mutex<()>, } impl VaultCredentialProvider { /// Authenticate with `source` and build the initial client generation. pub(crate) async fn new( settings: VaultConnectionSettings, source: Box, policy: VaultCredentialPolicy, ) -> Result { let startup_cancel = CancellationToken::new(); let lease = policy::execute("vault_login", OpClass::Auth, &policy.retry, &startup_cancel, || source.acquire()).await?; let client = settings.build_client(lease.expose())?; Ok(Self { current: ArcSwap::from_pointee(VaultClientHandle { generation: 0, client, issued_at: Instant::now(), lease: lease.lease_info(), }), settings, source, policy, refresh_lock: tokio::sync::Mutex::new(()), }) } /// Snapshot the current generation without the expiry gate. Internal use /// (renewal scheduling) and tests only; request paths go through /// [`VaultCredentialProvider::current`]. pub(crate) fn snapshot(&self) -> Arc { self.current.load_full() } /// Snapshot the current client generation for a single request. /// /// Take one snapshot per Vault call: the returned `Arc` pins the /// generation for exactly that call, so a concurrent rotation applies to /// the next call without interrupting this one. /// /// Fails closed when the token is inside the safety window of its expiry: /// a request signed with such a token could lapse mid-flight, so refusing /// it locally is strictly safer than an unpredictable remote failure. pub(crate) fn current(&self) -> Result> { let handle = self.current.load_full(); if let Some(expires_at) = handle.expires_at() { let now = Instant::now(); if self.inside_safety_window(now, expires_at) { return Err(KmsError::credentials_unavailable(format!( "Vault token (generation {}) is within {:?} of expiry and has not been refreshed; refusing to use it", handle.generation, self.policy.safety_window ))); } } Ok(handle) } /// Whether the token expiring at `expires_at` is close enough to refuse. /// /// `safety_window` reaches here from persisted configuration, so it is not /// guaranteed to have passed this version's validation: a window too large /// to add to the current instant would panic on the bare `+`. Such a window /// means every token is always inside it, so saturating to "refuse" is both /// the fail-closed answer and the one the arithmetic was reaching for. fn inside_safety_window(&self, now: Instant, expires_at: Instant) -> bool { now.checked_add(self.policy.safety_window) .is_none_or(|deadline| deadline >= expires_at) } /// Publish the credential gauges for the generation currently installed. /// /// The fail-closed gauge re-evaluates the very gate /// [`VaultCredentialProvider::current`] applies, so what operators see and /// what the request path does cannot drift apart. fn record_credential_gauges(&self) { let handle = self.current.load(); let now = Instant::now(); let fail_closed = match handle.expires_at() { Some(expires_at) => { metrics::gauge!(METRIC_TOKEN_TTL_SECONDS).set(expires_at.saturating_duration_since(now).as_secs_f64()); self.inside_safety_window(now, expires_at) } // A generation without an expiry has no remaining TTL to report // and can never lapse, so it can never fail closed either. None => false, }; metrics::gauge!(METRIC_CREDENTIALS_FAIL_CLOSED).set(if fail_closed { 1.0 } else { 0.0 }); } /// Wait until `deadline`, republishing the credential gauges on the /// observation cadence. Reports `false` when cancellation cut the wait /// short. async fn wait_publishing_gauges(&self, deadline: Instant, cancel: &CancellationToken) -> bool { loop { self.record_credential_gauges(); let now = Instant::now(); if now >= deadline { return true; } let slice = (deadline - now).min(CREDENTIAL_GAUGE_INTERVAL); tokio::select! { biased; _ = cancel.cancelled() => return false, _ = tokio::time::sleep(slice) => {} } } } /// Refresh the credentials if generation `observed` is still current. /// /// Single-flight: concurrent callers serialize on the refresh lock, and a /// caller that finds a newer generation already installed returns without /// touching Vault. Renewable tokens are renewed in place; anything else /// (or a failed renewal) falls back to a fresh login. pub(crate) async fn refresh(&self, observed: u64, cancel: &CancellationToken) -> Result<()> { let _guard = self.refresh_lock.lock().await; let current = self.snapshot(); if current.generation != observed { return Ok(()); } let renewable = current.lease.map(|lease| lease.renewable).unwrap_or(false); let renewed = if renewable { match policy::execute("vault_token_renew", OpClass::Auth, &self.policy.renew_retry, cancel, || { self.source.renew(¤t.client) }) .await { Ok(lease) => Some(lease), Err(error @ KmsError::OperationCancelled { .. }) => return Err(error), Err(error) => { warn!( generation = current.generation, error = %error, "Vault token renewal failed; falling back to a fresh login" ); None } } } else { None }; let lease = match renewed { Some(lease) => lease, None => policy::execute("vault_login", OpClass::Auth, &self.policy.retry, cancel, || self.source.acquire()).await?, }; let client = self.settings.build_client(lease.expose())?; self.current.store(Arc::new(VaultClientHandle { generation: current.generation + 1, client, issued_at: Instant::now(), lease: lease.lease_info(), })); Ok(()) } /// Spawn the background renewal task for lease-bound credentials. /// /// Returns `None` when the current token never expires (static tokens): /// there is nothing to renew. The returned handle cancels the task when /// dropped, tying the task's lifetime to whoever owns the handle (the /// service version that owns this backend). pub(crate) fn spawn_renewal_task(self: &Arc) -> Option { self.snapshot().lease?; let cancel = CancellationToken::new(); let join = tokio::spawn(renewal_loop(Arc::clone(self), cancel.clone())); Some(CredentialTaskHandle { cancel, join: std::sync::Mutex::new(Some(join)), }) } } impl fmt::Debug for VaultCredentialProvider { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("VaultCredentialProvider") .field("source", &self.source) .field("current", &self.current.load()) .field("policy", &self.policy) .finish_non_exhaustive() } } /// Drive credential refreshes until cancelled. /// /// Each cycle sleeps until the current generation's renewal point (half TTL), /// then refreshes. A failed cycle logs, waits `retry_interval`, and tries /// again immediately (the renewal point is already in the past), so the /// provider keeps trying to recover even after the fail-closed window has /// been reached. /// /// Both waits run through [`VaultCredentialProvider::wait_publishing_gauges`], /// which is the only place the credential gauges are published: the loop is /// already the component that tracks token expiry, and doing it here keeps the /// request path free of any metric work. async fn renewal_loop(provider: Arc, cancel: CancellationToken) { describe_credential_metrics(); loop { let handle = provider.snapshot(); let Some(renew_at) = handle.renew_at() else { // The current generation never expires; nothing left to schedule. provider.record_credential_gauges(); return; }; if !provider.wait_publishing_gauges(renew_at, &cancel).await { return; } match provider.refresh(handle.generation, &cancel).await { Ok(()) => {} Err(KmsError::OperationCancelled { .. }) => return, Err(error) => { warn!( generation = handle.generation, error = %error, "Vault credential refresh failed; retrying until the credentials recover" ); let retry_at = Instant::now() + provider.policy.retry_interval; if !provider.wait_publishing_gauges(retry_at, &cancel).await { return; } } } } } /// Owner handle for a spawned renewal task. /// /// Dropping the handle cancels the task, so hanging it off the service version /// recycles the task on stop and reconfigure without explicit lifecycle calls. pub(crate) struct CredentialTaskHandle { cancel: CancellationToken, join: std::sync::Mutex>>, } impl CredentialTaskHandle { /// Cancel the renewal task and wait for it to exit. pub(crate) async fn shutdown(&self) { self.cancel.cancel(); let join = self.join.lock().expect("credential task join mutex poisoned").take(); if let Some(join) = join { let _ = join.await; } } } impl Drop for CredentialTaskHandle { fn drop(&mut self) { self.cancel.cancel(); } } #[cfg(test)] mod tests { use super::*; use crate::config::{DEFAULT_VAULT_KUBERNETES_MOUNT, REDACTED_SECRET}; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; const TEST_TOKEN: &str = "vault-token-debug-leak-canary"; const TEST_SECRET_ID: &str = "approle-secret-id-leak-canary"; fn test_settings() -> VaultConnectionSettings { VaultConnectionSettings { address: "http://127.0.0.1:8200".to_string(), namespace: Some("team-namespace".to_string()), attempt_timeout: Duration::from_secs(30), skip_tls_verify: false, } } /// Tight retry budget so paused-clock tests stay deterministic: one /// attempt per cycle, failed cycles spaced by `retry_interval`. fn test_policy(safety_window: Duration, retry_interval: Duration) -> VaultCredentialPolicy { let retry = RetryPolicy::for_test( Duration::from_secs(1), Duration::from_secs(1), 1, Duration::from_millis(10), Duration::from_millis(10), ); let renew_retry = RetryPolicy::for_test( Duration::from_secs(1), Duration::from_secs(1), 1, Duration::from_millis(10), Duration::from_millis(10), ); VaultCredentialPolicy { retry, renew_retry, safety_window, retry_interval, } } #[test] fn configured_login_and_renewal_use_reserved_credential_capacity() { let config = KmsConfig::default(); let auth_method = VaultAuthMethod::Token { token: TEST_TOKEN.to_string(), }; let credentials = VaultCredentialPolicy::from_kms_config( &config, &auth_method, "vault-kv2", "https://credential-policy.example.invalid", Some("team-namespace"), ); let operations = RetryPolicy::for_backend( &config, "vault-kv2", "https://credential-policy.example.invalid", Some("team-namespace"), "operations", ); assert!(credentials.retry.uses_credential_reserve()); assert!(credentials.renew_retry.uses_credential_reserve()); assert!(!operations.uses_credential_reserve()); assert!(credentials.retry.shares_active_capacity_with(&credentials.renew_retry)); assert!(credentials.retry.shares_active_capacity_with(&operations)); } /// Shared observable state of a [`ScriptedSource`]. #[derive(Debug, Default)] struct ScriptedState { login_calls: AtomicU32, renew_calls: AtomicU32, fail_login: AtomicBool, fail_renew: AtomicBool, } /// Token source with scriptable outcomes for driving the provider without /// a Vault server. #[derive(Debug)] struct ScriptedSource { state: Arc, ttl: Duration, renewable: bool, login_delay: Duration, } impl ScriptedSource { fn lease(&self) -> Option { (!self.ttl.is_zero()).then_some(LeaseInfo { ttl: self.ttl, renewable: self.renewable, }) } fn failure() -> AttemptError { AttemptError { class: ErrorClass::RetryableStatus, error: KmsError::backend_error("scripted auth failure (503)"), } } } #[async_trait] impl TokenSource for ScriptedSource { async fn acquire(&self) -> AttemptResult { if !self.login_delay.is_zero() { tokio::time::sleep(self.login_delay).await; } let call = self.state.login_calls.fetch_add(1, Ordering::SeqCst); if self.state.fail_login.load(Ordering::SeqCst) { return Err(Self::failure()); } Ok(TokenLease::new(format!("scripted-login-{call}"), self.lease())) } async fn renew(&self, _client: &VaultClient) -> AttemptResult { let call = self.state.renew_calls.fetch_add(1, Ordering::SeqCst); if self.state.fail_renew.load(Ordering::SeqCst) { return Err(Self::failure()); } Ok(TokenLease::new(format!("scripted-renew-{call}"), self.lease())) } } async fn scripted_provider( ttl: Duration, renewable: bool, policy: VaultCredentialPolicy, ) -> (Arc, Arc) { let state = Arc::new(ScriptedState::default()); let source = ScriptedSource { state: state.clone(), ttl, renewable, login_delay: Duration::ZERO, }; let provider = VaultCredentialProvider::new(test_settings(), Box::new(source), policy) .await .expect("scripted provider must build without a live Vault"); (Arc::new(provider), state) } async fn static_provider() -> VaultCredentialProvider { VaultCredentialProvider::new( test_settings(), Box::new(StaticToken::new(TEST_TOKEN.to_string())), test_policy(Duration::from_secs(10), Duration::from_secs(5)), ) .await .expect("static provider must build without a live Vault") } #[tokio::test] async fn test_static_token_snapshots_pin_one_generation() { let provider = static_provider().await; let first = provider.current().expect("static tokens never expire"); let second = provider.current().expect("static tokens never expire"); assert_eq!(first.generation, 0); assert!( Arc::ptr_eq(&first, &second), "without rotation every snapshot must return the same client generation" ); } #[tokio::test] async fn test_static_token_spawns_no_renewal_task() { let provider = Arc::new(static_provider().await); assert!(provider.spawn_renewal_task().is_none(), "a token without a lease has nothing to renew"); } #[tokio::test] async fn test_static_token_source_yields_configured_token() { let settings = test_settings(); let source = token_source_for( &VaultAuthMethod::Token { token: TEST_TOKEN.to_string(), }, &settings, ) .expect("token auth must map to a source"); let lease = source.acquire().await.expect("static acquire cannot fail"); assert_eq!(lease.expose(), TEST_TOKEN); assert!(lease.lease_info().is_none(), "static tokens must not carry a lease"); } #[tokio::test] async fn test_approle_auth_method_maps_to_login_source() { let settings = test_settings(); let source = token_source_for(&VaultAuthMethod::approle("role".to_string(), TEST_SECRET_ID.to_string()), &settings) .expect("approle auth must map to a login source"); assert!(format!("{source:?}").contains("AppRoleLogin")); } #[tokio::test] async fn test_kubernetes_auth_method_maps_to_login_source() { let settings = test_settings(); let source = token_source_for(&VaultAuthMethod::kubernetes("rustfs".to_string()), &settings) .expect("kubernetes auth must map to a login source"); assert!(format!("{source:?}").contains("KubernetesLogin")); } /// `refresh_safety_window_secs` is operator-supplied and reaches the request /// path from persisted configuration, so the fail-closed comparison must /// survive a window too large to add to the current instant. Before the /// checked arithmetic this panicked with "overflow when adding duration to /// instant" on the first request after a lease-bearing login. #[tokio::test] async fn test_current_refuses_rather_than_panics_on_an_unrepresentable_safety_window() { let (provider, _state) = scripted_provider( Duration::from_secs(60), true, test_policy(Duration::from_secs(u64::MAX), Duration::from_secs(5)), ) .await; let error = provider .current() .expect_err("a window wider than any lease must refuse the token"); assert!( matches!(error, KmsError::CredentialsUnavailable { .. }), "expected CredentialsUnavailable, got {error:?}" ); } /// `lease_duration` is a bare u64 straight off the Vault response and forms /// the other side of the same comparison, so an absurd one must not panic /// either. It is indistinguishable from a non-expiring token, which is how /// the zero-lease case already behaves. #[tokio::test] async fn test_an_unrepresentable_lease_is_treated_as_non_expiring() { let (provider, _state) = scripted_provider( Duration::from_secs(u64::MAX), true, test_policy(Duration::from_secs(30), Duration::from_secs(5)), ) .await; provider .current() .expect("a token whose expiry cannot be represented must stay usable"); } /// The configured flag has to reach the HTTP client, not just the config /// struct: every generation (authenticated and login) builds its own client, /// and a Vault with a self-signed certificate fails the handshake unless /// each one carries the setting. #[test] fn test_skip_tls_verify_reaches_every_vault_client_generation() { for skip_tls_verify in [false, true] { let settings = VaultConnectionSettings { address: "https://vault.example.com:8200".to_string(), namespace: None, attempt_timeout: Duration::from_secs(30), skip_tls_verify, }; let authenticated = settings.build_client(TEST_TOKEN).expect("authenticated client must build"); assert_eq!(authenticated.settings.verify, !skip_tls_verify); let login = settings.build_login_client().expect("login client must build"); assert_eq!(login.settings.verify, !skip_tls_verify); } } /// vaultrs derives `verify` from its own VAULT_SKIP_VERIFY variable when the /// builder leaves it unset, which would disable certificate verification /// without passing the KMS insecure-defaults gate. #[test] fn test_vaultrs_skip_verify_env_cannot_override_the_configured_setting() { temp_env::with_var("VAULT_SKIP_VERIFY", Some("true"), || { let client = test_settings().build_client(TEST_TOKEN).expect("client must build"); assert!( client.settings.verify, "a stray VAULT_SKIP_VERIFY must not disable verification behind the KMS configuration" ); }); } /// The projected token is read fresh per login attempt and trimmed, so a /// kubelet rotation is picked up without a restart and a trailing newline /// does not corrupt the assertion sent to Vault. #[tokio::test] async fn test_kubernetes_login_rereads_and_trims_the_service_account_token() { let dir = tempfile::tempdir().expect("temp dir"); let path = dir.path().join("token"); tokio::fs::write(&path, " first-jwt\n").await.expect("write token"); let login = KubernetesLogin::new( &test_settings(), DEFAULT_VAULT_KUBERNETES_MOUNT.to_string(), "rustfs".to_string(), path.clone(), ) .expect("login source must build"); assert_eq!(login.resolve_jwt().await.expect("first read").expose(), "first-jwt"); tokio::fs::write(&path, "rotated-jwt").await.expect("rotate token"); assert_eq!( login.resolve_jwt().await.expect("second read").expose(), "rotated-jwt", "a rotated projected token must be picked up without a restart" ); } /// The ServiceAccount token is re-read per attempt, so an unreadable or /// empty one fails that attempt without reaching Vault; the refresh loop /// keeps retrying, which is what lets a late projection heal the source. #[tokio::test] async fn test_kubernetes_login_rejects_an_unusable_service_account_token() { let dir = tempfile::tempdir().expect("temp dir"); let missing = dir.path().join("absent-token"); let empty = dir.path().join("empty-token"); tokio::fs::write(&empty, " \n").await.expect("write empty token"); for (path, expected) in [(missing, "Failed to read"), (empty, "is empty")] { let login = KubernetesLogin::new(&test_settings(), DEFAULT_VAULT_KUBERNETES_MOUNT.to_string(), "rustfs".to_string(), path) .expect("login source must build"); let error = login .acquire() .await .expect_err("an unusable ServiceAccount token must fail the attempt"); assert!(matches!(error.class, ErrorClass::Fatal)); assert!(error.error.to_string().contains(expected), "got {}", error.error); } } #[tokio::test(start_paused = true)] async fn test_renewal_task_renews_at_half_ttl() { let (provider, state) = scripted_provider( Duration::from_secs(60), true, test_policy(Duration::from_secs(10), Duration::from_secs(5)), ) .await; let task = provider.spawn_renewal_task().expect("lease-bound tokens need renewal"); tokio::time::sleep(Duration::from_secs(29)).await; assert_eq!(state.renew_calls.load(Ordering::SeqCst), 0, "renewal must not run before half TTL"); assert_eq!(provider.snapshot().generation, 0); tokio::time::sleep(Duration::from_secs(2)).await; assert_eq!(state.renew_calls.load(Ordering::SeqCst), 1, "renewal must run at half TTL"); assert_eq!(state.login_calls.load(Ordering::SeqCst), 1, "renewable tokens must not re-login"); assert_eq!(provider.snapshot().generation, 1, "a successful renewal must install a new generation"); task.shutdown().await; } #[tokio::test(start_paused = true)] async fn test_failed_renewal_falls_back_to_login() { let (provider, state) = scripted_provider( Duration::from_secs(60), true, test_policy(Duration::from_secs(10), Duration::from_secs(5)), ) .await; state.fail_renew.store(true, Ordering::SeqCst); let task = provider.spawn_renewal_task().expect("renewal task"); tokio::time::sleep(Duration::from_secs(31)).await; assert_eq!(state.renew_calls.load(Ordering::SeqCst), 1, "renewal must be attempted first"); assert_eq!( state.login_calls.load(Ordering::SeqCst), 2, "failed renewal must fall back to a fresh login" ); assert_eq!(provider.snapshot().generation, 1); task.shutdown().await; } #[tokio::test(start_paused = true)] async fn test_current_fails_closed_inside_safety_window_and_recovers() { let (provider, state) = scripted_provider( Duration::from_secs(60), true, test_policy(Duration::from_secs(10), Duration::from_secs(5)), ) .await; state.fail_renew.store(true, Ordering::SeqCst); state.fail_login.store(true, Ordering::SeqCst); let task = provider.spawn_renewal_task().expect("renewal task"); // Refresh cycles at 30s, 35s, ... keep failing; the token stays usable // until 50s (60s TTL minus the 10s safety window). tokio::time::sleep(Duration::from_secs(49)).await; provider .current() .expect("token outside the safety window must still be served"); tokio::time::sleep(Duration::from_secs(2)).await; let error = provider .current() .expect_err("token inside the safety window must be refused"); assert!( matches!(error, KmsError::CredentialsUnavailable { .. }), "expected CredentialsUnavailable, got {error:?}" ); // Recovery: after the failed-refresh circuit's cool-down, its single // half-open probe succeeds, installs a fresh generation, and the // provider serves requests again. state.fail_renew.store(false, Ordering::SeqCst); state.fail_login.store(false, Ordering::SeqCst); tokio::time::sleep(Duration::from_secs(31)).await; let handle = provider.current().expect("provider must recover after a successful refresh"); assert!(handle.generation >= 1); task.shutdown().await; } #[tokio::test(start_paused = true)] async fn test_shutdown_recycles_renewal_task_promptly() { let (provider, _state) = scripted_provider( Duration::from_secs(60), true, test_policy(Duration::from_secs(10), Duration::from_secs(5)), ) .await; let task = provider.spawn_renewal_task().expect("renewal task"); tokio::time::timeout(Duration::from_secs(1), task.shutdown()) .await .expect("cancelled renewal task must exit promptly"); } #[tokio::test(start_paused = true)] async fn test_dropping_task_handle_cancels_renewal_task() { let (provider, _state) = scripted_provider( Duration::from_secs(60), true, test_policy(Duration::from_secs(10), Duration::from_secs(5)), ) .await; let task = provider.spawn_renewal_task().expect("renewal task"); let cancel_probe = task.cancel.clone(); drop(task); tokio::time::timeout(Duration::from_secs(1), cancel_probe.cancelled()) .await .expect("dropping the handle must cancel the renewal task"); } #[tokio::test(start_paused = true)] async fn test_concurrent_refreshes_coalesce_into_one_login() { let state = Arc::new(ScriptedState::default()); let source = ScriptedSource { state: state.clone(), ttl: Duration::from_secs(60), renewable: false, login_delay: Duration::from_millis(100), }; let provider = Arc::new( VaultCredentialProvider::new( test_settings(), Box::new(source), test_policy(Duration::from_secs(10), Duration::from_secs(5)), ) .await .expect("provider"), ); assert_eq!(state.login_calls.load(Ordering::SeqCst), 1, "initial login"); let cancel = CancellationToken::new(); let first = { let provider = provider.clone(); let cancel = cancel.clone(); tokio::spawn(async move { provider.refresh(0, &cancel).await }) }; let second = { let provider = provider.clone(); let cancel = cancel.clone(); tokio::spawn(async move { provider.refresh(0, &cancel).await }) }; first.await.expect("join").expect("refresh"); second.await.expect("join").expect("refresh"); assert_eq!( state.login_calls.load(Ordering::SeqCst), 2, "concurrent refreshes of the same generation must coalesce into one login" ); assert_eq!(provider.snapshot().generation, 1); } #[tokio::test] async fn test_approle_secret_id_file_missing_fails_fatally() { let dir = tempfile::tempdir().expect("tempdir"); let source = AppRoleLogin::new( &test_settings(), "approle".to_string(), "role".to_string(), String::new(), Some(dir.path().join("absent-secret-id")), ) .expect("source"); let failure = source .resolve_secret_id() .await .expect_err("missing secret_id file must fail the attempt"); assert_eq!(failure.class, ErrorClass::Fatal); assert!(matches!(failure.error, KmsError::ConfigurationError { .. })); } #[tokio::test] async fn test_approle_secret_id_file_empty_fails_fatally() { let file = tempfile::NamedTempFile::new().expect("tempfile"); std::fs::write(file.path(), " \n\t\n").expect("write whitespace"); let source = AppRoleLogin::new( &test_settings(), "approle".to_string(), "role".to_string(), String::new(), Some(file.path().to_path_buf()), ) .expect("source"); let failure = source .resolve_secret_id() .await .expect_err("effectively empty secret_id file must fail the attempt"); assert_eq!(failure.class, ErrorClass::Fatal); assert!(failure.error.to_string().contains("is empty")); } #[tokio::test] async fn test_approle_secret_id_file_takes_precedence_and_is_trimmed() { let file = tempfile::NamedTempFile::new().expect("tempfile"); std::fs::write(file.path(), format!(" {TEST_SECRET_ID}\n")).expect("write secret"); let source = AppRoleLogin::new( &test_settings(), "approle".to_string(), "role".to_string(), "inline-secret-id-must-lose".to_string(), Some(file.path().to_path_buf()), ) .expect("source"); let secret_id = source.resolve_secret_id().await.expect("readable file must resolve"); assert_eq!(secret_id.expose(), TEST_SECRET_ID); } /// Leak regression: the Debug output of every credential-carrying type /// must stay free of token and secret_id literals. #[tokio::test] async fn test_credential_types_debug_redacts_token() { let provider = static_provider().await; let handle = provider.current().expect("static token"); let lease = TokenLease::new( TEST_TOKEN.to_string(), Some(LeaseInfo { ttl: Duration::from_secs(60), renewable: true, }), ); let static_source = StaticToken::new(TEST_TOKEN.to_string()); let approle_source = AppRoleLogin::new( &test_settings(), "approle".to_string(), "leak-test-role-id".to_string(), TEST_SECRET_ID.to_string(), None, ) .expect("approle source"); for rendered in [ format!("{provider:?}"), format!("{handle:?}"), format!("{lease:?}"), format!("{static_source:?}"), format!("{approle_source:?}"), ] { assert!(!rendered.contains(TEST_TOKEN), "debug output must not leak the vault token: {rendered}"); assert!( !rendered.contains(TEST_SECRET_ID), "debug output must not leak the approle secret_id: {rendered}" ); } assert!(format!("{lease:?}").contains(REDACTED_SECRET)); let approle_rendered = format!("{approle_source:?}"); assert!(approle_rendered.contains("leak-test-role-id"), "role_id is not a secret"); assert!(approle_rendered.contains(REDACTED_SECRET)); } /// Write a token file with owner-only permissions, as a Vault Agent sink /// would. fn write_owner_only(path: &std::path::Path, contents: &str) { std::fs::write(path, contents).expect("write token file"); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).expect("chmod token file"); } } async fn token_file_provider(path: std::path::PathBuf, policy: VaultCredentialPolicy) -> Arc { Arc::new( VaultCredentialProvider::new(test_settings(), Box::new(TokenFileSource::new(path, Duration::from_secs(60))), policy) .await .expect("token file provider must build without a live Vault"), ) } #[tokio::test] async fn test_token_file_source_reads_and_trims_token() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("vault-token"); write_owner_only(&path, &format!(" {TEST_TOKEN}\n")); let source = TokenFileSource::new(path, Duration::from_secs(60)); let lease = source.acquire().await.expect("readable token file must resolve"); assert_eq!(lease.expose(), TEST_TOKEN); let lease_info = lease.lease_info().expect("token file leases carry an observed validity"); assert_eq!(lease_info.ttl, Duration::from_secs(60)); assert!(!lease_info.renewable, "agent-managed tokens are refreshed by re-reading, not renew-self"); } #[tokio::test] async fn test_token_file_missing_fails_fatally() { let dir = tempfile::tempdir().expect("tempdir"); let source = TokenFileSource::new(dir.path().join("absent-token"), Duration::from_secs(60)); let failure = source.acquire().await.expect_err("missing token file must fail the attempt"); assert_eq!(failure.class, ErrorClass::Fatal); assert!(matches!(failure.error, KmsError::ConfigurationError { .. })); } #[tokio::test] async fn test_token_file_empty_fails_fatally() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("vault-token"); write_owner_only(&path, " \n\t\n"); let source = TokenFileSource::new(path, Duration::from_secs(60)); let failure = source .acquire() .await .expect_err("effectively empty token file must fail the attempt"); assert_eq!(failure.class, ErrorClass::Fatal); assert!(failure.error.to_string().contains("is empty")); } #[cfg(unix)] #[tokio::test] async fn test_token_file_rejects_group_or_other_permission_bits() { use std::os::unix::fs::PermissionsExt; let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("vault-token"); write_owner_only(&path, TEST_TOKEN); let source = TokenFileSource::new(path.clone(), Duration::from_secs(60)); for mode in [0o640u32, 0o604, 0o622, 0o660] { std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).expect("chmod"); let failure = source .acquire() .await .expect_err("token file readable or writable beyond the owner must be rejected"); assert_eq!(failure.class, ErrorClass::Fatal, "mode {mode:#o}"); let rendered = failure.error.to_string(); assert!(rendered.contains("insecure permissions"), "mode {mode:#o}: {rendered}"); assert!(!rendered.contains(TEST_TOKEN), "permission errors must not echo the token"); } std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).expect("chmod"); source.acquire().await.expect("owner-only token file must resolve"); } #[tokio::test(start_paused = true)] async fn test_token_file_replacement_installs_new_generation_next_cycle() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("vault-token"); write_owner_only(&path, "agent-token-v1"); let provider = token_file_provider(path.clone(), test_policy(Duration::from_secs(10), Duration::from_secs(5))).await; let task = provider.spawn_renewal_task().expect("token file credentials are lease-bound"); tokio::time::sleep(Duration::from_secs(29)).await; assert_eq!(provider.snapshot().generation, 0, "no re-read before half the observed validity"); // Atomic replacement, as a Vault Agent sink writes: temp file + rename. let staged = dir.path().join("vault-token.tmp"); write_owner_only(&staged, "agent-token-v2"); std::fs::rename(&staged, &path).expect("atomic replace"); tokio::time::sleep(Duration::from_secs(2)).await; assert_eq!( provider.snapshot().generation, 1, "the poll after a rotation must install a new generation" ); // A poll without a rotation still installs a fresh generation: each // successful read re-extends the token's observed validity. tokio::time::sleep(Duration::from_secs(30)).await; assert_eq!(provider.snapshot().generation, 2); task.shutdown().await; } #[tokio::test(start_paused = true)] async fn test_token_file_deletion_fails_closed_and_recovers() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("vault-token"); write_owner_only(&path, "agent-token-v1"); let provider = token_file_provider(path.clone(), test_policy(Duration::from_secs(10), Duration::from_secs(5))).await; let task = provider.spawn_renewal_task().expect("renewal task"); std::fs::remove_file(&path).expect("remove token file"); // Polls at 30s, 35s, ... keep failing; the last read keeps the token // usable until 50s (60s observed validity minus the 10s safety window). tokio::time::sleep(Duration::from_secs(49)).await; provider .current() .expect("token outside the safety window must still be served"); tokio::time::sleep(Duration::from_secs(2)).await; let error = provider .current() .expect_err("token inside the safety window must be refused"); assert!( matches!(error, KmsError::CredentialsUnavailable { .. }), "expected CredentialsUnavailable, got {error:?}" ); // Restoring the file heals the provider on the next retry cycle. write_owner_only(&path, "agent-token-v2"); tokio::time::sleep(Duration::from_secs(6)).await; let handle = provider.current().expect("provider must recover once the token file is back"); assert!(handle.generation >= 1); task.shutdown().await; } #[tokio::test] async fn test_token_file_debug_redacts_token() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("vault-token"); write_owner_only(&path, TEST_TOKEN); let source = TokenFileSource::new(path.clone(), Duration::from_secs(60)); source.acquire().await.expect("token file must resolve"); let rendered = format!("{source:?}"); assert!(!rendered.contains(TEST_TOKEN), "debug output must not leak the vault token: {rendered}"); assert!(rendered.contains("vault-token"), "the file path is not a secret"); } // -- Metric emission ---------------------------------------------------- // // Each test installs a thread-local debugging recorder and drives a // paused-clock current-thread runtime inside it, so the renewal loop's // gauge publications land on exact virtual timestamps. use metrics_util::MetricKind; use metrics_util::debugging::{DebugValue, DebuggingRecorder}; type MetricEntry = ( metrics_util::CompositeKey, Option, Option, DebugValue, ); /// Run `test` on a paused current-thread runtime under a debugging /// recorder and return one snapshot of everything it emitted. /// /// A single snapshot per test on purpose: `Snapshotter::snapshot` drains /// the recorded state, so taking it per assertion would only show the /// first assertion any data. fn record_metrics( test: impl FnOnce() -> std::pin::Pin>>, ) -> (Vec, Out) { let recorder = DebuggingRecorder::new(); let snapshotter = recorder.snapshotter(); let out = metrics::with_local_recorder(&recorder, || { let runtime = tokio::runtime::Builder::new_current_thread() .enable_time() .start_paused(true) .build() .expect("current-thread runtime must build"); runtime.block_on(test()) }); (snapshotter.snapshot().into_vec(), out) } /// Last value of a gauge, plus the labels it was published with. fn gauge(snapshot: &[MetricEntry], name: &str) -> Option<(f64, Vec)> { snapshot.iter().find_map(|(composite, _unit, _description, value)| { let matches = composite.kind() == MetricKind::Gauge && composite.key().name() == name; match (matches, value) { (true, DebugValue::Gauge(value)) => Some(( value.into_inner(), composite.key().labels().map(|label| label.key().to_string()).collect(), )), _ => None, } }) } #[test] fn renewal_loop_republishes_token_ttl_while_it_waits() { let (snapshot, ()) = record_metrics(|| { Box::pin(async { let (provider, _state) = scripted_provider( Duration::from_secs(60), true, test_policy(Duration::from_secs(10), Duration::from_secs(5)), ) .await; let task = provider.spawn_renewal_task().expect("lease-bound tokens need renewal"); // Well inside the first half of the lease: nothing has been // renewed yet, so only the observation cadence can have moved // the gauge off its initial 60s. tokio::time::sleep(Duration::from_secs(25)).await; task.shutdown().await; }) }); let (ttl, labels) = gauge(&snapshot, METRIC_TOKEN_TTL_SECONDS).expect("token TTL gauge must be published"); assert!( (ttl - 40.0).abs() < 1.0, "expected ~40s left of a 60s lease at the last observation, got {ttl}" ); assert!(labels.is_empty(), "credential gauges must carry no labels, got {labels:?}"); assert_eq!( gauge(&snapshot, METRIC_CREDENTIALS_FAIL_CLOSED).map(|(value, _)| value), Some(0.0), "a token outside its safety window must not report fail-closed" ); } #[test] fn fail_closed_gauge_tracks_the_gate_the_request_path_applies() { let (snapshot, refused) = record_metrics(|| { Box::pin(async { let (provider, state) = scripted_provider( Duration::from_secs(60), true, test_policy(Duration::from_secs(10), Duration::from_secs(5)), ) .await; state.fail_renew.store(true, Ordering::SeqCst); state.fail_login.store(true, Ordering::SeqCst); let task = provider.spawn_renewal_task().expect("renewal task"); // 60s lease minus the 10s safety window: by 51s the provider // is refusing the token, and the gauge must already say so. tokio::time::sleep(Duration::from_secs(51)).await; let refused = provider.current().is_err(); task.shutdown().await; refused }) }); assert!(refused, "a token inside the safety window must be refused"); assert_eq!( gauge(&snapshot, METRIC_CREDENTIALS_FAIL_CLOSED).map(|(value, _)| value), Some(1.0), "the gauge must report the same fail-closed state the request path enforces" ); let (ttl, _labels) = gauge(&snapshot, METRIC_TOKEN_TTL_SECONDS).expect("token TTL gauge must be published"); assert!( (ttl - 10.0).abs() < 1.0, "expected the TTL gauge to track the lease down into its safety window, got {ttl}" ); } }