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 —el punto 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.