460 lines
14 KiB
Rust
460 lines
14 KiB
Rust
|
|
use std::fmt;
|
||
|
|
use std::time::Duration;
|
||
|
|
|
||
|
|
use remora_domain::OpaqueId;
|
||
|
|
use reqwest::redirect::Policy;
|
||
|
|
use reqwest::{Client, StatusCode};
|
||
|
|
use serde::{Deserialize, Serialize};
|
||
|
|
|
||
|
|
use crate::error::CredentialError;
|
||
|
|
use crate::types::SecretMaterial;
|
||
|
|
|
||
|
|
/// One non-destroyed KV v2 version observed via secret metadata.
|
||
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||
|
|
pub struct LiveVaultVersion {
|
||
|
|
pub version: u64,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||
|
|
pub(crate) enum EmptyPathFence {
|
||
|
|
Created(u64),
|
||
|
|
Occupied,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Vault KV v2 access for credential secret material.
|
||
|
|
///
|
||
|
|
/// Topology and auth method are deployment choices. This client accepts a token
|
||
|
|
/// (fixture root token or workload token after Kubernetes auth) and never logs
|
||
|
|
/// request or response bodies that contain secret material.
|
||
|
|
#[derive(Clone)]
|
||
|
|
pub struct VaultConfig {
|
||
|
|
pub address: String,
|
||
|
|
pub token: String,
|
||
|
|
/// KV v2 mount, e.g. `secret`.
|
||
|
|
pub mount: String,
|
||
|
|
/// Path prefix under the mount where this deployment may allocate secrets.
|
||
|
|
pub path_prefix: String,
|
||
|
|
pub timeout: Duration,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl fmt::Debug for VaultConfig {
|
||
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||
|
|
formatter
|
||
|
|
.debug_struct("VaultConfig")
|
||
|
|
.field("address", &self.address)
|
||
|
|
.field("mount", &self.mount)
|
||
|
|
.field("path_prefix", &self.path_prefix)
|
||
|
|
.field("timeout", &self.timeout)
|
||
|
|
.field("token", &"REDACTED")
|
||
|
|
.finish()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
impl VaultConfig {
|
||
|
|
pub fn from_env() -> Result<Self, CredentialError> {
|
||
|
|
let address = required_env("REMORA_VAULT_ADDR")?;
|
||
|
|
let token = required_env("REMORA_VAULT_TOKEN")?;
|
||
|
|
let full_path = required_env("REMORA_VAULT_PATH")?;
|
||
|
|
let (mount, path_prefix) = split_vault_path(&full_path)?;
|
||
|
|
Ok(Self {
|
||
|
|
address,
|
||
|
|
token,
|
||
|
|
mount,
|
||
|
|
path_prefix,
|
||
|
|
timeout: Duration::from_secs(10),
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
#[must_use]
|
||
|
|
pub fn with_timeout(mut self, timeout: Duration) -> Self {
|
||
|
|
self.timeout = timeout;
|
||
|
|
self
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Allocate the opaque secret path for one credential version identity.
|
||
|
|
#[must_use]
|
||
|
|
pub fn secret_path_for(&self, credential_id: &OpaqueId, version_id: &OpaqueId) -> String {
|
||
|
|
format!(
|
||
|
|
"{}/versions/{}",
|
||
|
|
self.credential_path_for(credential_id),
|
||
|
|
version_id.as_str()
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
#[must_use]
|
||
|
|
pub fn credential_path_for(&self, credential_id: &OpaqueId) -> String {
|
||
|
|
if self.path_prefix.is_empty() {
|
||
|
|
format!("credentials/{}", credential_id.as_str())
|
||
|
|
} else {
|
||
|
|
format!(
|
||
|
|
"{}/credentials/{}",
|
||
|
|
self.path_prefix.trim_matches('/'),
|
||
|
|
credential_id.as_str()
|
||
|
|
)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Clone)]
|
||
|
|
pub struct VaultClient {
|
||
|
|
http: Client,
|
||
|
|
config: VaultConfig,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl fmt::Debug for VaultClient {
|
||
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||
|
|
formatter
|
||
|
|
.debug_struct("VaultClient")
|
||
|
|
.field("config", &self.config)
|
||
|
|
.finish()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
impl VaultClient {
|
||
|
|
pub fn new(config: VaultConfig) -> Result<Self, CredentialError> {
|
||
|
|
// Disable redirects and ambient proxy discovery so X-Vault-Token and
|
||
|
|
// secret write bodies cannot leave the configured Vault endpoint via
|
||
|
|
// hop rewriting or HTTP_PROXY/HTTPS_PROXY injection.
|
||
|
|
let http = Client::builder()
|
||
|
|
.timeout(config.timeout)
|
||
|
|
.redirect(Policy::none())
|
||
|
|
.no_proxy()
|
||
|
|
.build()
|
||
|
|
.map_err(|error| {
|
||
|
|
CredentialError::Config(format!("failed to build vault HTTP client: {error}"))
|
||
|
|
})?;
|
||
|
|
Ok(Self { http, config })
|
||
|
|
}
|
||
|
|
|
||
|
|
#[must_use]
|
||
|
|
pub fn config(&self) -> &VaultConfig {
|
||
|
|
&self.config
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Create secret material and return the immutable KV v2 version number.
|
||
|
|
///
|
||
|
|
/// The write uses CAS 0 so an existing intent-bound path is never overwritten.
|
||
|
|
pub async fn put_version(
|
||
|
|
&self,
|
||
|
|
mount: &str,
|
||
|
|
secret_path: &str,
|
||
|
|
material: &SecretMaterial,
|
||
|
|
) -> Result<u64, CredentialError> {
|
||
|
|
let url = format!(
|
||
|
|
"{}/v1/{}/data/{}",
|
||
|
|
self.config.address.trim_end_matches('/'),
|
||
|
|
mount.trim_matches('/'),
|
||
|
|
secret_path.trim_start_matches('/')
|
||
|
|
);
|
||
|
|
let body = KvWriteRequest {
|
||
|
|
data: material.clone(),
|
||
|
|
options: KvWriteOptions { cas: 0 },
|
||
|
|
};
|
||
|
|
let response = self
|
||
|
|
.http
|
||
|
|
.post(&url)
|
||
|
|
.header("X-Vault-Token", &self.config.token)
|
||
|
|
.json(&body)
|
||
|
|
.send()
|
||
|
|
.await
|
||
|
|
.map_err(map_transport)?;
|
||
|
|
let status = response.status();
|
||
|
|
if !status.is_success() {
|
||
|
|
let _ = response.bytes().await;
|
||
|
|
return Err(map_http_status(status, "write secret version"));
|
||
|
|
}
|
||
|
|
let parsed: KvWriteResponse = response.json().await.map_err(map_transport)?;
|
||
|
|
let version = parsed.data.and_then(|data| data.version).ok_or_else(|| {
|
||
|
|
CredentialError::Config("vault write response omitted version".into())
|
||
|
|
})?;
|
||
|
|
if version < 1 {
|
||
|
|
return Err(CredentialError::Config(
|
||
|
|
"vault returned non-positive secret version".into(),
|
||
|
|
));
|
||
|
|
}
|
||
|
|
Ok(version)
|
||
|
|
}
|
||
|
|
|
||
|
|
pub(crate) async fn fence_empty_path(
|
||
|
|
&self,
|
||
|
|
mount: &str,
|
||
|
|
secret_path: &str,
|
||
|
|
) -> Result<EmptyPathFence, CredentialError> {
|
||
|
|
let url = format!(
|
||
|
|
"{}/v1/{}/data/{}",
|
||
|
|
self.config.address.trim_end_matches('/'),
|
||
|
|
mount.trim_matches('/'),
|
||
|
|
secret_path.trim_start_matches('/')
|
||
|
|
);
|
||
|
|
let response = self
|
||
|
|
.http
|
||
|
|
.post(&url)
|
||
|
|
.header("X-Vault-Token", &self.config.token)
|
||
|
|
.json(&FenceWriteRequest {
|
||
|
|
data: FenceMarker {
|
||
|
|
remora_write_fence: true,
|
||
|
|
},
|
||
|
|
options: KvWriteOptions { cas: 0 },
|
||
|
|
})
|
||
|
|
.send()
|
||
|
|
.await
|
||
|
|
.map_err(map_transport)?;
|
||
|
|
let status = response.status();
|
||
|
|
if status == StatusCode::BAD_REQUEST {
|
||
|
|
let _ = response.bytes().await;
|
||
|
|
return Ok(EmptyPathFence::Occupied);
|
||
|
|
}
|
||
|
|
if !status.is_success() {
|
||
|
|
let _ = response.bytes().await;
|
||
|
|
return Err(map_http_status(status, "fence empty secret path"));
|
||
|
|
}
|
||
|
|
let parsed: KvWriteResponse = response.json().await.map_err(map_transport)?;
|
||
|
|
let version = parsed.data.and_then(|data| data.version).ok_or_else(|| {
|
||
|
|
CredentialError::Config("vault fence response omitted version".into())
|
||
|
|
})?;
|
||
|
|
if version < 1 {
|
||
|
|
return Err(CredentialError::Config(
|
||
|
|
"vault returned non-positive fence version".into(),
|
||
|
|
));
|
||
|
|
}
|
||
|
|
Ok(EmptyPathFence::Created(version))
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Read one exact KV v2 version. Missing or destroyed versions fail closed.
|
||
|
|
pub async fn get_version(
|
||
|
|
&self,
|
||
|
|
mount: &str,
|
||
|
|
secret_path: &str,
|
||
|
|
vault_kv_version: u64,
|
||
|
|
) -> Result<SecretMaterial, CredentialError> {
|
||
|
|
let url = format!(
|
||
|
|
"{}/v1/{}/data/{}?version={vault_kv_version}",
|
||
|
|
self.config.address.trim_end_matches('/'),
|
||
|
|
mount.trim_matches('/'),
|
||
|
|
secret_path.trim_start_matches('/')
|
||
|
|
);
|
||
|
|
let response = self
|
||
|
|
.http
|
||
|
|
.get(&url)
|
||
|
|
.header("X-Vault-Token", &self.config.token)
|
||
|
|
.send()
|
||
|
|
.await
|
||
|
|
.map_err(map_transport)?;
|
||
|
|
let status = response.status();
|
||
|
|
if status == StatusCode::NOT_FOUND {
|
||
|
|
let _ = response.bytes().await;
|
||
|
|
return Err(CredentialError::Unresolvable);
|
||
|
|
}
|
||
|
|
if !status.is_success() {
|
||
|
|
let _ = response.bytes().await;
|
||
|
|
return Err(map_http_status(status, "read secret version"));
|
||
|
|
}
|
||
|
|
let parsed: KvReadResponse = response.json().await.map_err(map_transport)?;
|
||
|
|
let data = parsed.data.ok_or(CredentialError::Unresolvable)?;
|
||
|
|
let material = data.data.ok_or(CredentialError::Unresolvable)?;
|
||
|
|
let reported = data
|
||
|
|
.metadata
|
||
|
|
.and_then(|meta| meta.version)
|
||
|
|
.ok_or(CredentialError::Unresolvable)?;
|
||
|
|
if reported != vault_kv_version {
|
||
|
|
return Err(CredentialError::Unresolvable);
|
||
|
|
}
|
||
|
|
material.validate()?;
|
||
|
|
Ok(material)
|
||
|
|
}
|
||
|
|
|
||
|
|
/// List non-destroyed KV v2 versions present at a path (for intent recovery).
|
||
|
|
///
|
||
|
|
pub async fn list_live_versions(
|
||
|
|
&self,
|
||
|
|
mount: &str,
|
||
|
|
secret_path: &str,
|
||
|
|
) -> Result<Vec<LiveVaultVersion>, CredentialError> {
|
||
|
|
let url = format!(
|
||
|
|
"{}/v1/{}/metadata/{}",
|
||
|
|
self.config.address.trim_end_matches('/'),
|
||
|
|
mount.trim_matches('/'),
|
||
|
|
secret_path.trim_start_matches('/')
|
||
|
|
);
|
||
|
|
let response = self
|
||
|
|
.http
|
||
|
|
.get(&url)
|
||
|
|
.header("X-Vault-Token", &self.config.token)
|
||
|
|
.send()
|
||
|
|
.await
|
||
|
|
.map_err(map_transport)?;
|
||
|
|
let status = response.status();
|
||
|
|
if status == StatusCode::NOT_FOUND {
|
||
|
|
let _ = response.bytes().await;
|
||
|
|
return Ok(Vec::new());
|
||
|
|
}
|
||
|
|
if !status.is_success() {
|
||
|
|
let _ = response.bytes().await;
|
||
|
|
return Err(map_http_status(status, "list secret metadata"));
|
||
|
|
}
|
||
|
|
let parsed: KvMetadataResponse = response.json().await.map_err(map_transport)?;
|
||
|
|
let versions = parsed
|
||
|
|
.data
|
||
|
|
.and_then(|data| data.versions)
|
||
|
|
.unwrap_or_default();
|
||
|
|
let mut live = Vec::new();
|
||
|
|
for (key, meta) in versions {
|
||
|
|
if meta.destroyed.unwrap_or(false) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
let Ok(version) = key.parse::<u64>() else {
|
||
|
|
continue;
|
||
|
|
};
|
||
|
|
if version < 1 {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
live.push(LiveVaultVersion { version });
|
||
|
|
}
|
||
|
|
live.sort_by_key(|entry| entry.version);
|
||
|
|
Ok(live)
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Irreversibly destroy one KV v2 version.
|
||
|
|
pub async fn destroy_version(
|
||
|
|
&self,
|
||
|
|
mount: &str,
|
||
|
|
secret_path: &str,
|
||
|
|
vault_kv_version: u64,
|
||
|
|
) -> Result<(), CredentialError> {
|
||
|
|
let url = format!(
|
||
|
|
"{}/v1/{}/destroy/{}",
|
||
|
|
self.config.address.trim_end_matches('/'),
|
||
|
|
mount.trim_matches('/'),
|
||
|
|
secret_path.trim_start_matches('/')
|
||
|
|
);
|
||
|
|
let body = DestroyRequest {
|
||
|
|
versions: vec![vault_kv_version],
|
||
|
|
};
|
||
|
|
let response = self
|
||
|
|
.http
|
||
|
|
.post(&url)
|
||
|
|
.header("X-Vault-Token", &self.config.token)
|
||
|
|
.json(&body)
|
||
|
|
.send()
|
||
|
|
.await
|
||
|
|
.map_err(map_transport)?;
|
||
|
|
let status = response.status();
|
||
|
|
if !status.is_success() {
|
||
|
|
let _ = response.bytes().await;
|
||
|
|
return Err(map_http_status(status, "destroy secret version"));
|
||
|
|
}
|
||
|
|
let _ = response.bytes().await;
|
||
|
|
Ok(())
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Serialize)]
|
||
|
|
struct KvWriteRequest {
|
||
|
|
data: SecretMaterial,
|
||
|
|
options: KvWriteOptions,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Serialize)]
|
||
|
|
struct KvWriteOptions {
|
||
|
|
cas: u8,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Serialize)]
|
||
|
|
struct FenceWriteRequest {
|
||
|
|
data: FenceMarker,
|
||
|
|
options: KvWriteOptions,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Serialize)]
|
||
|
|
struct FenceMarker {
|
||
|
|
remora_write_fence: bool,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Deserialize)]
|
||
|
|
struct KvWriteResponse {
|
||
|
|
data: Option<KvWriteData>,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Deserialize)]
|
||
|
|
struct KvWriteData {
|
||
|
|
version: Option<u64>,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Deserialize)]
|
||
|
|
struct KvReadResponse {
|
||
|
|
data: Option<KvReadData>,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Deserialize)]
|
||
|
|
struct KvReadData {
|
||
|
|
data: Option<SecretMaterial>,
|
||
|
|
metadata: Option<KvMetadata>,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Deserialize)]
|
||
|
|
struct KvMetadata {
|
||
|
|
version: Option<u64>,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Deserialize)]
|
||
|
|
struct KvMetadataResponse {
|
||
|
|
data: Option<KvMetadataListData>,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Deserialize)]
|
||
|
|
struct KvMetadataListData {
|
||
|
|
versions: Option<std::collections::BTreeMap<String, KvVersionMeta>>,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Deserialize)]
|
||
|
|
struct KvVersionMeta {
|
||
|
|
destroyed: Option<bool>,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Serialize)]
|
||
|
|
struct DestroyRequest {
|
||
|
|
versions: Vec<u64>,
|
||
|
|
}
|
||
|
|
|
||
|
|
fn required_env(name: &str) -> Result<String, CredentialError> {
|
||
|
|
std::env::var(name)
|
||
|
|
.ok()
|
||
|
|
.filter(|value| !value.is_empty())
|
||
|
|
.ok_or_else(|| CredentialError::Config(format!("{name} is required")))
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Split a fixture path such as `secret/remora/lane-0` into mount + prefix.
|
||
|
|
pub fn split_vault_path(full: &str) -> Result<(String, String), CredentialError> {
|
||
|
|
let trimmed = full.trim().trim_matches('/');
|
||
|
|
if trimmed.is_empty() {
|
||
|
|
return Err(CredentialError::Config(
|
||
|
|
"REMORA_VAULT_PATH must include a mount".into(),
|
||
|
|
));
|
||
|
|
}
|
||
|
|
match trimmed.split_once('/') {
|
||
|
|
Some((mount, prefix)) if !mount.is_empty() => {
|
||
|
|
Ok((mount.to_owned(), prefix.trim_matches('/').to_owned()))
|
||
|
|
}
|
||
|
|
Some(_) | None => Ok((trimmed.to_owned(), String::new())),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn map_transport(_error: reqwest::Error) -> CredentialError {
|
||
|
|
CredentialError::SecretStoreUnavailable
|
||
|
|
}
|
||
|
|
|
||
|
|
fn map_http_status(status: StatusCode, operation: &str) -> CredentialError {
|
||
|
|
if status.is_server_error() || status == StatusCode::SERVICE_UNAVAILABLE {
|
||
|
|
CredentialError::SecretStoreUnavailable
|
||
|
|
} else if status == StatusCode::FORBIDDEN || status == StatusCode::UNAUTHORIZED {
|
||
|
|
// Treat policy denials as unresolvable rather than leaking store details.
|
||
|
|
CredentialError::Unresolvable
|
||
|
|
} else if status == StatusCode::NOT_FOUND {
|
||
|
|
CredentialError::Unresolvable
|
||
|
|
} else {
|
||
|
|
CredentialError::Config(format!(
|
||
|
|
"vault {operation} failed with HTTP {}",
|
||
|
|
status.as_u16()
|
||
|
|
))
|
||
|
|
}
|
||
|
|
}
|