/// Secrets layer for bucket credentials. /// /// Credentials never appear in `lakehouse.toml`. Each bucket entry has an /// opaque `secret_ref` handle, resolved at startup by a `SecretsProvider`. /// /// MVP ships `FileSecretsProvider` — reads a TOML file at a path given by /// `LAKEHOUSE_SECRETS` env var, defaulting to `/etc/lakehouse/secrets.toml`. /// The file should be root-owned, mode 0600, never in git. /// /// Future providers (Vault, SOPS, OS keyring) implement the same trait. use async_trait::async_trait; use serde::Deserialize; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; #[derive(Debug, Clone)] pub struct BucketCredentials { pub access_key: String, pub secret_key: String, } // Redact credentials when debug-printing — NEVER let these end up in logs. impl BucketCredentials { pub fn redacted(&self) -> String { let prefix: String = self.access_key.chars().take(4).collect(); format!("access_key={}... (redacted)", prefix) } } #[async_trait] pub trait SecretsProvider: Send + Sync { /// Resolve a handle to credentials. Returns Err if the handle is unknown. async fn resolve(&self, handle: &str) -> Result; /// List known handles (for diagnostics only, never values). async fn list_handles(&self) -> Vec; } #[derive(Debug, Deserialize)] struct RawSecretEntry { access_key: String, secret_key: String, } /// File-backed secrets provider. pub struct FileSecretsProvider { path: PathBuf, cache: tokio::sync::RwLock>, } impl FileSecretsProvider { pub fn new(path: impl Into) -> Self { Self { path: path.into(), cache: tokio::sync::RwLock::new(HashMap::new()), } } /// Default path: env var `LAKEHOUSE_SECRETS` or `/etc/lakehouse/secrets.toml`. pub fn from_env() -> Self { let path = std::env::var("LAKEHOUSE_SECRETS") .unwrap_or_else(|_| "/etc/lakehouse/secrets.toml".to_string()); Self::new(path) } /// Arc wrapper for convenient injection. pub fn shared(path: impl Into) -> Arc { Arc::new(Self::new(path)) } /// Load and populate the cache. Call once at startup. pub async fn load(&self) -> Result { if !self.path.exists() { tracing::info!( "secrets file {} not present — running with zero credentials configured", self.path.display() ); return Ok(0); } check_permissions(&self.path)?; let content = std::fs::read_to_string(&self.path) .map_err(|e| format!("read secrets file: {e}"))?; let raw: HashMap = toml::from_str(&content) .map_err(|e| format!("parse secrets file: {e}"))?; let mut cache = self.cache.write().await; for (handle, entry) in raw { cache.insert(handle.clone(), BucketCredentials { access_key: entry.access_key, secret_key: entry.secret_key, }); } let count = cache.len(); tracing::info!("secrets: loaded {count} handles from {}", self.path.display()); Ok(count) } } #[async_trait] impl SecretsProvider for FileSecretsProvider { async fn resolve(&self, handle: &str) -> Result { let cache = self.cache.read().await; cache .get(handle) .cloned() .ok_or_else(|| format!("secret handle not found: {handle}")) } async fn list_handles(&self) -> Vec { self.cache.read().await.keys().cloned().collect() } } /// Reject world-readable secrets files. Fail closed. fn check_permissions(path: &Path) -> Result<(), String> { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let meta = std::fs::metadata(path) .map_err(|e| format!("stat secrets file: {e}"))?; let mode = meta.permissions().mode() & 0o777; if mode & 0o044 != 0 { return Err(format!( "secrets file {} is group/world-readable (mode {:o}); chmod 600 to proceed", path.display(), mode )); } } let _ = path; Ok(()) } /// A zero-handles provider for tests / purely-local setups. pub struct EmptySecretsProvider; #[async_trait] impl SecretsProvider for EmptySecretsProvider { async fn resolve(&self, handle: &str) -> Result { Err(format!("no secrets provider configured (wanted handle '{handle}')")) } async fn list_handles(&self) -> Vec { Vec::new() } }