Skip to content

Multi-operator — empleados de un tenant con identidad propia

This content is not available in your language yet.

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.

HOY CON MULTI-OPERATOR
────────────────────────────────────────────────────────────────────
Ana: dj_live_anafintech_XXX Ana: wallet_id w_ana, role: owner
Mariana usa la misma key Mariana: wallet w_mariana, role: admin
Diego (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é)
-- 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;
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: *:read

Los 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).

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

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.

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.

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 true
WHERE 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.

  • Operadores compartiendo la cuenta del owner. Defeats el propósito. El portal detecta múltiples logins distintos del mismo wallet_id desde 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.
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 + telemetry
  • 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_id se 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):

  1. Super-admin de API key: cualquier API key del tenant que tenga scope platform:operators:write puede 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 campo CallerOperator del servicio es no-nil — lo cual no ocurre en esta fase.

  2. Defensa disponible: el scope platform:operators:write no 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.

  3. Auditoría explícita: cada mutación sin CallerOperator deja:

    • Un header de response X-Operator-Auth-Mode: api-key-super-admin para 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.Warn en el service con "API key super-admin path" para que el equipo de ops sea alertado cuando esto ocurre en producción.
  4. 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.

  5. Accept con email: el endpoint POST /v1/operator-invites/{token}/accept requiere el campo email en 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 del operator_session_token del bmonkey OIDC callback.

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 CallerOperator en 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:write siguen siendo super-admin documentadas (para uso programático / scripts de administración).
  • El endpoint transfer-ownership se re-habilita (cuando ADEMÁS T52 face-MFA + 24h cool-down estén implementados).
AspectoDecisión
StatusAccepted (2026-05-30)
Aplicable aplatform-api · tenant-portal · bmonkey (wallets)
Roles canónicosowner / admin / operator / support / readonly
Multi-tenant per walletsí — 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 T49API 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 ownershipDeshabilitado (501) hasta T49 + T52 face-MFA + 24h cool-down
Re-evaluarcuando agreguemos delegaciones (Ana delega temporalmente a Mariana sin invitarla permanente)

F3.1 · Sign-in with bjungle end-to-end cierra el gap super-admin del MVP T50.

ComponenteStatusNotas
operator_session_token HS256 minting✅ entregadoPOST /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✅ entregadoAcepta 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)✅ entregadoCuando 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✅ entregadoescrito en metadata.actor_mode de cada audit entry; tenancy.ActorMode(ctx) accessor.
E2E spec 14 (10 specs portal OIDC login)✅ entregadocubre 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-ownership step-up flow — face-MFA (ADR-002 Patrón A) + 24h cool-down. Hoy el endpoint responde 501 Not Implemented.
  • Delegaciones temporales (Ana delega a Mariana 7 días sin invitarla permanente).
  • Migrar id_token a RS256 con JWKS — hoy F3.1 usa wallet session HS256 como id_token (interim).