Multi-operator — empleados de un tenant con identidad propia
Hoy un tenant tiene una API key que se usa indistintamente para máquinas y humanos. Cuando Ana contrata a Mariana para operar su tenant, le pasa la misma API key. Cuando Mariana se va a otra empresa, Ana tiene que rotar la key (afecta también a sus servers) o aceptar que Mariana mantiene acceso indefinido.
Este ADR resuelve el patrón con multi-operator: cada empleado tiene su propio wallet bjungle con scope al tenant, audit log lo identifica individualmente, y revocarlo no afecta a nadie más.
El problema concreto
Sección titulada «El problema concreto»HOY CON MULTI-OPERATOR────────────────────────────────────────────────────────────────────Ana: dj_live_anafintech_XXX Ana: wallet_id w_ana, role: ownerMariana usa la misma key Mariana: wallet w_mariana, role: adminDiego (junior) usa la misma key Diego: wallet w_diego, role: support
Mariana renuncia. Mariana renuncia.Ana debe rotar la key entera. Ana hace 1 click: "revocar Mariana".Sus servers (otra app) Servers no se enteran. Diego sigue.también necesitan actualizar.Audit log: "actor: dj_live..." Audit log: "actor: wallet:w_mariana" (juicio claro de quién hizo qué)Modelo de datos
Sección titulada «Modelo de datos»-- Nueva tabla en platform-api (los wallets se mantienen en bmonkey).-- Es un JOIN entre wallets globales (bmonkey) y tenants (platform).
CREATE TABLE platform.tenant_operators ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES platform.tenants(id), wallet_id UUID NOT NULL, -- referencia a bmonkey.wallets
role TEXT NOT NULL, -- owner|admin|operator|support|readonly scopes TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], -- override fino sobre el rol -- ej: ['bhawk:rules:read']
invited_by UUID, -- wallet_id del invitante invited_at TIMESTAMPTZ NOT NULL DEFAULT now(), joined_at TIMESTAMPTZ, -- null = invitación pendiente last_active_at TIMESTAMPTZ, revoked_at TIMESTAMPTZ, revoke_reason TEXT,
-- Para invitations: token con expiry corto invite_token_hash TEXT, invite_expires_at TIMESTAMPTZ,
UNIQUE (tenant_id, wallet_id) -- un wallet, un rol por tenant);
CREATE INDEX tenant_operators_active_idx ON platform.tenant_operators (tenant_id, wallet_id) WHERE revoked_at IS NULL;Los roles canónicos
Sección titulada «Los roles canónicos»owner Único por tenant. Fundador / billing. No se revoca por UI; requiere transfer-of-ownership. Scopes implícitos: *
admin Gestiona el tenant a nivel completo (operadores, billing, módulos). Puede invitar / revocar otros operators excepto owner. Scopes implícitos: *
operator Día-a-día: configura flows, reglas, plantillas. NO toca billing ni operators. Scopes implícitos: bmonkey:*, bhawk:*, bseal:*, platform:tenants:read
support Vista de read-only + acciones suaves (e.g. resender OTPs a un usuario que perdió el email). Para customer success de un tenant grande. Scopes implícitos: *:read, bmonkey:sessions:resend_otp, bhawk:screenings:read
readonly Dashboards / analytics. No puede mutar nada. Scopes implícitos: *:readLos scopes extra permiten override granular sobre el rol base.
Ejemplo: un “operator” sin bseal:signatures:create (no puede firmar
contratos sin que pase por admin).
El flow de invitación
Sección titulada «El flow de invitación»1. Ana (owner) entra al tenant-portal :4401, tab "Equipo".
2. Click "Invitar operador": ┌──────────────────────────────────────────┐ │ Email: mariana@ana-fintech.co │ │ Rol: ▼ admin │ │ ┌────────────────────────────────────┐ │ │ │ Scopes override (opcional) │ │ │ │ [_] bseal:signatures:create │ │ │ │ [_] platform:webhooks:write │ │ │ └────────────────────────────────────┘ │ │ [ Enviar ] │ └──────────────────────────────────────────┘
3. Backend: a. Verifica que Ana tiene scope para invitar (admin/owner). b. Genera invite_token (32 random bytes, hash en DB). c. INSERT tenant_operators con joined_at = NULL. d. Envía email a Mariana: "Ana te invitó a ana-fintech en bjungle. Para aceptar, hacé click → https://login.bjungle.com/invite/<token>"
4. Mariana recibe el email. Click.
5. login.bjungle.com/invite/<token>: a. ¿Mariana ya tenía wallet bjungle? - SÍ → "Aceptás invitación a ana-fintech como admin?" con passkey - NO → flujo de onboarding mínimo (consent + email_otp + face_enroll) para crear su wallet primero. b. Después de aceptar: - UPDATE tenant_operators SET joined_at = now(), wallet_id = ... - email a Ana: "Mariana aceptó la invitación" - redirect Mariana al tenant-portal logueada.El flow de revocación
Sección titulada «El flow de revocación»1. Mariana renuncia. Ana entra al portal → tab "Equipo".
2. Click "Revocar" en la fila de Mariana: "Estás por revocar el acceso de mariana@ana-fintech.co. Esta acción es inmediata. Razón (opcional): ┌────────┐ │ Salida │ └────────┘ [ Cancelar ] [ Revocar ahora ]"
3. Backend: a. UPDATE tenant_operators SET revoked_at = now(), revoke_reason = ... b. Invalida session activa de Mariana (si la tiene en este tenant). c. Audit log entry: "actor=Ana, action=operator_revoked, target=Mariana, reason=Salida" d. Email a Mariana: "Tu acceso a ana-fintech fue revocado."
4. Si Mariana intenta hacer cualquier acción en el portal de Ana: → 403 con "Tu acceso a este tenant fue revocado".
5. El wallet de Mariana sigue funcionando para CUALQUIER OTRO tenant donde haya sido invitada o donde sea end-user. Eso es lo importante: bjungle no la cancela, solo Ana le cierra la puerta.Login del operador (sin API key)
Sección titulada «Login del operador (sin API key)»El tenant-portal cambia su login screen. Hoy:
┌──────────────────────────────────────┐│ Portal bjungle ││ ──────────── ││ API key: ____________________ ││ Email: ____________________ ││ [ Entrar ] │└──────────────────────────────────────┘Con multi-operator:
┌──────────────────────────────────────┐│ Portal bjungle ││ ──────────── ││ ┌─────────────────────────────────┐ ││ │ ┃ Iniciar con bjungle │ │ ← passkey + wallet│ └─────────────────────────────────┘ ││ ││ ─── otros métodos ─── ││ · email magic link ││ · legacy (API key + email) ⚠ deprecado└──────────────────────────────────────┘El operador click “Iniciar con bjungle” → flujo Sign-in with bjungle
(ADR-003) → wallet PWA pide passkey + face opcional → callback al
tenant-portal con su wallet_id → portal busca tenant_operators WHERE wallet_id = ? AND revoked_at IS NULL.
Si tiene múltiples tenants:
┌──────────────────────────────────────┐│ ¿En qué tenant querés operar hoy? ││ ││ ┌──────────────────────────────┐ ││ │ ana-fintech (admin) │ ││ │ carolina-pay (operator) │ ││ │ bjungle-staff (owner) │ ││ └──────────────────────────────┘ │└──────────────────────────────────────┘Una sola identidad personal, múltiples scopes según el contexto. Igual a Google Workspace cuando alternás entre cuentas de trabajo y personal.
Transfer of ownership
Sección titulada «Transfer of ownership»El rol owner es único por tenant y no se puede revocar por UI. Para
transferir (Ana se va, Mariana hereda):
1. Ana (owner saliente) → tab "Equipo" → "Transferir ownership".2. Selecciona destinatario (otro operador existente, no email random).3. Step-up con face-MFA (ADR-002) — es acción crítica.4. Confirmation email a Mariana + a Ana con 24h de cool-down.5. Después de 24h sin oposición: roles cambian, Ana queda como `admin`.24h previene secuestros si alguien comprometiera el wallet de Ana brevemente.
Migración para tenants existentes
Sección titulada «Migración para tenants existentes»Tenants actuales (con solo dj_live_XXX + email del fundador) reciben
automáticamente:
INSERT INTO tenant_operators (tenant_id, wallet_id, role, scopes, joined_at)SELECT t.id, w.id, 'owner', ARRAY['*'], now() FROM tenants t JOIN ( SELECT bmonkey.wallet_create_or_get( sha256(t.contact_email), -- key sintética '','', '{}'::jsonb ) AS id ) w ON trueWHERE NOT EXISTS (SELECT 1 FROM tenant_operators WHERE tenant_id = t.id);Esto crea el wallet del founder con el email como identidad inicial. La próxima vez que el founder entre al portal, el sistema le pide activar el wallet con passkey:
"Bienvenido. bjungle ahora soporta operadores múltiples. Para seguir usando el portal, configurá tu passkey. [ Activar mi wallet ]"API key sigue funcionando como antes para sus servers. Solo cambia el login HUMANO al portal.
Anti-patrones
Sección titulada «Anti-patrones»- Operadores compartiendo la cuenta del owner. Defeats el propósito.
El portal detecta múltiples logins distintos del mismo
wallet_iddesde IPs muy diferentes y manda warning. - Crear un wallet bjungle “compartido” para todos los empleados. Mismo problema. Cada humano = un wallet.
- Roles ad-hoc sin scopes claros. El catálogo de roles arriba es la fuente única de verdad. Si Ana necesita un rol nuevo (e.g. “fraud_analyst”) proponer entry en el catálogo, no hack vía scopes override.
- No revocar a empleados que se fueron. El last_active_at + el banner “este operador no entra hace 90 días, ¿revocar?” ayuda a higiene.
Roadmap de implementación
Sección titulada «Roadmap de implementación»Fase 1 — Backend (~4 días) - Migration: platform.tenant_operators - Endpoints platform-api: POST /v1/tenants/{id}/operators (invitar) GET /v1/tenants/{id}/operators PATCH /v1/tenants/{id}/operators/{op_id} DELETE /v1/tenants/{id}/operators/{op_id} (revocar) POST /v1/invites/{token}/accept - Middleware: cuando el portal hace request, busca operator_id + role + scopes en lugar de validar API key.
Fase 2 — Portal (~3 días) - Nueva tab "Equipo" con lista de operators + acciones - Login screen rediseñado con "Sign in with bjungle" - Tenant switcher (multi-tenant per wallet)
Fase 3 — Migración (~1 semana) - Auto-bootstrap del owner desde tenants.contact_email - Email a founders: "Configurá tu passkey la próxima vez que entres" - Soporte legacy login (API key + email) durante 6 meses con warning + telemetryImplicaciones para otros ADRs
Sección titulada «Implicaciones para otros ADRs»- ADR-001 audiencias: este ADR es la materialización de “operador”. Wallet con scope al tenant, no a la vida personal.
- ADR-003 Sign-in with bjungle: el login
del portal es un consumidor del OIDC bridge. El tenant-portal es el
primer RP del ecosistema y su
client_idse hardcodea. - ADR-004 API keys: API keys YA NO se usan para humanos. La transición es progresiva (Fase 3 de cada uno).
- ADR-002 face-MFA: transfer of ownership + acciones admin pesadas activan face-MFA si la trust policy del tenant lo configura.
MVP sin T49 — modelo de seguridad transitorio
Sección titulada «MVP sin T49 — modelo de seguridad transitorio»Autenticación de operadores en el MVP (sin T49)
Sección titulada «Autenticación de operadores en el MVP (sin T49)»Mientras T49 (Sign-in with bjungle) no esté implementado, los endpoints de
mutación de operadores (invite, update, revoke) se invocan solo
vía API key con scope platform:operators:write. No existe todavía un
“operator session” resuelto por el tenancy middleware desde un
operator_session_token.
Implicaciones concretas (DJ-PT-50-07):
-
Super-admin de API key: cualquier API key del tenant que tenga scope
platform:operators:writepuede invitar, actualizar y revocar CUALQUIER operador del tenant, sin importar el rol del invocador. Los role-checks del service layer (ej. admin no puede revocar owner) SOLO se activan cuando el campoCallerOperatordel servicio es no-nil — lo cual no ocurre en esta fase. -
Defensa disponible: el scope
platform:operators:writeno es una key pública. Solo las keys que lo tienen explícitamente (o con wildcard*) pueden mutar operadores. La defensa viene del control de qué keys tienen ese scope. -
Auditoría explícita: cada mutación sin
CallerOperatordeja:- Un header de response
X-Operator-Auth-Mode: api-key-super-adminpara que el cliente sepa con qué path se autorizó. - Un campo
actor_mode: "api-key"en la metadata del audit log entry, para que compliance pueda distinguir qué tipo de actor realizó la mutación. - Un
slog.Warnen el service con"API key super-admin path"para que el equipo de ops sea alertado cuando esto ocurre en producción.
- Un header de response
-
Transfer of ownership: el endpoint está intencionalmente deshabilitado (501 Not Implemented) hasta que estén listos T49 + T52 (face-MFA + 24h cool-down). Esto previene que si T49 aterrizara antes que T52, el handler quedara habilitado sin el step-up requerido.
-
Accept con email: el endpoint
POST /v1/operator-invites/{token}/acceptrequiere el campoemailen el body. El service lo valida case-insensitive contra el email del invite. Esto protege contra un atacante que interceptó el token de invitación pero no conoce el email de destino — necesita AMBOS para poder aceptar con una wallet arbitraria. Cuando T49 aterrice, este check se reemplaza por la verificación deloperator_session_tokendel bmonkey OIDC callback.
Cuándo se cierra este gap
Sección titulada «Cuándo se cierra este gap»Esta fase termina cuando T49 (OIDC bridge + Sign-in with bjungle) aterrice y
el tenancy middleware resuelva el CallerOperator desde el
operator_session_token. En ese momento:
- El campo
CallerOperatoren los inputs del service pasa a ser non-nil. - Los role-checks se activan para requests de operadores humanos.
- Las API keys con
platform:operators:writesiguen siendo super-admin documentadas (para uso programático / scripts de administración). - El endpoint
transfer-ownershipse re-habilita (cuando ADEMÁS T52 face-MFA + 24h cool-down estén implementados).
Decisión
Sección titulada «Decisión»| Aspecto | Decisión |
|---|---|
| Status | Accepted (2026-05-30) |
| Aplicable a | platform-api · tenant-portal · bmonkey (wallets) |
| Roles canónicos | owner / admin / operator / support / readonly |
| Multi-tenant per wallet | sí — un wallet puede tener operator entries en varios tenants |
| Login obligatorio para operadores | ”Sign in with bjungle” (Fase 2); legacy API+email tolerated 6 meses |
| MVP sin T49 | API key con platform:operators:write actúa como super-admin; auditado vía actor_mode=api-key en audit log + header X-Operator-Auth-Mode |
| Transfer of ownership | Deshabilitado (501) hasta T49 + T52 face-MFA + 24h cool-down |
| Re-evaluar | cuando agreguemos delegaciones (Ana delega temporalmente a Mariana sin invitarla permanente) |
Fase 3 entregada (2026-05-30)
Sección titulada «Fase 3 entregada (2026-05-30)»F3.1 · Sign-in with bjungle end-to-end cierra el gap super-admin del MVP T50.
| Componente | Status | Notas |
|---|---|---|
operator_session_token HS256 minting | ✅ entregado | POST /v1/oidc/callback/operator-session reusa wallet session JWT como id_token (DEFERRED-F3.1.7: migrar a RS256 cuando JWKS verification ship). |
tenancy.MiddlewareWithOperatorSession | ✅ entregado | Acepta X-API-Key (super-admin programático) o Authorization: Bearer <operator_session_token> (human operator). Setea CallerOperator(ctx) no-nil para la segunda variante. |
Multi-tenant picker (select-tenant.tsx) | ✅ entregado | Cuando un wallet es operador en >1 tenant, callback responde 300 con available_tenants[]; portal pide segundo POST con requested_tenant_id. |
X-Operator-Auth-Mode response header | ✅ entregado | "api-key" vs "operator-session" — audit log y telemetría pueden distinguir. |
Audit log actor_mode | ✅ entregado | escrito en metadata.actor_mode de cada audit entry; tenancy.ActorMode(ctx) accessor. |
| E2E spec 14 (10 specs portal OIDC login) | ✅ entregado | cubre login completo + multi-tenant + token revocation. |
Migration 0020_tenant_operators + 0021_operator_session_hardening | ✅ entregado | (renumeradas desde 0016/0017 — ver commit log F3.1). |
Status final: Accepted.
DEFERRED a Wave 5:
transfer-ownershipstep-up flow — face-MFA (ADR-002 Patrón A) + 24h cool-down. Hoy el endpoint responde501 Not Implemented.- Delegaciones temporales (Ana delega a Mariana 7 días sin invitarla permanente).
- Migrar
id_tokena RS256 con JWKS — hoy F3.1 usa wallet session HS256 como id_token (interim).