Archived
f4abfe6fdc
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/.
196 lines
7.4 KiB
EmacsLisp
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))
|