Archived
feat: rename crates/ → vessels/ + add El ports per sub-vessel
Belated rename commit for foundation/el-ui — was missed in the workspace-wide crates→vessels pass earlier today. Same structural intent as the rename in the other repos: 'crates' is the Rust word, 'vessel' is El's, and the directory rename is the marker that this slot holds an El buildable unit even if its current contents are still Rust pending port. Plus the El ports themselves — manifest.el + src/main.el per sub- vessel (el-aop, el-auth, el-config, el-i18n, el-identity, el-layout, el-platform, el-publish, el-secrets, el-services, el-style, el-ui- compiler). The ui-compiler is a stub: elc only emits C right now; generating browser-target JS/Wasm is the biggest open language gap and gets its own project. Until then, el-ui-compiler emits a JS module that throws elc.backend_missing so callers fail loudly. Cross-repo path dependencies in Cargo.toml updated to vessels/.
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "el-secrets"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "el-ui secrets management — typed, never-logged, source-agnostic"
|
||||
license = "MIT"
|
||||
|
||||
[lib]
|
||||
name = "el_secrets"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
thiserror = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
@@ -0,0 +1,20 @@
|
||||
// el-secrets — Secrets management for el-ui applications.
|
||||
//
|
||||
// Secrets are NEVER in code. Wrapped in a `Secret<T>` type that displays as
|
||||
// `[REDACTED]` in all logs and JSON output. Explicit `.expose()` to read.
|
||||
|
||||
vessel "el-secrets" {
|
||||
version "0.1.0"
|
||||
description "Resolver chain: env vars, Vault, AWS Secrets Manager"
|
||||
authors ["Will Anderson <will@neurontechnologies.ai>"]
|
||||
edition "2026"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
el-platform "1.0"
|
||||
}
|
||||
|
||||
build {
|
||||
entry "src/main.el"
|
||||
output "dist/"
|
||||
}
|
||||
@@ -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