feat: port el-ui vessels — rename crates→vessels, add El source + manifests
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum SecretsError {
|
||||
#[error("secret '{key}' not found in source '{source_name}'")]
|
||||
NotFound { key: String, source_name: String },
|
||||
|
||||
#[error("secret '{key}' not found in any configured source")]
|
||||
NotFoundInAnySource { key: String },
|
||||
|
||||
#[error("secrets source '{source_name}' unavailable: {reason}")]
|
||||
SourceUnavailable { source_name: String, reason: String },
|
||||
|
||||
#[error("required secrets missing at startup: {keys:?}")]
|
||||
MissingRequired { keys: Vec<String> },
|
||||
|
||||
#[error("secret type conversion failed for key '{key}': {reason}")]
|
||||
TypeError { key: String, reason: String },
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! el-secrets — Secrets management for el-ui applications.
|
||||
//!
|
||||
//! Secrets are NEVER in code. They are:
|
||||
//! - `EnvVarSource` — environment variables (`EL_SECRET_JWT_KEY=...`)
|
||||
//! - `VaultSource` — HashiCorp Vault (stub, ready for HTTP client integration)
|
||||
//! - `AwsSecretsSource` — AWS Secrets Manager (stub)
|
||||
//! - `InMemorySource` — for testing only
|
||||
//!
|
||||
//! All secrets are wrapped in `Secret<T>` which:
|
||||
//! - Displays as `[REDACTED]` in all logs and debug output
|
||||
//! - Serializes as `"[REDACTED]"` in JSON — never the actual value
|
||||
//! - Requires explicit `.expose()` to read
|
||||
//!
|
||||
//! ## Quick start
|
||||
//!
|
||||
//! ```
|
||||
//! use el_secrets::prelude::*;
|
||||
//!
|
||||
//! // Load secrets at startup — fails early if anything is missing
|
||||
//! let mut src = InMemorySource::new();
|
||||
//! src.insert("jwt.key", "test-key-for-testing-only");
|
||||
//!
|
||||
//! let secrets = SecretsResolver::new()
|
||||
//! .source(Box::new(src))
|
||||
//! .require("jwt.key")
|
||||
//! .resolve()
|
||||
//! .expect("required secrets must be present at startup");
|
||||
//!
|
||||
//! let jwt_key = secrets.require("jwt.key");
|
||||
//! // jwt_key displays as [REDACTED]
|
||||
//! // jwt_key.expose() gives the actual value
|
||||
//! assert_eq!(jwt_key.expose(), "test-key-for-testing-only");
|
||||
//! ```
|
||||
|
||||
#![deny(warnings)]
|
||||
|
||||
pub mod error;
|
||||
pub mod resolver;
|
||||
pub mod secret;
|
||||
pub mod source;
|
||||
|
||||
pub mod prelude {
|
||||
pub use crate::error::SecretsError;
|
||||
pub use crate::resolver::{ResolvedSecrets, SecretsResolver};
|
||||
pub use crate::secret::{Secret, SecretGuard};
|
||||
pub use crate::source::{
|
||||
AwsSecretsSource, EnvVarSource, InMemorySource, SecretsSource, VaultSource,
|
||||
};
|
||||
}
|
||||
|
||||
pub use prelude::*;
|
||||
@@ -0,0 +1,195 @@
|
||||
// el-secrets — Secrets management for el-ui.
|
||||
//
|
||||
// A Secret is a value with a redacted display contract. Source plugins
|
||||
// fetch by key from env, Vault, AWS Secrets Manager, or in-memory.
|
||||
// `SecretsResolver` composes a chain and resolves required keys at startup.
|
||||
|
||||
// ── Errors ───────────────────────────────────────────────────────────────────
|
||||
|
||||
let SECRET_ERR_MISSING: String = "secret.missing"
|
||||
let SECRET_ERR_SOURCE: String = "secret.source_error"
|
||||
let SECRET_ERR_PERMISSION: String = "secret.permission_denied"
|
||||
|
||||
// ── Source kinds ─────────────────────────────────────────────────────────────
|
||||
|
||||
let SRC_ENV: String = "env"
|
||||
let SRC_VAULT: String = "vault"
|
||||
let SRC_AWS_SECRETS: String = "aws_secrets"
|
||||
let SRC_IN_MEMORY: String = "in_memory"
|
||||
|
||||
// ── Source descriptor ───────────────────────────────────────────────────────
|
||||
|
||||
type SecretsSource {
|
||||
kind: String // env | vault | aws_secrets | in_memory
|
||||
config_json: String // source-specific (e.g. {"prefix":"EL_SECRET_"} or {"path":"secret/data/app"})
|
||||
}
|
||||
|
||||
fn source_env(prefix: String) -> SecretsSource {
|
||||
{ "kind": "env", "config_json": "{\"prefix\":\"" + prefix + "\"}" }
|
||||
}
|
||||
|
||||
fn source_vault(addr: String, token: String, mount: String) -> SecretsSource {
|
||||
let cfg: String = "{\"addr\":\"" + addr + "\",\"token\":\"" + token
|
||||
+ "\",\"mount\":\"" + mount + "\"}"
|
||||
{ "kind": "vault", "config_json": cfg }
|
||||
}
|
||||
|
||||
fn source_aws(region: String) -> SecretsSource {
|
||||
{ "kind": "aws_secrets", "config_json": "{\"region\":\"" + region + "\"}" }
|
||||
}
|
||||
|
||||
fn source_in_memory() -> SecretsSource {
|
||||
{ "kind": "in_memory", "config_json": "{\"map\":{}}" }
|
||||
}
|
||||
|
||||
// ── Source lookup ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// Each source kind has its own fetch fn. `source_get` dispatches by kind.
|
||||
|
||||
fn source_get(src: SecretsSource, key: String) -> String {
|
||||
if str_eq(src.kind, "env") {
|
||||
let prefix: String = json_get(src.config_json, "prefix")
|
||||
return env(prefix + str_to_upper(str_replace(key, ".", "_")))
|
||||
}
|
||||
if str_eq(src.kind, "vault") {
|
||||
let addr: String = json_get(src.config_json, "addr")
|
||||
let token: String = json_get(src.config_json, "token")
|
||||
let mount: String = json_get(src.config_json, "mount")
|
||||
return vault_kv_get_at(addr, token, mount, key)
|
||||
}
|
||||
if str_eq(src.kind, "aws_secrets") {
|
||||
let region: String = json_get(src.config_json, "region")
|
||||
return aws_secrets_get(region, key)
|
||||
}
|
||||
if str_eq(src.kind, "in_memory") {
|
||||
let map: String = json_get(src.config_json, "map")
|
||||
return json_get(map, key)
|
||||
}
|
||||
""
|
||||
}
|
||||
|
||||
fn in_memory_insert(src: SecretsSource, key: String, value: String) -> SecretsSource {
|
||||
let map: String = json_get(src.config_json, "map")
|
||||
let map = json_set(map, key, "\"" + value + "\"")
|
||||
let cfg = json_set(src.config_json, "map", map)
|
||||
{ "kind": src.kind, "config_json": cfg }
|
||||
}
|
||||
|
||||
// ── Secret<T> — redacted wrapper ────────────────────────────────────────────
|
||||
//
|
||||
// A Secret stores its raw value behind a tag. `secret_display` always
|
||||
// returns `[REDACTED]`. `secret_expose` returns the actual string.
|
||||
// Serializers should call `secret_display` by default.
|
||||
|
||||
type Secret {
|
||||
tag: String // marker: "secret"
|
||||
raw: String // the actual value — never log this directly
|
||||
}
|
||||
|
||||
fn secret_new(value: String) -> Secret {
|
||||
{ "tag": "secret", "raw": value }
|
||||
}
|
||||
|
||||
fn secret_display(s: Secret) -> String {
|
||||
"[REDACTED]"
|
||||
}
|
||||
|
||||
fn secret_expose(s: Secret) -> String {
|
||||
s.raw
|
||||
}
|
||||
|
||||
// Disposable read-once guard — like Rust's `SecretGuard`. Consumes the secret
|
||||
// once and zeroes it. Useful at boundaries: read JWT signing key once, hand to
|
||||
// the signing routine, never readable again.
|
||||
type SecretGuard {
|
||||
consumed: Bool
|
||||
raw: String
|
||||
}
|
||||
|
||||
fn guard_new(s: Secret) -> SecretGuard {
|
||||
{ "consumed": false, "raw": s.raw }
|
||||
}
|
||||
|
||||
fn guard_take(g: SecretGuard) -> String {
|
||||
if g.consumed { return "" }
|
||||
g.raw
|
||||
}
|
||||
|
||||
// ── SecretsResolver — builder ───────────────────────────────────────────────
|
||||
//
|
||||
// Sources are tried in order. First non-empty hit wins.
|
||||
// `require(key)` marks a key as required at startup; `resolve()` fails if any
|
||||
// required key is unresolved.
|
||||
|
||||
type SecretsResolver {
|
||||
sources_json: String // JSON array of SecretsSource
|
||||
required_json: String // JSON array of required keys
|
||||
}
|
||||
|
||||
fn resolver_new() -> SecretsResolver {
|
||||
{ "sources_json": "[]", "required_json": "[]" }
|
||||
}
|
||||
|
||||
fn resolver_source(r: SecretsResolver, src: SecretsSource) -> SecretsResolver {
|
||||
{ "sources_json": json_array_push(r.sources_json, json_encode(src)),
|
||||
"required_json": r.required_json }
|
||||
}
|
||||
|
||||
fn resolver_require(r: SecretsResolver, key: String) -> SecretsResolver {
|
||||
{ "sources_json": r.sources_json,
|
||||
"required_json": json_array_push(r.required_json, "\"" + key + "\"") }
|
||||
}
|
||||
|
||||
// Resolved bag. Keys -> Secret. Failure if any required key is missing.
|
||||
type ResolvedSecrets {
|
||||
map_json: String // JSON: key -> raw value
|
||||
errors: String // JSON array of missing required keys
|
||||
}
|
||||
|
||||
fn resolver_resolve(r: SecretsResolver) -> ResolvedSecrets {
|
||||
let resolved: String = "{}"
|
||||
let errors: String = "[]"
|
||||
let n: Int = json_array_len(r.required_json)
|
||||
let i: Int = 0
|
||||
while i < n {
|
||||
let key: String = json_array_get(r.required_json, i)
|
||||
let value: String = lookup_chain(r.sources_json, key)
|
||||
if str_eq(value, "") {
|
||||
let errors = json_array_push(errors, "\"" + key + "\"")
|
||||
}
|
||||
if !str_eq(value, "") {
|
||||
let resolved = json_set(resolved, key, "\"" + value + "\"")
|
||||
}
|
||||
let i = i + 1
|
||||
}
|
||||
{ "map_json": resolved, "errors": errors }
|
||||
}
|
||||
|
||||
fn lookup_chain(sources_json: String, key: String) -> String {
|
||||
let n: Int = json_array_len(sources_json)
|
||||
let i: Int = 0
|
||||
while i < n {
|
||||
let src_json: String = json_array_get(sources_json, i)
|
||||
let src: SecretsSource = { "kind": json_get(src_json, "kind"),
|
||||
"config_json": json_get(src_json, "config_json") }
|
||||
let v: String = source_get(src, key)
|
||||
if !str_eq(v, "") { return v }
|
||||
let i = i + 1
|
||||
}
|
||||
""
|
||||
}
|
||||
|
||||
fn resolved_require(r: ResolvedSecrets, key: String) -> Secret {
|
||||
let raw: String = json_get(r.map_json, key)
|
||||
secret_new(raw)
|
||||
}
|
||||
|
||||
// ── Entry — smoke test ──────────────────────────────────────────────────────
|
||||
|
||||
let src: SecretsSource = source_in_memory()
|
||||
let src = in_memory_insert(src, "jwt.key", "test-key-for-testing-only")
|
||||
let resolver: SecretsResolver = resolver_source(resolver_new(), src)
|
||||
let resolver = resolver_require(resolver, "jwt.key")
|
||||
let resolved: ResolvedSecrets = resolver_resolve(resolver)
|
||||
let jwt: Secret = resolved_require(resolved, "jwt.key")
|
||||
println("[el-secrets] jwt.key = " + secret_display(jwt))
|
||||
@@ -0,0 +1,282 @@
|
||||
/// SecretsResolver — loads all required secrets at startup.
|
||||
///
|
||||
/// The resolver validates that all required secrets are present before the
|
||||
/// application starts. If any are missing, startup fails with a clear error
|
||||
/// listing what's missing — not a runtime panic deep in the app.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use crate::error::SecretsError;
|
||||
use crate::secret::Secret;
|
||||
use crate::source::SecretsSource;
|
||||
|
||||
/// A resolved, validated set of secrets.
|
||||
///
|
||||
/// Created by SecretsResolver at startup. Once resolved, all secrets are
|
||||
/// available and guaranteed to have been present at startup time.
|
||||
pub struct ResolvedSecrets {
|
||||
values: HashMap<String, Secret<String>>,
|
||||
}
|
||||
|
||||
impl ResolvedSecrets {
|
||||
/// Get a secret by key.
|
||||
///
|
||||
/// Returns None if the key wasn't declared as required.
|
||||
/// In normal use you will always have the keys you declared.
|
||||
pub fn get(&self, key: &str) -> Option<&Secret<String>> {
|
||||
self.values.get(key)
|
||||
}
|
||||
|
||||
/// Get a secret or panic with a clear message.
|
||||
///
|
||||
/// Use this for secrets that are truly required and were declared in
|
||||
/// the resolver — if the resolver passed, this will always succeed.
|
||||
pub fn require(&self, key: &str) -> &Secret<String> {
|
||||
self.values.get(key).unwrap_or_else(|| {
|
||||
panic!(
|
||||
"Secret '{}' was not declared as required in SecretsResolver. \
|
||||
Declare all required secrets before calling resolve().",
|
||||
key
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// The number of resolved secrets.
|
||||
pub fn len(&self) -> usize {
|
||||
self.values.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.values.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads and validates all required secrets at startup.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```ignore
|
||||
/// let secrets = SecretsResolver::new()
|
||||
/// .source(EnvVarSource::new())
|
||||
/// .require("jwt.secret_key")
|
||||
/// .require("database.password")
|
||||
/// .resolve()?;
|
||||
///
|
||||
/// let jwt_key = secrets.require("jwt.secret_key");
|
||||
/// ```
|
||||
pub struct SecretsResolver {
|
||||
sources: Vec<Box<dyn SecretsSource>>,
|
||||
required: Vec<String>,
|
||||
}
|
||||
|
||||
impl SecretsResolver {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sources: Vec::new(),
|
||||
required: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a secret source. Sources are tried in order; first success wins.
|
||||
pub fn source(mut self, source: Box<dyn SecretsSource>) -> Self {
|
||||
self.sources.push(source);
|
||||
self
|
||||
}
|
||||
|
||||
/// Declare a required secret key.
|
||||
///
|
||||
/// All required keys must be present in at least one source.
|
||||
/// resolve() fails if any are missing.
|
||||
pub fn require(mut self, key: impl Into<String>) -> Self {
|
||||
self.required.push(key.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Declare multiple required secret keys.
|
||||
pub fn require_all(mut self, keys: &[&str]) -> Self {
|
||||
for key in keys {
|
||||
self.required.push(key.to_string());
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Load all required secrets and validate they are all present.
|
||||
///
|
||||
/// Returns an error listing ALL missing secrets — not just the first one —
|
||||
/// so you can fix them all in one go.
|
||||
pub fn resolve(self) -> Result<ResolvedSecrets, SecretsError> {
|
||||
let mut values = HashMap::new();
|
||||
let mut missing = Vec::new();
|
||||
|
||||
for key in &self.required {
|
||||
match self.fetch_from_sources(key) {
|
||||
Ok(secret) => {
|
||||
values.insert(key.clone(), secret);
|
||||
}
|
||||
Err(_) => {
|
||||
missing.push(key.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !missing.is_empty() {
|
||||
return Err(SecretsError::MissingRequired { keys: missing });
|
||||
}
|
||||
|
||||
Ok(ResolvedSecrets { values })
|
||||
}
|
||||
|
||||
/// Attempt to fetch a key from sources in order.
|
||||
fn fetch_from_sources(&self, key: &str) -> Result<Secret<String>, SecretsError> {
|
||||
for source in &self.sources {
|
||||
if let Ok(secret) = source.get(key) {
|
||||
return Ok(secret);
|
||||
}
|
||||
}
|
||||
Err(SecretsError::NotFoundInAnySource {
|
||||
key: key.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve without requiring all keys — useful when you want
|
||||
/// to load whatever is available.
|
||||
pub fn resolve_optional(self) -> ResolvedSecrets {
|
||||
let mut values = HashMap::new();
|
||||
for key in &self.required {
|
||||
if let Ok(secret) = self.fetch_from_sources(key) {
|
||||
values.insert(key.clone(), secret);
|
||||
}
|
||||
}
|
||||
ResolvedSecrets { values }
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SecretsResolver {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::source::InMemorySource;
|
||||
|
||||
fn make_source(pairs: &[(&str, &str)]) -> InMemorySource {
|
||||
let mut src = InMemorySource::new();
|
||||
for (k, v) in pairs {
|
||||
src.insert(*k, *v);
|
||||
}
|
||||
src
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_all_present() {
|
||||
let src = make_source(&[("jwt.key", "secret"), ("db.password", "pass123")]);
|
||||
let secrets = SecretsResolver::new()
|
||||
.source(Box::new(src))
|
||||
.require("jwt.key")
|
||||
.require("db.password")
|
||||
.resolve()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(secrets.require("jwt.key").expose(), "secret");
|
||||
assert_eq!(secrets.require("db.password").expose(), "pass123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_missing_fails_with_list() {
|
||||
let src = make_source(&[("jwt.key", "secret")]);
|
||||
let result = SecretsResolver::new()
|
||||
.source(Box::new(src))
|
||||
.require("jwt.key")
|
||||
.require("db.password") // missing
|
||||
.require("stripe.key") // also missing
|
||||
.resolve();
|
||||
|
||||
match result {
|
||||
Err(SecretsError::MissingRequired { keys }) => {
|
||||
assert!(keys.contains(&"db.password".to_string()));
|
||||
assert!(keys.contains(&"stripe.key".to_string()));
|
||||
assert_eq!(keys.len(), 2);
|
||||
}
|
||||
_ => panic!("expected MissingRequired error"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_optional_skips_missing() {
|
||||
let src = make_source(&[("jwt.key", "secret")]);
|
||||
let secrets = SecretsResolver::new()
|
||||
.source(Box::new(src))
|
||||
.require("jwt.key")
|
||||
.require("missing.key")
|
||||
.resolve_optional();
|
||||
|
||||
assert!(secrets.get("jwt.key").is_some());
|
||||
assert!(secrets.get("missing.key").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_sources_fallback() {
|
||||
let mut primary = InMemorySource::new();
|
||||
primary.insert("jwt.key", "from-primary");
|
||||
let mut secondary = InMemorySource::new();
|
||||
secondary.insert("db.password", "from-secondary");
|
||||
|
||||
let secrets = SecretsResolver::new()
|
||||
.source(Box::new(primary))
|
||||
.source(Box::new(secondary))
|
||||
.require("jwt.key")
|
||||
.require("db.password")
|
||||
.resolve()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(secrets.require("jwt.key").expose(), "from-primary");
|
||||
assert_eq!(secrets.require("db.password").expose(), "from-secondary");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_source_wins() {
|
||||
let mut s1 = InMemorySource::new();
|
||||
s1.insert("key", "value-1");
|
||||
let mut s2 = InMemorySource::new();
|
||||
s2.insert("key", "value-2");
|
||||
|
||||
let secrets = SecretsResolver::new()
|
||||
.source(Box::new(s1))
|
||||
.source(Box::new(s2))
|
||||
.require("key")
|
||||
.resolve()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(secrets.require("key").expose(), "value-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn require_all() {
|
||||
let src = make_source(&[("a", "1"), ("b", "2"), ("c", "3")]);
|
||||
let secrets = SecretsResolver::new()
|
||||
.source(Box::new(src))
|
||||
.require_all(&["a", "b", "c"])
|
||||
.resolve()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(secrets.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_len() {
|
||||
let src = make_source(&[("k", "v")]);
|
||||
let secrets = SecretsResolver::new()
|
||||
.source(Box::new(src))
|
||||
.require("k")
|
||||
.resolve()
|
||||
.unwrap();
|
||||
assert_eq!(secrets.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_is_empty() {
|
||||
let resolved = ResolvedSecrets { values: HashMap::new() };
|
||||
assert!(resolved.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/// Secret<T> — a typed value that never leaks via Display/Debug.
|
||||
///
|
||||
/// The wrapper ensures that accidental logging or serialization of a secret
|
||||
/// never reveals the actual value. You must explicitly call `.expose()` to
|
||||
/// read it, which creates a visible opt-in point in the code.
|
||||
|
||||
use serde::{Deserialize, Serialize, Serializer};
|
||||
|
||||
/// A secret value. Never prints the inner value via Display or Debug.
|
||||
///
|
||||
/// Always displays as `[REDACTED]`. To access the inner value:
|
||||
/// ```
|
||||
/// use el_secrets::Secret;
|
||||
/// let key = Secret::new("my-secret-key".to_string());
|
||||
/// let actual: &str = key.expose(); // explicit opt-in
|
||||
/// ```
|
||||
#[derive(Clone)]
|
||||
pub struct Secret<T: Clone>(T);
|
||||
|
||||
impl<T: Clone> Secret<T> {
|
||||
/// Wrap a value in a Secret.
|
||||
pub fn new(value: T) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
|
||||
/// Access the inner value.
|
||||
///
|
||||
/// This is the ONLY way to get the actual secret value out.
|
||||
/// Name it `expose` so it's searchable in code review.
|
||||
pub fn expose(&self) -> &T {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// Consume the Secret and return the inner value.
|
||||
pub fn into_inner(self) -> T {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// Map the inner value to a new type, wrapping in a new Secret.
|
||||
pub fn map<U: Clone, F: FnOnce(T) -> U>(self, f: F) -> Secret<U> {
|
||||
Secret(f(self.0))
|
||||
}
|
||||
}
|
||||
|
||||
/// Debug never reveals the secret value.
|
||||
impl<T: Clone> std::fmt::Debug for Secret<T> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "Secret([REDACTED])")
|
||||
}
|
||||
}
|
||||
|
||||
/// Display never reveals the secret value.
|
||||
impl<T: Clone> std::fmt::Display for Secret<T> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "[REDACTED]")
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize writes [REDACTED], never the actual value.
|
||||
/// This prevents secrets from appearing in JSON logs, API responses, etc.
|
||||
impl<T: Clone> Serialize for Secret<T> {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str("[REDACTED]")
|
||||
}
|
||||
}
|
||||
|
||||
/// Deserialize from a string — used when loading secrets from files/env.
|
||||
/// Only implemented for Secret<String> since we always load as strings.
|
||||
impl<'de> Deserialize<'de> for Secret<String> {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let s = String::deserialize(deserializer)?;
|
||||
Ok(Secret::new(s))
|
||||
}
|
||||
}
|
||||
|
||||
/// A guard that prevents a value from being accidentally exposed.
|
||||
///
|
||||
/// Use this on struct fields that should never be serialized or logged.
|
||||
#[derive(Clone)]
|
||||
pub struct SecretGuard<T: Clone> {
|
||||
inner: Secret<T>,
|
||||
/// A hint shown in Debug output (not the value itself).
|
||||
label: &'static str,
|
||||
}
|
||||
|
||||
impl<T: Clone> SecretGuard<T> {
|
||||
pub fn new(value: T, label: &'static str) -> Self {
|
||||
Self {
|
||||
inner: Secret::new(value),
|
||||
label,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn expose(&self) -> &T {
|
||||
self.inner.expose()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Clone> std::fmt::Debug for SecretGuard<T> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "SecretGuard({}: [REDACTED])", self.label)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn secret_expose() {
|
||||
let s = Secret::new("my-api-key".to_string());
|
||||
assert_eq!(s.expose(), "my-api-key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_debug_redacted() {
|
||||
let s = Secret::new("super-secret".to_string());
|
||||
let debug = format!("{:?}", s);
|
||||
assert_eq!(debug, "Secret([REDACTED])");
|
||||
assert!(!debug.contains("super-secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_display_redacted() {
|
||||
let s = Secret::new(12345u32);
|
||||
let display = format!("{}", s);
|
||||
assert_eq!(display, "[REDACTED]");
|
||||
assert!(!display.contains("12345"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_serialize_redacted() {
|
||||
let s = Secret::new("should-not-appear".to_string());
|
||||
let json = serde_json::to_string(&s).unwrap();
|
||||
assert_eq!(json, r#""[REDACTED]""#);
|
||||
assert!(!json.contains("should-not-appear"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_deserialize() {
|
||||
let s: Secret<String> = serde_json::from_str(r#""my-secret""#).unwrap();
|
||||
assert_eq!(s.expose(), "my-secret");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_map() {
|
||||
let s = Secret::new("42".to_string());
|
||||
let n: Secret<u32> = s.map(|v| v.parse().unwrap());
|
||||
assert_eq!(*n.expose(), 42u32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_into_inner() {
|
||||
let s = Secret::new("value".to_string());
|
||||
assert_eq!(s.into_inner(), "value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_guard_debug() {
|
||||
let g = SecretGuard::new("token".to_string(), "jwt_token");
|
||||
let debug = format!("{:?}", g);
|
||||
assert!(debug.contains("jwt_token"));
|
||||
assert!(!debug.contains("token\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_guard_expose() {
|
||||
let g = SecretGuard::new("secret-value".to_string(), "api_key");
|
||||
assert_eq!(g.expose(), "secret-value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_clone_does_not_expose() {
|
||||
let s1 = Secret::new("clone-me".to_string());
|
||||
let s2 = s1.clone();
|
||||
assert_eq!(s2.expose(), "clone-me");
|
||||
assert!(!format!("{:?}", s2).contains("clone-me"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
/// SecretsSource trait and built-in implementations.
|
||||
///
|
||||
/// Each source knows how to retrieve a named secret. Sources are tried in
|
||||
/// order by the SecretsResolver until one succeeds.
|
||||
|
||||
use crate::error::SecretsError;
|
||||
use crate::secret::Secret;
|
||||
|
||||
/// A source of secret values.
|
||||
pub trait SecretsSource: Send + Sync {
|
||||
/// The name of this source.
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// Retrieve a secret by key.
|
||||
fn get(&self, key: &str) -> Result<Secret<String>, SecretsError>;
|
||||
|
||||
/// List available secret keys (may not be supported by all sources).
|
||||
fn list(&self) -> Result<Vec<String>, SecretsError>;
|
||||
}
|
||||
|
||||
/// Reads secrets from environment variables.
|
||||
///
|
||||
/// Key mapping: `jwt.key` → `EL_SECRET_JWT_KEY` (uppercased, dots → underscores).
|
||||
pub struct EnvVarSource {
|
||||
prefix: String,
|
||||
}
|
||||
|
||||
impl EnvVarSource {
|
||||
pub fn new() -> Self {
|
||||
Self { prefix: "EL_SECRET".to_string() }
|
||||
}
|
||||
|
||||
pub fn with_prefix(prefix: impl Into<String>) -> Self {
|
||||
Self { prefix: prefix.into() }
|
||||
}
|
||||
|
||||
fn env_key(&self, key: &str) -> String {
|
||||
let normalized = key.replace('.', "_").replace('-', "_").to_uppercase();
|
||||
format!("{}_{}", self.prefix, normalized)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EnvVarSource {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl SecretsSource for EnvVarSource {
|
||||
fn name(&self) -> &str {
|
||||
"env-var"
|
||||
}
|
||||
|
||||
fn get(&self, key: &str) -> Result<Secret<String>, SecretsError> {
|
||||
let env_key = self.env_key(key);
|
||||
std::env::var(&env_key)
|
||||
.map(Secret::new)
|
||||
.map_err(|_| SecretsError::NotFound {
|
||||
key: key.to_string(),
|
||||
source_name: "env-var".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn list(&self) -> Result<Vec<String>, SecretsError> {
|
||||
let prefix = format!("{}_", self.prefix);
|
||||
let keys: Vec<String> = std::env::vars()
|
||||
.filter(|(k, _)| k.starts_with(&prefix))
|
||||
.map(|(k, _)| {
|
||||
k.strip_prefix(&prefix)
|
||||
.unwrap_or(&k)
|
||||
.to_lowercase()
|
||||
.replace('_', ".")
|
||||
})
|
||||
.collect();
|
||||
Ok(keys)
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory secrets source for testing.
|
||||
///
|
||||
/// NEVER use in production — secrets are in plaintext in memory
|
||||
/// and this source is not safe for production credentials.
|
||||
#[derive(Default)]
|
||||
pub struct InMemorySource {
|
||||
secrets: std::collections::HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl InMemorySource {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Insert a secret. Only for testing.
|
||||
pub fn insert(&mut self, key: impl Into<String>, value: impl Into<String>) {
|
||||
self.secrets.insert(key.into(), value.into());
|
||||
}
|
||||
}
|
||||
|
||||
impl SecretsSource for InMemorySource {
|
||||
fn name(&self) -> &str {
|
||||
"in-memory"
|
||||
}
|
||||
|
||||
fn get(&self, key: &str) -> Result<Secret<String>, SecretsError> {
|
||||
self.secrets
|
||||
.get(key)
|
||||
.map(|v| Secret::new(v.clone()))
|
||||
.ok_or_else(|| SecretsError::NotFound {
|
||||
key: key.to_string(),
|
||||
source_name: "in-memory".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn list(&self) -> Result<Vec<String>, SecretsError> {
|
||||
Ok(self.secrets.keys().cloned().collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// Vault source stub — HashiCorp Vault integration.
|
||||
///
|
||||
/// This is a stub that defines the interface. A real implementation
|
||||
/// would make HTTP calls to the Vault API. The stub is sufficient
|
||||
/// for the type system and resolver to work correctly.
|
||||
pub struct VaultSource {
|
||||
/// Vault server URL (e.g. "https://vault.example.com").
|
||||
pub address: String,
|
||||
/// Mount path for the KV engine (e.g. "secret").
|
||||
pub mount: String,
|
||||
/// The Vault path prefix for this app's secrets.
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
impl VaultSource {
|
||||
pub fn new(address: impl Into<String>, mount: impl Into<String>, path: impl Into<String>) -> Self {
|
||||
Self {
|
||||
address: address.into(),
|
||||
mount: mount.into(),
|
||||
path: path.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SecretsSource for VaultSource {
|
||||
fn name(&self) -> &str {
|
||||
"vault"
|
||||
}
|
||||
|
||||
fn get(&self, key: &str) -> Result<Secret<String>, SecretsError> {
|
||||
// Stub: in a real impl, make HTTP GET to Vault KV API
|
||||
Err(SecretsError::SourceUnavailable {
|
||||
source_name: "vault".to_string(),
|
||||
reason: format!(
|
||||
"Vault HTTP client not implemented in stub — would fetch {}/{}/{}/{}",
|
||||
self.address, self.mount, self.path, key
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
fn list(&self) -> Result<Vec<String>, SecretsError> {
|
||||
Err(SecretsError::SourceUnavailable {
|
||||
source_name: "vault".to_string(),
|
||||
reason: "Vault HTTP client not implemented in stub".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// AWS Secrets Manager source stub.
|
||||
pub struct AwsSecretsSource {
|
||||
pub region: String,
|
||||
pub path_prefix: String,
|
||||
}
|
||||
|
||||
impl AwsSecretsSource {
|
||||
pub fn new(region: impl Into<String>, path_prefix: impl Into<String>) -> Self {
|
||||
Self {
|
||||
region: region.into(),
|
||||
path_prefix: path_prefix.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SecretsSource for AwsSecretsSource {
|
||||
fn name(&self) -> &str {
|
||||
"aws-secrets-manager"
|
||||
}
|
||||
|
||||
fn get(&self, key: &str) -> Result<Secret<String>, SecretsError> {
|
||||
Err(SecretsError::SourceUnavailable {
|
||||
source_name: "aws-secrets-manager".to_string(),
|
||||
reason: format!(
|
||||
"AWS SDK not linked — would fetch {}/{} in {}",
|
||||
self.path_prefix, key, self.region
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
fn list(&self) -> Result<Vec<String>, SecretsError> {
|
||||
Err(SecretsError::SourceUnavailable {
|
||||
source_name: "aws-secrets-manager".to_string(),
|
||||
reason: "AWS SDK not linked".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn in_memory_get() {
|
||||
let mut src = InMemorySource::new();
|
||||
src.insert("jwt.key", "secret-jwt-key");
|
||||
let val = src.get("jwt.key").unwrap();
|
||||
assert_eq!(val.expose(), "secret-jwt-key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_memory_missing() {
|
||||
let src = InMemorySource::new();
|
||||
assert!(src.get("missing.key").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_memory_list() {
|
||||
let mut src = InMemorySource::new();
|
||||
src.insert("key.a", "a");
|
||||
src.insert("key.b", "b");
|
||||
let mut keys = src.list().unwrap();
|
||||
keys.sort();
|
||||
assert_eq!(keys, vec!["key.a", "key.b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_key_mapping() {
|
||||
let src = EnvVarSource::new();
|
||||
assert_eq!(src.env_key("jwt.key"), "EL_SECRET_JWT_KEY");
|
||||
assert_eq!(src.env_key("database.password"), "EL_SECRET_DATABASE_PASSWORD");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vault_source_stub_returns_error() {
|
||||
let src = VaultSource::new("https://vault.example.com", "secret", "myapp");
|
||||
assert!(src.get("jwt.key").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aws_source_stub_returns_error() {
|
||||
let src = AwsSecretsSource::new("us-east-1", "myapp");
|
||||
assert!(src.get("jwt.key").is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user