provisioning/schemas/security/secrets-loader.ncl

127 lines
4 KiB
Text
Raw Permalink Normal View History

# Secrets Loader - Import and merge encrypted YAML secrets into Nickel configs
{
# Type for a secrets configuration
SecretsConfig = {
# Source YAML file path (can be SOPS-encrypted)
source_path | std.string | doc "Path to YAML secrets file (relative or absolute)",
# Environment (dev, staging, prod) - determines which secrets file to load
environment | std.string | doc "Environment: dev, staging, or prod" = "dev",
# Whether to merge with defaults
merge_defaults | std.bool | doc "Merge with default configuration" = true,
},
# Load secrets from a YAML file
# If file ends with .enc, it will be decrypted via vault-service at runtime
load = fun source_path =>
let contents = std.string.trim (
# Import the YAML file as a string
# At deployment time, this will be decrypted SOPS file
std.json.stringify { path = source_path }
) in
# Parse YAML as record structure
# Assumes file is in format:
# key1:
# nested: value
# key2: value
contents,
# Load environment-specific secrets
# Tries: secrets.{env}.yaml → secrets.yaml → {}
load_env = fun base_path environment =>
let env_path = $"($base_path)/secrets.($environment).yaml" in
let default_path = $"($base_path)/secrets.yaml" in
{
env_specific = env_path,
default = default_path,
environment = environment,
},
# Merge secrets into configuration template
# Replaces placeholder values with actual secrets
merge = fun template secrets =>
let replace_placeholders = fun obj =>
std.record.map (
fun _key value =>
if std.string.is_string value then
# Check if value is a placeholder like "${secret:database.password}"
if value |> std.string.starts_with "${secret:" then
let secret_path = (
value
|> std.string.drop_prefix "${secret:"
|> std.string.drop_suffix "}"
) in
# Navigate to secret_path in secrets record
# e.g., "database.password" → secrets.database.password
std.record.get_path (std.string.split "." secret_path) secrets
else
value
else if std.record.is_record value then
replace_placeholders value
else
value
) obj in
replace_placeholders template,
# Extract secrets by path pattern
# Useful for selecting subset of secrets
extract_by_path = fun secrets pattern =>
let matches_pattern = fun key =>
key |> std.string.contains pattern in
secrets
|> std.record.to_array
|> std.array.filter (fun {key, _value} => matches_pattern key)
|> std.array.fold_left (fun acc item =>
acc & { (item.key) = item.value }
) {},
# Validate secrets structure against schema
validate_secrets = fun secrets required_keys =>
let missing = (
required_keys
|> std.array.filter (fun key =>
not (std.record.has_field key secrets)
)
) in
if std.array.length missing > 0 then
{
valid = false,
missing_keys = missing,
error = $"Missing required secret keys: {std.string.join \", \" missing}",
}
else
{
valid = true,
missing_keys = [],
error = null,
},
# Resolve secrets from multiple sources (environment variables, files, defaults)
resolve = fun secret_specs =>
let resolved = fun spec =>
let env_var = $"PROVISIONING_SECRET_{spec.key}" in
{
key = spec.key,
source = (
if (std.string.from_env env_var | std.string.is_empty) then
"file"
else
"env"
),
value = (
std.string.from_env env_var
| (fun env_val =>
if std.string.is_empty env_val then
spec.default_value
else
env_val
)
),
} in
secret_specs
|> std.array.map resolved
|> std.array.fold_left (fun acc item =>
acc & { (item.key) = item.value }
) {},
}