Ambar · spec en curso

API key de Anthropic por usuario, con aislamiento total entre instancias

Cómo se almacena la key de cada usuario, cómo se inyecta en cada ejecución, y, lo central, cómo queda aislado lo de un usuario de lo de otro. Validado por una PoC con keys reales.

branch poc/anthropic-key-per-user runner claude -p modelo de prueba claude-haiku-4-5

La idea en una frase

Cada usuario corre con su propia cuenta de Anthropic, y nada se cruza

Un solo backend atiende a muchos usuarios. Por cada mensaje se lanza un proceso claude -p. Queremos que ese proceso (a) facture a la cuenta del usuario dueño del chat, y (b) no comparta nada con el de otro usuario: ni credenciales, ni archivos, ni sesiones.

Eso se logra con dos mitades: almacenamiento (la key vive cifrada en la DB) e inyección + aislamiento (en runtime se descifra a memoria y se inyecta en un proceso con su propio entorno y sus propios directorios).

Cómo leer este documento

Leyenda

Cifrado
en reposo, en la DB
En claro · efímero
solo en memoria, durante un run
Aislado por usuario
cada uno tiene el suyo

Tres términos

instance_idel identificador canónico del usuario (su teléfono normalizado). Todo se keyea por acá.
holderel dueño de la sesión: la conversación standalone, o el grupo (cuando varios chats se vinculan con /vincular y comparten contexto).
subprocessel proceso claude -p que se lanza por mensaje. Su entorno (env) es cómo se le pasan los datos del usuario.

Proceso A · puntual, lo hace el admin

Alta y asignación de la key

Anthropic no permite crear keys por API, así que el primer paso es siempre manual. A partir de ahí, el sistema valida, cifra y guarda.

  1. El admin crea la API key en la Console de Anthropic.
    Manual, fuera del sistema. La copia una sola vez.
  2. La asigna a un usuario vía panel / endpoint / CLI de admin.
    PUT /api/admin/users/{id}/anthropic-key · solo is_admin.
  3. El sistema la valida contra Anthropic antes de guardarla.
    Si es inválida o sin saldo → se rechaza y el admin reintenta.
  4. Se cifra y se persiste asociada al usuario. cifrada
    encrypt_str(key) (Fernet ⟵ bridge_secret) → omzg_user_anthropic_key.api_key_encrypted. Se guarda además key_hint (últimos 4, para la UI). Nunca en claro.
El admin también puede cambiar, deshabilitar/rehabilitar (sin borrar, reversible) y borrar la key de un usuario. Deshabilitada cuenta como “sin key”.

Proceso B · en cada mensaje del usuario

Ejecución: de un mensaje a una respuesta

  1. Llega el mensaje (WhatsApp / Telegram) y se resuelve el instance_id del usuario.
  2. Se busca su key en la DB y se aplica la precedencia:
    1.º key propia (asignada y activa) → 2.º key compartida → fail-closed (aborta + alerta). El login OAuth del server nunca se usa (normativa).
  3. Se descifra a memoria. en claro · efímero
    decrypt_str(api_key_encrypted) → un string, vivo solo durante este run.
  4. Se arma el entorno del subprocess, con su key y sus directorios propios. aislado
    Ver la sección Aislamiento: HOME / CLAUDE_CONFIG_DIR / cwd por instance + ANTHROPIC_API_KEY del usuario.
  5. Se ejecuta claude -p con ese entorno.
    La key inyectada gana sobre el login OAuth del server (verificado: el CLI lo dice explícito).
  6. Anthropic factura a la cuenta dueña de esa key y la respuesta vuelve por el mismo canal.
    El puntero de sesión se guarda de nuevo en la DB para el próximo mensaje.
El viaje de la key: DB (cifrada)memoria (decrypt)env del subprocess → se destruye al terminar el run. Nunca toca un archivo.

El punto central

Aislamiento: tres capas, ninguna fuga

El aislamiento no es una sola cosa: son tres capas que tapan fugas distintas. Juntas hacen que ni las credenciales, ni los datos, ni los archivos o sesiones de un usuario se crucen con los de otro.

