use async_trait::async_trait;
use crate::sql::connect_diagnosis::SEALED_SCHEME;
#[derive(Debug, thiserror::Error)]
pub enum SecretsError {
#[error("secret not found: {0}")]
NotFound(String),
#[error("invalid secret reference: {0}")]
Invalid(String),
#[error("secrets backend error: {0}")]
Backend(#[source] Box<dyn std::error::Error + Send + Sync>),
}
#[async_trait]
pub trait SecretsResolver: Send + Sync + 'static {
async fn resolve(&self, reference: &str) -> Result<String, SecretsError>;
}
pub struct LiteralSecretsResolver;
#[async_trait]
impl SecretsResolver for LiteralSecretsResolver {
async fn resolve(&self, reference: &str) -> Result<String, SecretsError> {
Ok(reference.to_owned())
}
}
pub struct EnvSecretsResolver;
#[async_trait]
impl SecretsResolver for EnvSecretsResolver {
async fn resolve(&self, reference: &str) -> Result<String, SecretsError> {
let var = reference
.strip_prefix("env://")
.ok_or_else(|| SecretsError::Invalid("expected an env:// reference".into()))?;
if var.is_empty() {
return Err(SecretsError::Invalid(
"env:// reference has an empty variable name".into(),
));
}
std::env::var(var).map_err(|_| SecretsError::NotFound(var.to_owned()))
}
}
pub struct ChainSecretsResolver {
matchers: Vec<(String, Box<dyn SecretsResolver>)>,
default: Box<dyn SecretsResolver>,
}
impl ChainSecretsResolver {
#[must_use]
pub fn new(default: impl SecretsResolver) -> Self {
Self {
matchers: Vec::new(),
default: Box::new(default),
}
}
#[must_use]
pub fn push(mut self, scheme: impl Into<String>, resolver: impl SecretsResolver) -> Self {
self.matchers.push((scheme.into(), Box::new(resolver)));
self
}
#[must_use]
pub fn standard() -> Self {
Self::new(LiteralSecretsResolver)
.push(ENV_SCHEME, EnvSecretsResolver)
.push(SEALED_SCHEME, SealedSecretsResolver)
}
}
#[async_trait]
impl<R: SecretsResolver + ?Sized> SecretsResolver for std::sync::Arc<R> {
async fn resolve(&self, reference: &str) -> Result<String, SecretsError> {
(**self).resolve(reference).await
}
}
const ENV_SCHEME: &str = "env://";
const SEALED_V1: &str = "enc:v1:";
const SEALED_AAD: &[u8] = b"rustango:tenancy-secret:v1";
pub fn seal(plaintext: &str) -> Result<String, SecretsError> {
if crate::aead::key_status() == crate::aead::KeyStatus::Short {
tracing::warn!(
target: "rustango::tenancy::secrets",
min = crate::aead::MIN_KEY_LEN,
"RUSTANGO_SECRET_KEY is shorter than the minimum; use 32 or more random bytes"
);
}
crate::aead::encrypt(plaintext.as_bytes(), SEALED_AAD)
.map(|b64| format!("{SEALED_V1}{b64}"))
.map_err(sealed_error)
}
pub const PREVIOUS_KEY_VAR: &str = crate::aead::PREVIOUS_KEY_VAR;
pub struct SealedSecretsResolver;
#[async_trait]
impl SecretsResolver for SealedSecretsResolver {
async fn resolve(&self, reference: &str) -> Result<String, SecretsError> {
let current = crate::aead::env_key();
let previous = std::env::var(PREVIOUS_KEY_VAR).ok();
open_sealed(reference, current.as_deref(), previous.as_deref())
}
}
pub(crate) fn open_sealed(
reference: &str,
current: Option<&str>,
previous: Option<&str>,
) -> Result<String, SecretsError> {
let b64 = reference.strip_prefix(SEALED_V1).ok_or_else(|| {
SecretsError::Invalid("unsupported sealed secret; expected `enc:v1:`".into())
})?;
let plain =
crate::aead::decrypt_either(current, previous, b64, SEALED_AAD).map_err(sealed_error)?;
String::from_utf8(plain).map_err(|_| SecretsError::Invalid("sealed secret is not UTF-8".into()))
}
#[derive(Clone)]
pub struct TenantSecrets {
resolver: std::sync::Arc<dyn SecretsResolver>,
require_sealed: bool,
}
pub const REQUIRE_SEALED_VAR: &str = "RUSTANGO_REQUIRE_SEALED_SECRETS";
impl TenantSecrets {
#[must_use]
pub fn new(resolver: impl SecretsResolver) -> Self {
Self {
resolver: std::sync::Arc::new(resolver),
require_sealed: std::env::var(REQUIRE_SEALED_VAR).is_ok_and(|v| v == "1"),
}
}
#[must_use]
pub fn standard() -> Self {
Self::new(ChainSecretsResolver::standard())
}
#[must_use]
pub fn require_sealed(mut self) -> Self {
self.require_sealed = true;
self
}
#[must_use]
pub fn requires_sealed(&self) -> bool {
self.require_sealed
}
}
#[async_trait]
impl SecretsResolver for TenantSecrets {
async fn resolve(&self, reference: &str) -> Result<String, SecretsError> {
self.resolver.resolve(reference).await
}
}
fn sealed_error(e: crate::aead::AeadError) -> SecretsError {
use crate::aead::AeadError;
match e {
AeadError::MissingKey => SecretsError::Backend(
format!(
"{} is not set or blank; it is needed to seal and open `enc:` secrets",
crate::aead::KEY_VAR
)
.into(),
),
AeadError::Encrypt(e) => SecretsError::Backend(format!("sealing failed: {e}").into()),
AeadError::Decode(e) => SecretsError::Invalid(format!("sealed secret is not base64: {e}")),
AeadError::Short => SecretsError::Invalid("sealed secret is too short".into()),
AeadError::Decrypt => SecretsError::Invalid(format!(
"sealed secret does not open: wrong {} or a tampered value",
crate::aead::KEY_VAR
)),
}
}
pub(crate) async fn stored_form(
reference: &str,
resolved: &str,
secrets: &TenantSecrets,
) -> Result<String, String> {
let literal_with_password =
reference == resolved && crate::sql::connect_diagnosis::redact(reference) != reference;
if !literal_with_password {
return Ok(reference.to_owned());
}
let why = match seal(reference) {
Ok(sealed) => match secrets.resolve(&sealed).await {
Ok(back) if back == resolved => return Ok(sealed),
_ => "the secrets resolver cannot open `enc:` values".to_owned(),
},
Err(e) => e.to_string(),
};
if secrets.require_sealed {
return Err(format!(
"this database URL has a password and sealed storage is required, but it cannot \
be sealed: {why}"
));
}
tracing::warn!(target: "rustango::tenancy::secrets", reason = %why, "tenant database URL stored unsealed");
Ok(reference.to_owned())
}
pub(crate) fn shown_reference(stored: &str) -> String {
if stored.starts_with(ENV_SCHEME) {
stored.to_owned()
} else if stored.starts_with(SEALED_SCHEME) {
crate::sql::connect_diagnosis::ENCRYPTED_LABEL.to_owned()
} else {
"<literal connection URL — masked>".to_owned()
}
}
#[async_trait]
impl SecretsResolver for ChainSecretsResolver {
async fn resolve(&self, reference: &str) -> Result<String, SecretsError> {
for (scheme, resolver) in &self.matchers {
if reference.starts_with(scheme.as_str()) {
return resolver.resolve(reference).await;
}
}
self.default.resolve(reference).await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn literal_resolver_passes_through() {
let r = LiteralSecretsResolver;
let v = r.resolve("postgres://u:p@h/db").await.unwrap();
assert_eq!(v, "postgres://u:p@h/db");
}
#[tokio::test]
async fn env_resolver_reads_named_env_var() {
let key = if std::env::var("PATH").is_ok() {
"PATH"
} else {
"USER"
};
let r = EnvSecretsResolver;
let v = r.resolve(&format!("env://{key}")).await.unwrap();
assert!(!v.is_empty(), "{key} should be a non-empty env var");
}
fn with_key() {
crate::aead::set_test_key();
}
#[tokio::test]
async fn the_standard_chain_opens_a_sealed_secret() {
with_key();
let sealed = seal("postgres://u:hunter2@h/db").unwrap();
assert!(sealed.starts_with("enc:v1:"), "{sealed}");
assert!(!sealed.contains("hunter2"));
let back = ChainSecretsResolver::standard()
.resolve(&sealed)
.await
.unwrap();
assert_eq!(back, "postgres://u:hunter2@h/db");
}
#[tokio::test]
async fn an_unknown_sealed_version_is_refused_not_used_literally() {
let err = ChainSecretsResolver::standard()
.resolve("enc:v9:AAAA")
.await
.unwrap_err();
assert!(matches!(err, SecretsError::Invalid(_)), "{err:?}");
}
#[tokio::test]
async fn a_tampered_sealed_secret_does_not_open() {
with_key();
let mut sealed = seal("postgres://u:p@h/db").unwrap();
let i = sealed.len() - 3;
let flip = if &sealed[i..=i] == "A" { "B" } else { "A" };
sealed.replace_range(i..=i, flip);
let err = SealedSecretsResolver.resolve(&sealed).await.unwrap_err();
assert!(err.to_string().contains("does not open"), "{err}");
}
#[test]
fn a_missing_key_names_the_variable() {
let err = sealed_error(crate::aead::AeadError::MissingKey);
assert!(err.to_string().contains("RUSTANGO_SECRET_KEY"), "{err}");
}
#[tokio::test]
async fn a_literal_with_a_password_is_stored_sealed() {
with_key();
let url = "postgres://u:hunter2@h/db";
let chain = TenantSecrets::standard();
let stored = stored_form(url, url, &chain).await.unwrap();
assert!(stored.starts_with("enc:v1:"), "{stored}");
assert_eq!(chain.resolve(&stored).await.unwrap(), url);
}
#[tokio::test]
async fn references_and_password_free_urls_are_stored_as_given() {
with_key();
let chain = TenantSecrets::standard();
for (reference, resolved) in [
("env://TENANT_URL", "postgres://u:p@h/db"),
("sqlite:///tmp/t.db?mode=rwc", "sqlite:///tmp/t.db?mode=rwc"),
("postgres://u@h/db", "postgres://u@h/db"),
] {
assert_eq!(
stored_form(reference, resolved, &chain).await.unwrap(),
reference
);
}
}
#[tokio::test]
async fn a_resolver_that_cannot_open_enc_gets_the_literal() {
with_key();
let url = "postgres://u:p@h/db";
let literal = TenantSecrets::new(LiteralSecretsResolver);
assert_eq!(stored_form(url, url, &literal).await.unwrap(), url);
}
#[tokio::test]
async fn strict_mode_refuses_an_unsealed_password_url() {
with_key();
let url = "postgres://u:hunter2@h/db";
let strict = TenantSecrets::new(LiteralSecretsResolver).require_sealed();
let err = stored_form(url, url, &strict).await.unwrap_err();
assert!(err.contains("sealed storage is required"), "{err}");
assert!(!err.contains("hunter2"), "{err}");
let ok = TenantSecrets::standard().require_sealed();
assert!(stored_form(url, url, &ok)
.await
.unwrap()
.starts_with("enc:v1:"));
}
#[test]
fn the_previous_key_opens_what_the_old_key_sealed() {
let b64 =
crate::aead::encrypt_with(Some("old-key"), b"postgres://u:p@h/db", SEALED_AAD).unwrap();
let sealed = format!("{SEALED_V1}{b64}");
assert!(open_sealed(&sealed, Some("new-key"), None).is_err());
assert_eq!(
open_sealed(&sealed, Some("new-key"), Some("old-key")).unwrap(),
"postgres://u:p@h/db"
);
assert_eq!(
open_sealed(&sealed, None, Some("old-key")).unwrap(),
"postgres://u:p@h/db"
);
}
#[test]
fn only_an_env_name_is_shown() {
assert_eq!(shown_reference("env://X"), "env://X");
assert_eq!(shown_reference("enc:v1:QUJD"), "<encrypted>");
assert!(!shown_reference("postgres://u:p@h/db").contains("u:p"));
assert!(!shown_reference("vault://secret/x").contains("vault"));
}
#[tokio::test]
async fn env_resolver_rejects_missing_prefix() {
let r = EnvSecretsResolver;
let err = r.resolve("FOO").await.unwrap_err();
assert!(matches!(err, SecretsError::Invalid(_)), "got {err:?}");
}
#[tokio::test]
async fn env_resolver_error_does_not_echo_the_reference() {
let err = EnvSecretsResolver
.resolve("postgres://u:hunter2@h/db")
.await
.unwrap_err();
assert!(!err.to_string().contains("hunter2"), "{err}");
}
#[tokio::test]
async fn env_resolver_rejects_empty_var_name() {
let r = EnvSecretsResolver;
let err = r.resolve("env://").await.unwrap_err();
assert!(matches!(err, SecretsError::Invalid(_)), "got {err:?}");
}
#[tokio::test]
async fn env_resolver_returns_not_found_for_unset_var() {
let r = EnvSecretsResolver;
let err = r
.resolve("env://RUSTANGO_TENANCY_DEFINITELY_NOT_SET_xyzzy")
.await
.unwrap_err();
assert!(matches!(err, SecretsError::NotFound(_)), "got {err:?}");
}
#[tokio::test]
async fn chain_dispatches_by_scheme_prefix() {
let chain = ChainSecretsResolver::standard();
let v = chain.resolve("env://PATH").await.unwrap();
assert!(!v.is_empty());
let v = chain.resolve("postgres://u:p@h/db").await.unwrap();
assert_eq!(v, "postgres://u:p@h/db");
}
}