diff --git a/AGENTS.md b/AGENTS.md index 60fdd93..250ac87 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,7 @@ No imprimir, copiar ni versionar .env locales o secretos. - Documentación en /docs, con nombres de ficheros en castellano. - Comentarios que expliquen decisiones, riesgos o contratos, nunca que repitan el código. - Mantener es y en para textos de interfaz; no insertar literales en áreas con i18n. +- En las sentencia condicionales (if) Utiliza siempre llaves cuando el bloque contiene una sola intrucción. ## Disciplina diff --git a/README.md b/README.md index 29b79f4..75a2ad0 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ Aplicación interna para soporte técnico a pequeñas empresas. - [Usuarios y acceso](docs/usuarios-y-acceso.md) - [Compañías, contactos y sedes](docs/companias-contactos-y-sedes.md) - [Personas y departamentos](docs/personas-y-departamentos.md) +- [Cuentas, grupos y recursos](docs/cuentas-grupos-y-recursos.md) - [Componentes de interfaz](docs/componentes-de-interfaz.md) - [Alcance y siguientes entregas](docs/plan-de-desarrollo.md) -Incluye acceso, sesiones persistentes, gestión de usuarios y compañías clientes con personas, departamentos, asignaciones con historial y sedes. Inventario y tickets se incorporarán progresivamente. +Incluye acceso, sesiones persistentes, gestión de usuarios y compañías clientes con personas, departamentos, asignaciones con historial y sedes, junto con catálogos informáticos, cuentas, grupos, recursos, permisos y necesidades con cobertura calculada. Inventario y tickets se incorporarán progresivamente. diff --git a/apps/server/src/access.integration.ts b/apps/server/src/access.integration.ts new file mode 100644 index 0000000..4d5f914 --- /dev/null +++ b/apps/server/src/access.integration.ts @@ -0,0 +1,354 @@ +import assert from "node:assert/strict"; + +import type { AccessFieldsDTO, AccessItemDTO, CompanyDTO } from "@metis/companies/common"; +import { AccessResponseSchema } from "@metis/companies/common"; +export async function verifyAccess( + request: (path: string, body?: unknown, origin?: string, token?: string) => Promise, + company: CompanyDTO, + other: CompanyDTO +) { + const base = `/access/${company.id}`; + async function create(scope: string, values: AccessFieldsDTO) { + const response = await request(`/access/${scope}`, { values }); + assert.equal(response.status, 201, JSON.stringify(await response.clone().json())); + const result = AccessResponseSchema.parse(await response.json()); + const item = result.items.at(-1); + assert.ok(item); + assert.equal(item.values.kind, values.kind); + assert.equal(item.id[14], "7"); + assert.equal(item.history.length, 1); + return item; + } + async function update(item: AccessItemDTO, values: AccessFieldsDTO) { + const response = await request(`${base}/${item.id}/update`, { values }); + assert.equal(response.status, 200, JSON.stringify(await response.clone().json())); + return AccessResponseSchema.parse(await response.json()); + } + const system = await create("global", { kind: "system", name: "Directorio", active: true }); + const resourceType = await create("global", { + kind: "resource_type", + name: "Carpeta", + active: true, + }); + const permission = await create("global", { + kind: "permission", + name: "Modificar", + description: null, + resourceTypeId: resourceType.id, + active: true, + }); + const template = await create("global", { + kind: "template", + name: "Administración", + description: "Acceso a documentos", + systemId: system.id, + active: true, + }); + assert.equal( + (await request(base, { values: { kind: "system", name: " DIRECTORIO ", active: false } })) + .status, + 409 + ); + assert.equal( + ( + await request(base, { + values: { kind: "system", name: "Zoom", active: true, password: "secret" }, + }) + ).status, + 400 + ); + assert.equal( + ( + await request( + base, + { values: { kind: "system", name: "Zoom", active: true } }, + "https://wrong.example" + ) + ).status, + 403 + ); + assert.equal( + ( + await request( + base, + { values: { kind: "system", name: "Zoom", active: true } }, + undefined, + "wrong" + ) + ).status, + 403 + ); + await create(company.id, { kind: "system", name: "ERP propio", active: true }); + assert.equal( + ( + await request("/access/global", { + values: { kind: "system", name: "ERP PROPIO", active: true }, + }) + ).status, + 409 + ); + const environment = await create(company.id, { + kind: "environment", + name: "Oficina", + systemId: system.id, + active: true, + }); + const otherEnvironment = await create(other.id, { + kind: "environment", + name: "Oficina", + systemId: system.id, + active: true, + }); + const accountValues: AccessFieldsDTO = { + kind: "account", + systemId: system.id, + environmentId: environment.id, + login: "ana", + usage: "personal", + active: true, + expiresOn: null, + notes: null, + }; + const account = await create(company.id, accountValues); + assert.equal((await request(base, { values: accountValues })).status, 409); + assert.equal( + ( + await request(base, { + values: { + ...accountValues, + systemId: system.id.toUpperCase(), + environmentId: environment.id.toUpperCase(), + }, + }) + ).status, + 409 + ); + assert.equal( + ( + await request(base, { + values: { ...accountValues, login: "foreign", environmentId: otherEnvironment.id }, + }) + ).status, + 409 + ); + assert.equal( + ( + await request(`${base}/${account.id}/update`, { + values: { ...accountValues, usage: "shared" }, + }) + ).status, + 409 + ); + const renamed = { ...accountValues, login: "ana.garcia" }; + const changed = await update(account, renamed); + assert.equal(changed.items.find((item) => item.id === account.id)?.history.length, 2); + const repeated = await update(account, renamed); + assert.equal(repeated.items.find((item) => item.id === account.id)?.history.length, 2); + const person = company.people.find((item) => item.active); + assert.ok(person); + const relationValues: AccessFieldsDTO = { + kind: "account_person", + accountId: account.id, + personId: person.id, + startDate: "2026-01-01", + endDate: null, + configuration: "configured", + }; + const relation = await create(company.id, relationValues); + assert.equal((await request(base, { values: relationValues })).status, 409); + const foreignPerson = other.people[0]; + if (foreignPerson) { + assert.equal( + (await request(base, { values: { ...relationValues, personId: foreignPerson.id } })).status, + 409 + ); + } + const groupValues: AccessFieldsDTO = { + kind: "group", + name: "Administración", + identifier: "GG_ADMIN", + systemId: system.id, + environmentId: environment.id, + templateId: template.id, + description: "Documentos", + active: true, + }; + const group = await create(company.id, groupValues); + const membershipValues: AccessFieldsDTO = { + kind: "membership", + accountId: account.id, + groupId: group.id, + startDate: "2026-01-01", + endDate: null, + configuration: "configured", + }; + const membership = await create(company.id, membershipValues); + const resource = await create(company.id, { + kind: "resource", + name: "Documentos", + resourceTypeId: resourceType.id, + systemId: null, + environmentId: null, + reference: "\\\\NAS\\Administracion", + notes: null, + active: true, + }); + const grantValues: AccessFieldsDTO = { + kind: "grant", + resourceId: resource.id, + permissionId: permission.id, + accountId: null, + groupId: group.id, + startDate: "2026-01-01", + endDate: null, + configuration: "configured", + }; + const grant = await create(company.id, grantValues); + const needValues: AccessFieldsDTO = { + kind: "need", + resourceId: resource.id, + personId: person.id, + permissionIds: [permission.id], + startDate: "2026-01-01", + endDate: null, + notes: null, + }; + const need = await create(company.id, needValues); + let response = AccessResponseSchema.parse(await (await request(base)).json()); + assert.equal(response.coverage.find((item) => item.needId === need.id)?.covered, false); + assert.ok( + response.reviews.some((item) => item.itemId === grant.id && item.reason === "unjustified") + ); + await create(company.id, { + kind: "coverage", + needId: need.id, + grantId: grant.id, + accountPersonId: relation.id, + membershipId: membership.id, + startDate: "2026-01-01", + endDate: null, + }); + response = AccessResponseSchema.parse(await (await request(base)).json()); + assert.equal(response.coverage.find((item) => item.needId === need.id)?.covered, true); + assert.equal( + response.reviews.some((item) => item.itemId === grant.id && item.reason === "unjustified"), + false + ); + const awaitingConfiguration = await update(relation, { + ...relationValues, + configuration: "pending", + }); + assert.equal( + awaitingConfiguration.coverage.find((item) => item.needId === need.id)?.covered, + false + ); + assert.equal( + awaitingConfiguration.reviews.some( + (item) => item.itemId === grant.id && item.reason === "unjustified" + ), + false + ); + await update(relation, relationValues); + const readPermission = await create("global", { + kind: "permission", + name: "Lectura", + description: null, + resourceTypeId: resourceType.id, + active: true, + }); + const differentNeed = await update(need, { ...needValues, permissionIds: [readPermission.id] }); + assert.equal(differentNeed.coverage.find((item) => item.needId === need.id)?.covered, false); + assert.ok( + differentNeed.reviews.some((item) => item.itemId === grant.id && item.reason === "unjustified") + ); + await update(need, needValues); + const closed = await update(membership, { + ...membershipValues, + endDate: "2026-10-03", + configuration: "removal_pending", + }); + assert.equal(closed.coverage.find((item) => item.needId === need.id)?.covered, false); + assert.ok( + closed.reviews.some( + (item) => item.itemId === membership.id && item.reason === "pending_removal" + ) + ); + const removed = await update(membership, { + ...membershipValues, + endDate: "2026-10-03", + configuration: "removed", + }); + assert.equal( + removed.reviews.some( + (item) => item.itemId === membership.id && item.reason === "pending_removal" + ), + false + ); + assert.equal(removed.items.find((item) => item.id === membership.id)?.history.length, 3); + assert.equal( + removed.items + .find((item) => item.id === membership.id) + ?.history.every((item) => Boolean(item.actorId)), + true + ); + const expired = await update(account, { ...renamed, expiresOn: "2000-01-01" }); + assert.ok( + expired.reviews.some((item) => item.itemId === account.id && item.reason === "expired") + ); + assert.equal( + (await request(base, { values: { ...membershipValues, startDate: "2026-10-03" } })).status, + 409 + ); + const technical = await create(company.id, { + ...accountValues, + login: "svc-backup", + usage: "technical", + active: false, + expiresOn: "2000-01-01", + }); + await create(company.id, { ...relationValues, accountId: technical.id, configuration: null }); + const otherTechnical = await create(company.id, { + ...accountValues, + login: "svc-clean", + usage: "technical", + active: true, + }); + assert.equal( + ( + await request(base, { + values: { ...relationValues, accountId: otherTechnical.id, configuration: "configured" }, + }) + ).status, + 400 + ); + const shared = await create(company.id, { ...accountValues, login: "shared", usage: "shared" }); + const sharedResponse = AccessResponseSchema.parse(await (await request(base)).json()); + assert.ok( + sharedResponse.reviews.some((item) => item.itemId === shared.id && item.reason === "incomplete") + ); + const concurrent = await Promise.all([ + request(base, { values: { ...accountValues, login: "concurrent" } }), + request(base, { values: { ...accountValues, login: "concurrent" } }), + ]); + assert.deepEqual(concurrent.map((item) => item.status).sort(), [201, 409]); + const otherScope = AccessResponseSchema.parse( + await (await request(`/access/${other.id}`)).json() + ); + assert.equal( + otherScope.items.some((item) => item.id === account.id), + false + ); + assert.equal( + otherScope.items.some((item) => item.id === system.id), + true + ); + const global = AccessResponseSchema.parse(await (await request("/access/global")).json()); + assert.equal( + global.items.every((item) => item.companyId === null), + true + ); + assert.equal( + (await request(`/access/${other.id}/${account.id}/update`, { values: renamed })).status, + 404 + ); +} diff --git a/apps/server/src/identity.test.ts b/apps/server/src/identity.test.ts index b2504b1..c2ddce3 100644 --- a/apps/server/src/identity.test.ts +++ b/apps/server/src/identity.test.ts @@ -16,6 +16,7 @@ import { } from "@metis/identity/common"; import { QueryTypes } from "sequelize"; +import { verifyAccess } from "./access.integration"; import { createApp } from "./app"; import { createDatabase, readDatabaseEnvironment } from "./config/database"; import { createIdentityRuntime } from "./infrastructure/http/identity-runtime"; @@ -31,6 +32,11 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de await once(server, "listening"); await database.query("UPDATE identity_state SET initialAdminId = NULL WHERE id = 1"); await database.query("DELETE FROM http_sessions"); + await database.query("DELETE FROM access_person_references"); + await database.query("DELETE FROM access_references"); + await database.query("DELETE FROM access_revisions"); + await database.query("DELETE FROM access_items"); + await database.query("DELETE FROM access_workspaces WHERE companyId IS NOT NULL"); await database.query("DELETE FROM users"); await database.query("DELETE FROM department_assignment_revisions"); await database.query("DELETE FROM person_department_assignments"); @@ -657,7 +663,20 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de ); assert.equal(Number(userCount[0]?.total), 2); + const accessCompany = CompanyResponseSchema.parse( + await (await operator.request(peoplePath)).json() + ).company; + const accessOther = CompanyResponseSchema.parse( + await (await operator.request(`/companies/${secondCompany.id}`)).json() + ).company; + await verifyAccess((...args) => operator.request(...args), accessCompany, accessOther); + const countAfterAccess = await database.query<{ total: number }>( + "SELECT COUNT(*) AS total FROM users", + { type: QueryTypes.SELECT } + ); + assert.equal(Number(countAfterAccess[0]?.total), 2); assert.equal((await operator.request("/auth/logout", {})).status, 200); + await expectError(await operator.request("/access/global"), 401, "UNAUTHENTICATED"); assert.equal((await operator.session()).user, null); await expectError(await operator.request("/users"), 401, "UNAUTHENTICATED"); await expectError(await operator.request("/companies"), 401, "UNAUTHENTICATED"); diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index 918a788..b7f25a8 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -1,4 +1,4 @@ -import { CompaniesPage } from "@metis/companies/client"; +import { AccessWorkspacePage, CompaniesPage } from "@metis/companies/client"; import { HomePage } from "@metis/core/client"; import { IdentityWorkspace } from "@metis/identity/client"; import { @@ -18,6 +18,7 @@ const LANGUAGE_OPTIONS = [ export function App() { const { t: companyTranslation } = useTranslation("companies"); + const { t: accessTranslation } = useTranslation("access"); const { t, i18n } = useTranslation("core"); const [languageError, setLanguageError] = useState(false); const languageLabelId = useId(); @@ -68,6 +69,13 @@ export function App() { ( + + ), + }, { id: "companies", label: companyTranslation("title"), diff --git a/apps/web/src/i18n.ts b/apps/web/src/i18n.ts index 1bfa02e..a2ae81f 100644 --- a/apps/web/src/i18n.ts +++ b/apps/web/src/i18n.ts @@ -1,4 +1,9 @@ -import { companiesEnglish, companiesSpanish } from "@metis/companies/client"; +import { + accessEnglish, + accessSpanish, + companiesEnglish, + companiesSpanish, +} from "@metis/companies/client"; import { coreEnglish, coreSpanish } from "@metis/core/client"; import { identityEnglish, identitySpanish } from "@metis/identity/client"; import { createInstance } from "i18next"; @@ -11,8 +16,18 @@ export function createAppI18n(language = "es") { supportedLngs: ["es", "en"], defaultNS: "core", resources: { - es: { core: coreSpanish, identity: identitySpanish, companies: companiesSpanish }, - en: { core: coreEnglish, identity: identityEnglish, companies: companiesEnglish }, + es: { + core: coreSpanish, + identity: identitySpanish, + companies: companiesSpanish, + access: accessSpanish, + }, + en: { + core: coreEnglish, + identity: identityEnglish, + companies: companiesEnglish, + access: accessEnglish, + }, }, interpolation: { escapeValue: false }, }); diff --git a/docs/arquitectura/modelo-ddd-e-identificadores.md b/docs/arquitectura/modelo-ddd-e-identificadores.md index 64e8972..c605443 100644 --- a/docs/arquitectura/modelo-ddd-e-identificadores.md +++ b/docs/arquitectura/modelo-ddd-e-identificadores.md @@ -43,6 +43,8 @@ User es la raíz del agregado de identidad. Sus Value Objects son UniqueID, Name Company es la raíz del agregado de compañías clientes. Person, Department, DepartmentAssignment y Site son entidades internas con UniqueID propio y referencia UniqueID a su compañía. AssignmentRevision conserva cada versión de una asignación y tiene su propio UniqueID. Los contactos son personas con isContact=true. Name, EmailAddress, PhoneNumber, CalendarDate y SiteAddress representan sus valores; los campos opcionales usan Maybe. Las reglas de pertenencia, periodos y departamento principal se resuelven dentro de Company. El repositorio carga y guarda el agregado completo dentro de una transacción con bloqueo de la compañía. +AccessWorkspace es una raíz adicional para el catálogo global o la configuración informática de una compañía. Cada espacio conserva su propio UUID. AccessItem y AccessRevision son entidades con UUID propio. AccessValues encapsula la definición tipada e inmutable, con identificadores y fechas reconstruidos como UniqueID y CalendarDate. Las referencias relacionales preservan las dependencias y el historial conserva el actor autenticado. Consultar [Cuentas, grupos y recursos](../cuentas-grupos-y-recursos.md). + Los módulos de documentos, tickets y assets todavía no existen. Se declararán como agregados o entidades al implementar sus operaciones y reglas de consistencia. La decisión de pertenencia a un agregado se toma por sus invariantes y transacciones; no convierte automáticamente cada tabla en una raíz ni fuerza todo el cliente y sus tickets dentro de un solo agregado. Cada entidad que se incorpore deberá tener UniqueID desde su primera implementación, mapper de persistencia y pruebas de identidad, invariantes, rehidratación y actualización. diff --git a/docs/componentes-de-interfaz.md b/docs/componentes-de-interfaz.md index 0204da4..a63b4af 100644 --- a/docs/componentes-de-interfaz.md +++ b/docs/componentes-de-interfaz.md @@ -8,7 +8,7 @@ El código se genera desde el registro oficial y queda versionado en este reposi - Button: volver a comprobar la conexión. - Card: bloques de información y estado. -- Select: cambio entre castellano e inglés, con navegación por teclado. +- Select: idioma y selección de sistemas, entornos, cuentas, personas y demás relaciones, con navegación por teclado. - Input y Label: credenciales y datos de usuario. - Checkbox: estado activo de usuarios, compañías, personas y departamentos. - Table: listados de usuarios, compañías, personas y departamentos. @@ -17,7 +17,7 @@ El código se genera desde el registro oficial y queda versionado en este reposi ## Convención de navegación -Los listados presentan datos, búsqueda y acciones. El botón de alta se coloca en la cabecera, a la derecha. Los formularios sencillos se abren en un modal; los complejos o con muchos campos se presentan en una página propia. No mostrar formularios junto al listado. Las ediciones se abren mediante una acción explícita. Compañías, personas, departamentos, asignaciones, sedes y usuarios siguen este criterio. Las personas se crean y editan en una página propia; los departamentos y las asignaciones usan modales. El historial se consulta en un diálogo. +Los listados presentan datos, búsqueda y acciones. El botón de alta se coloca en la cabecera, a la derecha. Los formularios sencillos se abren en un modal; los complejos o con muchos campos se presentan en una página propia. No mostrar formularios junto al listado. Las ediciones se abren mediante una acción explícita. Compañías, personas, departamentos, asignaciones, sedes y usuarios siguen este criterio. Las personas se crean y editan en una página propia; los departamentos y las asignaciones usan modales. El historial se consulta en un diálogo. Catálogos, cuentas, grupos, recursos, permisos y necesidades siguen la misma convención. Los permisos de una necesidad se eligen mediante casillas shadcn/ui. TanStack Table filtra el listado de compañías por nombre; React Hook Form gestiona los formularios con los contratos Zod compartidos. diff --git a/docs/cuentas-grupos-y-recursos.md b/docs/cuentas-grupos-y-recursos.md new file mode 100644 index 0000000..bdcc277 --- /dev/null +++ b/docs/cuentas-grupos-y-recursos.md @@ -0,0 +1,145 @@ +# Cuentas, grupos, recursos y necesidades + +## Dónde se gestionan + +- Catálogos informáticos, en la navegación principal: sistemas, tipos de recurso, permisos disponibles y plantillas de grupos comunes. +- Compañías → ficha → Cuentas y accesos: cuentas, relaciones con personas, grupos, miembros, recursos, permisos concedidos, necesidades, cobertura y entornos. También permite añadir sistemas, tipos y permisos propios de la compañía. +- Los botones de alta están a la derecha de la cabecera. Los formularios sencillos se abren en modal; los extensos sustituyen el listado por una página de formulario. Edición e historial son acciones explícitas. + +Los catálogos comienzan vacíos. Definir primero los sistemas comunes que se utilicen, como Dominio, Correo, Microsoft 365 o ERP. Las altas posteriores seleccionan esas definiciones; no se crean datos de ejemplo en la base de trabajo. + +## Orden práctico + +1. Definir sistemas, tipos de recurso y permisos en los catálogos globales. Las compañías pueden añadir elementos propios sin sustituir los globales. +2. Crear entornos de la compañía cuando haga falta distinguir dominios o instalaciones del mismo sistema. +3. Registrar cuentas y vincular sus titulares, personas autorizadas o responsables. +4. Crear grupos, opcionalmente desde una plantilla global, y registrar pertenencias de cuentas. +5. Registrar recursos y los permisos concedidos directamente a cuentas o mediante grupos. +6. Registrar las necesidades de las personas y vincular las vías que las cubren. + +Una persona de la compañía no obtiene acceso a Metis Desk. Estos registros documentan sus cuentas en otros sistemas; no crean cuentas externas ni cambian permisos en esos sistemas. + +## Catálogos y entornos + +Los sistemas, tipos de recurso y permisos pueden ser globales o propios de una compañía. El selector combina ambos ámbitos. Los nombres no se duplican en la lista visible, ignorando mayúsculas y espacios exteriores. Los permisos se distinguen por su tipo de recurso. + +Los entornos pertenecen a una compañía y un sistema. Su elección es opcional. Sus nombres son únicos dentro de esa combinación. Un entorno no se traslada a otro sistema. + +Desactivar una definición impide seleccionarla en nuevas altas, pero conserva los registros existentes. Los registros afectados se señalan para revisar. No hay borrado físico ni desactivación en cascada. + +## Cuentas + +Datos: compañía, sistema, entorno opcional, identificador de acceso, uso personal/compartido/técnico, estado activo, caducidad opcional y observaciones. + +Compañía, sistema, entorno y uso quedan fijos. El identificador, estado, caducidad y observaciones pueden cambiar, conservando UUID e historial. + +La combinación compañía + sistema + entorno + identificador es única, incluyendo cuentas inactivas. El identificador distingue mayúsculas y minúsculas; se eliminan los espacios exteriores. + +- Personal: como máximo un titular simultáneo. Puede registrarse sin titular y aparecerá pendiente de completar. +- Compartida: varias personas autorizadas. Sin ninguna relación abierta aparece pendiente de completar. +- Técnica: responsable opcional y único simultáneamente. La responsabilidad no concede acceso. + +Los cambios de titular, personas autorizadas y responsables conservan periodos independientes. Los periodos incompatibles no se solapan. El nuevo titular puede registrarse aunque quede pendiente retirar el acceso anterior. + +Las nuevas relaciones de acceso requieren una cuenta activa y no caducada. Una responsabilidad técnica puede registrarse sobre una cuenta inactiva o caducada. Las personas deben estar activas y pertenecer a la misma compañía. + +Una fecha vencida no desactiva automáticamente la cuenta: genera un aviso si sigue registrada como activa. La caducidad es válida hasta el final de la fecha indicada. El cálculo de fecha de referencia utiliza UTC, igual que los timestamps del servidor. + +Las contraseñas y secretos no forman parte de los campos de las cuentas. + +## Grupos y plantillas + +Cada grupo pertenece a una compañía, sistema y entorno opcional. Tiene nombre visible, identificador real en el sistema, descripción opcional y estado activo. Compañía, sistema y entorno no cambian tras el alta. + +El identificador es único dentro de su compañía, sistema y entorno, incluso estando inactivo. Distingue mayúsculas y minúsculas como los identificadores de cuentas. + +Las plantillas globales proponen nombre, descripción y sistema opcional. Al elegir una plantilla se copian esos valores y se permite personalizarlos. Cambiar la plantilla no modifica grupos existentes. Una plantilla no contiene miembros ni permisos. + +Los miembros son cuentas del mismo sistema y entorno. No hay grupos anidados. Los grupos pueden estar vacíos. Desactivar un grupo conserva sus pertenencias y señala las abiertas para revisar. + +## Periodos y configuración + +Cada relación tiene UUID, fecha de inicio y fecha de fin opcional. Sin fecha de fin está abierta; con fecha de fin está cerrada. Las fechas documentan periodos: no hay un planificador de activaciones o cierres futuros. Un periodo puede terminar el mismo día que comienza el siguiente. + +Las autorizaciones de personas, pertenencias a grupos y permisos concedidos distinguen: + +| Estado de configuración | Significado | +| --- | --- | +| Pendiente de configurar | Autorización registrada; falta realizar la configuración | +| Configurada | Se ha registrado que la configuración está realizada | +| Pendiente de retirar | Autorización cerrada; falta eliminar el acceso | +| Retirada | Se ha confirmado su eliminación | +| Cerrada sin configurar | Terminó antes de llegar a configurarse | + +Cerrar una relación configurada pasa automáticamente a pendiente de retirar. La confirmación de retirada se registra después, editando la relación. Cerrar una relación pendiente la deja cerrada sin configurar. Un alta puede indicar que el acceso ya estaba configurado. + +No se permite marcar retirada una relación abierta. Las responsabilidades técnicas no llevan estado de configuración. No hay duplicados abiertos ni periodos solapados para la misma relación; titulares y responsables únicos tampoco se solapan entre personas diferentes. + +Cada cambio registra una revisión con UUID, fecha y usuario de Metis Desk. La interfaz muestra el nombre del usuario. Las revisiones anteriores se conservan y repetir una edición semánticamente idéntica no añade historial. + +## Recursos y permisos + +Cada recurso tiene nombre, tipo, estado, ubicación o referencia opcional, sistema y entorno opcionales y observaciones. Puede existir antes de inventariar el activo que lo aloja. Su nombre es único por compañía, tipo, sistema y entorno. + +La ubicación o referencia puede cambiar manteniendo el UUID. Tipo, sistema y entorno identifican el contexto del recurso y quedan fijos. + +Los permisos disponibles se definen por tipo: lectura/modificación para carpetas, lectura/enviar como para buzones, etc. Son configurables globalmente y ampliables por compañía. + +Cada autorización concede un permiso sobre un recurso a una cuenta o a un grupo, nunca a ambos en el mismo registro. Puede haber varios permisos distintos para el mismo destinatario. Solo admite permisos del tipo correspondiente. La compañía debe coincidir, aunque el sistema del recurso y el de la cuenta puedan ser distintos: por ejemplo, un NAS autenticado mediante el directorio corporativo. + +No hay equivalencias entre permisos, herencia ni denegaciones. Administración no incluye Lectura automáticamente. Registrar un acceso no demuestra que exista en el sistema externo; la configuración se confirma manualmente. + +## Necesidades y cobertura + +Una necesidad relaciona una persona con un recurso y uno o varios permisos necesarios. Puede registrarse antes de conocer la cuenta que se utilizará. Conserva periodo, observaciones e historial. + +Las vías de cobertura vinculan: + +- Necesidad. +- Permiso concedido. +- Relación de la persona con su cuenta. +- Pertenencia al grupo, cuando el permiso se concede a través de un grupo. + +El servidor comprueba que la persona, cuenta, recurso, permiso y grupo corresponden. Una responsabilidad técnica no sirve como relación de acceso. + +La cobertura se calcula a partir de los vínculos registrados. Para estar cubierta, una necesidad abierta debe tener todos sus permisos cubiertos por vías autorizadas y configuradas. La persona, cuenta, recurso, permiso y grupo deben seguir habilitados; la cuenta no debe estar caducada. Una pertenencia retirada o cerrada deja de cubrirla. + +Se admiten varias vías y la pérdida de una no deja la necesidad pendiente si otra cubre el mismo permiso. Los vínculos anteriores permanecen consultables. La lista indica qué permisos faltan. + +Cerrar una necesidad no retira automáticamente accesos. Un permiso puede seguir sirviendo a otra necesidad o a otros miembros de un grupo. Una necesidad vinculada y autorizada justifica el permiso aunque su configuración esté pendiente. Los accesos sin una necesidad vigente que los justifique se señalan para revisar, sin calificarlos automáticamente como indebidos. + +## Avisos + +Se calculan al consultar los datos: cuentas incompletas o caducadas, personas inactivas con relaciones abiertas, destinos inactivos, catálogos desactivados, configuraciones y retiradas pendientes, necesidades sin cubrir y accesos sin necesidad cubierta. + +Por ahora los avisos no generan tareas ni actúan sobre sistemas externos. Reactivar un elemento no restaura configuraciones ni relaciones cerradas. + +## Arquitectura y persistencia + +El módulo companies mantiene las capas de dominio, aplicación e infraestructura. AccessWorkspace es una raíz independiente para el catálogo global o la configuración informática de una compañía. Cada espacio tiene UUID propio. AccessItem representa una entidad tipada por su definición; AccessRevision es una entidad de historial. Ambas tienen UUID v7 para nuevas identidades. + +AccessValues es un valor compuesto inmutable, con UUID, Name y CalendarDate reconstruidos como Value Objects y ausencia mediante Maybe. La definición discriminada y sus schemas viven en el dominio; los contratos HTTP comunes exponen esos valores sin importar ORM. Result representa fallos esperados. + +MariaDB almacena: +- access_workspaces: identidad del agregado y ámbito de compañía. +- access_items: entidades con definición tipada serializada en JSON. +- access_revisions: versiones, secuencia, actor y timestamp. +- access_references y access_person_references: referencias relacionales para impedir huérfanos y conservar las dependencias. + +Los JSON se validan y reconstruyen al cargar; no son bolsas de campos libres. No se aceptan claves desconocidas. La última revisión debe corresponder con el estado actual. Las revisiones se añaden, no se sobrescriben. + +Las migraciones son explícitas e idempotentes. Los cambios se realizan dentro de transacciones, bloqueando primero el catálogo global y después la compañía. Esta serialización evita altas concurrentes duplicadas y colisiones entre catálogos globales y locales. Las pertenencias y reglas entre ámbitos se validan en el dominio. + +La gestión usa GET /api/access/global o /api/access/:companyId. Las altas usan POST en esas rutas con {values}; las ediciones POST /api/access/:scope/:id/update. Todas requieren sesión válida. Las escrituras requieren origen y CSRF válidos. El actor se obtiene de la sesión, nunca del cuerpo enviado por el navegador. + +## Validación + +42 pruebas pasan en el proyecto, incluyendo reglas de dominio, contratos, renderizado de interfaz y HTTP con MariaDB. La integración usa exclusivamente metis_desk_test y comprueba aislamiento, UUID, duplicados, campos inmutables, historial, idempotencia, caducidad, responsabilidades técnicas, concurrencia y pérdida de cobertura al retirar una pertenencia. + +Se ha comprobado en navegador el catálogo global, el selector combinado global/local, el alta de cuenta con entorno y caducidad, el vínculo con persona, su cierre automático como retirada pendiente, el historial con actor y las casillas de permisos necesarios. + +Pasan typecheck, lint, test, check y build. La compilación conserva el aviso previo sobre el tamaño del paquete frontend. + +## Pendiente para futuras entregas + +Configuración por ordenador o móvil, POP/IMAP, teletrabajo y acceso remoto, inventario y modelos de activos, procesos y plantillas de alta/cambio/salida, relación con licencias e integraciones con sistemas externos. diff --git a/docs/personas-y-departamentos.md b/docs/personas-y-departamentos.md index 771564b..d9cb973 100644 --- a/docs/personas-y-departamentos.md +++ b/docs/personas-y-departamentos.md @@ -56,4 +56,6 @@ Las pertenencias cruzadas se rechazan. El departamento de una asignación existe Implementado: personas, clasificación como contacto, departamentos, puestos específicos, departamento principal, periodos e historial de asignaciones. -Pendiente: cuentas informáticas, grupos, recursos, autorizaciones de acceso, teletrabajo, dispositivos personales, procesos y plantillas, inventario de activos. Los datos de estas funciones no se crean todavía. +Cuentas informáticas, grupos, recursos, autorizaciones y necesidades se gestionan en Cuentas y accesos de la compañía. No implican acceso de estas personas a Metis Desk. + +Pendiente: configuración por dispositivos, teletrabajo, procesos y plantillas e inventario de activos. Los datos de estas funciones no se crean todavía. diff --git a/docs/plan-de-desarrollo.md b/docs/plan-de-desarrollo.md index 7e8f37d..8e70838 100644 --- a/docs/plan-de-desarrollo.md +++ b/docs/plan-de-desarrollo.md @@ -31,16 +31,18 @@ No crear un sistema multiempresa complejo sin necesidad, companyId identifica a Personas sin acceso a Metis Desk, contactos como clasificación, puesto general y pertenencia a varios departamentos con puestos específicos, un principal, periodos e historial. Datos existentes e identificadores preservados. Consultar [Personas y departamentos](personas-y-departamentos.md). +## Cuentas, grupos y recursos: implementado + +Catálogos globales con adiciones por compañía, entornos, cuentas con uso fijo y caducidad opcional, relaciones con personas, plantillas globales de grupos, pertenencias sin anidación, recursos y permisos configurables, necesidades y cobertura calculada. Las autorizaciones y configuraciones se registran por separado, con historial, actor y avisos de revisión. Consultar [Cuentas, grupos y recursos](cuentas-grupos-y-recursos.md). + ## Siguientes entregas: orden propuesto -1. Cuentas informáticas, grupos sin grupos anidados y recursos de la compañía. Separar personas, cuentas y usuarios autenticados. -2. Perfil de configuración de cada persona: correos, carpetas compartidas, impresoras y demás recursos autorizados. -3. Teletrabajo y accesos remotos: autorización, equipo de origen, conectividad VPN y destino. La VPN no concede permisos sobre todos los recursos. Asistencia técnica remota independiente. -4. Configuraciones por dispositivo, incluidos móviles corporativos y personales con correo POP/IMAP. Dispositivos personales con identificación mínima, sin inventario técnico detallado. -5. Procesos de alta, cambio y salida, con seguimiento de retiradas pendientes y acceso que permanece en dispositivos personales. Plantillas reutilizables entre compañías; los datos y ejecuciones pertenecen a cada compañía. -6. Modelos de activos, inventario, relaciones entre activos y conjuntos de puesto de trabajo. +1. Perfil de configuración por dispositivo: correos con POP/IMAP, carpetas compartidas, impresoras y recursos. +2. Teletrabajo y accesos remotos: autorización, equipo de origen, conectividad VPN y destino. Asistencia técnica remota independiente. +3. Procesos de alta, cambio y salida con seguimiento de configuraciones y retiradas. Plantillas reutilizables entre compañías; ejecuciones y datos propios. +4. Modelos de activos, inventario, relaciones y conjuntos de puesto de trabajo. -Este orden se concreta antes de cada entrega. Las plantillas con acciones automáticas requieren los recursos y relaciones sobre los que actuar. +Este orden se concreta antes de cada entrega. Las licencias y su relación con cuentas se explorarán posteriormente. ## Inventario: pendiente diff --git a/modules/companies/package.json b/modules/companies/package.json index 8ec9698..9a0c4ed 100644 --- a/modules/companies/package.json +++ b/modules/companies/package.json @@ -10,7 +10,7 @@ }, "scripts": { "typecheck": "tsc -p tsconfig.json", - "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/company.aggregate.test.ts src/api/domain/people.test.ts src/web/components/companies-page.test.tsx" + "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/company.aggregate.test.ts src/api/domain/people.test.ts src/web/components/companies-page.test.tsx src/api/domain/access/access.test.ts src/web/access/access.test.tsx" }, "dependencies": { "@repo/rdx-ddd": "workspace:*", diff --git a/modules/companies/src/api/application/access-response.builder.ts b/modules/companies/src/api/application/access-response.builder.ts new file mode 100644 index 0000000..bfeaf5a --- /dev/null +++ b/modules/companies/src/api/application/access-response.builder.ts @@ -0,0 +1,22 @@ +import type { AccessResponseDTO } from "../../common/dto/shared/access.dto"; +import { projectAccess } from "../domain/access/access-projection"; + +import type { AccessSnapshot } from "./access.repository"; +export function buildAccessResponse(snapshot: AccessSnapshot): AccessResponseDTO { + const items = [...snapshot.context.catalog, ...snapshot.workspace.items]; + return { + items: items.map((item) => ({ + id: item.id.toPrimitive(), + companyId: item.companyId.getOrUndefined()?.toPrimitive() ?? null, + values: item.values.data, + history: item.history.map((revision) => ({ + id: revision.id.toPrimitive(), + actorId: revision.actorId.toPrimitive(), + actorName: snapshot.actors.get(revision.actorId.toPrimitive()) ?? "—", + recordedAt: revision.recordedAt, + values: revision.values.data, + })), + })), + ...projectAccess(items, snapshot.context.people, snapshot.context.today), + }; +} diff --git a/modules/companies/src/api/application/access.repository.ts b/modules/companies/src/api/application/access.repository.ts new file mode 100644 index 0000000..1353604 --- /dev/null +++ b/modules/companies/src/api/application/access.repository.ts @@ -0,0 +1,17 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import type { Maybe, Result } from "@repo/rdx-utils"; + +import type { AccessError } from "../domain/access/access-error"; +import type { AccessContext, AccessWorkspace } from "../domain/access/access-workspace.aggregate"; +export type AccessSnapshot = { + workspace: AccessWorkspace; + actors: ReadonlyMap; + context: AccessContext; +}; +export interface IAccessRepository { + get(companyId: Maybe): Promise>; + update( + companyId: Maybe, + action: (snapshot: AccessSnapshot) => Result + ): Promise>; +} diff --git a/modules/companies/src/api/application/access.use-cases.ts b/modules/companies/src/api/application/access.use-cases.ts new file mode 100644 index 0000000..e41aad3 --- /dev/null +++ b/modules/companies/src/api/application/access.use-cases.ts @@ -0,0 +1,46 @@ +import { UniqueID } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import type { AccessFieldsDTO } from "../../common/dto/shared/access.dto"; +import { AccessError } from "../domain/access/access-error"; +import { AccessItem } from "../domain/access/access-item.entity"; +import { AccessValues } from "../domain/access/access-values"; + +import type { IAccessRepository } from "./access.repository"; +export class AccessUseCases { + constructor(private readonly repository: IAccessRepository) {} + private scope(value: string) { + if (value === "global") { + return Result.ok(Maybe.none()); + } + const id = UniqueID.create(value); + return id.isSuccess + ? Result.ok(Maybe.some(id.data)) + : Result.fail(new AccessError("INVALID_REQUEST")); + } + async get(scope: string) { + const parsed = this.scope(scope); + return parsed.isSuccess ? this.repository.get(parsed.data) : parsed; + } + async save(scope: string, id: string | undefined, dto: AccessFieldsDTO, actorId: string) { + const parent = this.scope(scope); + const actor = UniqueID.create(actorId); + const values = AccessValues.create(dto); + const identity = id === undefined ? undefined : UniqueID.create(id); + if ( + !(parent.isSuccess && actor.isSuccess && values.isSuccess) || + (identity && !identity.isSuccess) + ) { + return Result.fail(new AccessError("INVALID_REQUEST")); + } + const audit = { actorId: actor.data, recordedAt: new Date().toISOString() }; + return this.repository.update(parent.data, (snapshot) => + identity?.isSuccess + ? snapshot.workspace.update(identity.data, values.data, audit, snapshot.context) + : snapshot.workspace.save( + AccessItem.create(parent.data, values.data, audit), + snapshot.context + ) + ); + } +} diff --git a/modules/companies/src/api/domain/access/access-definition.ts b/modules/companies/src/api/domain/access/access-definition.ts new file mode 100644 index 0000000..fb633f2 --- /dev/null +++ b/modules/companies/src/api/domain/access/access-definition.ts @@ -0,0 +1,117 @@ +import { z } from "zod/v4"; + +const id = z.uuid(); +const optionalId = id.nullable(); +const name = z.string().trim().min(1).max(100); +const note = z.string().trim().max(4000).nullable(); +const optionalText = z.string().trim().min(1).max(255).nullable(); +export const ConfigurationSchema = z.enum([ + "pending", + "configured", + "removal_pending", + "removed", + "never_configured", +]); +const period = { startDate: z.iso.date(), endDate: z.iso.date().nullable() }; +const scope = { systemId: id, environmentId: optionalId }; +const named = { name, active: z.boolean() }; +const definitions = [ + z.strictObject({ kind: z.literal("system"), ...named }), + z.strictObject({ kind: z.literal("resource_type"), ...named }), + z.strictObject({ + kind: z.literal("permission"), + ...named, + resourceTypeId: id, + description: note, + }), + z.strictObject({ + kind: z.literal("template"), + ...named, + description: note, + systemId: optionalId, + }), + z.strictObject({ kind: z.literal("environment"), ...named, systemId: id }), + z.strictObject({ + kind: z.literal("account"), + ...scope, + login: z.string().trim().min(1).max(255), + usage: z.enum(["personal", "shared", "technical"]), + active: z.boolean(), + expiresOn: z.iso.date().nullable(), + notes: note, + }), + z.strictObject({ + kind: z.literal("account_person"), + accountId: id, + personId: id, + ...period, + configuration: ConfigurationSchema.nullable(), + }), + z.strictObject({ + kind: z.literal("group"), + ...scope, + ...named, + identifier: z.string().trim().min(1).max(255), + description: note, + templateId: optionalId, + }), + z.strictObject({ + kind: z.literal("membership"), + accountId: id, + groupId: id, + ...period, + configuration: ConfigurationSchema, + }), + z.strictObject({ + kind: z.literal("resource"), + ...named, + resourceTypeId: id, + systemId: optionalId, + environmentId: optionalId, + reference: optionalText, + notes: note, + }), + z.strictObject({ + kind: z.literal("grant"), + resourceId: id, + permissionId: id, + accountId: optionalId, + groupId: optionalId, + ...period, + configuration: ConfigurationSchema, + }), + z.strictObject({ + kind: z.literal("need"), + personId: id, + resourceId: id, + permissionIds: z.array(id).min(1).max(50), + ...period, + notes: note, + }), + z.strictObject({ + kind: z.literal("coverage"), + needId: id, + grantId: id, + accountPersonId: id, + membershipId: optionalId, + ...period, + }), +] as const; +export const AccessDefinitionSchema = z.discriminatedUnion("kind", definitions); +export type AccessDefinition = z.infer; +export type AccessKind = AccessDefinition["kind"]; +export const AccessKindSchema = z.enum([ + "system", + "resource_type", + "permission", + "template", + "environment", + "account", + "account_person", + "group", + "membership", + "resource", + "grant", + "need", + "coverage", +]); diff --git a/modules/companies/src/api/domain/access/access-error.ts b/modules/companies/src/api/domain/access/access-error.ts new file mode 100644 index 0000000..153216d --- /dev/null +++ b/modules/companies/src/api/domain/access/access-error.ts @@ -0,0 +1,14 @@ +export class AccessError extends Error { + constructor( + public readonly code: + | "INVALID_REQUEST" + | "ACCESS_NOT_FOUND" + | "ACCESS_CONFLICT" + | "INACTIVE_TARGET" + | "IMMUTABLE_FIELDS" + | "SCOPE_MISMATCH" + | "COMPANY_NOT_FOUND" + ) { + super(code); + } +} diff --git a/modules/companies/src/api/domain/access/access-item.entity.ts b/modules/companies/src/api/domain/access/access-item.entity.ts new file mode 100644 index 0000000..53b660d --- /dev/null +++ b/modules/companies/src/api/domain/access/access-item.entity.ts @@ -0,0 +1,115 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import { DomainEntity } from "@repo/rdx-ddd"; +import type { Maybe } from "@repo/rdx-utils"; +import { Result } from "@repo/rdx-utils"; + +import type { AccessDefinition } from "./access-definition"; +import { AccessError } from "./access-error"; +import { AccessValues } from "./access-values"; + +export type AuditContext = Readonly<{ actorId: UniqueID; recordedAt: string }>; +export class AccessRevision extends DomainEntity { + static create(values: AccessValues, audit: AuditContext) { + return new AccessRevision({ ...audit, values }); + } + static rehydrate(values: AccessValues, audit: AuditContext, id: UniqueID) { + return new AccessRevision({ ...audit, values }, id); + } + get values() { + return this.props.values; + } + get actorId() { + return this.props.actorId; + } + get recordedAt() { + return this.props.recordedAt; + } +} +type ItemProps = { + companyId: Maybe; + values: AccessValues; + history: readonly AccessRevision[]; +}; +export class AccessItem extends DomainEntity { + protected constructor(props: ItemProps, id?: UniqueID) { + super({ ...props, history: Object.freeze([...props.history]) }, id); + } + static create(companyId: Maybe, values: AccessValues, audit: AuditContext) { + return new AccessItem({ companyId, values, history: [AccessRevision.create(values, audit)] }); + } + static rehydrate(props: ItemProps, id: UniqueID) { + return new AccessItem(props, id); + } + get companyId() { + return this.props.companyId; + } + get values() { + return this.props.values; + } + get history() { + return this.props.history; + } + update(values: AccessValues, audit: AuditContext): Result { + const before = this.values.data; + const adjusted = closeConfiguration(values.data, before); + if (!adjusted.isSuccess) { + return adjusted; + } + const next = adjusted.data; + const after = next.data; + const fields: Record = { + account: ["kind", "systemId", "environmentId", "usage"], + group: ["kind", "systemId", "environmentId", "templateId"], + environment: ["kind", "systemId"], + permission: ["kind", "resourceTypeId"], + resource: ["kind", "resourceTypeId", "systemId", "environmentId"], + account_person: ["kind", "accountId", "personId"], + membership: ["kind", "accountId", "groupId"], + grant: ["kind", "resourceId", "permissionId", "accountId", "groupId"], + need: ["kind", "resourceId", "personId"], + coverage: ["kind", "needId", "grantId", "accountPersonId", "membershipId"], + system: ["kind"], + resource_type: ["kind"], + template: ["kind"], + }; + const fixed = fields[before.kind]; + if ( + fixed.some( + (key) => + Object.entries(before).find(([name]) => name === key)?.[1] !== + Object.entries(after).find(([name]) => name === key)?.[1] + ) + ) { + return Result.fail(new AccessError("IMMUTABLE_FIELDS")); + } + if (this.values.equals(next)) { + return Result.ok(this); + } + return Result.ok( + new AccessItem( + { + ...this.props, + values: next, + history: [...this.history, AccessRevision.create(next, audit)], + }, + this.id + ) + ); + } +} + +function closeConfiguration(value: AccessDefinition, before: AccessDefinition) { + if (!("configuration" in value && "endDate" in before)) { + return AccessValues.create(value); + } + if (before.endDate !== null || value.endDate === null || !("configuration" in before)) { + return AccessValues.create(value); + } + if (before.configuration === "configured") { + return AccessValues.create({ ...value, configuration: "removal_pending" }); + } + if (before.configuration === "pending") { + return AccessValues.create({ ...value, configuration: "never_configured" }); + } + return AccessValues.create(value); +} diff --git a/modules/companies/src/api/domain/access/access-policy.ts b/modules/companies/src/api/domain/access/access-policy.ts new file mode 100644 index 0000000..2b89606 --- /dev/null +++ b/modules/companies/src/api/domain/access/access-policy.ts @@ -0,0 +1,357 @@ +import { UniqueID } from "@repo/rdx-ddd"; +import type { Maybe } from "@repo/rdx-utils"; +import { Result } from "@repo/rdx-utils"; + +import type { AccessDefinition, AccessKind } from "./access-definition"; +import { AccessError } from "./access-error"; +import type { AccessItem } from "./access-item.entity"; +import type { AccessContext } from "./access-workspace.aggregate"; + +function _open(item: AccessItem) { + return "endDate" in item.values.data && item.values.data.endDate === null; +} +function active(item: AccessItem, today: string) { + const values = item.values.data; + return !( + ("active" in values && !values.active) || + (values.kind === "account" && values.expiresOn !== null && values.expiresOn < today) + ); +} +function overlaps(left: AccessItem, right: AccessItem) { + const a = left.values.data; + const b = right.values.data; + return ( + "startDate" in a && + "startDate" in b && + ((a.endDate === null && b.endDate === null) || + ((b.endDate === null || a.startDate < b.endDate) && + (a.endDate === null || b.startDate < a.endDate))) + ); +} + +export class AccessPolicy { + constructor( + public readonly companyId: Maybe, + public readonly items: readonly AccessItem[], + public readonly context: AccessContext, + public readonly isNewOrReopen: boolean + ) {} + find = (id: string | null, kind: string) => + this.items.find((item) => { + const parsed = id === null ? undefined : UniqueID.create(id); + return parsed?.isSuccess && item.id.equals(parsed.data) && item.values.data.kind === kind; + }); + usable = (item: AccessItem | undefined) => + item && + (!item.companyId.getOrUndefined() || + item.companyId.getOrUndefined()?.equals(this.companyId.getOrUndefined())); + ensure = (item: AccessItem | undefined) => + Boolean( + this.usable(item) && (!this.isNewOrReopen || (item && active(item, this.context.today))) + ); + person = (id: string) => + this.context.people.find( + (item) => + item.id.toPrimitive() === id && item.companyId.equals(this.companyId.getOrUndefined()) + ); +} +export function checkAccountPerson( + value: Extract, + policy: AccessPolicy +) { + const { find, usable, person, isNewOrReopen, context } = policy; + + const account = find(value.accountId, "account"); + const target = person(value.personId); + if (!usable(account) || account?.values.data.kind !== "account" || !target) { + return Result.fail(new AccessError("SCOPE_MISMATCH")); + } + if ( + isNewOrReopen && + (!target.active || + (account.values.data.usage !== "technical" && !active(account, context.today))) + ) { + return Result.fail(new AccessError("INACTIVE_TARGET")); + } + if ((account.values.data.usage === "technical") !== (value.configuration === null)) { + return Result.fail(new AccessError("INVALID_REQUEST")); + } +} + +export function checkMembership( + value: Extract, + policy: AccessPolicy +) { + const { find, ensure } = policy; + + const account = find(value.accountId, "account"); + const group = find(value.groupId, "group"); + if ( + !(ensure(account) && ensure(group)) || + account?.values.data.kind !== "account" || + group?.values.data.kind !== "group" + ) { + return Result.fail(new AccessError("INACTIVE_TARGET")); + } + if ( + account.values.data.systemId !== group.values.data.systemId || + account.values.data.environmentId !== group.values.data.environmentId + ) { + return Result.fail(new AccessError("SCOPE_MISMATCH")); + } +} + +export function checkGrant( + value: Extract, + policy: AccessPolicy +) { + const { find, ensure } = policy; + + const resource = find(value.resourceId, "resource"); + const permission = find(value.permissionId, "permission"); + const target = value.accountId ? find(value.accountId, "account") : find(value.groupId, "group"); + if (Boolean(value.accountId) === Boolean(value.groupId)) { + return Result.fail(new AccessError("INVALID_REQUEST")); + } + if ( + !(ensure(resource) && ensure(permission) && ensure(target)) || + resource?.values.data.kind !== "resource" || + permission?.values.data.kind !== "permission" + ) { + return Result.fail(new AccessError("INACTIVE_TARGET")); + } + if (resource.values.data.resourceTypeId !== permission.values.data.resourceTypeId) { + return Result.fail(new AccessError("SCOPE_MISMATCH")); + } + // El sistema del recurso puede diferir del que autentica la cuenta: p. ej. NAS con directorio corporativo. +} + +export function checkNeed( + value: Extract, + policy: AccessPolicy +) { + const { find, ensure, person, isNewOrReopen } = policy; + + const resource = find(value.resourceId, "resource"); + const target = person(value.personId); + if ( + !target || + (isNewOrReopen && !target.active) || + !ensure(resource) || + resource?.values.data.kind !== "resource" + ) { + return Result.fail(new AccessError("INACTIVE_TARGET")); + } + for (const permissionId of value.permissionIds) { + const permission = find(permissionId, "permission"); + if ( + !ensure(permission) || + permission?.values.data.kind !== "permission" || + permission.values.data.resourceTypeId !== resource.values.data.resourceTypeId + ) { + return Result.fail(new AccessError("SCOPE_MISMATCH")); + } + } +} + +export function checkCoverage( + value: Extract, + policy: AccessPolicy +) { + const { find, usable } = policy; + + const need = find(value.needId, "need"); + const grant = find(value.grantId, "grant"); + const relation = find(value.accountPersonId, "account_person"); + const membership = + value.membershipId === null ? undefined : find(value.membershipId, "membership"); + if ( + !(usable(need) && usable(grant) && usable(relation)) || + need?.values.data.kind !== "need" || + grant?.values.data.kind !== "grant" || + relation?.values.data.kind !== "account_person" + ) { + return Result.fail(new AccessError("SCOPE_MISMATCH")); + } + const account = find(relation.values.data.accountId, "account"); + if ( + account?.values.data.kind !== "account" || + account.values.data.usage === "technical" || + relation.values.data.personId !== need.values.data.personId || + grant.values.data.resourceId !== need.values.data.resourceId || + !need.values.data.permissionIds.includes(grant.values.data.permissionId) + ) { + return Result.fail(new AccessError("SCOPE_MISMATCH")); + } + if (grant.values.data.accountId !== null) { + if (grant.values.data.accountId !== relation.values.data.accountId || membership) { + return Result.fail(new AccessError("SCOPE_MISMATCH")); + } + } else if ( + !usable(membership) || + membership?.values.data.kind !== "membership" || + membership.values.data.accountId !== relation.values.data.accountId || + membership.values.data.groupId !== grant.values.data.groupId + ) { + return Result.fail(new AccessError("SCOPE_MISMATCH")); + } +} + +export function checkConfiguration(value: AccessDefinition, previous: AccessItem | undefined) { + if (!("configuration" in value) || value.configuration === null) { + return; + } + + if (!("endDate" in value)) { + return Result.fail(new AccessError("INVALID_REQUEST")); + } + if ( + value.endDate === null && + ["removed", "removal_pending", "never_configured"].includes(value.configuration) + ) { + return Result.fail(new AccessError("INVALID_REQUEST")); + } + if (value.endDate !== null && ["configured", "pending"].includes(value.configuration)) { + return Result.fail(new AccessError("INVALID_REQUEST")); + } + return checkConfigurationTransition(value, previous); +} +function checkConfigurationTransition( + value: Extract, + previous: AccessItem | undefined +) { + const before = previous?.values.data; + if (before && "configuration" in before) { + if ( + before.configuration === "configured" && + value.endDate !== null && + value.configuration !== "removal_pending" + ) { + return Result.fail(new AccessError("INVALID_REQUEST")); + } + if ( + before.configuration === "pending" && + value.endDate !== null && + value.configuration !== "never_configured" + ) { + return Result.fail(new AccessError("INVALID_REQUEST")); + } + if ( + before.configuration === "removal_pending" && + !["removal_pending", "removed"].some((state) => state === value.configuration) + ) { + return Result.fail(new AccessError("INVALID_REQUEST")); + } + } +} + +export function checkCatalog(value: AccessDefinition, policy: AccessPolicy) { + const { find, ensure } = policy; + if ("systemId" in value && value.systemId !== null && !ensure(find(value.systemId, "system"))) { + return Result.fail(new AccessError("INACTIVE_TARGET")); + } + if ("environmentId" in value && value.environmentId !== null) { + const environment = find(value.environmentId, "environment"); + if ( + !ensure(environment) || + environment?.values.data.kind !== "environment" || + !("systemId" in value) || + environment.values.data.systemId !== value.systemId + ) { + return Result.fail(new AccessError("SCOPE_MISMATCH")); + } + } + if ("resourceTypeId" in value && !ensure(find(value.resourceTypeId, "resource_type"))) { + return Result.fail(new AccessError("INACTIVE_TARGET")); + } + if ( + value.kind === "group" && + value.templateId !== null && + !ensure(find(value.templateId, "template")) + ) { + return Result.fail(new AccessError("INACTIVE_TARGET")); + } +} + +export function checkRelations(value: AccessDefinition, policy: AccessPolicy) { + switch (value.kind) { + case "account_person": + return checkAccountPerson(value, policy); + case "membership": + return checkMembership(value, policy); + case "grant": + return checkGrant(value, policy); + case "need": + return checkNeed(value, policy); + case "coverage": + return checkCoverage(value, policy); + default: + return undefined; + } +} +const keys: Record = { + system: ["name"], + resource_type: ["name"], + permission: ["resourceTypeId", "name"], + template: ["name"], + environment: ["systemId", "name"], + account: ["systemId", "environmentId", "login"], + group: ["systemId", "environmentId", "identifier"], + resource: ["resourceTypeId", "systemId", "environmentId", "name"], + account_person: ["accountId", "personId"], + membership: ["accountId", "groupId"], + grant: ["resourceId", "accountId", "groupId", "permissionId"], + need: ["personId", "resourceId"], + coverage: ["needId", "grantId", "accountPersonId", "membershipId"], +}; +function equalFields(a: AccessDefinition, b: AccessDefinition, fields: readonly string[]) { + const get = (value: AccessDefinition, key: string) => { + const field = Object.entries(value).find(([name]) => name === key)?.[1]; + return key === "name" && typeof field === "string" + ? field.trim().normalize("NFC").toLowerCase() + : field; + }; + return fields.every((key) => get(a, key) === get(b, key)); +} +function accountRelationConflict( + candidate: AccessItem, + other: AccessItem, + items: readonly AccessItem[] +) { + const value = candidate.values.data; + const b = other.values.data; + if ( + value.kind !== "account_person" || + b.kind !== "account_person" || + value.accountId !== b.accountId + ) { + return false; + } + const account = items.find((item) => item.id.toPrimitive() === value.accountId)?.values.data; + const exclusive = account?.kind === "account" && account.usage !== "shared"; + return (exclusive || value.personId === b.personId) && overlaps(candidate, other); +} +export function accessConflict( + candidate: AccessItem, + other: AccessItem, + items: readonly AccessItem[] +) { + const a = candidate.values.data; + const b = other.values.data; + if (candidate.id.equals(other.id) || a.kind !== b.kind) { + return false; + } + const left = candidate.companyId.getOrUndefined()?.toPrimitive() ?? null; + const right = other.companyId.getOrUndefined()?.toPrimitive() ?? null; + const catalog = ["system", "resource_type", "permission", "template"].includes(a.kind); + if (left !== right && !(catalog && (left === null || right === null))) { + return false; + } + if (a.kind === "account_person") { + return accountRelationConflict(candidate, other, items); + } + if (!equalFields(a, b, keys[a.kind])) { + return false; + } + return "startDate" in a ? overlaps(candidate, other) : true; +} diff --git a/modules/companies/src/api/domain/access/access-projection.ts b/modules/companies/src/api/domain/access/access-projection.ts new file mode 100644 index 0000000..38d8ad5 --- /dev/null +++ b/modules/companies/src/api/domain/access/access-projection.ts @@ -0,0 +1,262 @@ +import type { Person } from "../person.entity"; + +import type { AccessDefinition } from "./access-definition"; +import type { AccessItem } from "./access-item.entity"; + +type Reason = + | "incomplete" + | "expired" + | "inactive_person" + | "inactive_target" + | "unjustified" + | "uncovered" + | "pending_configuration" + | "pending_removal" + | "inactive_catalog"; +type Coverage = { needId: string; covered: boolean; missingPermissionIds: string[] }; +class AccessProjection { + private readonly entries: ReadonlyMap; + constructor( + private readonly items: readonly AccessItem[], + private readonly people: readonly Person[], + private readonly today: string + ) { + this.entries = new Map(items.map((item) => [item.id.toPrimitive(), item])); + } + data = (id: string) => this.entries.get(id)?.values.data; + isOpen = (id: string) => { + const value = this.data(id); + return value && "endDate" in value && value.endDate === null; + }; + configured = (id: string) => { + const value = this.data(id); + return ( + this.isOpen(id) && value && "configuration" in value && value.configuration === "configured" + ); + }; + isActive = (id: string) => { + const value = this.data(id); + return ( + value && + (!("active" in value) || value.active) && + (value.kind !== "account" || value.expiresOn === null || value.expiresOn >= this.today) + ); + }; + personActive = (id: string) => + this.people.some((person) => person.id.toPrimitive() === id && person.active); + private accountCovered(relation: Extract) { + return this.personActive(relation.personId) && this.isActive(relation.accountId); + } + private membershipAuthorized(id: string | null) { + if (id === null) { + return true; + } + const membership = this.data(id); + return ( + membership?.kind === "membership" && this.isOpen(id) && this.isActive(membership.groupId) + ); + } + private permissionNeeded(needId: string, permissionId: string) { + const need = this.data(needId); + return need?.kind === "need" && need.permissionIds.includes(permissionId); + } + authorizedLink = (item: AccessItem) => { + const value = item.values.data; + if (value.kind !== "coverage" || value.endDate !== null || !this.isOpen(value.needId)) { + return false; + } + const relation = this.data(value.accountPersonId); + const grant = this.data(value.grantId); + if (relation?.kind !== "account_person" || grant?.kind !== "grant") { + return false; + } + if (!(this.isOpen(value.accountPersonId) && this.isOpen(value.grantId))) { + return false; + } + return ( + this.accountCovered(relation) && + this.isActive(grant.resourceId) && + this.isActive(grant.permissionId) && + this.permissionNeeded(value.needId, grant.permissionId) && + this.membershipAuthorized(value.membershipId) + ); + }; + validLink = (item: AccessItem) => { + const value = item.values.data; + if (value.kind !== "coverage" || !this.authorizedLink(item)) { + return false; + } + return ( + this.configured(value.accountPersonId) && + this.configured(value.grantId) && + (value.membershipId === null || this.configured(value.membershipId)) + ); + }; + needCoverage(item: AccessItem): Coverage[] { + const value = item.values.data; + if (value.kind !== "need") { + return []; + } + const links = this.items.filter( + (link) => + link.values.data.kind === "coverage" && + link.values.data.needId === item.id.toPrimitive() && + this.validLink(link) + ); + const coveredPermissions = links.flatMap((link) => { + if (link.values.data.kind !== "coverage") { + return []; + } + const grant = this.data(link.values.data.grantId); + return grant?.kind === "grant" ? [grant.permissionId] : []; + }); + const missingPermissionIds = value.permissionIds.filter( + (id) => !coveredPermissions.includes(id) + ); + return [ + { + needId: item.id.toPrimitive(), + covered: value.endDate === null && missingPermissionIds.length === 0, + missingPermissionIds, + }, + ]; + } + private accountReview( + item: AccessItem, + value: Extract + ): Reason[] { + const reasons: Reason[] = []; + if (value.active && value.expiresOn !== null && value.expiresOn < this.today) { + reasons.push("expired"); + } + if ( + value.usage !== "technical" && + !this.items.some( + (relation) => + relation.values.data.kind === "account_person" && + relation.values.data.accountId === item.id.toPrimitive() && + relation.values.data.endDate === null + ) + ) { + reasons.push("incomplete"); + } + return reasons; + } + private personRelationReview( + value: Extract + ): Reason[] { + if (value.endDate !== null) { + return []; + } + const reasons: Reason[] = []; + if (!this.personActive(value.personId)) { + reasons.push("inactive_person"); + } + if (!this.isActive(value.accountId)) { + reasons.push("inactive_target"); + } + return reasons; + } + private grantReview( + item: AccessItem, + value: Extract + ): Reason[] { + if (value.endDate !== null) { + return []; + } + const reasons: Reason[] = []; + if ( + !( + this.isActive(value.resourceId) && + this.isActive(value.permissionId) && + this.isActive(value.accountId ?? value.groupId ?? "") + ) + ) { + reasons.push("inactive_target"); + } + if ( + !this.items.some( + (link) => + link.values.data.kind === "coverage" && + link.values.data.grantId === item.id.toPrimitive() && + this.authorizedLink(link) + ) + ) { + reasons.push("unjustified"); + } + return reasons; + } + private needReview( + item: AccessItem, + value: Extract, + coverage: readonly Coverage[] + ): Reason[] { + if (value.endDate !== null) { + return []; + } + const reasons: Reason[] = []; + if (!this.personActive(value.personId)) { + reasons.push("inactive_person"); + } + if (!coverage.find((result) => result.needId === item.id.toPrimitive())?.covered) { + reasons.push("uncovered"); + } + return reasons; + } + private specificReview(item: AccessItem, coverage: readonly Coverage[]): Reason[] { + const value = item.values.data; + switch (value.kind) { + case "account": + return this.accountReview(item, value); + case "account_person": + return this.personRelationReview(value); + case "grant": + return this.grantReview(item, value); + case "need": + return this.needReview(item, value, coverage); + case "membership": + return value.endDate === null && + !(this.isActive(value.accountId) && this.isActive(value.groupId)) + ? ["inactive_target"] + : []; + default: + return []; + } + } + private configurationReview(value: AccessDefinition): Reason[] { + if (!("configuration" in value)) { + return []; + } + if (value.configuration === "pending" && value.endDate === null) { + return ["pending_configuration"]; + } + return value.configuration === "removal_pending" ? ["pending_removal"] : []; + } + private catalogReview(value: AccessDefinition): Reason[] { + const inactive = Object.entries(value).some( + ([key, id]) => + ["systemId", "environmentId", "resourceTypeId"].includes(key) && + typeof id === "string" && + !this.isActive(id) + ); + return inactive ? ["inactive_catalog"] : []; + } + project() { + const coverage = this.items.flatMap((item) => this.needCoverage(item)); + const reviews = this.items.flatMap((item) => + [ + ...this.specificReview(item, coverage), + ...this.configurationReview(item.values.data), + ...this.catalogReview(item.values.data), + ].map((reason) => ({ itemId: item.id.toPrimitive(), reason })) + ); + return { coverage, reviews }; + } +} +export function projectAccess( + items: readonly AccessItem[], + people: readonly Person[], + today: string +) { + return new AccessProjection(items, people, today).project(); +} diff --git a/modules/companies/src/api/domain/access/access-values.ts b/modules/companies/src/api/domain/access/access-values.ts new file mode 100644 index 0000000..9b43d0c --- /dev/null +++ b/modules/companies/src/api/domain/access/access-values.ts @@ -0,0 +1,131 @@ +import { Name, UniqueID } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import { CalendarDate } from "../value-objects/calendar-date"; + +import type { AccessDefinition } from "./access-definition"; +import { AccessDefinitionSchema } from "./access-definition"; +import { AccessError } from "./access-error"; + +function referenceValues(data: AccessDefinition): readonly [string, string][] { + return Object.entries(data).flatMap<[string, string]>(([key, value]) => { + if (key.endsWith("Id") && typeof value === "string") { + return [[key, value]]; + } + if (key === "permissionIds" && Array.isArray(value)) { + return value.map((id: string): [string, string] => [`permission:${id}`, id]); + } + return []; + }); +} +function identitiesOf(data: AccessDefinition) { + const identities = new Map(); + for (const [key, value] of referenceValues(data)) { + const id = UniqueID.create(value); + if (!id.isSuccess) { + return Result.fail(new AccessError("INVALID_REQUEST")); + } + identities.set(key, id.data); + } + return Result.ok(identities); +} +function datesOf(data: AccessDefinition) { + const dates = new Map(); + for (const [key, value] of Object.entries(data)) { + if (!["startDate", "endDate", "expiresOn"].includes(key) || typeof value !== "string") { + continue; + } + const date = CalendarDate.create(value); + if (!date.isSuccess) { + return Result.fail(new AccessError("INVALID_REQUEST")); + } + dates.set(key, date.data); + } + if ("startDate" in data && data.endDate !== null && data.endDate < data.startDate) { + return Result.fail(new AccessError("INVALID_REQUEST")); + } + return Result.ok(dates); +} +function titleOf(data: AccessDefinition) { + const title = "name" in data ? data.name : "login" in data ? data.login : undefined; + if (title === undefined) { + return Result.ok(Maybe.none()); + } + const name = Name.create(title, "name" in data ? 100 : 255); + return name.isSuccess + ? Result.ok(Maybe.some(name.data)) + : Result.fail(new AccessError("INVALID_REQUEST")); +} +/** Valor compuesto inmutable: igualdad semántica, referencias UUID y fechas reconstruidas como Value Objects. */ +export class AccessValues { + private constructor( + public readonly data: Readonly, + private readonly identities: ReadonlyMap, + private readonly dates: ReadonlyMap, + public readonly displayName: Maybe + ) { + Object.freeze(this); + } + static create(input: AccessDefinition): Result { + const parsed = AccessDefinitionSchema.safeParse(input); + if (!parsed.success) { + return Result.fail(new AccessError("INVALID_REQUEST")); + } + // Un UUID escrito en mayúsculas representa la misma identidad; no debe eludir unicidad ni pertenencia. + const data = AccessDefinitionSchema.parse( + Object.fromEntries( + Object.entries(parsed.data).map(([key, value]) => { + if (key.endsWith("Id") && typeof value === "string") { + return [key, value.toLowerCase()]; + } + if (key === "permissionIds" && Array.isArray(value)) { + return [key, value.map((id: string) => id.toLowerCase())]; + } + return [key, value]; + }) + ) + ); + const ids = identitiesOf(data); + const dates = datesOf(data); + const name = titleOf(data); + if (!ids.isSuccess) { + return ids; + } + if (!dates.isSuccess) { + return dates; + } + if (!name.isSuccess) { + return name; + } + const frozen = + "permissionIds" in data + ? { ...data, permissionIds: [...new Set(data.permissionIds)] } + : { ...data }; + if ("permissionIds" in frozen) { + Object.freeze(frozen.permissionIds); + } + return Result.ok(new AccessValues(Object.freeze(frozen), ids.data, dates.data, name.data)); + } + reference(key: string): Maybe { + const id = this.identities.get(key); + return id ? Maybe.some(id) : Maybe.none(); + } + date(key: string): Maybe { + const date = this.dates.get(key); + return date ? Maybe.some(date) : Maybe.none(); + } + references(): readonly UniqueID[] { + return [...this.identities.values()]; + } + equals(other: AccessValues) { + if (this.data.kind !== other.data.kind) { + return false; + } + return Object.entries(this.data).every(([key, value]) => { + const candidate = Object.entries(other.data).find(([name]) => name === key)?.[1]; + return Array.isArray(value) && Array.isArray(candidate) + ? value.length === candidate.length && value.every((item) => candidate.includes(item)) + : value === candidate; + }); + } +} diff --git a/modules/companies/src/api/domain/access/access-workspace.aggregate.ts b/modules/companies/src/api/domain/access/access-workspace.aggregate.ts new file mode 100644 index 0000000..d49f5f8 --- /dev/null +++ b/modules/companies/src/api/domain/access/access-workspace.aggregate.ts @@ -0,0 +1,111 @@ +import { AggregateRoot, type UniqueID } from "@repo/rdx-ddd"; +import type { Maybe } from "@repo/rdx-utils"; +import { Result } from "@repo/rdx-utils"; + +import type { Person } from "../person.entity"; + +import { AccessError } from "./access-error"; +import type { AccessItem, AuditContext } from "./access-item.entity"; +import { + AccessPolicy, + accessConflict, + checkCatalog, + checkConfiguration, + checkRelations, +} from "./access-policy"; +import type { AccessValues } from "./access-values"; + +export type AccessContext = Readonly<{ + catalog: readonly AccessItem[]; + people: readonly Person[]; + today: string; +}>; +export class AccessWorkspace extends AggregateRoot<{ + companyId: Maybe; + items: readonly AccessItem[]; +}> { + protected constructor( + props: { companyId: Maybe; items: readonly AccessItem[] }, + id: UniqueID + ) { + super({ ...props, items: Object.freeze([...props.items]) }, id); + } + static create(companyId: Maybe, id: UniqueID) { + return new AccessWorkspace({ companyId, items: [] }, id); + } + static rehydrate(companyId: Maybe, items: readonly AccessItem[], id: UniqueID) { + return new AccessWorkspace({ companyId, items }, id); + } + get companyId() { + return this.props.companyId; + } + get items() { + return this.props.items; + } + save( + candidate: AccessItem, + context: AccessContext, + previous?: AccessItem + ): Result { + const value = candidate.values.data; + if ( + this.companyId.getOrUndefined()?.toPrimitive() !== + candidate.companyId.getOrUndefined()?.toPrimitive() + ) { + return Result.fail(new AccessError("SCOPE_MISMATCH")); + } + const global = ["system", "resource_type", "permission", "template"]; + if (!(this.companyId.getOrUndefined() || global.includes(value.kind))) { + return Result.fail(new AccessError("SCOPE_MISMATCH")); + } + if (value.kind === "template" && this.companyId.getOrUndefined()) { + return Result.fail(new AccessError("SCOPE_MISMATCH")); + } + const all = [...context.catalog, ...this.items.filter((item) => !item.id.equals(candidate.id))]; + const isNewOrReopen = + !previous || + ("endDate" in value && + value.endDate === null && + "endDate" in previous.values.data && + previous.values.data.endDate !== null); + const policy = new AccessPolicy(this.companyId, all, context, isNewOrReopen); + const failure = + checkCatalog(value, policy) ?? + checkRelations(value, policy) ?? + checkConfiguration(value, previous); + if (failure) { + return failure; + } + const duplicates = all.some((other) => accessConflict(candidate, other, all)); + if (duplicates) { + return Result.fail(new AccessError("ACCESS_CONFLICT")); + } + if (previous === candidate) { + return Result.ok(this); + } + return Result.ok( + new AccessWorkspace( + { + companyId: this.companyId, + items: previous + ? this.items.map((item) => (item.id.equals(candidate.id) ? candidate : item)) + : [...this.items, candidate], + }, + this.id + ) + ); + } + update( + id: UniqueID, + values: AccessValues, + audit: AuditContext, + context: AccessContext + ): Result { + const previous = this.items.find((item) => item.id.equals(id)); + if (!previous) { + return Result.fail(new AccessError("ACCESS_NOT_FOUND")); + } + const candidate = previous.update(values, audit); + return candidate.isSuccess ? this.save(candidate.data, context, previous) : candidate; + } +} diff --git a/modules/companies/src/api/domain/access/access.test.ts b/modules/companies/src/api/domain/access/access.test.ts new file mode 100644 index 0000000..1bd3501 --- /dev/null +++ b/modules/companies/src/api/domain/access/access.test.ts @@ -0,0 +1,271 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { UniqueID } from "@repo/rdx-ddd"; +import { Maybe } from "@repo/rdx-utils"; + +import { CompanyInputMapper } from "../../application/company-input.mapper"; +import { Person } from "../person.entity"; + +import type { AccessDefinition } from "./access-definition"; +import { AccessDefinitionSchema } from "./access-definition"; +import { AccessItem } from "./access-item.entity"; +import { AccessValues } from "./access-values"; +import type { AccessContext } from "./access-workspace.aggregate"; +import { AccessWorkspace } from "./access-workspace.aggregate"; + +const audit = { actorId: UniqueID.generateNewID(), recordedAt: "2026-01-02T10:00:00.000Z" }; +function values(definition: AccessDefinition) { + const result = AccessValues.create(definition); + assert.ok(result.isSuccess); + return result.data; +} +function fixture() { + const companyId = UniqueID.generateNewID(); + const global = AccessItem.create( + Maybe.none(), + values({ kind: "system", name: "Directorio", active: true }), + audit + ); + const input = new CompanyInputMapper(); + const personValues = input.person({ + name: "Ana", + position: null, + email: null, + phone: null, + isContact: false, + active: true, + }); + assert.ok(personValues.isSuccess); + const person = Person.create({ ...personValues.data, companyId }); + assert.ok(person.isSuccess); + const second = Person.create({ ...personValues.data, companyId }); + assert.ok(second.isSuccess); + let workspace = AccessWorkspace.create(Maybe.some(companyId), UniqueID.generateNewID()); + const context: AccessContext = { + catalog: [global], + people: [person.data, second.data], + today: "2026-01-02", + }; + const add = (definition: AccessDefinition) => { + const item = AccessItem.create(Maybe.some(companyId), values(definition), audit); + const result = workspace.save(item, context); + assert.ok(result.isSuccess, result.isSuccess ? "" : result.error.code); + workspace = result.data; + return item; + }; + const account: AccessDefinition = { + kind: "account", + systemId: global.id.toPrimitive(), + environmentId: null, + login: "ana", + usage: "personal", + active: true, + expiresOn: null, + notes: null, + }; + return { + companyId, + global, + context, + person: person.data, + second: second.data, + add, + account, + workspace: () => workspace, + }; +} +test("catálogos globales no se duplican con adiciones locales y no se admiten grupos anidados", () => { + const f = fixture(); + const duplicate = AccessItem.create( + Maybe.some(f.companyId), + values({ kind: "system", name: " DIRECTORIO ", active: false }), + audit + ); + const result = f.workspace().save(duplicate, f.context); + assert.equal(result.isSuccess, false); + assert.equal( + AccessDefinitionSchema.safeParse({ + kind: "membership", + accountId: UniqueID.generateNewID().toPrimitive(), + groupId: UniqueID.generateNewID().toPrimitive(), + nestedGroupId: UniqueID.generateNewID().toPrimitive(), + startDate: "2026-01-01", + endDate: null, + configuration: "pending", + }).success, + false + ); +}); +test("renombrar una cuenta conserva UUID, historial y semántica de repetición; su uso permanece fijo", () => { + const f = fixture(); + const account = f.add(f.account); + const renamed = account.update(values({ ...f.account, login: "ana.garcia" }), audit); + assert.ok(renamed.isSuccess); + assert.ok(renamed.data.id.equals(account.id)); + assert.equal(renamed.data.history.length, 2); + assert.equal(account.history.length, 1); + const repeated = renamed.data.update(values({ ...f.account, login: " ana.garcia " }), audit); + assert.ok(repeated.isSuccess); + assert.equal(repeated.data, renamed.data); + const conversion = account.update(values({ ...f.account, usage: "shared" }), audit); + assert.equal(conversion.isSuccess, false); + assert.equal(renamed.data.history[1]?.actorId.toPrimitive(), audit.actorId.toPrimitive()); +}); +test("cerrar una autorización configurada deja retirada pendiente y cerrar una pendiente no inventa un acceso", () => { + const f = fixture(); + const account = f.add(f.account); + const definition: AccessDefinition = { + kind: "account_person", + accountId: account.id.toPrimitive(), + personId: f.person.id.toPrimitive(), + startDate: "2026-01-01", + endDate: null, + configuration: "configured", + }; + const relation = f.add(definition); + const closed = f + .workspace() + .update(relation.id, values({ ...definition, endDate: "2026-01-02" }), audit, f.context); + assert.ok(closed.isSuccess); + const saved = closed.data.items.find((item) => item.id.equals(relation.id)); + assert.ok(saved); + assert.equal( + "configuration" in saved.values.data ? saved.values.data.configuration : null, + "removal_pending" + ); + const retired = closed.data.update( + relation.id, + values({ ...definition, endDate: "2026-01-02", configuration: "removed" }), + audit, + f.context + ); + assert.ok(retired.isSuccess); + const pending = AccessItem.create( + Maybe.some(f.companyId), + values({ ...definition, configuration: "pending" }), + audit + ); + const never = pending.update( + values({ ...definition, endDate: "2026-01-02", configuration: "pending" }), + audit + ); + assert.ok(never.isSuccess); + assert.equal( + "configuration" in never.data.values.data ? never.data.values.data.configuration : null, + "never_configured" + ); +}); +test("titulares no se solapan incluso en periodos cerrados; el relevo conserva una retirada anterior", () => { + const f = fixture(); + const account = f.add(f.account); + const definition: AccessDefinition = { + kind: "account_person", + accountId: account.id.toPrimitive(), + personId: f.person.id.toPrimitive(), + startDate: "2026-01-01", + endDate: "2026-02-01", + configuration: "removal_pending", + }; + const relation = f.add(definition); + const conflicting = AccessItem.create( + Maybe.some(f.companyId), + values({ + ...definition, + personId: f.second.id.toPrimitive(), + startDate: "2026-01-15", + endDate: "2026-03-01", + }), + audit + ); + assert.equal(f.workspace().save(conflicting, f.context).isSuccess, false); + const next = f.add({ + ...definition, + personId: f.second.id.toPrimitive(), + startDate: "2026-02-01", + endDate: null, + configuration: "pending", + }); + assert.equal( + f.workspace().items.filter((item) => item.values.data.kind === "account_person").length, + 2 + ); + assert.notEqual(next.id.toPrimitive(), relation.id.toPrimitive()); +}); +test("responsabilidad técnica no concede acceso y se permite sobre una cuenta caducada o inactiva", () => { + const f = fixture(); + const account = f.add({ + ...f.account, + login: "svc", + usage: "technical", + active: false, + expiresOn: "2000-01-01", + }); + f.add({ + kind: "account_person", + accountId: account.id.toPrimitive(), + personId: f.person.id.toPrimitive(), + startDate: "2026-01-01", + endDate: null, + configuration: null, + }); + const invalid = AccessItem.create( + Maybe.some(f.companyId), + values({ + kind: "account_person", + accountId: account.id.toPrimitive(), + personId: f.second.id.toPrimitive(), + startDate: "2026-01-01", + endDate: null, + configuration: "configured", + }), + audit + ); + assert.equal(f.workspace().save(invalid, f.context).isSuccess, false); +}); +test("valores reconstruyen UUID y fechas, rechazan fechas imposibles y no permiten credenciales", () => { + const f = fixture(); + const parsed = values(f.account); + assert.ok(parsed.reference("systemId").getOrUndefined()?.equals(f.global.id)); + const definition: AccessDefinition = { + kind: "need", + personId: f.person.id.toPrimitive(), + resourceId: UniqueID.generateNewID().toPrimitive(), + permissionIds: [f.global.id.toPrimitive()], + startDate: "2026-01-01", + endDate: null, + notes: null, + }; + const needed = values(definition); + assert.equal(needed.date("startDate").getOrUndefined()?.toPrimitive(), "2026-01-01"); + assert.equal(AccessValues.create({ ...definition, startDate: "2026-02-30" }).isSuccess, false); + assert.equal(AccessValues.create({ ...definition, endDate: "2025-01-01" }).isSuccess, false); + assert.equal( + AccessDefinitionSchema.safeParse({ ...f.account, password: "hidden" }).success, + false + ); + assert.throws(() => { + if ("permissionIds" in needed.data) { + needed.data.permissionIds.push(f.global.id.toPrimitive()); + } + }, TypeError); +}); + +test("las referencias UUID se normalizan y no permiten duplicar cuentas variando mayúsculas", () => { + const f = fixture(); + f.add(f.account); + const uppercase = AccessItem.create( + Maybe.some(f.companyId), + values({ ...f.account, systemId: f.global.id.toPrimitive().toUpperCase() }), + audit + ); + assert.equal( + uppercase.values.reference("systemId").getOrUndefined()?.toPrimitive(), + f.global.id.toPrimitive() + ); + assert.equal( + "systemId" in uppercase.values.data ? uppercase.values.data.systemId : null, + f.global.id.toPrimitive() + ); + assert.equal(f.workspace().save(uppercase, f.context).isSuccess, false); +}); diff --git a/modules/companies/src/api/infrastructure/http/access.routes.ts b/modules/companies/src/api/infrastructure/http/access.routes.ts new file mode 100644 index 0000000..b7c9c57 --- /dev/null +++ b/modules/companies/src/api/infrastructure/http/access.routes.ts @@ -0,0 +1,86 @@ +import type { Result } from "@repo/rdx-utils"; +import type { Application, Request, RequestHandler, Response } from "express"; +import { Router } from "express"; +import { z } from "zod/v4"; + +import { + AccessItemParamsSchema, + AccessSaveRequestSchema, + AccessScopeParamsSchema, +} from "../../../common/dto/request/access.request.dto"; +import type { AccessSnapshot } from "../../application/access.repository"; +import type { AccessUseCases } from "../../application/access.use-cases"; +import { buildAccessResponse } from "../../application/access-response.builder"; +import type { AccessError } from "../../domain/access/access-error"; + +function errorStatus(code: string) { + if (code === "INVALID_REQUEST") { + return 400; + } + return code.endsWith("NOT_FOUND") ? 404 : 409; +} +function sendError(response: Response, code: string, status: number) { + response.status(status).json({ code, requestId: response.getHeader("x-request-id") }); +} +function visibleSnapshot(snapshot: AccessSnapshot, scope: string) { + // Las adiciones locales se cargan para detectar colisiones, pero nunca se exponen en el catálogo global. + return scope === "global" + ? { ...snapshot, context: { ...snapshot.context, catalog: [] } } + : snapshot; +} +function sendResult( + response: Response, + result: Result, + scope: string, + status: number +) { + if (!result.isSuccess) { + sendError(response, result.error.code, errorStatus(result.error.code)); + return; + } + response.status(status).json(buildAccessResponse(visibleSnapshot(result.data, scope))); +} +function actorId(request: Request) { + if (!("session" in request)) { + return undefined; + } + const parsed = z.object({ userId: z.uuid() }).safeParse(request.session); + return parsed.success ? parsed.data.userId : undefined; +} +export function registerAccessRoutes( + app: Application, + useCases: AccessUseCases, + authenticate: RequestHandler +) { + const router = Router(); + router.get("/:scope", (request, response, next) => { + const scope = AccessScopeParamsSchema.safeParse(request.params); + if (!(scope.success && z.strictObject({}).safeParse(request.query).success)) { + sendError(response, "INVALID_REQUEST", 400); + return; + } + useCases + .get(scope.data.scope) + .then((result) => sendResult(response, result, scope.data.scope, 200)) + .catch(next); + }); + for (const path of ["/:scope", "/:scope/:id/update"]) { + const schema = path.endsWith("update") ? AccessItemParamsSchema : AccessScopeParamsSchema; + router.post(path, (request, response, next) => { + const params = schema.safeParse(request.params); + const body = AccessSaveRequestSchema.safeParse(request.body); + const actor = actorId(request); + if (!(params.success && body.success && actor)) { + sendError(response, "INVALID_REQUEST", 400); + return; + } + const id = z.object({ id: z.uuid() }).safeParse(params.data); + const identity = id.success ? id.data.id : undefined; + useCases + .save(params.data.scope, identity, body.data.values, actor) + .then((result) => sendResult(response, result, params.data.scope, identity ? 200 : 201)) + .catch(next); + }); + } + app.use("/api/access", authenticate, router); +} diff --git a/modules/companies/src/api/infrastructure/persistence/access-persistence.ts b/modules/companies/src/api/infrastructure/persistence/access-persistence.ts new file mode 100644 index 0000000..cfdf9da --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/access-persistence.ts @@ -0,0 +1,404 @@ +import { UniqueID } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; +import type { + CreationOptional, + InferAttributes, + InferCreationAttributes, + Sequelize, + Transaction, +} from "sequelize"; +import { DataTypes, Model, QueryTypes } from "sequelize"; + +import type { AccessSnapshot, IAccessRepository } from "../../application/access.repository"; +import type { ICompanyRepository } from "../../application/company.repository"; +import { AccessDefinitionSchema } from "../../domain/access/access-definition"; +import { AccessError } from "../../domain/access/access-error"; +import { AccessItem, AccessRevision } from "../../domain/access/access-item.entity"; +import { AccessValues } from "../../domain/access/access-values"; +import { AccessWorkspace } from "../../domain/access/access-workspace.aggregate"; +import type { Person } from "../../domain/person.entity"; + +class WorkspaceModel extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: string; + declare companyId: string | null; + declare scopeKey: string; +} +class ItemModel extends Model, InferCreationAttributes> { + declare id: string; + declare workspaceId: string; + declare kind: string; + declare values: unknown; +} +class RevisionModel extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: string; + declare itemId: string; + declare sequence: number; + declare actorId: string; + declare recordedAt: string; + declare values: unknown; +} +class ReferenceModel extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: CreationOptional; + declare itemId: string; + declare targetId: string; +} +class PersonReferenceModel extends Model< + InferAttributes, + InferCreationAttributes +> { + declare itemId: string; + declare personId: string; +} +const idColumn = { type: DataTypes.UUID, primaryKey: true }; +const reference = (table: string) => ({ + type: DataTypes.UUID, + allowNull: false, + references: { model: table, key: "id" }, + onDelete: "RESTRICT", +}); +export async function migrateAccess(database: Sequelize) { + const query = database.getQueryInterface(); + const rows = await database.query<{ name: string }>( + "SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE()", + { type: QueryTypes.SELECT } + ); + const exists = (name: string) => rows.some((row) => row.name === name); + if (!exists("access_workspaces")) { + await query.createTable("access_workspaces", { + id: idColumn, + companyId: { ...reference("companies"), allowNull: true }, + scopeKey: { type: DataTypes.STRING(40), allowNull: false, unique: true }, + }); + } + if (!exists("access_items")) { + await query.createTable("access_items", { + id: idColumn, + workspaceId: reference("access_workspaces"), + kind: { type: DataTypes.STRING(30), allowNull: false }, + values: { type: DataTypes.JSON, allowNull: false }, + }); + } + if (!exists("access_revisions")) { + await query.createTable("access_revisions", { + id: idColumn, + itemId: reference("access_items"), + sequence: { type: DataTypes.INTEGER, allowNull: false }, + actorId: reference("users"), + recordedAt: { type: DataTypes.STRING(24), allowNull: false }, + values: { type: DataTypes.JSON, allowNull: false }, + }); + } + if (!exists("access_references")) { + await query.createTable("access_references", { + id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, + itemId: reference("access_items"), + targetId: reference("access_items"), + }); + } + if (!exists("access_person_references")) { + await query.createTable("access_person_references", { + itemId: { ...reference("access_items"), primaryKey: true }, + personId: { ...reference("company_contacts"), primaryKey: true }, + }); + } + // Índices fuera de las ramas de creación: permiten completar un arranque interrumpido. + for (const [table, fields, name] of [ + ["access_revisions", ["itemId", "sequence"], "access_revision_sequence_unique"], + ["access_references", ["itemId", "targetId"], "access_reference_unique"], + ] as const) { + const indexes = await database.query<{ name: string }>( + "SELECT DISTINCT INDEX_NAME AS name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=:table", + { replacements: { table }, type: QueryTypes.SELECT } + ); + if (!indexes.some((item) => item.name === name)) { + await query.addIndex(table, [...fields], { unique: true, name }); + } + } + WorkspaceModel.init( + { + id: idColumn, + companyId: { type: DataTypes.UUID, allowNull: true }, + scopeKey: { type: DataTypes.STRING(40), allowNull: false }, + }, + { sequelize: database, tableName: "access_workspaces", timestamps: false } + ); + ItemModel.init( + { + id: idColumn, + workspaceId: { type: DataTypes.UUID, allowNull: false }, + kind: { type: DataTypes.STRING(30), allowNull: false }, + values: { type: DataTypes.JSON, allowNull: false }, + }, + { sequelize: database, tableName: "access_items", timestamps: false } + ); + RevisionModel.init( + { + id: idColumn, + itemId: { type: DataTypes.UUID, allowNull: false }, + sequence: { type: DataTypes.INTEGER, allowNull: false }, + actorId: { type: DataTypes.UUID, allowNull: false }, + recordedAt: { type: DataTypes.STRING(24), allowNull: false }, + values: { type: DataTypes.JSON, allowNull: false }, + }, + { sequelize: database, tableName: "access_revisions", timestamps: false } + ); + ReferenceModel.init( + { + id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, + itemId: { type: DataTypes.UUID, allowNull: false }, + targetId: { type: DataTypes.UUID, allowNull: false }, + }, + { sequelize: database, tableName: "access_references", timestamps: false } + ); + PersonReferenceModel.init( + { + itemId: { type: DataTypes.UUID, primaryKey: true }, + personId: { type: DataTypes.UUID, primaryKey: true }, + }, + { sequelize: database, tableName: "access_person_references", timestamps: false } + ); + // Completar referencias de registros ya existentes sin reescribir sus valores ni su historial. + for (const item of await ItemModel.findAll()) { + const value = domainValues(item.values).data; + if ("personId" in value) { + await PersonReferenceModel.findOrCreate({ + where: { itemId: item.id, personId: value.personId }, + defaults: { itemId: item.id, personId: value.personId }, + }); + } + } + await WorkspaceModel.findOrCreate({ + where: { scopeKey: "global" }, + defaults: { id: UniqueID.generateNewID().toPrimitive(), companyId: null, scopeKey: "global" }, + }); +} +function validId(value: string) { + const id = UniqueID.create(value); + if (!id.isSuccess) { + throw new Error("Identidad de acceso persistida inconsistente."); + } + return id.data; +} +function domainValues(value: unknown) { + const parsed = AccessDefinitionSchema.safeParse(value); + if (!parsed.success) { + throw new Error("Valores de acceso persistidos inconsistentes."); + } + const values = AccessValues.create(parsed.data); + if (!values.isSuccess) { + throw new Error("Valores de acceso persistidos inconsistentes."); + } + return values.data; +} +export class AccessPersistenceMapper { + toDomain(root: WorkspaceModel, rows: readonly ItemModel[], revisions: readonly RevisionModel[]) { + const items = rows.map((row) => { + const values = domainValues(row.values); + const history = revisions + .filter((revision) => revision.itemId === row.id) + .sort((a, b) => a.sequence - b.sequence) + .map((revision, index) => { + if ( + revision.sequence !== index || + !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(revision.recordedAt) + ) { + throw new Error("Historial de acceso persistido inconsistente."); + } + return AccessRevision.rehydrate( + domainValues(revision.values), + { actorId: validId(revision.actorId), recordedAt: revision.recordedAt }, + validId(revision.id) + ); + }); + if (!history.at(-1)?.values.equals(values) || row.kind !== values.data.kind) { + throw new Error("Historial de acceso persistido inconsistente."); + } + return AccessItem.rehydrate( + { + companyId: root.companyId ? Maybe.some(validId(root.companyId)) : Maybe.none(), + values, + history, + }, + validId(row.id) + ); + }); + return AccessWorkspace.rehydrate( + root.companyId ? Maybe.some(validId(root.companyId)) : Maybe.none(), + items, + validId(root.id) + ); + } + toPersistence(workspace: AccessWorkspace) { + return workspace.items.map((item) => ({ + item: { + id: item.id.toPrimitive(), + workspaceId: workspace.id.toPrimitive(), + kind: item.values.data.kind, + values: item.values.data, + }, + revisions: item.history.map((revision, sequence) => ({ + id: revision.id.toPrimitive(), + itemId: item.id.toPrimitive(), + sequence, + actorId: revision.actorId.toPrimitive(), + recordedAt: revision.recordedAt, + values: revision.values.data, + })), + references: item.values + .references() + .filter((id) => { + const person = item.values.reference("personId").getOrUndefined(); + return !person?.equals(id); + }) + .map((id) => ({ itemId: item.id.toPrimitive(), targetId: id.toPrimitive() })), + })); + } +} +export class SequelizeAccessRepository implements IAccessRepository { + constructor( + private readonly database: Sequelize, + private readonly companies: ICompanyRepository, + private readonly mapper: AccessPersistenceMapper + ) {} + private async load( + companyId: Maybe, + transaction: Transaction, + write: boolean + ): Promise> { + // Orden de bloqueo único: catálogo global, compañía y espacio de accesos. Serializa altas y cambios de catálogos. + const global = await WorkspaceModel.findOne({ + where: { scopeKey: "global" }, + transaction, + ...(write ? { lock: transaction.LOCK.UPDATE } : {}), + }); + if (!global) { + throw new Error("Catálogo no inicializado."); + } + let root = global; + let people: readonly Person[] = []; + const company = companyId.getOrUndefined(); + if (company) { + const exists = await this.database.query<{ id: string }>( + `SELECT id FROM companies WHERE id=:id${write ? " FOR UPDATE" : ""}`, + { replacements: { id: company.toPrimitive() }, transaction, type: QueryTypes.SELECT } + ); + if (!exists.length) { + return Result.fail(new AccessError("COMPANY_NOT_FOUND")); + } + const aggregate = (await this.companies.findById(company)).getOrUndefined(); + if (!aggregate) { + return Result.fail(new AccessError("COMPANY_NOT_FOUND")); + } + people = aggregate.people; + const [created] = await WorkspaceModel.findOrCreate({ + where: { scopeKey: company.toPrimitive() }, + defaults: { + id: UniqueID.generateNewID().toPrimitive(), + scopeKey: company.toPrimitive(), + companyId: company.toPrimitive(), + }, + transaction, + }); + root = created; + } + const roots = company ? [global, root] : await WorkspaceModel.findAll({ transaction }); + const rows = await ItemModel.findAll({ + where: { workspaceId: roots.map((item) => item.id) }, + transaction, + }); + const revisions = rows.length + ? await RevisionModel.findAll({ where: { itemId: rows.map((item) => item.id) }, transaction }) + : []; + const workspace = this.mapper.toDomain( + root, + rows.filter((row) => row.workspaceId === root.id), + revisions + ); + const catalog = roots + .filter((item) => item.id !== root.id) + .flatMap( + (item) => + this.mapper.toDomain( + item, + rows.filter((row) => row.workspaceId === item.id), + revisions + ).items + ); + const actorRows = await this.database.query<{ id: string; name: string }>( + "SELECT id,name FROM users", + { transaction, type: QueryTypes.SELECT } + ); + const actors = new Map(actorRows.map((row) => [row.id, row.name])); + return Result.ok({ + actors, + workspace, + context: { catalog, people, today: new Date().toISOString().slice(0, 10) }, + }); + } + get(companyId: Maybe) { + return this.database.transaction((transaction) => this.load(companyId, transaction, false)); + } + update(companyId: Maybe, action: Parameters[1]) { + return this.database.transaction(async (transaction) => { + const loaded = await this.load(companyId, transaction, true); + if (!loaded.isSuccess) { + return loaded; + } + const changed = action(loaded.data); + if (!changed.isSuccess) { + return changed; + } + if (changed.data !== loaded.data.workspace) { + await this.persistChanges(changed.data, loaded.data.workspace, transaction); + } + return Result.ok({ + workspace: changed.data, + context: loaded.data.context, + actors: loaded.data.actors, + }); + }); + } + private async persistChanges( + workspace: AccessWorkspace, + previousWorkspace: AccessWorkspace, + transaction: Transaction + ) { + const records = this.mapper.toPersistence(workspace); + const previous = new Set( + previousWorkspace.items.flatMap((item) => + item.history.map((revision) => revision.id.toPrimitive()) + ) + ); + for (const record of records) { + await ItemModel.upsert(record.item, { transaction }); + const revisions = record.revisions.filter((revision) => !previous.has(revision.id)); + if (revisions.length) { + await RevisionModel.bulkCreate(revisions, { transaction }); + } + const personId = Object.entries(record.item.values).find(([key]) => key === "personId")?.[1]; + if (typeof personId === "string") { + await PersonReferenceModel.findOrCreate({ + where: { itemId: record.item.id, personId }, + defaults: { itemId: record.item.id, personId }, + transaction, + }); + } + for (const reference of record.references) { + await ReferenceModel.findOrCreate({ + where: reference, + defaults: reference, + transaction, + }); + } + } + } +} diff --git a/modules/companies/src/api/module.ts b/modules/companies/src/api/module.ts index 010419c..f962ef1 100644 --- a/modules/companies/src/api/module.ts +++ b/modules/companies/src/api/module.ts @@ -1,9 +1,16 @@ import type { RequestHandler } from "express"; import type { Sequelize } from "sequelize"; +import { AccessUseCases } from "./application/access.use-cases"; import { CompanyUseCases } from "./application/company.use-cases"; import { CompanyInputMapper } from "./application/company-input.mapper"; +import { registerAccessRoutes } from "./infrastructure/http/access.routes"; import { registerCompanyRoutes } from "./infrastructure/http/company.routes"; +import { + AccessPersistenceMapper, + SequelizeAccessRepository, + migrateAccess, +} from "./infrastructure/persistence/access-persistence"; import { initializeCompanyModels } from "./infrastructure/persistence/company.model"; import { CompanyPersistenceMapper } from "./infrastructure/persistence/company-persistence.mapper"; import { migrateCompanies } from "./infrastructure/persistence/migrate-companies"; @@ -16,12 +23,16 @@ export async function createCompanyModule(database: Sequelize, authenticate: Req await migratePeople(database); initializeCompanyModels(database); initializePeopleModels(database); - const useCases = new CompanyUseCases( - new SequelizeCompanyRepository(database, new CompanyPersistenceMapper()), - new CompanyInputMapper() + const repository = new SequelizeCompanyRepository(database, new CompanyPersistenceMapper()); + await migrateAccess(database); + const access = new AccessUseCases( + new SequelizeAccessRepository(database, repository, new AccessPersistenceMapper()) ); + const useCases = new CompanyUseCases(repository, new CompanyInputMapper()); return { - register: (app: Parameters[0]) => - registerCompanyRoutes(app, useCases, authenticate), + register: (app: Parameters[0]) => { + registerCompanyRoutes(app, useCases, authenticate); + registerAccessRoutes(app, access, authenticate); + }, }; } diff --git a/modules/companies/src/common/dto/request/access.request.dto.ts b/modules/companies/src/common/dto/request/access.request.dto.ts new file mode 100644 index 0000000..f59b091 --- /dev/null +++ b/modules/companies/src/common/dto/request/access.request.dto.ts @@ -0,0 +1,8 @@ +import { z } from "zod/v4"; + +import { AccessFieldsSchema } from "../shared/access.dto"; +export const AccessSaveRequestSchema = z.strictObject({ values: AccessFieldsSchema }); +export const AccessScopeParamsSchema = z.strictObject({ + scope: z.union([z.literal("global"), z.uuid()]), +}); +export const AccessItemParamsSchema = AccessScopeParamsSchema.extend({ id: z.uuid() }); diff --git a/modules/companies/src/common/dto/shared/access.dto.ts b/modules/companies/src/common/dto/shared/access.dto.ts new file mode 100644 index 0000000..d7526e3 --- /dev/null +++ b/modules/companies/src/common/dto/shared/access.dto.ts @@ -0,0 +1,49 @@ +import { z } from "zod/v4"; + +import { AccessDefinitionSchema } from "../../../api/domain/access/access-definition"; + +export type { AccessKind } from "../../../api/domain/access/access-definition"; +export { AccessKindSchema } from "../../../api/domain/access/access-definition"; +export const AccessFieldsSchema = AccessDefinitionSchema; +export type AccessFieldsDTO = z.infer; +const id = z.uuid(); +const optionalId = id.nullable(); +export const AccessRevisionSchema = z.strictObject({ + id, + actorId: id, + actorName: z.string().min(1).max(255), + recordedAt: z.iso.datetime(), + values: AccessFieldsSchema, +}); +export const AccessItemSchema = z.strictObject({ + id, + companyId: optionalId, + values: AccessFieldsSchema, + history: z.array(AccessRevisionSchema).min(1), +}); +export type AccessItemDTO = z.infer; +export const AccessReviewSchema = z.strictObject({ + itemId: id, + reason: z.enum([ + "incomplete", + "expired", + "inactive_person", + "inactive_target", + "unjustified", + "uncovered", + "pending_configuration", + "pending_removal", + "inactive_catalog", + ]), +}); +export const AccessCoverageSchema = z.strictObject({ + needId: id, + covered: z.boolean(), + missingPermissionIds: z.array(id), +}); +export const AccessResponseSchema = z.strictObject({ + items: z.array(AccessItemSchema), + reviews: z.array(AccessReviewSchema), + coverage: z.array(AccessCoverageSchema), +}); +export type AccessResponseDTO = z.infer; diff --git a/modules/companies/src/common/index.ts b/modules/companies/src/common/index.ts index 09d30fe..90a74d9 100644 --- a/modules/companies/src/common/index.ts +++ b/modules/companies/src/common/index.ts @@ -1,5 +1,7 @@ +export * from "./dto/request/access.request.dto"; export * from "./dto/request/company.request.dto"; export * from "./dto/request/people.request.dto"; export * from "./dto/response/company.response.dto"; +export * from "./dto/shared/access.dto"; export * from "./dto/shared/company.dto"; export * from "./dto/shared/people.dto"; diff --git a/modules/companies/src/web/access/access-api.ts b/modules/companies/src/web/access/access-api.ts new file mode 100644 index 0000000..425ecc6 --- /dev/null +++ b/modules/companies/src/web/access/access-api.ts @@ -0,0 +1,26 @@ +import { z } from "zod/v4"; + +import type { AccessFieldsDTO } from "../../common"; +import { AccessResponseSchema } from "../../common"; +export const accessKeys = { scope: (scope: string) => ["access", scope] }; +export class AccessApiError extends Error { + constructor(public readonly code: string) { + super(code); + } +} +export const accessApi = { + async request(scope: string, values?: AccessFieldsDTO, id?: string, token?: string) { + const response = await fetch(`/api/access/${scope}${id ? `/${id}/update` : ""}`, { + method: values ? "POST" : "GET", + credentials: "same-origin", + headers: values ? { "content-type": "application/json", "x-csrf-token": token ?? "" } : {}, + ...(values ? { body: JSON.stringify({ values }) } : {}), + }); + const json: unknown = await response.json(); + if (!response.ok) { + const parsed = z.object({ code: z.string() }).safeParse(json); + throw new AccessApiError(parsed.success ? parsed.data.code : "INTERNAL_ERROR"); + } + return AccessResponseSchema.parse(json); + }, +}; diff --git a/modules/companies/src/web/access/access-field-control.tsx b/modules/companies/src/web/access/access-field-control.tsx new file mode 100644 index 0000000..76ad424 --- /dev/null +++ b/modules/companies/src/web/access/access-field-control.tsx @@ -0,0 +1,121 @@ +import { Checkbox } from "@repo/shadcn-ui/components/checkbox"; +import { Input } from "@repo/shadcn-ui/components/input"; +import { Label } from "@repo/shadcn-ui/components/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@repo/shadcn-ui/components/select"; +import { Textarea } from "@repo/shadcn-ui/components/textarea"; +import type { UseFormReturn } from "react-hook-form"; +import { useTranslation } from "react-i18next"; + +import type { AccessField } from "./access-fields"; +import type { AccessFormValues } from "./access-form-values"; + +type Option = { value: string; label: string }; +export function AccessFieldControl({ + field, + id, + form, + value, + options, + disabled, + onSelected, +}: { + field: AccessField; + id: string; + form: UseFormReturn; + value: string | boolean | string[] | undefined; + options: readonly Option[]; + disabled: boolean; + onSelected: (value: string) => void; +}) { + const { t } = useTranslation("access"); + if (field.type === "boolean") { + return ( +
+ form.setValue(field.key, checked === true)} + /> + +
+ ); + } + if (field.type === "permissions") { + return ( +
+ {t(field.key)} + {options.map((option) => ( +
+ { + const values = Array.isArray(value) ? value : []; + form.setValue( + field.key, + checked + ? [...values, option.value] + : values.filter((item) => item !== option.value) + ); + }} + /> + +
+ ))} + {!options.length &&

{t("noPermissions")}

} +
+ ); + } + return ( +
+ + {field.type === "note" ? ( +