Subprocess A · usuario A
Capa 1 · Filesystem
HOME = …/A/home
CLAUDE_CONFIG_DIR = …/A/cfg
cwd = …/A/work
Capa 2 · Credenciales
ANTHROPIC_API_KEY = KEY_A
tokens Google / Notion = de A
Capa 3 · Identidad
HMAC(jwt, instance_A)
sin fuga
Subprocess B · usuario B
Capa 1 · Filesystem
HOME = …/B/home
CLAUDE_CONFIG_DIR = …/B/cfg
cwd = …/B/work
Capa 2 · Credenciales
ANTHROPIC_API_KEY = KEY_B
tokens Google / Notion = de B
Capa 3 · Identidad
HMAC(jwt, instance_B)
cuenta Anthropicfactura a A
Drive / Notion / CRMdatos de A
discosesiones+archivos de A
cuenta Anthropicfactura a B
Drive / Notion / CRMdatos de B
discosesiones+archivos de B
Capa 1cada instance tiene su HOME / CLAUDE_CONFIG_DIR / cwd → los archivos y las sesiones del CLI no se cruzan en el disco del server.
Capa 2el agente de A no llega a los datos ni a la cuenta de B (ni le factura). El env per-usuario + el filtro _SENSITIVE_KEYS arman el muro.
Capa 3A no puede forjar acciones (mensajes, tareas) como si fuera B: el jwt_secret no se expone al subprocess.
El “muro” no es un componente: es el efecto de arrancar de un entorno controlado (un dict de env nuevo por mensaje): solo lo de cada usuario entra a su subprocess, nada del otro ni del server.

Por qué se hace así

Un entorno por subprocess

Un backend único atiende a muchos usuarios en paralelo, y el env es el único canal para inyectarle a cada claude -p las credenciales de ese usuario. Hacerlo por subprocess (un dict fresco por mensaje) evita el “race” entre runs concurrentes y garantiza que la key/los datos de un usuario nunca lleguen al proceso de otro.

Si fuera un env globalCon un env por subprocess
roto A setea KEY_A → B (concurrente) setea KEY_B → el run de A factura a B. Race y fuga. ok env_A={…KEY_A} → proceso A · env_B={…KEY_B} → proceso B. Dos dicts, dos procesos, cero cruce.

Además es efímero: el env vive solo mientras corre ese proceso y muere con él; la key descifrada no queda en ningún lado.

Continuidad de la conversación

Sesiones: puntero (DB) + transcript (disco)

Una sesión de claude -p son dos cosas en dos lugares. La fuente de verdad es la DB, no el disco. (Verificado con claude -p real + el código del orchestrator.)

PiezaQué esDóndeAislamiento
session_id (puntero)un UUID, el “nombre” de la sesiónDB · claude_session_idpor holder
historial (fuente de verdad)los mensajes de la conversaciónDB · omzg_messagespor conversación
transcript (fast-path)el .jsonl que el CLI usa para --resumedisco · CLAUDE_CONFIG_DIR/projects/<cwd>/<id>.jsonlpor instance
Si el disco no está, no se pierde el historial. El .jsonl es solo un atajo para reusar contexto barato vía cache. Si --resume falla, el orchestrator reconstruye el prompt desde la DB (fallback session_expired → últimos 40 mensajes / 24 h). Persistir los directorios por usuario es una optimización de costo/latencia, no un requisito de correctitud.
Cómo se aísla el transcript: cada instance corre con un CLAUDE_CONFIG_DIR + cwd propios y estables, así los .jsonl de cada usuario viven en su propia carpeta. El CLI indexa el transcript por cwd, de modo que esos directorios per-instance son lo que mantiene cada sesión separada y, a la vez, permite el --resume.

Validación

La idea, validada de punta a punta

Probado con dos API keys reales y el modelo más barato. Las dos mitades (almacenamiento cifrado e inyección con aislamiento) quedaron demostradas.

Qué se probóResultado
Las 2 keys funcionan contra la API y con claude -p respuesta correcta
La key inyectada gana sobre el login OAuth del server el CLI lo confirma; key inválida → 401
Aislamiento total entre 2 instancias en paralelo 6/6: cada agente leyó solo su secreto, sin fuga
Cifrado/descifrado de la key (round-trip) reversible, decrypt == original
Las dos mitades de la idea quedan demostradas: la key vive cifrada en reposo y se inyecta aislada por usuario en cada run, facturando a su propia cuenta y sin cruzar credenciales, datos ni sesiones con ningún otro usuario.

Roadmap

Mejoras priorizadas

Tres mejoras decididas para construir sobre la base. No bloquean nada; suman valor operativo y de seguridad, ordenadas por retorno.

  1. Ledger de uso por usuario.
    claude -p ya devuelve total_cost_usd + tokens en cada run. Persistirlos por instance_id da costo y consumo por usuario sin depender de la Console, y habilita límites y alertas. La data ya está; solo falta guardarla.
  2. Health-check de keys + revocación real.
    Un job diario re-valida las keys activas y avisa antes de que una muerta sorprenda al usuario a mitad de conversación. Y al revocar, se desactiva la key en Anthropic vía Admin API; borrar la fila no la mataba (seguía viva en la cuenta).
  3. Cifrado versionado.
    Guardar la versión del secret junto al cifrado permite rotar la llave maestra de forma incremental, sin invalidar todas las keys de golpe. Barato ahora, caro después de tener muchos usuarios.