Secretos y el vault¶
Un módulo, y deliberadamente ningún segundo mecanismo
Toda clave de provider, todo token de bot de canal, toda credencial MCP y
toda clave de API de terceros de esta plataforma pasa por
app/core/vault.py. Añadir una segunda forma de guardar una credencial en
reposo es el defecto que dos migraciones eliminaron.
Cifrado de sobre¶
Cada secreto se sella con su propia clave de datos aleatoria. Esa clave de datos se sella con una clave derivada de la clave maestra y del scope al que pertenece el secreto — una organización, o el miembro al que pertenece una conexión personal.
flowchart LR
M["VAULT_MASTER_KEY<br/><i>version n</i>"] --> K
S["the owning scope<br/><i>org id, or member id</i>"] --> K
K["derived key"] -->|wraps| D["a random data key<br/><i>one per secret</i>"]
D -->|seals| C["the ciphertext<br/><i>+ key_version</i>"]
De ahí se siguen dos propiedades, y ambas son la razón de esta forma:
Un texto cifrado no se puede mover entre propietarios
Incluso con acceso completo a la base de datos, una fila copiada de la
organización A a la organización B no se puede desenvolver. Aquí el
aislamiento entre inquilinos es criptográfico, no una cláusula WHERE que
alguien podría olvidar.
La clave maestra es rotable. Nunca cifra directamente una carga útil, solo
claves de datos, de modo que rotarla vuelve a envolver un blob pequeño por
secreto en lugar de volver a cifrar cada valor. Cada sobre registra la
key_version que lo selló, y eso es lo que hace posible una rotación por etapas.
El vault no decide nada sobre quién puede leer un secreto — de eso se encarga la capa de permisos. Solo garantiza que un secreto en reposo es ilegible sin la clave maestra e inservible fuera del scope para el que fue sellado.
Cómo llegó a ser un único mecanismo¶
Esa frase de arriba necesitó dos rondas para volverse cierta, y la historia merece un minuto porque es la forma misma del error.
Antes eran tres los mecanismos que guardaban secretos en reposo, y solo uno
ataba un texto cifrado a su propietario. Las claves de provider pasaban por el
vault, los tokens de bot de canal por una única clave Fernet común a todo el
despliegue, y los tokens MCP por otra. Un token de Slack se podía copiar de la
fila de una organización a la de otra y descifraba. Una migración eliminó esos
dos, antes de que la cadena se aplastara en 0001_baseline.
Un cuarto sobrevivió a aquello, y le sobrevivió unos meses a la frase que tenía
encima. app/core/crypto.py mantenía una única clave Fernet común a todo el
despliegue sobre los campos de credenciales de sync_sources.config — el JSON de
la cuenta de servicio de Google y el par de claves de AWS con el que se autentica
un conector de sincronización de RAG.
Era honesto consigo mismo en su propio docstring, y aun así era un segundo mecanismo, de modo que quien leyera «no hay un segundo mecanismo» se equivocaba respecto a una tabla.
Lo que lo mantuvo vivo fue un problema de orden, no un desacuerdo: un sobre se
deriva del id de su propietario, y sync_sources.organization_id era nullable
porque la CLI creaba filas sin ella.
#707 le dio una organización
a rag-source-add, 0042_sync_source_secret_id hizo que la columna lo dijera, y
#937 borró el módulo.
Una fuente de sincronización ahora referencia por id un secreto del vault, tal
como hacen ModelProfile.secret_id y CapabilityBindingSpec.secret_id, y su
config solo guarda lo que un conector necesita para encontrar los documentos.
Dos consecuencias más allá de la criptografía, y son las que nota quien opera la plataforma: una credencial se añade una vez y la reutiliza cada fuente que la necesita, en lugar de pegarse en cada fuente y rotarse en otros tantos sitios; y aparece en la página Vault como todo lo demás, así que «¿tiene esta organización una credencial de Google?» tiene respuesta.
Los kinds¶
Un secreto no siempre es una cadena, y meter a la fuerza toda credencial en un único campo «API key» produce un formulario que alguien rellena correctamente y del que sale igualmente una credencial que falla en el primer run. Por eso un secreto tiene un kind, y el kind decide qué campos existen.
| Kind | Campos |
|---|---|
api_key |
Un token opaco |
azure_openai |
Clave, endpoint, versión de API fijada |
aws_credentials |
Access key id, secret access key, región, session token opcional |
gcp_service_account |
El JSON de la cuenta de servicio, validado al entrar |
github_oauth_app |
El client id público de una GitHub OAuth App y su secreto |
none |
No es un secreto — la marca para un endpoint que no necesita credencial |
github_oauth_app lo gasta la plataforma en lugar de elegirlo una persona — el
flujo de conexión con GitHub lo lee en el servidor para ejecutar el intercambio de
tokens —, así que tiene que ser visible para la organización, y tiene que haber
exactamente uno: la credencial privada de un miembro nunca se usa en silencio
para la conexión de toda la organización, y con dos apps visibles para la
organización guardadas la conexión se rechaza (nombrando ambas) en lugar de
quedar atada al nombre que ordene primero.
aws_credentials es el caso más claro de por qué existen los kinds: el access key
id no es secreto y el secret access key sí lo es, y un único campo no puede
expresar eso. gcp_service_account se valida al pegarlo porque el modo de fallo
de uno malformado es un error de autenticación horas después, sin nada que apunte
de vuelta al pegado que lo causó.
none es lo que guardas para Ollama en localhost. Es un kind y no una cadena
vacía para que el resolver pueda decidir sobre un conjunto total — y porque el
vault se niega a sellar un valor vacío. Solo el runtime puede contener none;
nadie puede guardar uno, y eso es lo que mantiene «un secreto sin valor» fuera del
schema de la API.
Todo campo que autentica — una clave de API, un secret access key, un client secret — debe tener al menos ocho caracteres. El listado muestra los últimos cuatro caracteres de una credencial como pista, así que un valor más corto quedaría publicado entero por su propia pista; el mínimo también atrapa un pegado truncado mientras el formulario sigue abierto.
Dónde se usan¶
Providers de modelos. Los nombra un perfil de modelo. El gasto se atribuye al secreto al que resolvió el run, y así «qué clave está costando más» tiene respuesta.
Capabilities. Una capability declara que necesita una credencial de un kind
dado — nunca una instancia. El código dice «necesito una clave de API»; la
secret_id de un binding dice cuál. Mira
el catálogo de capabilities.
Conexiones MCP. Bearer tokens y cargas útiles de OAuth, selladas a la organización o al miembro. Mira MCP.
Bots de canal. Todas las credenciales de la fila, selladas a la organización
del bot bajo una misma key_version: el token del bot, el signing secret y el app
token de una app de Slack, y el secreto compartido contra el que se autentica un
webhook entrante — el X-Telegram-Bot-Api-Secret-Token de Telegram, el token de
un webhook saliente de Mattermost. Mira Canales.
Triggers de eventos. El secreto contra el que se verifica el webhook entrante
de un trigger de evento - la clave HMAC de GitHub, o el signing secret que envía
un relay de correo o de API - sellado a la organización y guardado en línea en la
fila del trigger junto con la key_version que lo selló, con la misma forma que el
signing secret de un bot de canal. Nunca se devuelve ni se registra en claro; la
verificación lo desella, compara en tiempo constante, y una entrega que falla es un
403. Mira Conceptos.
Embeds. Un widget jwt verifica los tokens de visitante contra un signing
secret HS256 que guarda el backend del cliente. Está sellado a la organización del
agent y registra su key_version como cualquier otra fila sellada, de modo que una
rotación de la clave maestra puede hacerle rewrap y el widget sigue verificando —
mientras que un embed que no hubiera registrado su versión no se podría volver a
abrir nunca tras una rotación.
Una fila con varias columnas de texto cifrado — las cuatro de un bot de canal, la
única de un embed — las sella mediante vault.seal_fields, que sella cada campo con
una misma versión y devuelve esa versión para guardarla: la única forma de escribir
una fila así, de manera que «sin columna de versión» y «devolver un campo a v1» ni
siquiera se puedan escribir a mano.
Servicios de terceros. Un pequeño catálogo de servicios para los que una organización puede traer su propia clave:
| Servicio | Lo usa |
|---|---|
| Tavily | web_research |
| Brave Search | web_research |
| Exa | web_research |
| Logfire | Observabilidad por agent — trazas a un proyecto propio |
| LlamaParse | Parseo de PDF, facturado a la clave propia de la organización |
| mem0 | memory_mem0 — la capability entera, que guarda las memorias semánticas de un agent en un servicio mem0 (en la nube o autoalojado) en vez de aquí. En este despliegue no se guarda nada, así que mem0 factura su propio embedding por fuera y enviar memorias a la nube de mem0 es una decisión de residencia de datos que nombra el Builder. Una base_url autoalojada tiene que ser https y estar en la lista de permitidos MEM0_ALLOWED_HOSTS, para que la clave del vault nunca se envíe a un origen controlado por el agent. No hay consola de operador para estas memorias: mem0 tiene su propio almacén, su propio listado y su propio borrado. |
Lo que nunca ocurre¶
Cuatro garantías, fijadas por tests y no por convención
Nada en claro en una respuesta, en una línea de log, en una entrada de auditoría ni en un spec exportado - y una capability nunca llega a saber de dónde salió su credencial.
- Ninguna respuesta de la API devuelve un texto en claro. No hay endpoint para ello. El servicio dueño de los secretos de la organización tiene dos lectores que producen uno, y ninguno de los dos se lo entrega a quien llama: el del runner, mientras construye un agent, y el del catálogo de modelos, que gasta un bearer token en una única petición saliente a un provider y devuelve los nombres de modelo que llegaron. Nada fuera de ese servicio abre un secreto — la ruta del listado de modelos lo hacía antes, y ese era el defecto de capas.
- Ninguna línea de log ni entrada de auditoría contiene uno. Todo campo que
lleva un secreto es un
SecretStrde Pydantic, de modo que las dataclasses que transportan credenciales se enmascaran a sí mismas en un repr — que es la vía por la que suele escaparse una clave en claro. - Ningún spec lleva uno. Un spec de agent exportado referencia los secretos por id. Eso es lo que hace seguro subirlo al repositorio de git de un cliente.
- Una capability nunca llega a saber de dónde salió su credencial, y el modelo no la ve en absoluto.
Esas cuatro están fijadas por tests, no por convención.
Acceso¶
| Permiso | Concede |
|---|---|
secrets:view |
Ver que un secreto existe, su kind, su etiqueta |
secrets:edit |
Crear, rotar, borrar |
mcp:manage |
Las conexiones MCP de la organización y sus credenciales |
connections:manage |
Credenciales de toda la organización: conexiones a providers de modelos e integraciones de fuentes de sincronización |
Los scopes difieren según el rol — un Owner edita cualquier secreto de la organización, un Member solo los suyos. Un secreto también se puede compartir con un miembro o un agent concretos mediante una concesión sobre el recurso, que amplía el acceso a esa única fila sin ascender a nadie. Mira Permisos.
Operaciones¶
La clave maestra es VAULT_MASTER_KEY. Recae en SECRET_KEY para que un checkout
recién hecho funcione sin configuración adicional, y la configuración rechaza una
clave sin fijar en todas partes salvo en local/development — staging es un
despliegue de pleno derecho y guarda habitualmente claves de provider reales, así
que recibe el mismo rechazo que producción.
Perder todas las claves configuradas significa que todas las credenciales guardadas se han perdido
No hay camino de recuperación ni copia en custodia: cada secreto hay que volver a introducirlo a mano. Respalda la clave en un sitio donde no esté el respaldo de la base de datos.
Rotar es una operación por etapas, y VAULT_MASTER_KEYS es la forma por etapas:
un mapa JSON de todas las versiones todavía en uso. La versión más alta sella los
secretos nuevos; las anteriores mantienen legibles las filas existentes hasta que
se vuelven a envolver. La key_version de cada fila sellada registra qué versión
la envolvió, y pedir una versión sin clave configurada falla nombrando la entrada
que falta, en lugar de como un error de descifrado genérico.
# 1. Configure both keys — the old one as the version that sealed today's rows,
# the new one above it — and unset the single VAULT_MASTER_KEY.
# VAULT_MASTER_KEYS={"1": "<old>", "2": "<new>"}
# 2. Prove every stored envelope opens before anything moves:
uv run agenticos cmd vault-rotate --dry-run
# 3. Re-wrap every sealed row to the new version:
uv run agenticos cmd vault-rotate
# 4. Once it reports zero failures, drop version 1 from VAULT_MASTER_KEYS.
No elimines la clave antigua hasta que vault-rotate informe de cero fallos
Una fila que falla queda nombrada y tal como estaba, y el comando termina con un código distinto de cero - eliminar la versión 1 en una rotación parcial deja esas filas ilegibles.
vault-rotate recorre todas las tablas que guardan sobres y mueve los textos
cifrados de cada fila junto con su columna de versión, o no los mueve en absoluto.
Una fila que no guarda ningún sobre pero nombra una versión — una conexión cuyas
credenciales se vaciaron — también ve esa declaración movida a la versión actual,
para que el siguiente secreto sellado en ella caiga sobre una clave que todavía
existe. Solo se vuelve a sellar la clave de datos envuelta — las cargas útiles
quedan intactas, y eso es lo que hace barata la rotación.
make platform-bootstrap BOOTSTRAP_API_KEY=sk-... guarda por ti la primera clave
de provider. Mira Configuración para el entorno, y la
lista de comprobación para producción antes
de salir a producción con un valor por defecto generado.
Resumen¶
- Un módulo,
app/core/vault.py. No hay un segundo mecanismo, y añadir uno es el defecto que dos migraciones eliminaron. - Un secreto se sella con su propia clave de datos, envuelta por una clave derivada de la clave maestra y del scope propietario — así un texto cifrado no puede moverse entre propietarios.
- Un secreto tiene un kind, porque
aws_credentialsson cuatro campos y uno de ellos no es secreto. - Cuatro garantías, fijadas por tests: nada en claro en una respuesta, un log, una entrada de auditoría o un spec exportado.
- La rotación es por etapas — configura ambas versiones,
vault-rotate --dry-run, rota, y luego elimina la clave antigua cuando informe de cero fallos.