223 lines
8.9 KiB
Markdown
223 lines
8.9 KiB
Markdown
# 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:
|
|
|
|
```http
|
|
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:
|
|
|
|
```ts
|
|
import { requireIdentityTenant } from "@erp/identity/api";
|
|
|
|
router.use(...requireIdentityTenant(params));
|
|
```
|
|
|
|
Para rutas solo autenticadas:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```txt
|
|
(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
|
|
|
|
```txt
|
|
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`
|
|
|
|
```ts
|
|
{
|
|
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:
|
|
|
|
```http
|
|
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:
|
|
|
|
```txt
|
|
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`):
|
|
|
|
```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.
|