M8.C3 — Secrets management: secret(), config() y Secret<T>¶
Pre-requisitos: M8.C2 — observability. Sabés emitir logs estructurados; ahora vas a asegurarte de que no filtren credenciales.
Objetivo: usar secret(key) -> Secret<Str> y config(key, default)
-> Str para leer env vars con dos semánticas distintas (sensitive vs
público), aprovechar el tipo opaco Secret<T> para que el checker
rechace fugas, y entender patterns canónicos (.env files, K8s
secrets, sidecars).
Por qué importa: en Python o Node, os.getenv("DB_PASSWORD")
devuelve un Str plano que podés tipear en cualquier print() o
logger.info() por accidente. Las fugas de secrets en logs son la
fuente #1 de incidentes de seguridad menores. Fitz lo previene a
nivel del checker: si pasás un Secret<T> a un kwarg de log.*,
se redacta automático.
Cross-link: cap 32 de la guía
con detalle de env()/env_or() builtins.
Por qué Fitz es distinto¶
Comparalo con el patrón típico:
# Python — fácil de filtrar
import os
DB_PASS = os.getenv("DB_PASSWORD")
logger.info(f"connecting with pass={DB_PASS}") # ❌ filtrado al log
// TypeScript — mismo problema
const dbPass = process.env.DB_PASSWORD;
console.log(`connecting with pass=${dbPass}`); // ❌ filtrado
# Fitz — el checker bloquea el filtro
let db_pass = secret("DB_PASSWORD")
log.info("connecting", password: db_pass)
// → JSON output: {"...":"password":"***"} ← automático
Comparativo:
| Feature | Python | TypeScript | Go | Spring | Fitz |
|---|---|---|---|---|---|
| Leer env var | os.getenv |
process.env |
os.Getenv |
@Value |
config("X", default) |
| Default value built-in | ⚠ truthy | ⚠ default arg | ⚠ check | ✅ | ✅ |
| Tipo opaco para secrets | ❌ | ❌ | ❌ | ⚠ con lib | ✅ Secret<T> built-in |
| Redacción automática en logs | ❌ | ❌ | ❌ | ⚠ con lib | ✅ checker + runtime |
| Validación estática | ❌ runtime | ❌ runtime | ❌ | ✅ | ✅ checker rechaza el filtro |
| .env loader built-in | python-dotenv |
dotenv |
godotenv |
❌ | ✅ load_env(path) |
El diferencial real: el tipo Secret<T> es opaco — no podés
imprimir, loggear, ni serializar a JSON un Secret<T> directamente.
Para extraer el valor necesitás llamar .expose() explícitamente.
Es opt-in difícil de hacer por error.
Paso 1 — Distinguir config() de secret()¶
Las dos leen env vars, pero con semánticas distintas:
config(key, default)devuelveStrdirecto. Para configuración no-sensitive: hostnames, ports, log levels, feature flags. Si la env var no existe, usa eldefault.secret(key)devuelveSecret<Str>opaco. Para credenciales: passwords, API keys, tokens. Si la env var no existe → error (los secrets NO tienen default silencioso — la app debe abortar si no están configurados).
let db_host = config("DB_HOST", "localhost") // Str — público
let db_port = config("DB_PORT", "5432") // Str — público
let db_pass = secret("DB_PASSWORD") // Secret<Str> — opaco
Regla práctica: si te incomoda ver el valor en un log, usá
secret(). Si está bien que el valor aparezca en logs ("conectando a
db.prod.example.com"), usá config().
Paso 2 — Trabajar con Secret<T>¶
Secret<T> es un tipo opaco. No podés hacer nada con él salvo:
- Pasarlo a un kwarg de log — se redacta automático.
- Llamar
.expose() -> Texplícitamente para extraer el valor. - Pasarlo a un context que requiere
Secret<T>(caso futuro:db.connect(url_with_secret)).
let pass = secret("DB_PASS")
log.info("login attempt", password: pass) // → "password":"***"
let raw = pass.expose() // Str — escapas al world
let url = "postgres://user:{raw}@db:5432/app"
let conn = db.connect(url)?
Por qué .expose(): si querés que un valor sensitive salga del
tipo opaco (ej: para construir una URL de DB), tenés que escribir
.expose() explícitamente. Es visible en code review. Si alguien
hace una PR con pass.expose() adentro de un log.info(...), el
reviewer lo nota inmediato.
Sin .expose(), el checker rechaza:
let pass = secret("DB_PASS")
print(pass) // ❌ Error: `print(Secret<Str>)` no se puede formatear directo
print(pass.expose()) // OK — pero feo a propósito
Paso 3 — Redacción automática en estructuras¶
Secret<T> se redacta recursivamente cuando cae adentro de:
- Kwargs directos del
log.*. List<Secret<T>>(lista de secrets).Map<Str, Secret<T>>(mapa con secrets como values).Instancecon fieldSecret<T>(typed object con un field sensitive).
let creds = {
"db_user": "admin",
"db_pass": secret("DB_PASS").expose() // ❌ ya expusiste, no se redacta
}
log.info("config", creds: creds)
// → "creds":{"db_user":"admin","db_pass":"actualvalue"} ← FILTRADO
let creds_safe = {
"db_user": "admin",
"db_pass": secret("DB_PASS") // Sin .expose() — mantiene tipo opaco
}
log.info("config", creds: creds_safe)
// → "creds":{"db_user":"admin","db_pass":"***"} ← redactado
Lección: .expose() "extrae" el valor del Secret. Una vez
extraído, el sistema de tipos no puede protegerlo más. Llamá
.expose() lo más tarde posible, idealmente sólo cuando vas a usar
el valor (construir URL, mandar a la DB).
Paso 4 — Cargar .env para dev local¶
En desarrollo local, querés tener un .env con tus credenciales
sin tener que export cada vez. Built-in load_env(path) ->
Result<Null>:
@server(3000)
fn main() {
// Cargar .env si existe (en prod no debería; usá env vars del orchestrator).
let _ = load_env(".env") // Result<Null> — ignoramos el Err
let db_pass = secret("DB_PASS")
// ...
}
Crea un .env adyacente al binario:
Y un .env.example que SÍ committeás (sin valores sensitive):
load_env() parsea el formato estándar (KEY=VALUE per line,
comentarios con #, comillas opcionales). Sobreescribe env vars
existentes con los valores del file.
Importante: en producción NO usás
load_env(".env"). Las env vars deben venir del orchestrator (K8s Secret, fly secrets, Docker compose env, etc.). El.enves solo para dev local.
Paso 5 — K8s secrets¶
En Kubernetes el patrón canónico es declarar un Secret separado y
montarlo como env vars:
# k8s-secret.yaml
apiVersion: v1
kind: Secret
metadata:
name: mi-app-secrets
type: Opaque
stringData:
DB_PASS: "supersecret-prod"
JWT_SECRET: "anothersupersecret"
# k8s-deployment.yaml
spec:
containers:
- name: app
image: mi-app:v1
envFrom:
- secretRef:
name: mi-app-secrets
env:
- name: DB_HOST
value: "db.prod.example.com" # config, no secret
- name: DB_PORT
value: "5432"
Aplicalo:
Tu app Fitz lee secret("DB_PASS") y config("DB_HOST", ...) de
forma transparente. No sabe que vienen de un K8s Secret, solo ve env
vars.
Cuidado: los K8s Secrets están en etcd codificados en base64 por default (NO encriptados). Para encriptación real, usá:
- Sealed Secrets (Bitnami) — encripta a nivel de operator.
- External Secrets Operator — sincroniza desde HashiCorp Vault / AWS Secrets Manager / etc.
- CSI Driver Secrets Store — monta secrets como volúmenes con rotación.
Paso 6 — fly.io / Railway / Heroku patterns¶
Para deploys "click and deploy" (sin K8s), los proveedores tienen sus propios secret managers:
# fly.io
$ fly secrets set DB_PASS=supersecret
$ fly secrets set JWT_SECRET=anothersecret
$ fly deploy
# Railway
$ railway variables set DB_PASS=supersecret
$ railway up
# Heroku
$ heroku config:set DB_PASS=supersecret
$ git push heroku main
Todos terminan inyectando env vars al container. Tu app Fitz lo
consume con secret() igual que en K8s o local.
Validación del cap¶
Probá end-to-end:
# 1. Local con .env
$ cat > .env <<EOF
DB_HOST=localhost
DB_PASS=test-pass
APP_NAME=demo-local
EOF
$ ./mi-app
{"timestamp":"...","msg":"server listo","app":"demo-local"}
# 2. Verificá redacción en logs
$ curl localhost:3000/admin -H "Authorization: Bearer demo-admin"
{"timestamp":"...","msg":"stats request","admin":"Ada","password":"***"}
^^^
redactado automático aunque pasaste secret() en kwargs
# 3. Probá K8s (si tenés cluster)
$ kubectl create secret generic mi-app-secrets \
--from-literal=DB_PASS=k8s-pass \
--from-literal=JWT_SECRET=k8s-jwt
$ kubectl apply -f deployment.yaml
$ kubectl logs deployment/mi-app
{"...","app":"demo-prod","host":"db.svc.cluster.local"}
# 4. Verificá que el binario NO contiene el secret (search en strings)
$ strings ./mi-app | grep -i "supersecret" # debería NO matchear
Si los 4 pasos pasan, dominaste secrets management.
Próximo: M8.C4 — Deploy avanzado con Docker¶
Tu app ahora maneja secrets sin filtrarlos. La última pieza:
llevarla a producción con fitz docker init, healthz/readyz para
K8s, SIGTERM drain, y patterns 12-factor.