metis-desk/docs/pertenencias-de-activos-a-dominios.md
2026-10-20 13:20:22 +02:00

292 lines
17 KiB
Markdown

# Pertenencias de activos a dominios · Paso 2.2
Assets registra el hecho explícito de que un activo pertenece a un dominio Windows.
El destino sigue siendo el UUID de un Resource con capacidad `directory:domain`,
validado contra la identidad estructurada del [paso 2.1](dominios-windows.md).
No se crea una entidad WindowsDomain ni se deducen relaciones de perfiles,
puestos, cuentas, IP, FQDN o sistemas operativos.
## Modelo, roles y compatibilidad
`AssetDirectoryMembership` es un AggregateRoot con identidad UniqueID UUID v7.
Mantiene compañía, activo, Resource de dominio y revisiones identificadas mediante
UUID v7. `DirectoryMembershipValues` contiene un solo rol y la fecha de finalización.
`DirectoryMembershipRevision` conserva valores, actor y momento del cambio.
Los roles son `member` y `domain-controller`. Un controlador ya es miembro: no se
crea otra fila para representar su pertenencia. Un dominio admite tantos miembros
y controladores como sea necesario, pero cada activo tiene como máximo una
pertenencia vigente, cualquiera que sea su rol o el dominio.
Se reutilizan clasificaciones estables existentes:
- Miembro: plantilla de serie `template:laptop`, `template:desktop`,
`template:server` o rol técnico explícito `server` de AssetInfrastructure.
- Controlador: plantilla `template:server` o rol técnico explícito `server` de la
última revisión de infraestructura.
- Impresoras, periféricos, almacenamiento o equipos de red sin clasificación de
servidor no se admiten. Una plantilla personalizada necesita una clasificación
técnica explícita de servidor para entrar en esta versión; no se interpreta su
nombre o icono. No se amplía el editor de plantillas en este paso.
Esto verifica compatibilidad documental con una función de servidor, no que el
sistema operativo real sea capaz de ejecutar AD. Un servidor Samba compatible
podría documentarse como controlador sin exigir una comparación de nombres de SO.
El operador sigue siendo responsable del hecho registrado; no hay sincronización.
Las altas, promociones y traslados necesitan un activo operativo y un dominio
activo. Se consideran operativos los estados técnicos actuales y los valores
heredados `available`/`in_use`, siguiendo su equivalencia existente. Los estados
`broken`, `maintenance` y `retired` impiden esas operaciones.
## Persistencia e integridad
Se añaden únicamente estas tablas de Assets:
| Tabla | Contenido y restricciones |
|---|---|
| `asset_directory_memberships` | UUID, compañía, activo, dominio, predecesora opcional, rol, creación, finalización y clave vigente. FK compuesta a `(companyId,id)` de assets y a `(resourceId,companyId)` de resource_directory_domains. |
| `asset_directory_membership_revisions` | UUID, pertenencia, posición, rol y finalización de esa revisión, actor y fecha. FK RESTRICT al registro y al usuario; UNIQUE `(membershipId,position)`. |
No se copia el DNS, nombre del dominio ni características del recurso. La FK al
registro estructurado de Resources exige que el destino sea realmente un dominio
y, transitivamente, que exista su Resource, plantilla semántica y contexto AD.
Se añade en la migración de Resources un índice único `(resourceId,companyId)`
para permitir esa FK compuesta. Es la única ampliación de su esquema en este paso.
`activeAssetId` es nullable y lleva el UUID del activo mientras `endedAt IS NULL`.
Un CHECK obliga a esa correspondencia y exige NULL cuando la pertenencia termina.
UNIQUE `activeAssetId` impide dos vínculos vigentes del mismo activo. No se puede
eludir la unicidad mandando NULL: el CHECK también se aplica a escrituras SQL.
Los registros históricos pueden compartir activo porque sus claves son NULL.
Se usa este patrón nullable ya conocido por el repositorio en lugar de una columna
generada: la expresión propuesta fue rechazada por la MariaDB local al combinarse
con el esquema de FK. No se desactivan restricciones ni modos estrictos para admitirla.
La documentación de [columnas generadas de MariaDB](https://mariadb.com/docs/server/reference/sql-statements/data-definition/create/generated-columns)
recoge sus restricciones; el patrón elegido evita depender de esa expresión.
Otros controles:
- CHECK de rol y periodo de vigencia válido.
- FK de predecesora a `(id,companyId,assetId)` de la misma tabla, con unicidad de
`predecessorId`: cada traslado tiene como máximo una sucesora de ese activo.
- Índices por compañía/activo y compañía/dominio/rol/vigencia para las consultas.
- FK e índices no utilizan nombres visibles ni listas de UUID/JSON.
- El mapper valida UUID, orden y continuidad de revisiones, fechas, cierre final y
correspondencia entre la última revisión, el rol y el estado de la raíz. Una
inconsistencia se trata como error de persistencia, nunca como historial vacío.
Las migraciones son explícitas y repetibles siguiendo el arranque actual. No se
usa `sync({ alter: true })`. Las revisiones se insertan y los cambios de raíz se
actualizan por UUID; no se usa upsert para resolver conflictos de unicidad.
## Comandos, idempotencia y concurrencia
El repositorio abre una transacción, bloquea compañía y activo y obtiene dentro de
ella la clasificación y las pertenencias. El servicio público de Resources lee y
bloquea el dominio en esa misma conexión. La política no hace comprobaciones de
disponibilidad fuera de la transacción.
| Comando | Semántica |
|---|---|
| `join` | Crea una pertenencia si no hay ninguna vigente. Si la vigente tiene exactamente dominio y rol solicitados, devuelve esa misma identidad sin revisión. Otro dominio o rol vigente produce `MEMBERSHIP_CONFLICT`. |
| `role` | Cambia el rol de la pertenencia indicada. Repetir el rol vigente no añade revisiones. Una pertenencia finalizada devuelve `MEMBERSHIP_ENDED`. Promover exige servidor operativo y dominio activo; retirar el rol permite corregir un vínculo aunque el activo o dominio estén inactivos. |
| `end` | Cierra la pertenencia indicada sin borrar extremos. Repetir devuelve el mismo registro cerrado sin revisión adicional. Una petición antigua no cierra otra pertenencia más reciente. |
| `transfer` | Exige el UUID de la pertenencia origen. Valida destino, finaliza origen y crea sucesora dentro del mismo commit. Pedir el mismo dominio exige utilizar el comando de rol, no un traslado. |
La predecesora conserva la idempotencia de `transfer`: un reintento con igual destino
y rol inicial devuelve la sucesora existente, incluso si después cambió o finalizó.
Otro destino o rol para esa misma transición produce conflicto. Dos solicitudes
concurrentes idénticas devuelven la misma sucesora; dos altas incompatibles para
el mismo activo se serializan y solo una obtiene una pertenencia vigente.
`join` expresa el estado deseado actual, no un identificador de operación indefinido:
tras finalizar una pertenencia, una nueva llamada a join crea un nuevo periodo.
Los cambios de rol son asignaciones del rol solicitado sobre la misma pertenencia;
los cambios opuestos concurrentes se aplican en el orden de serialización, sin
inventar una versión o ACL no presentes en el contrato.
En un traslado se persiste primero el cierre para liberar la clave vigente. Si la
sucesora o cualquier revisión falla, la transacción revierte también el cierre.
No hay dos peticiones HTTP ni un estado intermedio confirmado.
## Resources, composición y seguridad
El contrato público nuevo de Resources es `IDirectoryDomainReader.find`, con
`UniqueID` de compañía y Resource. Devuelve Maybe de `DirectoryDomainReference`:
identidad, compañía, capacidad `directory:domain` y estado. Un recurso ordinario,
inexistente o de otra compañía no se devuelve. No expone nombres ni resúmenes.
El composition root de Resources ofrece `services.directoryDomains(transaction)`.
El servidor lo inyecta en el módulo Assets. El puerto de aplicación no importa
Sequelize; solo Infrastructure y composición vinculan la transacción. Assets no
importa repositorios privados de Resources y Resources no depende de Assets.
Se conserva el grafo modular acíclico.
Los consumidores antiguos pueden construir Assets sin el participante: conservan
sus funciones existentes y no registran rutas de pertenencia. El runtime principal
lo inyecta expresamente y prepara su migración. No hay descubrimiento dinámico ni
proveedores ficticios.
Las rutas utilizan el middleware de identidad existente. En el runtime real se
conservan sesión activa, actor de sesión, origen y CSRF de los comandos. No se
aceptan actor, compañía adicional, privilegios o permisos en el body. Cada consulta
y comando queda limitado a la compañía de su ruta y a extremos realmente
pertenecientes a ella. No se presupone una ACL granular: las facultades son las que
ya proporciona el modelo actual de usuarios autenticados de Metis.
## API disponible
Base: `/api/asset-directory-memberships`.
| Método y ruta | Uso |
|---|---|
| `GET /:companyId/:assetId/current` | Pertenencia vigente o `membership: null`. Activo inexistente o de otra compañía devuelve 404. |
| `GET /:companyId` | Página de pertenencias; filtros opcionales `assetId`, `domainResourceId`, `role`, `includeEnded`, `pageSize`, `cursor`. |
| `POST /:companyId/:assetId` | Ejecuta uno de los cuatro comandos siguientes. |
```json
{ "action": "join", "domainResourceId": "<uuid>", "role": "member" }
```
```json
{ "action": "role", "membershipId": "<uuid>", "role": "domain-controller" }
```
```json
{ "action": "end", "membershipId": "<uuid>" }
```
```json
{ "action": "transfer", "membershipId": "<uuid-origen>", "domainResourceId": "<uuid-destino>", "role": "member" }
```
Los comandos devuelven 200 con `membership`, tanto al aplicar como al reconocer
un reintento. La consulta devuelve `memberships` y `nextCursor`; sin filtros usa
solo vigentes, hasta 50 registros por defecto y entre 1 y 100 por petición. El
cursor es el último UUID devuelto, con orden estable ascendente y filtros por
compañía aplicados en cada consulta. No concede acceso ni sustituye autenticación.
`includeEnded=true` incluye también las pertenencias cerradas; las revisiones de
rol y finalización están dentro del historial de cada pertenencia.
Ejemplos de consulta:
```text
?assetId=<uuid>&includeEnded=true
?domainResourceId=<uuid>&role=domain-controller
?domainResourceId=<uuid>&role=member&pageSize=25
```
Roles diferenciados: `role=member` devuelve miembros ordinarios, mientras que sin
filtro incluye también controladores. Los schemas Zod son estrictos. Los errores
funcionales usan los códigos `MEMBERSHIP_CONFLICT`, `MEMBERSHIP_ENDED`,
`MEMBERSHIP_NOT_FOUND`, `ASSET_INCOMPATIBLE`, `ASSET_INACTIVE`, `DOMAIN_INACTIVE`,
`DOMAIN_NOT_FOUND` y los errores de compañía/activo/entrada ya existentes. Entrada
inválida devuelve 400, ausencia 404 y conflicto funcional 409; sesión/origen/CSRF
mantienen las respuestas 401/403 del middleware existente. Los errores inesperados
siguen el manejador global, sin devolver SQL al cliente.
## Ciclo de vida y límites
Desactivar un dominio o retirar un activo no finaliza automáticamente los vínculos.
La pertenencia vigente es un hecho documental conservado que puede requerir revisión,
no una afirmación de disponibilidad. Se bloquean altas, promociones y traslados
incompatibles; se permite finalizar o retirar el rol. Cambiar la clasificación
posteriormente tampoco borra historial ni altera relaciones silenciosamente.
Un traslado puede abandonar un dominio inactivo si el nuevo destino está activo
y el activo vuelve a estar operativo.
El borrado físico de activos con pertenencias, incluso solo históricas, devuelve
`ASSET_IN_USE` mediante el guard de referencias existente, sin modificarlo. Las FK
protegen los dominios estructurados y sus recursos. No hay cascadas nuevas ni
comandos de borrado de pertenencias o revisiones. Una pertenencia finalizada deja
libre la clave vigente y permite registrar un nuevo periodo.
No se implementan UI, LDAP, descubrimiento, sincronización, cambios de Workstation,
WorkProfile, FolderMapping o relaciones entre activos.
## Preparación del paso 2.3
La definición estática `assets:directory-membership` usa los contratos de
`@metis/relationship-contracts`: origen `assets:asset`, destino `resources:resource`,
capacidad destino `directory:domain`, dirección dirigida, cardinalidad activa 1:N,
representación record, historial revisions y escritura owner-command. La definición
validada se consume en el schema y builder HTTP para el identificador de tipo.
Las etiquetas tienen claves es/en, sin añadir pantallas.
No se crea un registry global ni se implementan RelationshipQueryService o
EntitySummaryResolver. El paquete común se consume como dependencia workspace, sin
modificarlo. En el paso 2.3 se necesitarán adaptadores de lectura y resúmenes reales,
autorización de ambos extremos y composición explícita. Habrá que resolver también
la validación del namespace de capacidad `directory`: el registry v1 valida los
namespaces de capacidades como proveedores conocidos; aquí no se registra un
proveedor ficticio para eludir esa comprobación.
## Archivos principales
- Assets/common: `directory-memberships.ts`, `directory-membership-definition.ts`,
índice público y etiquetas en locales es/en.
- Assets/domain: `directory-membership.aggregate.ts`,
`directory-membership-revision.entity.ts`, `directory-membership-values.ts`,
`directory-membership-eligibility.ts`,
`value-objects/directory-membership-values.vo.ts` y pruebas de contrato/modelo.
- Assets/application: puerto de repositorio, plan de transiciones, casos de uso y
builder `directory-membership-*`.
- Assets/infrastructure: migración, mapper y repositorio `*directory-membership*`,
router HTTP y composition root del módulo.
- Resources: puerto y lector `directory-domain-reader.ts`, exportación services,
composición y un índice compuesto en la migración de identidad del dominio.
- Servidor: inyección en `identity-runtime.ts`, fixture e integración
`asset-directory-memberships.*` y script `test:directory-memberships`.
- Configuración: dependencia workspace de Assets, scripts de pruebas y lockfile.
## Verificación de la entrega
- Typecheck de Assets, Resources y servidor: correcto.
- Biome check y ESLint sobre los 30 ficheros de código/configuración de este paso:
correctos. `git diff --check`: correcto.
- Suite completa de Assets: **116/116** aprobadas, incluidas las siete nuevas
pruebas de pertenencias. Se ejecutó el listado configurado por el paquete con
`--test-isolation=none` para enumerar los casos reales en este entorno.
- Suite completa de Resources: **53/53** aprobadas con el mismo modo del runner.
- Integración MariaDB/HTTP: aprobada mediante
`pnpm --filter @metis/server test:directory-memberships`. Se añade también al
listado de integración del script test del servidor. Exige explícitamente
`NODE_ENV=test` y `DB_NAME=metis_desk_test` antes de preparar el esquema o fixtures.
- La integración prueba sesiones reales de la aplicación de prueba, usuario
habilitado, origen y CSRF con el middleware existente. Solo el endpoint efímero
de fixture emite una sesión preparada, sin crear una puerta de autenticación en
producción ni cambiar el administrador inicial de la base de pruebas.
- Comprueba varios controladores, rol incompatible, activo/dominio inexistentes,
recurso ordinario, compañía distinta, consultas e historial, paginación,
reintentos concurrentes de promoción/traslado y conflicto de altas concurrentes.
- Verifica SQL real: unicidad vigente, CHECK que rechaza NULL fraudulento, rol,
compañía y FK al dominio estructurado. Un trigger exclusivamente de fixture,
limitado a sus UUID y eliminado en finally, fuerza un fallo al insertar la
sucesora después de cerrar el origen; se comprueba rollback del traslado.
- Verifica conservación al retirar/desactivar, finalización repetida, borrado
bloqueado incluso cuando solo queda historial y nueva alta tras finalizar.
- Build del servidor: correcto. Grafo de 13 paquetes sin ciclos.
El primer arranque de integración reprodujo un bloqueo anterior en
`asset_definitions`: error 1118 al añadir icon pese a tener un esquema lógico
pequeño. Se resolvió reconstruyendo físicamente **solo esa tabla de
metis_desk_test** con `ALTER TABLE asset_definitions FORCE`, sin cambiar columnas
ni borrar registros. No se modificó la migración anterior ni una base de desarrollo
o producción. La integración completa pasó después de esa reparación del entorno.
La instalación offline del lockfile encontró metadatos npm ausentes de una
dependencia anterior (`@dotenvx/primitives`) durante la política de suministro.
El único cambio nuevo de dependencias es el enlace workspace a
`@metis/relationship-contracts`; su entrada del lockfile se ajustó explícitamente.
Las dependencias ya instaladas permitieron typecheck, importaciones, pruebas y
build. No se añade ninguna biblioteca externa ni se desactiva la política en la
configuración del repositorio.
No se ejecuta la suite global que limpia otros fixtures de la base de pruebas ni
la suite de Companies, cuyo código no se modifica en este paso. Los dos fallos de
textos de Companies documentados en el paso 2.1 no se corrigen ni se presentan
como comprobados de nuevo aquí. Los fallos transitorios de este paso se han
corregido y las verificaciones anteriores son sus resultados finales.