API keys — política de scopes, rotation y audit
Este conteúdo não está disponível em sua língua ainda.
Las API keys de bjungle se usan hoy para autenticar máquina ↔ máquina:
el backend de un tenant llama nuestras APIs server-to-server. El patrón
es estándar (X-API-Key: dj_live_XXXX) pero, como cualquier secret de
larga duración, requiere políticas operativas para no degenerar en el
patrón “password compartido entre humanos” que vemos en SaaS legacy.
Este ADR fija cuatro políticas concretas:
- Scopes obligatorios — ninguna key tiene
*por default. - Rotation forzada cada 90 días.
- IP allowlist opcional pero recomendada para prod.
- Audit log por key + last_used tracking.
El estado actual y por qué hay que endurecerlo
Sección titulada «El estado actual y por qué hay que endurecerlo»Hoy:
dj_live_XXXX ─ se genera una vez al crear el tenant ─ tiene acceso TOTAL a todos los módulos del tenant ─ no expira ─ no hay IP restriction ─ no hay log estructurado por keyRiesgos concretos:
- Filtración indetectable: si el secret se commitea por accidente a GitHub, el atacante puede usar la key durante meses sin que aparezca en nuestros radars.
- Blast radius máximo: la key puede crear sesiones de flow, firmar contratos, ejecutar screenings, consultar todo. Un atacante con la key controla el tenant entero.
- Humanos como key holders: hoy varios developers de un tenant comparten la misma key porque “es más fácil”. El audit log no identifica quién hizo qué.
Política nueva: scopes obligatorios
Sección titulada «Política nueva: scopes obligatorios»Cada key tiene un conjunto de scopes que el tenant define al crearla. El tenancy middleware valida que el endpoint llamado esté en la lista.
Scopes disponibles (catálogo extensible):─────────────────────────────────────────────────────────────────bmonkey:flows:read leer flows propiosbmonkey:flows:write crear / editar / publicar flowsbmonkey:sessions:create iniciar sesiones de onboardingbmonkey:sessions:read leer estado de sesionesbmonkey:wallet:read lookup + discover (sin pedir presentación)bmonkey:wallet:request pedir presentation (cobra Açaí)bhawk:screenings:read leer historial de screeningsbhawk:screenings:run correr screenings nuevos (cobra Açaí)bhawk:rules:read leer validations + reglasbhawk:rules:write crear / editar / publicar reglasbseal:templates:read leer plantillasbseal:templates:write subir / editar plantillasbseal:signatures:create firmar documentos (cobra Açaí)bseal:signatures:read leer status / descargar firmadosplatform:tenants:read leer info del propio tenantplatform:tenants:write editar info del propio tenantplatform:acai:read leer balance + ledger Açaíplatform:webhooks:write configurar endpoints + secrets─────────────────────────────────────────────────────────────────Un tenant típico tiene 3-5 keys distintas, no una sola:
prod-server → bmonkey:sessions:create, bmonkey:wallet:request, bhawk:screenings:run, bseal:signatures:createprod-backend → platform:acai:read, platform:webhooks:writeci-deploy → bmonkey:flows:write, bhawk:rules:writeanalytics → bmonkey:sessions:read, bhawk:screenings:readread-only → *:*:read (dashboards, soporte)Si un key se filtra, el blast radius está acotado al subset que tenía.
Política nueva: rotation cada 90 días
Sección titulada «Política nueva: rotation cada 90 días»Día 0 Key creadaDía 80 Warning email a operadores del tenant: "Rotá la key en 10 días"Día 89 Warning más fuerte + banner en el portalDía 90 Rotation forzada — la key vieja pasa a estado `rotation_pending` (acepta requests pero responde con header `X-Rotation-Required: true` para que el cliente actualice)Día 97 La key vieja deja de funcionar (`revoked_at` automático)El client del tenant puede rotar programáticamente:
# Generar key nueva sin invalidar la viejacurl -X POST https://api.bjungle.com/v1/api-keys/rotate \ -H "X-API-Key: dj_live_old_XXX" \ -H "Content-Type: application/json" \ -d '{"label": "prod-server (rotation 2026-05)"}'
# Response:# {# "new_key": "dj_live_new_YYY", ← guardalo, no se muestra otra vez# "old_key_grace_until": "2026-08-28T00:00:00Z" ← 7 días de overlap# }Política nueva: IP allowlist opcional
Sección titulada «Política nueva: IP allowlist opcional»ALTER TABLE platform.api_keys ADD COLUMN ip_allowlist CIDR[] DEFAULT NULL;Si ip_allowlist está NULL, la key funciona desde cualquier IP (default
hoy). Si tiene CIDRs, el middleware rechaza requests fuera de la lista
con 403 forbidden + log entry para alertar.
Recomendado para keys de prod-server (típicamente sale de IPs fijas del data-center / VPC).
Útil para defense-in-depth contra leaks: aunque la key salga al wild, el atacante necesita IP en la lista para usarla.
Política nueva: audit log + last_used
Sección titulada «Política nueva: audit log + last_used»Tabla extendida:
ALTER TABLE platform.api_keys ADD COLUMN last_used_at TIMESTAMPTZ, last_used_ip INET, last_used_ua TEXT;
-- audit_log entries por uso (sample sample_rate, no cada call):-- 1 cada 100 calls al mismo (key, endpoint) en ventana de 1h-- + 100% de los calls que fallaron auth (sospechosos)UI en el portal: tab “API keys” muestra por cada key:
prod-server ⚠ rotation en 12 días ─ Última request: hace 3 minutos desde 54.213.x.x ─ Scope: bmonkey:sessions:create, bhawk:screenings:run +2 más ─ Total requests hoy: 12,450 ─ Errors (4xx/5xx): 23 (0.18%) ─ Top endpoints: POST /v1/flows/sessions (8200) POST /v1/screenings (3100) POST /v1/wallet/request-presentation (1150) [ rotar ] [ revocar ] [ editar scopes ]Cuando un operador rota o revoca: entry en audit_log con actor_id
(el wallet_id del operador, vía multi-operator ADR-005) — no anónimo.
Migración para tenants existentes
Sección titulada «Migración para tenants existentes»Las keys actuales (sin scope explícito) reciben un scope grandfathered:
['*']. La UI muestra un warning:
⚠ Esta key tiene scope completo (legacy). Recomendamos crear keys con scope específico y deprecar esta. [ migrar ahora ]A los 6 meses de lanzar la política, las keys con scope ['*'] empiezan
a generar warnings en cada response (X-Key-Legacy-Scope: true). Al
año, fuerza rotation con scope explícito.
Modelo de datos
Sección titulada «Modelo de datos»-- platform.api_keys (extender la existente, no nueva):ALTER TABLE platform.api_keys ADD COLUMN scopes TEXT[] NOT NULL DEFAULT ARRAY['*'], expires_at TIMESTAMPTZ, ip_allowlist CIDR[] DEFAULT NULL, last_used_at TIMESTAMPTZ, last_used_ip INET, last_used_ua TEXT, revoked_at TIMESTAMPTZ, revoke_reason TEXT, rotation_of UUID REFERENCES platform.api_keys(id), created_by UUID REFERENCES bmonkey.wallets(id), -- operador que la creó label TEXT NOT NULL DEFAULT '';
-- Constraint: revoked_at xor nullALTER TABLE platform.api_keys ADD CONSTRAINT api_key_active_or_revoked CHECK ((revoked_at IS NULL AND expires_at IS NULL OR expires_at > now()) OR revoked_at IS NOT NULL);Tenancy middleware extendido
Sección titulada «Tenancy middleware extendido»El middleware actual (shared-libs/go-bjungle-tenancy) hace:
- Lee
X-API-Key. - Hash → busca
tenants.api_key_hash. - Abre tx + setea
app.current_tenant.
El extendido agrega:
- Verifica
expires_at > now() AND revoked_at IS NULL. - Verifica que el endpoint llamado matchea al menos un
scope. - Si
ip_allowlist != null, verificaclient_ip ∈ ip_allowlist. - Update
last_used_at, last_used_ipasync (best-effort).
Cada chequeo que falla → 403 con WWW-Authenticate: Bearer error="insufficient_scope" + log entry.
Anti-patrones
Sección titulada «Anti-patrones»- Una sola key con scope
*en todos los servers. Equivalente a “password root compartido”. Migrate a keys por server con scopes granulares. - Hardcodear keys en código del cliente (mobile, web). Las keys son de máquina, no de browser. Mobile usa el wallet (ADR-002) o Sign-in-with-bjungle (ADR-003), no API keys.
- Rotation manual cada 3 años “cuando nos acordamos”. La rotation automatizada cada 90 días con overlap de 7 días NO es punitiva — es higiene que también protege al tenant.
- Audit log sin actor. Si una key fue creada/rotada/revocada,
audit_log.actor_iddebe identificar al humano (vía multi-operator). “system” como actor es bug.
Roadmap de implementación
Sección titulada «Roadmap de implementación»Fase 1 — Backend (~3 días) - Migration platform/db: extender api_keys con todas las columnas - shared-libs/go-bjungle-tenancy: middleware extendido (scopes, expires, ip_allowlist) - Catálogo de scopes en código (no en DB) — single source of truth - Endpoint POST /v1/api-keys/rotate (con overlap de 7 días) - Cron job: warning a 80 días + rotation forzada a 90
Fase 2 — Portal (~2 días) - Tab "API keys" en tenant-portal con la tabla descrita - UI para crear key con scope picker - Vista de uso + rotation history - Banner global cuando hay keys legacy con scope *
Fase 3 — Migration de tenants existentes (~1 semana) - Job que marca todas las keys actuales como "legacy_scope = true" - Email a operadores: "tu tenant tiene N keys legacy" - Wizard en el portal para migrar (crear key nueva con scope específico, deprecar la vieja) - Después de 6 meses: warning en response. Después de 12: forzar.Implicaciones para otros ADRs
Sección titulada «Implicaciones para otros ADRs»- ADR-001 audiencias: API keys SON solo para máquina. Humanos NO entran al portal con API key (ADR-005 los manda a Sign-in with bjungle).
- ADR-003 Sign-in with bjungle: el
client_secret del RP es una variante de API key con scope reservado
oidc:client. Mismo modelo de rotation + audit. - ADR-005 multi-operator: el operador que
crea/rota/revoca una API key queda en el audit como
actor_id = wallet_id. Esto solo es posible cuando el operador ya entró con su propia identidad (no con la API key del tenant).
Decisión
Sección titulada «Decisión»| Aspecto | Decisión |
|---|---|
| Status | Accepted (2026-05-30) |
| Aplicable a | platform-api · tenancy middleware · todos los módulos |
| Rotation cycle | 90 días con 7 días de overlap |
| Default scope | obligatorio explícito; * solo para legacy con warning |
| IP allowlist | opcional pero recomendada para prod |
| Re-evaluar | cuando agreguemos clients confidenciales (private_key_jwt) que reemplacen API key por client assertions |
Estado de implementación · 2026-05-30
Sección titulada «Estado de implementación · 2026-05-30»Tras la implementación inicial (sprint T51) y el pen-test del security agent, dos puntos del ADR quedan deferidos a follow-ups:
1. Rotation forzada automática a 90 días (DJ-PT-51-09 deferred)
Sección titulada «1. Rotation forzada automática a 90 días (DJ-PT-51-09 deferred)»Estado: ⏳ Rotation actualmente es manual.
El flujo descrito arriba (“Día 80 warning, Día 90 forzada, Día 97 expira”)
asume un cron job que escanea platform.api_keys y actualiza
expires_at / grace_until automáticamente. Ese cron NO fue
implementado en la primera tanda porque:
- Su valor depende de notification rails (email + UI banner en el portal) que tampoco están implementados todavía.
- La rotation se puede activar manualmente vía
POST /v1/api-keys/{id}/rotatedesde el portal o API directa, sin pérdida de funcionalidad. - El back-fill de las keys legacy sí recibe
expires_at = now() + 180 días(migration 0005), forzando una migración explícita en ese plazo.
Follow-up: implementar el cron + email a operadores cuando el sistema de notification del portal aterrice (post T50 multi-operator).
2. RLS en platform.api_keys (DJ-PT-51-12 deferred)
Sección titulada «2. RLS en platform.api_keys (DJ-PT-51-12 deferred)»Estado: ⏳ La tabla NO usa RLS. Defensa cross-tenant vive en
service-layer (APIKeyService.canGrant, ownership checks en
Rotate / Revoke).
Por qué no RLS:
- El lookup del middleware (
LookupActive) corre antes de que exista la tx conapp.current_tenant. RLS por tenant_id rompería el bootstrap. - Una RLS deny-all + funciones SECURITY DEFINER (el patrón usado en bmonkey para wallets globales) sería viable, pero agrega complejidad sin cerrar un vector que el service-layer no cubra.
- Los tests integration (con goroutines paralelas y cross-tenant probes) verifican que la defensa de service-layer funciona en práctica.
Follow-up: re-evaluar cuando aterrice T50 multi-operator y la tabla
crezca con created_by, last_modified_by, etc. Si el modelo de
auditoría exige RLS por operador (no solo por tenant), migrar a
patrón global tipo bmonkey.
Lo que SÍ se implementó (resumen del sprint T51)
Sección titulada «Lo que SÍ se implementó (resumen del sprint T51)»| Feature | Status |
|---|---|
Tabla platform.api_keys con scopes, expires_at, ip_allowlist, rotation_of, grace_until, last_used_*, revoked_at | ✅ migration 0004 |
Back-fill legacy con expires_at = now() + 180 días | ✅ migration 0005 |
Tabla platform.audit_log append-only + writer SECURITY DEFINER | ✅ migration 0005 |
Catálogo de ~24 scopes en código (service.ValidScopes) | ✅ |
Middleware MiddlewareWithKeys + RequireScope helper | ✅ shared-libs/go-bjungle-tenancy |
Endpoints /v1/api-keys create / rotate / revoke / list / scopes con RequireScope wireado | ✅ |
RotateAtomic con pg_advisory_xact_lock + SELECT FOR UPDATE | ✅ rotation race cerrada |
| IP allowlist enforced en middleware | ✅ 403 si fuera del rango |
X-Rotation-Required: true + X-Rotation-Grace-Until headers durante grace | ✅ |
Cache-Control: no-store + Pragma: no-cache en plaintext responses | ✅ |
| Cross-tenant rotate / revoke: 404 (no enumeración) | ✅ ErrAPIKeyCrossTenant |
| Audit log entries en create / rotate / revoke | ✅ |
| Tests unit (14) + integration (13) + E2E (10 specs, 9 pass + 1 skip) | ✅ |
Fase 3 entregada (2026-05-30)
Sección titulada «Fase 3 entregada (2026-05-30)»F3.3 · API key auto-rotation cron + portal banner.
| Componente | Status | Notas |
|---|---|---|
Cron api_key_rotation (platform-worker) | ✅ entregado | internal/api_key_rotation.go corre cada hora. Bandas: 80/90/97 días — emite eventos audit apikey.rotation_due_80/90/97. |
GET /v1/me/api-key endpoint | ✅ entregado | DJ-PT-F3.3-21 — tenant ve status (label, scopes, expires_at, days_until_expiry, rotation_required). |
ApiKeyRotationBanner portal | ✅ entregado | platform/frontend/tenant-portal/src/components/ApiKeyRotationBanner.tsx aparece en _authed layout cuando rotation_required=true o days_until_expiry < 30. CTA → /settings/api-keys?filter=needs-rotation. |
Tab /settings/api-keys con filtro needs-rotation | ✅ entregado | _authed.settings.api-keys.tsx con vista paginada + filtro server-side. |
| E2E spec 18 (refresh) cubre rotation + grace window | ✅ entregado | parte del make e2e. |
Status final: Accepted.
DEFERRED a Wave 5:
- RLS sobre
platform.api_keys(T51 decision: superuser-driven; revaluar cuando aterrice multi-operator T50 concreated_by). - Wizard de migración para tenants con keys legacy
*(back-fill 0004) — UI guided rotation to scoped keys.