8.9 KiB
Tenant Context
Objetivo
Documentar cómo se resuelve el contexto tenant actual del ERP mediante X-Company-Id y requireIdentityTenant(params).
Header tenant
Las rutas tenant-scoped deben recibir:
Authorization: Bearer <access_token>
X-Company-Id: <uuid-company>
X-Company-Id es el identificador de la company activa en la request.
No usar:
X-Company-SlugcompanySlugen tokenreq.user.companySlug
Middleware público
Para rutas tenant-scoped:
import { requireIdentityTenant } from "@erp/identity/api";
router.use(...requireIdentityTenant(params));
Para rutas solo autenticadas:
import { requireIdentityAuthenticated } from "@erp/identity/api";
router.use(...requireIdentityAuthenticated(params));
Validación real de requireIdentityTenant(params)
Actualmente ya valida:
- access token válido
- account autenticable
X-Company-IdpresenteX-Company-Idcon formato UUID válido- membership
activeenidentity_company_membershipso titularidad global de Super-administrador - company
activeencompanies - solo entonces rellena
req.user.companyId
Esto significa que hoy el contexto tenant garantiza:
(membership activa o Super-administrador activo)
+ company activa
La autorización granular se aplica después, mediante requirePermission(authorization, code).
El tenant por sí solo no concede permisos de operación.
Semántica esperada de errores
sin Authorization -> 401
token inválido -> 401
cuenta inexistente/no autenticable -> 401
sin X-Company-Id -> 403
X-Company-Id inválido -> 400
company inexistente -> 403
company disabled -> 403
membership inexistente en una cuenta ordinaria -> 403
membership disabled/invited en una cuenta ordinaria -> 403
membership active + company active -> entra al controller
Shape mínimo de req.user
{
userId: UniqueID;
email?: EmailAddress;
companyId?: UniqueID;
roles?: string[];
}
No añadir:
companySlugcompanypermissionsenriquecidoscompanyStatus
Módulos tenant-scoped migrados
Actualmente usan requireIdentityTenant(params):
modules/companiesparaGET /companies/current,PATCH /companies/currenty endpoints company-scoped legacymodules/catalogsmodules/customersmodules/customer-invoicesmodules/factuges
modules/suppliers también usa el contexto tenant canónico.
Qué no debe ir en el token
El access token no debe incluir:
companyIdcompanySlug- roles
- permisos
El contexto tenant se resuelve por request con X-Company-Id.
Caso actual: company settings
/settings/company/details opera siempre sobre la company activa.
Endpoints:
GET /companies/current
PATCH /companies/current
Política actual de lectura GET /companies/current: autenticación, membership y empresa activas.
No se añade un permiso a esta consulta usada por selección/configuración actual.
La edición PATCH /companies/current y PATCH /companies/:company_id, así como
POST /companies/:company_id/enable y /disable, requieren además
identity:companies:update. Antes bastaba el contexto empresarial.
GET /companies/:company_id requiere companies:companies:read. Antes bastaba el tenant.
El parámetro sigue comparándose con la empresa autenticada; el permiso de A no autoriza B.
Las operaciones globales de cuenta y consulta de empresas disponibles siguen autenticadas.
POST /companies exige companies:companies:create (SYSTEM); su transacción crea la empresa
y asigna al actor su administrador inicial con todos los COMPANY actuales.
Los POST de onboarding inicial exigen identity:system:initial-onboarding (SYSTEM).
La lectura de estado del onboarding sigue autenticada. No se introduce autorización granular
en todas las operaciones de todos los módulos de una vez.
Document context
Si un flujo documental necesita companySlug, debe resolverlo desde companies, por ejemplo:
companyId -> companies:general.finder.findById(...) -> slug
Autorización y registro de permisos
La raíz de cada módulo declara permissions: PermissionDefinition[] en su manifest backend.
El runtime compone y valida códigos, namespace y duplicados antes de cualquier setup.
Cada definición declara scope: "SYSTEM" | "COMPANY"; un scope ausente o inválido impide el setup.
Registrar un COMPANY amplía el rol reservado Administradores durante el mantenimiento de arranque
y en nuevas altas. Los demás roles conservan sus permisos explícitos. Identity no importa los módulos contribuyentes.
La raíz del consumidor resuelve identity:general y suministra authorization a sus rutas.
La API pública neutral es authorization.can({ accountId, companyId, permissionCode, transaction? }),
que devuelve Result<boolean, Error>. Código desconocido o no asignado deniega; fallos técnicos no conceden.
El guard requirePermission(authorization, code) se instala después de auth/tenant y solo toma
cuenta/empresa de req.user. Falta de autenticación devuelve 401; tenant/permiso ausente 403;
fallo técnico 500 con mensaje seguro. No depende de permisos frontend.
CompanyAdminRoleEnsurer obtiene todos los COMPANY del catálogo completo suministrado por el runtime.
Identifica el rol reservado por code = admin e isSystem = true, con nombre de alta Administradores.
Añade los códigos ausentes, conserva adicionales y fechas cuando no cambia nada, y nunca incorpora SYSTEM.
El nombre histórico Administrator puede conservarse en filas existentes: no identifica privilegios.
Los roles normales no se modifican. Poseer un código SYSTEM dentro de un JSON de rol empresarial tampoco
concede autorización global: canSystem solo consulta la capacidad global persistida.
El warmup obligatorio de Identity enumera únicamente roles admin reservados, los relee con bloqueo
y aplica la unión en una transacción por empresa. Se ejecuta tras preparar el esquema y antes de servir HTTP;
si falla o expira, impide arrancar, aunque los warmups opcionales no sean estrictos. Se puede repetir.
No hay escrituras al resolver permisos ni sincronización durante requests.
Super-administrador y bootstrap
Identity persiste una titularidad global en identity_system_owner, fila id = 1, vinculada a una cuenta activa.
No se deriva de email, nombre, fecha de alta ni rol empresarial. systemBootstrap.create(props) es la
entrada confiable de composición que crea la primera cuenta y reserva la titularidad en la misma transacción:
bloquea el singleton y rechaza un almacén que ya contiene cuentas. No existe un endpoint público de promoción.
El bootstrap recibe Value Objects, incluido el hash generado por el hasher de Identity; no recibe contraseñas por HTTP.
El usuario creado puede autenticarse y completar el onboarding inicial existente.
La cuenta global dispone de todos los permisos registrados según su scope, también los futuros.
authorization.canSystem({ accountId, permissionCode, transaction? }) exige SYSTEM y no requiere tenant.
authorization.can({ accountId, companyId, permissionCode, transaction? }) exige COMPANY y se invoca después
de validar el tenant; el permiso global no permite omitir X-Company-Id ni usar una empresa inexistente/inactiva.
El selector de empresas disponibles incluye todas las activas para el Super-administrador.
Las cuentas ordinarias reciben únicamente sus empresas con membership activa.
Los catálogos que usan requireIdentityTenantMembership también comprueban empresa activa: el host suministra
tenantCompanyFinder durante startModules, una vez construidos los módulos. Usa el finder público de Companies,
sin imports de su infraestructura ni dependencia circular de setup Catalogs → Companies → Catalogs.
Un host que no suministre ese puerto no puede iniciar esas rutas. El helper conserva su nombre por compatibilidad.
Bases existentes
Con DB_SYNC_MODE=none, aplicar expresamente modules/identity/sql/001-system-owner.sql antes de iniciar
esta versión. La DDL es idempotente y no promueve cuentas. En una base existente debe identificarse y
acreditarse la cuenta inicial antes de establecer expresamente su titularidad mediante un procedimiento
administrativo revisado. No se ha ejecutado esa asignación ni ninguna DDL sobre datos reales.
No se elige automáticamente al primer admin ni a la cuenta más antigua.
Los roles Administradores existentes sí reciben los COMPANY en el arranque, conservando sus permisos adicionales.
Patrón de composición (referencia real: companies.di.ts y companies.routes.ts):
// Composition root: resolver una sola vez y suministrar el contrato público al router.
const identity = params.getService<IIdentityPublicServices>("identity:general");
// Route, después de autenticación y tenant:
router.patch("/current", tenant, requirePermission(identity.authorization, permissionCode), handler);
No reconstruir resolver, roles o repositories de Identity desde otro módulo.