Add durable credential registry and secret lifecycle

This commit is contained in:
ms2k 2026-07-17 00:18:02 +09:00
commit ac2289bd0a

View file

@ -0,0 +1,460 @@
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()
))
}
}