mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-11 23:56:53 +00:00
3921336b23
* feat(kms): add AppRole configuration surface for Vault auth Extend VaultAuthMethod::AppRole with secret_id_file (re-read on every login so external rotation is picked up), a configurable auth mount (default "approle"), and an optional fail-closed safety window. All new fields are serde(default) so previously persisted configurations keep deserializing, and the strict admin-configure deserializer accepts them as optional. Environment selection: setting RUSTFS_KMS_VAULT_APPROLE_ROLE_ID switches both Vault backends to AppRole; the secret_id comes from RUSTFS_KMS_VAULT_APPROLE_SECRET_ID_FILE (path stored, file wins) or RUSTFS_KMS_VAULT_APPROLE_SECRET_ID, following the static secret-key file precedent. validate() rejects AppRole configs without a role_id, without any secret_id source, or with an empty mount. Also append the CredentialsUnavailable error variant used by the fail-closed credential gate. * feat(kms): implement AppRole login with background renewal and fail-closed expiry Implement the AppRoleLogin token source (vaultrs approle login + renew-self) and wire lease-bound credentials through the provider: - Each successful login/renewal installs a new client generation in the ArcSwap; in-flight requests finish on the generation they captured. - A background renewal task refreshes at half the lease TTL: renewable tokens are renewed in place, everything else (or a failed renewal) falls back to a fresh login. Auth exchanges run under the typed retry policy (OpClass::Auth) and failed cycles retry on a fixed cadence, so the provider recovers once Vault does. - Fail-closed: current() refuses to hand out a token inside the configured safety window of its expiry (default: one attempt timeout), returning CredentialsUnavailable instead of sending a request whose token may lapse mid-flight. - Refreshes are single-flight: concurrent triggers for the same generation coalesce into one login. - The renewal task's owner handle lives on the KMS service version: stop() shuts it down explicitly and reconfigure recycles it via cancel-on-drop when the old version is discarded. - The secret_id file is re-read on every login attempt; missing or empty files fail the attempt without contacting Vault. Crate-owned copies of tokens and secret_ids are zeroized on drop, and Debug output of every credential-carrying type stays redacted (leak regression tests). The renewal machinery is covered by paused-clock tests driving a scripted token source: renew-at-half-TTL timing, login fallback, fail-closed window entry and recovery, prompt task recycling, and coalesced concurrent refreshes. * feat(kms): add Vault Agent token file authentication Add the TokenFile source: the token is read from an agent-managed sink file (RUSTFS_KMS_VAULT_TOKEN_FILE or the TokenFile auth config) and re-read once per poll interval (default 30s) through the existing renewal loop, so a token rotated by the agent installs a new client generation within one poll of the atomic replace. Each successful read extends the token's observed validity to twice the poll interval; 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. Reads are strict and never contact Vault on failure: the file must be non-empty after trimming, and on Unix group/other permission bits are a hard error (mirroring the SFTP host-key rule). Rotation detection uses a content digest; the token itself is never stored on the source and the crate-owned copy is zeroized. Configuring the token file together with AppRole or an explicit static token is rejected as a configuration error. All new config fields are serde(default) and the strict admin-configure deserializer accepts the new variant. Covered by paused-clock tests (atomic replacement installs a new generation next cycle, deletion fails closed and recovers, prompt task recycling) plus negatives for missing/empty/over-permissive files and a Debug leak regression. * docs(kms): add Vault authentication and credential lifecycle runbook Cover choosing between static token, AppRole, and Vault Agent token file auth; AppRole role setup with SecretID delivery and rotation; Agent sink deployment with the permission requirements; and the fail-closed window semantics with a troubleshooting table keyed on the renewal task's log lines.
1358 lines
51 KiB
Rust
1358 lines
51 KiB
Rust
// 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<T> = std::result::Result<T, AttemptError>;
|
|
|
|
/// 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;
|
|
|
|
/// 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<LeaseInfo>,
|
|
}
|
|
|
|
impl TokenLease {
|
|
pub(crate) fn new(token: String, lease: Option<LeaseInfo>) -> 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<LeaseInfo> {
|
|
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<TokenLease>;
|
|
|
|
/// 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<TokenLease> {
|
|
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<TokenLease> {
|
|
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<PathBuf>,
|
|
}
|
|
|
|
impl AppRoleLogin {
|
|
pub(crate) fn new(
|
|
settings: &VaultConnectionSettings,
|
|
mount: String,
|
|
role_id: String,
|
|
secret_id: String,
|
|
secret_id_file: Option<PathBuf>,
|
|
) -> Result<Self> {
|
|
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<SecretString> {
|
|
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<TokenLease> {
|
|
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<TokenLease> {
|
|
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::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<Option<[u8; 32]>>,
|
|
}
|
|
|
|
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<TokenLease> {
|
|
// 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<Box<dyn TokenSource>> {
|
|
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::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<String>,
|
|
/// Per-attempt HTTP timeout applied to the underlying reqwest client.
|
|
pub(crate) attempt_timeout: Duration,
|
|
}
|
|
|
|
impl VaultConnectionSettings {
|
|
/// Build an authenticated client for one generation.
|
|
fn build_client(&self, token: &str) -> Result<VaultClient> {
|
|
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);
|
|
|
|
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<VaultClient> {
|
|
self.build_client("")
|
|
}
|
|
}
|
|
|
|
/// Refresh and fail-closed tuning for a [`VaultCredentialProvider`].
|
|
#[derive(Debug, Clone)]
|
|
pub(crate) struct VaultCredentialPolicy {
|
|
/// Retry budget for one login/renewal cycle.
|
|
pub(crate) 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) -> Self {
|
|
let retry = RetryPolicy::from_config(config);
|
|
let safety_window = match auth_method {
|
|
VaultAuthMethod::AppRole {
|
|
refresh_safety_window_secs: Some(secs),
|
|
..
|
|
}
|
|
| VaultAuthMethod::TokenFile {
|
|
refresh_safety_window_secs: Some(secs),
|
|
..
|
|
} => Duration::from_secs(*secs),
|
|
_ => retry.attempt_timeout,
|
|
};
|
|
Self {
|
|
retry,
|
|
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<LeaseInfo>,
|
|
}
|
|
|
|
impl VaultClientHandle {
|
|
/// Absolute expiry of this generation's token.
|
|
fn expires_at(&self) -> Option<Instant> {
|
|
self.lease.map(|lease| self.issued_at + 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.
|
|
fn renew_at(&self) -> Option<Instant> {
|
|
self.lease.map(|lease| self.issued_at + 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<dyn TokenSource>,
|
|
policy: VaultCredentialPolicy,
|
|
current: ArcSwap<VaultClientHandle>,
|
|
/// 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<dyn TokenSource>,
|
|
policy: VaultCredentialPolicy,
|
|
) -> Result<Self> {
|
|
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<VaultClientHandle> {
|
|
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<Arc<VaultClientHandle>> {
|
|
let handle = self.current.load_full();
|
|
if let Some(expires_at) = handle.expires_at() {
|
|
let now = Instant::now();
|
|
if now + self.policy.safety_window >= 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)
|
|
}
|
|
|
|
/// 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.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<Self>) -> Option<CredentialTaskHandle> {
|
|
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.
|
|
async fn renewal_loop(provider: Arc<VaultCredentialProvider>, cancel: CancellationToken) {
|
|
loop {
|
|
let handle = provider.snapshot();
|
|
let Some(renew_at) = handle.renew_at() else {
|
|
// The current generation never expires; nothing left to schedule.
|
|
return;
|
|
};
|
|
tokio::select! {
|
|
biased;
|
|
_ = cancel.cancelled() => return,
|
|
_ = tokio::time::sleep_until(renew_at) => {}
|
|
}
|
|
|
|
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"
|
|
);
|
|
tokio::select! {
|
|
biased;
|
|
_ = cancel.cancelled() => return,
|
|
_ = tokio::time::sleep(provider.policy.retry_interval) => {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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<Option<tokio::task::JoinHandle<()>>>,
|
|
}
|
|
|
|
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::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),
|
|
}
|
|
}
|
|
|
|
/// 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 {
|
|
VaultCredentialPolicy {
|
|
retry: RetryPolicy {
|
|
attempt_timeout: Duration::from_secs(1),
|
|
op_deadline: Duration::from_secs(1),
|
|
max_attempts: 1,
|
|
base_backoff: Duration::from_millis(10),
|
|
max_backoff: Duration::from_millis(10),
|
|
},
|
|
safety_window,
|
|
retry_interval,
|
|
}
|
|
}
|
|
|
|
/// 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<ScriptedState>,
|
|
ttl: Duration,
|
|
renewable: bool,
|
|
login_delay: Duration,
|
|
}
|
|
|
|
impl ScriptedSource {
|
|
fn lease(&self) -> Option<LeaseInfo> {
|
|
(!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<TokenLease> {
|
|
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<TokenLease> {
|
|
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<VaultCredentialProvider>, Arc<ScriptedState>) {
|
|
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(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: the next retry cycle 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(6)).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<VaultCredentialProvider> {
|
|
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");
|
|
}
|
|
}
|