FactuGES_ERP/docs/architecture/tenant-context.md

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-Slug
  • companySlug en token
  • req.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:

  1. access token válido
  2. account autenticable
  3. X-Company-Id presente
  4. X-Company-Id con formato UUID válido
  5. membership active en identity_company_memberships o titularidad global de Super-administrador
  6. company active en companies
  7. 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:

  • companySlug
  • company
  • permissions enriquecidos
  • companyStatus

Módulos tenant-scoped migrados

Actualmente usan requireIdentityTenant(params):

  • modules/companies para GET /companies/current, PATCH /companies/current y endpoints company-scoped legacy
  • modules/catalogs
  • modules/customers
  • modules/customer-invoices
  • modules/factuges

modules/suppliers también usa el contexto tenant canónico.

Qué no debe ir en el token

El access token no debe incluir:

  • companyId
  • companySlug
  • 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.