Pular para o conteúdo

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:

  1. Scopes obligatorios — ninguna key tiene * por default.
  2. Rotation forzada cada 90 días.
  3. IP allowlist opcional pero recomendada para prod.
  4. 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 key

Riesgos 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é.

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 propios
bmonkey:flows:write crear / editar / publicar flows
bmonkey:sessions:create iniciar sesiones de onboarding
bmonkey:sessions:read leer estado de sesiones
bmonkey:wallet:read lookup + discover (sin pedir presentación)
bmonkey:wallet:request pedir presentation (cobra Açaí)
bhawk:screenings:read leer historial de screenings
bhawk:screenings:run correr screenings nuevos (cobra Açaí)
bhawk:rules:read leer validations + reglas
bhawk:rules:write crear / editar / publicar reglas
bseal:templates:read leer plantillas
bseal:templates:write subir / editar plantillas
bseal:signatures:create firmar documentos (cobra Açaí)
bseal:signatures:read leer status / descargar firmados
platform:tenants:read leer info del propio tenant
platform:tenants:write editar info del propio tenant
platform: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:create
prod-backend → platform:acai:read, platform:webhooks:write
ci-deploy → bmonkey:flows:write, bhawk:rules:write
analytics → bmonkey:sessions:read, bhawk:screenings:read
read-only → *:*:read (dashboards, soporte)

Si un key se filtra, el blast radius está acotado al subset que tenía.

Día 0 Key creada
Dí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 portal
Dí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:

Ventana de terminal
# Generar key nueva sin invalidar la vieja
curl -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
# }
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.

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.

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.

-- 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 null
ALTER 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);

El middleware actual (shared-libs/go-bjungle-tenancy) hace:

  1. Lee X-API-Key.
  2. Hash → busca tenants.api_key_hash.
  3. Abre tx + setea app.current_tenant.

El extendido agrega:

  1. Verifica expires_at > now() AND revoked_at IS NULL.
  2. Verifica que el endpoint llamado matchea al menos un scope.
  3. Si ip_allowlist != null, verifica client_ip ∈ ip_allowlist.
  4. Update last_used_at, last_used_ip async (best-effort).

Cada chequeo que falla → 403 con WWW-Authenticate: Bearer error="insufficient_scope" + log entry.

  • 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_id debe identificar al humano (vía multi-operator). “system” como actor es bug.
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.
  • 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).
AspectoDecisión
StatusAccepted (2026-05-30)
Aplicable aplatform-api · tenancy middleware · todos los módulos
Rotation cycle90 días con 7 días de overlap
Default scopeobligatorio explícito; * solo para legacy con warning
IP allowlistopcional pero recomendada para prod
Re-evaluarcuando agreguemos clients confidenciales (private_key_jwt) que reemplacen API key por client assertions

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}/rotate desde 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:

  1. El lookup del middleware (LookupActive) corre antes de que exista la tx con app.current_tenant. RLS por tenant_id rompería el bootstrap.
  2. 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.
  3. 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)»
FeatureStatus
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)

F3.3 · API key auto-rotation cron + portal banner.

ComponenteStatusNotas
Cron api_key_rotation (platform-worker)✅ entregadointernal/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✅ entregadoDJ-PT-F3.3-21 — tenant ve status (label, scopes, expires_at, days_until_expiry, rotation_required).
ApiKeyRotationBanner portal✅ entregadoplatform/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✅ entregadoparte 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 con created_by).
  • Wizard de migración para tenants con keys legacy * (back-fill 0004) — UI guided rotation to scoped keys.