Files
el/ui/vessels/el-secrets/src/main.el

196 lines
7.4 KiB
EmacsLisp

// 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))