diff --git a/.github/workflows/docs-quality.yml b/.github/workflows/docs-quality.yml index 5484b56f..0c7a203c 100644 --- a/.github/workflows/docs-quality.yml +++ b/.github/workflows/docs-quality.yml @@ -44,13 +44,29 @@ jobs: - name: Check for TODO/demo references run: | - TODO_COUNT=$(grep -r "TODO\|FIXME\|DEMO\|HACK" docs/ --include="*.md" | wc -l) - if [ $TODO_COUNT -gt 0 ]; then - echo "Found $TODO_COUNT TODO/FIXME/DEMO/HACK references" - grep -r "TODO\|FIXME\|DEMO\|HACK" docs/ --include="*.md" + # Se busca el MARCADOR (`TODO:`, `TODO(`, `FIXME`…), no la palabra suelta: en prosa + # española «TODOS los perfiles» no es deuda técnica, y con la coincidencia laxa el + # gate disparaba sobre texto corriente. + # + # Quedan fuera los documentos que hablan DE los marcadores en vez de tenerlos: el + # registro de deuda técnica, el TODO del proyecto (que es su inventario), las actas + # de release y la especificación que cita sus propios TD-xxx. Gatearlos obligaba a + # borrar el inventario de deuda para poder publicar documentación. + # El `sed` vacía los code spans antes de buscar: citar `TODO(G-069)` para explicar + # dónde está la deuda no es tener deuda. + MATCHES=$(grep -rn "" docs/ --include="*.md" \ + --exclude-dir=releases \ + --exclude="TODO.md" --exclude="TODO.es.md" \ + --exclude="technical-debt*.md" \ + --exclude="*parameterization-system-spec.md" \ + | sed 's/`[^`]*`//g' \ + | grep -E "(TODO[:(]|FIXME|HACK[:(]|\bDEMO\b)" || true) + if [ -n "$MATCHES" ]; then + echo "Found $(echo "$MATCHES" | wc -l) TODO/FIXME/DEMO/HACK markers" + echo "$MATCHES" exit 1 fi - echo "No TODO/FIXME/DEMO/HACK references found" + echo "No TODO/FIXME/DEMO/HACK markers found" link-validation: name: Internal & External Link Validation @@ -100,8 +116,12 @@ jobs: - name: Extract mermaid diagrams run: | - grep -r "```mermaid" docs/ --include="*.md" -A 50 | grep -v "^--$" > mermaid-diagrams.md - echo "Found $(grep -c "graph\|flowchart\|sequence\|class\|state\|er\|gantt" mermaid-diagrams.md || echo 0) potential mermaid blocks" + # Comillas SIMPLES: entre dobles, los tres backticks del patrón abren una sustitución + # de comandos y el paso moría con `unexpected EOF while looking for matching`. + # El `|| true` cubre el caso sin coincidencias, que en grep es salida 1 y bajo `bash -e` + # tumbaba el paso igual. + grep -r '```mermaid' docs/ --include="*.md" -A 50 | grep -v "^--$" > mermaid-diagrams.md || true + echo "Found $(grep -c 'graph\|flowchart\|sequence\|class\|state\|er\|gantt' mermaid-diagrams.md || echo 0) potential mermaid blocks" - name: Validate Mermaid syntax run: | diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index ca6cdfe6..84838c3d 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -169,6 +169,11 @@ jobs: name: npm Vulnerability Audit runs-on: ubuntu-latest if: github.event_name == 'pull_request' + # El workspace npm/nx vive bajo src/, no en la raíz: sin esto `npm ci` moría con ENOENT + # buscando un package.json que nunca existió ahí. Mismo patrón que ci.yml. + defaults: + run: + working-directory: src steps: - name: Checkout uses: actions/checkout@v4 @@ -176,14 +181,19 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: ${{ env.NODE_VERSION }} + # React Router v8 exige node >= 22; el NODE_VERSION global (20) no basta. + node-version: 22 cache: 'npm' + cache-dependency-path: src/package-lock.json - name: Install dependencies run: npm ci - name: Audit for vulnerabilities - run: npm audit --audit-level=high + # `--omit=dev` por la misma razón que en ci.yml: se audita lo que se DESPLIEGA. Las + # high restantes son de tooling de build y no llegan al runtime; auditarlas aquí + # dejaría el gate en rojo permanente sin señalar riesgo real de lo entregado. + run: npm audit --omit=dev --audit-level=high dockerfile-scan: name: Docker Image Security Scan @@ -193,17 +203,20 @@ jobs: - name: Checkout uses: actions/checkout@v4 + # `@v3` no existe como tag en hadolint-action: el job moría en «Set up job» sin llegar a + # ejecutar nada. v3.3.0 es el release real de esa línea. - name: Hadolint Lint - uses: hadolint/hadolint-action@v3 + uses: hadolint/hadolint-action@v3.3.0 with: - dockerfile: Dockerfile + dockerfile: src/apps/ums.api/Dockerfile continue-on-error: true + # No hay Dockerfile en la raíz, así que el `if` de antes nunca se cumplía: no se construía + # imagen alguna y Trivy escaneaba una referencia inexistente (lo tapaba su + # continue-on-error). El contexto de build es src/, porque el grafo de proyectos alcanza + # Ums.ReadModels y libs/sdk/dotnet, fuera de apps/ums.api. - name: Build Docker image for scan - run: | - if [ -f "Dockerfile" ]; then - docker build -t ums-app:test . --quiet - fi + run: docker build -f src/apps/ums.api/Dockerfile -t ums-app:test src/ --quiet - name: Run Trivy scanner uses: aquasecurity/trivy-action@master @@ -214,11 +227,15 @@ jobs: severity: 'CRITICAL,HIGH' continue-on-error: true + # `upload-security-results` no existe en codeql-action —de ahí que el job muriera en + # «Set up job», antes de ejecutar nada—. La acción real es `upload-sarif`, y v2 está + # retirada. El `if` cubre que Trivy no llegue a escribir el SARIF: sube solo si hay + # fichero, en vez de fallar el paso. - name: Upload Trivy results - uses: github/codeql-action/upload-security-results@v2 - if: always() + uses: github/codeql-action/upload-sarif@v3 + if: always() && hashFiles('trivy-results.sarif') != '' with: - tool_name: 'Trivy' + category: 'trivy' sarif_file: 'trivy-results.sarif' tenant-isolation-tests: diff --git a/docs/architecture/adrs/0071-auth-graph-engine.md b/docs/architecture/adrs/0071-auth-graph-engine.md index fa9e4490..e855f811 100644 --- a/docs/architecture/adrs/0071-auth-graph-engine.md +++ b/docs/architecture/adrs/0071-auth-graph-engine.md @@ -37,7 +37,7 @@ AuthorizationGraph ├── context — user, tenant, systemSuite, role, profile, branch ├── authentication — method (Local|IDP), provider, mfaRequired, expiry ├── actions[] — all registered actions in the SystemSuite -├── menuAccess[] — Module→Menu→SubMenu→Option tree with AccessEffect per option +├── menuAccess[] — Module→MenuNode recursive tree (ADR-0090), AccessEffect per node action ├── domainPermissions[] — domain resources with effect per action (Aggregate/Entity) ├── featureFlags[] — flags evaluated against user context at auth-time ├── effectiveConfig — tenant-resolved parameters (session timeout, MFA, etc.) diff --git a/docs/architecture/adrs/0090-recursive-menu-node-tree.es.md b/docs/architecture/adrs/0090-recursive-menu-node-tree.es.md new file mode 100644 index 00000000..c906587e --- /dev/null +++ b/docs/architecture/adrs/0090-recursive-menu-node-tree.es.md @@ -0,0 +1,97 @@ +# ADR-0090: El Árbol Recursivo `MenuNode` Sustituye a `Menu` / `SubMenu` / `Option` + +**Estado:** Aceptado +**Fecha:** 2026-08-10 +**Responsable de Decisión:** Arquitectura +**Reemplaza:** La jerarquía rígida de cuatro niveles Suite → Módulo → Menú → Submenú → Opción +**Relacionado:** [ADR-0071](./0071-auth-graph-engine.es.md) · gap G-029 · decisión D-009 + +--- + +> **Registro retroactivo.** La decisión se implementó antes de escribirse. Este ADR se reconstruye +> a partir del código en producción (`Ums.Domain/Authorization/SystemSuite/MenuNode/`) y de los más +> de quince documentos que ya la citan como aceptada. Documenta lo que el sistema hace hoy; no +> propone un cambio. + +## Contexto + +La topología de navegación de una suite se modelaba como una **cadena fija de cuatro niveles**: + +``` +SystemSuite → Module → Menu → SubMenu → Option +``` + +Tres entidades propias distintas —`Menu`, `SubMenu`, `Option`— expresaban tres niveles de la misma +idea: *un sitio en un árbol*. Esa rigidez traía problemas concretos: + +1. **Un nivel obligatorio que nadie quería.** Colgar una opción directamente de un menú era + imposible: había que inventar un submenú de relleno. Esos rellenos existen en los datos sembrados + y en las suites de clientes, y en la interfaz se ven como ramas vacías. +2. **La relación funcionalidad↔opción era 1:1 y débil.** El vínculo era un `ActionCode` en texto + sobre la opción, sin clave ajena. Una misma funcionalidad no podía alcanzarse desde dos sitios + del menú, y nada impedía que una opción nombrara una acción inexistente. +3. **Sin metadatos de gobernanza por nodo.** `Status` existía en la suite y en el módulo. El nodo + —lo que una persona navega, y por lo que pregunta una auditoría— no llevaba responsable, ni + criticidad, ni trazabilidad al artefacto SDLC que lo justifica. +4. **Tres de todo.** Tres agregados de comandos, manejadores, validadores, configuraciones EF y + registros para expresar un único concepto recursivo. Añadir una regla obligaba a añadirla tres + veces, y las tres copias derivaban. + +## Decisión + +Modelar la topología de navegación como una **única entidad recursiva**, `MenuNode`, propiedad de +`Module`. + +- Un `Module` posee una colección de **nodos raíz**; cada nodo puede anidar hijos recursivamente + (lista de adyacencia mediante `ParentNodeId`). +- El papel del nodo se clasifica con `NodeKind` —`Menu`, `SubMenu`, `Option`— **sin fijar la + profundidad**. `Menu` y `SubMenu` actúan como rama y `Option` como hoja. Los nombres sobreviven + como *roles*, no como tipos, porque es el vocabulario que el negocio ya usa. +- El vínculo con la funcionalidad pasa a ser **N:M** mediante la tabla puente + `SystemSuiteNodeActions`, de modo que una funcionalidad se alcanza desde varios sitios y todo + vínculo apunta a una acción que existe. +- Cada nodo lleva `MenuNodeMetadata`, un objeto de valor con campos de gobernanza SDLC + (responsable, criticidad, producto impactado, componente técnico, dependencias, evidencias, + trazabilidad SDLC). Todos opcionales; se reemplazan de forma atómica. + +| Dimensión | Modelo rígido (retirado) | Árbol `MenuNode` | +|---|---|---| +| Profundidad | Fija de 4 niveles, submenú obligatorio | Variable; submenú opcional | +| Funcionalidad↔opción | 1:1 débil (`ActionCode` sin FK) | **N:M** vía `SystemSuiteNodeActions` | +| Metadatos de gobernanza | Solo `Status` en suite/módulo | **Metadatos SDLC por nodo** (`MenuNodeMetadata`) | +| Entidades | `Menu`, `SubMenu`, `Option` | Un único `MenuNode` recursivo | + +Las operaciones siguen en la raíz de agregado `SystemSuite`, que delega en `Module`/`MenuNode`: +`AddModuleRootNode`, `AddModuleChildNode`, `UpdateModuleNode`, `RemoveModuleNode` (el nodo **y su +subárbol**), `ActivateModuleNode` / `DeactivateModuleNode`, `LinkModuleNodeAction` / +`UnlinkModuleNodeAction`, `SetModuleNodeMetadata`. + +## Consecuencias + +**Se gana.** Una entidad, un juego de reglas. La profundidad la marca el producto y no el esquema. +Una funcionalidad alcanzable desde dos menús es expresable. Cada nodo puede responder «quién es su +responsable y por qué existe». + +**Se paga.** El árbol se guarda plano y se reconstruye en memoria +(`AuthorizationAggregateFactory.RehydrateNode`, agrupando por `ParentNodeId`); una suite profunda +cuesta un recorrido de sus nodos al cargar. La recursión admite ciclos que una cadena fija no +permitía, así que la invariante «un nodo no es su propio ancestro» pasa a exigir vigilancia en vez +de darse por supuesta. + +**Se retira.** `Menu`, `SubMenu` y `Option`, con sus comandos, manejadores, validadores, +configuraciones EF y registros, **ya no existen en el código**. Tampoco `SystemSuiteMenuRecord`, +`SystemSuiteSubMenuRecord` ni `SystemSuiteOptionRecord`. La documentación que siga describiendo la +cadena de cuatro niveles como vigente está obsoleta, no describe una alternativa. + +## Persistencia + +- `ums_authorization.SystemSuiteNodes` — lista de adyacencia (`ParentNodeId`), con columnas de + metadatos SDLC. +- `ums_authorization.SystemSuiteNodeActions` — puente N:M, nodo ↔ `ActionCode`. +- Migración `20260715165202_AddSystemSuiteNodes`. + +## Referencias + +- Ficha de dominio: [`domain-es/authorization/menu-node.md`](../../domain-es/authorization/menu-node.md) +- Agregado: [`domain-es/authorization/system-suite.md`](../../domain-es/authorization/system-suite.md) +- Código: `src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/MenuNode/` diff --git a/docs/architecture/adrs/0090-recursive-menu-node-tree.md b/docs/architecture/adrs/0090-recursive-menu-node-tree.md new file mode 100644 index 00000000..28721401 --- /dev/null +++ b/docs/architecture/adrs/0090-recursive-menu-node-tree.md @@ -0,0 +1,102 @@ +--- +adr: 0090 +title: Recursive MenuNode tree replaces the rigid Menu/SubMenu/Option hierarchy +status: Accepted +date: 2026-08-10 +tags: [EvolithSatellite, authorization, system-suite, navigation, domain-model] +supersedes: none +relates: [ADR-0071 auth graph engine, ADR-0081 semantic auth graph client contract] +gap: G-029 +decision: D-009 +--- + +# ADR-0090 — A Recursive `MenuNode` Tree Replaces `Menu` / `SubMenu` / `Option` + +> **Retroactive record.** The decision was implemented before it was written down. This ADR is +> reconstructed from the shipped code (`Ums.Domain/Authorization/SystemSuite/MenuNode/`) and from +> the fifteen-plus documents that already cite it as accepted. It documents what the system does +> today; it does not propose a change. + +## Status + +Accepted. Implemented and in production. + +## Context + +The navigation topology of a system suite was modelled as a **fixed four-level chain**: + +``` +SystemSuite → Module → Menu → SubMenu → Option +``` + +Three separate owned entities — `Menu`, `SubMenu`, `Option` — expressed three levels of the same +idea: *a place in a tree*. That rigidity produced concrete problems: + +1. **A mandatory level nobody wanted.** Anchoring an option directly under a menu was impossible; + a filler submenu had to be invented for it. Those fillers exist in seeded data and in customer + suites, and they show up in the UI as empty branches. +2. **Functionality ↔ option was 1:1 and weak.** The link was an `ActionCode` string on the option, + with no foreign key. The same functionality could not be reached from two places in the menu, + and nothing stopped an option from naming an action that did not exist. +3. **No governance metadata per node.** `Status` existed on the suite and the module. A node — the + thing a person actually navigates to, and the thing an auditor asks about — carried no owner, + no criticality, no traceability to the SDLC artefact that justifies it. +4. **Three of everything.** Three aggregates' worth of commands, handlers, validators, EF + configurations and records to express one recursive concept. Adding a rule meant adding it + three times, and the three copies drifted. + +## Decision + +Model the navigation topology as a **single recursive entity**, `MenuNode`, owned by `Module`. + +- A `Module` owns a collection of **root nodes**; every node may nest children recursively + (adjacency list via `ParentNodeId`). +- The node's role is classified by `NodeKind` — `Menu`, `SubMenu`, `Option` — **without fixing the + depth**. `Menu` and `SubMenu` behave as branches, `Option` as a leaf. The names survive as + *roles*, not as types, because that is the vocabulary the business already uses. +- Functionality binding becomes **N:M** through the `SystemSuiteNodeActions` bridge table, so one + functionality can be reached from several places and every link points at an action that exists. +- Every node carries `MenuNodeMetadata`, a value object with SDLC governance fields (owner, + criticality, impacted product, technical component, dependencies, evidence, SDLC traceability). + All optional; replaced atomically. + +| Dimension | Rigid model (withdrawn) | `MenuNode` tree | +|---|---|---| +| Depth | Fixed, 4 levels, submenu mandatory | Variable; submenu optional | +| Functionality ↔ option | 1:1, weak (`ActionCode` string, no FK) | **N:M** via `SystemSuiteNodeActions` | +| Governance metadata | `Status` on suite/module only | **Per-node SDLC metadata** (`MenuNodeMetadata`) | +| Entities | `Menu`, `SubMenu`, `Option` | One recursive `MenuNode` | + +Operations stay on the `SystemSuite` aggregate root, which delegates to `Module`/`MenuNode`: +`AddModuleRootNode`, `AddModuleChildNode`, `UpdateModuleNode`, `RemoveModuleNode` (node **and its +subtree**), `ActivateModuleNode` / `DeactivateModuleNode`, `LinkModuleNodeAction` / +`UnlinkModuleNodeAction`, `SetModuleNodeMetadata`. + +## Consequences + +**Gained.** One entity, one set of rules. Depth follows the product instead of the schema. A +functionality reachable from two menus is expressible. Every node can answer "who owns this and +why does it exist". + +**Paid.** The tree is stored flat and rebuilt in memory +(`AuthorizationAggregateFactory.RehydrateNode`, grouping by `ParentNodeId`); a deep suite costs one +pass over its nodes on load. Recursion admits cycles that a fixed chain could not, so the invariant +"a node is not its own ancestor" now has to be enforced rather than assumed. + +**Migrated away.** `Menu`, `SubMenu` and `Option` and their commands, handlers, validators, EF +configurations and records are **gone from the code**. `SystemSuiteMenuRecord`, +`SystemSuiteSubMenuRecord` and `SystemSuiteOptionRecord` no longer exist. Documentation still +describing the four-level chain as current is stale, not describing an alternative. + +## Persistence + +- `ums_authorization.SystemSuiteNodes` — adjacency list (`ParentNodeId`), with SDLC metadata columns. +- `ums_authorization.SystemSuiteNodeActions` — N:M bridge, node ↔ `ActionCode`. +- Migration `20260715165202_AddSystemSuiteNodes`. + +## References + +- Domain sheet: [`domain-es/authorization/menu-node.md`](../../domain-es/authorization/menu-node.md) · + [`domain/authorization/menu-node.md`](../../domain/authorization/menu-node.md) +- Aggregate: [`domain-es/authorization/system-suite.md`](../../domain-es/authorization/system-suite.md) +- Code: `src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/MenuNode/` diff --git a/docs/architecture/adrs/0164-branch-closure-is-terminal.es.md b/docs/architecture/adrs/0164-branch-closure-is-terminal.es.md new file mode 100644 index 00000000..805eb809 --- /dev/null +++ b/docs/architecture/adrs/0164-branch-closure-is-terminal.es.md @@ -0,0 +1,96 @@ +# ADR-0164: Cerrar una Sucursal Es Terminal y Lógico + +**Estado:** Aceptado +**Fecha:** 2026-08-10 +**Responsable de Decisión:** Arquitectura +**Reemplaza:** `Tenant.RemoveBranch` — el borrado físico de sucursales +**Relacionado:** [ADR-0071](./0071-auth-graph-engine.es.md) + +--- + +> **Registro retroactivo.** La decisión se implementó antes de escribirse. Este ADR se reconstruye +> a partir del código en producción (`Ums.Domain/Identity/Tenant/Tenant.cs`, `…/Branch/`) y de los +> documentos que ya la citan como aceptada. + +## Contexto + +`Tenant.RemoveBranch` quitaba la sucursal de la colección del agregado, y el reconciliador de +colecciones hijas de EF lo traducía en un `DELETE` real. Tres cosas estaban mal: + +1. **Dejaba huérfanos en silencio.** `Profiles.BranchId` y `UserAccounts.BranchId` **no tienen clave + ajena** contra `TenantBranches`. Al borrar la sucursal, esas filas quedaban apuntando a nada, y + nada protestaba. +2. **Hacía inexplicable el pasado.** Un despacho de 2024 registra la sucursal de la que salió. Una + vez borrada, la pregunta «¿de dónde salió esto?» no tiene respuesta. Para un operador aduanero + eso no es una pérdida cosmética. +3. **Liberaba el código de la sucursal.** Una sucursal nueva podía tomar el código de una borrada, + así que una consulta sobre datos históricos no podía decir a cuál de las dos se refería. + +Había además una confusión más sutil: desactivar y eliminar se trataban como puntos de un mismo eje +—desactiva y luego borra— cuando responden a preguntas distintas. «¿Esta sucursal opera ahora +mismo?» es reversible. «¿Esta sucursal sigue existiendo como lugar?» no lo es. + +## Decisión + +### §2.1 — El cierre es lógico y terminal; la colección nunca encoge + +`CloseBranch` sustituye a `RemoveBranch`. No hay borrado físico. La fila permanece para que las +operaciones pasadas sigan siendo explicables, y la colección de sucursales del agregado **nunca +encoge**: la vía de escritura necesita ver las cerradas, y resolver una sucursal por id desde un +perfil antiguo tiene que seguir encontrándola. Quien *lista* sucursales para un humano filtra por +`!IsClosed` (véase `GetBranchesByTenantIdQueryHandler`). + +### §2.2 — El cierre lo bloquean las referencias VIVAS, y se dice cuáles + +Una sucursal no se cierra mientras existan registros ACTIVOS de `UserAccount` o `Profile` que la +apunten. Lo ya eliminado o desactivado no bloquea: el recuento solo mira lo vivo. La guarda se +verifica **antes** de actuar y rechaza nombrando qué bloquea —el análogo de un `ON DELETE +RESTRICT`—, nunca arrastra en cascada ni huerfaniza. Ambas clases de bloqueo se informan a la vez, +mediante `BlockingDependency`, para no obligar a quien opera a descubrirlas de una en una. El código +de error único es `BRANCH_HAS_LIVE_REFERENCES`. + +Los recuentos viven en otros agregados, así que los aporta la aplicación y el dominio solo decide +con ellos —la misma forma que `UserAccount.Delete(activeProfileCount)`—. + +### §2.3 — El código de una sucursal cerrada no se libera nunca + +La unicidad del código dentro del inquilino se evalúa **incluyendo las sucursales cerradas**. +Liberarlo permitiría dos sucursales distintas con el mismo código bajo el mismo inquilino, y una +consulta sobre un despacho de 2024 no podría decir a cuál se refiere. El índice único de la base +tampoco filtra por estado, así que ambos lados dicen lo mismo. + +### §2.4 — Desactivar y cerrar son verbos DISTINTOS + +Una sucursal cerrada no se reactiva ni se desactiva, y al estado terminal **no se llega manipulando +`IsActive`**. Desactivar es una pausa reversible; cerrar no se revierte. Ninguno lleva al otro: +cerrar no exige desactivar antes, y desactivar no acerca al cierre. + +### §2.5 — Cada episodio del ciclo de vida se anota + +Apertura, desactivación, reactivación y cierre se anotan en `TenantBranchLifecycleEntries` **dentro +de la misma transacción**, con fecha, actor y la foto de la sucursal (nombre y geocerca) de esa +época. La bitácora no viaja con el agregado: se lee aparte. + +## Consecuencias + +**Se gana.** La historia sigue siendo explicable. No hay huérfanos. Un código significa una +sucursal, para siempre. Las dos preguntas del ciclo de vida las responden dos verbos que ya no se +confunden. + +**Se paga.** La colección de sucursales crece de forma monótona, así que un inquilino longevo +arrastra al cargar todas las sucursales que tuvo. Las vías de lectura tienen que acordarse de +filtrar `!IsClosed`; olvidarlo enseña sucursales cerradas a los usuarios, y esa es una clase de +error real que este diseño introduce. + +**Se retira.** `RemoveBranchCommand` con su manejador, validador y respuesta **ya no existen en el +código**, ni `BranchRemovedEvent` —lo sustituye `BranchClosedEvent`, que además lleva el código, +porque quien lo consuma necesita saber QUÉ código queda ocupado y no puede resolverlo releyendo una +fila que ya no debe listarse—. La documentación que siga describiendo `RemoveBranch`, o que dibuje +la salida del agregado como un borrado condicionado a la inactividad, está obsoleta. + +## Referencias + +- Ficha de dominio: [`domain-es/identity/tenant.md`](../../domain-es/identity/tenant.md) +- Código: `src/apps/ums.api/Ums.Domain/Identity/Tenant/Tenant.cs` (`CloseBranch`), + `src/apps/ums.api/Ums.Domain/Identity/Tenant/Branch/` +- Migración: `20260804194508_AddBranchClosureAndLifecycleLog` diff --git a/docs/architecture/adrs/0164-branch-closure-is-terminal.md b/docs/architecture/adrs/0164-branch-closure-is-terminal.md new file mode 100644 index 00000000..192c403c --- /dev/null +++ b/docs/architecture/adrs/0164-branch-closure-is-terminal.md @@ -0,0 +1,100 @@ +--- +adr: 0164 +title: Closing a branch is terminal and logical; there is no physical deletion +status: Accepted +date: 2026-08-10 +tags: [EvolithSatellite, identity, tenant, lifecycle, auditability] +supersedes: none +relates: [ADR-0071 auth graph engine] +--- + +# ADR-0164 — Closing a Branch Is Terminal and Logical + +> **Retroactive record.** The decision was implemented before it was written down. This ADR is +> reconstructed from the shipped code (`Ums.Domain/Identity/Tenant/Tenant.cs`, `…/Branch/`) and from +> the documents that already cite it as accepted. It documents what the system does today. + +## Status + +Accepted. Implemented and in production. + +## Context + +`Tenant.RemoveBranch` removed the branch from the aggregate's collection, and the EF child-collection +reconciler turned that into a real `DELETE`. Three things were wrong with it: + +1. **It orphaned rows in silence.** `Profiles.BranchId` and `UserAccounts.BranchId` have **no foreign + key** against `TenantBranches`. Deleting the branch left those pointing at nothing, and nothing + complained. +2. **It made the past unexplainable.** A dispatch from 2024 records the branch it left from. Once + that branch is deleted, the question "where did this leave from?" has no answer. For a customs + operator that is not a cosmetic loss. +3. **The branch code was freed.** A new branch could take the code of a deleted one, so a query over + historical data could not tell which of the two it meant. + +There was also a subtler confusion: deactivation and removal were treated as points on one axis — +deactivate, then delete — when they answer different questions. "Is this branch operating right now?" +is reversible. "Does this branch still exist as a place?" is not. + +## Decision + +### §2.1 — Closure is logical and terminal; the collection never shrinks + +`CloseBranch` replaces `RemoveBranch`. There is no physical deletion. The row stays so past +operations remain explainable, and the aggregate's branch collection **never shrinks** — the write +path needs to see closed branches, and resolving a branch by id from an old profile must still find +it. Whoever *lists* branches for a human filters by `!IsClosed` (see `GetBranchesByTenantIdQueryHandler`). + +### §2.2 — Closure is blocked by LIVE references, and says which + +A branch does not close while ACTIVE `UserAccount` or `Profile` records point at it. Already-deleted +or deactivated records do not block: the count only looks at what is live. The guard runs **before** +acting and rejects naming what blocks — the analogue of `ON DELETE RESTRICT`, never a cascade and +never an orphan. Both classes of blocker are reported at once, through `BlockingDependency`, so an +operator does not discover them one at a time. The single error code is `BRANCH_HAS_LIVE_REFERENCES`. + +The counts live in other aggregates, so the application supplies them and the domain only decides — +the same shape as `UserAccount.Delete(activeProfileCount)`. + +### §2.3 — A closed branch's code is never freed + +Code uniqueness within a tenant is evaluated **including closed branches**. Freeing the code would +allow two different branches with the same code under one tenant, and a query about a 2024 dispatch +could not say which. The database's unique index does not filter by state either, so both sides say +the same thing. + +### §2.4 — Deactivating and closing are different verbs + +A closed branch is neither reactivated nor deactivated, and the terminal state is **not reachable by +manipulating `IsActive`**. Deactivation is a reversible pause; closure does not revert. Neither +leads to the other: closing does not require deactivating first, and deactivating does not bring a +branch closer to closure. + +### §2.5 — Every lifecycle episode is journalled + +Opening, deactivation, reactivation and closure are each recorded in `TenantBranchLifecycleEntries` +**within the same transaction**, with date, actor, and a snapshot of the branch (name and geofence) +as it was then. The journal does not travel with the aggregate; it is read separately. + +## Consequences + +**Gained.** History stays explainable. No orphans. A code means one branch, for good. The two +lifecycle questions are answered by two verbs that cannot be confused. + +**Paid.** The branch collection grows monotonically, so a long-lived tenant carries every branch it +ever had on aggregate load. Read paths must remember to filter `!IsClosed`; forgetting shows closed +branches to users, which is a real class of bug this design introduces. + +**Migrated away.** `RemoveBranchCommand` and its handler, validator and response are **gone from the +code**, along with `BranchRemovedEvent` — replaced by `BranchClosedEvent`, which additionally carries +the code, because a consumer needs to know *which* code stays occupied and cannot resolve it by +re-reading a row that should no longer be listed. Documentation still describing `RemoveBranch`, or +drawing branch removal as the aggregate's exit conditioned on inactivity, is stale. + +## References + +- Domain sheet: [`domain-es/identity/tenant.md`](../../domain-es/identity/tenant.md) · + [`domain/identity/tenant.md`](../../domain/identity/tenant.md) +- Code: `src/apps/ums.api/Ums.Domain/Identity/Tenant/Tenant.cs` (`CloseBranch`), + `src/apps/ums.api/Ums.Domain/Identity/Tenant/Branch/` +- Migration: `20260804194508_AddBranchClosureAndLifecycleLog` diff --git a/docs/architecture/adrs/index.es.md b/docs/architecture/adrs/index.es.md index 358e0380..a408d726 100644 --- a/docs/architecture/adrs/index.es.md +++ b/docs/architecture/adrs/index.es.md @@ -42,6 +42,8 @@ UMS es un repositorio satelite de `evolith_arch32`. El repositorio padre define | [ADR-0080](./0080-auth-graph-preview-internal-pipeline.es.md) | Preview de auth graph interno | Aceptado | | [ADR-0081](./0081-semantic-auth-graph-client-contract.es.md) | Contrato semantico del auth graph cliente | Propuesto | | [ADR-0082](./0082-postgresql-authoritative-persistence-baseline.es.md) | Linea base autoritativa de persistencia PostgreSQL | Aceptado | +| [ADR-0090](./0090-recursive-menu-node-tree.es.md) | El Árbol Recursivo MenuNode Sustituye a Menu/SubMenu/Option | Aceptado | +| [ADR-0164](./0164-branch-closure-is-terminal.es.md) | Cerrar una Sucursal Es Terminal y Lógico | Aceptado | --- diff --git a/docs/architecture/adrs/index.md b/docs/architecture/adrs/index.md index 5f0a1127..aa124659 100644 --- a/docs/architecture/adrs/index.md +++ b/docs/architecture/adrs/index.md @@ -59,12 +59,14 @@ UMS is a satellite repository of `evolith_arch32`. The parent repository defines | [ADR-0080](./0080-auth-graph-preview-internal-pipeline.md) | Auth Graph Preview — Internal vs External Pipeline | Accepted | > **Evolith candidate** - ADR has zero UMS-specific dependencies and is proposed for extraction to the Evolith parent architecture baseline. | [ADR-0081](./0081-semantic-auth-graph-client-contract.md) | Semantic Auth Graph Client Contract — Code-First, ID-Optional | Proposed | | [ADR-0082](./0082-postgresql-authoritative-persistence-baseline.md) | PostgreSQL Authoritative Persistence Baseline | Accepted | +| [ADR-0090](./0090-recursive-menu-node-tree.md) | Recursive MenuNode Tree Replaces Menu/SubMenu/Option | Accepted | +| [ADR-0164](./0164-branch-closure-is-terminal.md) | Branch Closure Is Terminal and Logical | Accepted | --- ## Bilingual Coverage (R-01 Compliance) -All ADRs (0050-0082) now have Spanish translations: +All ADRs (0050-0082) have Spanish translations, as do ADR-0090 and ADR-0164: | ADR | Spanish | ADR | Spanish | |-----|---------|-----|---------| diff --git a/docs/architecture/api-dotnet/ums-api-dotnet-applied-reference.es.md b/docs/architecture/api-dotnet/ums-api-dotnet-applied-reference.es.md index 1c71c0f0..f2b4ad78 100644 --- a/docs/architecture/api-dotnet/ums-api-dotnet-applied-reference.es.md +++ b/docs/architecture/api-dotnet/ums-api-dotnet-applied-reference.es.md @@ -25,7 +25,7 @@ Perfil observado: | Frontera de aplicacion | MediatR y FluentValidation | | Superficie API | Comandos REST y consultas GraphQL | | Versionado | Versionado API por segmento URL | -| Persistencia | EF Core con baseline SQL Server y soporte SQLite local | +| Persistencia | EF Core sobre PostgreSQL, único proveedor relacional (ADR-0082). SQL Server y SQLite se retiraron; el esquema lo crean las migraciones de EF, no un bootstrapper | | Operaciones | Logs estructurados, registro de telemetria, health checks, rate limits, background workers | | Politicas transversales | Aspectos para auditoria, transacciones, validacion de tenant y logging | diff --git a/docs/architecture/api-dotnet/ums-api-dotnet-applied-reference.md b/docs/architecture/api-dotnet/ums-api-dotnet-applied-reference.md index 4925d011..cdf8fe8e 100644 --- a/docs/architecture/api-dotnet/ums-api-dotnet-applied-reference.md +++ b/docs/architecture/api-dotnet/ums-api-dotnet-applied-reference.md @@ -25,7 +25,7 @@ Observed profile: | Application boundary | MediatR and FluentValidation | | API surface | REST commands and GraphQL queries | | Versioning | URL segment API versioning | -| Persistence | EF Core with SQL Server baseline and local SQLite support | +| Persistence | EF Core on PostgreSQL, the single relational provider (ADR-0082). SQL Server and SQLite were withdrawn; the schema is created by EF migrations, not by a bootstrapper | | Operations | Structured logs, telemetry registration, health checks, rate limits, background workers | | Cross-cutting policies | Aspects for audit, transactions, tenant validation, and logging | diff --git a/docs/architecture/blueprints-es/database-design-er.md b/docs/architecture/blueprints-es/database-design-er.md index cdef39e1..143c3395 100644 --- a/docs/architecture/blueprints-es/database-design-er.md +++ b/docs/architecture/blueprints-es/database-design-er.md @@ -1,5 +1,12 @@ # Modelo Entidad-Relación (E/R) - SQL Server 2022 +> **Parcialmente superado (ADR-0090).** La cadena `FUNCTIONAL_MENU` / `FUNCTIONAL_SUBMENU` / +> `FUNCTIONAL_OPTION` que aparece más abajo **ya no existe**. La navegación es hoy una única tabla +> recursiva, `ums_authorization.SystemSuiteNodes` (lista de adyacencia por `ParentNodeId`), más la +> tabla puente N:M `ums_authorization.SystemSuiteNodeActions`. El resto del documento sigue siendo +> válido. Fuente de verdad actual: [ADR-0090](../adrs/0090-recursive-menu-node-tree.es.md) y +> [`domain-es/authorization/menu-node.md`](../../domain-es/authorization/menu-node.md). + **Tipo de Documento:** Diseño de Base de Datos **Estado:** Refactorizado (Alcance por Rol y Jerarquía Estricta) **Arquitectura:** Marco Maestro Jerárquico (Control de 5 Niveles) diff --git a/docs/architecture/blueprints-es/er-export-formats.md b/docs/architecture/blueprints-es/er-export-formats.md index de58c236..bf3e00fb 100644 --- a/docs/architecture/blueprints-es/er-export-formats.md +++ b/docs/architecture/blueprints-es/er-export-formats.md @@ -1,5 +1,12 @@ # UMS E/R Model - Export Formats & Alternatives +> **Parcialmente superado (ADR-0090).** La cadena `FUNCTIONAL_MENU` / `FUNCTIONAL_SUBMENU` / +> `FUNCTIONAL_OPTION` que aparece más abajo **ya no existe**. La navegación es hoy una única tabla +> recursiva, `ums_authorization.SystemSuiteNodes` (lista de adyacencia por `ParentNodeId`), más la +> tabla puente N:M `ums_authorization.SystemSuiteNodeActions`. El resto del documento sigue siendo +> válido. Fuente de verdad actual: [ADR-0090](../adrs/0090-recursive-menu-node-tree.es.md) y +> [`domain-es/authorization/menu-node.md`](../../domain-es/authorization/menu-node.md). + If Mermaid visualization is failing or insufficient, use these industry-standard formats to visualize the **Advanced IGA, Role Evolution & Hierarchical Configuration Framework**. ## 1. dbdiagram.io (DBML - Recommended) diff --git a/docs/architecture/blueprints/data-model-consistency-review.md b/docs/architecture/blueprints/data-model-consistency-review.md index fb3810e6..6869062c 100644 --- a/docs/architecture/blueprints/data-model-consistency-review.md +++ b/docs/architecture/blueprints/data-model-consistency-review.md @@ -1,5 +1,12 @@ # Data Model Consistency Review +> **Superseded in part (ADR-0090).** The `FUNCTIONAL_MENU` / `FUNCTIONAL_SUBMENU` / +> `FUNCTIONAL_OPTION` chain shown below **no longer exists**. Navigation is now a single recursive +> table, `ums_authorization.SystemSuiteNodes` (adjacency list via `ParentNodeId`), plus the N:M +> bridge `ums_authorization.SystemSuiteNodeActions`. Everything else in this document still holds. +> Current source of truth: [ADR-0090](../adrs/0090-recursive-menu-node-tree.md) and +> [`domain-es/authorization/menu-node.md`](../../domain-es/authorization/menu-node.md). + **Document Type:** Architecture Consistency Review **Status:** Active Reference **Scope:** Conceptual model, DDD aggregate model, physical ER, and EF Core persistence records diff --git a/docs/architecture/blueprints/database-design-er.md b/docs/architecture/blueprints/database-design-er.md index ed8778a1..330fa5a9 100644 --- a/docs/architecture/blueprints/database-design-er.md +++ b/docs/architecture/blueprints/database-design-er.md @@ -1,5 +1,12 @@ # Entity-Relationship (E/R) Model - SQL Server 2022 +> **Superseded in part (ADR-0090).** The `FUNCTIONAL_MENU` / `FUNCTIONAL_SUBMENU` / +> `FUNCTIONAL_OPTION` chain shown below **no longer exists**. Navigation is now a single recursive +> table, `ums_authorization.SystemSuiteNodes` (adjacency list via `ParentNodeId`), plus the N:M +> bridge `ums_authorization.SystemSuiteNodeActions`. Everything else in this document still holds. +> Current source of truth: [ADR-0090](../adrs/0090-recursive-menu-node-tree.md) and +> [`domain-es/authorization/menu-node.md`](../../domain-es/authorization/menu-node.md). + **Document Type:** Database Design **Status:** Refactored (Role-Scoped & Strict Hierarchy) **Architecture:** Hierarchical Master Framework (5-Level Control) diff --git a/docs/architecture/blueprints/er-export-formats.md b/docs/architecture/blueprints/er-export-formats.md index 3a8263ac..d3f769ca 100644 --- a/docs/architecture/blueprints/er-export-formats.md +++ b/docs/architecture/blueprints/er-export-formats.md @@ -1,5 +1,12 @@ # UMS E/R Model - Export Formats & Alternatives +> **Superseded in part (ADR-0090).** The `FUNCTIONAL_MENU` / `FUNCTIONAL_SUBMENU` / +> `FUNCTIONAL_OPTION` chain shown below **no longer exists**. Navigation is now a single recursive +> table, `ums_authorization.SystemSuiteNodes` (adjacency list via `ParentNodeId`), plus the N:M +> bridge `ums_authorization.SystemSuiteNodeActions`. Everything else in this document still holds. +> Current source of truth: [ADR-0090](../adrs/0090-recursive-menu-node-tree.md) and +> [`domain-es/authorization/menu-node.md`](../../domain-es/authorization/menu-node.md). + If Mermaid visualization is failing or insufficient, use these industry-standard formats to visualize the **Advanced IGA, Role Evolution & Hierarchical Configuration Framework**. ## 1. dbdiagram.io (DBML - Recommended) diff --git a/docs/architecture/blueprints/service-entity-map.md b/docs/architecture/blueprints/service-entity-map.md index 45dbad49..c88203a5 100644 --- a/docs/architecture/blueprints/service-entity-map.md +++ b/docs/architecture/blueprints/service-entity-map.md @@ -1,5 +1,12 @@ # Service-Entity Map & Data Ownership +> **Superseded in part (ADR-0090).** The `FUNCTIONAL_MENU` / `FUNCTIONAL_SUBMENU` / +> `FUNCTIONAL_OPTION` chain shown below **no longer exists**. Navigation is now a single recursive +> table, `ums_authorization.SystemSuiteNodes` (adjacency list via `ParentNodeId`), plus the N:M +> bridge `ums_authorization.SystemSuiteNodeActions`. Everything else in this document still holds. +> Current source of truth: [ADR-0090](../adrs/0090-recursive-menu-node-tree.md) and +> [`domain-es/authorization/menu-node.md`](../../domain-es/authorization/menu-node.md). + This document serves as the authoritative mapping between system entities, their Bounded Contexts, owning services, and database schemas within the UMS enterprise ecosystem. --- diff --git a/docs/architecture/e2e-dashboard-parallelization-plan.es.md b/docs/architecture/e2e-dashboard-parallelization-plan.es.md new file mode 100644 index 00000000..6224262b --- /dev/null +++ b/docs/architecture/e2e-dashboard-parallelization-plan.es.md @@ -0,0 +1,253 @@ +# Plan de paralelización — cerrar la integración E2E UMS ↔ Tablero SDLC + +**Repositorios:** `ums` (rama `develop`) · `evolith-core` (rama `develop`) +**Fecha:** 2026-08-02 · **Autor:** Arquitecto Enterprise · **Tipo:** Plan de ejecución +**Insumo:** [análisis de integración E2E](./analisis-integracion-e2e-ums-tablero-sdlc.md) · [diseño de selección de sistema](./diseno-seleccion-de-sistema-en-autenticacion.md) +**Norma vigente:** `ADR-0156` (grafo por API, sistema solicitado, multi-perfil) y `ADR-0157` (firma asimétrica, token corto), ambos `Aceptado` en `evolith-core` + +> **Para qué sirve este documento.** El trabajo pendiente está registrado en dos repositorios y se puede atacar por varios frentes a la vez. Lo que sigue reparte ese trabajo en **lotes que no se pisan**, separa lo que **bloquea la prueba** de lo que es **deuda que puede esperar**, y dice **qué no se puede cerrar hoy** — porque descubrirlo a media tarde cuesta más que leerlo ahora. + +--- + +## 0. Estado real del entorno, medido hoy + +Todo lo de esta sección está verificado el 2026-08-02 contra los procesos vivos. Lo que no se pudo medir se declara, no se rellena. + +| Pieza | Estado | Cómo se comprobó | +| :--- | :--- | :--- | +| UMS | Vivo en `http://localhost:5080`, `/health` → **200** | `curl` | +| Suite `SDLC` | **Cargada** en el inquilino `BEYONDNET` | `GET /api/v1/profiles` la devuelve en 10 perfiles | +| Cuentas del Tablero | **8**, activas, con contraseña determinista | `admin.sdlc.sdlc@`, `arquitecto.sdlc@`, `auditor.sdlc@`, `directorio.sdlc@`, `equipo.sdlc@`, `pmo.sdlc@`, `product.owner.sdlc@`, `tech.lead.sdlc@` | +| Multi-perfil | **Dos sujetos reales**: `equipo.sdlc@` (`EQUIPO` + `AUDITOR`) y `pmo.sdlc@` (`PMO` + `DIRECTORIO`) | `GET /api/v1/profiles?page=1&pageSize=100` → 23 perfiles / 21 usuarios | +| Autenticación de cliente | **200** con grafo `2.3.0` y `context.systemSuite.code = SDLC` | `POST /api/v1/client/authenticate?format=json` | +| **Concesiones del grafo** | **Vacías para todos los roles**: `menuAccess: []`, `domainPermissions: []`, `scopes: []` | Tres roles con matrices muy distintas (`TECH_LEAD` 20 concesiones, `ARQUITECTO` 11, `AUDITOR` 1) devuelven grafos **idénticos** | +| Causa | Las **8 plantillas de la suite `SDLC` están en `Draft`**; el cargador no las publica | `GET /api/v1/permission-templates` → 9 logísticas `Published`, 8 de `SDLC` `Draft` | +| Tablero — servidor | Vivo en `:4317`, `GET /api/auth/estado` → `{"configurado":true}` | `curl` | +| Tablero — web | Viva en `:5317` | `curl` | +| Tablero — login | **Corregido** el 502: `interpretarGrafo()` acepta la cadena serializada | `procesarLogin` → **200 con `Set-Cookie`** | +| Tablero — formulario | Sin campo de inquilino; lo aplica el servidor | `LoginPage.jsx` | +| **Tablero — sesión** | **`{"autenticado":false}` con cookie válida** | `resolverSesion` sobre la cookie que acaba de emitir el login | + +### 0.1 El 401 del Tablero: diagnosticado + +El encargo lo describía como un bloqueante sin causa. **Tiene causa, y está cerrada la cadena entera:** + +1. El proceso del servidor del Tablero corría con `UMS_BASE_URL=http://localhost:5000`. UMS escucha en **5080**. +2. En macOS el puerto 5000 lo ocupa el receptor AirPlay de `ControlCenter`: `POST http://localhost:5000/api/v1/client/authenticate` responde **`403 Forbidden`** con `Server: AirTunes/950.7.1`. +3. `procesarLogin` colapsa **cualquier** 401 o 403 del destino en `{"error":"Credenciales inválidas."}`. De ahí el 401 con credenciales correctas. +4. La misma credencial contra `:5080` devuelve **200**, y `procesarLogin` con `UMS_BASE_URL=http://localhost:5080` devuelve **200 con cookie**. + +Es decir: **el mensaje de error señalaba a la única pieza que estaba bien.** El arreglo inmediato es un valor de entorno; el arreglo duradero es que el servidor compruebe que su destino es UMS y no lo dé por supuesto — registrado como `G-283` en `evolith-core`. + +### 0.2 El bloqueante que el 401 tapaba + +Con `UMS_BASE_URL` corregido el login pasa, y aparece el siguiente: **la sesión no se sostiene**. `resolverSesion` sigue haciendo `grafoDeToken(payload)` sobre un token que —desde `ADR-0156` §2.3— ya no lleva grafo, así que devuelve `{autenticado:false}` **siempre**. El usuario teclea su contraseña, la aplicación la acepta y lo devuelve al formulario. + +Registrado como `G-290` en `evolith-core`. **Es el bloqueante número uno del camino crítico**, y es trabajo nuevo, no configuración: falta la caché de grafo en el servidor que `ADR-0156` §2.3 especifica. + +--- + +## 1. Camino crítico y deuda: la separación + +La prueba que justifica el encargo es una sola frase: **el Tablero se autentica contra UMS y su interfaz cambia según el perfil del grafo.** Todo lo que no haga falta para que esa frase sea cierta y observable es deuda, por importante que sea. + +### 1.1 Camino crítico — sin esto no hay prueba + +| # | Qué | Dónde | Gap | Por qué bloquea | +| ---: | :--- | :--- | :--- | :--- | +| 1 | Apuntar `UMS_BASE_URL` a `:5080` | Tablero (entorno) | `G-283` | Sin esto el login es 401 y todo lo demás es invisible | +| 2 | Sesión por API + caché de grafo en el servidor | `evolith-core` | `G-290` | Sin esto se entra y se sale en la misma pantalla | +| 3 | Publicar las 8 plantillas de la suite `SDLC` | `ums` | `G-220` | Sin esto el grafo llega vacío: no hay nada que gatear | +| 4 | Alinear los códigos de menú (decisión **D2**) | ambos | `G-216` / `G-277` | Con concesiones pero códigos distintos, la barra sigue vacía | +| 5 | `systemCode` en la autenticación de cliente | `ums` | `G-204` | Hoy funciona **por casualidad**: las 8 cuentas solo tienen perfiles `SDLC`. En cuanto una tenga perfil en otra suite, el desempate entrega el grafo equivocado | +| 6 | `profiles[].id` + `POST /client/switch-profile` | `ums` | `G-205`, `G-206` | El cambio de perfil es requisito funcional confirmado, y hoy el contrato ofrece la operación y retiene su clave | + +Sobre el punto 5, con honestidad: **una primera pasada verde es posible sin `systemCode`**, porque las cuentas del Tablero no tienen perfiles fuera de `SDLC`. Lo que no es posible sin él es afirmar que la especificación está implementada. Se mantiene en el camino crítico porque el propio cliente lo puso ahí, pero **no bloquea el primer semáforo verde** — y esa distinción es la que permite paralelizar. + +### 1.2 Deuda declarada — no bloquea, y no se olvida + +| Qué | Dónde | Gap | Por qué puede esperar | +| :--- | :--- | :--- | :--- | +| Migración a RS256 + JWKS (etapas E0–E3) | ambos | `G-199`, `G-278`, `G-203` | HS256 funciona hoy; `ADR-0157` ya fijó el destino y sus etapas | +| Token de 15 minutos (etapa E4) | `ums` | `G-219` | **Prohibido antes** del refresco por portador (`ADR-0157` §4.6) | +| Refresco por portador | `ums` | `G-218`, `G-213`, `G-187` | La prueba cabe en la vida del token si ningún escenario dura más de 60 min | +| SDK: `graph` como cadena; fixtures imposibles | `ums` | `G-207`, `G-208` | El Tablero **no usa el SDK**: vendoriza su contrato. Bloquea a otros consumidores, no a esta prueba | +| Pin de esquema del arnés RoboSoft en `2.2.0` | `ums` | `G-210` | Afecta al carril existente, no al nuevo | +| `SameSite=Lax` vs `Strict` | `evolith-core` | `G-284` | Divergencia a declarar; no rompe el flujo | +| Cookie `Secure` y topología | `evolith-core` | `G-282` | Solo muerde fuera de `localhost`; el primer ciclo es `localhost` | +| Clúster irreproducible, dev-abierto en el pod | `evolith-core` | `G-285`, `G-281` | El primer ciclo corre contra procesos locales, no contra los clústeres | +| Desempate por GUID; siembra multi-perfil | `ums` | `G-211`, `G-209` | Hay sujeto multi-perfil vivo; lo que falta es que sea reproducible desde cero | +| SDK sin verificar firma (los cuatro, no solo Express) | `ums` | `G-217` | Ningún consumidor de esta prueba lo usa. **Sigue siendo grave**: no es «poco importante», es «no bloqueante». Cerrado el 2026-08-03 | + +--- + +## 2. Reglas de reparto + +Tres restricciones mandan sobre el reparto, y no son negociables: + +1. **Dos agentes sobre la misma solución .NET se estorban.** `dotnet build` bloquea `obj/` y `bin/`; dos compilaciones concurrentes sobre el mismo árbol producen fallos que se leen como errores de código. Todo lote que toque `src/apps/ums.api/**` va en **worktree propio**. +2. **`ums` y `evolith-core` son repositorios distintos**, luego naturalmente paralelos: dos lotes en repositorios distintos nunca se pisan por construcción. +3. **El árbol Node del Tablero y el árbol .NET de UMS no comparten nada.** Un lote en `reference/governance/tablero-ejecutivo/app/**` y otro en `src/apps/ums.api/**` pueden correr a la vez sin coordinación. + +Y una regla de higiene que ya está aprendida: el `pre-push` escanea todo el árbol con gitleaks, así que **ningún lote versiona artefactos de prueba con credenciales**. Las contraseñas de las cuentas `*.sdlc@` son deterministas y derivables; no hace falta anotarlas en ninguna parte. + +--- + +## 3. Los lotes + +### Ola 1 — arranca ya, los cuatro a la vez + +#### Lote A · Sesión y caché de grafo en el Tablero — **camino crítico** + +| Campo | Contenido | +| :--- | :--- | +| **Repositorio** | `evolith-core` | +| **Gaps** | `G-290` (bloqueante), `G-280`, `G-283` | +| **Toca** | `reference/governance/tablero-ejecutivo/app/server/src/auth-ums.js` (`resolverSesion` + caché), `server/test/auth-ums.test.mjs`, arranque en `server/src/index.js` para la comprobación de destino | +| **NO toca** | Nada bajo `app/web/**` (es del lote B) · Nada de `ums` · `net-guard.js`, que es correcto donde está y solo se aplica a `evidencia_url` | +| **Verificación** | `POST /api/auth/login` seguido de `GET /api/auth/sesion` devuelve `autenticado:true` **con grafo**, en el mismo proceso y tras reiniciarlo; prueba en negativo con grafo vencido → `autenticado:false`; arrancar con `UMS_BASE_URL` apuntando a un no-UMS **falla o lo dice**, en vez de acusar a las credenciales | +| **Esfuerzo** | **Medio** | + +La caché es el único componente de servidor genuinamente nuevo del gate. Se indexa por `jti` o `session_tracking_id`, vive hasta `graph_valid_until` y revalida contra `GET /api/v1/client/graph`. Ante UMS caído **no** se confunde «no puedo revalidar» con «no estás autenticado» mientras la copia siga vigente (`ADR-0156` §2.10). + +#### Lote B · Publicación de plantillas y códigos de menú — **camino crítico** + +| Campo | Contenido | +| :--- | :--- | +| **Repositorio** | `ums` (cargador) **+** `evolith-core` (cliente web) — coordinado por la decisión **D2** | +| **Gaps** | `G-220` (bloqueante), `G-216`, `G-222`, `G-277` | +| **Toca** | `src/provisioning/sdlc/cargar-en-ums.mjs` (publicar las plantillas al cierre y **verificar** el estado resultante; normalizar `correoDe()`), `src/provisioning/sdlc/sdlc-suite.json` **o** `app/web/src/components/common.jsx` — uno de los dos, según D2 | +| **NO toca** | **Nada de `src/apps/ums.api/**`**: el cargador es Node y no compila la solución. Nada del servidor del Tablero (lote A) | +| **Verificación** | `GET /api/v1/permission-templates` no devuelve ninguna plantilla de `SDLC` en `Draft`; y los grafos de `TECH_LEAD`, `ARQUITECTO` y `AUDITOR` traen **`menuAccess` distinto entre sí y no vacío**, con códigos que el cliente web reconoce | +| **Esfuerzo** | **Corto** el publicado; **corto o largo** la alineación de códigos, según D2 | + +> **La decisión D2 sigue abierta y hay que tomarla antes de empezar el lote.** `ADR-0156` §2.9 mantiene la convención `TABLERO.*` y no elige lado. Cambiar cinco constantes en `common.jsx` cuesta minutos; renombrar 64 nodos ya trazados a 96 rutas de API arriesga la trazabilidad del inventario (`SD-05`). **Recomendación: que ceda el cliente web en el primer ciclo, declarando la divergencia respecto del ADR**, con la adopción de la convención como destino. Lo que no vale es aplicarlo en silencio. + +#### Lote C · Contrato de UMS: `systemCode`, `accessState` y `profiles[].id` — **camino crítico (especificación)** + +| Campo | Contenido | +| :--- | :--- | +| **Repositorio** | `ums`, **en worktree propio** | +| **Gaps** | `G-204`, `G-205`, `G-184` (mitad de contrato), `G-211` | +| **Toca** | `ClientAuthEndpoints.cs`, `AuthenticateUserCommand`, `AuthorizationGraphBuilderService`, `AuthGraphPayload.cs`, esquema del grafo a `2.4.0` | +| **NO toca** | `AuthEndpoints.cs` en su bloque `switch-profile`/`switch-tenant`: **es del lote D** y los dos lotes chocarían en el mismo archivo · Ningún archivo del Tablero · El cargador de provisión (lote B) | +| **Verificación** | `POST /client/authenticate` con `systemCode: "SDLC"` devuelve el grafo de `SDLC`; con un código inexistente y con uno sin perfil devuelve **el mismo 200** con `accessState: "NoProfileInSystem"` —indistinguibles, sin consultar el catálogo—; `profiles[].id` presente **siempre**, con `AUTH_GRAPH_INCLUDE_TECHNICAL_METADATA` en `false` | +| **Esfuerzo** | **Medio** | + +La propiedad anti-enumeración es estructural, no cosmética: el filtro se aplica **sobre los perfiles que el usuario ya tiene** y tiene prohibido consultar el catálogo de sistemas por código. Si hubiera dos ramas, alguna acabaría divergiendo en un mensaje, un status o un tiempo. + +#### Lote E · Deuda de contrato de los SDK — **no bloquea** + +| Campo | Contenido | +| :--- | :--- | +| **Repositorio** | `ums`, árbol principal (TypeScript y JSON, sin compilar .NET) | +| **Gaps** | `G-207`, `G-208`, `G-210`, `G-217` | +| **Toca** | `src/libs/sdk/typescript/**`, `src/libs/sdk/contracts/fixtures/**`, `src/tests/e2e-functional/robosoft/contexts/configuration.py` | +| **NO toca** | `src/apps/ums.api/**` (lote C) · el cargador de provisión (lote B) · nada del Tablero | +| **Verificación** | Un login por SDK contra la API real devuelve grafo, no `AuthGraphSchemaMissing`; ningún fixture declara `profiles: []` junto a `onboardingPending: false`; el pin de `schemaVersion` **se deriva del contrato publicado** en vez de repetirlo | +| **Esfuerzo** | **Medio** | + +Se puede arrancar hoy y terminar después del lote C — pero **el pin de esquema y los fixtures nuevos se cierran cuando el contrato `2.4.0` exista**, no antes. Ver §4. + +### Ola 2 — depende de la ola 1 + +#### Lote D · Cambio de perfil por el carril de satélite + +| Campo | Contenido | +| :--- | :--- | +| **Repositorio** | `ums`, **mismo worktree que el lote C, después de él** | +| **Gaps** | `G-206`, `G-177` (mitad de consumo), `G-201` | +| **Toca** | `ClientAuthEndpoints.cs` (endpoint nuevo `POST /client/switch-profile`), reutilizando `SwitchProfileCommand` y `BuildForProfileAsync` **sin tocarlos** | +| **NO toca** | `POST /auth/switch-profile`: **no se extiende**, porque valida el token a mano con `ValidateIssuer=false` (`G-201`) y devuelve una cookie de portal que el satélite no tiene | +| **Depende de** | Lote C: sin `profiles[].id` el cliente no tiene qué enviar | +| **Verificación** | `equipo.sdlc@` cambia de `AUDITOR` a `EQUIPO` con su portador y recibe un grafo distinto y coherente; el mismo intento sin `profileId` válido → 4xx | +| **Esfuerzo** | **Medio** | + +#### Lote F · El robot E2E + +| Campo | Contenido | +| :--- | :--- | +| **Repositorio** | `ums`, árbol principal | +| **Gaps** | cierra la mitad de validación de `G-276` (`evolith-core`) | +| **Toca** | `src/tests/e2e-functional/robosoft/integracion-tablero/**` (nuevo) y `scripts/certify-e2e.sh` (`--carril c`) | +| **NO toca** | `robosoft/api/**` ni `robosoft/contexts/**`: son el carril existente. **No se crea un `tests/e2e/` paralelo** | +| **Depende de** | Lotes A y B **cerrados**. Escribir el robot antes es escribir pruebas rojas que describen funcionalidad ausente — el error que ya documenta `G-189` | +| **Verificación** | La aserción es de **concordancia, no de presencia**: el conjunto de secciones visibles es exactamente el conjunto de códigos con `Allow` efectivo en el grafo de ese perfil. Así falla igual quien ve de más y quien ve de menos. «El administrador ve más que el auditor» pasaría hoy en verde sobre tres interfaces vacías, y ese es justo el falso positivo contra el que existe la prueba | +| **Esfuerzo** | **Largo** | + +#### Lote G · Entorno reproducible + +| Campo | Contenido | +| :--- | :--- | +| **Repositorio** | ambos | +| **Gaps** | `G-214` (`ums`), `G-285`, `G-281` (`evolith-core`) | +| **Toca** | `scripts/` de entorno en UMS, `k8s/kind-cluster.yaml` y `k8s/app.yaml` del Tablero | +| **NO toca** | Ningún archivo de aplicación de los lotes A–E | +| **Verificación** | Una ejecución en frío desde cero, **dos veces**, con el mismo punto de acceso y el mismo resultado | +| **Esfuerzo** | **Largo** | + +Es independiente del resto **y no está en el camino crítico del primer ciclo**, porque el primer ciclo corre contra procesos locales. Se puede arrancar en paralelo desde el minuto uno con quien sobre. + +### 3.1 Mapa de concurrencia + +| | A (arch/server) | B (ums/prov + arch/web) | C (ums/.NET wt) | E (ums/sdk) | G (infra) | +| :--- | :---: | :---: | :---: | :---: | :---: | +| **A** | — | ✅ | ✅ | ✅ | ✅ | +| **B** | ✅ | — | ✅ | ✅ | ✅ | +| **C** | ✅ | ✅ | — | ⚠️ | ✅ | +| **E** | ✅ | ✅ | ⚠️ | — | ✅ | +| **G** | ✅ | ✅ | ✅ | ✅ | — | + +⚠️ **C y E no chocan en archivos, sí en secuencia**: E cierra sus fixtures y su pin contra el contrato que C publica. Corren a la vez; E termina después. + +**Cuatro agentes a la vez sin coordinación:** A, B, C y E. G entra como quinto si hay a quién asignarlo. D y F entran cuando sus precondiciones estén cerradas. + +--- + +## 4. Dependencias duras + +Estas no son preferencias de orden: violarlas produce trabajo que hay que rehacer. + +| # | Antes | Después | Por qué | +| ---: | :--- | :--- | :--- | +| 1 | **Contrato `2.4.0`** (lote C) | SDK, fixtures y pin del arnés (lote E) | Un fixture escrito contra el contrato viejo hay que reescribirlo; capturarlo de la API real exige que la API real ya lo emita | +| 2 | **`profiles[].id`** (lote C) | `POST /client/switch-profile` (lote D) | El cliente no puede enviar una clave que el contrato no publica | +| 3 | **Refresco por portador** (`G-218`) | **Token de 15 minutos** (`G-219`) | `ADR-0157` §4.6 lo declara condición de secuencia no negociable: sin refresco, acortar el token pide contraseña **cuatro veces por hora**, y una medida de seguridad que entrena a teclear credenciales sin pensar es una pérdida neta | +| 4 | **Verificadores dobles** (E1 de `ADR-0157`) | **Conmutación a RS256** (E2) | Publicar la clave pública y seguir aceptando HS256 abre la confusión de algoritmo de RFC 8725 §2.1: peor que el statu quo | +| 5 | **Conmutación** (E2) + una vida de token | **Borrado de `UMS_JWT_SECRET`** (E3) | Mientras exista el secreto compartido, existe el emisor paralelo. E3 **no es opcional** y no puede quedar detrás de una bandera | +| 6 | **Publicar las plantillas** (lote B) | **Robot de gating** (lote F) | Con el grafo vacío, el robot pasa en verde sin ejercer nada | +| 7 | **Sesión sostenida** (lote A) | **Robot de sesión y gating** (lote F) | Sin sesión no hay segunda petición que gatear | +| 8 | **Decisión D2** | Cualquier línea de código del lote B sobre códigos | Alinear hacia el lado equivocado cuesta dos veces | + +--- + +## 5. Qué NO se puede cerrar hoy, y por qué + +Escrito antes de empezar, no al final. + +| Qué | Por qué no | +| :--- | :--- | +| **La migración a RS256 completa (`G-199`, `G-278`, `G-203`)** | No es una tarea: es una secuencia de cinco etapas con esperas obligadas entre ellas. E2→E3 exige **dejar pasar una vida de token completa** antes de borrar el secreto. Se puede empezar E0 hoy; no se puede terminar hoy | +| **El token de 15 minutos (`G-219`)** | Depende de `G-218` (refresco por portador), que es trabajo de UMS y no está hecho. `ADR-0157` §4.6 lo prohíbe expresamente antes | +| **La siembra reproducible del multi-perfil (`G-209`)** | Hoy hay sujeto vivo —`equipo.sdlc@` y `pmo.sdlc@`—, pero lo creó el cargador contra la instancia, **no la siembra**. Una base recreada desde cero vuelve a no tener ninguno. Cerrarlo exige tocar el sembrador bajo `SeedDevData && !IsProduction`, que es otro lote y otra clase de riesgo | +| **El escenario de dos clústeres (`G-214`, `G-285`)** | Los dos clústeres existen y **ninguno está en estado de servir**: el namespace `ums` a 0 réplicas, `ums-uat` sin Ingress, el clúster del Tablero sin controlador de Ingress y publicando un puerto que su manifiesto no declara. Reconstruirlo de forma determinista es un lote largo por sí solo | +| **TLS real (T2)** | Decidido fuera del primer ciclo (**D4**). Introducirlo ahora mezcla fallos de certificado con fallos de contrato, y los de certificado enmascaran a los otros | +| **La versión del plugin (`G-287`)** | La serie publicada no es monótona (llegó a `3.1.0` y volvió a `1.37.0`), así que una caché tibia ancla al consumidor a un estándar viejo. Recuperar la monotonía o retirar las etiquetas 2.x/3.x es una **decisión de gobernanza del núcleo**, no una tarea de este encargo | +| **Las 18 fichas de gap que faltan (`G-289`)** | Se ha puesto la comprobación para que el tablero no prometa fichas inexistentes, pero crear dieciocho documentos de gobernanza es trabajo propio, no un efecto colateral de este | +| **Afirmar que la prueba «pasa»** | Hasta que los lotes A y B estén cerrados, cualquier verde es un falso positivo: hoy el gating no puede fallar porque no hay nada que conceder | + +--- + +## 6. Secuencia recomendada + +1. **Ahora, sin esperar a nadie:** corregir `UMS_BASE_URL` a `:5080` y **tomar la decisión D2**. Son minutos y desbloquean dos lotes. +2. **Ola 1 en paralelo:** A (Tablero/servidor), B (provisión + códigos), C (contrato .NET, en worktree), E (SDK y fixtures). Opcionalmente G. +3. **Puerta de la ola 1 — una sola comprobación:** `POST /api/auth/login` seguido de `GET /api/auth/sesion` devuelve `autenticado:true` **con grafo no vacío**, y dos perfiles distintos producen dos `menuAccess` distintos. Mientras eso no ocurra, **no se escribe el robot**. +4. **Ola 2:** D (cambio de perfil) y F (robot), en paralelo entre sí. +5. **Deuda, por su cuenta:** las etapas E0–E3 de `ADR-0157`, el refresco por portador y la siembra reproducible. + +--- + +

+ © BeyondNet S.A.C. · RUC 20100412447 · Operador Logístico Aduanero desde 1978 +

diff --git a/docs/architecture/e2e-sdlc-dashboard-integration-analysis.es.md b/docs/architecture/e2e-sdlc-dashboard-integration-analysis.es.md new file mode 100644 index 00000000..8d5b5105 --- /dev/null +++ b/docs/architecture/e2e-sdlc-dashboard-integration-analysis.es.md @@ -0,0 +1,748 @@ +# Análisis previo de la integración E2E — UMS ↔ Tablero Ejecutivo SDLC en dos clústeres kind + +**Repositorios:** `ums` (rama `develop`, commit `361fdd6`) · `evolith-core` (rama `develop`, commit `ab5c85b`) +**Fecha:** 2026-08-02 · **Revisión:** 2026-08-02 (§0 — especificación confirmada por el cliente) · **Autor:** Arquitecto Enterprise · **Tipo:** Análisis de arquitectura previo a construcción +**Método:** verificación adversarial. Toda afirmación de este documento se apoya en una ruta de archivo con línea, en una respuesta HTTP real capturada contra la instancia viva de UMS en `http://localhost:5080`, o en la salida de `kubectl`/`docker` sobre los clústeres existentes. Lo que no se pudo verificar se declara como **incógnita**, no se rellena. + +> **Por qué existe este documento.** El encargo anterior escribió una capa de autenticación nueva en el Tablero ignorando que `ADR-0155` ya la tenía implementada en `develop` — el mismo error queda registrado en [`G-189`](../../GAPS.md), que invalidó a `G-186` por haberse comprobado sobre un checkout desactualizado. Este análisis se escribe para que la tercera vez no ocurra: la §3 es un **inventario de lo que NO hay que volver a escribir**, y es la sección que debe leerse antes que ninguna otra. + +--- + +## 0. Actualización 2026-08-02 — especificación confirmada por el cliente + +El cliente confirmó las siete decisiones de §10 (**D1–D7**) y precisó la especificación funcional. Esta sección registra qué cambió, qué se produjo y **qué queda invalidado de lo escrito antes**. Prevalece sobre cualquier afirmación anterior de este documento que la contradiga. + +### 0.1 La especificación, tal como quedó + +1. **Perfil = inquilino + sistema + usuario**; el **rol** es la dimensión que varía. Un mismo usuario, mismo inquilino y mismo sistema, puede tener más de un perfil por rol. +2. El **inquilino es `BEYONDNET` siempre**, salvo indicación contraria. +3. El **login pide usuario y contraseña**. Ni rol, ni inquilino. +4. El **cliente envía el código de sistema** cuando quiere acotar. El del Tablero es `SDLC` y lo envía siempre. +5. El `systemCode` es **opcional**: sin él, UMS devuelve los perfiles del usuario en el inquilino **de todos los sistemas** (multiproducto); con él, filtra. +6. Con **más de un perfil**, el cliente ofrece **cambio de perfil**, reutilizando lo que UMS ya publica. +7. El Tablero **no calcula permisos**: usa exclusivamente el grafo. + +### 0.2 Lo producido + +| Artefacto | Ruta | Qué fija | +| :--- | :--- | :--- | +| **ADR-0156** (nuevo, `Aceptado`, supersede a `ADR-0155`) | `evolith-core` · `reference/architecture/adrs/core/0156-autenticacion-tablero-ums-sistema-solicitado-grafo-por-api.es.md` | El grafo se obtiene por API y se cachea (§2.3); el cliente declara su sistema (§2.5); el multi-perfil se resuelve con el mecanismo de UMS (§2.6); vigencia y revalidación del grafo (§2.10) | +| **ADR-0155** | `evolith-core` · `…/core/0155-autenticacion-obligatoria-tablero-contra-ums.es.md` | Pasa a `Supersedido`, con nota de continuidad: lo que sigue vigente y lo que se retira | +| **Diseño del contrato de UMS** | [`diseno-seleccion-de-sistema-en-autenticacion.md`](./diseno-seleccion-de-sistema-en-autenticacion.md) | Dónde entra `systemCode`, cómo filtra, qué devuelve con 0/1/N perfiles, versión `2.4.0` del grafo, SDK y fixtures, cambio de perfil por el carril de satélite | + +**La contradicción de gobernanza está resuelta.** `ADR-0155` §2.3 ya no rige; `D-031` y `ADR-0156` §2.3 dicen lo mismo. **Ya se puede tocar `auth-ums.js`** sin violar `S-06`, que es lo que §7.1 y el bloque 0.1 de §8 exigían como prerrequisito. + +### 0.3 Lo que la especificación invalida de este documento + +| Dónde | Qué decía | Qué rige ahora | +| :--- | :--- | :--- | +| §1.4, §4.6, §7.1, §8 bloque 0.1 | «Hay una contradicción abierta entre `ADR-0155` §2.3 (`Aceptado`) y `D-031`; corregir el Tablero antes de enmendar es escribir contra la norma vigente» | **Cerrado.** `ADR-0155` está `Supersedido` por `ADR-0156`, que fija el grafo por API. El prerrequisito de gobernanza **está cumplido** | +| §5 · V-01 | «El arreglo es de una función del Tablero y **no hay que cambiar el contrato ni tocar UMS**» | **Invalidado en su segunda mitad.** Arreglar `procesarLogin` sigue siendo necesario, pero **no es suficiente**: sin `systemCode` el Tablero recibe el grafo de otro sistema, y sin `profiles[].id` no puede ofrecer cambio de perfil. **UMS sí cambia** | +| §4.2 | «El arreglo del Tablero es sustituir `grafoDeToken()` por una llamada a `/client/graph`… No hay que cambiar el contrato» | Igual que la anterior: la frase describe el transporte del grafo, no su **contenido**. El contenido está mal acotado | +| §5 · V-02 | El desajuste de códigos se plantea como el único bloqueo de la autorización | **Sigue siendo bloqueante, pero ya no es el único.** Aunque los códigos coincidieran, hoy el Tablero recibiría el grafo de `SIL` y ningún código casaría de todas formas. **V-12 (§5) es anterior a V-02 en el orden de causas** | +| §7.4 y §7.5 | «Un perfil por usuario cubre la comparación» y «con un usuario por perfil el problema del cambio de perfil no se plantea» | **Invalidado.** La especificación pone el multi-perfil en el camino principal: es requisito funcional, no escenario opcional. Y la siembra actual **no tiene ningún usuario con dos perfiles**, así que el caso central no es reproducible sin sembrarlo (§5, V-14) | +| §10 · D7 | «Se acepta que la prueba no ejercite multi-perfil por usuario» | **Revocado por el propio cliente.** D7 se confirmó en su parte de TLS; su parte de multi-perfil queda anulada por el punto 6 de la especificación | +| §11 (incógnitas) | «¿Cómo llega el Tablero a `/client/graph` tras un `switch-profile`?» — sin resolver | **Resuelto y peor de lo previsto:** `POST /api/v1/auth/switch-profile` devuelve **`401`** con el portador semántico del satélite. No es que el Tablero no consuma el token nuevo: **no puede ni pedirlo** | +| §3.5 | `POST /api/v1/client/authenticate` listado como pieza cerrada que se reutiliza tal cual | **Matizado.** El endpoint se reutiliza, pero su **cuerpo de petición cambia** (campo `systemCode` opcional) y su grafo sube a `2.4.0` | + +### 0.4 Hechos nuevos verificados el 2026-08-02 + +Todos contra `http://localhost:5080`, base resembrada. + +| # | Hecho | Evidencia | +| :--- | :--- | :--- | +| 1 | **El grafo no publica el identificador del perfil.** Las claves de `profiles[0]` son `system`, `role`, `branch`, `scope`, `isCurrent`. **Sin `id`** | `AuthGraphPayload.cs:133` lo emite vía `WithId(meta,…)`, y `IncludeTechnicalMetadata` es `false` por defecto | +| 2 | **`POST /api/v1/auth/switch-profile` devuelve `401` con el portador de satélite** | `AuthEndpoints.LeerTokenDeGrafo` (`:673-707`) exige `sub` GUID y claim `tenant_id`; el token semántico lleva `sub` = correo y `tenant_code` | +| 3 | **1 y 2 juntos hacen inejecutable el punto 6 de la especificación.** «Reutilizar `switch-profile`» exige antes hacerlo alcanzable y darle su clave | — | +| 4 | **`admin@beyondnet.com.pe` tiene exactamente un perfil, y es de `SIL`** | `GET /api/v1/profiles` · `context.systemSuite.code` = `SIL`. **El grafo de `SIL` no es fruto de un desempate desafortunado: es el único perfil que existe.** El desempate no eligió mal — nadie le preguntó por `SDLC` | +| 5 | **Ningún usuario del inquilino tiene más de un perfil** (13 perfiles / 13 usuarios) | `GET /api/v1/profiles?page=1&pageSize=100` | +| 6 | **La suite `SDLC` sigue sin cargar** — 6 suites: `ADUANAS`, `WMS`, `FACTURACION`, `PORTAL_CLIENTE`, `SIL`, `TMS` | `GET /api/v1/system-suites`. Confirma §5 V-02 tras la resiembra | +| 7 | **Ambos SDK están rotos contra el endpoint real.** Tipan `graph` como objeto; la API lo devuelve como **cadena** de 10 172 caracteres | `sdk-client/src/client.ts:62` → `undefined` → `AuthGraphSchemaMissing`; `UmsAuthClient.cs:75` igual. **Es el mismo defecto que dejó al Tablero en 502**, y demuestra que ningún SDK ha ejercido nunca el endpoint | +| 8 | **Los 12 golden fixtures codifican un estado imposible:** `profiles: []` con `onboardingPending: false`. Cero perfiles implica grafo lobby, que fija `true` | `src/libs/sdk/contracts/fixtures/*.json`. **Ningún fixture ejerce el bloque `profiles`**: por eso el hecho 1 pasó inadvertido | +| 9 | **El arnés RoboSoft pinea `schemaVersion == "2.2.0"` y el servidor emite `2.3.0`** | `src/tests/e2e-functional/robosoft/contexts/configuration.py:418-420`. Ya estaba desfasado antes de este cambio | +| 10 | **El filtro por `systemCode` proyectado en `diseno-cambio-de-perfil.md` §4.2 nunca se construyó** | `LoginRequest` (`AuthEndpoints.cs:861-865`) y `ClientAuthRequest` (`ClientAuthEndpoints.cs:320-325`) no tienen el campo | + +--- + +## 1. Resumen ejecutivo + +La integración **no está a medio construir: está construida y rota en un punto exacto y demostrable**. Las dos aplicaciones existen, se despliegan de forma independiente, cada una tiene su clúster kind vivo, y el Tablero ya tiene login, cliente hacia UMS, sesión por cookie `httpOnly`, verificación de firma HS256 y gating por grafo. Lo que falta es de configuración y de contrato, no de funcionalidad. + +Cinco conclusiones, en orden de gravedad: + +1. **El login del Tablero contra el UMS real devuelve HTTP 502 hoy — no «se degrada»: falla.** Verificado ejecutando la propia función del Tablero (`procesarLogin`) contra `http://localhost:5080` con el administrador de `BEYONDNET`: `status: 502`, `{"error":"El grafo de autorización de UMS es inválido o de versión incompatible."}`. La causa es una línea: `auth-ums.js:321` resuelve el grafo como `grafoDeToken(v.payload) ?? data?.graph`, y en la respuesta real el token no lleva grafo (D-031) mientras `data.graph` es una **cadena serializada de 10 172 caracteres**, no un objeto — así que `validarGrafo()` la rechaza en `auth-ums.js:322`. Esto es **peor** de lo que describe [`G-190`](../../GAPS.md), que afirma que «el login funciona y la siguiente petición devuelve `autenticado:false`». No entra ni la primera vez. + +2. **El endpoint que `G-190` pide como faltante YA EXISTE y funciona.** `GET /api/v1/client/graph` está implementado en [`ClientAuthEndpoints.cs:54-57`](../../src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Auth/ClientAuthEndpoints.cs) bajo la política `UmsSatelite`, y verificado en vivo responde **200 con 5 288 bytes** de grafo con portador válido y **401 + `WWW-Authenticate: Bearer`** sin él. `G-190` sigue marcado `Pendiente` en [`GAPS.md`](../../GAPS.md) y su evidencia es `—`: el registro de gaps va por detrás del código. El trabajo restante está **entero del lado del Tablero**, no de UMS. + +3. **Aunque se arregle el login, ningún perfil verá nada — y la causa NO es la que parece.** Existe ya una especificación declarativa completa del Tablero como sistema de UMS en `src/provisioning/sdlc/` (suite `SDLC`, tenant `BEYONDNET`, 6 módulos, 64 nodos de navegación, 13 acciones, 22 recursos de dominio, 8 roles y 8 plantillas de permiso) con su cargador `cargar-en-ums.mjs`. **Pero sus códigos y los que el Tablero gatea no coinciden en ninguno:** la spec declara menús `SATELITES`, `DASHBOARDS`, `DEM_TABLERO`, `PER_REGISTRO`, `CFG_CALENDARIO`, `CFG_ARTEFACTOS`; el Tablero exige `TABLERO.SATELITES`, `TABLERO.DASHBOARDS`, `TABLERO.DEMANDAS`, `TABLERO.PERSONAS`, `TABLERO.CONFIG` (`web/src/components/common.jsx:10-16`). Intersección: **cero de cinco**. Como el recorrido es fail-closed, la barra lateral queda vacía para todos los perfiles incluso después de aprovisionar. A esto se suma que la suite **no está cargada** en la instancia viva (las 6 suites de `BEYONDNET` son `ADUANAS`, `WMS`, `FACTURACION`, `PORTAL_CLIENTE`, `SIL`, `TMS`) y que ninguna cuenta `*.sdlc@beyondnet.com.pe` existe todavía. + +4. ~~**Hay una contradicción de gobernanza abierta entre un ADR aceptado y una decisión local.**~~ **CERRADO el 2026-08-02 (§0.2).** `ADR-0155` §2.3 fijaba que el servidor del Tablero «**rehidrata el grafo** desde el token», contra [`D-031`](../../DECISIONS.md), que decide el grafo por API. `ADR-0155` pasa a `Supersedido`; rige `ADR-0156` §2.3, alineado con `D-031`. **Ya no hay prerrequisito de gobernanza pendiente para tocar `auth-ums.js`.** + +5. **El escenario «dos clústeres» está montado a medias y no como dice su propio manifiesto.** Ambos clústeres existen (`evolith-ums-cluster` y `beyondnet-arch-management`), pero: en el de UMS **el namespace `ums` tiene todos sus deployments a 0 réplicas** y `http://localhost:8080/health` devuelve **503**; el namespace `ums-uat` sí corre pero **no tiene Ingress**, así que no es alcanzable desde el host; el clúster del Tablero **no tiene controlador de Ingress en absoluto** y su `tablero-web` es `ClusterIP`; y el nodo publica `0.0.0.0:4337->30017` cuando `k8s/kind-cluster.yaml` declara `hostPort: 4317`. Recrear el clúster desde su manifiesto **no reproduce el clúster vivo**. + +> **Corrección de la recomendación global tras §0.** «No construir nada nuevo» era correcto para el **transporte** del grafo y sigue siéndolo. No lo es para su **contenido**: la especificación confirmada exige un cambio de contrato en UMS —`systemCode`, `accessState`, `profiles[].id`, cambio de perfil por el carril de satélite— que no existe hoy y no se puede sustituir con configuración. Ver [`diseno-seleccion-de-sistema-en-autenticacion.md`](./diseno-seleccion-de-sistema-en-autenticacion.md). + +**Recomendación global (redacción original, 2026-08-02):** no construir nada nuevo. Enmendar `ADR-0155`, corregir **una** función del Tablero (`procesarLogin`/`resolverSesion`), **alinear los códigos de menú y ejecutar el cargador que ya existe** (`src/provisioning/sdlc/cargar-en-ums.mjs`), y reutilizar el arnés RoboSoft de `src/tests/e2e-functional/robosoft/` con su runner `scripts/certify-e2e.sh`. El único componente genuinamente nuevo es el carril de UI del Tablero dentro de ese arnés, más una caché de grafo en el servidor del Tablero. + +--- + +## 2. Arquitectura real de cada aplicación + +### 2.1 UMS — proveedor de identidad y autorización + +| Aspecto | Estado verificado | +| :--- | :--- | +| Backend | .NET 10, Clean Architecture + CQRS. `src/apps/ums.api` | +| Frontend | React 19 + Vite, servido por nginx que además proxya `/api` al backend | +| Empaquetado | Chart Helm único `src/infra/ums-helm`; imágenes `ums/backend` y `ums/frontend` con `pullPolicy: Never` (precarga por `kind load`) | +| Clúster | `kind` `evolith-ums-cluster`, definido en `src/infra/kind-config.yaml`: un nodo control-plane con `ingress-ready=true` y `extraPortMappings` 80→8080 y 443→8443 | +| Ingress | `ingress-nginx` instalado; `Ingress` `ums-frontend` con host `ums.local` y `ums-frontend-localhost` con host `*`. **`tls: []`** en `src/infra/ums-helm/values/frontend.yaml` — sin certificado | +| Puerta de entrada | El Ingress apunta **solo al frontend**: nginx sirve la SPA y proxya `/api/` al backend. No hay ruta de Ingress directa al backend | +| Secretos | El chart plantilla `ums-db-secret` y `ums-admin-secret` (`templates/secret.yaml`) | +| Observabilidad | OTel Collector + Loki + Tempo + Prometheus + Alertmanager + Grafana en el mismo namespace (`observability.enabled: true`) | + +**Estado vivo (verificado con `kubectl`, 2026-08-02):** + +| Namespace | Pods | Ingress | Veredicto | +| :--- | :--- | :--- | :--- | +| `ums` | **0/0 en todos los deployments** (`ums-backend`, `grafana`, `loki`, `prometheus`, `tempo`, `otel-collector`, `alertmanager`) | `ums-frontend` (`ums.local`) y `ums-frontend-localhost` (`*`) | Ingress apunta a un Service **sin endpoints** → `http://localhost:8080/health` = **503** | +| `ums-uat` | 4/4 corriendo (`ums-backend`, `ums-frontend`, `ums-postgres`, `ums-redis`) | **ninguno** | Vivo pero **inalcanzable desde el host** | + +**La instancia que responde en `http://localhost:5080` NO es el clúster.** Es un proceso local; el único contenedor de datos publicado es `ums_postgres` en `0.0.0.0:5433->5432`. Todas las verificaciones de contrato de este documento se hicieron contra ella. + +**Configuración que UMS necesita y hoy no recibe en el chart:** + +- **`Jwt__Secret` no se inyecta.** `src/infra/ums-helm/templates/backend-deployment.yaml` inyecta `ConnectionStrings__DefaultConnection` y `ADMIN_PASSWORD` por `secretKeyRef`, pero **ninguna** variable `Jwt__*`. `appsettings.json` trae el marcador `YOUR_VERY_LONG_SECRET_KEY_HERE_CHANGE_IN_PRODUCTION_MIN_32_CHARS` y `appsettings.Development.json` su equivalente. Es exactamente [`G-203`](../../GAPS.md), y **es bloqueante para esta prueba**, no solo un riesgo de seguridad: el Tablero necesita ese mismo secreto para verificar la firma, y hoy no hay una fuente única de la que ambos lo tomen. +- **`AllowedOrigins` está vacío** en `appsettings.json`, `appsettings.Production.json` y `appsettings.UAT.json`; solo `Development` lista `localhost:5173/5174/5175/3000`. Ver §6.4: **para esta integración es irrelevante**, y creer lo contrario es la trampa principal del plan anterior. + +### 2.2 Tablero Ejecutivo SDLC + +| Aspecto | Estado verificado | +| :--- | :--- | +| Ubicación | `evolith-core/reference/governance/tablero-ejecutivo/app`, monorepo npm con workspaces `shared`, `server`, `web` | +| Backend | Node 24 + Express, puerto 4317. Imagen `tablero-sdlc:local` (`server/Dockerfile`, base `node:24-bookworm-slim`, incluye JRE + graphviz + `plantuml.jar` fijado por checksum) | +| Frontend | React + Vite, servido por nginx. Imagen `tablero-web:local` (`web/Dockerfile`, base `nginx:alpine`) | +| Persistencia | PostgreSQL 16 (`StatefulSet` en `k8s/postgres.yaml`); SQLite como motor de rollback (`DB_ENGINE`) | +| Clúster | `kind` `beyondnet-arch-management` (`k8s/kind-cluster.yaml`): control-plane + worker, `extraPortMappings` 30017→**4317** | +| Exposición | `tablero-app` es `NodePort` 30017; `tablero-web` es **`ClusterIP`** — el frontend **no está publicado**; el README instruye `kubectl port-forward svc/tablero-web 8080:80` | +| Ingress | **No hay controlador de Ingress instalado** en ese clúster (`kubectl get ingress -A` → `No resources found`) | +| CSP | `web/nginx.conf` fija `connect-src 'self'` — el navegador **no puede** llamar a UMS por diseño | + +**Estado vivo (verificado, 2026-08-02):** + +- Pods `tablero-app`, `tablero-web` y `tablero-postgres` corriendo con las imágenes `tablero-sdlc:local` y `tablero-web:local`. +- El nodo publica `0.0.0.0:4337->30017/tcp`. **El manifiesto dice `hostPort: 4317`.** Divergencia real entre `k8s/kind-cluster.yaml` y el clúster vivo — probablemente para no chocar con el 4317 de OTLP. Recrear el clúster desde el manifiesto cambia el punto de acceso. +- **El deployment no tiene ninguna variable `UMS_*`.** Verificado enumerando `spec.template.spec.containers[0].env`: `DB_ENGINE`, `DATABASE_URL`, `PORT`, `OTEL_*`, `LOG_LEVEL`, `GITHUB_*`. Nada más. +- Consecuencia verificada ejecutando dentro del pod: `GET /api/auth/estado` → **`{"configurado":false}`**. El Tablero desplegado opera hoy en **modo dev-abierto**: sin gate, sin login, acceso anónimo total (`App.jsx:134`). + +--- + +## 3. Inventario de lo YA IMPLEMENTADO — lo que NO hay que volver a escribir + +Esta sección es normativa para el encargo de construcción. Cada fila es funcionalidad **existente y probada**; reimplementarla es incumplir la restricción del cliente. + +### 3.1 Servidor del Tablero — `server/src/auth-ums.js` (453 líneas, ADR-0155) + +| Pieza | Función | Líneas | NO reescribir | +| :--- | :--- | :--- | :--- | +| Detección de configuración | `authConfigurada()` | 29-31 | Exige `UMS_BASE_URL` + `UMS_JWT_SECRET`; sin ambos el gate degrada a 501 sin bypass | +| Verificación HS256 | `verificarJwtHs256()` | 72-98 | Con `node:crypto`, sin dependencias. Rechaza `alg` ≠ HS256 (bloquea `alg:none`), compara la firma en **tiempo constante** (`timingSafeEqual`) y valida `exp`. Está bien hecho | +| Firma de tokens de prueba | `firmarJwtHs256()` | 55-61 | Útil para el arnés E2E: permite fabricar tokens caducados o de firma alterada sin tocar UMS | +| Vigencia del grafo | `grafoVigente()` | 114-119 | Comprueba `schemaVersion` en rango y `validUntil` futuro | +| Gating por menú | `menuPermitido()` | 150-153 | Espejo fail-closed del `AuthorizationValidator` del SDK | +| Gating por scope | `scopePermitido()` | 179-186 | `deny-wins` sobre `menuAccess` y `domainPermissions`; comparación case-insensitive | +| Feature flags | `featureFlagActivo()` | 195-199 | Fail-closed, alineado con ADR-0060 | +| Cookie de sesión | `serializarCookieSesion()` / `cookieDeLimpieza()` / `leerCookie()` | 209-238 | `HttpOnly`, `Path=/`, `Max-Age` acotado | +| Resolución de sesión | `resolverSesion()` | 250-262 | **Único punto a modificar** (§4.1) | +| Login proxy | `procesarLogin()` | 273-345 | Proxy servidor-a-servidor a `/api/v1/client/authenticate?format=json`, clasificación de errores (401 vs 502), `Max-Age` = mín(`expiresIn`, restante del grafo). **Solo hay que tocar las líneas 321-327** | +| Alta de cuenta | `procesarSignup()` | 382-420 | Delega en `POST /api/v1/auth/user-signup`. Devuelve **202**, no 201, porque en UMS la cuenta queda solicitada | +| Recuperación | `procesarRecuperacion()` | 431-452 | Delega en `POST /api/v1/auth/forgot-password`. **Descarta deliberadamente `simulatedTemporaryPassword`** que UMS devuelve en el cuerpo. Esta decisión es correcta y no debe revertirse | + +### 3.2 Contrato vendorizado — `server/src/lib/ums-contracts.js` + +`SCHEMA_VERSION` (Actual `2.3.0`, rango `[2.0.0, 3.0.0)`), `esSchemaSoportado()` y `validarGrafo()`. Copia mínima del paquete `@ums/sdk-contracts`, que **no está publicado en npm**. Verificado en vivo: el grafo real declara `schemaVersion: "2.3.0"` — el rango es correcto y no hay que tocarlo. + +### 3.3 Rutas HTTP del Tablero — `server/src/index.js:100-142` + +`GET /api/auth/estado` (107) · `POST /api/auth/login` (111) · `GET /api/auth/sesion` (119) · `POST /api/auth/signup` (127) · `POST /api/auth/recuperar` (134) · `POST /api/auth/logout` (140). **El contrato HTTP hacia el navegador está cerrado y no debe cambiar.** + +### 3.4 Cliente web — gate, login y gating de interfaz + +| Pieza | Ruta | Qué hace | +| :--- | :--- | :--- | +| Máquina de arranque | `web/src/App.jsx:121-172` | Tres fases: `dev-abierto` (UMS no configurado, con banner), `login` (configurado y sin sesión → **solo** `LoginPage`), `autenticado`. Fail-closed: error de red cae al login, nunca a la app anónima | +| Pantalla de acceso | `web/src/components/LoginPage.jsx` | Formulario tenant + usuario + contraseña, `autoComplete` correcto, mensajes de error diferenciados por status | +| Contexto de sesión | `web/src/lib/sesion.jsx` | `SesionProvider`, `construirSesion()` y los tres helpers `puedeVerMenu` / `puedeAccion` / `featureActiva`. El recorrido de `menuAccess` **hereda el cierre** y aplica `deny-wins` entre ramas | +| Consumo del gating | `web/src/components/common.jsx:75-81,108` y `AppBar.jsx:225,238` | La barra lateral y la navegación móvil ya filtran por `puedeVerMenu(code)` | +| Cliente HTTP | `web/src/api.js:46-54` | `authEstado`, `authSesion`, `login`, `logout` | + +### 3.5 Del lado de UMS + +| Pieza | Ruta | Estado | +| :--- | :--- | :--- | +| `POST /api/v1/client/authenticate` | [`ClientAuthEndpoints.cs:33-40`](../../src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Auth/ClientAuthEndpoints.cs) | Anónimo. Anti-enumeración `G-053` aplicada: tenant inexistente/inactivo y credencial inválida colapsan al mismo 401 | +| `GET /api/v1/client/graph` | [`ClientAuthEndpoints.cs:54-57`](../../src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Auth/ClientAuthEndpoints.cs) | **Existe y funciona.** Política `UmsSatelite` (portador exclusivo). **Reconstruye** el grafo, no devuelve copia guardada (`:109-115`) | +| Esquema de política `UmsAuto` | [`AuthenticationExtensions.cs:35-49`](../../src/apps/ums.api/Ums.Presentation/Extensions/AuthenticationExtensions.cs) | Reenvía a portador si hay `Authorization: Bearer`, a cookie si no. Cerrado por `D-034`/[`G-191`](../../GAPS.md) | +| Arnés E2E RoboSoft | `src/tests/e2e-functional/robosoft/` | Dos carriles: API (`api/tests/*.spec.ts`, 11 suites) + helpers (`auth.ts`, `provision.ts`, `invariant.ts`) | +| Runner de certificación | `scripts/certify-e2e.sh` | Ejecuta ambos carriles contra `E2E_BASE_URL`, verifica `/health` antes de empezar (fail-fast SD-06) | +| Playwright | `src/apps/ums.web-app/playwright.config.ts` + `@playwright/test 1.60.0` | Ya soporta despliegue externo por `E2E_BASE_URL` sin levantar `webServer` | +| Ciclo reproducible | `scripts/uat-env.sh` | `up`/`reset`/`smoke`/`status` | +| **Alta del Tablero como sistema de UMS** | `src/provisioning/sdlc/sdlc-suite.json` (47 KB) + `README.md` | **Especificación declarativa completa y trazada** (`SD-05`): suite `SDLC`, tenant `BEYONDNET`, 6 módulos (`GOB`, `PORT`, `PRD`, `DEM`, `PER`, `CFG`), 11 + 19 + 34 nodos de tipo `Menu`, `SubMenu` y `Option` (el cargador los da de alta como `MenuNode` sobre `/modules/{id}/nodes`, ADR-0090), 13 acciones, 84 vínculos opción↔acción, 22 `DomainResource`, 4 `AppSetting`, 8 roles, 8 plantillas. Extraída de 96 rutas de API, 22 tablas y 7 vistas del propio Tablero | +| **Cargador del alta** | `src/provisioning/sdlc/cargar-en-ums.mjs` | Aplica la spec contra la API de UMS. **Bajo modificación activa** en el árbol de trabajo (idempotencia en reejecución) | +| Render del grafo esperado | `src/provisioning/sdlc/render-auth-graph.mjs` + `auth-graph/` | Materializa el grafo que debería producir cada rol | + +### 3.6 Lo que ESTORBA o induce a error + +| Elemento | Ruta | Problema | Qué hacer | +| :--- | :--- | :--- | :--- | +| Prueba que fija el contrato equivocado | `server/test/auth-ums.test.mjs:142-157` | Firma tokens **con el grafo embebido** (`firmarJwtHs256({sub, exp, graph: g}, SECRETO)`) y afirma que `resolverSesion` los acepta. Verde contra un supuesto que la API real contradice. Es el mecanismo exacto por el que `G-190` pasó inadvertido | Reescribir junto con `resolverSesion`. **No borrar**: convertir en prueba de que un token **sin** grafo se resuelve pidiéndolo por API | +| Fallback muerto | `auth-ums.js:321` (`?? data?.graph`) | Parece un camino alternativo válido y no lo es: `data.graph` es **cadena**, nunca objeto | Eliminar o parsear explícitamente. Dejarlo ambiguo es lo que produjo el 502 | +| `SameSite=Lax` vs ADR | `auth-ums.js:214` (`'SameSite=Lax'`) | `ADR-0155` §2.3 fija `SameSite=Strict` | Divergencia no declarada: o se corrige el código, o el ADR lo justifica | +| Manifiesto de clúster desincronizado | `k8s/kind-cluster.yaml` (`hostPort: 4317`) | El clúster vivo publica **4337** | Alinear antes de automatizar nada | +| Endpoints con validación propia de portador | `AuthEndpoints.cs` (`switch-profile`, `switch-tenant`) | [`G-201`](../../GAPS.md): validan a mano con `ValidateIssuer=false`, `ValidateAudience=false` y `ClockSkew` de 5 min | **No apoyar el robot en ellos** para comparar perfiles mientras siga abierto: la prueba pasaría por la puerta más floja | +| `net-guard.js` — **falso positivo, no tocar** | `server/src/net-guard.js` | Bloquea destinos privados/loopback y **prohíbe `localhost`** | Solo se aplica a `evidencia_url` (`index.js:1979`). **No** intercepta la llamada a UMS. No «arreglarlo»: es correcto donde está | + +--- + +## 4. Cómo llega hoy la autorización al Tablero + +### 4.1 El hecho central: el grafo NO viaja en el JWT + +Verificado contra `POST /api/v1/client/authenticate?format=json` con `admin@beyondnet.com.pe` / tenant `BEYONDNET`: + +| Medida | Valor real | +| :--- | :--- | +| Claves de la respuesta | `token`, `tokenType`, `expiresIn`, `issuedAt`, `format`, `graph`, `requestId` | +| Longitud del token | **1 149** caracteres | +| Tipo de `graph` | **`str`** (cadena JSON serializada), **10 172** caracteres | +| Claims del token | `sub`, `email`, `name`, `tenant_code`, `tenant_name`, `auth_method`, `graph_generated_at`, `graph_valid_until`, `session_tracking_id`, `jti`, `sys_suite`, `sys_suite_name`, `role`, `role_name`, `profile_scope`, `scope[]`, `exp`, `iss: ums-api`, `aud: ums-web-app` | +| Claim `graph` | **ausente** | + +El Tablero espera `payload.graph` (`auth-ums.js:101-104`). No existe. Cae al fallback `data?.graph`, que es una **cadena**, y `validarGrafo()` exige objeto (`ums-contracts.js:77`). Resultado medido: + +```text +procesarLogin({username:'admin@beyondnet.com.pe', password:}, env={UMS_BASE_URL:'http://localhost:5080', …}) + → status: 502 + → body: {"error":"El grafo de autorización de UMS es inválido o de versión incompatible."} + → cookie: null +``` + +**Corrección de [`G-190`](../../GAPS.md):** su enunciado («el login funciona y la siguiente petición devuelve `autenticado:false`») es optimista. El login **no funciona**. La sesión no llega a abrirse. + +### 4.2 Se consulta por API: el endpoint existe y sirve el mismo contrato + +`GET /api/v1/client/graph` con el portador de `/client/authenticate`: + +| Prueba | Resultado medido | +| :--- | :--- | +| Con portador válido | **200**, 5 288 bytes | +| Sin portador | **401** + `WWW-Authenticate: Bearer`, **sin** `Location` | + +El payload es **estructuralmente idéntico** al `graph` de `/client/authenticate` — mismas 14 claves de primer nivel (`schemaVersion`, `onboardingPending`, `context`, `authentication`, `actions`, `profiles`, `menuAccess`, `domainPermissions`, `featureFlags`, `effectiveConfig`, `settings`, `scopes`, `generatedAt`, `validUntil`); solo difieren las marcas de tiempo, porque **se reconstruye en cada llamada** ([`ClientAuthEndpoints.cs:109-115`](../../src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Auth/ClientAuthEndpoints.cs)). Eso es justamente lo que hace posible la revocación que motiva [`D-031`](../../DECISIONS.md). + +**Consecuencia de diseño:** el arreglo del Tablero es sustituir `grafoDeToken()` por una llamada a `/client/graph` con el portador, cacheada en el servidor. **No hay que cambiar el contrato ni tocar UMS.** + +### 4.3 No se cachea, y no hay invalidación + +Hoy el Tablero es *stateless* por diseño (`ADR-0155` §2.3: «no guarda estado de sesión en BD»). Con `D-031` **necesita** una caché de servidor. Ninguna existe: no hay estructura de caché en `auth-ums.js` ni en `index.js` para el grafo. Es trabajo nuevo, pequeño y acotado, y es el único componente de servidor que hay que añadir. + +Las claves de invalidación ya viajan en el token y no hay que inventarlas: `graph_generated_at` y `graph_valid_until`. + +### 4.4 La ventana de vigencia del grafo es la mitad que la del token — y ahí muere la sesión + +Medido sobre una respuesta real: + +| Marca | Valor | Duración | +| :--- | :--- | :--- | +| `graph_generated_at` | `2026-08-02T15:04:08.878Z` | — | +| `graph_valid_until` | `2026-08-02T15:34:08.878Z` | **30 min** | +| `expiresIn` / `exp` | 3 600 s | **60 min** | +| `effectiveConfig.sessionTimeoutMinutes` | 30 | 30 min | + +`procesarLogin` calcula `Max-Age = mín(expiresIn, restante del grafo)` (`auth-ums.js:329-332`) → **30 minutos**. A los 30 minutos `grafoVigente()` devuelve `false` y `resolverSesion` responde `{autenticado:false}` **con un token todavía válido otros 30 minutos**. El usuario es expulsado al login sin que su credencial haya caducado, y **no existe camino para renovar el grafo sin volver a pedir contraseña**. Esto cuantifica la mitad abierta de [`G-187`](../../GAPS.md). + +### 4.5 La superficie de sesión por portador, medida hoy + +| Endpoint | Con portador | Veredicto | +| :--- | :--- | :--- | +| `GET /api/v1/client/graph` | **200** | Cerrado por `D-034`/[`G-191`](../../GAPS.md) | +| `GET /api/v1/auth/session` | **200** | Cerrada la mitad «verificación» de [`G-187`](../../GAPS.md). **Pero** devuelve `tenantId: ""` y `permissions: []` — es [`G-202`](../../GAPS.md) | +| `POST /api/v1/auth/logout` | **200** | Funciona | +| `POST /api/v1/auth/refresh` | **401** | `.RequireAuthorization()` bajo `UmsAuto`, pero el manejador espera la **cookie de sesión** (`AuthEndpoints.cs:44-47`, «Refresh access token using **session cookie**») | +| `POST /api/v1/auth/refresh-token` (cuerpo) | **401** | Anónimo por diseño, pero exige que el inquilino haya activado la capacidad (fail-closed, ADR-UMS-091). `BEYONDNET` no la tiene activa | +| `POST /api/v1/auth/login` (portal) | 200, **`refreshToken: null`** | El refresco viaja en la cookie `ums.session`. Mitad abierta de [`G-187`](../../GAPS.md) | + +**No existe refresco por portador.** Es la incógnita operativa número uno del escenario E2E y determina si la prueba puede durar más de 30 minutos. + +### 4.6 Contraste con las decisiones y gaps registrados + +| Registro | Qué dice | Qué muestra la evidencia | +| :--- | :--- | :--- | +| [`D-031`](../../DECISIONS.md) | Grafo por API + caché en el satélite; JWT solo identidad y vigencia | **Confirmada del lado de UMS** (token de 1 149 B sin grafo; `/client/graph` operativo). **No implementada del lado del Tablero.** Y **contradice a `ADR-0155` §2.3, que está `Aceptado`** — ver §7.1 | +| [`D-034`](../../DECISIONS.md) | Esquema `UmsAuto` + política `UmsSatelite`; sin redirección bajo `/api/**` | **Confirmada.** 401 con `WWW-Authenticate: Bearer` y sin `Location` | +| [`G-187`](../../GAPS.md) | Superficie de sesión no consumible por un satélite | **Parcialmente cerrado.** Verificación por portador: sí. Refresco y `tenantId`: no | +| [`G-190`](../../GAPS.md) | Falta `GET /client/graph` | **Obsoleto: el endpoint existe y responde 200.** El gap sobrevive con evidencia `—`. Lo que queda abierto es del lado del Tablero, y el enunciado subestima la gravedad (§4.1) | +| [`G-191`](../../GAPS.md) | La API no autenticaba su propio JWT | **Cerrado y verificado de nuevo hoy** | +| [`G-199`](../../GAPS.md) | HS256 con secreto compartido no escala a federación | **Confirmado, y es el nudo de configuración de esta prueba.** El Tablero necesita `UMS_JWT_SECRET` para `authConfigurada()` (`auth-ums.js:29-31`); con él **puede forjar tokens de cualquier usuario**. Con el grafo por API (`D-031`) el secreto deja de ser necesario para el grafo, pero **sigue siéndolo** para verificar la firma localmente y para que el gate arranque. Exige ADR aceptado en `evolith-core` antes de tocar código (`S-06`) | + +--- + +## 5. Vacíos que hoy impiden la prueba + +Ordenados por bloqueo. Los marcados **[nuevo]** no estaban identificados en el encargo ni en el plan previo. + +### V-01 · El Tablero rehidrata el grafo del token y por eso el login devuelve 502 — **bloqueante** + +**Evidencia:** `auth-ums.js:321-327`; `resolverSesion` en `:253`; ejecución real → 502. +**Solución:** en `procesarLogin`, tras verificar la firma, **pedir el grafo a `GET /api/v1/client/graph`** con el token recién obtenido; cachearlo en el servidor indexado por `jti` (o `session_tracking_id`) con TTL hasta `graph_valid_until`. `resolverSesion` deja de leer `payload.graph` y consulta la caché, revalidando contra UMS si expiró. **Cambio acotado a un archivo.** ~~Requiere antes la enmienda de `ADR-0155` §2.3~~ — **desbloqueado el 2026-08-02**: rige `ADR-0156` §2.3 (§0.2). + +> **Corrección tras §0.** La afirmación «no hay que cambiar el contrato ni tocar UMS» (§4.2) **queda invalidada**. Arreglar esto es necesario y **no suficiente**: resuelve el transporte del grafo, no su contenido. Ver V-12. + +### V-12 · El Tablero no puede decir a UMS qué sistema pide, y por eso recibe el grafo de otro — **bloqueante** — **[nuevo, 2026-08-02]** + +**Evidencia:** `ClientAuthRequest` (`ClientAuthEndpoints.cs:320-325`) y `AuthenticateUserCommand` (`AuthenticateUserCommand.cs:16-23`) no tienen campo de sistema. `AuthorizationGraphBuilderService:160-167` resuelve el perfil por desempate y construye el grafo de la suite de ese perfil. Verificado: el administrador de `BEYONDNET` recibe `context.systemSuite.code` = `SIL`. + +**Matiz que corrige a §5 V-02:** en la instancia actual `SIL` **no** es fruto de un desempate desafortunado — es el **único** perfil que ese usuario tiene. El desempate no eligió mal: **nadie le preguntó por `SDLC`**. El defecto es de contrato, no de algoritmo. + +**Solución:** `systemCode` opcional en la autenticación de cliente, filtrando los perfiles del usuario **sin consultar el catálogo de sistemas**, para que un código inexistente y un código sin perfil sean indistinguibles. Especificado en [`diseno-seleccion-de-sistema-en-autenticacion.md`](./diseno-seleccion-de-sistema-en-autenticacion.md) §3 y §4. + +**Es anterior a V-02 en el orden de causas:** alinear los códigos de menú no sirve de nada mientras el grafo entregado sea el de otro sistema. + +### V-13 · El cambio de perfil que la especificación exige reutilizar es hoy inalcanzable — **bloqueante** — **[nuevo, 2026-08-02]** + +**Evidencia, dos hechos independientes y ambos verificados:** + +1. El grafo **no publica el identificador del perfil**: las claves de `profiles[0]` son `system`, `role`, `branch`, `scope`, `isCurrent`. `AuthGraphPayload.cs:133` lo emite vía `WithId(meta,…)` y `IncludeTechnicalMetadata` está en `false` por defecto. **El cliente no tiene nada que enviar.** +2. `POST /api/v1/auth/switch-profile` responde **`401`** al portador semántico del satélite: `LeerTokenDeGrafo` (`AuthEndpoints.cs:673-707`) exige `sub` GUID y claim `tenant_id`, y el token semántico lleva `sub` = correo y `tenant_code`. + +**Consecuencia de gobernanza:** el punto 6 de la especificación — «reutilizar `switch-profile`, no reinventarlo» — es correcto como dirección pero **no ejecutable como está**. Reutilizarlo exige antes **hacerlo alcanzable** y **darle su clave**. Esto no es reinventar: el comando y el constructor de grafo por perfil se reutilizan sin tocarse; lo que se añade es un adaptador HTTP en el carril `/client`. + +**Solución:** `profiles[].id` siempre presente y `POST /api/v1/client/switch-profile` bajo la política de portador. Especificado en [`diseno-seleccion-de-sistema-en-autenticacion.md`](./diseno-seleccion-de-sistema-en-autenticacion.md) §5.3 y §8. + +### V-14 · El caso multi-perfil no es reproducible con la siembra actual — **[nuevo, 2026-08-02]** + +**Evidencia:** `GET /api/v1/profiles?page=1&pageSize=100` devuelve 13 perfiles sobre **13 usuarios distintos**. Ningún usuario tiene dos. + +**Consecuencia:** el escenario que la especificación pone en el camino principal —ofrecer cambio de perfil— **no tiene sujeto**. Cualquier prueba de multi-perfil escrita hoy pasaría en verde sin ejercer nada. + +**Solución:** sembrar los cuatro casos de [`diseno-seleccion-de-sistema-en-autenticacion.md`](./diseno-seleccion-de-sistema-en-autenticacion.md) §9, con semántica logística real y bajo `SeedDevData && !IsProduction`. + +### V-15 · Los SDK nunca han hablado con el endpoint real — **[nuevo, 2026-08-02]** + +**Evidencia:** ambos clientes tipan `ClientAuthResult.graph` como objeto (`sdk-client/src/types.ts:16`, `Ums.Sdk.Contracts/AuthorizationGraph.cs`), y la API lo devuelve como **cadena** de 10 172 caracteres. `client.ts:62` evalúa `parsed.graph?.schemaVersion` a `undefined` y devuelve `AuthGraphSchemaMissing`; `UmsAuthClient.cs:75` falla igual. + +**Es el mismo defecto que dejó al Tablero en 502** (§4.1), en otro consumidor. Y a esto se suma que **los 12 golden fixtures traen `profiles: []` con `onboardingPending: false`** —un estado que el servidor no puede producir— de modo que **ningún fixture ejerce el bloque `profiles`**. Esa es la razón mecánica por la que la ausencia del `id` (V-13) no se detectó antes. + +**Solución:** tipar `graph` como `string` en el DTO de transporte y deserializar según `format` antes de validar `schemaVersion`; corregir los fixtures y añadir uno **capturado de la API real**. + +### V-02 · Los códigos de la spec de aprovisionamiento y los del gating del Tablero no coinciden en ninguno — **bloqueante para la prueba de autorización** — **[nuevo]** + +**El aprovisionamiento NO hay que escribirlo: existe.** `src/provisioning/sdlc/sdlc-suite.json` es una spec declarativa completa y trazada, y `cargar-en-ums.mjs` la aplica. Proponer «dar de alta la suite con la API» sería exactamente la solución paralela que este encargo prohíbe. + +**El defecto real es un desajuste de contrato entre dos artefactos que ya existen:** + +| Sección del Tablero | Código que **exige** el gating (`common.jsx:10-16`) | Código que **declara** la spec | ¿Coincide? | +| :--- | :--- | :--- | :--- | +| Satélites | `TABLERO.SATELITES` | `SATELITES` (Menu, módulo `GOB`) | **No** — falta el prefijo | +| Dashboards | `TABLERO.DASHBOARDS` | `DASHBOARDS` (Menu, módulo `PORT`) | **No** — falta el prefijo | +| Demandas | `TABLERO.DEMANDAS` | `DEM_TABLERO` (Menu, módulo `DEM`) | **No** — nombre distinto | +| Personas | `TABLERO.PERSONAS` | `PER_REGISTRO` (Menu, módulo `PER`) | **No** — nombre distinto | +| Configuración | `TABLERO.CONFIG` | `CFG_CALENDARIO` + `CFG_ARTEFACTOS` (dos Menu) | **No** — además 1→2 | + +**Intersección: 0 de 5.** Ejecutar el cargador hoy dejaría la interfaz igual de vacía, y el diagnóstico sería caro porque todo lo demás estaría verde: UMS entregaría un grafo rico y correcto, y el Tablero lo descartaría entero por fail-closed. Es la misma clase de defecto que `G-190` —dos lados probados cada uno contra su propio supuesto— aplicada a los códigos en vez de al transporte del grafo. + +`ADR-0155` §2.5 fija la convención «jerárquica, punteada, en mayúsculas, con raíz `TABLERO`» y declara que **los `code` reales los define UMS**. La spec no siguió esa convención; el cliente web sí. Uno de los dos tiene que ceder, y es decisión de arquitectura, no de implementación (§10, **D2**). + +**Estado adicional verificado:** la suite `SDLC` **no está cargada** en la instancia viva (`GET /system-suites` devuelve `ADUANAS`, `WMS`, `FACTURACION`, `PORTAL_CLIENTE`, `SIL`, `TMS`) y **no existe ninguna cuenta** `*.sdlc@beyondnet.com.pe` (`GET /user-accounts?search=sdlc` → 0). El cargador no se ha ejecutado con éxito todavía. + +**Fricción conocida y ya registrada:** [`G-166`](../../GAPS.md) (UMS no ofrece importación declarativa; por eso existe este cargador artesanal) y [`G-193`](../../GAPS.md) (no se pueden **leer** las concesiones de una plantilla, así que la reentrada depende de provocar el error de duplicado — que es justo lo que el cambio en curso sobre `cargar-en-ums.mjs` está intentando resolver). + +**Coordinación:** `cargar-en-ums.mjs` está **siendo modificado ahora mismo** en el árbol de trabajo. Cualquier encargo sobre V-02 debe sincronizarse con ese trabajo en vez de abrir una segunda vía. + +### V-03 · El despliegue del Tablero no tiene credenciales de UMS — **bloqueante** + +**Evidencia:** `k8s/app.yaml` no declara `UMS_BASE_URL`, `UMS_JWT_SECRET` ni `UMS_TENANT_DEFAULT`; verificado en vivo → `{"configurado":false}`. +**Solución:** `Secret` `tablero-ums` con `UMS_JWT_SECRET`, más `UMS_BASE_URL` y `UMS_TENANT_DEFAULT=BEYONDNET` como env. **Nunca en la imagen.** Encadenado con V-04. + +### V-04 · UMS firma con un marcador de posición y no lo inyecta el chart — **bloqueante** — [`G-203`](../../GAPS.md) + +**Evidencia:** `backend-deployment.yaml` sin `Jwt__Secret`; `appsettings.Production.json` y `appsettings.UAT.json` sin sección `Jwt`. +**Solución:** un `Secret` **único** del que beban ambos despliegues, generado por el script de entorno, nunca versionado. Es el punto donde `G-199` deja de ser teórico: **un mismo secreto en dos clústeres distintos**. + +### V-05 · No hay refresco por portador: la sesión muere a los 30 minutos sin recuperación — **[nuevo en su cuantificación]** + +**Evidencia:** §4.4 y §4.5. +**Solución posible sin tocar UMS:** con V-01 resuelto, el Tablero **revalida el grafo** contra `/client/graph` mientras el token siga vigente (60 min), lo que extiende la sesión útil de 30 a 60 min. Pasados los 60 min no hay salida sin re-login o sin cerrar la mitad abierta de `G-187`. +**Decisión previa necesaria:** ¿la prueba acepta re-login a los 60 minutos, o `G-187` entra en alcance? + +### V-06 · La cookie de sesión es `Secure` y eso condiciona la topología de red — **[nuevo]** + +**Evidencia:** `auth-ums.js:333` (`const seguro = env.NODE_ENV !== 'test'`) y `:218` (`if (seguro) attrs.push('Secure')`). En el pod `NODE_ENV` no está definido ⇒ `undefined !== 'test'` ⇒ **`Secure` siempre activo** en el clúster. +**Consecuencia:** un navegador solo acepta una cookie `Secure` desde un **origen confiable**. `http://localhost:PUERTO` lo es; `http://tablero.local:PUERTO` **no**. Si se expone el Tablero por un host `.local` sobre HTTP plano, **el login parecerá funcionar (200) y la sesión no se guardará**: el usuario vuelve al formulario sin mensaje de error. Es un fallo mudo, del tipo más caro de diagnosticar. +**Solución:** o TLS en el Ingress del Tablero, o acceso exclusivamente por `localhost`. **Determina la §6 y hay que decidirlo antes de montar la red.** + +### V-07 · Ningún clúster está hoy en estado de servir la prueba — **[nuevo]** + +**Evidencia:** §2.1 y §2.2 (`ums` a 0 réplicas, `localhost:8080` → 503; `ums-uat` sin Ingress; clúster del Tablero sin controlador de Ingress; `hostPort` 4337 ≠ 4317 del manifiesto). +**Solución:** un script de entorno que **construya el estado desde cero de forma determinista**, en la línea de `scripts/uat-env.sh`. Partir del estado actual es partir de algo irreproducible. + +### V-08 · La prueba del Tablero fija el contrato equivocado — **[nuevo]** + +**Evidencia:** `server/test/auth-ums.test.mjs:142-157`. Ver §3.6. +**Solución:** reescribirla junto con V-01, y añadir una prueba de contrato que consuma **la respuesta real** de `/client/authenticate` (fixture capturado de la API viva, no inventado). Sin esto, el mismo defecto vuelve. + +### V-09 · Sin carril de UI para el Tablero en el arnés E2E — **[nuevo]** + +**Evidencia:** ni `package.json`, ni `web/package.json`, ni `server/package.json` del Tablero declaran `playwright`, `cypress` o `test:e2e`. +**Solución:** añadir el carril como **tercer proyecto** del arnés RoboSoft existente. No crear un `tests/e2e/` paralelo (§7.2). + +### V-10 · `tablero-web` no es alcanzable desde el host — **[nuevo]** + +**Evidencia:** `tablero-web` es `ClusterIP`; el `extraPortMapping` del clúster solo publica el NodePort **del backend**; el README instruye `port-forward`. +**Consecuencia:** un robot de UI que dependa de un `port-forward` manual no es reproducible, y `port-forward` cae en silencio bajo carga. +**Solución:** parte de la decisión de §6. + +### V-11 · Incógnitas declaradas — no rellenadas + +| Incógnita | Por qué no se resolvió | +| :--- | :--- | +| ¿La imagen `tablero-sdlc:local` del clúster corresponde a `develop` con `ADR-0155`? | El pod tiene 12 h y **no** tiene `UMS_*`. No se verificó el digest contra un build de `develop`. Debe reconstruirse antes de concluir nada | +| ¿Funciona `/api/v1/auth/user-signup` contra `BEYONDNET` extremo a extremo? | No se ejecutó: crearía datos reales en la instancia viva. Debe probarse contra un entorno desechable | +| ¿`ums-uat` sirve como UMS de la prueba? | Corre pero no tiene Ingress. No se verificó su `ASPNETCORE_ENVIRONMENT` ni su siembra | +| ¿Cómo llega el Tablero a `/client/graph` tras un `switch-profile`? | El grafo se reconstruye desde el **perfil vigente** del usuario. `switch-profile` emite otro token; el Tablero no lo consume. Si la prueba compara perfiles del **mismo** usuario, hay que diseñarlo. Con un usuario por perfil (§7.4) el problema no se plantea | +| ¿`cargar-en-ums.mjs` completa hoy una ejecución limpia? | **No se ejecutó**: crearía 8 roles, 8 plantillas, 64 nodos y 8 cuentas en la instancia viva. Además el archivo **está siendo modificado ahora mismo** por otro trabajo en curso. Debe validarse contra un entorno desechable, no aquí | +| ¿Los grafos de `src/provisioning/sdlc/auth-graph/` corresponden a la spec actual? | `sdlc-suite.json` y ese directorio se tocaron el mismo día (2026-08-01 21:22), pero no se verificó que el render sea reproducible desde la spec vigente. Antes de usarlos como oráculo (§7.5) hay que regenerarlos y comparar | +| ¿Los 34 `Option` de la spec tienen correspondencia en la interfaz del Tablero? | Se verificó la ausencia de correspondencia en los **5 códigos de navegación** que el cliente gatea hoy. Los otros 59 nodos **no** se contrastaron uno a uno: el cliente no los consulta todavía | + +--- + +## 6. Arquitectura de comunicación entre los dos clústeres + +### 6.1 Restricción que decide el diseño (y que el análisis previo pasó por alto) + +**El navegador nunca habla con UMS.** La CSP del Tablero fija `connect-src 'self'` (`web/nginx.conf`) y `ADR-0155` §2.2 impone el proxy servidor-a-servidor. Por tanto: + +- **CORS no aplica.** La llamada a UMS la hace `fetch` de Node desde `auth-ums.js:290`, no el navegador. No hay preflight ni `Origin`. **Añadir el origen del Tablero a `AllowedOrigins` de UMS es trabajo innecesario que abre una superficie que la arquitectura cierra a propósito.** El plan previo lo listaba como riesgo a mitigar (§3.1 de `plan-e2e-fase1-autenticacion.md`): **queda invalidado**. +- **La CSP de UMS es irrelevante** para este flujo, por lo mismo. +- **La cookie `ums.session` de UMS no participa.** El satélite usa portador. Todo el análisis previo sobre `SameSite=None; Secure` entre orígenes **queda invalidado**. + +Lo único que cruza la frontera es **una llamada HTTP servidor-a-servidor desde el pod `tablero-app` hacia la API de UMS.** El problema es de **egreso de pod y resolución de nombre**, no de navegador. + +### 6.2 Opciones + +| Opción | Cómo | Veredicto | +| :--- | :--- | :--- | +| **A. Red Docker compartida entre nodos kind** | `docker network connect kind `; el Tablero llama a UMS por la IP/nombre del contenedor del control-plane de UMS, puerto 80 del Ingress | **Elegida.** Los dos nodos kind son contenedores Docker; conectarlos a una red común es una operación soportada y **el tráfico no sale al host**: reproduce «dos redes distintas unidas por un borde», que es la topología real | +| B. `host.docker.internal` como punto de encuentro | El pod del Tablero llama a `http://host.docker.internal:8080` | Descartada como principal: en Linux no existe sin `--add-host`, y **acopla la prueba al mapeo de puertos del host**, que ya diverge del manifiesto (V-07). Se conserva como **plan de contingencia** | +| C. Un clúster, dos namespaces | `ums` y `tablero` en el mismo clúster | Descartada: **contradice el encargo** y esconde egreso, DNS y TLS — justo lo que la prueba debe descubrir | +| D. Ambos por Ingress del host con `.local` en `/etc/hosts` | Cada clúster publica su Ingress; ambos se alcanzan por nombre | Descartada como principal: **choca de frente con V-06** (cookie `Secure` sobre HTTP en host `.local` = fallo mudo) salvo que se implante TLS, lo que la convierte en la opción A con más piezas | + +### 6.3 Diseño propuesto + +**Red y DNS** + +- Una red Docker dedicada, `beyondnet-e2e`, a la que se conectan los nodos control-plane de ambos clústeres. Un alias estable (`--alias ums-ingress`) evita depender de IPs efímeras. +- Dentro del clúster del Tablero, un `Service` de tipo `ExternalName` **no** sirve (no resuelve nombres de la red Docker desde CoreDNS). Se resuelve con **`UMS_BASE_URL` apuntando al alias**, más una entrada `hostAliases` en el pod `tablero-app` si el alias no resuelve. **A verificar en el montaje: es la única pieza cuya viabilidad no se ha comprobado empíricamente en este análisis.** + +**Puertos** — mapa único y versionado: + +| Extremo | Dentro de la red | Desde el host | +| :--- | :--- | :--- | +| Ingress de UMS | `ums-ingress:80` / `:443` | `localhost:8080` / `:8443` | +| Web del Tablero | `tablero-web.beyondnet-arch-management.svc:80` | `localhost:8081` (**nuevo `extraPortMapping` + Ingress**) | +| API del Tablero | `tablero-app…svc:4317` | `localhost:4337` (NodePort 30017, **alinear con el manifiesto**) | + +**TLS — la decisión que no se puede diferir.** Por V-06 hay dos caminos, y **solo dos**: + +| Camino | Qué implica | Recomendación | +| :--- | :--- | :--- | +| **T1 · Todo por `localhost`, sin TLS** | El robot accede al Tablero solo por `http://localhost:8081`. La cookie `Secure` es aceptada porque `localhost` es origen confiable | **Elegido para el primer ciclo.** Es la vía más corta a una prueba que descubra defectos **funcionales**, sin añadir un fallo de certificados | +| T2 · TLS real con `mkcert` en ambos Ingress | Certificados en el almacén de confianza del navegador de Playwright; hosts `.local` | Fase 2. Se parece más a producción y **descubre problemas reales de certificado**, pero introducirlos en el primer ciclo mezcla dos clases de fallo | + +**Riesgo asumido y declarado de T1:** no ejercita TLS, y por tanto **no descubre** los defectos de certificado que sí aparecerían en producción. Se acepta a cambio de que el primer ciclo aísle los defectos de contrato (§5), y se registra como deuda con salida a T2. **Nótese que el tráfico entre clústeres sí es HTTP plano en ambos caminos**: eso es fiel a lo que hoy hace el chart de UMS (`tls: []`). + +**ConfigMaps y Secrets** + +| Objeto | Clúster | Contenido | +| :--- | :--- | :--- | +| `Secret/ums-jwt` | `evolith-ums-cluster` | `Jwt__Secret` — **generado por el script**, nunca versionado (V-04) | +| `Secret/tablero-ums` | `beyondnet-arch-management` | `UMS_JWT_SECRET` — **el mismo valor** (V-03) | +| `ConfigMap/tablero-ums` | `beyondnet-arch-management` | `UMS_BASE_URL`, `UMS_TENANT_DEFAULT=BEYONDNET` | + +Que el mismo secreto viva en dos clústeres es la manifestación operativa de [`G-199`](../../GAPS.md). El script debe imprimirlo como advertencia en cada ejecución: es la prueba de que la firma asimétrica no es una mejora estética. + +**Descubrimiento de servicio.** No hay malla, ni federación, ni DNS compartido — y **no debe haberla**: en producción estos dos sistemas se hablan por HTTP a través de un borde. El acoplamiento es una URL en configuración. Ese es el diseño correcto y ya es el que implementa `auth-ums.js`. + +--- + +## 7. Arquitectura del robot E2E + +### 7.1 Prerrequisito de gobernanza — **cumplido el 2026-08-02** + +`ADR-0155` §2.3 (`Aceptado`) y [`D-031`](../../DECISIONS.md) se contradecían (§1.4). **Resuelto por supersesión, no por enmienda** (§0.2): + +1. `ADR-0155` pasa a **`Supersedido`**, con nota de continuidad que declara qué de él sigue vigente y qué se retira. +2. Rige **`ADR-0156`**, que fija el grafo por API y lo cachea (§2.3), recalibra el argumento de la verificación local (§2.4) y añade sistema solicitado (§2.5), multi-perfil (§2.6) y vigencia del grafo (§2.10). + +**Por qué supersesión y no enmienda:** la premisa «el grafo viaja en el token» no vivía solo en §2.3 — recorría §1, §2.4 y §5. Corregirla en el sitio dejaría un ADR aceptado cuyo texto ya no coincidiría con lo que estuvo en vigor cuando se escribió el código que lo obedeció, que es el agujero de auditoría que `G-189` documenta. Y había tres decisiones **nuevas**, no una corrección. + +**Ya se puede escribir el código del Tablero.** Sigue abierto: + +* La divergencia `SameSite=Lax` vs `Strict` (§3.6): `ADR-0156` §2.3 mantiene `Strict`, así que **el código diverge de la norma vigente** y debe corregirse o declararse. +* El desajuste de códigos de menú (§5 V-02): `ADR-0156` §2.9 mantiene la convención `TABLERO.*` y **no elige qué lado cede** — sigue siendo la decisión **D2** de §10. + +### 7.2 Herramienta: Playwright — **por reutilización, no por comparación** + +La elección **ya está tomada y desplegada**: `@playwright/test 1.60.0` en `src/apps/ums.web-app/package.json`, `playwright.config.ts` que ya soporta despliegue externo por `E2E_BASE_URL`, arnés RoboSoft de dos carriles en `src/tests/e2e-functional/robosoft/` con 11 suites y helpers, runner `scripts/certify-e2e.sh`, y respaldo normativo en `ADR-0109` (`Aceptado`, «Vitest/Playwright»). Por `S-07`, proponer otra herramienta exigiría un ADR nuevo. **La comparativa Playwright/Cypress/Selenium del plan previo es correcta pero ya no es la razón: la razón es que existe.** + +**Estructura — extender, no crear en paralelo.** El plan previo proponía un árbol `tests/e2e/` nuevo (`plan-e2e-fase1-autenticacion.md` §5.2). **Se descarta**: duplicaría helpers, fixtures y runner. Se propone: + +```text +src/tests/e2e-functional/robosoft/ +├─ api/ # EXISTE — carril B de UMS, intacto +├─ contexts/ # EXISTE +└─ integracion-tablero/ # NUEVO — único añadido + ├─ playwright.config.ts # espejo del de api/, con dos baseURL + ├─ fixtures/ + │ ├─ entorno.ts # URLs de ambos sistemas por env + │ └─ perfiles.ts # los 13 perfiles reales de BEYONDNET (§7.4) + ├─ paginas/ # Page Objects del Tablero, sin aserciones + ├─ escenarios/ + │ ├─ 01-acceso.spec.ts # login, credencial inválida, bloqueado, inactivo + │ ├─ 02-sesion.spec.ts # cookie httpOnly, vigencia del grafo, logout + │ ├─ 03-gating-perfiles.spec.ts # comparación entre perfiles (el corazón) + │ ├─ 04-resiliencia.spec.ts # UMS a 0 réplicas, timeout, token forjado + │ └─ 05-alta-recuperacion.spec.ts + └─ soporte/ + ├─ evidencia.ts # HAR + decodificación del JWT + volcado del grafo + └─ aserciones.ts +``` + +Y `scripts/certify-e2e.sh` gana un `--carril c`, en vez de un runner nuevo. + +### 7.3 Levantado y espera + +Un script `scripts/e2e-dos-clusteres.sh` con la interfaz de `uat-env.sh` (`up` / `reset` / `test` / `status` / `down`), que ejecuta en orden: + +1. **Secreto compartido** — generar `Jwt:Secret` (≥ 32 caracteres, aleatorio) **una vez**, y crear ambos Secrets desde él. +2. **Clúster UMS** — `kind create --config src/infra/kind-config.yaml` si no existe; `ingress-nginx` + `kubectl wait`; `make build`; `kind load`; `helm upgrade --install` con `values/backend.yaml`, `values/frontend.yaml` y `--set` del secreto. +3. **Clúster Tablero** — `kind create --config k8s/kind-cluster.yaml` (**corregido**, con `extraPortMapping` para la web); `ingress-nginx`; build de `tablero-sdlc:local` y `tablero-web:local`; `kind load`; `kubectl apply` + Secret/ConfigMap de UMS. +4. **Red** — conectar ambos control-plane a `beyondnet-e2e` con alias. +5. **Espera activa, con reintento exponencial y diagnóstico por sistema** (nunca `sleep`): + - UMS: `GET /health` = 200 **y** `POST /client/authenticate` con el admin de `BEYONDNET` = 200. Health verde con siembra a medias es un falso positivo. + - Tablero: `GET /ready` = 200 **y** `GET /api/auth/estado` = **`{"configurado":true}`**. Sin esta segunda comprobación el robot probaría el modo dev-abierto y **19 escenarios pasarían en verde sin haber autenticado nada** — el peor resultado posible. + - Frontera: desde el pod `tablero-app`, `POST /api/auth/login` con el admin de `BEYONDNET` = **200**. Si esto no da 200, el robot **aborta con diagnóstico**. +6. **Aprovisionar la suite `SDLC`** (V-02) — ejecutar `src/provisioning/sdlc/cargar-en-ums.mjs`, ya idempotente, y **verificar la carga** (`GET /system-suites` contiene `SDLC`; existen las 8 cuentas `*.sdlc@beyondnet.com.pe`). Un cargador que termina sin error pero deja el catálogo a medias es el peor punto de partida para un robot. +7. **Ejecutar** `certify-e2e.sh --carril c`. + +### 7.4 Obtención de los perfiles de `BEYONDNET` + +**Hay dos poblaciones de perfiles y la prueba necesita las dos, para cosas distintas.** + +**(a) Los perfiles del Tablero — los que dan sentido a la comparación.** Los define `src/provisioning/sdlc/sdlc-suite.json`: 8 roles con matrices de permiso deliberadamente desiguales, que es justo lo que una prueba de gating necesita. + +| Rol | Nombre | Nivel | Concesiones en su plantilla | +| :--- | :--- | ---: | ---: | +| `ADMIN_SDLC` | Administrador del Tablero | 0 | 1 (comodín) | +| `DIRECTORIO` | Directorio | 0 | 6 | +| `AUDITOR` | Auditor de Cumplimiento | 0 | 1 | +| `PMO` | Oficina de Gestión | 1 | 7 | +| `ARQUITECTO` | Arquitecto de la Suite | 1 | 11 | +| `PRODUCT_OWNER` | Product Owner | 2 | 9 | +| `TECH_LEAD` | Líder Técnico | 2 | **20** | +| `EQUIPO` | Miembro de Equipo | 3 | 16 | + +El cargador crea una cuenta por rol con el patrón `correoDe()` de `cargar-en-ums.mjs:495`. **Ninguna existe todavía** (§5, V-02): esta población es *consecuencia* de aprovisionar, no un dato disponible hoy. + +**(b) Los perfiles logísticos sembrados — disponibles ya, útiles para el carril de autenticación.** Verificado contra la API viva: + +- **78 cuentas** en el inquilino, con estados reales `Active`, `Pending` y `Blocked` — la prueba de «usuario bloqueado» e «inactivo» tiene sujeto real, sin fabricarlo. +- **13 perfiles activos** sobre **9 roles** y **4 suites**: + +| Suite | Roles | +| :--- | :--- | +| `SIL` | `ADMINISTRADOR` (×2), `ANALISTA_DOC`, `AUDITOR`, `EJECUTIVO_CUENTA` (×2) | +| `ADUANAS` | `AGENTE_ADUANAS` (×2), `DESPACHADOR` | +| `WMS` | `JEFE_ALMACEN` (×2), `OPERARIO_ALMACEN` | +| `TMS` | `COORD_TRANSPORTE` | + +- **Todas las cuentas semilla de BEYONDNET comparten la contraseña `BeyondNet.Dev.2026`** (`CoreDevDataSeeder.cs:53`, `BeyondNetDevPassword`, aplicada en `IdentityDevDataSeeder.cs:416`). Es dato de desarrollo bajo `SeedDevData && !IsProduction`, no un secreto. + +**El robot descubre los perfiles, no los codifica:** `GET /api/v1/profiles?page=1&pageSize=100` con la sesión del administrador devuelve `userEmail`, `roleCode`, `systemSuiteCode` y `scope`. La fixture se genera de ahí, y **cubre las dos poblaciones sin distinguirlas**: tras aprovisionar, los perfiles `SDLC` aparecen en la misma consulta. Si la siembra o la spec cambian, la prueba se adapta sola en vez de mentir. + +**Reparto entre carriles:** los perfiles logísticos (b) sirven para `01-acceso` y `02-sesion` —autenticar, cookie, vigencia, logout— porque para eso el contenido del grafo es indiferente. La comparación de gating (`03-gating-perfiles`) **solo tiene sentido con la población (a)**, y por tanto **depende de V-02**. + +### 7.5 Comparación entre perfiles — cómo se hace honesta + +Diferencia real medida hoy entre dos perfiles logísticos: `ANALISTA_DOC` (suite `SIL`, menús `FILES`/`COST`/`TRACE`, 8 scopes) vs `JEFE_ALMACEN` (suite `WMS`, menús `INV`/`RCV`/`REPORTS`, 12 scopes). **El grafo distingue perfiles con nitidez.** Lo que hoy no puede reflejarlo es la interfaz del Tablero, porque los códigos que gatea no son los que nadie le entrega (V-02). + +Con la suite `SDLC` cargada y los códigos alineados, la diferencia esperada es mucho más rica: de 1 concesión (`AUDITOR`) a 20 (`TECH_LEAD`) sobre el mismo árbol de 64 nodos. Ese contraste es el que convierte la prueba en una verificación de autorización y no en un humo de login. + +**El grafo esperado por rol ya está materializado** en `src/provisioning/sdlc/auth-graph/` (generado por `render-auth-graph.mjs`): sirve como **oráculo** del carril de contrato, en vez de afirmar contra lo que UMS devuelva —que sería tautológico—. + +Por eso la comparación se hace en **tres niveles, y los tres deben concordar**: + +| Nivel | Qué compara | Cómo | +| :--- | :--- | :--- | +| **Contrato** | El grafo que UMS entrega por perfil | `GET /client/graph`; se afirma sobre `menuAccess`, `scopes` y `context.role` | +| **Sesión** | Lo que el Tablero expone al cliente | `GET /api/auth/sesion`; el `graph` devuelto debe ser **idéntico** al del nivel anterior | +| **Interfaz** | Lo que el usuario ve | Elementos presentes en la barra lateral y la `AppBar` | + +**La aserción que da valor a la prueba es la de concordancia, no la de presencia:** *el conjunto de secciones visibles es exactamente el conjunto de códigos de menú con `Allow` efectivo en el grafo de ese perfil.* Así, un perfil que ve de más **y** un perfil que ve de menos fallan igual. Comprobar solo que «el administrador ve más» **pasaría en verde con la interfaz vacía de hoy**, que es precisamente el falso positivo contra el que hay que blindarse. + +**Matriz mínima:** un perfil por cada uno de los 8 roles `SDLC`, más las tres cuentas no operables (`Pending`, `Blocked`, inexistente) de la población logística. + +### 7.6 Evidencias generadas + +| Evidencia | Mecanismo | Política | +| :--- | :--- | :--- | +| Informe | Reporter HTML de Playwright + JUnit XML | Siempre | +| Traza navegable | `trace` (DOM + red + consola por paso) | `on-first-retry`, como el config existente | +| Captura | `screenshot` | `only-on-failure` | +| Vídeo | `video` | `retain-on-failure` | +| **HTTP** | HAR por escenario | Siempre. Deja auditable la conversación completa, incluida la llamada del pod a UMS | +| **JWT** | Cabecera y payload **decodificados y volcados**, con `exp`, `graph_generated_at`, `graph_valid_until`, `jti` y `scope` | Siempre. La firma **no** se adjunta | +| **Grafo** | El JSON de `/client/graph` por perfil, adjunto | Siempre. Es el contrato contra el que se afirma | +| **Sesión** | Atributos de la cookie: `HttpOnly`, `Secure`, `SameSite`, `Max-Age`. Y que `localStorage`/`sessionStorage` **no** contienen el token | Siempre. `ADR-0155` §2.3 lo exige y hay que demostrarlo, no suponerlo | +| Métricas | Duración por escenario, reintentos, flakiness | Siempre. **Una prueba que necesita reintento no es verde: es deuda** | + +**Regla anti-fuga:** ningún artefacto puede contener `UMS_JWT_SECRET` ni la firma de un JWT. El HAR de la llamada servidor-a-servidor lleva credenciales de usuario en el cuerpo: el helper de evidencia debe redactarlas. El `pre-push` con gitleaks escanea todo el árbol, y estos artefactos no deben versionarse. + +### 7.7 Simulación — solo donde no se puede provocar de verdad + +| Escenario | Cómo | Por qué | +| :--- | :--- | :--- | +| UMS indisponible | `kubectl scale deploy/ums-backend --replicas=0` | Indisponibilidad **real**. Prueba que el Tablero distingue «no responde» (502) de «responde error» (401) — `auth-ums.js:295-305` ya lo distingue y hay que verificarlo | +| Token con firma inválida | `firmarJwtHs256(payload, 'otro-secreto')` (`auth-ums.js:55`) | La utilidad ya existe. **Reutilizarla** | +| Token caducado | El mismo helper con `exp` en el pasado | Sin esperar una hora | +| Grafo vencido | `validUntil` en el pasado | Verifica `grafoVigente()` sin esperar 30 min | +| Cuenta bloqueada/pendiente | **Cuentas semilla reales** (`ex.empleado@…` = `Blocked`, `coordinador.flota@…` = `Pending`) | No se simula lo que existe | +| Timeout | `page.route` con retardo | Único caso sin equivalente real barato | + +--- + +## 8. Orden de ejecución recomendado + +### Bloque 0 — Gobernanza (bloquea todo lo demás; ninguna línea de código antes) + +| # | Acción | Dónde | +| :--- | :--- | :--- | +| 0.1 | **Enmendar `ADR-0155` §2.3/§2.4**: el grafo se obtiene por API y se cachea (alinear con `D-031`) | `evolith-core` | +| 0.2 | Resolver la divergencia `SameSite` `Lax` vs `Strict` | `evolith-core` | +| 0.3 | **Resolver el desajuste de códigos** entre `sdlc-suite.json` y `common.jsx:10-16` (0 de 5 coinciden, §5 V-02): ¿la spec adopta el prefijo `TABLERO.` de `ADR-0155` §2.5, o el cliente web adopta los códigos de la spec? | `evolith-core` + `ums` | +| 0.4 | Decidir el alcance de `G-187` (refresco por portador): ¿dentro o fuera? (§5, V-05) | Cliente | +| 0.5 | Actualizar el registro de gaps: `G-190` está obsoleto en su parte de UMS y subestimado en su parte de Tablero (§4.6) | `ums` | + +### Bloque 1 — Prerrequisitos técnicos (paralelizables entre sí) + +| # | Acción | Vacío | Repositorio | +| :--- | :--- | :--- | :--- | +| 1.1 | Corregir `procesarLogin`/`resolverSesion`: grafo por API + caché de servidor | V-01 | `evolith-core` (Tablero) | +| 1.2 | Reescribir `auth-ums.test.mjs` y añadir prueba de contrato con fixture **capturado de la API real** | V-08 | `evolith-core` (Tablero) | +| 1.3 | **Alinear los códigos** según 0.3 y **ejecutar el cargador existente** `src/provisioning/sdlc/cargar-en-ums.mjs` hasta que sea idempotente. **No escribir un cargador nuevo** — y sincronizar con el trabajo ya en curso sobre ese archivo | V-02 | `ums` | +| 1.4 | Inyectar `Jwt__Secret` en el chart de UMS por `secretKeyRef` | V-04 | `ums` | +| 1.5 | Añadir `UMS_BASE_URL` / `UMS_JWT_SECRET` / `UMS_TENANT_DEFAULT` a `k8s/app.yaml` | V-03 | `evolith-core` (Tablero) | +| 1.6 | Alinear `k8s/kind-cluster.yaml` con la realidad y añadir Ingress + puerto para `tablero-web` | V-07, V-10 | `evolith-core` (Tablero) | + +**Puerta de salida del bloque 1 — una sola comprobación:** desde el pod `tablero-app`, `POST /api/auth/login` con `admin@beyondnet.com.pe` / `BEYONDNET` devuelve **200 con `Set-Cookie`**, y `GET /api/auth/sesion` devuelve **`autenticado:true` con grafo**. Mientras esto no ocurra, escribir el robot es escribir 19 pruebas rojas que describen una funcionalidad ausente — el error que ya se cometió y que documenta [`G-189`](../../GAPS.md). + +### Bloque 2 — Entorno reproducible + +| # | Acción | +| :--- | :--- | +| 2.1 | `scripts/e2e-dos-clusteres.sh` (`up`/`reset`/`test`/`status`/`down`), §7.3 | +| 2.2 | Red Docker `beyondnet-e2e` + alias + verificación de resolución desde el pod (**la pieza no verificada de §6.3**) | +| 2.3 | Espera activa con las **tres** comprobaciones de §7.3.5, incluida `{"configurado":true}` | +| 2.4 | Ejecución en frío completa desde cero, dos veces, con el mismo resultado | +| 2.5 | Integrar el cargador de la suite `SDLC` en el `up` del script, tras la espera activa de UMS | + +### Bloque 3 — El robot (esto es la prueba; lo anterior son prerrequisitos) + +| # | Acción | +| :--- | :--- | +| 3.1 | `integracion-tablero/` dentro del arnés RoboSoft + `--carril c` en `certify-e2e.sh` | +| 3.2 | Fixtures de perfiles **descubiertos** por `GET /api/v1/profiles` | +| 3.3 | `01-acceso` y `02-sesion` (autenticación y sesión) | +| 3.4 | `03-gating-perfiles` — comparación en tres niveles (§7.5). **Es el escenario que justifica el encargo** | +| 3.5 | `04-resiliencia` y `05-alta-recuperacion` | +| 3.6 | Helper de evidencia con redacción de credenciales (§7.6) | + +### Bloque 4 — Deuda declarada, fuera del primer ciclo + +TLS real con `mkcert` (T2 de §6.3) · firma asimétrica [`G-199`](../../GAPS.md) · refresco por portador [`G-187`](../../GAPS.md) · identificador estable en el token semántico [`G-202`](../../GAPS.md) · endpoints con validación propia de portador [`G-201`](../../GAPS.md). + +--- + +## 9. Hallazgos propuestos para registrar + +**No se ha modificado [`GAPS.md`](../../GAPS.md) ni [`DECISIONS.md`](../../DECISIONS.md).** Se proponen para registro posterior, con dimensión, criticidad y complejidad según `S-20`: + +| # | Hallazgo | Dimensión | Criticidad | Complejidad | +| :--- | :--- | :--- | :--- | :--- | +| P-01 | **El login del Tablero contra el UMS real devuelve 502** (§4.1). Corrige y agrava a `G-190`, cuyo enunciado dice que el login funciona | SDLC-Construccion | Alta | Baja | +| P-02 | **`G-190` está obsoleto en su premisa**: `/api/v1/client/graph` existe, responde 200 y tiene pruebas, pero el gap sigue `Pendiente` con evidencia `—` | SDLC-Validacion | Media | Baja | +| P-03 | **Contradicción entre `ADR-0155` §2.3 (`Aceptado`) y `D-031`**. Viola `SD-03`/`S-06`: hay código escrito contra cada lado | Arq-Gobernanza | Alta | Media | +| P-04 | **La ventana del grafo (30 min) es la mitad que la del token (60 min)** y no hay refresco: expulsión silenciosa con credencial válida (§4.4). Cuantifica `G-187` | Arq-Seguridad | Alta | Media | +| P-05 | **La cookie de sesión es `Secure` en todo entorno desplegado**; sobre HTTP en host no-`localhost` el login falla en silencio (§5, V-06) | Arq-Seguridad | Alta | Baja | +| P-06 | **La prueba `auth-ums.test.mjs` fija el contrato equivocado** firmando tokens con grafo embebido: verde contra su propio supuesto (§3.6) | SDLC-Validacion | Alta | Baja | +| P-07 | **`k8s/kind-cluster.yaml` no reproduce el clúster vivo** (`hostPort` 4317 vs 4337) y `tablero-web` no es alcanzable desde el host | SDLC-Entrega | Media | Baja | +| P-08 | **El Tablero desplegado corre en modo dev-abierto** (`{"configurado":false}`): sin gate, acceso anónimo, con el gate de `ADR-0155` presente en el código | Arq-Seguridad | Alta | Baja | +| P-09 | **El namespace `ums` está a 0 réplicas y `ums-uat` no tiene Ingress**: no hay hoy un UMS alcanzable en clúster (§2.1) | SDLC-Entrega | Media | Baja | +| P-10 | **Desajuste total de códigos de menú entre `sdlc-suite.json` y `common.jsx:10-16`** (0 de 5 coinciden): aprovisionar hoy deja la interfaz igual de vacía, con todo lo demás en verde (§5, V-02). Reformula y agrava a `G-277` de `evolith-core`, que lo plantea como «falta el árbol» cuando el árbol existe y lo que falla es el contrato de códigos | SDLC-Construccion | Alta | Media | +| P-13 | **La suite `SDLC` no está cargada en la instancia viva** y no existe ninguna cuenta `*.sdlc@beyondnet.com.pe`: el cargador nunca completó una ejecución con éxito (§5, V-02) | SDLC-Entrega | Alta | Baja | +| P-14 | **`sdlc-suite.json` no sigue la convención de `code` de `ADR-0155` §2.5** (raíz `TABLERO`, jerárquica punteada) sin declarar divergencia, y el `README.md` del directorio sigue en estado `Borrador` pese a ser la fuente del alta | Arq-Gobernanza | Media | Baja | +| P-11 | **`SameSite=Lax` diverge de `ADR-0155` §2.3 (`Strict`)** sin justificación declarada | Arq-Gobernanza | Baja | Baja | +| P-12 | **El plan `plan-e2e-fase1-autenticacion.md` §3.1 y §8 quedan invalidados**: CORS, CSP de UMS y `SameSite=None` no aplican al proxy servidor-a-servidor (§6.1), y §4 propone construir lo que ya existe | SDLC-Diseno | Media | Baja | + +### 9.1 Hallazgos añadidos el 2026-08-02 + +| # | Hallazgo | Dimensión | Criticidad | Complejidad | +| :--- | :--- | :--- | :--- | :--- | +| P-15 | **El contrato de autenticación no admite el sistema solicitado**, de modo que el desempate resuelve una pregunta que nadie hizo y el satélite recibe el grafo de otro sistema (§5, V-12). Es la causa de contrato de [`G-184`](../../GAPS.md), que hoy está registrado como si fuera un defecto de desempate | SDLC-Diseno | Alta | Media | +| P-16 | **El grafo publica `profiles` sin el identificador de cada perfil**, y `POST /auth/switch-profile` exige `profileId`: el contrato ofrece una operación y retiene su clave (§5, V-13). Reabre la mitad de consumo de [`G-177`](../../GAPS.md), cerrado el 2026-08-01 sin verificar que el cliente pudiera ejecutar el cambio | Arq-Interoperabilidad | Alta | Baja | +| P-17 | **`POST /api/v1/auth/switch-profile` devuelve `401` al portador semántico del satélite** (`LeerTokenDeGrafo` exige `sub` GUID y `tenant_id`). El carril de satélite no tiene cambio de perfil (§5, V-13). Relacionado con [`G-201`](../../GAPS.md) y con [`G-202`](../../GAPS.md), que documenta la falta de identificador estable en el token semántico | Arq-Interoperabilidad | Alta | Media | +| P-18 | **Ambos SDK tipan `graph` como objeto y la API lo devuelve como cadena**: todo login por SDK falla con `AuthGraphSchemaMissing`. Ningún SDK ha ejercido nunca el endpoint real (§5, V-15). Misma clase que el 502 del Tablero | SDLC-Validacion | Alta | Baja | +| P-19 | **Los 12 golden fixtures del contrato codifican un estado imposible** (`profiles: []` con `onboardingPending: false`) y **ninguno ejerce el bloque `profiles`**. Es la razón mecánica por la que P-16 pasó inadvertido (§5, V-15) | SDLC-Validacion | Alta | Baja | +| P-20 | **Ningún usuario del inquilino tiene más de un perfil** (13 perfiles / 13 usuarios): el escenario multi-perfil, que la especificación pone en el camino principal, no tiene sujeto (§5, V-14) | SDLC-Validacion | Media | Baja | +| P-21 | **El arnés RoboSoft pinea `schemaVersion == "2.2.0"` mientras el servidor emite `2.3.0`** (`contexts/configuration.py:418-420`): el pin estaba desfasado antes de este cambio | SDLC-Validacion | Media | Baja | +| P-22 | **El segundo criterio de desempate ordena por `SystemSuiteId` —un GUID— pese a que su comentario dice «luego el sistema»** (`AuthorizationGraphBuilderService.cs:165`). Es el mismo defecto que [`G-177`](../../GAPS.md) corrigió en el primer criterio y dejó sin corregir en el segundo | SDLC-Construccion | Baja | Baja | +| P-23 | **El filtro por sistema proyectado en `diseno-cambio-de-perfil.md` §4.2 nunca se construyó** y el documento sigue en estado `Propuesta` sin declararlo: un diseño aceptado como referencia describe capacidades que no existen | Arq-Gobernanza | Media | Baja | +| P-24 | **`ADR-0155` estuvo `Aceptado` describiendo un servidor que no existía** (grafo embebido en el token) porque se escribió contract-first sin UMS desplegado, y hay código del Tablero escrito contra esa descripción. Cerrado por supersesión (§0.2); se registra la **clase de riesgo**: un ADR contract-first debe re-verificarse contra el sistema real antes de que su consumidor se construya | Arq-Gobernanza | Alta | Baja | + +**Actualización de P-03:** queda **cerrado** por la supersesión de `ADR-0155` (§0.2). Se conserva en la tabla como registro histórico. + +--- + +## 10. Decisiones que el cliente debe tomar antes de escribir código + +> **Estado 2026-08-02: D1–D7 confirmadas por el cliente.** La tabla se conserva como registro. Cambios sobre lo escrito: +> +> * **D1** — confirmada, y **ejecutada por supersesión** en vez de por enmienda (§0.2, §7.1). El motivo del cambio de vía está en §7.1. +> * **D2** — confirmada la necesidad de decidir, **pero la decisión sigue abierta**: `ADR-0156` §2.9 mantiene la convención `TABLERO.*` y no elige lado. Y ahora es **posterior** a V-12: alinear códigos no sirve mientras el grafo entregado sea el de otro sistema. +> * **D7** — confirmada **solo en su parte de TLS**. Su parte de multi-perfil (**«se acepta que la prueba no ejercite multi-perfil por usuario»**) queda **revocada** por el punto 6 de la especificación: el multi-perfil es requisito funcional, no escenario opcional. +> +> **Decisiones nuevas que la especificación abre y que no estaban en esta tabla:** +> +> | # | Decisión | Recomendación | +> | :--- | :--- | :--- | +> | **D8** | ¿`profiles[].id` se emite siempre, o se activa `AUTH_GRAPH_INCLUDE_TECHNICAL_METADATA` para el inquilino? | **Siempre.** Activar el flag enciende los ids de módulos, nodos y recursos —decorativos— para conseguir uno que no lo es. Se especifica en el diseño §5.3 | +> | **D9** | ¿Adaptador nuevo `POST /client/switch-profile`, o se extiende `/auth/switch-profile`? | **Adaptador nuevo.** Extender el existente mete al satélite por la puerta con validación manual de `G-201` y le devuelve una cookie de portal. Diseño §8.3 | +> | **D10** | Con 0 perfiles en el sistema pedido, ¿`200` con estado de acceso o un `4xx`? | **`200`.** Un status de error es en sí mismo un oráculo del catálogo y conflatea «credencial mala» con «falta el perfil». Diseño §5.4 | +> | **D11** | ¿Entra `systemCode` también en `POST /auth/login` (portal) en esta ola? | **No.** Cierra solo la mitad de contrato de `G-184`; la otra mitad exige una suite para el portal que no existe en el catálogo. Diseño §10 | + + +| # | Decisión | Opciones | Recomendación | +| :--- | :--- | :--- | :--- | +| **D1** | **¿Se enmienda `ADR-0155` §2.3 para alinearlo con `D-031`?** | (a) Enmendar el ADR y corregir el Tablero · (b) Revertir `D-031` y volver a embeber el grafo en el token | **(a).** `D-031` está bien argumentada —grafo revocable, token de 1 149 B— y es lo que UMS ya implementa. Pero **la enmienda va primero**: sin ella el código nace violando `S-06` | +| **D2** | **¿Qué lado cede en el desajuste de códigos de menú?** Hoy `sdlc-suite.json` declara `SATELITES`/`DASHBOARDS`/`DEM_TABLERO`/`PER_REGISTRO`/`CFG_*` y `common.jsx:10-16` exige `TABLERO.*`. Coinciden **cero de cinco** | (a) La spec adopta el prefijo `TABLERO.` de `ADR-0155` §2.5 · (b) El cliente web adopta los códigos de la spec · (c) Se cambia la convención del ADR | **(b) para el primer ciclo, con (a) como destino.** Cambiar 5 constantes en `common.jsx` cuesta minutos; renombrar 64 nodos ya extraídos y trazados a 96 rutas de API arriesga romper la trazabilidad `SD-05` del inventario. Pero la convención del ADR está `Aceptada`, así que (b) es una **divergencia que hay que declarar**, no un atajo silencioso | +| **D3** | **¿Entra el refresco por portador (`G-187`) en el alcance?** | (a) No: la sesión dura 60 min y luego re-login · (b) Sí: se implementa | **(a) para el primer ciclo**, siempre que se acepte que ningún escenario dure más de 60 min. (b) si se quiere probar renovación de sesión, y entonces es trabajo de UMS | +| **D4** | **¿TLS en el primer ciclo?** | (a) T1: todo por `localhost`, sin TLS · (b) T2: `mkcert` + hosts `.local` | **(a).** Por V-06, (b) sin certificados **rompe el login en silencio**; con certificados añade una clase de fallo que enmascara los defectos de contrato que la prueba busca | +| **D5** | **¿Cómo se custodia el secreto HS256 compartido entre dos clústeres?** | (a) Generado por el script en cada `up`, efímero · (b) Secret fijo gestionado fuera · (c) Se acelera `G-199` (firma asimétrica) | **(a) para la prueba**, con la advertencia impresa en cada ejecución. (c) es lo correcto a plazo y **exige ADR aceptado antes de tocar código** (`S-06`) | +| **D6** | **¿El robot cubre también el portal de UMS, o solo el Tablero?** | (a) Solo el Tablero · (b) Ambos | **(a).** El portal ya tiene su carril A en RoboSoft. Duplicarlo es el error que este documento existe para evitar | +| **D7** | **¿Se acepta que la prueba no ejercite TLS ni multi-perfil por usuario?** | Sí / No | **Sí, declarándolo.** Un perfil por usuario (13 disponibles) cubre la comparación; el cambio de perfil en caliente depende de `G-201` y `G-202`, ambos abiertos | + +--- + +

+ © BeyondNet S.A.C. · RUC 20100412447 · Operador Logístico Aduanero desde 1978 +

diff --git a/docs/architecture/ep-06-approvals-detailed-design.es.md b/docs/architecture/ep-06-approvals-detailed-design.es.md new file mode 100644 index 00000000..8d7bbb6d --- /dev/null +++ b/docs/architecture/ep-06-approvals-detailed-design.es.md @@ -0,0 +1,1074 @@ +# EP-06: Diseño Detallado — Seguridad, Acceso Externo y Delegación **Versión:** 1.0 **Fecha:** 2026-05-14 **Épica:** EP-06 (Post-MVP) + +**Historias:** US-017 a US-022 **Functional Stories:** FS-09, FS-10, FS-14 + +--- + +## PARTE 1: FS-09 — Adaptive MFA & Passwordless Authentication + +### 1.1 Definición **FS-09** implementa autenticación adaptativa donde + +* **MFA**: Multi-Factor Authentication (requisito condicional basado en riesgo) +* **Passwordless**: Métodos sin contraseña (FIDO2, magic links, biometría) + +El sistema calcula un **Risk Score**en tiempo real y decide automáticamente si MFA es requerido. + +### 1.2 Risk Scoring Model + +#### 1.2.1 Factores de Riesgo + +| Factor | Rango | Peso | Ejemplo | +| -------- | ------- | ------ | --------- | +| **Login Frequency Anomaly** | 0-30 | 0.20 | User nunca ha hecho login a esta hora | +| **Geographic Anomaly** | 0-30 | 0.25 | User está en país diferente al usual | +| **Device Reputation** | 0-20 | 0.15 | Device nuevo o no reconocido | +| **Network Anomaly** | 0-10 | 0.10 | IP sospechosa, VPN, proxy | +| **Failed Attempts** | 0-10 | 0.10 | N intentos fallidos recientes | +| **Tenant Risk Level** | 0-30 | 0.20 | Tenant categorizado como "high-risk" | + +**Risk Score = Σ(Factor × Weight)** Rango: 0 (bajo riesgo) a 100 (alto riesgo) + +#### 1.2.2 Thresholds de Decisión + +```csharp +public class MFADecisionEngine +{ + // Risk Score → MFA Requirement + public MFARequirement CalculateMFARequirement(decimal riskScore, User user, Tenant tenant) + { + return (riskScore, user.Category, tenant.RiskLevel) switch + { + // Bajo riesgo: Sin MFA requerido + (< 20, _, _) => MFARequirement.NotRequired, + + // Riesgo medio: MFA recomendado (opcional) + (20 to 40, UserCategory.INTERNAL, _) => MFARequirement.Recommended, + (20 to 40, _, _) => MFARequirement.Required, + + // Riesgo alto: MFA obligatorio + (40 to 70, _, _) => MFARequirement.Required, + + // Riesgo crítico: MFA + intervención de admin + (> 70, _, _) => MFARequirement.RequiredWithSecurityReview, + + _ => MFARequirement.Required + }; + } +} + +public enum MFARequirement +{ + NotRequired, // User puede skipear MFA + Recommended, // Mostrar prompt pero permitir skip + Required, // MFA obligatorio + RequiredWithSecurityReview // MFA + manual admin review +} +``` + +#### 1.2.3 Cálculo de Riesgos por Factor + +```csharp +public class RiskScoringEngine +{ + // Factor 1: Login Frequency Anomaly (0-30 puntos) + public int CalculateFrequencyAnomaly(User user, DateTime loginAttemptTime) + { + var userLoginHistory = _auditRepository.GetLoginsByUser(user.Id, lastDays: 30); + var usualLoginHours = userLoginHistory + .GroupBy(l => l.Timestamp.Hour) + .Select(g => (hour: g.Key, frequency: g.Count())) + .OrderByDescending(g => g.frequency) + .Take(5) // Top 5 horas + .Select(g => g.hour) + .ToList(); + + if (!usualLoginHours.Contains(loginAttemptTime.Hour)) + return 30; // Anomalía total + + return 0; // Patrón conocido + } + + // Factor 2: Geographic Anomaly (0-30 puntos) + public int CalculateGeographicAnomaly(User user, string ipAddress) + { + var userLocation = _geoIpService.GetLocation(ipAddress); + var usualCountries = _auditRepository.GetLoginsByUser(user.Id, lastDays: 90) + .Select(l => _geoIpService.GetLocation(l.IpAddress).Country) + .Distinct() + .ToList(); + + if (!usualCountries.Contains(userLocation.Country)) + { + // Check si geográficamente POSIBLE viajar en el tiempo + var lastLoginLocation = _auditRepository.GetLastLogin(user.Id); + var travelTime = CalculateTravelTime(lastLoginLocation, userLocation); + + if (travelTime.TotalMinutes < 120) // Imposible viajar en 2h + return 30; // Muy sospechoso + + return 20; // Viaje posible pero raro + } + + return 0; + } + + // Factor 3: Device Reputation (0-20 puntos) + public int CalculateDeviceReputation(User user, string deviceFingerprint) + { + var knownDevices = _deviceRepository.GetDevicesByUser(user.Id) + .Where(d => d.Status == DeviceStatus.TRUSTED) + .Select(d => d.Fingerprint) + .ToList(); + + if (!knownDevices.Contains(deviceFingerprint)) + return 20; // Device desconocido + + return 0; + } + + // Factor 4: Network Anomaly (0-10 puntos) + public int CalculateNetworkAnomaly(string ipAddress) + { + var threatIntel = _threatIntelService.CheckIP(ipAddress); + + return threatIntel switch + { + { IsMalicious: true } => 10, + { IsVPN: true } => 5, // VPN = algo sospechoso + { IsProxy: true } => 5, + { IsTor: true } => 10, + _ => 0 + }; + } + + // Factor 5: Failed Attempts (0-10 puntos) + public int CalculateFailedAttempts(User user, string ipAddress) + { + var recentFailures = _auditRepository + .GetFailedLoginAttempts(user.Id, ipAddress, lastMinutes: 60) + .Count; + + return recentFailures switch + { + 0 => 0, + 1 to 3 => 3, + 4 to 6 => 7, +>= 7 => 10 + }; + } + + // Factor 6: Tenant Risk Level (0-30 puntos) + public int CalculateTenantRiskLevel(Tenant tenant) + { + return tenant.RiskLevel switch + { + TenantRiskLevel.LOW => 0, + TenantRiskLevel.MEDIUM => 10, + TenantRiskLevel.HIGH => 25, + TenantRiskLevel.CRITICAL => 30, + _ => 10 + }; + } +} +``` + +--- + +### 1.3 Acceptance Criteria (FS-09) + +#### US-017: Adaptive MFA**Como:** Administrador de Seguridad **Quiero:** Reglas de MFA adaptativo para exigir verificación en accesos de riesgo **Para que:** La postura de seguridad mejore sin fricción uniforme **Criteria:** + +```gherkin +Feature: Adaptive MFA Requirements + + Scenario: Low-risk login (interno, dispositivo conocido, hora usual) + Given User "alice@corp.com" (INTERNAL) intenta login a las 9am + And desde su dispositivo conocido + And desde su país usual + When Risk Score calculado = 15 + Then MFA no es requerido + And login completa sin MFA + + Scenario: Medium-risk login (hora inusual) + Given User "bob@corp.com" intenta login a las 3am + And Risk Score calculado = 35 + When User category = INTERNAL + Then MFA es "Recommended" (optional) + And se muestra prompt "Verificación adicional?" con skip button + + Scenario: High-risk login (país diferente) + Given User "charlie@corp.com" (EXTERNAL) intenta login desde Brasil + And su último login fue desde USA hace 1 hora (viaje imposible) + When Risk Score calculado = 75 + Then MFA es "Required" + And login BLOQUEADO hasta completar MFA + + Scenario: Critical risk login (múltiples factores) + Given User intenta login con Risk Score = 85 + And factores: país desconocido + 5 intentos fallidos + IP maliciosa + When Risk Score > 70 + Then MFA es "RequiredWithSecurityReview" + And login bloqueado + security team notificado + And auditoría registra intent malicioso + + Scenario: Tenant High-Risk Category + Given Tenant "HighRiskCorp" categorizado como HIGH_RISK + And User es de ese tenant + When cualquier login + Then Risk Score recibe +25 puntos automáticamente + And MFA es más probable (threshold más bajo) +``` + +--- + +### 1.4 Métodos Passwordless Soportados + +#### FS-09 Scope: Métodos Disponibles + +| Método | Descripción | Seguridad | UX | Requisitos | +| -------- | ------------- | ----------- | ----- | ------------ | +| **FIDO2 / WebAuthn** | Biometría o security key | (Alta) | (Excelente) | Device con soporte FIDO2 | +| **Magic Link** | Link por email con token temporal | (Media) | (Excelente) | Email access | +| **App Notification** | Push a app móvil (similar a Microsoft/Google Authenticator) | (Alta) | (Excelente) | Authenticator app instalada | +| **SMS OTP** | Código temporal por SMS | (Baja) | (Buena) | Número teléfono verificado | +| **TOTP** | Time-based OTP (Google Authenticator, Authy) | (Media) | (Buena) | Authenticator app | + +**MVP FS-09 Scope:** FIDO2 + Magic Link + App Notification + +```csharp +public interface IPasswordlessMethod +{ + string MethodName { get; } // "fido2", "magic_link", "app_notification" + Task InitiateAsync(User user); + Task VerifyAsync(PasswordlessChallenge challenge, string response); +} + +public class FIDO2Method : IPasswordlessMethod +{ + public string MethodName => "fido2"; + + public async Task InitiateAsync(User user) + { + // 1. Generar challenge (random bytes) + var challenge = GenerateSecureChallenge(32); + + // 2. Recuperar credential IDs registrados del usuario + var credentials = await _credentialRepository.GetFIDO2CredentialsByUser(user.Id); + + // 3. Construir WebAuthn PublicKeyCredentialRequestOptions + var options = new PublicKeyCredentialRequestOptions + { + Challenge = challenge, + Timeout = 60000, // 60 segundos + UserVerification = UserVerificationRequirement.Preferred, + AllowCredentials = credentials.Select(c => new PublicKeyCredentialDescriptor + { + Type = PublicKeyCredentialType.PublicKey, + Id = Convert.FromBase64String(c.CredentialId) + }).ToList() + }; + + // 4. Guardar challenge en cache temporal (expiración 5 min) + await _challengeCache.SetAsync($"fido2:{user.Id}", challenge, TimeSpan.FromMinutes(5)); + + return new PasswordlessChallenge + { + Method = "fido2", + Options = JsonSerializer.Serialize(options), + ExpiresAt = DateTime.UtcNow.AddMinutes(5) + }; + } + + public async Task VerifyAsync(PasswordlessChallenge challenge, string response) + { + // 1. Parsear respuesta WebAuthn del cliente + var assertion = JsonSerializer.Deserialize(response); + + // 2. Validar signature usando credential público + var credential = await _credentialRepository.GetCredential(assertion.Id); + var isValid = VerifySignature(assertion, credential.PublicKey); + + // 3. Validar counter (prevenir replay attacks) + if (assertion.SignCount <= credential.SignCount) + return false; // Posible cloning attack + + credential.SignCount = assertion.SignCount; + await _credentialRepository.UpdateAsync(credential); + + return isValid; + } +} + +public class MagicLinkMethod : IPasswordlessMethod +{ + public string MethodName => "magic_link"; + + public async Task InitiateAsync(User user) + { + // 1. Generar token único (40 caracteres aleatorios) + var token = GenerateSecureToken(40); + + // 2. Crear "passwordless session" en BD + var session = new PasswordlessSession + { + Id = Guid.NewGuid(), + UserId = user.Id, + Method = "magic_link", + Token = HashToken(token), // Store hash, no plaintext + ExpiresAt = DateTime.UtcNow.AddMinutes(15), + Status = PasswordlessSessionStatus.PENDING + }; + await _sessionRepository.AddAsync(session); + + // 3. Enviar email con link + var magicLink = $"https://ums.example.com/auth/passwordless/verify?token={token}&session={session.Id}"; + await _emailService.SendAsync(user.Email, new PasswordlessMagicLinkEmail + { + UserName = user.Name, + MagicLink = magicLink, + ExpiresIn = "15 minutos" + }); + + return new PasswordlessChallenge + { + Method = "magic_link", + SessionId = session.Id.ToString(), + ExpiresAt = session.ExpiresAt, + Message = $"Link enviado a {MaskEmail(user.Email)}" + }; + } + + public async Task VerifyAsync(PasswordlessChallenge challenge, string response) + { + // response = token del user + var session = await _sessionRepository.GetAsync(Guid.Parse(challenge.SessionId)); + + if (session == null || session.ExpiresAt < DateTime.UtcNow) + return false; // Session no existe o expiró + + // Timing-safe comparison para evitar timing attacks + var isValid = TimingSafeEquals(HashToken(response), session.Token); + + if (isValid) + { + session.Status = PasswordlessSessionStatus.VERIFIED; + session.VerifiedAt = DateTime.UtcNow; + await _sessionRepository.UpdateAsync(session); + } + + return isValid; + } +} +``` + +#### Magic Link Flow Detallado + +```mermaid +sequenceDiagram + participant Browser + participant API as UMS API + participant Email + Browser->>API: POST /auth/passwordless { email } + Note over API: Generar token
Crear session
Hash token + API->>Email: Enviar magic link + Email-->>API: Email sent + API-->>Browser: 202 Accepted { sessionId, expiresAt } + Note over Browser: Usuario click magic link + Browser->>API: GET /auth/passwordless/verify?token=XXX&session=YYY + Note over API: Recuperar session
Validar token
Crear JWT + API-->>Browser: 302 Redirect + Set-Cookie session_jwt + Note over Browser: Usuario autenticado +``` + +--- + +### 1.5 Configuration (FS-09) + +Dónde y cómo se configuran las reglas MFA: + +```sql +-- Nueva tabla en Configuration Context +CREATE TABLE configuration.mfa_policies (id uuid PRIMARY KEY, + root_tenant_id uuid NOT NULL, + code varchar(64), -- "default", "high-risk-users", etc. + name varchar(255), + enabled boolean, + scope_type varchar(32), -- 'GLOBAL', 'TENANT', 'ORGANIZATION' + applies_to_user_category varchar(32), -- 'INTERNAL', 'EXTERNAL', 'B2B' + +-- Risk-based thresholds + risk_score_required_threshold integer, -- Ej: 40 + risk_score_review_threshold integer, -- Ej: 70 + +-- Enabled methods + allow_fido2 boolean, + allow_magic_link boolean, + allow_app_notification boolean, + allow_sms_otp boolean, + allow_totp boolean, + +-- Passwordless-only mode (no password auth) + passwordless_only boolean, + + created_at timestamptz, + modified_at timestamptz, + root_tenant_id uuid); + +-- Tabla de Risk Scoring customization por tenant +CREATE TABLE configuration.risk_scoring_weights (id uuid PRIMARY KEY, + root_tenant_id uuid NOT NULL, + frequency_anomaly_weight DECIMAL(3,2), -- Default: 0.20 + geographic_anomaly_weight DECIMAL(3,2), -- Default: 0.25 + device_reputation_weight DECIMAL(3,2), -- Default: 0.15 + network_anomaly_weight DECIMAL(3,2), -- Default: 0.10 + failed_attempts_weight DECIMAL(3,2), -- Default: 0.10 + tenant_risk_weight DECIMAL(3,2) -- Default: 0.20); +``` + +--- + +## PARTE 2: FS-14 — Delegated Administration & Scopes + +### 2.1 Definición **FS-14** permite que administradores deleguen autoridad de gestión a otros con límites controlados + +* **Delegating Admin** (A) → **Delegated Admin** (B): "Puedes gestionar usuarios en mi división" +* **Scope Limiting**: "Solo en ORGANIZATION X", "Solo acciones CREATE_USER y ASSIGN_PROFILE" +* **Temporal Constraints**: "Válido hasta 2026-12-31" +* **Approval Required**: Crear delegación puede requerir aprobación (si es sensitive) + +### 2.2 State Machine (Delegación) + +#### Ciclo de Vida de la Delegacion + +```mermaid +stateDiagram-v2 + [*] --> DRAFT: Admin completa configuracion + DRAFT --> PENDING_APPROVAL: Si requiere approval + DRAFT --> ACTIVE: Si no requiere approval + PENDING_APPROVAL --> ACTIVE: Approver aprueba + PENDING_APPROVAL --> REJECTED: Approver rechaza + ACTIVE --> REVOKED: Revocado + ACTIVE --> EXPIRED: Expirado + ACTIVE --> COMPLETED: Finaliza + REVOKED --> ARCHIVED + EXPIRED --> ARCHIVED + COMPLETED --> ARCHIVED + REJECTED --> ARCHIVED + ARCHIVED --> [*] +``` + +#### 2.2.1 Estados Detallados + +| Estado | Descripción | Transiciones Válidas | Eventos | +| -------- | ------------- | --------------------- | -------- | +| **DRAFT** | Delegación en creación, no visible | → PENDING_APPROVAL, → ACTIVE | Created | +| **PENDING_APPROVAL** | Esperando aprobación (si config lo requiere) | → ACTIVE (approved), → REJECTED | SubmittedForApproval | +| **ACTIVE** | Delegación operativa | → REVOKED, → EXPIRED | Activated | +| **REVOKED** | Revocado manualmente por admin | → ARCHIVED | Revoked | +| **EXPIRED** | Expiró por fecha (valid_until) | → ARCHIVED | Expired | +| **COMPLETED** | Finalizado naturalmente (fin de período) | → ARCHIVED | Completed | +| **REJECTED** | Rechazado en aprobación | → ARCHIVED | Rejected | +| **ARCHIVED** | Histórico (no visible en operaciones) | (ninguna) | Archived | + +#### 2.2.2 Transiciones Bloqueadas + +```csharp +public class DelegationStateValidator +{ + public bool IsValidTransition(DelegationStatus from, DelegationStatus to) + { + var validTransitions = new Dictionary> + { + { DelegationStatus.DRAFT, new() { DelegationStatus.PENDING_APPROVAL, DelegationStatus.ACTIVE } }, + { DelegationStatus.PENDING_APPROVAL, new() { DelegationStatus.ACTIVE, DelegationStatus.REJECTED } }, + { DelegationStatus.ACTIVE, new() { DelegationStatus.REVOKED, DelegationStatus.EXPIRED } }, + { DelegationStatus.REVOKED, new() { DelegationStatus.ARCHIVED } }, + { DelegationStatus.EXPIRED, new() { DelegationStatus.ARCHIVED } }, + { DelegationStatus.COMPLETED, new() { DelegationStatus.ARCHIVED } }, + { DelegationStatus.REJECTED, new() { DelegationStatus.ARCHIVED } }, + { DelegationStatus.ARCHIVED, new() { } } // Terminal + }; + + return validTransitions.ContainsKey(from) && validTransitions[from].Contains(to); + } +} +``` + +--- + +### 2.3 Scope Model (Límites de Delegación) + +Una delegación define qué acciones puede hacer el delegated admin. + +#### 2.3.1 Scope Types + +```csharp +public enum ScopeType +{ + TENANT, // Toda la organización (root tenant) + ORGANIZATION, // Una organización específica (child tenant) + DEPARTMENT, // Un departamento + SYSTEM, // Un sistema/aplicación específico + TEAM // Un equipo +} + +public record DelegationScope +{ + public ScopeType Type { get; init; } + public Guid? ScopeId { get; init; } // ID de la organización, sistema, etc. + public List AllowedActions { get; init; } // ["CREATE_USER", "ASSIGN_PROFILE"] +} +``` + +#### 2.3.2 Allowed Actions (¿Qué puede hacer el delegated admin?) + +```csharp +public enum DelegatedAction +{ + // User Management + CREATE_USER, + VIEW_USER, + UPDATE_USER, + DEACTIVATE_USER, + DELETE_USER, + RESET_PASSWORD, + + // Profile/Role Assignment + ASSIGN_PROFILE, + REVOKE_PROFILE, + APPROVE_PROFILE_REQUEST, + + // Delegation + CREATE_DELEGATION, + REVOKE_DELEGATION, + VIEW_DELEGATION, + + // Approvals + APPROVE_EXTERNAL_ACCESS, + REJECT_EXTERNAL_ACCESS, + + // Audit/Reporting + VIEW_AUDIT_LOG, + EXPORT_USERS, + + // Configuration + CONFIGURE_ORGANIZATION, + MANAGE_ORGANIZATION_POLICIES +} +``` + +#### 2.3.3 Principle of Least Privilege Validation **Regla crítica:** Un admin delegado NO puede otorgar permisos mayores a los que posee + +```csharp +public class DelegationPermissionValidator +{ + /// + /// Valida que los permisos siendo delegados no excedan los del delegating admin. + /// + public async Task ValidateDelegationAsync(User delegatingAdmin, + User delegatedAdmin, + DelegationScope requestedScope) + { + // 1. Obtener permisos efectivos del delegating admin + var delegatingAdminPermissions = await _authorizationService + .GetEffectivePermissionsAsync(delegatingAdmin.Id); + + // 2. Validar que requested actions están en delegatingAdminPermissions + var unauthorizedActions = requestedScope.AllowedActions + .Except(delegatingAdminPermissions.Select(p => p.ActionCode)) + .ToList(); + + if (unauthorizedActions.Any()) + return ValidationResult.Failure($"Admin no puede delegar acciones: {string.Join(", ", unauthorizedActions)}"); + + // 3. Validar scope: delegating admin no puede delegar fuera de su propio scope + var delegatingAdminScope = await _delegationRepository + .GetDelegationScopeAsync(delegatingAdmin.Id); + + if (!IsWithinScope(requestedScope, delegatingAdminScope)) + return ValidationResult.Failure("Delegación solicitada excede el scope del admin delegante"); + + // 4. Validar que delegated admin no tenga conflictos de interés + // (ej: no delegar a admin de un competidor dentro mismo tenant) + if (HasConflictOfInterest(delegatedAdmin, requestedScope)) + return ValidationResult.Failure("Conflicto de interés detectado"); + + return ValidationResult.Success(); + } + + private bool IsWithinScope(DelegationScope requested, DelegationScope delegatingAdmin) + { + return requested.Type switch + { + ScopeType.TENANT when delegatingAdmin.Type == ScopeType.TENANT + => requested.ScopeId == delegatingAdmin.ScopeId, + + ScopeType.ORGANIZATION when delegatingAdmin.Type == ScopeType.TENANT + => true, // Tenant-level admin puede delegar a org-level + + ScopeType.ORGANIZATION when delegatingAdmin.Type == ScopeType.ORGANIZATION + => requested.ScopeId == delegatingAdmin.ScopeId, + + _ => false + }; + } +} +``` + +--- + +### 2.4 Temporal Constraints + +Delegaciones pueden tener validez limitada. + +```csharp +public record DelegationTemporalConstraints +{ + public DateTime ValidFrom { get; init; } + public DateTime ValidUntil { get; init; } + public TimeSpan? MaxDuration { get; init; } // Máximo duración permitida (ej: 90 días) + public DayOfWeek[]? AllowedDaysOfWeek { get; init; } // Ej: solo business days + public TimeSpan? AllowedTimeRange { get; init; } // Ej: 9am-6pm solo +} + +public class DelegationExpirationService : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + // Cada hora, buscar delegaciones que expiraron + var expiredDelegations = await _delegationRepository + .GetExpiredAsync(DateTime.UtcNow); + + foreach (var delegation in expiredDelegations) + { + // Transicionar a EXPIRED state + delegation.Status = DelegationStatus.EXPIRED; + delegation.ModifiedAt = DateTime.UtcNow; + + await _delegationRepository.UpdateAsync(delegation); + + // Registrar en auditoria + await _auditService.LogAsync(new AuditEvent + { + EventType = "DELEGATION_EXPIRED", + DelegationId = delegation.Id, + RootTenantId = delegation.RootTenantId, + Timestamp = DateTime.UtcNow + }); + + // Notificar al delegating admin + await _notificationService.NotifyAsync(delegation.DelegatingAdminId, + "Delegación expirada", + $"Delegación a {delegation.DelegatedAdmin.Name} expiró"); + } + + await Task.Delay(TimeSpan.FromHours(1), stoppingToken); + } + } +} +``` + +--- + +### 2.5 Acceptance Criteria (FS-14) + +```gherkin +Feature: Delegated Administration with Scope Control + + Scenario: Create delegation within scope + Given Admin "alice@corp.com" (TENANT-level) + When crea delegación a "bob@corp.com" + And scope: ORGANIZATION "Sales Division" + And allowed_actions: [CREATE_USER, ASSIGN_PROFILE] + And valid_from: 2026-05-15 + And valid_until: 2026-12-31 + Then Delegation creada en estado DRAFT + And Audit registra: DELEGATION_CREATED + + Scenario: Approve delegation that requires review + Given Delegation en estado PENDING_APPROVAL + When Approver aprueba + Then Delegation transiciona a ACTIVE + And Delegated admin puede gestionar usuarios + And Audit registra: DELEGATION_APPROVED + + Scenario: Prevent escalation of privilege + Given Admin "charlie@corp.com" (ORG-level, permisos limitados) + When intenta crear delegación con permisos > sus propios + Then Validación falla + And Error: "Cannot delegate permissions you don't possess" + And Audit registra: DELEGATION_VALIDATION_FAILED + + Scenario: Auto-expire delegation on valid_until + Given Delegation con valid_until: 2026-12-31 + When Sistema alcanza 2027-01-01 + Then Delegation transiciona automáticamente a EXPIRED + And Delegated admin pierde acceso + And Audit registra: DELEGATION_EXPIRED + + Scenario: Manual revocation by delegating admin + Given Delegation en estado ACTIVE + When Delegating admin ejecuta "Revoke delegation" + Then Delegation transiciona a REVOKED + And Razón de revocación registrada + And Delegated admin recibe notificación + And Audit registra: DELEGATION_REVOKED + + Scenario: Delegated admin operates within scope + Given Delegated admin "bob" con scope: ORG "Sales" + And allowed_actions: [CREATE_USER] + When intenta crear user en Sales org + Then Operación permitida + When intenta crear user en Engineering org (fuera scope) + Then Operación bloqueada + And Error: "Outside delegated scope" +``` + +--- + +## PARTE 3: ER Model Completo (EP-06) + +### 3.1 Tablas Nuevas + +```sql +-- ============================================ +-- APPROVALS CONTEXT TABLES +-- ============================================ + +CREATE TABLE approval.approval_workflows (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + root_tenant_id uuid NOT NULL, + code varchar(64) NOT NULL, + name varchar(255) NOT NULL, + description text, + +-- Trigger que inicia el workflow + trigger_type varchar(32) NOT NULL, -- 'USER_ONBOARDING', 'PROFILE_ASSIGNMENT', 'DELEGATION_CREATION', 'B2B_ACCESS_REQUEST' + +-- Tipo de aprobación + approval_type varchar(32) NOT NULL, -- 'SERIAL' (uno después de otro), 'PARALLEL' (todos simultáneamente), 'QUORUM' (mayoría) + required_approvals integer NOT NULL DEFAULT 1, -- Cuántas aprobaciones se necesitan + +-- Timing + timeout_days integer DEFAULT 7, -- Cuántos días antes de auto-reject + escalate_after_days integer, -- Cuándo escalar a superior si no aprueba + +-- Scope + scope_type varchar(32), -- 'GLOBAL', 'TENANT', 'ORGANIZATION' + applies_to_user_category varchar(32), -- 'INTERNAL', 'EXTERNAL', 'B2B' (NULL = all) + +-- Audit + enabled boolean NOT NULL DEFAULT true, + created_by varchar(255), + created_at timestamptz NOT NULL DEFAULT now(), + modified_by varchar(255), + modified_at timestamptz, + is_deleted boolean NOT NULL DEFAULT false, + + CONSTRAINT pk_approval_workflows PRIMARY KEY (id, root_tenant_id), + CONSTRAINT fk_approval_workflows_tenant FOREIGN KEY (root_tenant_id) REFERENCES identity.tenants(id)); + +CREATE TABLE approval.approval_rules (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + root_tenant_id uuid NOT NULL, + workflow_id uuid NOT NULL, + rule_order integer NOT NULL, -- Orden de evaluación + +-- Condición que gatilla esta regla + condition_json text, -- JSON: { "riskScore": "> 50", "userCategory": "EXTERNAL" } + +-- Quién aprueba si esta regla aplica + approver_role varchar(64), -- 'SECURITY_ADMIN', 'DEPARTMENT_HEAD', 'COMPLIANCE_OFFICER' + approver_count integer DEFAULT 1, + + created_at timestamptz NOT NULL DEFAULT now(), + is_deleted boolean NOT NULL DEFAULT false, + + CONSTRAINT pk_approval_rules PRIMARY KEY (id, root_tenant_id), + CONSTRAINT fk_approval_rules_workflow FOREIGN KEY (workflow_id, root_tenant_id) REFERENCES approval.approval_workflows(id, root_tenant_id)); + +CREATE TABLE approval.approval_requests (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + root_tenant_id uuid NOT NULL, + workflow_id uuid NOT NULL, + +-- Quién solicita + requester_id uuid NOT NULL, + +-- Target de la solicitud + target_user_id uuid, + target_entity_type varchar(32), -- 'USER', 'PROFILE', 'DELEGATION', 'B2B_ACCESS' + target_entity_id uuid, + +-- Descripción + requested_action varchar(255) NOT NULL, + request_reason text, + business_justification text, + +-- Timing + created_at timestamptz NOT NULL DEFAULT now(), + submitted_at timestamptz, + expires_at timestamptz, + completed_at timestamptz, + +-- Estado + status varchar(32) NOT NULL DEFAULT 'DRAFT', -- DRAFT, SUBMITTED, PENDING, APPROVED, REJECTED, ESCALATED + final_decision varchar(32), -- APPROVED, REJECTED + final_decision_reason text, + +-- Metadata + priority varchar(32), -- LOW, MEDIUM, HIGH, CRITICAL + risk_score DECIMAL(5,2), + + CONSTRAINT pk_approval_requests PRIMARY KEY (id, root_tenant_id), + CONSTRAINT fk_approval_requests_workflow FOREIGN KEY (workflow_id, root_tenant_id) REFERENCES approval.approval_workflows(id, root_tenant_id), + CONSTRAINT fk_approval_requests_requester FOREIGN KEY (requester_id, root_tenant_id) REFERENCES identity.users(id, root_tenant_id), + CONSTRAINT fk_approval_requests_target FOREIGN KEY (target_user_id, root_tenant_id) REFERENCES identity.users(id, root_tenant_id)); + +CREATE TABLE approval.approval_approvers (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + root_tenant_id uuid NOT NULL, + approval_request_id uuid NOT NULL, + +-- Quién aprueba + approver_id uuid NOT NULL, + approver_role varchar(64), + +-- Orden de aprobación (para SERIAL workflows) + approval_order integer, + +-- Decisión + status varchar(32) NOT NULL DEFAULT 'PENDING', -- PENDING, APPROVED, REJECTED, ESCALATED + approved_at timestamptz, + decision_reason text, + decision_notes text, + +-- Escalación + escalated_to_id uuid, -- Superior si escalado + escalated_at timestamptz, + + CONSTRAINT pk_approval_approvers PRIMARY KEY (id, root_tenant_id), + CONSTRAINT fk_approval_approvers_request FOREIGN KEY (approval_request_id, root_tenant_id) REFERENCES approval.approval_requests(id, root_tenant_id), + CONSTRAINT fk_approval_approvers_approver FOREIGN KEY (approver_id, root_tenant_id) REFERENCES identity.users(id, root_tenant_id)); + +CREATE TABLE approval.approval_attachments (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + root_tenant_id uuid NOT NULL, + approval_request_id uuid NOT NULL, + + document_name varchar(255) NOT NULL, + document_type varchar(64), -- 'SERVICE_AGREEMENT', 'IDENTITY_PROOF', etc. + storage_uri text NOT NULL, -- URL a archivo en Azure Blob Storage, S3, etc. + file_size_bytes bigint, + uploaded_by uuid, + uploaded_at timestamptz NOT NULL DEFAULT now(), + + CONSTRAINT pk_approval_attachments PRIMARY KEY (id, root_tenant_id), + CONSTRAINT fk_approval_attachments_request FOREIGN KEY (approval_request_id, root_tenant_id) REFERENCES approval.approval_requests(id, root_tenant_id)); + +-- ============================================ +-- DELEGATION CONTEXT TABLES +-- ============================================ + +CREATE TABLE delegation.user_management_delegations (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + root_tenant_id uuid NOT NULL, + +-- Admin roles + delegating_admin_id uuid NOT NULL, -- Quién delega + delegated_admin_id uuid NOT NULL, -- A quién se delega + +-- Scope + scope_type varchar(32) NOT NULL, -- TENANT, ORGANIZATION, DEPARTMENT, SYSTEM, TEAM + scope_id uuid, -- ID de org, dept, etc. + +-- Acciones permitidas + allowed_actions text NOT NULL, -- JSON array: ["CREATE_USER", "ASSIGN_PROFILE", ...] + +-- Temporal validity + valid_from timestamptz NOT NULL, + valid_until timestamptz NOT NULL, + max_duration_days integer, -- Máxima duración permitida (para validación) + +-- Approval + requires_approval boolean NOT NULL DEFAULT false, + approval_request_id uuid, -- Link a approval request si fue requerido + +-- Estado + status varchar(32) NOT NULL DEFAULT 'DRAFT', -- DRAFT, PENDING_APPROVAL, ACTIVE, REVOKED, EXPIRED, REJECTED, COMPLETED, ARCHIVED + revoked_at timestamptz, + revoked_by uuid, + revocation_reason text, + +-- Restricciones adicionales + restricted_to_user_category varchar(32), -- Ej: solo usuarios EXTERNAL + restricted_to_organization_id uuid, -- Ej: solo en esta org + +-- Audit + created_by uuid NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + modified_by varchar(255), + modified_at timestamptz, + + CONSTRAINT pk_user_management_delegations PRIMARY KEY (id, root_tenant_id), + CONSTRAINT fk_delegation_delegating_admin FOREIGN KEY (delegating_admin_id, root_tenant_id) REFERENCES identity.users(id, root_tenant_id), + CONSTRAINT fk_delegation_delegated_admin FOREIGN KEY (delegated_admin_id, root_tenant_id) REFERENCES identity.users(id, root_tenant_id), + CONSTRAINT fk_delegation_approval FOREIGN KEY (approval_request_id, root_tenant_id) REFERENCES approval.approval_requests(id, root_tenant_id)); + +-- ============================================ +-- INDICES para Performance +-- ============================================ + +CREATE INDEX idx_approval_requests_workflow ON approval.approval_requests (workflow_id, root_tenant_id) + WHERE status NOT IN ('APPROVED', 'REJECTED'); + +CREATE INDEX idx_approval_requests_target ON approval.approval_requests (target_user_id, root_tenant_id); + +CREATE INDEX idx_approval_approvers_request ON approval.approval_approvers (approval_request_id, root_tenant_id); + +CREATE INDEX idx_approval_approvers_approver ON approval.approval_approvers (approver_id, root_tenant_id) + WHERE status = 'PENDING'; + +CREATE INDEX idx_delegations_delegated_admin ON delegation.user_management_delegations (delegated_admin_id, root_tenant_id) + WHERE status = 'ACTIVE'; + +CREATE INDEX idx_delegations_scope ON delegation.user_management_delegations (scope_type, scope_id, root_tenant_id) + WHERE status IN ('ACTIVE', 'PENDING_APPROVAL'); +``` + +--- + +### 3.2 Modification to Existing Tables + +```sql +-- Agregar columnas a users table para track delegated admin status +ALTER TABLE identity.users + ADD COLUMN is_delegated_admin boolean NOT NULL DEFAULT false, + ADD COLUMN delegated_admin_scopes text; -- JSON: cached scopes for performance + +-- Agregar columnas a approval_requests para link a MFA/passwordless decisions +ALTER TABLE approval.approval_requests + ADD COLUMN risk_score DECIMAL(5,2), + ADD COLUMN mfa_required boolean, + ADD COLUMN passwordless_allowed boolean; +``` + +--- + +## PARTE 4: Integration Map (EP-06) + +### 4.1 Approvals ↔ Authorization Context + +```mermaid +flowchart TD + subgraph AC[APPROVALS CONTEXT] + AR[approval_requests] + AW[approval_workflows] + AA[approval_approvers] + AT[approval_attachments] + end + AC -->|Requires user permission?| AZ + subgraph AZ[AUTHORIZATION CONTEXT] + PO[policies] + PB[policy_bindings] + PE[permissions] + end +``` + +Flujo: el Approver debe tener permiso `APPROVE_PROFILE_ASSIGNMENT` para aprobar un `approval_request` de asignacion de profile. + +**Queries de integración:** + +```csharp +public interface IApprovalAuthorizationValidator +{ + /// + /// Valida que approver tiene permiso para aprobar esta request. + /// + Task CanApproveAsync(User approver, ApprovalRequest request); +} + +public class ApprovalAuthorizationValidator : IApprovalAuthorizationValidator +{ + public async Task CanApproveAsync(User approver, ApprovalRequest request) + { + // 1. Determinar qué permission se necesita basado en el tipo de request + var requiredPermission = request.TargetEntityType switch + { + "PROFILE" => "APPROVE_PROFILE_ASSIGNMENT", + "USER_ONBOARDING" => "APPROVE_USER_ONBOARDING", + "B2B_ACCESS" => "APPROVE_B2B_ACCESS", + "DELEGATION" => "APPROVE_DELEGATION", + _ => throw new InvalidOperationException() + }; + + // 2. Check si el approver tiene esa permission + var permissions = await _authorizationService + .GetEffectivePermissionsAsync(approver.Id); + + return permissions.Any(p => p.ActionCode == requiredPermission); + } +} +``` + +### 4.2 Approvals ↔ Audit Context + +```mermaid +flowchart TD + AC[APPROVALS CONTEXT
Generates events] + AC -->|APPROVAL_REQUEST_CREATED
APPROVAL_SUBMITTED
APPROVAL_APPROVED
APPROVAL_REJECTED
APPROVAL_ESCALATED| AU + AU[AUDIT CONTEXT
audit_log receives events
stores immutable trail] +``` + +Cada decision de aprobacion se registra en `audit_log` con: approver, timestamp, decision, reason. + +### 4.3 Approvals ↔ Configuration Context + +```mermaid +flowchart TD + CC[CONFIGURATION CONTEXT
approval_workflows configurable
approval_rules configurable
mfa_policies configurable
risk_scoring_weights tunable] + CC -->|Defines approval behavior| AC + AC[APPROVALS CONTEXT
Uses workflows from config
Applies rules from config
Evaluates risk scores per config] +``` + +--- + +## Summary EP-06 Deliverables + +### Completed in This Document + +1. **FS-09 Adaptive MFA** + + * Risk Scoring Model (6 factors, weighted) + * Decision Engine (thresholds) + * Passwordless Methods (FIDO2, Magic Link, App Notification) + * Configuration Model + * Acceptance Criteria (5 scenarios) + +2. **FS-14 Delegated Admin** + + * State Machine (8 states) + * Scope Model (5 scope types, allowed actions) + * Principle of Least Privilege Validation + * Temporal Constraints & Auto-Expiration + * Acceptance Criteria (6 scenarios) + +3. **ER Model (Complete)** + + * approval_workflows + * approval_rules + * approval_requests + * approval_approvers + * approval_attachments + * user_management_delegations + * Indices for performance + +4. **Integration Map** + + * Approvals ↔ Authorization + * Approvals ↔ Audit + * Approvals ↔ Configuration + +--- + +### Próximo: EP-07 Compliance (Documento separado) + +--- + +**Aprobado por:** Arquitecto Principal **Fecha:** 2026-05-14 diff --git a/docs/architecture/ep-07-compliance-detailed-design.es.md b/docs/architecture/ep-07-compliance-detailed-design.es.md new file mode 100644 index 00000000..28ef9ed7 --- /dev/null +++ b/docs/architecture/ep-07-compliance-detailed-design.es.md @@ -0,0 +1,959 @@ +# EP-07: Diseño Detallado — Ciclo de Vida de Cumplimiento + +**Versión:** 1.0 +**Fecha:** 2026-05-14 +**Épica:** EP-07 (Post-MVP) +**Historias:** US-023 a US-028 +**Functional Stories:** FS-11, FS-15 (NEW), FS-16 (NEW) + +--- + +## PARTE 1: FS-11 — Upload & Validate User Document + +### 1.1 Definición + +**FS-11** permite que usuarios y administradores carguen documentos (identidad, certificados, acuerdos) para cumplimiento. + +Workflow: + +1. **Upload**: Usuario carga documento → storage seguro +2. **Validation**: Validador revisa → APPROVED / REJECTED +3. **Lifecycle**: Documento válido hasta fecha de revalidación +4. **Enforcement**: Si vence, acceso puede ser afectado (integración con FS-16) + +### 1.2 Document Type Taxonomy + +```csharp +public enum DocumentType +{ + // Identity Verification + IDENTITY_PROOF, // Passport, DNI, Driver License + ADDRESS_VERIFICATION, // Utility bill, bank statement + CORPORATE_REGISTRATION, // Articles of incorporation + + // Authorization + SERVICE_AGREEMENT, // B2B contract + DATA_PROCESSING_AGREEMENT, // DPA + NON_DISCLOSURE_AGREEMENT, // NDA + + // Compliance + BACKGROUND_CHECK, // Criminal record clearance + INSURANCE_CERTIFICATE, // Liability, D&O + SECURITY_CLEARANCE, // Government clearance + + // Role-specific + CERTIFICATION, // Professional cert (CPA, CISSP) + TRAINING_COMPLETION, // Mandatory training proof + MEDICAL_CLEARANCE, // For certain roles + + // Custom (tenant-specific) + CUSTOM_DOCUMENT // Tenant-defined +} + +public record DocumentTypeConfiguration +{ + public DocumentType Type { get; init; } + public string Name { get; init; } + public string Description { get; init; } + public TimeSpan ValidityPeriod { get; init; } // Cuánto tiempo válido + public bool RequiresValidation { get; init; } // Quién aprueba + public List ValidatorRoles { get; init; } // COMPLIANCE_OFFICER, HR_ADMIN, etc. + public long MaxFileSizeBytes { get; init; } + public List AllowedMimeTypes { get; init; } // PDF, JPG, etc. +} +``` + +### 1.3 Acceptance Criteria (FS-11) + +```gherkin +Feature: Document Upload & Validation + + Scenario: Upload identity document + Given User "alice@corp.com" is EXTERNAL + When uploads document type: IDENTITY_PROOF + And document: passport.pdf (500KB, valid PDF) + Then document stored in secure location + And document status = UPLOADED + And audit logs: DOCUMENT_UPLOADED + And validator notified for review + + Scenario: Validate document - APPROVED + Given Document in UPLOADED status + When Compliance Officer reviews + And approves with: "Document valid, matches user" + Then document status = APPROVED + And valid_until = now + 365 days + And audit logs: DOCUMENT_APPROVED with notes + And user notified: "Document approved" + + Scenario: Validate document - REJECTED + Given Document in UPLOADED status + When Compliance Officer reviews + And rejects with reason: "Document expired" + Then document status = REJECTED + And audit logs: DOCUMENT_REJECTED with reason + And user notified: "Document rejected" + And user can re-upload + + Scenario: Document revalidation needed + Given APPROVED document with valid_until = 2026-12-31 + When today > 2026-12-31 + Then document status = REVALIDATION_REQUIRED + And notified: user + admin + And user can upload new document + + Scenario: Prevent upload of invalid file type + Given User tries to upload: document.exe + When file type not in allowed list + Then upload rejected + And error: "Invalid file type. Allowed: PDF, JPG, PNG" +``` + +--- + +### 1.4 Storage & Security + +```csharp +public class SecureDocumentStorageService : IDocumentStorageService +{ + private readonly ISecureStorageProvider _storage; // Azure Blob, S3, etc. + private readonly IEncryptionService _encryption; + private readonly IDocumentRepository _repository; + + public async Task UploadDocumentAsync( + User uploader, + DocumentUploadRequest request, + Stream fileStream, + CancellationToken cancellationToken) + { + // 1. Validar el archivo + if (!IsValidFileType(request.DocumentType, request.FileName)) + throw new InvalidDocumentException("File type not allowed"); + + if (fileStream.Length > GetMaxFileSize(request.DocumentType)) + throw new DocumentTooLargeException("File exceeds maximum size"); + + // 2. Encriptar documento + var encryptedStream = await _encryption.EncryptAsync(fileStream); + + // 3. Almacenar en secure storage con path pattern: + // /documents/{root_tenant_id}/{user_id}/{document_id}/{filename} + var documentId = Guid.NewGuid(); + var storagePath = $"documents/{uploader.RootTenantId}/{uploader.Id}/{documentId}/{request.FileName}"; + + var storageUri = await _storage.UploadAsync(storagePath, encryptedStream); + + // 4. Crear registro de documento + var document = new UserDocument + { + Id = documentId, + RootTenantId = uploader.RootTenantId, + UserId = uploader.Id, + Type = request.DocumentType, + FileName = request.FileName, + StorageUri = storageUri, + FileSizeBytes = fileStream.Length, + Status = DocumentStatus.UPLOADED, + UploadedBy = uploader.Id, + UploadedAt = DateTime.UtcNow, + FileHash = ComputeHash(fileStream) // Para virus/tamper detection + }; + + await _repository.AddAsync(document); + + // 5. Notificar validadores + var validators = await _userRepository.GetUsersByRoleAsync( + uploader.RootTenantId, + "COMPLIANCE_OFFICER"); + + foreach (var validator in validators) + { + await _notificationService.NotifyAsync( + validator.Id, + $"Document requiring validation: {request.DocumentType}", + $"User {uploader.Name} uploaded {request.DocumentType}"); + } + + // 6. Audit + await _auditService.LogAsync(new AuditEvent + { + EventType = "DOCUMENT_UPLOADED", + UserId = uploader.Id, + ResourceId = documentId.ToString(), + Details = new { DocumentType = request.DocumentType, FileName = request.FileName } + }); + + return new StorageResult { DocumentId = documentId, Status = "UPLOADED" }; + } + + public async Task DownloadDocumentAsync( + User requester, + Guid documentId, + CancellationToken cancellationToken) + { + var document = await _repository.GetAsync(documentId); + + // Validar acceso + if (document.UserId != requester.Id && + !await _authorizationService.HasPermissionAsync(requester, "VIEW_DOCUMENTS")) + throw new UnauthorizedAccessException(); + + // Descargar y desencriptar + var encryptedStream = await _storage.DownloadAsync(document.StorageUri); + var decryptedStream = await _encryption.DecryptAsync(encryptedStream); + + // Audit + await _auditService.LogAsync(new AuditEvent + { + EventType = "DOCUMENT_DOWNLOADED", + UserId = requester.Id, + ResourceId = documentId.ToString() + }); + + return decryptedStream; + } +} +``` + +### 1.5 Validation Workflow + +```sql +CREATE TABLE compliance.documents ( + id uuid PRIMARY KEY, + root_tenant_id uuid NOT NULL, + user_id uuid NOT NULL, + document_type varchar(64) NOT NULL, -- IDENTITY_PROOF, SERVICE_AGREEMENT, etc. + document_name varchar(255) NOT NULL, + storage_uri text NOT NULL, + file_size_bytes bigint, + file_hash varchar(256), -- SHA-256 para integrity + + uploaded_at timestamptz NOT NULL DEFAULT now(), + uploaded_by uuid, + status varchar(32) NOT NULL DEFAULT 'UPLOADED', -- UPLOADED, VALIDATING, APPROVED, REJECTED, REVALIDATION_REQUIRED + valid_until timestamptz, -- Cuándo vence + + CONSTRAINT pk_documents PRIMARY KEY (id, root_tenant_id), + CONSTRAINT fk_documents_user FOREIGN KEY (user_id, root_tenant_id) REFERENCES identity.users(id, root_tenant_id) +); + +CREATE TABLE compliance.document_validators ( + id uuid PRIMARY KEY, + root_tenant_id uuid NOT NULL, + document_id uuid NOT NULL, + validator_id uuid NOT NULL, + + validation_status varchar(32), -- PENDING, APPROVED, REJECTED + validation_date timestamptz, + validation_notes text, + validation_reason text, + + CONSTRAINT pk_document_validators PRIMARY KEY (id, root_tenant_id), + CONSTRAINT fk_document_validators_doc FOREIGN KEY (document_id, root_tenant_id) REFERENCES compliance.documents(id, root_tenant_id), + CONSTRAINT fk_document_validators_user FOREIGN KEY (validator_id, root_tenant_id) REFERENCES identity.users(id, root_tenant_id) +); +``` + +--- + +## PARTE 2: FS-15 — Expiration Notification Rules (NEW) + +### 2.1 Definición + +**FS-15** define cuándo y cómo notificar a usuarios/admins sobre accesos que vencerán. + +**Concepto clave:** Reglas configurables por tenant para alertar ANTES de que el acceso sea revocado. + +### 2.2 Notification Rule Model + +```csharp +public record ExpirationNotificationRule +{ + public Guid Id { get; init; } + public Guid RootTenantId { get; init; } + public string Code { get; init; } // "expiry_30d", "expiry_7d", etc. + public string Name { get; init; } + public string Description { get; init; } + + // Qué tipo de acceso expira + public string ScopeType { get; init; } // 'PROFILE', 'PERMISSION', 'DELEGATION', 'DOCUMENT' + public string? TargetUserCategory { get; init; } // INTERNAL, EXTERNAL, B2B (null = all) + + // Cuándo notificar ANTES de expiración + public int DaysBeforeExpiration { get; init; } // 30, 7, 1 + + // Quién se notifica + public bool NotifyUser { get; init; } + public bool NotifyAdmin { get; init; } + public bool NotifyApprover { get; init; } + + // Cómo notificar + public List Channels { get; init; } // EMAIL, IN_APP, SMS, WEBHOOK + + // Frecuencia de renotificación + public NotificationFrequency Frequency { get; init; } // ONCE, DAILY, WEEKLY + + public bool Enabled { get; init; } + public DateTime CreatedAt { get; init; } +} + +public enum NotificationChannel +{ + EMAIL, + IN_APP, + SMS, + WEBHOOK, + SLACK +} + +public enum NotificationFrequency +{ + ONCE, // Una sola notificación + DAILY, // Cada día hasta expiración + WEEKLY, // Una vez por semana + ON_LOGIN // Cada vez que user intenta login +} +``` + +### 2.3 Notification Engine + +```csharp +public class ExpirationNotificationEngine : BackgroundService +{ + private readonly IExpirationRepository _expirationRepo; + private readonly INotificationService _notificationService; + private readonly IExpirationRuleRepository _ruleRepository; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + // Ejecutar cada hora + await ProcessExpiringAccessAsync(stoppingToken); + await Task.Delay(TimeSpan.FromHours(1), stoppingToken); + } + } + + private async Task ProcessExpiringAccessAsync(CancellationToken cancellationToken) + { + // 1. Obtener todas las reglas habilitadas + var rules = await _ruleRepository.GetEnabledRulesAsync(); + + foreach (var rule in rules) + { + // 2. Encontrar accesos que expiran en rule.DaysBeforeExpiration días + var expiringAccess = await _expirationRepo.GetExpiringAccessAsync( + ruleScope: rule.ScopeType, + daysUntilExpiration: rule.DaysBeforeExpiration, + userCategory: rule.TargetUserCategory); + + foreach (var access in expiringAccess) + { + // 3. Verificar si ya se notificó (para evitar spam) + var lastNotification = await _notificationService.GetLastNotificationAsync( + access.UserId, + rule.Id); + + if (ShouldSendNotification(lastNotification, rule.Frequency)) + { + // 4. Enviar notificación + var notification = new ExpirationNotification + { + UserId = access.UserId, + AccessType = rule.ScopeType, + ExpiresAt = access.ExpiresAt, + DaysRemaining = rule.DaysBeforeExpiration, + RuleId = rule.Id + }; + + await SendNotificationAsync(notification, rule); + + // 5. Registrar en auditoría + await _auditService.LogAsync(new AuditEvent + { + EventType = "EXPIRATION_NOTIFICATION_SENT", + UserId = access.UserId, + Details = new { RuleId = rule.Id, DaysRemaining = rule.DaysBeforeExpiration } + }); + } + } + } + } + + private async Task SendNotificationAsync(ExpirationNotification notification, ExpirationNotificationRule rule) + { + var user = await _userRepository.GetAsync(notification.UserId); + + if (rule.NotifyUser) + { + foreach (var channel in rule.Channels) + { + await SendViaChannelAsync(user, notification, channel); + } + } + + if (rule.NotifyAdmin) + { + var admins = await _userRepository.GetUsersByRoleAsync( + user.RootTenantId, "ADMIN"); + + foreach (var admin in admins) + { + foreach (var channel in rule.Channels) + { + await SendViaChannelAsync(admin, notification, channel); + } + } + } + } + + private async Task SendViaChannelAsync(User recipient, ExpirationNotification notification, NotificationChannel channel) + { + var message = BuildNotificationMessage(notification); + + switch (channel) + { + case NotificationChannel.EMAIL: + await _emailService.SendAsync(recipient.Email, + $"Access Expiring in {notification.DaysRemaining} Days", + message); + break; + + case NotificationChannel.IN_APP: + await _inAppNotificationService.SendAsync(recipient.Id, message); + break; + + case NotificationChannel.SMS: + if (recipient.PhoneNumber != null) + await _smsService.SendAsync(recipient.PhoneNumber, message); + break; + + case NotificationChannel.WEBHOOK: + await _webhookService.NotifyAsync(notification); + break; + + case NotificationChannel.SLACK: + await _slackService.NotifyAsync(recipient.SlackUserId, message); + break; + } + } + + private bool ShouldSendNotification(DateTime? lastNotification, NotificationFrequency frequency) + { + return frequency switch + { + NotificationFrequency.ONCE => lastNotification == null, + NotificationFrequency.DAILY => lastNotification == null || + (DateTime.UtcNow - lastNotification.Value).TotalDays >= 1, + NotificationFrequency.WEEKLY => lastNotification == null || + (DateTime.UtcNow - lastNotification.Value).TotalDays >= 7, + _ => false + }; + } +} +``` + +### 2.4 Acceptance Criteria (FS-15) + +```gherkin +Feature: Expiration Notification Rules + + Scenario: Configure notification rule + Given Admin accesses Configuration > Expiration Notifications + When creates rule: +- Code: "external_30d" +- Scope: PROFILE +- Target: EXTERNAL users +- Days: 30 +- Notify: User + Admin +- Channels: EMAIL, IN_APP +- Frequency: ONCE + Then rule saved and enabled + And audit logs: RULE_CREATED + + Scenario: Auto-notify user before expiry + Given Notification rule for 30 days + And User "alice" has PROFILE expiring in 30 days + When background job executes + Then email sent to alice@corp.com: "Access expires in 30 days" + And in-app notification created + And audit logs: EXPIRATION_NOTIFICATION_SENT + + Scenario: Notify admin daily (repeated notifications) + Given Rule with Frequency: DAILY + And User access expiring in 5 days + When day 1: notification sent to admin + And day 2: re-check rule → frequency=DAILY → send again + And day 3, 4, 5: repeat + Then admin receives 5 notifications (one per day) + + Scenario: Customizable channels per rule + Given Rule with Channels: [EMAIL, SLACK, WEBHOOK] + When access expiring in 10 days + Then notification sent via EMAIL to user + And slack message to #compliance channel + And webhook POST to https://company.com/compliance/expiry +``` + +--- + +## PARTE 3: FS-16 — Access Behavior on Expiration (NEW) + +### 3.1 Definición + +**FS-16** define qué ocurre con el acceso cuando se vence. + +**Modos:** WARNING (aviso), SUSPEND (suspensión temporal), REVOKE (revocación permanente) + +### 3.2 Access Expiration Policy Model + +```csharp +public record AccessExpirationPolicy +{ + public Guid Id { get; init; } + public Guid RootTenantId { get; init; } + public string Code { get; init; } // "contract_expiry", "cert_expiry" + public string Name { get; init; } + + // Qué tipo de acceso controla + public string ScopeType { get; init; } // 'PROFILE', 'PERMISSION', 'DELEGATION' + public string? TargetUserCategory { get; init; } + + // Qué ocurre en expiración + public ExpirationAction OnExpirationAction { get; init; } // WARNING, SUSPEND, REVOKE + + // Grace period: días después de expiración antes de enforcement + public int GracePeriodDays { get; init; } + + // Permisiones especiales + public bool AllowExtension { get; init; } // Puede user solicitar extensión? + public int MaxExtensionDays { get; init; } // Máxima extensión permitida + public bool RequireReapprovalOnExtend { get; init; } // ¿Necesita nueva aprobación? + + // Excepciones + public bool AllowExceptions { get; init; } // Puede admin hacer excepción? + + public bool Enabled { get; init; } +} + +public enum ExpirationAction +{ + WARNING, // Solo notificación, acceso permanece + SUSPEND, // Acceso suspendido temporalmente + REVOKE // Acceso revocado permanentemente +} +``` + +### 3.3 Enforcement Engine + +```csharp +public class AccessExpirationEnforcementEngine : BackgroundService +{ + private readonly IAccessRepository _accessRepository; + private readonly IExpirationPolicyRepository _policyRepository; + private readonly IAuthorizationService _authorizationService; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + // Ejecutar cada 6 horas + await EnforceExpiredAccessAsync(stoppingToken); + await Task.Delay(TimeSpan.FromHours(6), stoppingToken); + } + } + + private async Task EnforceExpiredAccessAsync(CancellationToken stoppingToken) + { + // 1. Obtener todas las políticas habilitadas + var policies = await _policyRepository.GetEnabledPoliciesAsync(); + + foreach (var policy in policies) + { + // 2. Encontrar accesos que expired hace más de grace_period + var expiredAccess = await _accessRepository.GetExpiredAccessAsync( + policyScope: policy.ScopeType, + expiredBeforeDays: policy.GracePeriodDays); + + foreach (var access in expiredAccess) + { + // 3. Aplicar enforcement según policy + await EnforceAccessAsync(access, policy); + } + } + } + + private async Task EnforceAccessAsync(UserAccess access, AccessExpirationPolicy policy) + { + switch (policy.OnExpirationAction) + { + case ExpirationAction.WARNING: + // Solo auditar, no hacer nada + await _auditService.LogAsync(new AuditEvent + { + EventType = "ACCESS_EXPIRED_WARNING", + UserId = access.UserId, + Details = new { AccessType = policy.ScopeType, ExpiresAt = access.ExpiresAt } + }); + break; + + case ExpirationAction.SUSPEND: + // Suspender el acceso + access.Status = AccessStatus.SUSPENDED; + access.SuspendedAt = DateTime.UtcNow; + access.SuspendedReason = $"Expired on {access.ExpiresAt:yyyy-MM-dd}"; + + await _accessRepository.UpdateAsync(access); + + // Remover permisos del usuario + await _authorizationService.RevokePermissionsAsync(access.UserId, access.Id); + + // Notificar + var user = await _userRepository.GetAsync(access.UserId); + await _notificationService.NotifyAsync(user.Id, + "Access Suspended", + $"Your {policy.ScopeType} access has been suspended due to expiration. " + + $"Contact admin to request extension."); + + // Audit + await _auditService.LogAsync(new AuditEvent + { + EventType = "ACCESS_SUSPENDED", + UserId = access.UserId, + ResourceId = access.Id.ToString(), + Details = new { Reason = "Expiration", GracePeriod = policy.GracePeriodDays } + }); + break; + + case ExpirationAction.REVOKE: + // Revocar el acceso permanentemente + access.Status = AccessStatus.REVOKED; + access.RevokedAt = DateTime.UtcNow; + access.RevokedReason = $"Expired on {access.ExpiresAt:yyyy-MM-dd}"; + + await _accessRepository.UpdateAsync(access); + await _authorizationService.RevokePermissionsAsync(access.UserId, access.Id); + + var revokedUser = await _userRepository.GetAsync(access.UserId); + await _notificationService.NotifyAsync(revokedUser.Id, + "Access Revoked", + $"Your {policy.ScopeType} access has been revoked due to expiration. " + + $"Reapply if needed."); + + await _auditService.LogAsync(new AuditEvent + { + EventType = "ACCESS_REVOKED", + UserId = access.UserId, + ResourceId = access.Id.ToString(), + Details = new { Reason = "Expiration" } + }); + break; + } + } +} +``` + +### 3.4 Extension Request Flow + +```csharp +public class AccessExtensionService +{ + public async Task RequestExtensionAsync( + User requester, + Guid accessId, + string justification) + { + var access = await _accessRepository.GetAsync(accessId); + var policy = await _policyRepository.GetByAccessTypeAsync(access.Type); + + // 1. Validar que extension es permitida + if (!policy.AllowExtension) + throw new ExtensionNotAllowedException("Extensions not allowed for this access type"); + + if (DateTime.UtcNow > access.ExpiresAt.AddDays(policy.GracePeriodDays)) + throw new ExtensionTooLateException("Too late to request extension"); + + // 2. Crear extension request + var request = new AccessExtensionRequest + { + Id = Guid.NewGuid(), + AccessId = accessId, + RequestedBy = requester.Id, + RequestedAt = DateTime.UtcNow, + CurrentExpirationDate = access.ExpiresAt, + ProposedNewExpirationDate = access.ExpiresAt.AddDays(30), // Default 30 days + Justification = justification, + Status = "PENDING" + }; + + // 3. Si requiere reaprobación, crear approval request + if (policy.RequireReapprovalOnExtend) + { + var approvalRequest = await _approvalService.CreateApprovalRequestAsync( + workflow: "ACCESS_EXTENSION_APPROVAL", + targetUser: requester.Id, + requestedAction: $"Extend {access.Type}", + linkedEntity: request.Id); + + request.ApprovalRequestId = approvalRequest.Id; + request.Status = "PENDING_APPROVAL"; + } + else + { + request.Status = "APPROVED"; + request.ApprovedAt = DateTime.UtcNow; + access.ExpiresAt = request.ProposedNewExpirationDate; + await _accessRepository.UpdateAsync(access); + } + + await _extensionRepository.AddAsync(request); + + // 4. Audit + await _auditService.LogAsync(new AuditEvent + { + EventType = "EXTENSION_REQUESTED", + UserId = requester.Id, + ResourceId = accessId.ToString() + }); + + return request; + } +} +``` + +### 3.5 Acceptance Criteria (FS-16) + +```gherkin +Feature: Access Behavior on Expiration + + Scenario: WARNING mode (access remains after expiry) + Given Policy with OnExpiration: WARNING, GracePeriod: 0 + When access expires + Then notification sent to user + And access remains ACTIVE (unchanged) + And audit logs: ACCESS_EXPIRED_WARNING + + Scenario: SUSPEND mode (access suspended after grace period) + Given Policy with OnExpiration: SUSPEND, GracePeriod: 7 + And access expired 7 days ago + When enforcement job runs + Then access status = SUSPENDED + And permissions revoked + And user notified: "Access suspended" + And audit logs: ACCESS_SUSPENDED + + Scenario: REVOKE mode (access permanently removed) + Given Policy with OnExpiration: REVOKE, GracePeriod: 3 + And access expired 3 days ago + When enforcement job runs + Then access status = REVOKED + And permissions permanently removed + And user notified: "Access revoked" + And cannot be re-enabled (only via new request) + + Scenario: Request extension (if allowed) + Given User has suspended access due to expiration + And Policy with AllowExtension: true, MaxExtensionDays: 60 + When user requests extension with justification + Then extension request created + And if RequireReapprovalOnExtend=true: approval_request created + And if RequireReapprovalOnExtend=false: automatically approved + Then access ExpiresAt extended + + Scenario: Extension not allowed past grace period + Given GracePeriod: 7 days + And access expired 8 days ago + When user attempts to request extension + Then request rejected: "Too late to request extension" +``` + +--- + +## PARTE 4: Compliance Context Definition + +### 4.1 Bounded Context + +```mermaid +flowchart TB + subgraph CC[COMPLIANCE CONTEXT] + direction TB + subgraph AG[Aggregates] + A1[UserDocument] + A2[ExpirationNotificationRule] + A3[AccessExpirationPolicy] + A4[AccessExtensionRequest] + end + subgraph PO[Ports] + P1[IDocumentStorageService] + P2[IExpirationNotificationEngine] + P3[IAccessExpirationEnforcement] + end + subgraph AD[Adapters] + AD1[DocumentStorageAdapter Azure/S3] + AD2[PostgreSqlComplianceRepository] + AD3[EmailNotificationAdapter] + end + subgraph EV[Events] + E1[DocumentUploadedEvent] + E2[DocumentApprovedEvent] + E3[DocumentRejectedEvent] + E4[ExpirationNotificationSentEvent] + E5[AccessSuspendedEvent] + E6[AccessRevokedEvent] + E7[ExtensionRequestedEvent] + end + end +``` + +### 4.2 Integration Points + +**Compliance → Approvals:** + +* Extension requests que requieren aprobación crean approval_requests + +**Compliance → Audit:** + +* Todos los eventos (upload, validation, enforcement) registrados immutablemente + +**Compliance → Configuration:** + +* Notification rules y expiration policies configurables por tenant + +**Compliance → Authorization:** + +* Cuando acceso es suspendido/revocado, se llama a authorization para remover permisos + +--- + +## PARTE 5: ER Model (EP-07) + +```sql +-- ============================================ +-- COMPLIANCE CONTEXT TABLES +-- ============================================ + +CREATE TABLE compliance.documents ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + root_tenant_id uuid NOT NULL, + user_id uuid NOT NULL, + document_type varchar(64) NOT NULL, + document_name varchar(255) NOT NULL, + storage_uri text NOT NULL, + file_size_bytes bigint, + file_hash varchar(256), + + uploaded_at timestamptz NOT NULL DEFAULT now(), + uploaded_by uuid, + status varchar(32) NOT NULL DEFAULT 'UPLOADED', + valid_until timestamptz, + + CONSTRAINT pk_documents PRIMARY KEY (id, root_tenant_id), + CONSTRAINT fk_documents_user FOREIGN KEY (user_id, root_tenant_id) REFERENCES identity.users(id, root_tenant_id) +); + +CREATE TABLE compliance.document_validators ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + root_tenant_id uuid NOT NULL, + document_id uuid NOT NULL, + validator_id uuid NOT NULL, + + validation_status varchar(32), + validation_date timestamptz, + validation_notes text, + + CONSTRAINT pk_document_validators PRIMARY KEY (id, root_tenant_id), + CONSTRAINT fk_doc_validators_doc FOREIGN KEY (document_id, root_tenant_id) REFERENCES compliance.documents(id, root_tenant_id) +); + +CREATE TABLE configuration.expiration_notification_rules ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + root_tenant_id uuid NOT NULL, + code varchar(64) NOT NULL, + name varchar(255) NOT NULL, + + scope_type varchar(32), + target_user_category varchar(32), + days_before_expiration integer NOT NULL, + + notify_user boolean, + notify_admin boolean, + notify_approver boolean, + notification_channels jsonb, -- JSON: ["EMAIL", "IN_APP"] + notification_frequency varchar(32), -- ONCE, DAILY, WEEKLY + + enabled boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + + CONSTRAINT pk_expiration_notification_rules PRIMARY KEY (id, root_tenant_id) +); + +CREATE TABLE configuration.access_expiration_policies ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + root_tenant_id uuid NOT NULL, + code varchar(64) NOT NULL, + name varchar(255) NOT NULL, + + scope_type varchar(32) NOT NULL, + target_user_category varchar(32), + on_expiration_action varchar(32) NOT NULL, -- WARNING, SUSPEND, REVOKE + grace_period_days integer DEFAULT 0, + + allow_extension boolean, + max_extension_days integer, + require_reapproval_on_extend boolean, + allow_exceptions boolean, + + enabled boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + + CONSTRAINT pk_access_expiration_policies PRIMARY KEY (id, root_tenant_id) +); + +CREATE TABLE compliance.access_extension_requests ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + root_tenant_id uuid NOT NULL, + access_id uuid NOT NULL, + + requested_by uuid NOT NULL, + requested_at timestamptz NOT NULL DEFAULT now(), + current_expiration_date timestamptz NOT NULL, + proposed_new_expiration_date timestamptz NOT NULL, + justification text, + + status varchar(32) NOT NULL DEFAULT 'PENDING', + approval_request_id uuid, + approved_at timestamptz, + approved_by uuid, + rejection_reason text, + + CONSTRAINT pk_access_extension_requests PRIMARY KEY (id, root_tenant_id) +); + +-- Índices +CREATE INDEX idx_documents_user ON compliance.documents (user_id, root_tenant_id) + WHERE status IN ('UPLOADED', 'VALIDATING'); + +CREATE INDEX idx_expiration_rules_scope ON configuration.expiration_notification_rules (scope_type, root_tenant_id) + WHERE enabled = true; + +CREATE INDEX idx_expiration_policies_scope ON configuration.access_expiration_policies (scope_type, root_tenant_id) + WHERE enabled = true; +``` + +--- + +## Summary EP-07 Completado + +* **FS-11**: Document Upload & Validation workflow +* **FS-15** (NEW): Expiration Notification Rules engine +* **FS-16** (NEW): Access Expiration Enforcement (WARNING, SUSPEND, REVOKE) +* **Compliance Context**: Bounded context defined +* **ER Model**: 4 nuevas tablas + configuración +* **Integration**: Compliance integra con Approvals, Audit, Authorization, Configuration + +**Próximo:** EP-08 (IGA Advanced) + +--- + +**Aprobado por:** Arquitecto Principal +**Fecha:** 2026-05-14 diff --git a/docs/architecture/ep-08-iga-detailed-design.es.md b/docs/architecture/ep-08-iga-detailed-design.es.md new file mode 100644 index 00000000..532ed499 --- /dev/null +++ b/docs/architecture/ep-08-iga-detailed-design.es.md @@ -0,0 +1,520 @@ +# EP-08: Diseño Detallado — IGA Avanzada (Identity Governance & Administration) + +**Versión:** 1.0 **Fecha:** 2026-05-14 **Épica:** EP-08 (Post-MVP) +**Historias:** US-031, US-032 (EXPAND a 5-6 historias) +**Historia Funcional:** FS-12 (Promoción de Rol y Madurez) + +--- + +## PARTE 1: IGA Strategic Domain & Role Maturity + +### 1.1 Definición de IGA**Identity Governance & Administration**es la práctica de + +* Mapear identidades a roles y responsabilidades +* Supervisar y evaluar la evolución de roles en el tiempo +* Autorizar cambios de responsabilidad (promociones) +* Auditar decisiones de gobernanza **En UMS:** Esto significa definir un modelo donde roles y permisos evolucionan en el tiempo, y las transiciones son gobernadas, auditadas y auditables. + +### 1.2 Role Maturity Model + +Cada role tiene un nivel de madurez que refleja responsabilidad y seniority: + +```csharp +public enum RoleMaturityLevel +{ + JUNIOR = 1, // Aprendiz (0-6 meses) + INTERMEDIATE = 2, // Contribuidor (6-18 meses) + SENIOR = 3, // Experto (18+ meses) + LEAD = 4, // Líder de equipo + PRINCIPAL = 5 // Arquitecto/Estratega +} + +public record RoleMaturityStatus +{ + public Guid UserId { get; init; } + public Guid RoleId { get; init; } + public RoleMaturityLevel CurrentLevel { get; init; } + public RoleMaturityLevel EligibleNextLevel { get; init; } + + // Timeline + public DateTime AssignedAt { get; init; } + public DateTime CurrentLevelSince { get; init; } // Cuándo alcanzó este nivel + public DateTime? EligibleForPromotionAt { get; init; } // Cuándo es eligible + + // Cumplimiento + public int CompletedCertifications { get; init; } + public int CompletedTrainings { get; init; } + public decimal PerformanceScore { get; init; } // 0.0 a 5.0 + public bool HasNoComplianceIssues { get; init; } + + public string? BlockingFactor { get; init; } // Ej: "Pending CISSP certification" +} +``` + +--- + +## PARTE 2: FS-12 — Role Promotion Process (EXPANDIDO) + +### 2.1 Definición Expandida **FS-12** gestiona el ciclo de vida completo de una promoción + +1. **Eligibility Check** → Verificar que user es eligible +2. **Impact Analysis** → Calcular qué permisos nuevos, riesgos +3. **Approval** → Gerente + Security aprueba +4. **Execution** → Aplicar nueva role +5. **Verification** → Auditar los cambios + +### 2.2 Sub-Historias Expandidas (5-6 historias) + +#### US-031: Request Role Promotion (Requestor) + +**Como:** Usuario Senior con 2 años en rol **Quiero:** Solicitar promoción a Lead **Para que:** Mi compensación y responsabilidades se alineen **Aceptación:** + +* Usuario puede ver cuál es su rol actual y siguiente eligible +* Puede describir motivos + logros +* Request guardado en DRAFT status +* Audit registra: PROMOTION_REQUESTED + +--- + +#### US-032: Review Promotion Impact (Reviewer) + +**Como:** Security Administrator **Quiero:** Ver impacto de una promoción (nuevas permisos, sistemas afectados) +**Para que:** No apruebe cambios que causen riesgos **Aceptación:** + +* Impacto muestra: permisos actuales vs nuevos +* Sistemas afectados listados +* Risk score calculado (0-100) +* Conflicting permissions identificados (si es posible) +* Reviewer puede comentar + +--- + +#### US-033: Approve/Reject Promotion (Manager) + +**Como:** Manager directo **Quiero:** Aprobar o rechazar solicitud de promoción **Para que:** Mi equipo esté alineado **Aceptación:** + +* Manager ve solicitud con impact analysis +* Puede aprobar o rechazar +* Debe escribir motivo +* Si aprueba → workflow a siguiente approver +* Si rechaza → solicitud cerrada + +--- + +#### US-034: Execute Promotion (Admin) + +**Como:** Admin IGA**Quiero:** Ejecutar una promoción aprobada **Para que:** Los permisos nuevos sean aplicados **Aceptación:** + +* Solo disponible si approval chain completa +* Al ejecutar: +* Role en usuario actualizado +* Permisos nuevos asignados +* Permisos viejos removidos (si aplica) +* Maturity level actualizado +* Audit event creado +* Notificación a usuario + +--- + +#### US-035: Monitor Promotion Metrics (Analytics) + +**Como:** HR Analytics **Quiero:** Ver metrics de promociones (tiempo promedio, aprobación rates) +**Para que:** Identifique bottlenecks **Aceptación:** + +* Dashboard mostrando: +* Promociones pendientes (count, edad) +* Approval time (avg, median, P95) +* Rejection rate por approver +* Blocked reasons (reasons por qué se rechaza) +* Impact score distribution + +--- + +#### US-036: Promotion Eligibility Engine (Automated) + +**Como:** IGA System **Quiero:** Auto-calcular cuándo usuario es eligible **Para que:** Notificaciones automáticas **Aceptación:** + +* Cada noche, recalcular eligibility +* Si usuario es nuevamente eligible: +* Notificar user + manager +* Crear "eligible for promotion" badge +* Considerar: +* Tiempo en rol actual +* Certifications completadas +* Training completadas +* Performance score +* Compliance issues + +--- + +### 2.3 Impact Analysis Engine + +```csharp +public class RolePromotionImpactAnalysis +{ + public Guid UserId { get; set; } + public Guid CurrentRoleId { get; set; } + public Guid TargetRoleId { get; set; } + + // Permisos + public List CurrentPermissions { get; set; } + public List TargetPermissions { get; set; } + public List PermissionsAdded { get; set; } + public List PermissionsRemoved { get; set; } + public List ConflictingPermissions { get; set; } + + // Sistemas afectados + public List AffectedSystems { get; set; } + + // Riesgo + public decimal RiskScore { get; set; } // 0-100 + public List RiskFactors { get; set; } + public List MitigationsSuggested { get; set; } + + // Auditoría + public DateTime AnalyzedAt { get; set; } + public string AnalyzedBy { get; set; } +} + +public record SystemImpact +{ + public string SystemName { get; init; } + public int NewPermissionsCount { get; init; } + public string ImpactLevel { get; init; } // LOW, MEDIUM, HIGH, CRITICAL + public string? Details { get; init; } +} + +public class PromotionImpactAnalysisService +{ + public async Task AnalyzeAsync(User user, + Role currentRole, + Role targetRole) + { + // 1. Get permisos actuales + var currentPermissions = await _authorizationService + .GetEffectivePermissionsAsync(user.Id); + + // 2. Get permisos del target role + var targetPermissions = await _authorizationService + .GetPermissionsByRoleAsync(targetRole.Id); + + // 3. Calcular diferencias + var added = targetPermissions + .Except(currentPermissions, new PermissionComparer()) + .ToList(); + + var removed = currentPermissions + .Except(targetPermissions, new PermissionComparer()) + .ToList(); + + // 4. Detectar conflicting permissions (ej: create + delete same resource = risky) + var conflicting = DetectConflictingPermissions(added); + + // 5. Identificar sistemas afectados + var affectedSystems = added + .GroupBy(p => p.System) + .Select(g => new SystemImpact + { + SystemName = g.Key, + NewPermissionsCount = g.Count(), + ImpactLevel = CalculateImpactLevel(g), + Details = string.Join(", ", g.Select(p => p.ActionCode)) + }) + .ToList(); + + // 6. Calcular risk score + var riskScore = CalculateRiskScore(added, removed, targetRole, user); + + return new RolePromotionImpactAnalysis + { + UserId = user.Id, + CurrentRoleId = currentRole.Id, + TargetRoleId = targetRole.Id, + CurrentPermissions = currentPermissions.ToList(), + TargetPermissions = targetPermissions.ToList(), + PermissionsAdded = added, + PermissionsRemoved = removed, + ConflictingPermissions = conflicting, + AffectedSystems = affectedSystems, + RiskScore = riskScore, + RiskFactors = IdentifyRiskFactors(riskScore, added, user), + AnalyzedAt = DateTime.UtcNow, + AnalyzedBy = "PromotionImpactAnalysisEngine" + }; + } + + private decimal CalculateRiskScore(List added, + List removed, + Role targetRole, + User user) + { + decimal score = 0; + + // Factor 1: Permissions sensitivity (0-40) + var sensitivePermissions = added.Count(p => p.RiskLevel == "CRITICAL"); + score += Math.Min(40, sensitivePermissions * 10); + + // Factor 2: Role seniority jump (0-30) + var seniority = targetRole.MaturityLevel - (user.CurrentRole.MaturityLevel ?? 0); + if (seniority > 2) score += 30; // Jumping more than 2 levels = risky + else if (seniority > 1) score += 15; + + // Factor 3: Time in current role (0-20) + var timeInRole = (DateTime.UtcNow - user.RoleAssignedAt).TotalDays; + if (timeInRole < 180) score += 20; // Less than 6 months = risky + else if (timeInRole < 365) score += 10; + + // Factor 4: User compliance history (0-10) + var compliance = await _auditService.GetComplianceScoreAsync(user.Id); + if (compliance < 0.9) score += 10; + + return Math.Min(100, score); + } + + private List DetectConflictingPermissions(List newPermissions) + { + var conflicts = new List(); + + // Anti-patterns + var hasCreate = newPermissions.Any(p => p.ActionCode.Contains("CREATE")); + var hasDelete = newPermissions.Any(p => p.ActionCode.Contains("DELETE")); + var hasApprove = newPermissions.Any(p => p.ActionCode.Contains("APPROVE")); + var hasExecute = newPermissions.Any(p => p.ActionCode.Contains("EXECUTE")); + + if (hasApprove && hasExecute) + conflicts.Add("APPROVAL_EXECUTION_CONFLICT: User can approve and execute same action"); + + if (newPermissions.Count > 15) + conflicts.Add("HIGH_PRIVILEGE_COUNT: More than 15 new permissions"); + + return conflicts; + } +} +``` + +### 2.4 Promotion Workflow + +#### Role Promotion Workflow (State Machine) + +```mermaid +stateDiagram-v2 + [*] --> DRAFT + DRAFT --> PENDING_MANAGER_APPROVAL: User submits request + PENDING_MANAGER_APPROVAL --> REJECTED: Manager rejects + PENDING_MANAGER_APPROVAL --> PENDING_SECURITY_REVIEW: Manager approves + PENDING_SECURITY_REVIEW --> PENDING_SECURITY_APPROVAL: Risky + PENDING_SECURITY_REVIEW --> APPROVED_READY_TO_EXECUTE: Safe + PENDING_SECURITY_APPROVAL --> APPROVED_READY_TO_EXECUTE: Security approves + PENDING_SECURITY_APPROVAL --> REJECTED: Security rejects + APPROVED_READY_TO_EXECUTE --> EXECUTED: Admin executes + EXECUTED --> VERIFIED: System verifies + EXECUTED --> VERIFICATION_FAILED: Verification failed (rollback and notify) + REJECTED --> [*] + VERIFIED --> [*] + VERIFICATION_FAILED --> [*] +``` + +--- + +## PARTE 3: IGA Bounded Context + +```mermaid +flowchart TB + subgraph IGA[IGA BOUNDED CONTEXT] + direction TB + subgraph AG[Aggregates] + A1[RoleMaturityStatus] + A2[PromotionRequest] + A3[PromotionImpactAnalysis] + end + subgraph PO[Ports] + P1[IPromotionApprovalService] + P2[IPromotionImpactAnalyzer] + P3[IEligibilityCalculator] + P4[IPromotionExecutor] + end + subgraph AD[Adapters] + AD1[PostgreSqlIGARepository] + AD2[RolePromotionApprovalAdapter] + AD3[PromotionImpactAnalysisAdapter] + end + subgraph EV[Events] + E1[PromotionRequestedEvent] + E2[PromotionEligibilityCalculatedEvent] + E3[PromotionApprovedEvent] + E4[PromotionRejectedEvent] + E5[PromotionExecutedEvent] + E6[PromotionVerifiedEvent] + end + end +``` + +--- + +## PARTE 4: ER Model (EP-08) + +```sql +-- ============================================ +-- IGA CONTEXT TABLES +-- ============================================ + +CREATE TABLE iga.role_maturity_levels (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + root_tenant_id uuid NOT NULL, + user_id uuid NOT NULL, + role_id uuid NOT NULL, + + current_maturity_level varchar(32) NOT NULL, -- JUNIOR, INTERMEDIATE, SENIOR, LEAD, PRINCIPAL + next_eligible_maturity_level varchar(32), + + assigned_at timestamptz NOT NULL, + current_level_since timestamptz NOT NULL, + eligible_for_promotion_at timestamptz, + +-- Cumplimiento + completed_certifications_count integer DEFAULT 0, + completed_trainings_count integer DEFAULT 0, + performance_score decimal(3,2), -- 0.0 to 5.0 + has_no_compliance_issues boolean DEFAULT true, + + blocking_factor text, + last_reviewed_at timestamptz, + + CONSTRAINT pk_role_maturity_levels PRIMARY KEY (id, root_tenant_id), + CONSTRAINT fk_role_maturity_user FOREIGN KEY (user_id, root_tenant_id) REFERENCES identity.users(id, root_tenant_id)); + +CREATE TABLE iga.promotion_requests (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + root_tenant_id uuid NOT NULL, + user_id uuid NOT NULL, + + current_role_id uuid NOT NULL, + target_role_id uuid NOT NULL, + + requested_at timestamptz NOT NULL DEFAULT now(), + requested_by uuid NOT NULL, + request_reason text, + +-- Approval chain + manager_id uuid NOT NULL, + manager_approval_status varchar(32), -- PENDING, APPROVED, REJECTED + manager_decision_at timestamptz, + manager_decision_reason text, + + security_approval_status varchar(32), + security_decision_at timestamptz, + +-- Overall status + status varchar(32) NOT NULL DEFAULT 'DRAFT', -- DRAFT, PENDING_MANAGER_APPROVAL, PENDING_SECURITY_REVIEW, APPROVED, REJECTED, EXECUTED, VERIFIED, FAILED + final_status varchar(32), -- PROMOTED, REJECTED, ROLLED_BACK + +-- Execution + executed_at timestamptz, + executed_by uuid, + verified_at timestamptz, + + CONSTRAINT pk_promotion_requests PRIMARY KEY (id, root_tenant_id), + CONSTRAINT fk_promotion_requests_user FOREIGN KEY (user_id, root_tenant_id) REFERENCES identity.users(id, root_tenant_id)); + +CREATE TABLE iga.promotion_impact_analysis (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + root_tenant_id uuid NOT NULL, + promotion_request_id uuid NOT NULL, + + risk_score decimal(5,2), + risk_level varchar(32), -- LOW, MEDIUM, HIGH, CRITICAL + new_permissions_count integer, + removed_permissions_count integer, + affected_systems_count integer, + + conflicting_permissions jsonb, -- JSON array + risk_factors jsonb, -- JSON array + suggested_mitigations jsonb, -- JSON array + + analyzed_at timestamptz NOT NULL DEFAULT now(), + analyzed_by varchar(255), + + CONSTRAINT pk_promotion_impact_analysis PRIMARY KEY (id, root_tenant_id), + CONSTRAINT fk_promotion_impact_request FOREIGN KEY (promotion_request_id, root_tenant_id) REFERENCES iga.promotion_requests(id, root_tenant_id)); + +CREATE TABLE iga.promotion_eligible_notifications (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + root_tenant_id uuid NOT NULL, + user_id uuid NOT NULL, + + eligible_for_next_level varchar(32), + eligible_at timestamptz NOT NULL, + notification_sent_at timestamptz, + user_acknowledged_at timestamptz, + + CONSTRAINT pk_promotion_eligible_notifications PRIMARY KEY (id, root_tenant_id)); + +-- Indices +CREATE INDEX idx_role_maturity_user ON iga.role_maturity_levels (user_id, root_tenant_id); +CREATE INDEX idx_promotion_requests_user ON iga.promotion_requests (user_id, root_tenant_id) + WHERE status IN ('PENDING_MANAGER_APPROVAL', 'PENDING_SECURITY_REVIEW'); +CREATE INDEX idx_promotion_requests_manager ON iga.promotion_requests (manager_id, root_tenant_id) + WHERE manager_approval_status = 'PENDING'; +``` + +--- + +## PARTE 5: Integration (IGA con otras contexts) + +### 5.1 IGA ↔ Approvals + +Promotion requests pueden requerir formal approval workflow si target role es sensitive: + +```csharp +// IGA → Approvals: Crear approval request +if (targetRole.RiskLevel == "CRITICAL") +{ + var approvalRequest = await _approvalService.CreateApprovalRequestAsync(workflow: "ROLE_PROMOTION_APPROVAL", + requester: user.Id, + targetUser: user.Id, + requestedAction: $"Promote from {currentRole.Name} to {targetRole.Name}", + linkedEntity: promotionRequest.Id); + + promotionRequest.ApprovalRequestId = approvalRequest.Id; +} +``` + +### 5.2 IGA ↔ Authorization + +Cuando promotion se ejecuta, permisos del usuario se actualizan: + +```csharp +// IGA → Authorization: Update user permissions +await _authorizationService.RevokePermissionsAsync(user.Id, currentRole.Id); +await _authorizationService.AssignPermissionsAsync(user.Id, targetRole.Id); +``` + +### 5.3 IGA ↔ Audit + +Todos los eventos auditados: + +```csharp +await _auditService.LogAsync(new AuditEvent +{ + EventType = "PROMOTION_EXECUTED", + UserId = user.Id, + ResourceId = promotionRequest.Id.ToString(), + Details = new + { + FromRole = currentRole.Name, + ToRole = targetRole.Name, + RiskScore = impactAnalysis.RiskScore + } +}); +``` + +--- + +## Summary EP-08 Completado + +* **FS-12 Expanded**: 6 sub-historias (US-031 a US-036) +* **IGA Bounded Context**: Definido con agregados, puertos, adaptadores, eventos +* **Role Maturity Model**: 5 niveles (JUNIOR a PRINCIPAL) +* **Promotion Impact Analysis**: Risk scoring, permission analysis, affected systems +* **ER Model**: Tables para maturity, requests, analysis +* **Integration**: IGA ↔ Approvals, Authorization, Audit + +--- + +**Aprobado por:** Arquitecto Principal **Fecha:** 2026-05-14 diff --git a/docs/architecture/ep-09-onboarding-flow-detailed-design.es.md b/docs/architecture/ep-09-onboarding-flow-detailed-design.es.md new file mode 100644 index 00000000..041cca60 --- /dev/null +++ b/docs/architecture/ep-09-onboarding-flow-detailed-design.es.md @@ -0,0 +1,176 @@ +# EP-09: Diseno Detallado - Bandeja de Aprobacion de Onboarding + +**Version:** 1.0 +**Fecha:** 2026-06-01 +**Epica:** EP-09 (Preparacion de Lanzamiento) +**Historias Funcionales:** FS-21, FS-22, FS-23, FS-24 +**ADR:** ADR-UMS-075 + +## 1. Objetivo del Diseno + +Esta epica introduce un modelo de onboarding en dos fases: + +* Fase 1 admite al usuario dentro del tenant. +* Fase 2 asigna entitlements operativos mediante un flujo de solicitud de perfil. + +El diseno mantiene simple la experiencia del operador sin perder el aislamiento por tenant ni la separacion entre admision de identidad y autorizacion. + +El diseno tambien exige trazabilidad completa del ciclo de vida de solicitudes de alta de usuario y de perfil por tenant. Los administradores deben cerrar cada requerimiento con un resultado final Aprobado o Denegado, y el solicitante debe ser notificado automaticamente cuando se registre la decision final. + +## 2. Superficie de Producto + +| Superficie | Visible para | Proposito | +| --- | --- | --- | +| Opcion de navegacion Identity | Aprobadores autorizados | Abre la bandeja de aprobacion de onboarding. | +| Pestaña de onboarding de empresa | System Admin | Revisa solicitudes de alta de empresa. | +| Gestion de equipo: pestaña Solicitudes de Ingreso | Tenant Admin | Revisa solicitudes pendientes de alta de usuario del tenant activo. | +| Gestion de equipo: pestaña Solicitudes de Perfiles | Tenant Admin o Gerente de Sucursal delegado | Revisa solicitudes pendientes de perfil y asigna el rol final. | +| Lobby de usuario | Usuarios activos sin perfil | Muestra bienvenida al tenant y formulario de solicitud de perfil. | +| Puntos publicos de registro | Visitantes anonimos | Enviar solicitudes de alta de tenant o de usuario. | + +## 3. Matriz de Ruteo de Aprobacion + +| Tipo de Solicitud | Fuente de Verdad | Estado Inicial | Alcance de Revision | Resultado de Aprobacion | +| --- | --- | --- | --- | --- | +| Solicitud de alta de empresa | Agregado `TenantSignupRequest` | Pending | Global | Crea tenant + primer admin + notificacion de contrasena temporal | +| Solicitud de alta de usuario | Agregado `UserAccount` | Pending | Tenant actual | Aprueba para activar la cuenta o deniega sin acceso al tenant | +| Solicitud de perfil | `ApprovalRequest` o modelo dedicado de solicitud de perfil | PendingAssignment | Tenant actual o sucursal delegada | Aprueba con asignacion de rol final o deniega sin asignacion de perfil | + +## 4. Contrato de Cierre de Ciclo de Vida + +| Tipo de Solicitud | Resultados Terminales Requeridos | Responsable de Cierre | Requisito de Notificacion | +| --- | --- | --- | --- | +| Solicitud de alta de usuario | Aprobado, Denegado | Tenant Admin | Notificar al solicitante cuando el acceso sea aprobado o denegado. | +| Solicitud de perfil | Aprobado, Denegado | Tenant Admin o Gerente de Sucursal delegado | Notificar al solicitante cuando la solicitud de perfil sea aprobada o denegada. | + +Todo registro de ciclo de vida debe conservar tenant, solicitante, estado actual, resultado final, fecha de decision, aprobador y motivo de decision cuando exista. Los registros pendientes permanecen accionables en la bandeja hasta que se registre una decision final. + +## 5. Modelo de Estados + +### 5.1 Solicitud de Alta de Empresa + +| Estado | Significado | Proxima Accion Permitida | +| --- | --- | --- | +| Pending | La solicitud fue enviada y espera revision. | Aprobar o rechazar | +| Approved | El tenant fue creado y se aprovisiono la primera cuenta admin. | Ninguna | +| Rejected | La solicitud se cerro sin crear el tenant. | Ninguna | + +### 5.2 Solicitud de Alta de Usuario + +| Estado | Significado | Proxima Accion Permitida | +| --- | --- | --- | +| Pending | La cuenta existe pero aun no puede iniciar sesion. | Aprobar o denegar | +| ActiveWithoutProfile | El Tenant Admin aprobo la solicitud, pero no existe perfil asignado. | Solicitar perfil | +| Active | Existe al menos un perfil activo. | Ninguna | +| Denied | La solicitud se cerro sin activar acceso al tenant. | Ninguna | + +### 5.3 Solicitud de Perfil + +| Estado | Significado | Proxima Accion Permitida | +| --- | --- | --- | +| PendingAssignment | El usuario solicito sistema, sucursal y rol sugerido. | Aprobar, modificar o denegar | +| Approved | Se otorgo un rol final. El rol otorgado puede coincidir con la solicitud o ser modificado por el aprobador. | Ninguna | +| Denied | No se asigno perfil para el alcance solicitado. | Ninguna | + +## 6. Diagramas de Secuencia + +### 6.1 Alta de Empresa + +```mermaid +sequenceDiagram + participant Visitante as Contacto de la Empresa + participant UI as Pantalla Publica de Login + participant Auth as API de Comandos Auth + participant Bandeja as Bandeja de Onboarding + participant Admin as System Admin + participant Identity as Dominio de Identidad + participant Notify as Servicio de Notificacion + + Visitante->>UI: Abre el formulario de alta de empresa + Visitante->>Auth: Envia los datos de la compania + Auth->>Identity: Crear TenantSignupRequest (Pending) + Auth->>Notify: Enviar notificacion de solicitud recibida + Auth-->>UI: Mostrar confirmacion + Bandeja->>Admin: Mostrar solicitud pendiente de empresa + Admin->>Auth: Aprobar solicitud de tenant + Auth->>Identity: Crear Tenant + primer usuario admin + Auth->>Notify: Enviar notificacion de aprobacion de tenant +``` + +### 6.2 Alta de Usuario + +```mermaid +sequenceDiagram + participant Solicitante as Solicitante + participant UI as Pantalla Publica de Login + participant Auth as API de Comandos Auth + participant Bandeja as Bandeja del Tenant + participant Admin as Tenant Admin + participant Identity as Dominio de Identidad + participant Notify as Servicio de Notificacion + + Solicitante->>UI: Abre el formulario de alta de usuario + Solicitante->>UI: Selecciona el tenant y envia la solicitud + UI->>Auth: Enviar datos de alta de usuario + Auth->>Identity: Crear UserAccount (Pending) + Auth->>Notify: Enviar notificacion de solicitud recibida + Auth-->>UI: Mostrar confirmacion + Bandeja->>Admin: Mostrar solicitud pendiente de usuario + Admin->>Auth: Aprobar o denegar cuenta + Auth->>Identity: Cerrar solicitud como Aprobada o Denegada + Auth->>Notify: Enviar notificacion final de decision de alta de usuario + Solicitante->>UI: Inicia sesion despues de la aprobacion + UI-->>Solicitante: Mostrar lobby cuando no existe perfil activo +``` + +### 6.3 Solicitud de Perfil + +```mermaid +sequenceDiagram + participant Usuario as Usuario en Lobby + participant UI as Pantalla de Lobby + participant Aprobaciones as Bandeja de Solicitudes de Perfil + participant Admin as Aprobador de Tenant o Sucursal + participant Authz as Dominio de Autorizacion + participant Notify as Servicio de Notificacion + + Usuario->>UI: Selecciona sistema, sucursal y rol sugerido + UI->>Aprobaciones: Enviar solicitud de perfil + Aprobaciones-->>Admin: Mostrar solicitud de perfil pendiente + Admin->>Aprobaciones: Aprobar, modificar o denegar + Aprobaciones->>Authz: Asignar Profile final si se aprueba + Aprobaciones->>Notify: Notificar decision final al usuario +``` + +## 7. Ubicacion en UI + +| Ubicacion | Componente | Notas | +| --- | --- | --- | +| Pantalla de login | Botones de entrada | Enlaza los formularios de alta de usuario y alta de empresa. | +| Navegacion del modulo Identity | Bandeja de Aprobacion de Onboarding | Nueva opcion visible para aprobadores. | +| Dashboard de tenants | Panel de solicitudes pendientes | Puede reutilizar el mismo read model por contexto, pero la bandeja sigue siendo la superficie principal de revision. | +| Gestion de equipo | Pestaña Solicitudes de Ingreso | Muestra solicitudes pendientes con alcance del tenant y acciones de aprobar y denegar. | +| Gestion de equipo | Pestaña Solicitudes de Perfiles | Muestra solicitudes de perfil con acciones de aprobar, modificar y denegar. | +| Lobby de usuario | Formulario de solicitud de perfil | Permite solicitar sistema, sucursal y rol sugerido a usuarios sin perfil. | + +## 8. Plan de Implementacion + +| Fase | Trabajo | Dependencia | Resultado | +| --- | --- | --- | --- | +| 1 | Mantener las solicitudes de alta de empresa como `TenantSignupRequest` y exponerlas en la bandeja. | Flujo existente de alta de tenant | Los admins globales pueden revisar solicitudes de empresa en un solo lugar. | +| 2 | Mantener las solicitudes de alta de usuario como `UserAccount` pendientes y mostrar acciones de aprobacion y denegacion en la bandeja del tenant. | Flujos existentes de alta, activacion y denegacion de usuario | Los Tenant Admins pueden cerrar solicitudes de acceso sin fuga entre tenants. | +| 3 | Agregar ruteo a lobby para usuarios autenticados sin perfil activo. | Resultado sin perfil del grafo de autorizacion | Los usuarios pueden entrar al tenant sin ver menus operativos. | +| 4 | Agregar flujo de solicitud de perfil con sistema, sucursal, rol solicitado y justificacion. | Catalogos de perfiles y roles | Los usuarios pueden solicitar entitlements explicitamente. | +| 5 | Agregar acciones de aprobacion: aprobar, modificar y denegar. | Comandos de aprobacion, denegacion y asignacion de perfil | Los aprobadores pueden asignar el rol final o cerrar la solicitud como denegada conservando auditoria. | +| 6 | Agregar verificaciones explicitas de capacidad por alcance. | Grafo de autorizacion y asignaciones de rol | Solo los aprobadores autorizados pueden usar las acciones de la bandeja. | +| 7 | Agregar historial de ciclo de vida y notificaciones de decision final para solicitudes de alta de usuario y de perfil. | Plantillas de notificacion y modelo de auditoria | Toda solicitud permanece trazable hasta quedar Aprobada o Denegada. | +| 8 | Agregar estados futuros de verificacion de pago si el negocio lo requiere. | Decision de producto | El onboarding de empresa puede pausarse por validacion comercial sin redisenar el punto de entrada. | + +## 9. Trazabilidad + +| Tipo | Referencias | +| --- | --- | +| Historias Funcionales | FS-21, FS-22, FS-23, FS-24 | +| ADR | ADR-UMS-075 | +| Entidades de Dominio | `TenantSignupRequest`, `Tenant`, `UserAccount`, `ApprovalRequest`, `Profile`, `Role`, `Branch` | +| Notificaciones | `TenantSignupRequestReceived`, `TenantSignupApproved`, `UserSignupRequestReceived`, `UserSignupApproved`, `UserSignupDenied`, `ProfileRequestApproved`, `ProfileRequestDenied` | diff --git a/docs/architecture/index.es.md b/docs/architecture/index.es.md index 2e4bc654..7c15c5e2 100644 --- a/docs/architecture/index.es.md +++ b/docs/architecture/index.es.md @@ -62,4 +62,31 @@ Referencia aplicada React Web para UMS. Esta seccion mapea evidencia actual de c --- +## Diseño de Solución y Diseños Detallados + +Documentos de diseño incorporados en la resincronización con la plataforma de origen. Están en +español y son la fuente normativa hasta que exista contraparte en inglés. + +### Transversales +- **[Arquitectura de la Solución](./solution-architecture.es.md)**: vista integral de la solución UMS. +- **[Objetivos de Calidad](./quality-objectives.es.md)**: atributos de calidad y sus escenarios. +- **[Modelo de Amenazas](./threat-model.es.md)**: superficie de ataque y contramedidas. +- **[Revisión de Arquitectura — Contexto de Sesión](./session-context-architecture-review.es.md)**. + +### Diseños detallados por épica +- **[EP-06 · Aprobaciones](./ep-06-approvals-detailed-design.es.md)** +- **[EP-07 · Cumplimiento](./ep-07-compliance-detailed-design.es.md)** +- **[EP-08 · IGA](./ep-08-iga-detailed-design.es.md)** +- **[EP-09 · Flujo de Onboarding](./ep-09-onboarding-flow-detailed-design.es.md)** + +### Diseños de funcionalidad +- **[Cambio de Perfil](./profile-switch-design.es.md)** +- **[Selección de Sistema en Autenticación](./system-selection-at-login-design.es.md)** + +### Integración E2E con el tablero SDLC +- **[Análisis de Integración](./e2e-sdlc-dashboard-integration-analysis.es.md)** +- **[Plan de Paralelización](./e2e-dashboard-parallelization-plan.es.md)** + +--- + **[Volver al Índice Maestro](../MASTER_INDEX.es.md)** | **[Volver al README Principal](../README.es.md)** diff --git a/docs/architecture/overview.es.md b/docs/architecture/overview.es.md index cce8c5e5..86cbfca3 100644 --- a/docs/architecture/overview.es.md +++ b/docs/architecture/overview.es.md @@ -146,9 +146,7 @@ Todas las reglas de negocio, invariantes y diagramas arquitectónicos se consoli ├── authorization/ │ ├── system-suite.md - Aplicaciones principales y opciones del sistema │ ├── module.md - Zonas funcionales dinámicas -│ ├── menu.md - Estructura de menús navegables -│ ├── sub-menu.md - Submenús anidados -│ ├── option.md - Interfaces e interfaces de acceso del usuario +│ ├── menu-node.md - Árbol de navegación recursivo (ADR-0090) │ ├── action.md - Tokens granulares de operación (Read/Write/Delete) │ ├── permission-template.md - Grupos de permisos preestablecidos │ ├── permission-template-item.md - Mapeos individuales dentro de un template diff --git a/docs/architecture/parameter-system-redesign.md b/docs/architecture/parameter-system-redesign.md index a788447b..40b4f4b9 100644 --- a/docs/architecture/parameter-system-redesign.md +++ b/docs/architecture/parameter-system-redesign.md @@ -136,7 +136,7 @@ Function GetEffectiveValue(tenantId, parameterCode): | Parameter | Scope | Tenant | Resolution | |-----------|-------|--------|------------| | SESSION_TIMEOUT_MINUTES | GlobalAndTenant | RANSA | RANSA override: 45 | -| SESSION_TIMEOUT_MINUTES | GlobalAndTenant | UNIMAR | No override → Global: 30 | +| SESSION_TIMEOUT_MINUTES | GlobalAndTenant | BEYONDNET | No override → Global: 30 | | ACCESS_TOKEN_DURATION_MS | GlobalOnly | Any | Global: 3600000 | | UI_CUSTOM_BRANDING_ENABLED | GlobalAndTenant | RANSA | RANSA override: true | | UI_CUSTOM_BRANDING_ENABLED | GlobalAndTenant | APM | No override → Global: false | @@ -158,7 +158,7 @@ Function GetEffectiveValue(tenantId, parameterCode): | UI_CUSTOM_BRANDING_ENABLED | Custom Branding Enabled | Boolean | false | GlobalAndTenant | Enable custom tenant branding | | UI_THEME | UI Theme | String | light | GlobalAndTenant | UI theme preference | | MAX_VALIDITY_PERIOD_DAYS | Max Validity Period Days | Number | 365 | GlobalAndTenant | Maximum user account validity period | -| FRONTEND_CONFIG_TRANSPORT | Frontend Config Transport | String | rest | GlobalOnly | Transport mode: graphql or rest | +| ~~FRONTEND_CONFIG_TRANSPORT~~ | *Withdrawn (2026-08-09)* | — | — | — | The web app's REST/GraphQL switch and the `query-transport.service` that read this flag were removed with the resync; data access is REST, and GraphQL is consumed directly where it is wanted | | ENABLE_GRAPHQL_INTROSPECTION | Enable GraphQL Introspection | Boolean | false | GlobalOnly | Allow GraphQL schema introspection | --- @@ -321,7 +321,7 @@ public interface IConfigurationProvider - Master catalog seeded from `ParameterCatalogSeeder` - Global values seeded with defaults - Tenant overrides seeded for demo tenants -- SQLite database in `Ums.Presentation/umsdev.db` +- PostgreSQL database (`UmsDev`); the SQLite dev file `Ums.Presentation/umsdev.db` was withdrawn with SQLite support ### 8.3 Production Mode diff --git a/docs/architecture/profile-switch-design.es.md b/docs/architecture/profile-switch-design.es.md new file mode 100644 index 00000000..6637835c --- /dev/null +++ b/docs/architecture/profile-switch-design.es.md @@ -0,0 +1,252 @@ +# Diseño — Lista de perfiles y cambio de perfil sin re-autenticar + +> **Estado:** Propuesta · **Fase SDLC:** 2 · Diseño · **Fecha:** 2026-08-01 +> **Depende de:** [Evaluación de arquitectura — Contexto de Sesión](./evaluacion-arquitectura-contexto-sesion.md) §11 (Fase 4) +> **Gaps relacionados:** [G-177](../../GAPS.md) (contrato mono-perfil), [G-171](../../GAPS.md), [G-172](../../GAPS.md) + +--- + +## 1. Qué problema resuelve + +Un perfil ata **un usuario, un rol y —por herencia del rol— un sistema**, más opcionalmente una +sucursal. El mismo usuario puede tener varios: PMO en el Tablero SDLC, Operador en TMS, o dos roles +distintos en el mismo sistema. El modelo lo permite a propósito: el índice +`(TenantId, UserId, RoleId, BranchId)` no es único (`ProfileRecordConfiguration.cs:21`). + +Hoy el login **elige uno y descarta el resto sin dejar traza**: `FirstOrDefault` sobre un orden por +`RoleId`, que es un GUID (`AuthorizationGraphBuilderService.cs:121-123`). El usuario no sabe que +tiene otros perfiles, no puede elegir, y el criterio de desempate no es explicable ni estable entre +despliegues. + +Este diseño cubre dos piezas: + +1. **Devolver la lista de perfiles autorizados** en la respuesta de autenticación. +2. **Cambiar de perfil sin volver a autenticarse**, con un endpoint dedicado. + +Ambas asumen la decisión ya tomada: **un contexto por perfil**, no una matriz compilada que funda +los permisos de todos los perfiles. El motivo es la trazabilidad — con un contexto por perfil +siempre se puede responder «puedes aprobar el gate porque entraste como PMO»; fundiendo, un permiso +denegado se convierte en una investigación. + +--- + +## 2. Qué ya existe y se reutiliza + +La mayor parte del trabajo está hecha. Esto es plomería, no un motor nuevo. + +| Pieza | Dónde | Estado | +|---|---|---| +| Cargar todos los perfiles de un usuario | `IProfileRepository.GetByUserIdAsync` (`Repositories.cs:18`) | **Ya se llama en cada login** y se descarta todo menos uno → coste marginal cero | +| Construir el grafo de un perfil concreto | `IAuthorizationGraphBuilder.BuildForProfileAsync` | Existe, lo usa la previsualización de administración | +| Emitir el token de grafo | `IJwtTokenService.GenerateGraphToken` | Existe | +| Cookie de sesión con claims | `AuthEndpoints.HandleLoginAsync` | Existe | +| Auditoría de eventos de autenticación | `IAuthAuditService.RecordAuthEventAsync` | Existe (`Auth.Login.Success`, `Auth.Refresh.*`) | +| Validación de token en endpoint sin `RequireAuthorization` | `HandleSwitchTenantAsync` (`AuthEndpoints.cs:528`) | Existe — es el patrón a imitar | + +Lo único que hay que **añadir** es un bloque en el contrato, un comando con su manejador, un +endpoint y las reglas de selección. + +--- + +## 3. Bloque `profiles` en el contrato + +Se añade al grafo, no al envoltorio de la respuesta de login: el endpoint de sistemas satélite +(`/client/authenticate`) devuelve solo el grafo y también necesita esta información. + +```jsonc +"profiles": [ + { + "id": "…", // opcional, como el resto de ids: solo con metadatos técnicos + "system": { "code": "SDLC", "value": "Tablero de Gobierno SDLC" }, + "role": { "code": "PMO", "value": "Oficina de Gestión (PMO)", "hierarchyLevel": 1 }, + "branch": null, // { code, value } si el perfil es BranchScoped + "scope": "OrgWide", + "isCurrent": true // exactamente uno lleva true + }, + { + "system": { "code": "TMS", "value": "Transporte" }, + "role": { "code": "OPERADOR", "value": "Operador de Transporte", "hierarchyLevel": 3 }, + "branch": { "code": "CALLAO", "value": "Callao" }, + "scope": "BranchScoped", + "isCurrent": false + } +] +``` + +Decisiones de forma: + +* **`system` viene del rol, no del perfil.** El perfil no guarda el sistema; se resuelve por + `Role.SystemSuiteId`. Se proyecta aquí porque el cliente lo necesita para pintar el selector, y + obligarle a cruzarlo sería trasladarle un detalle de nuestro modelo. +* **Solo perfiles activos.** Un perfil inactivo no es una opción que ofrecer. +* **`isCurrent` en vez de `isDefault`.** Lo que el cliente necesita saber es con cuál está operando + ahora; cuál sería el de por defecto es una regla del servidor, no información de la sesión. +* **Bloque aditivo** → bump MINOR de `schemaVersion`, no MAJOR. Un consumidor que lo ignore sigue + funcionando. Ojo: el arnés RoboSoft pinea `schema_version == "1.0.0"` (`configuration.py:419`) y + hay que actualizarlo en el mismo cambio. + +**Coste:** cero consultas nuevas. Los perfiles ya se cargan; hoy se tiran. Resolver el `system` de +cada uno sí exige leer sus roles: **una consulta adicional** por login para el conjunto de roles +referenciados (`WHERE Id IN (…)`), no una por perfil. + +--- + +## 4. Selección de perfil en el login + +Dos cambios, independientes entre sí. + +### 4.1 Desempate explicable + +Cuando el usuario tiene varios perfiles activos y no pide ninguno en concreto, el orden es: + +1. `Role.HierarchyLevel` ascendente — el rol más alto primero. Ya existe y ya se proyecta. +2. `SystemSuite.Code` alfabético. +3. `Role.Code` alfabético. + +Determinista, estable entre despliegues y **explicable a un humano**, que es lo que hoy no es +ordenar por un GUID. + +### 4.2 Filtros opcionales en la petición + +```jsonc +POST /api/v1/auth/login +{ + "tenantCode": "BEYONDNET", + "username": "ana.torres", + "password": "…", + "system": "SDLC", // opcional + "role": "PMO", // opcional + "profileId": null // opcional; si viene, gana sobre los otros dos +} +``` + +Reglas: + +* Sin filtros → se aplica el desempate de §4.1 y `profiles` viaja completo. +* Con `system` y/o `role` → se filtra la lista **y** se elige de entre los filtrados. +* Si los filtros no casan con ningún perfil del usuario, el login **es correcto** y devuelve un + grafo lobby con `profiles` vacío. No se devuelve 401: las credenciales eran válidas, y confundir + «no tienes ese perfil» con «tu contraseña es incorrecta» convierte un problema de asignación en + un incidente de soporte. + +--- + +## 5. Endpoint de cambio de perfil + +```jsonc +POST /api/v1/auth/switch-profile +Authorization: Bearer + +{ "profileId": "…" } +``` + +Respuesta: **la misma forma que el login** (`LoginSuccessResponse`), con el grafo del perfil nuevo, +un token nuevo y la cookie de sesión reescrita. Que la forma sea idéntica no es cosmética: el +cliente reutiliza tal cual el código que ya tiene para inicializar la aplicación tras el login. + +### Reglas de validación, en orden + +| # | Regla | Fallo | +|---|---|---| +| 1 | El token de grafo es válido y no ha expirado | `401` | +| 2 | El perfil existe | `404` | +| 3 | **El perfil pertenece al usuario del token** | `403` | +| 4 | **El perfil pertenece al inquilino de la sesión** | `403` | +| 5 | El perfil está activo | `409` | +| 6 | El usuario sigue activo | `401` | + +Las reglas 3 y 4 son el corazón de la seguridad de este endpoint: el `profileId` lo envía el +cliente, así que **nunca se confía en él**. Se resuelve el perfil, y se comprueba que su `UserId` +coincide con el `sub` del token y que su `TenantId` coincide con el `tenant_id`. Sin la regla 3, +este endpoint sería una escalada de privilegios de una línea. + +No se pide contraseña otra vez: la identidad no cambia, solo el sombrero. Si en el futuro se quiere +elevación de privilegios con re-autenticación (pasar a un rol con `HierarchyLevel` 0, por ejemplo), +es una regla adicional sobre este mismo endpoint, no un diseño distinto. + +### Auditoría + +Evento `Auth.Profile.Switch` con el perfil de origen y el de destino. Es el registro que sostiene la +promesa de trazabilidad de la opción A: sin él, saber con qué sombrero se hizo una operación exige +correlacionar por tiempo. + +--- + +## 6. El token anterior sigue vivo — y por qué se acepta + +La tentación es revocar el token previo al cambiar de perfil. **No se puede con lo que hay, y +tampoco hace falta.** + +`ITokenRevocationStore.RevokeAsync(userId, revokeUntilUtc)` revoca **por usuario y ventana de +tiempo**, no por token: `IsRevokedAsync` devuelve `true` para _cualquier_ token de ese usuario +mientras `now < until` (`InMemoryTokenRevocationStore.cs:29-40`). Usarlo aquí dejaría al usuario +fuera de la aplicación inmediatamente después de cambiarse de perfil, incluido el token recién +emitido. La revocación por token exigiría un registro de `jti`, que hoy no existe. + +Y no hace falta porque **no hay escalada**: el usuario poseía legítimamente ambos perfiles. Mantener +el token viejo vivo hasta que expire equivale a tener dos sesiones abiertas con dos sombreros, que +es exactamente lo que el modelo permite. Lo que sí hay que asumir con honestidad, y documentar en el +contrato, es la consecuencia: **el cambio de perfil no cierra la sesión anterior**. + +Si más adelante se quiere «un solo perfil activo a la vez», la pieza que falta es revocación por +`jti`; queda anotado como trabajo futuro, no como parte de este diseño. + +--- + +## 7. Piezas a implementar + +| Capa | Pieza | Nota | +|---|---|---| +| Application | `SwitchProfileCommand(Guid ProfileId)` + manejador | Reutiliza `BuildForProfileAsync`; aplica las reglas 2-6 | +| Application | `ProfileSummaryDto` y su proyección | Alimenta el bloque `profiles` | +| Application | `AuthGraphPayload` — proyectar `profiles` | Único mapeador del grafo (D-025): tocar solo ahí | +| Domain | `GraphProfileOption` en `GraphContext` | Record nuevo; el grafo no cambia de forma, gana un bloque | +| Presentation | `POST /auth/switch-profile` | Imita `HandleSwitchTenantAsync` en validación de token | +| Presentation | Reescritura de la cookie de sesión | Mismos claims que el login, con el perfil nuevo | +| Contrato | `profiles` en esquema, SDK TS, SDK .NET, fixtures | Bump MINOR | +| E2E | Actualizar el pin `schema_version` de RoboSoft | `configuration.py:419` | + +--- + +## 8. Casos borde + +* **Un solo perfil** → `profiles` con un elemento y `isCurrent: true`. El cliente no debe pintar + selector; que decida él con el dato, en vez de que el servidor se lo oculte. +* **Ningún perfil** → grafo lobby (G-043), `profiles: []`, `onboardingPending: true`. Ya resuelto. +* **Perfil desactivado a mitad de sesión** → el token vigente sigue funcionando hasta expirar. Es la + misma staleness que el resto del diseño acepta a propósito («los cambios se reflejan al + re-autenticar»). El cambio A→B→A no lo esquiva: la regla 5 rechaza volver a un perfil inactivo. +* **Perfiles de dos inquilinos** → no se mezclan nunca. Cambiar de inquilino es `switch-tenant`, que + ya existe y exige ser administrador interno. +* **Dos perfiles con el mismo rol y distinta sucursal** → dos entradas que solo se distinguen por + `branch`. El cliente debe mostrar la sucursal en el selector o serán indistinguibles. + +--- + +## 9. Impacto en el frontend + +El selector de perfil vive junto al de inquilino que ya existe. Al cambiar: llamar al endpoint, +reemplazar el grafo en memoria y **re-renderizar la navegación completa** — no parchear el menú +actual, porque el árbol entero cambia. El token nuevo sustituye al anterior en el store. + +--- + +## 10. Pruebas + +Unitarias del manejador: perfil de otro usuario → 403; perfil de otro inquilino → 403; perfil +inactivo → 409; perfil válido → grafo del perfil nuevo con sus permisos y no los del anterior. +Desempate: tres perfiles con distinto `HierarchyLevel` → gana el más alto, de forma reproducible. +Contrato: `profiles` presente, con exactamente un `isCurrent`, y coherente con el `context.role` +del propio grafo. + +--- + +## 11. Fuera de alcance + +Devolver **varios contextos de sistema a la vez** (Fase 4 del roadmap). Este diseño devuelve la +lista de perfiles y el contexto **de uno**. Servir N contextos multiplica el término dominante del +coste por el número de suites distintas, y esa conversación necesita antes los números de la +instrumentación que se acaba de añadir. + +Tampoco entran aquí: revocación por `jti`, elevación con re-autenticación, ni el bloque de branding +y configuración visual que pide el escenario objetivo ([G-178](../../GAPS.md)). diff --git a/docs/architecture/quality-objectives.es.md b/docs/architecture/quality-objectives.es.md new file mode 100644 index 00000000..e841f061 --- /dev/null +++ b/docs/architecture/quality-objectives.es.md @@ -0,0 +1,108 @@ +# Objetivos de Rendimiento y Confiabilidad — ums + +> **Estado:** Adoptado | **Propietario:** BeyondNet S.A.C. | **Reglas:** S-06, SD-08 +> **Versión:** 1.0.1 · **Fecha:** 2026-07-14 · **Avanza:** [G-002](../../GAPS.md), [G-003](../../GAPS.md) + +Objetivos de nivel de servicio (SLO) y estrategia de confiabilidad del satélite +**ums**. Fijan el _qué se espera_ del sistema en producción; la +**verificación empírica** (pruebas de carga y de fallo) se ejecuta en el sprint de +pruebas ([G-014](../../GAPS.md)). Complementan el [PRD](../01-concepcion/PRD-UMS-001.es.md) (NFR) y la +[arquitectura](./arquitectura-solucion.md). + +## 1. Objetivos de Rendimiento (SLO) + +Presupuestos de latencia para las operaciones críticas del camino de +autenticación y autorización, medidos en el percentil indicado bajo carga +nominal. Son objetivos iniciales, a calibrar con la prueba de carga base. + +| Operación | Objetivo (p95) | Objetivo (p99) | Notas | +| :--- | :--- | :--- | :--- | +| Emisión del Grafo de Autorización (login local) | ≤ 300 ms | ≤ 600 ms | Sin saltos externos (pipeline interno, ADR-UMS-080) | +| Validación de credenciales locales (BCrypt) | ≤ 250 ms | ≤ 500 ms | Coste de hashing acotado por _work factor_ | +| Resolución del método de autenticación | ≤ 20 ms | ≤ 50 ms | Caché en memoria; refresco en el siguiente login | +| Consulta REST de lectura (`GET`, proyección plana) | ≤ 150 ms | ≤ 400 ms | CQRS con `ReadModels` en la capa de aplicación | +| Comando REST de escritura (`POST/PUT/PATCH/DELETE`, con outbox) | ≤ 300 ms | ≤ 700 ms | Incluye persistencia + evento de integración | + +**Throughput y recursos:** el gateway impone límites de complejidad, timeouts y +_rate limiting_ por cliente; se define una cuota por inquilino para evitar que un +tenant degrade a los demás (aislamiento de rendimiento). + +**Contra-objetivo:** ninguna optimización de latencia puede introducir una ruta +que lea u opere datos fuera del inquilino del solicitante. + +### 1.1 Línea base medida (G-002) + +Medición base con `k6` contra el runtime vivo en `kind` (`evolith-ums-cluster`, +namespace `ums`), 3 VUs con think-time, 100 % de éxito. **Hardware de desarrollo +(no producción); los SLO se fijan «en producción».** + +| Operación | Objetivo p95 | Medido p95 | Estado | +| :--- | :--- | :--- | :--- | +| Login local (emisión del grafo de autorización, `POST /auth/login`) | ≤ 300 ms | ~353 ms | ⚠️ ligeramente por encima — dominado por BCrypt (work factor) en HW de dev | +| Consulta REST de lectura (`GET /tenants`, proyección plana) | ≤ 150 ms | ~18 ms | ✅ holgado | + +**Resiliencia bajo carga:** a mayor concurrencia (10 VUs desde un mismo cliente) +el _rate limiting_ por cliente rechaza el exceso de forma rápida y controlada +(no hay degradación en cascada), como fija la estrategia de throughput. + +> Fecha de medición: 2026-07-23. Repetible con `k6 run` del script de carga +> (login + lectura). El desvío de login se re-evalúa sobre hardware de piloto +> real antes del corte de release ([G-074](../../GAPS.md)). + +## 2. Estrategia de Confiabilidad y Disponibilidad + +* **Objetivo de disponibilidad:** ≥ 99.5 % mensual para el servicio de + autenticación (objetivo inicial, a revisar con datos de operación). +* **Consistencia sin 2PC:** los cambios y sus eventos se publican con + _Transactional Outbox_ en la misma transacción; la auditoría y las proyecciones + se actualizan por consistencia eventual confiable. +* **Idempotencia:** middleware de `Idempotency-Key` (ADR-UMS-063) hace seguros los + reintentos de comandos ante fallos transitorios. +* **Degradación controlada:** ante indisponibilidad de un IdP externo, el sistema + aplica _fail-closed_ en autorización (deniega por defecto) y expone un error + accionable con id de diagnóstico, en vez de conceder acceso indebido. +* **Aislamiento de fallos:** circuit breakers y timeouts hacia dependencias + externas (IdP, bus) para evitar el agotamiento de hilos/conexiones; _backpressure_ + en el consumidor de eventos. +* **Recuperación:** el estado autoritativo vive en PostgreSQL; las proyecciones de + lectura son reconstruibles desde los eventos, por lo que una proyección corrupta + no es una pérdida de datos. + +## 3. Cómo se Verifica + +Los objetivos anteriores no se dan por cumplidos hasta medirlos. La verificación +se compone de: + +* **Prueba de carga base** (k6/JMeter) sobre las operaciones de la §1, para + establecer la línea base y detectar regresiones. Los proyectos de carga se + importaron en `src/tests/load` y `src/tests/performance`. +* **Pruebas de fallo** (indisponibilidad de IdP, corte del bus, reintentos) que + ejerciten la degradación y la idempotencia de la §2. +* **Observabilidad** (OpenTelemetry) para medir las latencias reales en operación + y comparar contra los SLO ([G-004](../../GAPS.md)). + +Esta verificación empírica se ejecuta en el sprint de pruebas +([G-014](../../GAPS.md)); hasta entonces, `G-002` y `G-003` permanecen abiertos en su +parte de _verificación_. + +## 4. Trazabilidad + +Los objetivos se apoyan en decisiones ya tomadas: pipeline interno del grafo +(ADR-UMS-080), idempotencia (ADR-UMS-063), API **REST-only** con CQRS en la capa de +aplicación ([D-007](../../DECISIONS.md), que revisa ADR-UMS-055/059, antes GraphQL para +queries), persistencia **PostgreSQL únicamente** ([D-008](../../DECISIONS.md), que +hace cumplir ADR-UMS-089) y bus con outbox (ADR-UMS-051). Su retrazado a ADRs aceptados +de `evolith-core` es deuda ([G-012](../../GAPS.md)). + +## Historial de Cambios + +| Versión | Fecha | Autor | Descripción | +| :--- | :--- | :--- | :--- | +| 1.0.1 | 2026-07-14 | BeyondNet S.A.C. | Alineación a API REST-only + PostgreSQL-only (D-007, D-008): SLO de lectura por REST `GET` en vez de GraphQL | +| 1.0.0 | 2026-07-13 | BeyondNet S.A.C. | Objetivos de rendimiento (SLO) y estrategia de confiabilidad iniciales. Avanza G-002 y G-003 (queda la verificación empírica) | + +--- + +

+ © BeyondNet S.A.C. · RUC 20100412447 · Operador Logístico Aduanero desde 1978 +

diff --git a/docs/architecture/session-context-architecture-review.es.md b/docs/architecture/session-context-architecture-review.es.md new file mode 100644 index 00000000..9b71e9be --- /dev/null +++ b/docs/architecture/session-context-architecture-review.es.md @@ -0,0 +1,605 @@ +# Informe de Evaluación de Arquitectura — UMS: Autenticación, Grafo de Autorización y Contexto de Sistema + +**Repositorio:** `ums` (rama `develop`) · **Fecha:** 2026-08-01 · **Autor:** Software Architect Enterprise +**Alcance:** backend `.NET 10` (`src/apps/ums.api`), SDK (`src/libs/sdk`), frontend (`src/apps/ums.web-app`), infraestructura (`src/infra`) +**Método:** análisis estático con verificación adversarial de todas las afirmaciones de alto impacto. Las afirmaciones refutadas o corregidas durante la verificación **no** aparecen como hechos en este informe. + +--- + +## 1. Resumen ejecutivo + +UMS **ya tiene** un motor de contexto de sesión. No hay que construirlo: `AuthorizationGraph` + `AuthorizationGraphBuilderService` + `AuthGraphPayload` son exactamente el "contexto de sistema" que pide el escenario objetivo, construido en 11 pasos y compartido por `/auth/login` y `/api/v1/client/authenticate` (`AuthorizationGraph.cs:83-112`, `AuthorizationGraphBuilderService.cs:84-237`, `AuthGraphPayload.cs:40-67`). Los patrones enterprise necesarios están presentes y bien usados: CQRS tipado sobre MediatR, Result pattern, agregados DDD, repositorios con UnitOfWork, outbox transaccional, factory de serializadores y dos capas AOP de autorización. **La brecha no es de patrones: es de cardinalidad, de composición del contrato y de coste por login.** + +Cuatro conclusiones, en orden de gravedad: + +1. **El contrato es mono-perfil y mono-sistema por construcción.** El builder elige UN perfil con `FirstOrDefault` sobre un orden por `RoleId` (un GUID) y descarta el resto sin registrarlo — no hay logger inyectado en el servicio (`AuthorizationGraphBuilderService.cs:121-123`, `PostgreSqlProfileRepository.cs:70`). `GraphContext` lleva un `SystemSuite?`, un `Role?` y un `Profile?` escalares (`GraphContext.cs:9-15`). El escenario multi-perfil es alcanzable en producción: el índice `(TenantId, UserId, RoleId, BranchId)` no es único (`ProfileRecordConfiguration.cs:21`) y el flujo IGA crea perfiles adicionales cuando difieren rol o sucursal (`ApproveRequestCommandHandler.cs:187-191`). Los puntos 3 y 4 del escenario objetivo **no tienen hoy ninguna representación en el contrato**. + +2. **El aplanamiento del árbol de navegación pierde datos en silencio.** `MenuNode` es un árbol recursivo de profundidad arbitraria aceptado por ADR-0090, pero `BuildMenuAccess` recorre exactamente tres niveles literales Menu→SubMenu→Option (`AuthorizationGraphBuilderService.cs:307,311,315`). Cualquier opción fuera de ese patrón —creable hoy desde la API (`AddNodeCommand.cs:37-51` no valida forma) y desde el UI de administración (`SuiteNode.tsx:90,358-360`)— desaparece del grafo sin error ni traza. El efecto es _fail-closed_ (el usuario pierde acceso, no lo gana), pero el diagnóstico es caro porque la data semilla nunca lo reproduce (`AuthorizationDevDataSeeder.cs:346-355`) y el read path administrativo **sí** muestra el nodo (`SystemSuiteDto.cs:73`, recursivo). + +3. **El coste por login es alto y una parte es desperdicio puro y demostrable.** El grafo emite 16 sentencias SQL estrictamente secuenciales (verificado sumando las implementaciones: Tenant 1 + Profile 2 + Role 1 + SystemSuite 7 + Templates 2 + FeatureFlags 3); el login completo suma ~24 con el handler. De esas, **2 son 100 % desperdicio**: la carga de todas las plantillas del inquilino con sus ítems cuyo resultado se descarta con `#pragma warning disable S1481` (`AuthorizationGraphBuilderService.cs:148-155`, `PostgreSqlPermissionTemplateRepository.cs:52-63`). Se paga en login, en ambos refresh y en el preview. Ninguna lectura del grafo usa `AsNoTracking` — hay exactamente una ocurrencia en todo `Infrastructure/Persistence` (`RefreshTokenStore.cs:53`). + +4. **El bloqueador operativo más urgente no es el grafo: es que UMS no puede escalar a más de una réplica.** `backend-deployment.yaml:8` fija `replicas: 1` literal, sin `resources`, sin HPA ni PDB. Y aunque se cambiara, se rompería funcionalidad: el código selecciona Redis con `configuration["Redis:Connection"]` (`DependencyInjection.cs:151`) mientras el chart inyecta `REDIS_CONNECTION` (`backend-deployment.yaml:53-54`) — la clave no enlaza, así que en Kubernetes corren `InMemoryTokenRevocationStore` e `InMemoryConfigurationCache`. Sumado a la ausencia total de Data Protection persistido (0 ocurrencias de `PersistKeysTo`/`SetApplicationName`, `AuthenticationExtensions.cs:49-70`), escalar hoy produce cookies indescifrables entre pods y tokens revocados que siguen valiendo. + +**Recomendación global:** no rediseñar. Hay una secuencia de siete cambios acotados —cinco de ellos de riesgo bajo y efecto medible— que resuelven el 80 % del problema antes de tocar el contrato. El contrato solo debe cambiar una vez, y con ADR. + +--- + +## 2. Evaluación de la arquitectura actual + +### 2.1 Lo que está bien hecho (y no hay que tocar) + +| Área | Evidencia | Valoración | +|---|---|---| +| CQRS tipado | `ICommand.cs:6`, `IQueryHandler.cs:6`, MediatR 12.4.1 (`Ums.Application.csproj:19`) | Separación de contrato, no solo de nombres. Correcto. | +| Result pattern | `Result.cs:7-45` | Alta adopción. Único reparo: el error es `string` y el código HTTP se extrae por parsing (`ClientAuthEndpoints.cs:160-164`). Deuda menor, no bloqueante. | +| Agregados DDD | `Profile.cs:10`, `SystemSuite.cs:14` sobre `AggregateRoot` | Invariantes en el dominio, no en handlers. Correcto. | +| Outbox transaccional | `DependencyInjection.cs:250-258`, `UmsPlatformDbContext.cs:234-235` | `UseBusOutbox()` = entrega post-commit. Bien resuelto. | +| Fail-closed en permisos | `AuthorizationGraphBuilderService.cs:389-394` (G-039/ADR-UMS-088) | El default es `NotGranted`, no `Allow`. Decisión correcta y documentada. | +| Deny-wins | `AuthorizationGraphBuilderService.cs:260-267`, tests en `AuthorizationGraphBuilderServiceTests.cs:290-352` | Independiente del orden, verificado por test adversarial. Correcto. | +| Grafo lobby (usuario sin perfil) | `AuthorizationGraphBuilderService.cs:125-130,458-505`, `AuthEndpoints.cs:186-199` | Caso resuelto de forma controlada y null-safe extremo a extremo (G-043, G-122). Bien. | +| Cadena IdP anti-spraying | `IdpChainAuthenticator.cs:163-166,170` | Solo avanza por `InfraUnavailable`, nunca por credencial. Detección de ciclos y tope de saltos. Diseño de seguridad correcto. | +| Índices del grafo | `SystemSuiteNodeRecordConfiguration.cs:33`, `ProfilePermissionRecordConfiguration.cs:18-19`, y resto | Todos los compuestos llevan el padre como columna líder: las cargas por FK están cubiertas. | +| Rehidratación del árbol | `AuthorizationAggregateFactory.cs:225-233` | Agrupa por padre en diccionario, reconstruye en O(N). Sin búsquedas cuadráticas. Bien resuelto. | +| SLOs formales | `docs/02-diseno/objetivos-calidad.md:18-24` | Presupuestos por operación, específicos, no genéricos. Artefacto real. | +| Observabilidad de ruta | `ObservabilityExtensions.cs:57-126`, `dashboards/ums-overview.json` | p95 por `http_route` medible contra SLO. Alertas sobre métricas que existen. | + +### 2.2 Los tres límites estructurales + +**(a) Cardinalidad singular.** El builder resuelve un perfil → un rol → una suite (`AuthorizationGraphBuilderService.cs:121-141`) y el contrato lo congela: `PrincipalContext` del JSON Schema exige `systemSuite`/`role`/`profile` singulares (`auth-graph.schema.json:87-105`). Nota importante para el diseño de la solución: **todo lo aguas abajo del par (suite, profile) ya está parametrizado por ese par** — `BuildPermissionMap` (`:245`), `BuildMenuAccess` (`:295`), `BuildDomainPermissions` (`:376`), `EvaluateFeatureFlagsAsync` (`:421`) y `DeriveScopes` (`:517`) son funciones puras sobre `(suite, permMap)` y serían reutilizables tal cual en un bucle por perfil. El rediseño toca los pasos 2-4 y 10 y el contrato; **no toca el motor de resolución de permisos**. + +**(b) Composición del contexto.** `GraphEffectiveConfig` es un record cerrado de 7 campos, todos de seguridad de sesión (`GraphEffectiveConfig.cs:9-16`). Falta la capa visual (logo, colores, temas, iconos, página inicial) y las integraciones. Dato relevante: el repositorio de suites **ya carga** `AppSettings` (clave/valor/ámbito, `AppSetting.cs:3-14`) en cada lectura (`PostgreSqlSystemSuiteRepository.cs:23`) y el builder **nunca los proyecta** — la materia prima se paga y se descarta. + +**(c) Coste no amortizado.** Cero caché en el camino del grafo. El servicio es Scoped sin decorador (`DependencyInjection.cs:112`). El catálogo de suite —idéntico para todos los usuarios de esa suite, cambia solo por acción administrativa— se relee y rehidrata entero en cada login de cada usuario (7 sentencias SQL con `AsSplitQuery`, `PostgreSqlSystemSuiteRepository.cs:20-26`). + +--- + +## 3. Comparación arquitectura actual vs requerimientos + +### 3.1 Requerimientos funcionales + +| # | Requerimiento del escenario | Estado | Evidencia | +|---|---|---|---| +| 1 | Detección automática del mecanismo (local/IdP) | **Cubierto** | `AuthMethodResolverService.cs:54-133`: cascada `AUTH_USE_EXTERNAL_IDP` (0 SQL, memoria) + reglas FR-042 por prioridad/suite/dominio de email. Dos fallbacks diferenciados. El cliente no elige nada. | +| 2 | Resolver el grafo con filtros opcionales (Sistema, Rol) | **Ausente** | `LoginRequest` = 4 campos (`AuthEndpoints.cs:649-653`). `RequestedScopes` está muerto: única ocurrencia en `src/` es su declaración (`ClientAuthEndpoints.cs:210`). `AuthenticateUserCommand.SystemSuiteId` **sí se lee** (`:94`, `:220`) pero **solo** para resolver IdP — es contexto de autenticación por FR-042, no filtro de autorización; y ningún endpoint lo puebla. | +| 3 | Devolver TODOS los perfiles autorizados | **Ausente** | `FirstOrDefault` y descarte (`AuthorizationGraphBuilderService.cs:121-123`). Matiz verificado: el usuario **sí puede listarlos** vía `GET /profiles?userId=` (`ProfileEndpoints.cs:21-49`), pero **no puede usarlos**: el único camino que honra un `profileId` es `BuildForProfileAsync`, consumido solo por el preview administrativo (`PreviewProfileAuthGraphCommandHandler.cs:79`), que no emite token. Previsualizable, nunca conmutable. | +| 4 | Contexto de sistema completo por sistema autorizado | **Parcial** | Viajan navegación, permisos efectivos de menú y dominio, flags y scopes (`AuthGraphPayload.cs:50-66`). También idioma y zona horaria, **pero solo en el login web** (`SessionParameters`, `AuthEndpoints.cs:219-229,661-671`) y **fuera del contrato versionado**: `/api/v1/client/authenticate` no los entrega. Ausentes: logo, colores, temas, iconos, layout, página inicial, integraciones, parámetros generales. También ausentes en el payload: icono y **ruta** de cada opción de menú (`MenuNodeProps.cs:9-19` no tiene esos campos) — por eso el front real tiene la navegación hardcodeada (`navigation.config.tsx:1-29`) y el único consumo de `menuAccess` es resolución de acceso (`use-access-resolution.ts`), no pintado de menú. | +| 5 | Cacheo en cliente toda la sesión; cambios al re-autenticar | **Cubierto** | Es el comportamiento actual: los tres puntos de entrada reconstruyen (`AuthenticateUserCommandHandler.cs:286`, `RefreshAuthenticationCommandHandler.cs:167`, `RefreshSessionCommandHandler.cs:79`) y `ValidUntil = GeneratedAt + SessionTimeout` (`AuthorizationGraph.cs:71-78`). | +| 6 | Evaluar `include=bloque1,bloque2` | **Costura presente, capacidad ausente** | `AuthGraphPayload` está sobre `Dictionary` **precisamente** para omitir claves según condición (comentario explícito, `:36-38`), y `WithId` ya omite bloques según `GraphSerializationOptions.IncludeTechnicalMetadata` (`:77-84`). Es la costura exacta donde encaja `include=`. Pero hoy `BuildInternalAsync` siempre construye todas las secciones (`:172-177`). | + +### 3.2 Requerimientos no funcionales + +| RNF | Estado | Números verificados | +|---|---|---| +| Múltiples aplicaciones | **Parcial** | El modelo lo soporta (SystemSuite por tenant); el contrato de sesión no (un sistema por grafo). | +| Multi-tenant | **Parcial, con defecto** | RLS activo en 18 tablas (`20260720152552_EnableRowLevelSecurity.cs:13-33`), pero la política castea `"TenantId"::text` (`:41-42`) → inutiliza los índices btree. Además el lookup de login es por email **global** (ver §6.2). | +| Miles de usuarios concurrentes | **No preparado** | `replicas: 1` en duro (`backend-deployment.yaml:8`); Redis nunca se activa; sin Data Protection persistido; idempotencia y rate limiter en proceso. | +| Latencia / throughput | **Medido una vez, no reproducible** | Login p95 ~353 ms vs SLO 300 ms (`objetivos-calidad.md:33-50`, k6 2026-07-23, 3 VUs). No hay artefacto de esa ejecución en el repo; los tres scripts k6 presentes apuntan a otros objetivos (`login-performance.js:17` → `localhost:5293`). El panel de métricas está en `_auto_` (`metrics/index.md:412-421`). Ningún objetivo de throughput verificado. | +| Memoria / ancho de banda | **Medido, malo** | 43.039–49.997 bytes por login (8 capturas reales, `src/provisioning/sdlc/auth-graph/*.json`). 94,8 % es el grafo; **52 % son `domainPermissions` cartesianas**. Sin compresión en ninguna capa (0 ocurrencias de `ResponseCompression` en `src/`, sin `gzip` en `nginx.conf.template`). | +| Escalabilidad horizontal | **Bloqueada** | Ver §6.1. | + +--- + +## 4. Requerimientos ya cubiertos + +Lo que **no** hay que volver a construir: + +1. **Resolución automática del mecanismo de autenticación.** Completa, con motor de reglas FR-042, cascada de configuración en memoria (0 SQL en modo local) y dos fallbacks bien diferenciados: legado `tenant.GetActiveIdentityProvider()` cuando ninguna `IdpConfiguration` gobierna (`AuthMethodResolverService.cs:119`), y caída a Local para `ExternalApi` (G-049) cuando la regla ganó pero el proveedor no está activo (`:95`). + +2. **Cadena de fallback IdP resistente a credential-spraying.** Solo avanza por `InfraUnavailable`; `CredentialTerminal` corta (`IdpChainAuthenticator.cs:163-166`). Cadena agotada → `AUTH_018` → HTTP 503, no 401 (`:176`, `AuthEndpoints.cs:311-312`). Correcto. + +3. **Motor de contexto de sesión.** El `AuthorizationGraph` es el contexto de sistema: principal, autenticación, catálogo de acciones, navegación con permisos efectivos, permisos de dominio, flags, configuración efectiva, scopes y ventana de validez. Compartido por el login web y el de satélites mediante un mapeador único (`AuthGraphPayload`). + +4. **Regeneración al re-autenticar (punto 5 del escenario).** Ya es el comportamiento: nada que hacer. + +5. **Precompilación del mapa de permisos.** `BuildPermissionMap` ya es exactamente un índice materializado: colapsa las `ProfilePermission` activas en `Dictionary<(TargetId,ActionId),(Effect,Source)>` con deny-wins estricto y override-wins entre Allow (`AuthorizationGraphBuilderService.cs:245-282`). La discusión no es si el patrón encaja: ya está. + +6. **Caso "usuario sin perfil".** Grafo lobby con `onboardingPending:true`, null-safe extremo a extremo (G-043/G-122, cerrado 2026-07-22 con login 200 verificado). + +7. **Extensibilidad en persistencia.** `SystemSuiteAppSettings(SuiteId, ConfigKey, ConfigValue, ScopeId)` con UNIQUE por las tres (`SystemSuiteAppSettingRecordConfiguration.cs:17`), igual que `AppConfigurations` y `ParameterTenantValues`: **añadir un tipo de configuración nuevo no requiere migración de esquema**. El requisito de extensibilidad está estructuralmente cubierto en la capa de datos. + +8. **Composición agregado→componentes en el modelo.** `DomainResource.ParentResourceId` existe, `SystemSuite.AddDomainResource` valida que el padre exista para `DomainMethod` (`SystemSuite.cs:420-431`), el builder lo propaga (`:414`) y el front ya sabe pintar el árbol (`ProfileDomainResourcesPanel.tsx:109,126,130`). + +9. **Versionado de contrato con política declarada.** `SCHEMA_VERSIONING.md:32` clasifica "Add new top-level section" como **MINOR**, los SDK validan la ventana `[1.0.0, 2.0.0)` (`schema-version.ts:38-44`, consumida por sdk-client, sdk-express y sdk-authorization) y existe fixture dedicada (`schema-minor-ahead.json`, versión 1.99.0). El mecanismo de extensión existe. + +10. **Costura para `include=`.** `AuthGraphPayload` sobre diccionarios + `GraphSerializationOptions` + parámetro de inquilino `AUTH_GRAPH_INCLUDE_TECHNICAL_METADATA`. Infraestructura lista. + +11. **Maquinaria de ETag.** `ETagHelper` (RFC 7232) ya usado en AppConfiguration y Tenant (`AppConfigurationQueryEndpoints.cs:45-68`). Reutilizable. + +12. **Outbox + catálogo de eventos de invalidación.** `SystemSuiteModuleAdded/Removed/StatusChanged`, `SystemSuiteActionRegistered/Removed`, `PermissionTemplatePublished/Mutated/Deprecated`, `PermissionOverridden`, `ProfileRoleChanged`, `RoleActionGranted/Revoked` (`DomainEvents.cs:44-77`) — exactamente los disparadores que necesitaría una caché de catálogo. Caveat: el transporte RabbitMQ solo se configura si existe `ConnectionStrings:RabbitMq` (`DependencyInjection.cs:211`), que ni el chart ni compose definen → hoy la rama efectiva es `UsingInMemory` (`:267-275`) y los eventos no cruzan proceso. + +--- + +## 5. Brechas identificadas + +### B-1 · Cardinalidad singular del contexto — **Alto** + +`FirstOrDefault` sobre orden por `RoleId` (`AuthorizationGraphBuilderService.cs:121-123`, `PostgreSqlProfileRepository.cs:70`). Sin criterio de negocio: `Role.HierarchyLevel` existe y se proyecta al grafo (`:205`) pero **no participa** en la selección. Sin logger: el descarte no deja traza. El orden es además un detalle de implementación del repositorio — `InMemoryProfileRepository.cs:55-60` no ordena en absoluto. Y hay un caso sin desempate posible: dos perfiles activos con el mismo `RoleId` y distinto `BranchId`. + +### B-2 · Aplanamiento del árbol de navegación con pérdida silenciosa — **Alto** + +Tres bucles literales (`:307,311,315`) frente a un dominio recursivo. El defecto es **mayor que el bucle**: el propio contrato de salida es de tres niveles por tipo (`GraphMenuAccess.cs:11-39`, `auth-graph.schema.json:230-261`) y lo replican los serializadores XML/YAML/CSV y la emisión de claims (`JwtTokenService.cs:159-163`, `AuthEndpoints.cs:232-238`). Corregir solo el builder **no basta**. El resto del código sí es recursivo (`SystemSuiteDto.cs:73`, `MenuNode.Find:191-208`): la regresión está confinada a la proyección del grafo de acceso. + +### B-3 · Composición del contexto incompleta — **Alto** + +Ausentes: branding (logo, colores, temas, iconos), layout, página inicial, integraciones, parámetros generales. Los códigos `UI_*` existentes son tres y ninguno aporta payload: `UI_CUSTOM_BRANDING_ENABLED` es un interruptor booleano, `UI_LANGUAGE_DEFAULT`, `UI_TIMEZONE_DEFAULT` (`AppConfigurationCodes.cs:15,18,19`). El VO `Logo` y el enum `LogoFormat` existen pero ningún agregado los usa (única referencia fuera de su definición: `LogoTests.cs`). La tabla `TenantBrandings` fue eliminada (`20260715020343_DropTenantBrandingTable.cs:15`). + +**Precisión importante:** `TenantParameter` es el metamodelo más rico —tiene `ValueType`, `Category` (con `Ui`=3 y `Localization`=6), `IsSensitive`, `DefaultValue`, `AllowedValues` (`TenantParameterProps.cs:37-48`, `TenantParameterCategory.cs:5-12`)— pero `TenantParameterCodes` no declara **ni un solo** código de UI ni de localización (`:5-25`). Es el candidato natural, está por poblar. + +### B-4 · La plantilla no es fuente de verdad en tiempo de build — **Alto** + +El builder consulta la plantilla publicada y la descarta (`:148-155`, S1481 suprimido, G-016). El docstring de la clase declara "IsOverride = false → use TemplateItem values" (`:30-36`) pero `BuildPermissionMap` lee siempre `pp.Props.IsAllowed/IsDenied`. La plantilla **sí llega** al mapa, pero por copia previa en `Profile.AssignTemplate` (`Profile.cs:95-118`), invocada al crear el perfil (`CreateProfileCommandHandler.cs:129-138`). + +**Corrección relevante frente a la lectura inicial:** una plantilla en uso **no se puede editar** — `AddItem`/`RemoveItem` exigen `Status == Draft` (`PermissionTemplate.cs:161,216,241`) y `AssignTemplate` exige `Published` (`Profile.cs:78`), sin retorno a Draft. La deriva no nace de editar la plantilla. Nace de que **publicar una versión nueva no tiene camino de resincronización**: `AssignTemplate` rechaza re-vincular la misma plantilla (`Profile.cs:85-88`), asignar la nueva **añade** sus permisos sin revocar los de la anterior, y `Deprecate` (`:118-135`) no desactiva los `ProfilePermission` derivados. Revocar exige desactivar permiso a permiso por id (`SetProfilePermissionStatusCommandHandler.cs:59-61`). + +Además: `ApproveRequestCommandHandler.cs:208` crea perfiles por aprobación IGA **sin** `AssignTemplate` — quedan con 0 permisos. + +### B-5 · Herencia de roles no efectiva sobre permisos — **Medio** + +No hay ascenso por `ParentRoleId` en ningún punto: ni en el grafo (`BuildPermissionMap` solo lee `profile.Permissions`) ni en la materialización (`CreateProfileCommandHandler` resuelve por `roleId` exacto). Un test lo fija adversarialmente con `Times.Never` sobre el rol padre ante un ciclo R1→R2→R1 (`AuthorizationGraphBuilderServiceTests.cs:465`). + +**Matiz que cambia la severidad en dos direcciones:** el efecto es _fail-closed_ (matriz incompleta, nunca escalada de privilegios). Pero la jerarquía **no es decorativa**: `HeuristicRiskScoreCalculator.cs:57,71` calcula el RiskScore de una promoción a partir del delta de `HierarchyLevel` y lo documenta literalmente como _proxy de permisos nuevos_, y ese score enruta la aprobación. **IGA puntúa riesgo asumiendo una correlación que el motor de autorización ignora por completo.** Ese es el riesgo real, más agudo que "el negocio podría suponer mal". + +### B-6 · Contrato: el coste de añadir un bloque no es donde se creía — **Medio** + +Verificación adversarial: **ningún SDK valida el payload contra el JSON Schema** — grep de `ajv|jsonschema|check-jsonschema` sobre todo el repo no devuelve nada fuera del `"$schema"` del propio fichero. El SDK .NET deserializa sin `UnmappedMemberHandling.Disallow` (`UmsAuthGraphMiddleware.cs:23-26`) → ignora claves desconocidas; el zod del front no usa `.strict()` y nunca se ejecuta en runtime. Un bloque nuevo **atravesaría los tres SDK sin romper nada**, y la política ya lo clasifica como MINOR. + +El coste real es otro: **cinco espejos cableados del contrato** (record, mapeador, JSON Schema, tipos TS/.NET, fixtures) más un test que exige la lista ordenada exacta de 12 claves (`AuthGraphPayloadTests.cs:155-161` — puerta deseada, no ruptura), y sobre todo un arnés E2E que **pinea `schema_version == "1.0.0"`** (`robosoft/contexts/configuration.py:419`): un bump MINOR legítimo hace FAIL INV-CF14. Además `effectiveConfig` es cerrado y sin hueco de extensión (0 ocurrencias de `extensions`/`x-` en el esquema). + +### B-7 · Sin caché, sin `AsNoTracking`, sin proyección — **Alto** + +Ninguna lectura del grafo usa `AsNoTracking` (única ocurrencia en `Infrastructure/Persistence`: `RefreshTokenStore.cs:53`). El único read model existente no materializa nada: `PermissionTemplateProjectionHandler.cs:35` crea el read model con `Items = []` y **nunca** las puebla; ninguna query lo lee (grep: solo el proyector, el DbContext y el registro de DI). No hay base para servir el contexto sin reconstruirlo. + +### B-8 · Sin paginación en el catálogo — **Alto** + +`GetAllSystemSuitesQueryHandler.cs:38-73` carga **todas** las suites del inquilino con el grafo completo y aplica `Where`/`OrderBy`/`Count`/`Skip`/`Take` sobre `IEnumerable` en memoria. Los repositorios de suites no tienen `Skip`/`Take` (`PostgreSqlSystemSuiteRepository.cs:57-88`). Ninguna tabla del grafo pagina. + +### B-9 · El único stage con usuarios reales no observa — **Medio** + +`values-uat.yaml:29-35` pone `observability.enabled: false` por colisión de NodePort con el despliegue dev en el mismo kind. El entorno con personas reales no emite trazas ni métricas: no puede calibrar SLOs (pendiente delegado en G-074). + +--- + +## 6. Riesgos técnicos + +### 6.1 · Escalar a más de una réplica rompe funcionalidad, no solo rendimiento — **Severidad: alta** + +Cuatro fallos independientes que se manifiestan simultáneamente al poner `replicas > 1`: + +| Fallo | Evidencia | Efecto | +|---|---|---| +| Cookies indescifrables entre pods | 0 ocurrencias de `DataProtection`/`PersistKeysTo`/`SetApplicationName`; `AuthenticationExtensions.cs:49-70` | Cookie emitida por pod A no se descifra en pod B → 401 aleatorios. Todas las sesiones se invalidan en cada rollout. | +| Revocación de token no compartida | `DependencyInjection.cs:151` lee `Redis:Connection`; chart inyecta `REDIS_CONNECTION` (`backend-deployment.yaml:53-54`) | Usuario bloqueado sigue autenticándose contra los pods que no procesaron la revocación. | +| Configuración incoherente | Misma causa → `InMemoryConfigurationCache`; `ReloadTenantAsync` es local (`InMemoryConfigurationCache.cs:104-116`, con `TODO(G-069)` que lo admite) | Sin TTL ni relectura periódica: los demás pods sirven el valor viejo **indefinidamente**. | +| Idempotencia perdida | `IdempotencyMiddleware.cs:23-32`, cuyo propio XML-doc lo admite | Reintento con la misma `Idempotency-Key` en otro pod re-ejecuta el comando. | + +Añadido: el rate limiter es `PartitionedRateLimiter` en proceso (`UmsApiServiceBootstrappers.cs:203-215`) → el límite efectivo se multiplica por N réplicas. Y migración + siembra corren en **cada** réplica al arrancar (`UmsApiServiceBootstrappers.cs:249-281` con ambos flags a `"true"` en `backend-deployment.yaml:49-52`): carrera de migraciones latente. + +**G-069 está marcado "Cerrado 2026-07-20" apoyándose en `RedisConfigurationCache.cs`, sobre una vía que el despliegue nunca ejerce.** Hay que reabrirlo. + +### 6.2 · Colisión de email entre inquilinos — **Severidad: alta** + +`GetByEmailAsync` resuelve `FirstOrDefaultAsync(x => x.Email == ...)` sin acotar por tenant y **sin `ORDER BY`** (`PostgreSqlUserAccountRepository.cs:35-44`). El filtro global de EF no actúa: el login es `AllowAnonymous` (`AuthEndpoints.cs:34,56`), `TenantContextMiddleware` corre después de `UseAuthentication` (`UmsApiServiceBootstrappers.cs:329-332`) y solo lee claims (`TenantContextMiddleware.cs:17-22`) → `OrganizationId` null → el `!HasValue` cortocircuita (`UmsPlatformDbContext.cs:265-269`). El `tenantCode` del body **nunca** alimenta al TenantContext. La unicidad es `(TenantId, Email)` (`UserAccountRecordConfiguration.cs:31`). + +**Tres agravantes verificados:** + +1. **La colisión es creable por el producto.** El alta administrativa usa el mismo `GetByEmailAsync`, que ahí **sí** está filtrado por tenant (admin autenticado) → solo ve su inquilino y deja pasar el duplicado cruzado (`CreateUserAccountCommandHandler.cs:55`). El mismo método actúa global o acotado según haya sesión: esa es la fragilidad de fondo. +2. **El flujo IdP es peor que denegación.** `AuthenticateUserCommandHandler.cs:235-244` hace el mismo lookup global pero **no compara `TenantId` en absoluto**; la cuenta hallada pasa a `BuildResultAsync` y el grafo se construye con el `tenantId` solicitado (`:286`). Eso es **potencial cruce de frontera de inquilino en la emisión del grafo**, no solo un 401. +3. **No es estable.** Sin `ORDER BY`, quién gana depende del plan; puede invertirse tras un `ANALYZE`. + +El remedio ya existe sin usar: `GetByTenantAndEmailAsync` (`PostgreSqlUserAccountRepository.cs:46-67`), hoy invocado solo por el seeder. + +### 6.3 · El JWT embebe la matriz cartesiana de permisos — **Severidad: alta** + +`GenerateGraphToken` añade un claim `perm` por **cada** opción de menú sin filtrar efecto, un `domain_perm` por **cada** par recurso×acción, un `scope` por scope y un `feature` por flag (`JwtTokenService.cs:157-174`). Con los datos capturados: 84 `perm` + 286 `domain_perm` + 370 `scope` (admin) o 29 (directorio) → payload base64url calculado de **~23,3 KB (admin) y ~16,2 KB (directorio) para UN solo sistema**. Se envía en `Authorization: Bearer` en **cada** petición (`auth.store.ts:283`). + +`nginx.conf.template` no fija `large_client_header_buffers` (default `4 8k` → HTTP 400 con línea >8 KB) y Kestrel tiene 32 KB por defecto. *Riesgo de despliegue estimado, no ejecutado; el tamaño de claims sí está calculado sobre datos reales.* + +### 6.4 · Pérdida silenciosa de nodos de navegación — **Severidad: alta** + +Ver B-2. Riesgo agravado: la pérdida es _fail-closed_ pero **invisible** — el admin ve el nodo en el read path recursivo (`SystemSuiteDto.cs:73`) mientras el usuario no lo recibe. `DeriveScopes` deriva scopes solo de `menuAccess` (`:523-528`), así que la opción caída tampoco genera scope. No existe gap registrado que cubra este caso; G-029 está **cerrado** afirmando "grafo de acceso desde module.Nodes", lo que da el trabajo por hecho. + +### 6.5 · RLS con cast a texto anula los índices — **Severidad: alta** + +`USING (current_setting(...) = '' OR "TenantId"::text = current_setting(...))` (`20260720152552_EnableRowLevelSecurity.cs:41-42`). El cast `uuid::text` impide usar `IX_SystemSuites_TenantId`, `IX_Profiles_TenantId`, etc.; el `OR` con expresión no relacionada bloquea el índice aunque se corrigiera el cast. Aplica a 18 tablas con `FORCE ROW LEVEL SECURITY` → **toda** consulta de la ruta de login. Complementario: el interceptor emite un `SELECT set_config(...)` extra en cada apertura de conexión (`OrganizationDbContextInterceptor.cs:75,83`). + +Añadido: los DbSet **hijos** del grafo (`SystemSuiteNodes`, `ProfilePermissions`, `PermissionTemplateItems`, …) no tienen RLS ni `HasQueryFilter`. Mientras se accede por `Include` el JOIN los acota, pero los accesos directos no: `PermissionTemplateItems.CountAsync(...)` (`PostgreSqlPermissionTemplateRepository.cs:154`) y `ProfilePermissions.Where(...)` (`PostgreSqlProfileRepository.cs:133`) recorren filas de todos los inquilinos. + +### 6.6 · Tormenta de invalidación al activar Redis — **Severidad: alta (condicional)** + +El handler de la suscripción llama `ReloadAsync`/`ReloadTenantAsync`, que a su vez llaman `InvalidateAll`/`InvalidateTenant`, **que vuelven a publicar** (`RedisConfigurationCache.cs:50-76,162-179`; `ConfigurationProvider.cs:78-115`). Con suscripción por patrón, cada pod recibe cada mensaje incluido el propio → ciclo autoamplificado proporcional al número de réplicas. Y `ConfigurationProvider.Dispose()` (`:199`) invoca `InvalidateAll()`: **cada apagado de pod en un rolling dispara una recarga total en todos los demás**. Además `InvalidateSuite`/`InvalidateModule` no publican nada (`:168-170`) → esos ámbitos nunca cruzarían pods: coherencia parcial, más difícil de diagnosticar que la incoherencia total actual. + +**Orden crítico: arreglar esto ANTES de corregir la clave de conexión.** El orden inverso convierte un fallo silencioso en una tormenta. + +### 6.7 · Formato del grafo declarado ≠ formato serializado — **Severidad: alta** + +El handler resuelve el formato por defecto del inquilino y lo devuelve en `GraphFormat` (`AuthenticateUserCommandHandler.cs:295,308-315`), pero serializa **siempre** con el `IAuthorizationGraphSerializer` inyectado, registrado explícitamente como JSON (`DependencyInjection.cs:143-145`). El endpoint solo re-serializa si el llamante pide un formato **distinto** al declarado (`ClientAuthEndpoints.cs:86-112`). Si el inquilino tiene `AUTH_GRAPH_DEFAULT_FORMAT=XML` y el cliente no envía `?format` ni `Accept`: respuesta con `Format=XML`, cabecera `X-Graph-Format: XML`, **cuerpo JSON**. + +### 6.8 · Divergencia documentación↔código en `AuthAccessScope` — **Severidad: media** + +`AuthAccessScope.PortalManagement` está documentado como "UMS management portal login (/api/v1/auth/login). Uses local BCrypt. IDP is NOT required or consulted" (`AuthAccessScope.cs:14-19`), pero `HandleLoginAsync` construye el comando con `AccessScope: AuthAccessScope.ExternalApi` (`AuthEndpoints.cs:157`). El atajo del resolver (`:54-57`) queda sin llamador en producción. Quien razone sobre superficie de ataque leyendo el dominio concluirá lo contrario de lo que ocurre. Es exactamente el tipo de mentira de estado que SD-05 prohíbe. + +### 6.9 · Refresco de perfiles multi-plantilla: `OverrideNeutral` no revoca — **Severidad: media** + +Una fila con efecto `NotGranted` se descarta con `continue` (`:256`) antes de la resolución de precedencia. En un perfil con dos plantillas, un "neutral" **no revoca** un Allow de la otra. Corolario del mismo diseño: el override **muta la fila en sitio** (`ProfilePermission.cs:50-75`), destruyendo el valor original de plantilla, así que la rama override-wins solo cambia la etiqueta `Source` del DTO, nunca el `Effect` — el único desempate con consecuencia funcional es deny-wins. + +--- + +## 7. Análisis de rendimiento y escalabilidad + +### 7.1 Coste real por login (verificado, ruta local, camino feliz) + +| Paso | Llamada | SQL | Nota | +|---|---|---|---| +| 1 | `_tenantRepo.GetByCodeAsync` (`handler:69`) | 1 | 2 Includes sin `AsSplitQuery` → cartesiano Branches×IdPs | +| 2 | `_methodResolver.ResolveAsync` (`:91`) | 0 | Todo en memoria (`ConfigurationProvider.cs:125-131`) | +| 3 | `_userRepo.GetByEmailAsync` (`:138`) | 3 | `AsSplitQuery` + MfaEnrollments + PasswordCredentials | +| 4 | `_configProvider.ForTenant` (`:160`) | 0 | Extensión en memoria | +| 5 | `_localStrategy.Authenticate` (`:175`) | 0 | BCrypt: mayor consumidor de CPU por login | +| 6 | `_userRepo.UpdateAsync` (`:198`) | 1 | **Relectura** del usuario ya cargado en [3] | +| 7 | `SaveEntitiesAsync` (`:199`) | ~0-1 | **No escribe outbox**: acumula en memoria para despacho in-process (`UmsPlatformDbContext.cs:64-68,88-89`). En login limpio no emite UPDATE (`UserAccount.cs:446-454`) | +| 8a | `_tenantRepo.GetByIdAsync` (`builder:94`) | 1 | **Redundante**: el tenant ya está cargado en [1] | +| 8b | `_profileRepo.GetByUserIdAsync` (`:121`) | 2 | Trae **todos** los perfiles con permisos; se descarta todo menos uno | +| 8c | `_roleRepo.GetByIdAsync` (`:134`) | 1 | | +| 8d | `_suiteRepo.GetByIdAsync` (`:139`) | **7** | Consulta dominante: raíz + Modules + Nodes + Node.Actions + AppSettings + Actions + DomainResources | +| 8e | `_templateRepo.GetByTenantIdAsync` (`:148`) | 2 | **100 % desperdicio**: resultado descartado (S1481, G-016) | +| 8f | `_featureFlagRepo.GetBySystemSuiteIdAsync` (`:428`) | 3 | Incluye `EvaluationLogs`, que el evaluador jamás consulta | +| 9 | `_formatProvider.GetDefaultFormatAsync` (`:295`) | 1 | Independiente del grafo | +| 10 | `_auditService.RecordAuthEventAsync` (`:298`) | 1 tx | `SaveChangesAsync` propio (`AuthAuditService.cs:47-51`) | +| 11 | `refreshTokenStore.IssueAsync` (`AuthEndpoints.cs:249`) | 1 tx | **Solo si** el inquilino lo activó — el default es `false` (`AppConfigurationDefaults.cs:22`) | + +**Totales verificados:** ~24 sentencias SQL en el login completo (16 solo el grafo), más un `SELECT set_config(...)` por cada apertura de conexión. **Transacciones de escritura en el camino feliz por defecto: 1** (el INSERT de auditoría), no 3 — el UPDATE de usuario no se emite con contador limpio y el refresh token está desactivado por defecto. + +**Cadena irreductiblemente secuencial: 4 saltos, no 5.** `GetByEmailAsync` toma solo un Email derivado de `command.Username` (`:138`) y **no depende** del resultado de [1]; la comprobación de pertenencia es posterior (`:141`). El tenant es una rama paralela de longitud 1. La cadena forzosa es user→profile→role→suite. + +**Caveat sobre paralelización:** el `DbContext` es scoped y no es thread-safe. Paralelizar exige `IServiceScopeFactory` con scopes separados — no es un `Task.WhenAll` gratis. + +**Lo que NO está verificado:** "satura el pool de conexiones y las escrituras serializan". No hay `MaxPoolSize` configurado (grep sin resultados; `DependencyInjection.cs:312-320` solo fija reintentos), no hay transacción que abarque el request, y las escrituras son un INSERT append-only y un UPDATE por fila distinta. El impacto demostrable es **latencia por número de round-trips**, lineal. La degradación no lineal del pool es hipótesis a medir, no hallazgo. + +En modo IdP hay coste adicional: el resolver recarga el tenant (`AuthMethodResolverService.cs:76`) y lee `IdpConfiguration` (`:112`), y acto seguido `IdpChainAuthenticator` **vuelve a leer** la misma colección y a ejecutar el mismo selector con los mismos argumentos (`:97-98`). 2 SQL y una evaluación de reglas duplicadas por login federado. + +### 7.2 Coste algorítmico (CPU) + +Para un sistema medio (6 módulos, ~35 opciones, 13 acciones, 22 recursos, P≈375 permisos): permMap ~1.100 ops, BuildActions ~61, BuildMenuAccess ~600-900, BuildDomainPermissions ~1.900, DeriveScopes ~3.200. **Total ≈7.000-8.000 operaciones elementales y ~2.000 objetos por login: orden de 0,3-0,8 ms de CPU pura.** El algoritmo **no es el cuello de botella a este tamaño.** + +Tres ineficiencias reales pero secundarias, que sí escalan mal: + +* `suite.Actions.FirstOrDefault(a => a.Props.Code == actionCode)` dentro del bucle quíntuple (`:325`). El `actionLookup` construido 160 líneas antes (`:164`) está indexado por Id, no por Code. Con 2.000 opciones y 200 acciones son 400.000 comparaciones de string por login. Corrección: un segundo diccionario, tres líneas. +* `actionLookup.OrderBy(kv => kv.Value.Code)` **dentro** del foreach de recursos (`:387`): O(R · A log A) en vez de O(A log A). +* Rehidratación por reflexión sin memoización: `GetConstructor`/`GetField`/`GetProperty` resueltos en **cada** entidad (`AuthorizationAggregateFactory.cs:355-379`). ~500 entidades por login del sistema medio; con 2.000 nodos por suite, ~8.000 búsquedas de metadatos. + +### 7.3 Payload (medido sobre 8 capturas reales) + +`src/provisioning/sdlc/auth-graph/*.json`, minificado: **43.039–49.997 bytes**. Desglose de `admin_sdlc.json` (49.997 B): + +| Bloque | Bytes | % | +|---|---:|---:| +| `authorizationGraph` | 47.396 | 94,8 % | +| → `domainPermissions` | 26.138 | **52,3 %** | +| → `menuAccess` | 11.823 | 23,6 % | +| → `scopes` | 7.588 | 15,2 % | +| → resto (actions, context, flags, config) | 1.847 | 3,7 % | +| `permissions` (duplica los Allow de menuAccess) | 1.708 | 3,4 % | +| `sessionParameters` | 280 | 0,6 % | + +**El payload no escala con lo que el usuario puede hacer, sino con el tamaño del catálogo.** El perfil más restringido (`directorio.json`) recibe 43.039 B con solo 22 Allow de 286 filas de dominio y 7 de 84 de menú: **se envían 264 filas `NotGranted`**. Causa directa: `BuildDomainPermissions` emite el producto cartesiano completo (`:383-406`). + +**Compresión: ausente en todas las capas.** Grep sobre `src/`: 0 ocurrencias de `ResponseCompression`/`UseResponseCompression`/`Brotli`/`Gzip`. El nginx del frontend —único ingreso, hace `proxy_pass` de `/api/`— no activa `gzip` (`nginx.conf.template:1-40`). Ahorro perdido medido: **admin_sdlc 49.997 → 5.308 B (9,4x); directorio 43.039 → 3.702 B (11,6x)**. + +Escala: un usuario con 4 sistemas descargaría ~190 KB por login; 1.000 logins/minuto ≈ 3,2 MB/s (~25 Mbps) solo de payload de autenticación. + +### 7.4 Trabajo desperdiciado, cuantificado + +| Desperdicio | Coste | Evidencia | +|---|---|---| +| Serialización del grafo que se descarta | 1 serialización JSON completa (~47 KB de salida) por login | `AuthenticateUserCommandHandler.cs:294-315` produce `SerializedGraph`; el endpoint de login nunca lo consume, construye con `AuthGraphPayload.Build(graph)` (`AuthEndpoints.cs:282`) | +| Plantillas cargadas y descartadas | 2 SQL + rehidratación de todos los ítems del inquilino | `:148-155` | +| `Include(EvaluationLogs)` | Parte de 3 SQL, colección sin cota ni poda | `PostgreSqlFeatureFlagRepository.cs:74` — el login usa el evaluador sin estado (`:442`), no `FeatureFlag.Evaluate`; la tabla crece por el endpoint admin (`EvaluateFeatureFlagCommandHandler.cs:52`), no por tráfico de login | +| Relectura de Tenant | 1 SQL | `builder:94` vs `handler:69` | +| Relectura de UserAccount | 1 SQL | `PostgreSqlUserAccountRepository.cs:198-204`. **El propio repo demuestra la alternativa**: `PostgreSqlSystemSuiteRepository.cs:101-116` busca en `ChangeTracker` para evitar exactamente eso | +| Change tracking en lecturas puras | Snapshot de cada entidad del grafo | 0 `AsNoTracking` en el camino | + +### 7.5 Línea base y observabilidad + +Existe una medición real: k6 contra kind, 3 VUs, 100 % éxito, 2026-07-23 → **login p95 ~353 ms** (sobre el SLO de 300 ms, atribuido a BCrypt en HW de dev) y GET /tenants p95 ~18 ms (`objetivos-calidad.md:33-50`, cierre de G-002). Pero **no es reproducible desde el repositorio**: no hay salida cruda ni JSON de resumen, y ninguno de los tres scripts presentes es el descrito (`login-performance.js:17` apunta a `localhost:5293`; `smoke.js` solo golpea health; `stress.js` golpea GET /tenants con DevAuth). Choca con SD-05. + +Observabilidad: OTel con trazas (ASP.NET, HttpClient, EF Core, AOP) y métricas exportadas por OTLP; el dashboard grafica p95 por `http_route`, así que **la latencia de POST /auth/login sí es medible contra su SLO**. Lo que no existe: instrumentos propios. Los meters `UMS.Application` y `UMS.Infrastructure` se registran con el comentario "reserved for future instrumentation" y grep confirma **0 ocurrencias de `new Meter(`** (`ObservabilityExtensions.cs:115-117`). No se mide el desglose del login (BCrypt vs consultas vs construcción vs serialización) ni el tamaño del payload — exactamente lo que hace falta para decidir dónde optimizar. + +### 7.6 Estimación de caché de catálogo (no verificada, orden de magnitud) + +El grafo proyectado de una suite media ocupa 47 KB de JSON; el árbol equivalente como objetos gestionados (~150-200 entidades) rondaría 100-200 KB por suite. **100 suites ≈ 10-20 MB por réplica; 1.000 suites ≈ 100-200 MB.** Frente a eso, hoy cada login paga 7 consultas SQL + rehidratación + snapshot de tracking del mismo árbol. El orden de magnitud sugiere que una caché de catálogo por proceso es barata; **requiere medición antes de afirmarlo.** + +--- + +## 8. Recomendaciones priorizadas + +### 8.1 ALTO impacto + +--- + +#### R-1 · Corregir el lookup de usuario en el login: usar `GetByTenantAndEmailAsync` + +* **Qué:** sustituir `GetByEmailAsync` por `GetByTenantAndEmailAsync(tenantId, email, ct)` en las dos ramas del handler (local `:138` e IdP `:235`), y **añadir en la rama IdP la comprobación de `TenantId` que hoy falta**. +* **Beneficio:** elimina el fallo de colisión cross-tenant (§6.2) y el potencial cruce de frontera en la emisión del grafo por IdP. Mejora además el plan de consulta (usa el índice único `(TenantId, Email)`). +* **Coste:** 2 líneas + 1 guarda. El método ya existe (`PostgreSqlUserAccountRepository.cs:46-67`). +* **Precaución:** antes de aplicar, consultar la base de producción por emails duplicados entre inquilinos. Si los hay, el fix cambia el comportamiento observado para esos usuarios (a mejor, pero hay que saberlo). +* **Medición:** test de integración con dos cuentas del mismo email en tenants distintos, ambas autenticando correctamente. Hoy ese test no existe (`TenantIsolationTests.cs` solo cubre lectura cruzada). + +--- + +#### R-2 · Desbloquear el escalado horizontal (tres cambios, en este orden) + +1. **Redis:** que `DependencyInjection.cs:151` lea la misma clave que el chart inyecta, **o** que el chart inyecte `Redis__Connection`. Añadir un log/health-check de arranque que afirme qué implementación quedó activa, y un test que falle si con Redis configurado se registra el store en memoria. **Reabrir G-069.** +2. **Data Protection:** registrar el anillo de claves persistido en Redis con `SetApplicationName` fijo. +3. **Idempotencia:** migrar `IdempotencyMiddleware` a `IDistributedCache` reutilizando el `InstanceName = "ums:"` existente, conservando el TTL de 24 h. + +* **PRE-REQUISITO OBLIGATORIO:** antes de (1), arreglar la tormenta de invalidación (§6.6): separar `InvalidateLocal*` (sin publicar, para el handler del suscriptor) de `Invalidate*` (con publicación, para la ruta de comando); incluir id de origen en el payload y descartar mensajes propios; **quitar la publicación de `Dispose()`**; y hacer que `InvalidateSuite`/`InvalidateModule` publiquen. +* **Beneficio:** habilita `replicas > 1` sin romper sesiones, revocación ni idempotencia. +* **Coste:** bajo por cambio; el orden es lo crítico. +* **Medición:** desplegar con 2 réplicas en UAT; login en pod A, petición autenticada servida por pod B → 200. Revocar token en A → 401 en B. +* **Solo después:** parametrizar `replicas` (`backend-deployment.yaml:8`), declarar `resources` (hoy QoS BestEffort, primer candidato a desalojo), añadir plantillas HPA y PDB — el bloque `autoscaling` de `values.yaml:158-163` no lo consume ninguna plantilla, y `values/backend.yaml` es huérfano. Y **antes de escalar**, mover migración/siembra fuera del arranque de cada réplica (`UmsApiServiceBootstrappers.cs:249-281`). + +--- + +#### R-3 · Activar compresión de respuesta + +* **Qué:** `AddResponseCompression` + `UseResponseCompression` (Brotli/Gzip) en la API, **o** `gzip on; gzip_proxied any;` en el nginx del frontend. +* **Beneficio medido:** 9,4x–11,6x sobre capturas reales (49.997 → 5.308 B; 43.039 → 3.702 B). Elimina ~90 % del ancho de banda del login. +* **Coste:** una llamada de configuración. CPU marginal para JSON de este tamaño. **No toca el contrato.** +* **Medición:** `Content-Length` de la respuesta de login antes/después. +* **Es la mejor relación beneficio/complejidad de todo el informe.** + +--- + +#### R-4 · Sacar la matriz de permisos del JWT + +* **Qué:** emitir un JWT de identidad y sesión (`sub`, `tenant`, `suite`, `rol`, `jti`, `exp`) y dejar que el grafo viaje solo en el cuerpo, donde el cliente ya lo cachea. Si hay que conservar algo en el token, únicamente los `scope` con efecto **Allow** (29–370 hoy), nunca las filas `NotGranted`. +* **Beneficio:** hoy el token estimado es 16–24 KB **para un solo sistema** y viaja en cada petición; con varios sistemas es inviable. Es la **única** optimización que actúa sobre todas las peticiones y no sobre una por sesión. +* **Coste:** medio. Toca `JwtTokenService.cs:157-174` y los consumidores que lean esos claims (el aspecto de autorización del servidor y el SDK). +* **Medición previa de 5 minutos:** medir el tamaño real del token emitido en UAT. Después: tamaño de token p95. + +--- + +#### R-5 · Eliminar el trabajo desperdiciado del builder (cuatro cortes) + +| Corte | Acción | Riesgo | +|---|---|---| +| Plantillas | Borrar `:148-155`. Si G-016 debe quedar documentado, dejarlo como comentario **sin ejecutar I/O**. Cuando se cablee, usar `GetByTenantRoleSuiteAsync` (`:65-74`), que ya existe | Nulo: el resultado no se usa | +| EvaluationLogs | Añadir sobrecarga del repositorio de flags sin `Include(EvaluationLogs)` para la vía del grafo | Nulo: el evaluador no los toca (`FeatureFlagEvaluator.cs:8-28`) | +| Tenant redundante | Pasar el `Tenant` ya cargado al builder. **Nota:** ninguna sobrecarga de `IAuthorizationGraphBuilder` acepta hoy un `Tenant` (`:21-35`); hay que añadir el parámetro (el patrón ya existe para `UserAccount`) | Bajo | +| Relectura de usuario | Resolver por `ChangeTracker` como ya hace `PostgreSqlSystemSuiteRepository.cs:101-116` | Bajo | + +* **Beneficio:** −5 sentencias SQL por login (de ~24 a ~19) y menos presión de GC; el corte de plantillas además elimina una carga cuyo volumen crece con el tamaño del inquilino, no del usuario. Se paga también en cada refresh y en el preview. +* **Coste:** bajo, todo interno. +* **Medición:** repetir la medición k6 de login p95 (hoy ~353 ms). Es la forma de saber si el desvío sobre el SLO es solo BCrypt, como afirma el documento. + +--- + +#### R-6 · Omitir las filas `NotGranted` del cable + +* **Qué:** emitir solo entradas con `Allow` o `Deny` explícito, y declarar en el contrato que la ausencia es denegación (semántica que el propio builder ya documenta, `:389-392`). +* **Beneficio medido:** ~52 % del grafo para perfiles restringidos. Combinado con R-3, el login de un perfil normal baja de ~43 KB a ~2 KB en el cable. +* **Coste:** cambio de contrato → ADR + bump de `schemaVersion`. **No** rediseño. +* **Precaución:** requiere actualizar el arnés RoboSoft, que hoy pinea `schema_version == "1.0.0"` (`configuration.py:419`) y haría FAIL con un bump legítimo. +* **Medición:** bytes del payload por perfil restringido, antes/después. + +--- + +#### R-7 · Índice O(1) en el SDK de autorización + +* **Qué:** al hacer `set(graph)` en el accessor, materializar un `Dictionary` para opciones de menú, otro para `(resourceCode, actionCode)` y un `HashSet` case-insensitive para scopes. `AuthorizationValidator` consulta el índice. +* **Beneficio:** hoy `RequireMenuOption` ejecuta **cuatro foreach anidados** en **cada** comprobación de permiso (`AuthorizationValidator.cs:58-83`), sobre un grafo que es inmutable toda la sesión (`memory.ts:11-24`). Es coste por petición, no por login. +* **Coste:** ~30 líneas por lenguaje. Cero cambios de contrato. **Aplicar en paralelo en .NET y TypeScript** para no romper la paridad del SDK. +* **Medición:** benchmark de `RequireMenuOption` con un grafo de 500 opciones. +* **Es el mayor ratio beneficio/coste del informe después de R-3.** + +--- + +#### R-8 · Corregir el aplanamiento del árbol de navegación + +* **Qué:** sustituir los tres records de nivel por un `GraphNavigationNode(Code, Value, Kind, SortOrder, ActionCode?, Effect?, Source?, Children)` y proyectar recursivamente desde `MenuNode`. +* **Alcance real:** builder + `GraphMenuAccess.cs:11-39` + `AuthGraphPayload` + JSON Schema + serializadores XML/YAML/CSV + emisión de claims + validadores del SDK (.NET y TS). **Exige bump MAJOR a `schemaVersion` 2.0.0.** +* **Antes de decidir:** ejecutar una consulta sobre los datos sembrados y de UAT contando cuántos nodos se pierden hoy. **Si son cero en producción, planificar junto al bump de esquema en vez de urgirlo.** +* **Red de seguridad inmediata (coste horas):** añadir un test que siembre una Option raíz y una Option hija directa de un Menu y verifique que aparecen en `MenuAccess`. Hoy fallará — y esa es la prueba de la brecha. +* **Adicionalmente, ahora:** validar la forma en `AddNodeCommand` (`:37-51`) para que la API deje de aceptar topologías que el grafo no puede representar. Eso convierte un fallo silencioso en un error explícito, que es lo que exige SD-06. + +--- + +### 8.2 MEDIO impacto + +#### R-9 · Determinismo y visibilidad de la selección de perfil (aditivo, sin romper contrato) + +Tres cambios que se apilan sin tocar la cardinalidad del contrato: + +1. Hacer explícito el desempate: `HierarchyLevel` del rol (ya existe y ya se proyecta) + `Code` de la suite como desempate estable, en vez del GUID. Resultado determinista y **explicable**. +2. Inyectar `ILogger` en el builder y **registrar cuántos perfiles se descartaron**. Hoy la pérdida es invisible. +3. Devolver la **lista** de perfiles autorizados como bloque aditivo: el builder ya la tiene cargada (`:121`) y hoy la tira → **coste marginal cero SQL**. + +* **Coste:** bajo. (3) requiere bump MINOR de esquema. +* **Medición:** un usuario con 3 perfiles ve los 3 en la respuesta y el elegido es reproducible entre despliegues. + +--- + +#### R-10 · Reparar el `set_config` y la política RLS + +* **Qué:** migración que reescriba la política comparando en tipo nativo: `"TenantId" = NULLIF(current_setting('app.current_organization_id', true),'')::uuid`, separando el caso "sin restricción" en política aparte para que el planificador pueda usar `IX_*_TenantId`. +* **Beneficio:** hoy toda consulta de la ruta de login hace seq scan sobre 18 tablas con `FORCE ROW LEVEL SECURITY`; la latencia degrada con el tamaño **total** de la base, no con el del inquilino. +* **Coste:** una migración. **Riesgo de seguridad si se equivoca** → revisión obligatoria. +* **Medición:** `EXPLAIN ANALYZE` antes/después sobre datos representativos. + +--- + +#### R-11 · Caché del catálogo de suite (medir primero) + +* **Qué:** caché de solo lectura del catálogo rehidratado con clave `(suiteId, versión)` sobre el `IDistributedCache` ya cableado, invalidada por los eventos `SystemSuite*` que el outbox ya publica. +* **Por qué esto y no el grafo por usuario:** el catálogo es **idéntico para todos los usuarios de la suite** y solo cambia por acción administrativa; es lo más caro de leer (7 SQL + reflexión). Cachear el grafo completo por usuario es la opción tentadora y la equivocada: cada entrada pesa |recursos|×|acciones|, la invalidación no se puede calcular barata, y **el propio negocio (punto 5) dice que los cambios se reflejan al re-autenticar**, lo que la hace innecesaria. +* **Pre-requisitos:** R-2 completo (sin Redis efectivo la caché sería incoherente entre pods) y **medición previa** con instrumentación de `BuildInternalAsync` que confirme que la suite es el término dominante. +* **Coste:** medio. **No abordar sin número.** + +--- + +#### R-12 · Correcciones algorítmicas locales + +| Cambio | Ubicación | Coste | +|---|---|---| +| `Dictionary` por código junto al `actionLookup` existente | `:164`, elimina el `FirstOrDefault` de `:325` | 3 líneas | +| Sacar el `OrderBy` fuera del bucle de recursos | `:387` | 2 líneas | +| Memoizar `ConstructorInfo`/`FieldInfo`/`PropertyInfo` en `ConcurrentDictionary` estáticos, o compilar delegados | `AuthorizationAggregateFactory.cs:355-379` | contenido en la factory, sin tocar dominio | +| `AsNoTracking()` en las lecturas del builder | los 5 repositorios del camino | bajo; verificar que ninguna ruta compartida escriba | + +* **Medición:** benchmark de `BuildAsync` antes/después. Sin benchmark, no aceptar ninguno. + +--- + +#### R-13 · Consolidar la doble resolución de IdP + +Pasar la selección ya resuelta del `AuthMethodResolverService` al `IdpChainAuthenticator` en vez de recalcularla (`:112-113` vs `:97-98`). **Cuidado quirúrgico:** el puente configuración→proveedor **no es el mismo** en ambos sitios — el resolver exige `IsActive` (`:128`), la cadena deliberadamente **no** (`IdpChainAuthenticator.cs:206-207`, con su justificación). Cualquier unificación debe preservar esa asimetría o rompe el fallback. + +--- + +#### R-14 · Cerrar las tres divergencias documento↔código + +1. `AuthAccessScope` (§6.8): decidir la intención y alinear el otro lado en el **mismo** cambio (patrón P7 del cierre de G-075). Si la intención es que el portal use IdP → corregir el XMLdoc y registrar el gap. Si es que sea Local → el endpoint tiene un defecto de seguridad silencioso. +2. Formato del grafo (§6.7): resolver el serializador por la factory dentro del handler usando `GraphSerializationCriteria` con el formato ya resuelto, **o** devolver siempre `Format=JSON` cuando se use el serializador por defecto. Añadir prueba de contrato: inquilino con formato no-JSON, petición sin override → el cuerpo parsea en el formato anunciado. +3. Docstring de `AuthorizationGraphBuilderService:30-36` ("IsOverride=false → use TemplateItem values"): el builder no hace eso. Corregir el texto o cablear el comportamiento; no dejar la mentira. + +--- + +#### R-15 · Política explícita de re-materialización de plantilla + +Decidir por ADR y documentar (§B-4). Opciones: (a) re-materializar por evento de publicación con un proceso en background que reconcilie `ProfilePermission` **preservando `IsOverride`**; (b) declarar que la plantilla es un molde de una sola aplicación y exponer una acción administrativa "reaplicar plantilla" que **reemplace** en vez de acumular. Hoy no está decidido ni documentado, y el estado actual acumula sin revocar. + +Como corrección puntual del mismo bloque: `ApproveRequestCommandHandler.cs:208` crea perfiles sin plantilla → 0 permisos. Alinear con `CreateProfileCommandHandler`. + +--- + +#### R-16 · `include=` propagado hasta el builder, no solo al serializador + +* **Qué:** extender `GraphSerializationOptions` con el conjunto de bloques solicitados y propagarlo **aguas arriba** hasta `BuildInternalAsync`, de modo que `EvaluateFeatureFlagsAsync` y `BuildDomainPermissions` se salten si su bloque no se pidió. +* **Advertencia central:** si `include=` se aplica solo al proyector, el ahorro es de ancho de banda y **el coste dominante (rehidratar la suite, evaluar flags) se sigue pagando**. Además, sobre el diseño actual —donde una sola lectura del agregado alimenta varios bloques— `include=` no ahorra consultas: para que las ahorre, cada bloque debe resolverse con su propia consulta proyectada. +* **Si se adopta:** hacerlo como conjunto **cerrado** de perfiles con nombre (`minimal`/`standard`/`full`), no como lista libre, para que la clave de caché siga acotada. Y reutilizar o **retirar** `RequestedScopes` antes de que algún satélite empiece a enviarlo — hoy un integrador que lo envíe recibe el grafo completo sin aviso, falla silenciosa en vez de 400. +* **Prioridad:** **último**. Las seis intervenciones anteriores dan más retorno. + +--- + +### 8.3 BAJO impacto + +* **Instrumentación propia:** dos histogramas en el meter `UMS.Application` ya registrado — duración de `BuildInternalAsync` y tamaño del payload emitido. Convierte el dashboard de "el login tarda X" a "el login tarda X y se va en Y". +* **Versionar la línea base k6** que produjo la medición del 2026-07-23, apuntando al despliegue (no a `localhost`), con su salida JSON. Requisito de SD-05. +* **Habilitar observabilidad en UAT** (`values-uat.yaml:29-35`) resolviendo la colisión de NodePort. Es el único entorno con personas reales. +* **Paginación real en `GetAllSystemSuites`:** método de repositorio que proyecte a DTO plano sin Includes, con `AsNoTracking` y `Skip`/`Take` traducidos a SQL. El agregado completo se reserva para escritura. +* **Índices faltantes:** `PermissionTemplateItems(TargetId, IsActive)`, `ProfilePermissions(TemplateId)`, índice parcial `Profiles(UserId) WHERE IsActive`, `SystemSuiteDomainResources(ModuleId)`. +* **`DeriveScopes`:** normalizar códigos a minúsculas en origen para evitar hasta ~1.000 asignaciones de string por login (`:517-536`). +* **`AuthorizationAspect`:** sustituir `Console.WriteLine` (`:35,66,70,73`) por el logger estructurado. +* **Auditoría de éxito fuera del camino crítico:** escribirla en la unidad de trabajo que ya se confirma (`handler:199`) o desacoplarla por el outbox existente. **La de fallo debe permanecer síncrona** (su propio comentario lo justifica) y **no tocar el registro de intentos fallidos**: sostiene la política de bloqueo ADR-UMS-095. + +--- + +## 9. Cambios mínimos necesarios + +Lo estrictamente necesario para que UMS sea correcto y desplegable en multi-réplica. Sin esto, nada más importa. + +| # | Cambio | Corrige | Coste | +|---|---|---|---| +| 1 | `GetByTenantAndEmailAsync` + guarda de `TenantId` en la rama IdP | Cruce de frontera de inquilino (§6.2) | 3 líneas | +| 2 | Separar invalidación local de publicada; quitar publicación de `Dispose()` | Tormenta de invalidación (§6.6) | Bajo | +| 3 | Alinear la clave de Redis y añadir health-check de arranque | Revocación y configuración incoherentes (§6.1) | Trivial | +| 4 | Data Protection persistido con `SetApplicationName` | Cookies indescifrables entre pods (§6.1) | Bajo | +| 5 | `IdempotencyMiddleware` → `IDistributedCache` | Idempotencia perdida (§6.1) | Bajo | +| 6 | Borrar la carga de plantillas del builder | 2 SQL de desperdicio puro por login/refresh/preview | 8 líneas | +| 7 | Quitar `Include(EvaluationLogs)` de la vía del grafo | Carga sin cota | 1 línea | +| 8 | Activar compresión | ~90 % del ancho de banda | 1 línea | +| 9 | Test que siembre Option fuera del patrón de 3 niveles | Convierte la pérdida silenciosa en fallo visible (§6.4) | Horas | +| 10 | Validar forma en `AddNodeCommand` | Impide crear topologías irrepresentables | Bajo | +| 11 | Reabrir G-069 y registrar en `GAPS.md` los hallazgos §6.4, §6.7, §6.8, §B-5 | SD-07 | Documental | + +**Orden obligatorio:** 2 → 3 (invertirlo convierte un fallo silencioso en una tormenta). El resto es independiente. + +--- + +## 10. Mejoras opcionales de alto valor + +**Contribuidores de bloque en vez de un método de 537 líneas.** Introducir `IGraphSectionContributor { string Key; Task BuildAsync(GraphBuildContext ctx, CancellationToken ct); }` y que `BuildInternalAsync` itere sobre los registrados en DI. Los seis métodos privados actuales se convierten en seis contribuidores **sin cambiar su lógica**; branding, i18n y layout entran como contribuidores nuevos. Es el prerrequisito limpio para B-3. + +**Marca de exposición en `AppSetting` antes de proyectarlo.** `AppSetting` no tiene `IsEncrypted` ni `IsSensitive`, a diferencia de `AppConfiguration` (`AppConfigurationProps.cs:14`) y `TenantParameter` (`TenantParameterProps.cs:45`). **No volcar la bolsa clave/valor tal cual**: introducir `IsClientVisible` con default `false` (fail-closed), proyectar solo lo marcado y con prefijo de espacio reservado (`UI_`/`LOCALE_`/`BRAND_`), y añadir un test que falle si una clave sin marca alcanza el payload. + +**Poblar `TenantParameterCodes` con la categoría `Ui`/`Localization`.** Es el metamodelo con tipo declarado y validación de dominio de valores, y está vacío en esas categorías. Candidato natural para idioma, región y parametría por inquilino. + +**Emitir siempre `parentResourceCode`.** Hoy `parentResourceId` solo viaja si el inquilino activa metadatos técnicos, apagado por defecto — y el front **ya sabe pintar** el árbol agregado→entidad→método (`ProfileDomainResourcesPanel.tsx:109,126,130`). Separar la relación de composición del interruptor de metadatos, usando la clave de negocio (`code`) en vez del GUID, coherente con la convención del contrato. Coste bajo, no expone identificadores internos. _Nota:_ ningún dato sembrado usa hoy la composición (todas las llamadas del seeder pasan `null`), así que hay que sembrarla para poder verificarlo. + +**ETag/304 sobre el contexto.** `ETagHelper` ya existe y ya se usa. Un grafo es determinista para (perfil, versión de configuración, versión de plantilla). Como cambio suelto **no paga**: sin caché de catálogo, el servidor tendría que reconstruir el grafo para calcular el hash. Si se adopta R-11, el hash del payload canónico se convierte a la vez en clave de caché y en ETag de un GET condicional — ahí sí paga. **Aplazar hasta entonces.** Y recordar que `SecurityHeadersMiddleware.cs:20` fija `Cache-Control: no-store` globalmente: cualquier cacheabilidad HTTP exige acotar esa cabecera por ruta, documentado en ADR. + +--- + +## 11. Roadmap de evolución + +### Fase 0 — Correcciones de corrección (2–3 semanas) · sin cambio de contrato + +Cambios 1–11 de §9. Salida: UMS correcto en aislamiento multi-tenant y desplegable en multi-réplica; ~19 SQL por login en vez de ~24; payload comprimido ~10x. +**Puerta de salida:** login p95 medido y publicado con script k6 versionado; test de 2 réplicas en UAT verde. + +### Fase 1 — Eficiencia y visibilidad (3–4 semanas) · sin cambio de contrato + +R-7 (índice del SDK), R-12 (correcciones algorítmicas + `AsNoTracking`), R-13 (doble resolución IdP), R-14 (divergencias doc↔código), instrumentación propia, observabilidad en UAT, paginación de suites, índices faltantes. +**Puerta de salida:** desglose del login instrumentado (BCrypt vs SQL vs construcción vs serialización) con números reales. + +### Fase 2 — Escalado horizontal real (2–3 semanas) + +Parametrizar réplicas, `resources`, HPA, PDB; sacar migración/siembra del arranque de réplica; R-10 (RLS); R-11 (caché de catálogo, **solo si** la Fase 1 confirma que la suite es el término dominante). +**Puerta de salida:** prueba de carga con 3 réplicas y >100 RPS sostenidos, rellenando las celdas `_auto_` de `metrics/index.md:412-421`. + +### Fase 3 — Evolución del contrato (una sola vez, con ADR en `evolith-core`) + +Aquí se agrupan **todos** los cambios de contrato para pagar un solo bump y una sola actualización de los cinco espejos y del arnés RoboSoft: + +* R-4 (sacar la matriz del JWT) +* R-6 (omitir `NotGranted`) +* R-8 (árbol de navegación recursivo → MAJOR 2.0.0) +* R-9.3 (lista de perfiles autorizados) +* Icono y **ruta** en los nodos de navegación (sin esto el cliente no puede inicializar la app sin llamadas adicionales, que es el punto 4 del escenario) +* Bloques de contexto nuevos vía contribuidores: branding, layout, i18n, integraciones +* Actualizar `configuration.py:419` para que deje de pinear `1.0.0` + +**Decisión de ADR obligatoria antes de empezar:** modelo de evolución del esquema. Recomendación: contenedor `extensions` con `additionalProperties:true` **solo dentro** de ese contenedor, dejando el resto cerrado — preserva la validación estricta donde importa y da extensibilidad real donde se necesita. `schemaVersion` ya existe para soportarlo. + +### Fase 4 — Multi-perfil / multi-sistema + +Extender el contrato a colección de contextos. **Solo después de las fases 0–2**, por una razón cuantificada: el término dominante crece con el número de **suites distintas** referenciadas por los perfiles activos (rol/suite `:134,:139`, flags por suite `:428`, `BuildMenuAccess` y `BuildDomainPermissions`). Con N suites, esos costes se multiplican por N. La carga de perfiles **ya está pagada** (`PostgreSqlProfileRepository.cs:64-73` trae todos con permisos y `:122` descarta todos menos uno → coste marginal cero por perfil extra) y el tenant, las plantillas y la configuración efectiva **no** multiplican. Deduplicar por suite es obligatorio en el diseño. + +**Antes de diseñar esta fase, resolver una ambigüedad de requisito:** el escenario objetivo pide "un contexto por sistema", pero la documentación de arquitectura del propio repo (`reference/gobernanza/requirements/ejemplo-matriz-permisos.md:29-35`, Axioma 3 / INV-P6) describe **una matriz compilada única** que cruza perfiles y sistemas con deny cross-perfil. Son dos diseños distintos y hoy **ninguno** está implementado (el mapa se construye desde UN perfil, `:169`). Esa decisión es un ADR previo, no un detalle de implementación. + +--- + +## 12. Conclusión final + +### ¿Está preparada para un entorno Enterprise de alta concurrencia? + +**No hoy, y el bloqueador no es el que parece.** + +El motor de autorización es sólido: fail-closed correcto y documentado, deny-wins verificado por test adversarial, agregados DDD con invariantes reales, outbox transaccional, cadena IdP resistente a credential-spraying, SLOs formales y observabilidad de ruta funcional. **Ese núcleo no hay que tocarlo.** + +Lo que impide el entorno Enterprise es un conjunto de problemas **operativos y de higiene**, no de diseño de dominio: + +1. **UMS no puede correr con más de una réplica sin romper funcionalidad.** `replicas: 1` en duro, Redis que nunca se activa por un desajuste de clave, cero Data Protection persistido, idempotencia y rate limiter en proceso. Cuatro fallos independientes que se manifiestan a la vez. +2. **Un defecto de aislamiento multi-tenant en el camino de login.** El lookup por email global, sin `ORDER BY`, con el filtro de tenant inerte por construcción, y **sin comprobación de `TenantId` en la rama IdP**. +3. **Coste por login inflado por trabajo demostrablemente inútil.** ~24 SQL de los cuales 5 son puro desperdicio, más una serialización completa que se descarta, más 43-50 KB sin comprimir de los que el 52 % son filas `NotGranted`. +4. **Pérdida silenciosa de datos entre modelo y contrato** en el árbol de navegación, con el read path administrativo mostrando lo que el usuario no recibe. + +Ninguno de los cuatro exige rediseñar. Los cuatro se corrigen con cambios acotados. + +### ¿Qué la convierte en una plataforma robusta y preparada para crecer? + +**Cinco acciones, en este orden estricto:** + +1. **Arreglar el aislamiento de inquilino en el login** (`GetByTenantAndEmailAsync` + guarda en IdP). Tres líneas. Es un defecto de seguridad, no una optimización. +2. **Desbloquear el escalado horizontal**, con la invalidación de caché arreglada **antes** de habilitar Redis. Sin esto, "miles de usuarios concurrentes" es una réplica única sirviendo todo el tráfico. +3. **Borrar el desperdicio y comprimir.** Cuatro cortes en el builder y una línea de compresión: −5 SQL por login y −90 % de ancho de banda, sin tocar el contrato ni el dominio. +4. **Instrumentar antes de optimizar más.** Dos histogramas en el meter ya registrado, y versionar el script k6 de la línea base. Sin números, cualquier caché es fe. +5. **Agrupar toda la evolución del contrato en un solo bump con ADR** — JWT sin matriz, sin `NotGranted`, árbol recursivo, lista de perfiles, icono y ruta, bloques de branding/layout/i18n. Pagar una vez los cinco espejos del contrato y el arnés E2E, no cinco veces. + +**Lo que explícitamente NO hay que tocar:** el motor de resolución de permisos (`BuildPermissionMap` con deny-wins), el fail-closed de `NotGranted` como default, el grafo lobby, la cadena de fallback IdP y su asimetría deliberada sobre `IsActive`, la auditoría síncrona de fallos, el registro de intentos fallidos que sostiene ADR-UMS-095, y el patrón CQRS+agregados+builder+proyector-por-diccionario. Tampoco introducir Event Sourcing, base de lectura separada para el grafo, Builder fluido, caché del grafo completo por usuario, Specification pattern para permisos ni GraphQL: el precedente del único read model existente —degradado, con campos de auditoría fijos y `Published` tratado como `Mutated` (`PermissionTemplateProjectionHandler.cs:35,54-60`)— demuestra el coste real de mantener proyecciones en este código, y el camino se ejecuta una vez por sesión, no por petición. + +Con las fases 0 a 2 completadas, UMS sostiene alta concurrencia sobre la arquitectura que ya tiene. Las fases 3 y 4 son evolución de producto, no reparación. + +--- + +*Cada afirmación de este informe está respaldada por `archivo:línea`. Las afirmaciones que la verificación adversarial refutó o corrigió han sido excluidas o reescritas con su matiz. Los hallazgos §6.4, §6.7, §6.8 y B-5, y la reapertura de G-069, deben registrarse en `GAPS.md` conforme a SD-07 antes de cerrar esta evaluación.* diff --git a/docs/architecture/shell-libraries/bootstrapper.es.md b/docs/architecture/shell-libraries/bootstrapper.es.md index 18502d06..eba86f1d 100644 --- a/docs/architecture/shell-libraries/bootstrapper.es.md +++ b/docs/architecture/shell-libraries/bootstrapper.es.md @@ -421,38 +421,26 @@ await host.RunAsync(); UMS actualmente usa `IHostedService`, `IStartupFilter`, y cableado directo de `Program.cs` para inicializacion de inicio. El patron Bootstrapper puede superponerse para casos complejos de multiples pasos. -### Patron recomendado para bootstrap de schema SQL Server +### Cómo arranca UMS su esquema en realidad + +UMS **no** encadena bootstrappers de esquema. El esquema lo crea y lo hace evolucionar las +**migraciones de EF Core**, aplicadas al arrancar desde +`UmsApiServiceBootstrappers.InitializeUmsPlatformAsync`: ```csharp -// En Ums.Infrastructure/Hosting/SchemaBootstrapperService.cs -public class SchemaBootstrapperService( - IServiceProvider sp, - ILogger logger) : IHostedService -{ - public async Task StartAsync(CancellationToken ct) - { - await new CompositeBootstrapperAsync() - .Add(new SqlServerSchemaBootstrapper(sp, logger)) - .Add(new DevDataSeedBootstrapper(sp, logger)) - .RunAsync(ct); - } +await platformDbContext.Database.MigrateAsync(); +await readModelDbContext.Database.MigrateAsync(); +``` - public Task StopAsync(CancellationToken ct) => Task.CompletedTask; -} +El patrón anterior —un `CompositeBootstrapperAsync` encadenando `SqlServerSchemaBootstrapper` y +compañía— está **retirado**, igual que SQL Server y SQLite: PostgreSQL es el único proveedor +relacional (ADR-0082), y `SqlServerSchemaBootstrapper`, `PostgreSqlSchemaBootstrapper` y +`SqliteSchemaBootstrapper` ya no existen en el código. -// Implementacion de cada fase: -public class SqlServerSchemaBootstrapper(IServiceProvider sp, ILogger logger) - : IBootstrapperAsync -{ - public async Task RunAsync(CancellationToken ct) - { - using var scope = sp.CreateScope(); - var bootstrapper = scope.ServiceProvider - .GetRequiredService(); - await bootstrapper.InitializeAsync(ct); - } -} -``` +Donde el patrón Bootstrapper sí se gana su sitio es en la **composición de servicios**, no en el +esquema: `CompositeBootstrapper` secuencia `UmsCoreApplicationBootstrapper`, +`UmsApiPlatformBootstrapper`, `UmsApiDocumentationBootstrapper` y `ConfigurationBootstrapper` en +el registro. ### Observabilidad en Program.cs diff --git a/docs/architecture/shell-libraries/bootstrapper.md b/docs/architecture/shell-libraries/bootstrapper.md index 96572d71..e36431ea 100644 --- a/docs/architecture/shell-libraries/bootstrapper.md +++ b/docs/architecture/shell-libraries/bootstrapper.md @@ -424,38 +424,24 @@ await host.RunAsync(); UMS currently uses `IHostedService`, `IStartupFilter`, and direct `Program.cs` wiring for startup initialization. The Bootstrapper pattern can be layered on top for complex multi-step cases. -### Recommended pattern for SQL Server schema bootstrap +### How UMS actually bootstraps its schema + +UMS does **not** chain schema bootstrappers. The schema is created and evolved by **EF Core +migrations**, applied at startup from `UmsApiServiceBootstrappers.InitializeUmsPlatformAsync`: ```csharp -// In Ums.Infrastructure/Hosting/SchemaBootstrapperService.cs -public class SchemaBootstrapperService( - IServiceProvider sp, - ILogger logger) : IHostedService -{ - public async Task StartAsync(CancellationToken ct) - { - await new CompositeBootstrapperAsync() - .Add(new SqlServerSchemaBootstrapper(sp, logger)) - .Add(new DevDataSeedBootstrapper(sp, logger)) - .RunAsync(ct); - } +await platformDbContext.Database.MigrateAsync(); +await readModelDbContext.Database.MigrateAsync(); +``` - public Task StopAsync(CancellationToken ct) => Task.CompletedTask; -} +The former pattern — a `CompositeBootstrapperAsync` chaining `SqlServerSchemaBootstrapper` and +friends — is **withdrawn**, along with SQL Server and SQLite themselves: PostgreSQL is the single +relational provider (ADR-0082), and `SqlServerSchemaBootstrapper`, `PostgreSqlSchemaBootstrapper` +and `SqliteSchemaBootstrapper` no longer exist in the code. -// Implementation of each phase: -public class SqlServerSchemaBootstrapper(IServiceProvider sp, ILogger logger) - : IBootstrapperAsync -{ - public async Task RunAsync(CancellationToken ct) - { - using var scope = sp.CreateScope(); - var bootstrapper = scope.ServiceProvider - .GetRequiredService(); - await bootstrapper.InitializeAsync(ct); - } -} -``` +Where the Bootstrapper pattern still earns its place is **service composition**, not schema: +`CompositeBootstrapper` sequences `UmsCoreApplicationBootstrapper`, `UmsApiPlatformBootstrapper`, +`UmsApiDocumentationBootstrapper` and `ConfigurationBootstrapper` at registration time. ### Observability in Program.cs diff --git a/docs/architecture/solution-architecture.es.md b/docs/architecture/solution-architecture.es.md new file mode 100644 index 00000000..c0f02d9e --- /dev/null +++ b/docs/architecture/solution-architecture.es.md @@ -0,0 +1,161 @@ +# Arquitectura de Solución — ums + +> **Estado:** Adoptado | **Propietario:** BeyondNet S.A.C. | **Reglas:** S-06, SD-08 +> **Versión:** 1.0.0 · **Fecha:** 2026-07-13 · **Cierra:** [G-007](../../GAPS.md) + +Documento de arquitectura del satélite **ums**: topología, límites, +contratos de integración y decisiones estructurales, trazadas a su corpus de +ADRs. Complementa el [PRD](../01-concepcion/PRD-UMS-001.es.md) (el _qué_) describiendo el _cómo_. +El detalle por decisión vive en [reference/architecture/](../../reference/architecture/index.md) +(ADR-0050 a ADR-0083). + +## 1. Estilo y Principios + +UMS es un **monolito modular** con **Arquitectura Limpia / Hexagonal** y **DDD +estricto**. Principios rectores: + +* **Boring technology:** .NET 10 LTS, PostgreSQL, React — estabilidad sobre novedad. +* **Dominio puro:** la capa Domain es 100 % POCO, sin dependencias NuGet. +* **Integración por identificadores:** los contextos se referencian por IDs + centrales, nunca por referencias directas de objeto entre contextos. +* **Result Pattern:** sin excepciones para control de flujo; invariantes vía + _Broken Rules Registry_. +* **Guardas de dependencia:** no se desactiva, archiva ni elimina un agregado con + dependencias activas (ADR-UMS-079). +* **Fuente única de verdad** por agregado; consistencia eventual sin 2PC. + +## 2. Mapa de Contextos + +```mermaid +flowchart TB + subgraph Presentation["Presentation"] + WEB["SPA React/TS"] + API["API REST (CQRS)"] + end + subgraph Contexts["Bounded Contexts (Application + Domain)"] + ID["Identity"] + AUTHZ["Authorization"] + CFG["Configuration"] + APR["Approvals"] + IGA["IGA"] + AUD["Audit"] + end + subgraph Infra["Infrastructure"] + DB[("PostgreSQL
esquema por módulo")] + BUS["Bus de eventos
+ Transactional Outbox"] + end + + WEB --> API --> ID + API --> AUTHZ + API --> CFG + API --> APR + API --> IGA + ID -->|IDs| AUTHZ + APR -->|IDs| IGA + ID & AUTHZ & CFG & APR & IGA -->|eventos| BUS + BUS --> AUD + ID & AUTHZ & CFG & APR & IGA & AUD --> DB + + style Contexts fill:#e3f2fd,stroke:#1565c0,color:#000 + style AUD fill:#e8f5e9,stroke:#2e7d32,color:#000 +``` + +Los contextos coinciden con los del PRD: **Identity**, **Authorization**, +**Configuration**, **Approvals**, **IGA** y **Audit** (suscriptor _downstream_ de +todos). El código materializa esta división en `src/apps/ums.api`. + +## 3. Estructura por Capas + +Cada contexto respeta la misma estratificación (proyectos `Ums.*`): + +| Capa | Proyecto | Responsabilidad | Dependencias | +| :--- | :--- | :--- | :--- | +| Domain | `Ums.Domain` | Agregados, invariantes, eventos de dominio | Solo `System.*` | +| Application | `Ums.Application` | CQRS (comandos/queries), handlers, validación | MediatR, FluentValidation | +| Infrastructure | `Ums.Infrastructure` | Persistencia, outbox, adaptadores, AOP | EF Core, Npgsql, MassTransit | +| Presentation | `Ums.Presentation` | API, endpoints, middleware | ASP.NET Core | +| ReadModels | `Ums.ReadModels` | Proyecciones de lectura (CQRS) | EF Core | +| Globalization | `Ums.Globalization` | Localización | — | + +La regla de dependencia apunta siempre hacia adentro: Presentation → Application → +Domain; Infrastructure implementa los puertos que Application declara. + +## 4. Contratos e Integraciones + +* **API REST unificada (ADR-UMS-055/059, revisado — [D-007](../../DECISIONS.md)):** un único + transporte REST para queries (GET, con proyecciones planas de lectura) y comandos + (POST/PUT/PATCH/DELETE). GraphQL fue retirado; el CQRS permanece en la capa de + aplicación. Nivel único tras el gateway/BFF. +* **Grafo de Autorización (ADR-UMS-088/0080/0081):** contrato de salida hacia los + sistemas cliente. Snapshot inmutable y autocontenido, _Code-First / ID-Optional_, + serializable (JSON/XML/YAML/CSV), entregado en el login y validado localmente por + el cliente. +* **SDK multi-runtime (ADR-UMS-073):** `Ums.Sdk.*` (.NET) y `@ums/ums-*` + (TypeScript / NestJS) para que los consumidores integren autorización sin acoplarse + a la API interna. +* **Proveedores de identidad externos:** OIDC / SAML 2.0 / WS-Federation, resueltos + dinámicamente desde configuración (ADR-UMS-072), no desde código. +* **Mensajería (ADR-UMS-051):** eventos de integración por bus como puerto inyectable, + con _Transactional Outbox_. El contrato de entrada de MMS + (`Ums.Contracts.MasterData:TenantEvent`) está fijado y su integración + diferida (D-006, [G-011](../../GAPS.md)). +* **Auditoría:** suscriptor _downstream_ que recibe eventos de todos los contextos + y persiste una traza inmutable (ADR-UMS-052). + +## 5. Transversales (Cross-Cutting) + +* **Shells corporativos:** `BeyondNetCode.Shell.*` (DDD, AOP, Factory, Bootstrapper), + ruteados desde el framework de origen (D-005). Ver [DECISIONS.md](../../DECISIONS.md). +* **AOP (ADR-UMS-060):** _concerns_ transversales (logging, auditoría, transacción, + validación de tenant, idempotencia) vía DispatchProxy sobre el pipeline, no + esparcidos en los handlers. +* **Contexto de ejecución (ADR-UMS-061):** propagación de correlación/traza; hoy con un + shim local de observabilidad ([G-015](../../GAPS.md)) pendiente de migrar a + `ActivitySource` nativo. +* **Observabilidad (ADR-UMS-053):** OpenTelemetry (trazas/métricas) y logging seguro de + PII (ADR-UMS-062). +* **Idempotencia (ADR-UMS-063):** middleware de `Idempotency-Key` ante reintentos. + +## 6. Persistencia y Datos + +* **PostgreSQL autoritativo (ADR-UMS-089)**, con **esquema por módulo** (ADR-UMS-087/0070) + para aislar los contextos también a nivel físico. +* **Aislamiento por inquilino** en la capa de aplicación mediante _global query filters_ + de EF Core sobre `OrganizationId` (mecanismo primario y suficiente). El _failsafe_ de + Row-Level Security a nivel de base de datos no está activo con PostgreSQL + ([G-020](../../GAPS.md)). +* **CQRS:** los `ReadModels` mantienen proyecciones planas para la vía de lectura; + la escritura pasa por los agregados y el outbox. +* **Fechas en UTC** (ADR-UMS-076); zona horaria e idioma se resuelven en el cliente. + +## 7. Topología de Despliegue + +UMS opera como **pasarela de identidad y autorización**, _standalone_ o integrada +con IdPs externos, tras un **API Gateway / BFF** (YARP, ADR-UMS-058) que aplica +límites de complejidad, timeouts y rate limiting. La infraestructura de referencia +(Terraform, Helm, Docker Compose con stack de observabilidad) se importó bajo +`src/infra/`; su promoción a un pipeline de despliegue gobernado es +[G-010](../../GAPS.md). + +## 8. Trazabilidad a ADRs + +Las decisiones estructurales residen en el corpus importado (ADR-0050 a ADR-0083). +Ancla por área: API híbrida (ADR-UMS-055/059), gateway (ADR-UMS-058), grafo de +autorización (ADR-UMS-088/0080/0081), resolución de auth (ADR-UMS-072), AOP (ADR-UMS-060), +contexto de ejecución (ADR-UMS-061), persistencia (ADR-UMS-087/0070/0082), auditoría +(ADR-UMS-052), SDK (ADR-UMS-073), guardas de dependencia (ADR-UMS-079). + +Estos ADRs son de origen importado; su **retrazado a ADRs aceptados de +`evolith-core`** (S-06) está registrado como [G-012](../../GAPS.md). + +## Historial de Cambios + +| Versión | Fecha | Autor | Descripción | +| :--- | :--- | :--- | :--- | +| 1.0.0 | 2026-07-13 | BeyondNet S.A.C. | Arquitectura de solución inicial: topología, capas, contratos y trazabilidad. Cierra G-007 | + +--- + +

+ © BeyondNet S.A.C. · RUC 20100412447 · Operador Logístico Aduanero desde 1978 +

diff --git a/docs/architecture/system-selection-at-login-design.es.md b/docs/architecture/system-selection-at-login-design.es.md new file mode 100644 index 00000000..a2b37120 --- /dev/null +++ b/docs/architecture/system-selection-at-login-design.es.md @@ -0,0 +1,465 @@ +# Diseño — El cliente declara su sistema al autenticar (`systemCode`) y el multi-perfil por el carril de satélite + +> **Estado:** Propuesta · **Fase SDLC:** 2 · Diseño · **Fecha:** 2026-08-02 +> **Norma que realiza:** `ADR-0156` de `evolith-core` (`reference/architecture/adrs/core/0156-autenticacion-tablero-ums-sistema-solicitado-grafo-por-api.es.md`), §2.5, §2.6 y §2.10. Supersede a `ADR-0155`. +> **Amplía a:** [Cambio de perfil y lista de perfiles](./diseno-cambio-de-perfil.md) — implementa su §4.2, que quedó como propuesta y **nunca se construyó**, y renombra su campo `system` a `systemCode` (§3.1). +> **Consumidor de referencia:** [Análisis de integración E2E — UMS ↔ Tablero SDLC](./analisis-integracion-e2e-ums-tablero-sdlc.md) +> **Gaps relacionados:** [G-177](../../GAPS.md), [G-184](../../GAPS.md), [G-201](../../GAPS.md), [G-202](../../GAPS.md) +> **Alcance:** especificación de contrato y de cambios. **No contiene código de producto**; contiene la precisión suficiente para implementarlo sin volver a decidir. + +--- + +## 1. El defecto, en una frase + +Ni [`ClientAuthRequest`](../../src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Auth/ClientAuthEndpoints.cs) ni [`AuthenticateUserCommand`](../../src/apps/ums.api/Ums.Application/Identity/Auth/Commands/AuthenticateUserCommand.cs) aceptan **qué sistema pide el llamante**, de modo que +[`AuthorizationGraphBuilderService`](../../src/apps/ums.api/Ums.Application/Authorization/Graph/AuthorizationGraphBuilderService.cs) resuelve el perfil por desempate (`:160-167`) y construye el grafo del sistema que ese desempate elija. + +El desempate no está mal: **responde a una pregunta que nadie le hizo**, porque el contrato no permite hacerla. Es la causa de [G-184](../../GAPS.md) y la causa de que el Tablero SDLC reciba el grafo de `SIL`. + +**Verificado el 2026-08-02** contra `http://localhost:5080` con `admin@beyondnet.com.pe` / `BEYONDNET`: `context.systemSuite.code` = `SIL`, `profiles` con un solo elemento, también de `SIL`. En esta instancia **ningún usuario tiene más de un perfil** (13 perfiles / 13 usuarios distintos, consultado en `GET /api/v1/profiles`), así que **el escenario multi-perfil no es reproducible con la siembra actual**: exige sembrar un usuario con dos perfiles antes de poder probarse (§9). + +## 2. Modelo, tal como el cliente lo precisó + +* Un **perfil** ata **inquilino + sistema + usuario**. El **rol** es la dimensión que varía: el mismo usuario, en el mismo inquilino y el mismo sistema, puede tener más de un perfil por rol. La sucursal lo acota opcionalmente. El modelo ya lo permite: el índice `(TenantId, UserId, RoleId, BranchId)` no es único. +* El **inquilino es `BEYONDNET` siempre**, salvo indicación contraria. Lo resuelve el servidor del cliente por configuración; **no se pide al usuario**. +* El **login pide usuario y contraseña**. Ni rol, ni inquilino, ni sistema. +* El **sistema lo declara el cliente**, no el usuario, y es **opcional en el contrato**: sin él, UMS devuelve los perfiles del usuario en ese inquilino **de todos los sistemas** (caso multiproducto); con él, los acota a ese sistema. +* Si el resultado trae **más de un perfil**, el cliente ofrece **cambio de perfil**, reutilizando lo que UMS ya publica. +* El cliente **no calcula permisos**: usa exclusivamente el grafo. + +--- + +## 3. Contrato de petición + +### 3.1 Dónde entra `systemCode` + +Cuatro puntos, en este orden. Los tres primeros son obligatorios; el cuarto cierra un hueco preexistente. + +| # | Capa | Artefacto | Cambio | +| :--- | :--- | :--- | :--- | +| 1 | Presentation | `ClientAuthRequest` (`ClientAuthEndpoints.cs:320-325`) | Nuevo parámetro `string? SystemCode = null`, **posicionado al final** para no alterar el orden de los existentes | +| 2 | Application | `AuthenticateUserCommand` (`AuthenticateUserCommand.cs:16-23`) | Nuevo parámetro `string? SystemCode = null`, al final | +| 3 | Domain (puerto) | `IAuthorizationGraphBuilder.BuildAsync` (`IAuthorizationGraphBuilder.cs:21-25`) | Nuevo parámetro `string? systemCode`, **entre `authMethod` y `cancellationToken`** | +| 4 | Presentation | `LoginRequest` (`AuthEndpoints.cs:861-865`) | Nuevo parámetro `string? SystemCode = null`, al final. Segunda ola (§10) | + +**Nombre: `systemCode`, no `system`.** [`diseno-cambio-de-perfil.md`](./diseno-cambio-de-perfil.md) §4.2 propuso `system`; nunca se implementó, así que no hay compatibilidad que romper. Se elige `systemCode` por simetría con `tenantCode`, que ya está en el mismo cuerpo, y porque el valor es un **código de negocio**, no un objeto. Esta decisión **anula** el nombre de aquella propuesta. + +**Normalización, en el endpoint y en un solo sitio:** `SystemCode?.Trim().ToUpperInvariant()`, y **cadena vacía o solo espacios equivale a ausente** (`null`). Sin esta regla, `"systemCode": ""` significaría «un sistema cuyo código es la cadena vacía» y devolvería cero perfiles: un cliente mal configurado obtendría «sin acceso» en vez del comportamiento multiproducto, y el diagnóstico sería caro. + +**El `systemCode` no es un campo de formulario.** Es configuración solo-servidor del cliente, igual que la URL de UMS. UMS no lo impone —no puede—, pero el contrato lo documenta: `ADR-0156` §2.5. + +### 3.2 Petición resultante + +```jsonc +POST /api/v1/client/authenticate?format=json +Content-Type: application/json + +{ + "tenantCode": "BEYONDNET", + "username": "pmo.sdlc@beyondnet.com.pe", + "password": "…", + "systemCode": "SDLC", // NUEVO · opcional · el Tablero lo envía siempre + "format": "JSON" // ya existía +} +``` + +### 3.3 Lo que NO se toca + +* **`RequestedScopes`** (`ClientAuthRequest`) sigue sin uso. No se aprovecha para esto: es un filtro de *scopes*, no de sistema, y darle una segunda semántica lo dejaría inservible para la primera. +* **`AuthenticateUserCommand.SystemSuiteId`** (`Guid?`) **no se reutiliza como entrada**. Es el contexto de resolución de IdP de FR-042 / `ADR-UMS-097` §2.2, viaja como identificador y hoy siempre llega `null`. Mezclar ambas semánticas en un campo ata el enrutado de IdP al filtro de perfiles, que son problemas distintos. + **Sí se puebla como salida derivada** (§4.4): cuando el código resuelva a una suite, el manejador rellena `SystemSuiteId` para que el enrutado por suite tenga por fin una fuente. Es una mejora colateral, **best-effort y silenciosa**: si no resuelve, se queda `null` y la autenticación sigue exactamente igual. + +--- + +## 4. Cómo filtra + +### 4.1 La regla que hace el filtro anti-enumeración por construcción + +> **El filtro se aplica sobre los sistemas de los perfiles que el usuario ya tiene. Este camino NO consulta el catálogo de sistemas por código. Nunca.** + +Es decir: **está prohibido** llamar a `ISystemSuiteRepository.GetByCodeAsync` en el flujo de autenticación para validar el `systemCode` recibido. + +La consecuencia es la propiedad de seguridad que pide `ADR-0156` §6: un código de sistema **inexistente** y un código **existente en el que el usuario no tiene perfil** producen exactamente el mismo estado interno —lista de candidatos vacía— y por tanto exactamente la misma respuesta. No hay dos ramas que puedan divergir en un mensaje, un status o un tiempo de respuesta, porque **no hay dos ramas**. Validar el código contra el catálogo y luego «devolver el mismo error» es la variante frágil: sobrevive hasta el primer refactor que añada un `log.Warn("suite {code} no existe")`. + +### 4.2 Punto exacto del cambio + +En `AuthorizationGraphBuilderService.ConstruirAsync`, entre `:129` y `:140`: + +1. Se cargan los perfiles activos (`GetActiveByUserAndTenantAsync`, `:129-130`) — sin cambio. +2. Se cargan sus roles por lote (`GetByIdsAsync`, `:134-137`) — sin cambio. +3. **Se adelanta** la resolución de resúmenes de suite (`_suiteRepo.GetSummariesByIdsAsync`) que hoy vive dentro de `ConstruirPerfilesAsync` (`:362-363`), y el diccionario resultante se pasa a esa función en vez de que lo calcule. **No añade consultas: mueve una.** +4. Se calcula la lista de candidatos: + +```text +perfilesCandidatos = + systemCode == null + ? perfilesDelUsuario + : perfilesDelUsuario donde suiteDelRol(perfil).Code ==(OrdinalIgnoreCase) systemCode +``` + +5. El desempate de `:160-167` opera sobre `perfilesCandidatos`, no sobre `perfilesDelUsuario`. +6. `ConstruirPerfilesAsync` proyecta `perfilesCandidatos`, **no** la lista completa. + +**El punto 6 es una regla de seguridad, no de estética.** Si el bloque `profiles` viajara completo mientras el grafo está acotado, un satélite que pide `SDLC` recibiría el inventario de los sistemas en los que ese usuario trabaja. Eso es información del inquilino filtrándose a un sistema que no la necesita, y contradice `ADR-0156` §2.5. + +### 4.3 Un perfil cuyo rol o suite no resuelva se descarta, y se registra + +`ConstruirPerfilesAsync` ya descarta en silencio el perfil cuyo rol o cuya suite no encuentra (`:369-370`). Con el filtro activo ese descarte **cambia de significado**: deja de ser una fila que no se pinta y pasa a poder ser la diferencia entre «tienes acceso» y «no tienes acceso». Debe **registrarse en el log a nivel `Warning`** con el identificador del perfil y el del rol. El servicio no tiene logger hoy —es la causa raíz que [G-177](../../GAPS.md) documentó— y hay que inyectárselo. + +### 4.4 Poblado derivado de `SystemSuiteId` + +Cuando `systemCode != null` y **coincide con la suite de algún perfil del usuario**, el manejador rellena `AuthenticateUserCommand.SystemSuiteId` con el id de esa suite antes de llamar a `_methodResolver.ResolveAsync` (`AuthenticateUserCommandHandler.cs:88-93`). + +**Orden y honestidad del dato:** hoy la resolución de método ocurre **antes** de conocer al usuario (paso 2 del manejador) y el filtro de perfiles **después** (paso 5). Resolver la suite antes exigiría o consultar el catálogo —prohibido por §4.1— o cargar los perfiles antes de validar credenciales —inaceptable—. Por tanto: + +> **En esta ola, `SystemSuiteId` se sigue enviando `null` al resolver de IdP.** El poblado derivado queda **fuera de alcance** y se registra como hallazgo. Prometerlo aquí y no poder cumplirlo sería exactamente el tipo de afirmación sin evidencia que `SD-05` prohíbe. + +--- + +## 5. Respuesta: 0, 1 y N perfiles + +### 5.1 Discriminador nuevo en el grafo: `accessState` + +`onboardingPending` no basta. Hoy significa «el usuario no tiene ningún perfil» (grafo lobby, G-043). Con el filtro aparece un estado que **no es ese**: el usuario tiene perfiles, pero ninguno en el sistema que se pidió. Devolver `onboardingPending: true` ahí sería mentir, y el cliente mostraría un flujo de alta a alguien que ya está de alta. + +Se añade un campo de primer nivel, **enumeración cerrada**: + +| `accessState` | Significado | `onboardingPending` | +| :--- | :--- | ---: | +| `Granted` | Hay perfil vigente y el grafo lleva su navegación y sus permisos | `false` | +| `NoProfileInSystem` | El usuario **tiene** perfiles, pero **ninguno** en el sistema pedido — o el sistema pedido no existe, que es indistinguible por §4.1 | `false` | +| `OnboardingPending` | El usuario **no tiene ningún** perfil activo en el inquilino | `true` | + +`onboardingPending` **se conserva** y se emite como `accessState == "OnboardingPending"`. No se retira en esta versión: un consumidor de 2.0–2.3 sigue funcionando sin tocarlo. Su retirada, si se decide, sigue el ciclo de deprecación de `SCHEMA_VERSIONING.md`. + +### 5.2 Eco del sistema pedido: `context.requestedSystem` + +```jsonc +"context": { + "requestedSystem": { "code": "SDLC" } // o null si no se pidió ninguno +} +``` + +Es el **eco literal de la entrada**, ya normalizada. No es una lectura del catálogo y por tanto no filtra nada que el llamante no supiera. Sirve para dos cosas: que el cliente pueda decir «no tiene acceso a *SDLC*» sin llevar su propia configuración al navegador, y que un grafo capturado como evidencia sea autodescriptivo —hoy, ante un grafo de `SIL`, no hay forma de saber si alguien pidió otra cosa—. + +### 5.3 `profiles[].id` pasa a emitirse siempre + +Hoy `AuthGraphPayload.Profile` (`AuthGraphPayload.cs:133`) emite el `id` a través de `WithId(meta, …)`, es decir **solo** cuando el inquilino activa `AUTH_GRAPH_INCLUDE_TECHNICAL_METADATA`, que por defecto está en `false`. **Verificado en vivo:** las claves de `profiles[0]` son `system`, `role`, `branch`, `scope`, `isCurrent`. **No hay `id`.** + +Consecuencia medida: **el cliente recibe una lista de perfiles y no tiene nada que enviar para cambiar a ninguno**, porque `POST /api/v1/auth/switch-profile` exige `{ "profileId": "" }` (`AuthEndpoints.cs:947`, `:602`). Un contrato que ofrece una operación y retiene su clave no es un contrato. + +**Cambio:** `id` se emite **siempre** en `profiles[]`, fuera de `WithId`. La regla de metadatos técnicos se mantiene intacta para módulos, nodos, recursos y flags: allí el `id` es decorativo y el `code` es la clave de negocio ([ADR-0090](../../reference/architecture/adrs/index.md)). Aquí no lo es — es el único identificador de una operación que el propio grafo invita a ejecutar. + +**Alternativa descartada:** un selector semántico `{ systemCode, roleCode, branchCode }` en lugar del id. Es coherente con «`code` es la clave de negocio», pero **no desambigua**: el índice `(TenantId, UserId, RoleId, BranchId)` no es único, así que dos perfiles pueden coincidir en los tres campos, y el selector obligaría a un `409` de «selector ambiguo» sin que el cliente tenga forma de resolverlo. Se descarta por eso, no por comodidad. + +### 5.4 Las tres respuestas + +El envoltorio no cambia en ningún caso: `{ token, tokenType, expiresIn, issuedAt, format, graph, requestId }`, con `graph` como **cadena serializada** en el formato negociado. + +#### N ≥ 2 perfiles candidatos — el caso que obliga al selector + +```jsonc +// HTTP 200 +"graph": { + "schemaVersion": "2.4.0", + "accessState": "Granted", + "onboardingPending": false, + "context": { + "user": { "email": "pmo.sdlc@beyondnet.com.pe", "username": "…", "value": "…", "status": "Active" }, + "tenant": { "code": "BEYONDNET", "value": "BeyondNet S.A.C.", "status": "Active", "isManagementOwner": true }, + "requestedSystem": { "code": "SDLC" }, + "systemSuite": { "code": "SDLC", "value": "Tablero de Gobierno SDLC", "status": "Active" }, + "role": { "code": "PMO", "value": "Oficina de Gestión", "hierarchyLevel": 1 }, + "profile": { "scope": "OrgWide", "isActive": true }, + "branch": null + }, + "profiles": [ + { "id": "3f2a…", "system": { "code": "SDLC", "value": "Tablero de Gobierno SDLC" }, + "role": { "code": "PMO", "value": "Oficina de Gestión", "hierarchyLevel": 1 }, + "branch": null, "scope": "OrgWide", "isCurrent": true }, + { "id": "9b71…", "system": { "code": "SDLC", "value": "Tablero de Gobierno SDLC" }, + "role": { "code": "EQUIPO", "value": "Miembro de Equipo", "hierarchyLevel": 3 }, + "branch": { "code": "CALLAO", "value": "Callao" }, "scope": "BranchScoped", "isCurrent": false } + ], + "menuAccess": [ /* … del perfil vigente, podado fail-closed … */ ], + "…": "resto sin cambios" +} +``` + +* **Exactamente uno** lleva `isCurrent: true`, y es el que el desempate eligió. +* Todos comparten `system.code` cuando se pidió `systemCode`. **Es el caso que el cliente precisó**: mismo inquilino, mismo sistema, distinto rol. +* El orden es el del desempate: nivel de jerarquía ascendente, código de sistema, código de rol. Con `systemCode` fijado, el segundo criterio es constante y el orden efectivo es **jerarquía, luego código de rol**. + +#### 1 perfil candidato + +Idéntico al anterior con un solo elemento en `profiles`, `isCurrent: true`. **El bloque viaja igual.** Que el cliente decida no pintar selector es distinto de que el servidor le oculte el dato: ocultarlo obligaría a una llamada adicional el día que quiera mostrar «operando como PMO» en la cabecera. + +#### 0 perfiles candidatos — el caso que no debe delatar nada + +**HTTP 200. No 401, no 403, no 404.** + +```jsonc +// HTTP 200 +"graph": { + "schemaVersion": "2.4.0", + "accessState": "NoProfileInSystem", + "onboardingPending": false, + "context": { + "user": { "email": "…", "username": "…", "value": "…", "status": "Active" }, + "tenant": { "code": "BEYONDNET", "value": "BeyondNet S.A.C.", "status": "Active", "isManagementOwner": true }, + "requestedSystem": { "code": "SDLC" }, + "systemSuite": null, "role": null, "profile": null, "branch": null + }, + "profiles": [], + "actions": [], "menuAccess": [], "domainPermissions": [], "featureFlags": [], "scopes": [], + "settings": {}, + "effectiveConfig": { "…": "del inquilino, sin cambios" }, + "generatedAt": "…", "validUntil": "…" +} +``` + +**Por qué 200 y no un error.** + +1. **El status es en sí mismo un oráculo.** `404` frente a `403` frente a `401` distingue tres cosas para quien las compara, y una de ellas es la existencia del sistema. Un único `200` no distingue nada. +2. **Las credenciales eran correctas.** Devolver `401` conflatea «tu contraseña está mal» con «no te han asignado el perfil». El primero es un problema del usuario; el segundo, del administrador. Fundirlos convierte una tarea de aprovisionamiento en un incidente de soporte de credenciales. +3. **Ya está decidido.** [`diseno-cambio-de-perfil.md`](./diseno-cambio-de-perfil.md) §4.2 lo fijó para el login del portal: «el login **es correcto** y devuelve un grafo lobby con `profiles` vacío». Esto lo extiende al carril de satélite y le añade el discriminador que allí faltaba. +4. **El cliente ya sabe hacer fail-closed.** Un grafo sin `menuAccess` no habilita nada. La denegación es efectiva sin necesidad de un status de error. + +**Se emite token igualmente.** La identidad quedó probada y el satélite necesita un portador para consultar `GET /api/v1/client/graph` y reevaluar más tarde —un administrador puede asignarle el perfil sin que el usuario vuelva a teclear su contraseña—. El token de este caso **no lleva** los claims `sys_suite`, `sys_suite_name`, `role`, `role_name` ni `profile_scope`. + +> **Comprobación obligatoria antes de implementar:** `IJwtTokenService.GenerateSemanticGraphToken` debe tolerar `context.systemSuite`, `context.role` y `context.profile` en `null`. Hoy solo se ejerce por la vía del grafo lobby, cuyo camino de emisión de token **no se verificó** en este análisis. Si lanza, se corrige aquí, no se cambia la decisión. + +#### Usuario sin ningún perfil (con o sin `systemCode`) + +Comportamiento actual del grafo lobby (G-043), más el discriminador: `accessState: "OnboardingPending"`, `onboardingPending: true`, `profiles: []`, `requestedSystem` con eco o `null`. + +### 5.5 Tabla de resolución completa + +| `systemCode` | Perfiles del usuario | Candidatos | `accessState` | `context.systemSuite` | `profiles` | +| :--- | ---: | ---: | :--- | :--- | :--- | +| ausente | 0 | 0 | `OnboardingPending` | `null` | `[]` | +| ausente | 1 | 1 | `Granted` | el del perfil | 1 elemento | +| ausente | N | N | `Granted` (desempate) | el del elegido | N, **de varios sistemas** | +| presente | 0 | 0 | `OnboardingPending` | `null` | `[]` | +| presente | N | 0 | `NoProfileInSystem` | `null` | `[]` | +| presente | N | 1 | `Granted` | el pedido | 1 elemento | +| presente | N | M ≥ 2 | `Granted` (desempate) | el pedido | M, **todos del sistema pedido** | + +La fila `presente / N / 0` cubre por igual «el sistema no existe» y «existe pero no tienes perfil». **Son la misma fila a propósito.** + +--- + +## 6. Qué pasa con el desempate actual + +**No desaparece y no se degrada. Cambia de papel.** Hoy es el mecanismo *único* de selección; pasa a ser el respaldo de dos casos legítimos: + +1. **Multiproducto** (`systemCode` ausente): un portal que ofrece varios sistemas necesita entrar con alguno. El desempate elige, y el bloque `profiles` completo le permite ofrecer el cambio. +2. **Multi-perfil intra-sistema** (`systemCode` presente, M ≥ 2): el usuario tiene dos roles en el mismo sistema. El desempate elige el de mayor jerarquía y el cliente ofrece el cambio. + +El orden se conserva **literalmente** —`HierarchyLevel` ascendente, `SystemSuiteId`, `Role.Code` ordinal (`:160-167`)— por tres razones: ya es explicable, ya está probado, y el bloque `profiles` se ordena con el mismo criterio (`:391-395`), de modo que **lo primero que el cliente pinta es lo que el servidor habría elegido**. Romper esa correspondencia haría que el primer elemento de la lista no fuera el marcado como vigente. + +> **Precisión sobre `ThenBy(SystemSuiteId)`:** ordena por **GUID**, no por código de sistema, pese a que el comentario del código dice «luego el sistema». Es estable y determinista, pero **no es explicable a un humano** — es el mismo defecto que [G-177](../../GAPS.md) corrigió en el primer criterio y que quedó sin corregir en el segundo. Con `systemCode` presente el criterio es constante y da igual; sin él, decide entre sistemas por un identificador opaco. **Corregir a `Role → SystemSuite.Code` ordinal**, que ya está disponible en el diccionario de resúmenes que §4.2 adelanta. Coste: cero consultas. + +--- + +## 7. Versiones y compatibilidad + +### 7.1 Contrato del grafo: `2.3.0` → `2.4.0` (MINOR) + +| Cambio | Categoría según `SCHEMA_VERSIONING.md` | Bump | +| :--- | :--- | :--- | +| `accessState` — nuevo campo de primer nivel, enumeración cerrada | «Add new top-level section» | MINOR | +| `context.requestedSystem` — nuevo campo, nulable | «Add optional field» | MINOR | +| `profiles[].id` — de opcional a siempre presente | Aditivo **para el consumidor**: recibe un campo que antes podía faltar | MINOR | + +**Es MINOR y no MAJOR** porque ningún consumidor de `2.0.0`–`2.3.0` deja de funcionar: no se elimina ni se renombra nada, no se estrecha ningún tipo y no cambia ninguna semántica existente. El rango de compatibilidad de los SDK **no se mueve**: sigue `[2.0.0, 3.0.0)`. + +**Ojo con `profiles[].id`:** pasa a la lista `required` del sub-esquema `ProfileOption`. Endurecer `required` en la **salida** de un productor único es aditivo para quien lee. Si algún día hubiera un segundo productor del grafo, dejaría de serlo. + +Trabajo asociado, según el flujo por cambio de `SCHEMA_VERSIONING.md`: + +1. `src/libs/sdk/contracts/auth-graph.schema.json` — `schemaVersion.const` a `2.4.0`; `accessState` con su `enum` en `required` de primer nivel; `requestedSystem` en `$defs` del contexto; `id` en `required` de `ProfileOption`. +2. `src/libs/sdk/contracts/error-codes.yaml` — sin códigos nuevos en la autenticación (§5.4 no introduce errores). **Sí** uno para el cambio de perfil de satélite (§8): el primer libre es `AUTH_036`. +3. Fixtures (§7.4). +4. Entrada en la matriz de compatibilidad y en el historial de `SCHEMA_VERSIONING.md`. +5. Prueba de contrato `AuthGraphPayloadTests` — ya compara la proyección real contra las claves del esquema; **debe fallar** hasta que ambos lados se actualicen. Es la red que evita la recaída de G-167. + +### 7.2 API REST: sigue en `v1` + +**Sin ruta `v2` y sin cabecera de versión nueva.** Todo lo de este diseño es aditivo sobre `/api/v1`: + +* `systemCode` es un campo **opcional** del cuerpo; un cliente que no lo envíe obtiene exactamente el comportamiento de hoy. +* `POST /api/v1/client/switch-profile` (§8) es un **recurso nuevo**; nadie lo consumía. +* Ningún campo de respuesta cambia de nombre, tipo ni significado. + +Versionar la API aquí obligaría a mantener dos superficies para una diferencia de un campo opcional, y a que el Tablero eligiera versión antes de existir el problema que la justifica. + +### 7.3 SDK + +**TypeScript** (`src/libs/sdk/typescript/`): + +| Archivo | Cambio | +| :--- | :--- | +| `sdk-contracts/src/schema-version.ts:6` | `Current: '2.3.0'` → `'2.4.0'`. Rango **sin tocar** | +| `sdk-contracts/src/auth-graph.ts` | `AuthorizationGraph` gana `readonly accessState: 'Granted' \| 'NoProfileInSystem' \| 'OnboardingPending'`; `GraphContext` gana `requestedSystem: { code: string } \| null`; `ProfileOption.id?: string` pasa a `readonly id: string` (`:121`) | +| `sdk-client/src/types.ts:3-8` | `ClientAuthRequest` gana `readonly systemCode?: string` | +| `sdk-client/src/client.ts` | Método `switchProfile()` (§8) | + +**.NET** (`src/libs/sdk/dotnet/`): + +| Archivo | Cambio | +| :--- | :--- | +| `Ums.Sdk.Contracts/SchemaVersion.cs` | `Current` → `2.4.0`. Rango sin tocar | +| `Ums.Sdk.Contracts/AuthorizationGraph.cs` | `AccessState` (string, `[JsonPropertyName("accessState")]`); `RequestedSystem` nulable en el contexto; `ProfileOption.Id` (`:113`) de `Guid?` a `Guid` **no nulable, y deja de ser el último parámetro con valor por defecto** | +| `Ums.Sdk.Client/ClientAuthRequest.cs` | Nuevo `[property: JsonPropertyName("systemCode")] string? SystemCode = null` | +| `Ums.Sdk.Client/IUmsAuthClient.cs` + `UmsAuthClient.cs` | `SwitchProfileAsync` (§8) | + +> **Defecto preexistente que este cambio destapa y hay que arreglar en la misma ola.** Los dos clientes del SDK tipan `ClientAuthResult.graph` como **objeto** (`AuthorizationGraph`), y la API lo devuelve como **cadena serializada** — verificado en vivo: `type(graph) == str`, 10 172 caracteres. Por eso `client.ts:62` (`parsed.graph?.schemaVersion`) evalúa a `undefined` y **todo login vía SDK TypeScript falla con `AuthGraphSchemaMissing`**; el equivalente .NET falla en `UmsAuthClient.cs:75`. Es el mismo defecto que dejó al Tablero en 502. **Ningún SDK ha ejercido nunca el endpoint real.** El arreglo: `graph` se tipa `string` en el DTO de transporte y el cliente lo deserializa según `format` antes de validar `schemaVersion`. Se registra como hallazgo (§11). + +### 7.4 Fixtures + +**Estado verificado hoy: los 12 fixtures de `src/libs/sdk/contracts/fixtures/` traen `profiles: []` con `onboardingPending: false`** — una combinación que el servidor **no puede producir**: cero perfiles implica grafo lobby, que fija `onboardingPending: true`. Los golden fixtures codifican un estado imposible y **no ejercen el bloque `profiles` en absoluto**. Por eso la ausencia del `id` (§5.3) pasó inadvertida hasta hoy. + +Trabajo: + +1. **Los 12 existentes** suben a `"schemaVersion": "2.4.0"` (salvo los tres que fijan versión a propósito: `schema-minor-ahead`, `schema-missing`, `schema-unsupported-major`) y ganan `"accessState": "Granted"` y `"requestedSystem": null`. +2. **Corregir la incoherencia**: o `profiles` deja de estar vacío, o `accessState`/`onboardingPending` reflejan el lobby. Recomendado lo primero — un fixture con un perfil y su `id` es el que habría detectado §5.3. +3. **Tres fixtures nuevos**, que son el criterio de correcto de este diseño: + +| Fixture | Qué fija | +| :--- | :--- | +| `system-filtered-single-profile.json` | `systemCode` pedido, 1 candidato, `accessState: "Granted"`, `requestedSystem.code` == `context.systemSuite.code` | +| `system-filtered-multi-profile.json` | `systemCode` pedido, **2 candidatos del mismo sistema y distinto rol**, exactamente uno `isCurrent`, ambos con `id`, orden por jerarquía | +| `no-profile-in-system.json` | `accessState: "NoProfileInSystem"`, `onboardingPending: false`, `profiles: []`, `systemSuite: null`, `requestedSystem.code` presente | + +4. **Un fixture capturado de la API real**, no escrito a mano, para el carril de contrato del Tablero. La regla de `ADR-0156` §2.7 es vinculante: una prueba no puede fabricar el contrato que verifica. + +### 7.5 Consumidores que hay que mover en el mismo cambio + +| Consumidor | Dónde | Estado hoy | Acción | +| :--- | :--- | :--- | :--- | +| Arnés RoboSoft | `src/tests/e2e-functional/robosoft/contexts/configuration.py:418-420` | Pinea `schema_version == "2.2.0"` — **ya está desfasado**, el servidor emite `2.3.0` | A `2.4.0` y añadir `accessState` a las claves de primer nivel esperadas | +| Contrato vendorizado del Tablero | `server/src/lib/ums-contracts.js` (repo `evolith-core`) | `SCHEMA_VERSION` actual `2.3.0`, rango `[2.0.0, 3.0.0)` | Actual a `2.4.0`. **El rango no cambia, así que no hay ruptura**: un Tablero sin actualizar sigue aceptando el grafo | +| SPA de UMS | `useGraphNavigation` / `useShellNavigation` (D-029, D-030) | Ignora `accessState` | Ninguna obligatoria. Opcional: usarlo en vez de `onboardingPending` | + +--- + +## 8. Cambio de perfil por el carril de satélite + +### 8.1 Por qué hace falta un adaptador y no basta el endpoint existente + +**Verificado el 2026-08-02:** `POST /api/v1/auth/switch-profile` con el portador semántico de `/client/authenticate` devuelve **`401`**. La causa está en `AuthEndpoints.LeerTokenDeGrafo` (`:673-707`): exige que `sub` sea un **GUID** y que exista el claim **`tenant_id`**. El token semántico lleva `sub` = correo del usuario y `tenant_code` = `BEYONDNET`, **sin** `tenant_id` — porque evita identificadores internos a propósito. + +Es decir: hoy el mecanismo de cambio de perfil **existe pero es inalcanzable desde un satélite**. Reutilizarlo, que es lo que el cliente pide, exige hacerlo alcanzable. + +### 8.2 Decisión: `POST /api/v1/client/switch-profile` + +Un endpoint nuevo **en el grupo `/client`**, que **reutiliza `SwitchProfileCommand` y `BuildForProfileAsync` sin tocarlos**. Lo único nuevo es el adaptador HTTP. + +```jsonc +POST /api/v1/client/switch-profile +Authorization: Bearer +Content-Type: application/json + +{ + "profileId": "9b71…", // obligatorio · sale de graph.profiles[].id + "systemCode": "SDLC", // opcional · guarda de coherencia (§8.4) + "format": "JSON" // opcional · mismo contrato que /client/authenticate +} +``` + +**Respuesta: el mismo envoltorio que `/client/authenticate`** (`ClientAuthResponse`: `token`, `tokenType`, `expiresIn`, `issuedAt`, `format`, `graph`, `requestId`), con el grafo del perfil nuevo. Que la forma sea idéntica no es cosmética: el satélite reutiliza tal cual el código que ya tiene para inicializar tras el login. + +**Política:** `UmsAuthPolicies.Satelite` — la misma que `GET /client/graph` (`ClientAuthEndpoints.cs:57`), que fija el esquema **portador**. La resolución de usuario e inquilino usa `ResolverUsuarioDelTokenAsync` (`ClientAuthEndpoints.cs:131-152`), que ya sabe resolver tanto un `sub` GUID como un `sub` semántico por correo dentro del inquilino. + +**No reescribe ninguna cookie.** El satélite no tiene sesión de cookie en UMS y no debe recibir una. + +### 8.3 Por qué no se extiende `/auth/switch-profile` en su lugar + +Tres razones, y la primera es de seguridad: + +1. Ese endpoint **valida el token a mano** con `ValidateIssuer = false`, `ValidateAudience = false` y `ClockSkew` de 5 minutos ([G-201](../../GAPS.md)). Encaminar el carril de satélite por ahí lo haría entrar por **la puerta más floja de la API** justo cuando lo que se quiere es acotarlo. +2. Devuelve `LoginSuccessResponse` —forma del portal— y **reescribe la cookie de sesión** (`AuthEndpoints.cs:636`). Un satélite tendría que aprender una segunda forma de respuesta y recibiría una cookie que no puede ni debe usar. +3. Habría que cambiar los tres puntos anteriores en un endpoint que el portal ya usa en producción. **Un adaptador nuevo sobre el mismo comando cambia menos.** + +`/auth/switch-profile` **no se toca** en esta ola. `G-201` sigue abierto y su cierre es independiente. + +### 8.4 Reglas de validación, en orden + +| # | Regla | Fallo | Nota | +| ---: | :--- | :--- | :--- | +| 1 | Portador válido y no expirado | `401` | La da la política `Satelite` | +| 2 | El token identifica usuario e inquilino | `401` `AUTH_020` | Mismo criterio que `/client/graph` (`:81-87`) | +| 3 | El perfil existe | `404` `AUTH_020` | **Mismo código y mensaje que la regla 4** | +| 4 | El perfil pertenece al usuario **y** a su inquilino | `404` `AUTH_020` | Distinguirla de la 3 convertiría el endpoint en un detector de perfiles ajenos | +| 5 | El perfil está activo | `409` `AUTH_021` | Ya lo comprueba `SwitchProfileCommandHandler` | +| 6 | Si viene `systemCode`, el perfil pertenece a ese sistema | `409` `AUTH_036` (nuevo) | Guarda de coherencia (§8.5) | + +Las reglas 3 y 4 **colapsan en la misma respuesta**, igual que ya hace `GET /client/graph` (`:99-107`). Sin ese colapso, un `404` frente a un `403` diría a cualquiera con un portador válido si un `profileId` dado existe en el sistema. + +### 8.5 La guarda de sistema + +`systemCode` es opcional y el Tablero **lo envía siempre**. Su función: impedir que un satélite acabe operando con el grafo de otro sistema si alguna vez recibe un `profileId` que no salió de su propio grafo. Como `profiles` ya viene acotado (§4.2), en el flujo normal esta guarda **nunca dispara** — que es exactamente lo que se espera de una guarda. No es redundante: hace que la propiedad «el Tablero solo ve `SDLC`» la garantice el **servidor**, no la disciplina del cliente. + +### 8.6 El token anterior no se revoca + +Sin cambio respecto de [`diseno-cambio-de-perfil.md`](./diseno-cambio-de-perfil.md) §6, y por la misma razón: `ITokenRevocationStore` revoca **por usuario y ventana de tiempo**, no por token, así que revocar aquí dejaría al usuario fuera inmediatamente después de cambiarse —incluido el token recién emitido—. No hay escalada: el usuario poseía legítimamente ambos perfiles. + +**El satélite sí debe descartar su copia anterior**: el token nuevo sustituye al viejo en la cookie de sesión del Tablero y la entrada de caché del grafo se **reemplaza**, no se añade. Mantener las dos vivas es tener dos autorizaciones simultáneas en un proceso que solo tiene una sesión. + +### 8.7 Auditoría + +Evento `Auth.Profile.Switch` con perfil de origen y de destino, y el sistema de cada uno. Ya lo emite el manejador; el adaptador no lo altera. + +--- + +## 9. Datos de prueba: hoy el caso central no es reproducible + +**Verificado:** los 13 perfiles de la instancia corresponden a 13 usuarios distintos. **Ningún usuario tiene dos perfiles.** El caso `N ≥ 2` —el que obliga al selector, el corazón de este diseño— **no puede probarse con la siembra actual**. + +La siembra debe ganar, coherente con el dominio logístico y sin puertas traseras (`SeedDevData && !IsProduction`): + +| Caso | Sujeto propuesto | Para qué | +| :--- | :--- | :--- | +| Dos perfiles, mismo sistema, distinto rol | un usuario de `SIL` con `ANALISTA_DOC` y `AUDITOR` | `N ≥ 2` intra-sistema, que es la forma exacta que el cliente precisó | +| Dos perfiles, distinto sistema | un usuario con `SIL` y `WMS` | Multiproducto sin `systemCode`, y filtrado con él | +| Dos perfiles, mismo rol, distinta sucursal | `JEFE_ALMACEN` en Callao y en Paita | Que el selector sea distinguible solo por sucursal | +| Perfiles y `systemCode` que no casan | cualquiera pidiendo `SDLC` | `NoProfileInSystem` sin delatar el catálogo | + +**Incógnita declarada:** la suite `SDLC` **no está cargada** en la instancia (`GET /api/v1/system-suites` devuelve `ADUANAS`, `WMS`, `FACTURACION`, `PORTAL_CLIENTE`, `SIL`, `TMS`). Mientras siga así, `systemCode: "SDLC"` cae siempre en `NoProfileInSystem`, que es **el comportamiento correcto** pero no permite probar el camino `Granted` del Tablero. + +--- + +## 10. Alcance y orden + +**Dentro (primera ola):** + +1. `systemCode` en `ClientAuthRequest`, `AuthenticateUserCommand` y `IAuthorizationGraphBuilder.BuildAsync` (§3.1, puntos 1–3). +2. Filtro y adelanto de los resúmenes de suite en `AuthorizationGraphBuilderService` (§4.2), con logger inyectado (§4.3). +3. Corrección del segundo criterio de desempate a código de sistema (§6). +4. `accessState`, `context.requestedSystem` y `profiles[].id` siempre presente (§5). +5. Contrato a `2.4.0`, esquema, fixtures, ambos SDK, RoboSoft, contrato vendorizado del Tablero (§7). +6. **Arreglo del tipo de `graph` en ambos SDK** (§7.3). No es opcional: sin él ningún SDK habla con el endpoint real. +7. `POST /api/v1/client/switch-profile` (§8). +8. Siembra de los cuatro casos multi-perfil (§9). + +**Fuera (segunda ola, con su propia decisión):** + +* `systemCode` en `POST /api/v1/auth/login` (§3.1, punto 4). Cierra la mitad de [G-184](../../GAPS.md) que depende del contrato — pero **no lo cierra entero**: el portal necesitaría un código de suite propio, y hoy no existe ninguna suite para el portal de UMS en el catálogo. Es una decisión de catálogo, no de contrato. +* Poblado derivado de `SystemSuiteId` para el enrutado de IdP (§4.4). +* Cierre de [G-201](../../GAPS.md) en `/auth/switch-profile`. +* Retirada de `onboardingPending` en favor de `accessState`, por el ciclo de deprecación. +* Refresco por portador, sin el cual la sesión del satélite muere con el token. + +--- + +## 11. Hallazgos para registrar + +**No se ha modificado [`GAPS.md`](../../GAPS.md) ni [`DECISIONS.md`](../../DECISIONS.md).** Ver §11 de [`analisis-integracion-e2e-ums-tablero-sdlc.md`](./analisis-integracion-e2e-ums-tablero-sdlc.md), donde se consolidan con dimensión, criticidad y complejidad según `S-20`. + +--- + +

+ © BeyondNet S.A.C. · RUC 20100412447 · Operador Logístico Aduanero desde 1978 +

+ diff --git a/docs/architecture/technical-debt.md b/docs/architecture/technical-debt.md index d7b177d7..7b3cf26c 100644 --- a/docs/architecture/technical-debt.md +++ b/docs/architecture/technical-debt.md @@ -27,17 +27,14 @@ ## [TD-002] GraphQL Queries Return Empty Data for AppConfigurations -- **Status**: Acknowledged +- **Status**: Acknowledged — **workaround withdrawn, needs re-measuring** - **Severity**: Medium - **Component**: `Ums.Presentation/GraphQL/Configuration/AppConfigurationQueries.cs`, `Ums.Application/Configuration/AppConfiguration/Queries/GetAllAppConfigurationsQueryHandler.cs` -- **Description**: GraphQL endpoint for AppConfigurations (`appConfigurations` query) returns empty items array with `totalItems: 0` despite REST endpoint returning correct data (7 items). The handler executes successfully (~25ms) without errors, but the result is empty. Other GraphQL queries (e.g., `tenants`) work correctly. -- **Rationale**: Investigation did not reveal root cause. Same handler logic works for REST, same repository returns data for REST but not for GraphQL. Possible HotChocolate DataLoader or resolver scope issue. -- **Impact**: - - Frontend falls back to REST due to `FRONTEND_CONFIG_TRANSPORT = "rest"` flag. - - GraphQL remains non-functional for AppConfiguration bounded context. - - Reduced flexibility for frontend data fetching choices. -- **Workaround**: Use `FRONTEND_CONFIG_TRANSPORT = "rest"` flag in BD (current default). -- **Target Resolution**: Investigate HotChocolate resolver execution context, DataLoader caching, or any middleware that could affect GraphQL resolution differently from REST. +- **Description**: The `appConfigurations` GraphQL query returned an empty `items` array with `totalItems: 0` while the REST endpoint returned the same data correctly. The handler completed in ~25 ms without error; the result was simply empty. Other GraphQL queries (e.g. `tenants`) behaved normally. +- **Rationale**: The investigation never found a root cause. Same handler, same repository — data for REST, nothing for GraphQL. Suspected a HotChocolate DataLoader or resolver-scope issue. +- **What changed (2026-08-09 resync)**: the escape hatch this entry relied on **no longer exists**. The `FRONTEND_CONFIG_TRANSPORT` parameter and the `query-transport.service` that read it were withdrawn along with the web app's REST/GraphQL switch; the imported data-access layer calls REST directly and `branding.service` is the only consumer left on the GraphQL client. So there is no flag to set, and no fallback to fall back *from* — but also no live symptom, because nothing routes AppConfiguration reads through GraphQL any more. +- **Impact today**: latent, not observed. The GraphQL query still exists and is still registered in the schema; if a client starts using it, the original defect — whatever it is — is presumably still there. +- **Next step is measurement, not investigation**: query `appConfigurations` directly against a seeded environment and see whether it still returns empty. If it does, the entry stands and the DataLoader/resolver-scope hypothesis is the place to start. If it does not, close it — something along the way fixed it, and carrying a phantom is worse than carrying nothing. - **Related Files**: - `src/apps/ums.api/Ums.Presentation/GraphQL/Configuration/AppConfigurationQueries.cs` - `src/apps/ums.api/Ums.Application/Configuration/AppConfiguration/Queries/GetAllAppConfigurationsQueryHandler.cs` @@ -65,3 +62,74 @@ - **Related Documents**: - [Parameterization System Specification](../governance/construction/ddd-design/parameterization-system-spec.md) - [TODO-003](../governance/project/TODO.md#todo-003-implement-parameterization-system-with-loader-and-provider) + +--- + +## [TD-004] The Web App Typecheck Has Never Actually Run + +- **Status**: Confirmed +- **Severity**: High +- **Component**: `src/apps/ums.web-app/tsconfig.app.json`, `src/apps/ums.web-app/tsconfig.node.json` +- **Description**: Both configs declare `tsBuildInfoFile` without `incremental` or `composite`. That combination is a **configuration error**, not a warning: `tsc` reports `TS5111` and exits *before* type-checking anything. Running `tsc --noEmit -p tsconfig.app.json` on a file containing a deliberately undefined identifier reports nothing and exits 0. The project believes it has a typecheck and does not have one. +- **How it surfaced**: During the 2026-08-09 resync a `useCallback` import was omitted from three components. The "typecheck" passed; **vitest** caught it at render time with `ReferenceError: useCallback is not defined`. A second, worse case slipped even further: a helper extracted into the wrong scope (`renderValue` declared inside an `if (!config)` guard while the consumer lived outside it) passed both the typecheck and the build, and would have been a runtime `ReferenceError` on opening the panel. No test covers that panel. +- **Inherited, not introduced**: `unimar-ums` carries the identical `tsconfig`, so its type errors are hidden the same way. This is not a regression of the resync. +- **Impact**: + - `vite build` uses esbuild and does **not** type-check, so nothing in the local loop catches type errors. + - CI never runs `tsc` either: the `build` script is `vite build`. + - Enabling `"incremental": true` surfaces **545 pre-existing type errors**, several of them substantive — e.g. `Property 'menus' does not exist` on the authorization-graph node type, which suggests the graph schema (`nodes`) and its consumers (`menus`) have drifted apart. +- **Why it is not fixed here**: Landing 545 errors inside the resync PR without a triage plan would bury them. The fix is one line; the work is the backlog behind it. +- **Suggested resolution**: + 1. Add `"incremental": true` to both configs and capture the full error list as a baseline. + 2. Triage it — the `menus` vs `nodes` drift in the authorization graph is the first thing to look at, since it is the code path that decides what a user can see. + 3. Add a `typecheck` script and wire it into CI so the gate stops being decorative. + 4. Report the same defect upstream: the source platform has it too. +- **Related**: the three-component `useCallback` omission and the `renderValue` scope bug are both fixed in the resync branch. + +--- + +## [TD-005] Notification Bodies Are Logged in Cleartext, Unconditionally + +- **Status**: Confirmed +- **Severity**: High +- **Component**: `Ums.Infrastructure/Services/Notifications/SimulatedNotificationAdapter.cs`, `Ums.Infrastructure/DependencyInjection.cs:88` +- **Description**: `SimulatedNotificationAdapter` is the **only** implementation of `INotificationService`, and it is registered with `services.AddScoped()` with **no environment gate**. Its `SendAsync` writes the full notification — recipient, subject and **body** — at `LogInformation`. Notification bodies carry password-reset links, approval tokens and account-activation URLs. Wherever this runs, those land in the log sink (Loki) in cleartext, readable by anyone with log access. +- **How it surfaced**: CodeQL `cs/cleartext-storage-of-sensitive-information` on the 2026-08-09 resync PR. It is one of three high-severity alerts; the other two are not real (see below). +- **Inherited, not introduced**: the adapter and its unconditional registration come from the source platform. +- **Impact**: + - A password-reset link in a log is a working credential for whoever reads the log, for as long as the token lives. + - Log retention outlives token lifetime, so the exposure window is the retention window. + - There is no real adapter to fall back to: nothing actually delivers notifications today, so removing the logging without a replacement would make the flows undebuggable. +- **Suggested resolution**: + 1. Gate the registration on `IHostEnvironment.IsDevelopment()` so the simulator cannot reach production. + 2. Register a no-op (or a hard failure) outside development, so an unimplemented delivery path is loud rather than silent. + 3. Drop `Body` from the log line, or redact it; recipient and subject are enough to trace a flow. + 4. Implement a real adapter before any environment sends notifications for real. +- **Assessment of the other two high-severity CodeQL alerts on the same PR** (both **not** actionable): + - `js/insufficient-key-size` in `sdk-authorization/tests/verificacion-de-firma.test.ts`: the 1024-bit RSA key is generated **on purpose**, to assert the SDK discards weak keys from a JWKS. The test is doing its job. + - `cs/user-controlled-bypass` in `AuthEndpoints.cs`: the `is_internal_admin` claim does drive an authorization decision, but the token's signature is verified first (`ValidateIssuerSigningKey = true`), so the claim is trustworthy. Worth noting separately that `ValidateIssuer` and `ValidateAudience` are both `false`, so any token signed with the same secret passes regardless of who issued it or for whom — a narrower concern, tracked here rather than as its own item. + +--- + +## [TD-006] Migrating a Fresh PostgreSQL Database From Scratch Fails + +- **Status**: Confirmed +- **Severity**: Medium +- **Component**: `Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260607033700_UpdatePostgresMassTransitOutbox.cs`, `…/20260607044815_AddPgCryptoExtension.cs` +- **Description**: `20260607033700_UpdatePostgresMassTransitOutbox` uses `gen_random_bytes(...)`, a **pgcrypto** function. The migration that declares the `pgcrypto` extension is `20260607044815_AddPgCryptoExtension` — over an hour **later** in the ordering. On an empty database the migrator therefore reaches the first migration before the extension exists and aborts with `42883: function gen_random_bytes(integer) does not exist`. The API never finishes starting. +- **How it surfaced**: Bringing up the local stack against a clean `UmsDev` during the 2026-08-09 resync verification. Workaround used for that verification: `CREATE EXTENSION IF NOT EXISTS pgcrypto;` by hand before first start. +- **Inherited, not introduced**: the source platform has the same two migrations, in the same order, with the same `gen_random_bytes` usage. +- **Why nobody hits it**: existing environments already have the extension installed from when the migrations were first applied out of band, and the test suites use the EF InMemory provider, so no pipeline ever migrates an empty PostgreSQL. +- **Impact**: a new environment — a fresh developer machine, a new namespace, a disaster-recovery restore into an empty instance — cannot start the API without a manual step that is not documented anywhere. +- **Suggested resolution**: move the extension declaration to the first migration that needs it (or add an idempotent `CREATE EXTENSION IF NOT EXISTS pgcrypto;` at the top of `UpdatePostgresMassTransitOutbox`), then verify by migrating a genuinely empty database in CI. A pipeline step that does exactly that is the only thing that keeps this from coming back. + +--- + +## [TD-007] The Dev Connection String Points at a Port the Compose File Does Not Publish + +- **Status**: Confirmed +- **Severity**: Low +- **Component**: `Ums.Presentation/appsettings.Development.json`, `src/infra/local/compose/docker-compose.yml` +- **Description**: `appsettings.Development.json` connects to `Host=localhost;Port=5433`, while the local compose publishes PostgreSQL on `5432:5432`. Running the API against the documented local stack fails to connect until the port or the connection string is overridden by hand. The passwords disagree too: `root` in appsettings, `Your_password123` in compose. +- **Inherited, not introduced**: identical in the source platform. +- **Impact**: the documented "start the local stack, run the API" path does not work as written. Everyone who has it working has an undocumented local override. +- **Suggested resolution**: pick one port and one password and make both files agree; the compose file is the better source of truth because CI and the container path already use it. diff --git a/docs/architecture/threat-model.es.md b/docs/architecture/threat-model.es.md new file mode 100644 index 00000000..c8598f49 --- /dev/null +++ b/docs/architecture/threat-model.es.md @@ -0,0 +1,203 @@ +# Modelo de Amenazas y Gestión de Secretos — ums + +> **Estado:** Adoptado | **Propietario:** BeyondNet S.A.C. | **Reglas:** S-06, SD-08 +> **Versión:** 1.0.1 · **Fecha:** 2026-07-14 · **Cierra:** [G-001](../../GAPS.md) + +Modelo de amenazas del satélite **ums**, un sistema de autenticación y +autorización (IAM). Un IAM concentra los activos más sensibles de la plataforma, +por lo que su superficie de ataque se analiza explícitamente. Se usa la +metodología **STRIDE** (Spoofing, Tampering, Repudiation, Information disclosure, +Denial of service, Elevation of privilege) sobre los actores, activos y fronteras +de confianza definidos en el [PRD](../01-concepcion/PRD-UMS-001.es.md). + +## 1. Alcance y Metodología + +Se analiza el backend .NET (API + dominio + mensajería), el frontend React y las +fronteras con sistemas externos (IdP, cliente API, productor MMS). Por cada +categoría STRIDE se listan las amenazas relevantes, el **control existente** +(derivado del código y la documentación de dominio) y la **brecha o mitigación +pendiente**. Los controles se trazan a requisitos (`FR/NFR` del PRD) y a los ADRs +del corpus importado; su retrazado a ADRs aceptados de `evolith-core` es deuda +([G-012](../../GAPS.md)). + +## 2. Actores y Fronteras de Confianza + +```mermaid +flowchart TB + subgraph Publico["Zona no confiable (Internet)"] + U["Usuario final"] + CLI["Sistema cliente / API externa"] + IDP["IdP externo"] + end + subgraph Gateway["Frontera: API Gateway / BFF"] + GW["Gateway (rate limit, timeouts)"] + end + subgraph App["Zona confiable (aplicación)"] + API["UMS API"] + PORTAL["Portal de gestión
(solo auth local)"] + end + subgraph Datos["Zona de datos (aislada por inquilino)"] + DB["PostgreSQL
(global query filters)"] + AUD["Auditoría (append-only)"] + SEC["Secretos / llaves"] + end + + U --> GW --> API + CLI --> GW + IDP -.credenciales federadas.-> API + API --> DB + API --> AUD + API -.referencia.-> SEC + ADMIN["Admin interno
cross-tenant"] --> PORTAL --> API + + style Publico fill:#fdecea,stroke:#c0392b,color:#000 + style Datos fill:#e8f5e9,stroke:#2e7d32,color:#000 + style SEC fill:#fff3e0,stroke:#e65100,color:#000 +``` + +**Fronteras de confianza principales:** + +* **Internet → Gateway:** todo tráfico externo pasa por el gateway (límites de + complejidad, timeouts, rate limiting). +* **API externa vs Portal de gestión:** el portal interno usa _siempre_ + autenticación local (nunca IdP), aunque el inquilino tenga federación + (`AuthAccessScope.PortalManagement`). +* **Aislamiento por inquilino:** frontera lógica dentro de la zona de datos; + ningún actor lee u opera datos de otro inquilino salvo `INTERNAL_ADMIN`, y + siempre auditado. +* **Frontera con el IdP externo:** UMS delega la verificación de credenciales pero + no confía ciegamente en los claims. + +## 3. Activos y Clasificación + +| Activo | Sensibilidad | Control base | +| :--- | :--- | :--- | +| Contraseñas (hash BCrypt) | Crítica | Hash en API, columna cifrada, nunca en logs/grafo (FR-010) | +| Tokens de sesión / Grafo de Autorización | Crítica | Vigencia acotada, sin secretos en el grafo (FR-034) | +| PII de usuario | Alta | Redacción en logs, anonimización en borrado (FR-004) | +| Traza de auditoría | Alta | Append-only, no repudio, sin UPDATE/DELETE (FR-070) | +| Secretos de IdP / llaves de cifrado | Crítica | Indirección por `SecretRef`; ver §5 | +| Configuración por inquilino | Media | Cifrado opcional (`IsEncrypted`), scope controlado | + +## 4. Análisis STRIDE + +### 4.1 Spoofing (suplantación de identidad) + +* **Amenazas:** robo de credenciales, replay de tokens, suplantación de IdP, + falsificación del claim `is_internal_admin`. +* **Controles existentes:** BCrypt para contraseñas locales; MFA multi-método + (FR-014); resolución dinámica del método de auth (FR-013); validación del claim + interno en `switch-tenant`. +* **Brechas / mitigación:** definir política de rotación y bloqueo por intentos + fallidos; validar `issuer`/`audience`/firma de los tokens del IdP; considerar + binding de sesión. → acciones de seguimiento en [G-001](../../GAPS.md). + +### 4.2 Tampering (manipulación) + +* **Amenazas:** alteración de permisos en tránsito, manipulación del Grafo de + Autorización, corrupción de configuración o de la traza. +* **Controles existentes:** grafo inmutable y autocontenido (FR-034); guardas de + dependencia; Result Pattern; validación de integridad de documentos por checksum + (FR-052); auditoría append-only. +* **Brechas / mitigación:** firmar el Grafo de Autorización entregado al cliente + para detección de manipulación; integridad de mensajes en el bus. + +### 4.3 Repudiation (repudio) + +* **Amenazas:** un actor niega una acción sensible (cambio de rol, aprobación). +* **Controles existentes:** traza inmutable no repudiable con actor, instante y + resultado (FR-070); `UPDATE/DELETE` de auditoría denegado en cadenas estándar; + control anti-colusión en aprobaciones (FR-050); SoD en IGA (FR-062). +* **Brechas / mitigación:** proteger la llave de emergencia que sí puede mutar + auditoría; considerar sellado criptográfico de la cadena de auditoría. + +### 4.4 Information Disclosure (divulgación) + +* **Amenazas:** fuga de PII, hashes o secretos por logs, proyecciones, el grafo o + lecturas cross-tenant. +* **Controles existentes:** PII redactada en logs (Serilog seguro); `PasswordHash` + nunca en logs/proyecciones/grafo; sin GUIDs crudos en la UI; aislamiento por + inquilino mediante _global query filters_ de EF Core sobre `OrganizationId` + (primario y suficiente; el _failsafe_ RLS a nivel de BD no está activo con + PostgreSQL, [G-020](../../GAPS.md)) (FR-022, NFR-Multitenancy); desinfección de + metadatos de auditoría (FR-072). +* **Brechas / mitigación:** revisión sistemática de los DTO de salida para PII; + cifrado en tránsito (TLS) exigido por política; pruebas automatizadas de + aislamiento cross-tenant. + +### 4.5 Denial of Service (denegación de servicio) + +* **Amenazas:** saturación del gateway, consultas REST de lectura costosas, tormenta + de eventos, agotamiento de conexiones. +* **Controles existentes:** límites de complejidad, timeouts y rate limiting en el + gateway (NFR-Rendimiento); pipeline interno de baja latencia para el grafo; + idempotencia ante reintentos (NFR-Confiabilidad). +* **Brechas / mitigación:** cuotas por inquilino; circuit breakers hacia el IdP; + backpressure en el consumidor de eventos. + +### 4.6 Elevation of Privilege (elevación de privilegios) + +* **Amenazas:** un usuario obtiene permisos que no le corresponden; un admin de + inquilino opera cross-tenant; promoción de rol sin control. +* **Controles existentes:** precedencia de permisos `Deny > Allow` con denegación + implícita (FR-035); cross-tenant restringido a `INTERNAL_ADMIN` y auditado + (FR-022); máquina de estados de promoción con revisión de seguridad y análisis + de riesgo tóxico (FR-060, FR-061); SoD (FR-062); feature flags _fail-closed_ + (FR-041). +* **Brechas / mitigación:** revisión periódica de accesos (certificaciones IGA); + principio de menor privilegio en los roles semilla; pruebas de autorización + negativas en el CI. + +## 5. Gestión de Secretos + +**Estado actual:** + +* Los secretos de IdP se referencian por indirección (`SecretRef`), no se + almacenan inline en la configuración. +* Los valores de configuración sensibles se cifran en reposo + (`IsEncrypted` + servicio de cifrado AES). +* Las contraseñas se guardan como hash BCrypt; nunca se exponen. +* Las credenciales de infraestructura (cadena de conexión, Redis) y los secretos + de CI se inyectan por entorno/secretos, nunca al repositorio (`.env` en + `.gitignore`; el CI usa el secreto `STANDARD_TOKEN`). + +**Objetivo (mitigación pendiente):** + +* Centralizar los secretos y las **llaves de cifrado** en un gestor dedicado + (vault / KMS), con rotación y auditoría de acceso, en vez de depender solo de + variables de entorno. +* Gestión del ciclo de vida de la llave AES que protege la configuración cifrada. +* Trazar la decisión de gestión de secretos a un ADR aceptado de `evolith-core`. + +## 6. Trazabilidad de Controles + +| Categoría | Controles (FR/NFR) | ADRs de referencia | +| :--- | :--- | :--- | +| Spoofing | FR-010, FR-013, FR-014 | ADR-UMS-072 | +| Tampering | FR-034, FR-052 | ADR-UMS-088, ADR-UMS-079 | +| Repudiation | FR-070, FR-050, FR-062 | ADR-UMS-052 | +| Info. Disclosure | FR-004, FR-022, FR-072 | ADR-UMS-062, ADR-UMS-065 | +| Denial of Service | NFR-Rendimiento, NFR-Confiabilidad | ADR-UMS-063, ADR-UMS-080 | +| Elevation | FR-035, FR-060, FR-061 | ADR-UMS-088, ADR-UMS-075, ADR-UMS-077 | + +## 7. Riesgos Residuales y Acciones + +Este modelo consolida los controles existentes y nombra las mitigaciones +pendientes. Las de mayor alcance se gestionan como gaps: la gestión centralizada +de secretos y las pruebas automatizadas de aislamiento/autorización se abordan +junto con la estabilización de pruebas ([G-014](../../GAPS.md)) y la observabilidad +de seguridad ([G-004](../../GAPS.md)). El modelo se revisa cuando cambie un actor, +una frontera de confianza o un activo crítico. + +## Historial de Cambios + +| Versión | Fecha | Autor | Descripción | +| :--- | :--- | :--- | :--- | +| 1.0.1 | 2026-07-14 | BeyondNet S.A.C. | Alineación a API REST-only + PostgreSQL-only (D-007, D-008): DoS sin GraphQL, aislamiento por global query filters con RLS no activo (G-020) | +| 1.0.0 | 2026-07-13 | BeyondNet S.A.C. | Modelo de amenazas STRIDE inicial y política de gestión de secretos. Cierra G-001 | + +--- + +

+ © BeyondNet S.A.C. · RUC 20100412447 · Operador Logístico Aduanero desde 1978 +

diff --git a/docs/architecture/web-frontend/material-design3-uiux-standard.es.md b/docs/architecture/web-frontend/material-design3-uiux-standard.es.md index 1602b26e..b24b8dbe 100644 --- a/docs/architecture/web-frontend/material-design3-uiux-standard.es.md +++ b/docs/architecture/web-frontend/material-design3-uiux-standard.es.md @@ -122,6 +122,8 @@ Todas las pantallas React de frontend deben pasar esta lista de verificacion aut > [!IMPORTANT] > **Estrategia de transporte REST vs GraphQL:** > El frontend usa un modelo de transporte mixto. Algunos contextos delimitados son intencionalmente solo REST, mientras que otros siguen usando GraphQL para lecturas donde esa sigue siendo la implementación activa. -> - Las lecturas de configuración que hoy son solo REST se manejan mediante `httpClient`. +> - Las lecturas de configuración se manejan mediante `httpClient` (REST). Es el arreglo +> definitivo, no uno transitorio: el conmutador REST/GraphQL (`FRONTEND_CONFIG_TRANSPORT` y +> `query-transport.service`) se retiró en la resincronización de 2026-08-09. > - Los contextos delimitados que siguen respaldados por GraphQL permanecen activos en áreas como autorización e identidad. > - Cualquier migración futura de regreso a GraphQL debe evaluarse por contexto delimitado, no como una regla global para todo el frontend. diff --git a/docs/architecture/web-frontend/material-design3-uiux-standard.md b/docs/architecture/web-frontend/material-design3-uiux-standard.md index 30e4f625..d181fceb 100644 --- a/docs/architecture/web-frontend/material-design3-uiux-standard.md +++ b/docs/architecture/web-frontend/material-design3-uiux-standard.md @@ -122,6 +122,6 @@ All frontend React screens must pass this automated and manual checklist prior t > [!IMPORTANT] > **REST vs GraphQL Transport Strategy:** > The frontend uses a mixed transport model. Some bounded contexts are intentionally REST-only, while others still use GraphQL for reads where that remains the active implementation. -> - Configuration reads that are currently REST-only are handled through `httpClient`. +> - Configuration reads go through `httpClient` (REST). This is the settled arrangement, not a transitional one: the REST/GraphQL switch was withdrawn in the 2026-08-09 resync. > - GraphQL-backed bounded contexts remain in place for areas such as authorization and identity. > - Any future migration back to GraphQL should be evaluated per bounded context, not as a blanket frontend-wide rule. diff --git a/docs/architecture/web-frontend/ums-react-applied-reference.es.md b/docs/architecture/web-frontend/ums-react-applied-reference.es.md index 4badb3dd..5b442a10 100644 --- a/docs/architecture/web-frontend/ums-react-applied-reference.es.md +++ b/docs/architecture/web-frontend/ums-react-applied-reference.es.md @@ -46,6 +46,9 @@ Perfil de implementacion observado: | Puente Tailwind de tokens | `src/apps/ums.web-app/tailwind.config.js` expone colores semanticos `m3.*` mediante `hsl(var(--token))`. | Candidato boilerplate | | Frontera HTTP | `src/apps/ums.web-app/src/infrastructure/http/httpClient.ts` centraliza configuracion Axios, headers, CSRF y errores normalizados. | Patron de frontera reutilizable; headers son locales | | Request context | `src/apps/ums.web-app/src/infrastructure/http/request-context.ts` centraliza contexto de usuario e idioma y base URL por entorno. | Patron reutilizable; placeholder de tenant es deuda tecnica local | +| Forma del error en la frontera | `httpClient.ts` exporta `NormalisedApiError` y `asApiError(unknown)`. **No** valida: un error que viene de la red puede ser cualquier cosa, así que da acceso tipado a los sitios donde tiene sentido mirar (`normalised`, `response.data`, `message`) sin dejar que `any` se filtre al catch. | Patrón de frontera reutilizable | +| Estado visual de campo | `src/apps/ums.web-app/src/presentation/shared/components/field-state.ts` concentra la matriz foco/error de los campos: el foco manda en el grosor del borde, el error manda en el color. Lo comparten `M3TextField`, `M3Select`, `M3FieldsetWrapper`, `SearchableSelect` y `TenantSelect`. | Reutilizable; los nombres de token son locales | +| Búsqueda en el grafo de autorización | `src/apps/ums.web-app/src/application/authorization/utils/graph-lookup.ts` recorre el árbol de navegación **recursivo** (`menuAccess[].nodes[]`, ADR-0090) para resolver un nodo o una acción. La ausencia significa «no concedido», no «error». | Reutilizable; el contrato lo gobierna `@ums/sdk-contracts` | | Perfil de pruebas | `src/apps/ums.web-app/package.json` declara Vitest, Testing Library, MSW y Playwright. | Perfil candidato de quality gates | ## 4. Items que deben permanecer locales en UMS diff --git a/docs/architecture/web-frontend/ums-react-applied-reference.md b/docs/architecture/web-frontend/ums-react-applied-reference.md index 27bbdf8f..2f0f5aa6 100644 --- a/docs/architecture/web-frontend/ums-react-applied-reference.md +++ b/docs/architecture/web-frontend/ums-react-applied-reference.md @@ -46,6 +46,9 @@ Observed implementation profile: | Tailwind token bridge | `src/apps/ums.web-app/tailwind.config.js` exposes `m3.*` semantic colors through `hsl(var(--token))`. | Boilerplate candidate | | HTTP boundary | `src/apps/ums.web-app/src/infrastructure/http/httpClient.ts` centralizes Axios setup, request headers, CSRF, and normalized errors. | Reusable boundary pattern; headers are local | | Request context | `src/apps/ums.web-app/src/infrastructure/http/request-context.ts` centralizes user and language request context and environment-based API base URL. | Reusable pattern; tenant placeholder is local technical debt | +| Error shape at the boundary | `httpClient.ts` exports `NormalisedApiError` and `asApiError(unknown)`. It does **not** validate: an error off the network can be anything, so it gives typed access to the places worth looking (`normalised`, `response.data`, `message`) without letting `any` leak into the catch. | Reusable boundary pattern | +| Field visual state | `src/apps/ums.web-app/src/presentation/shared/components/field-state.ts` holds the focus/error matrix for form fields: focus decides border weight, error decides colour. Shared by `M3TextField`, `M3Select`, `M3FieldsetWrapper`, `SearchableSelect` and `TenantSelect`. | Reusable; token names are local | +| Authorization graph lookup | `src/apps/ums.web-app/src/application/authorization/utils/graph-lookup.ts` walks the **recursive** navigation tree (`menuAccess[].nodes[]`, ADR-0090) to resolve a node or an action. Absence means "not granted", not "error". | Reusable; contract is governed by `@ums/sdk-contracts` | | Testing profile | `src/apps/ums.web-app/package.json` declares Vitest, Testing Library, MSW, and Playwright. | Candidate quality gate profile | ## 4. Items that should remain UMS-local diff --git a/docs/domain-es/approvals/access-enforcement-policy.md b/docs/domain-es/approvals/access-enforcement-policy.md index 0e0e8613..172131e4 100644 --- a/docs/domain-es/approvals/access-enforcement-policy.md +++ b/docs/domain-es/approvals/access-enforcement-policy.md @@ -10,25 +10,30 @@ ## 1. Vista General del Agregado ### Propósito + El agregado raíz `AccessEnforcementPolicy` establece reglas generales de restricción de acceso a nivel de inquilino (tenant). Determina qué privilegios de acceso (como un perfil o mapeo de rol) se bloquean, degradan o restringen cuando un usuario entra en estado de incumplimiento (por ejemplo, falta de credenciales válidas obligatorias o documentos críticos vencidos). ### Responsabilidad de Negocio -- Definir bloqueos de acceso específicos asignados a componentes de autorización (perfiles o roles). -- Administrar la segregación multi-inquilino de las reglas de política específicas de cada inquilino. -- Mantener el control del ciclo de vida (activo frente a inactivo) sobre las ejecuciones de políticas. -- Forzar una validación estricta al cambiar los comportamientos de aplicación. + +* Definir bloqueos de acceso específicos asignados a componentes de autorización (perfiles o roles). +* Administrar la segregación multi-inquilino de las reglas de política específicas de cada inquilino. +* Mantener el control del ciclo de vida (activo frente a inactivo) sobre las ejecuciones de políticas. +* Forzar una validación estricta al cambiar los comportamientos de aplicación. ### Raíz del Agregado + `AccessEnforcementPolicy` actúa como una raíz de agregado independiente de `DocumentType` para separar las definiciones de credenciales de las reglas de reacción de seguridad. ### Invariantes y Reglas de Consistencia + 1. **INV-AEP1 (Restricción del Alcance de la Política):** Una política debe apuntar a un `ProfileId` o a un `RoleId` (o a ambos). Está prohibido crear una política sin especificar al menos un objetivo (`DomainErrors.Approvals.PolicyRequiresProfileOrRole`). 2. **INV-AEP2 (Desactivación del Ciclo de Vida):** Una política no se puede desactivar si ya está inactiva (`DomainErrors.Approvals.PolicyAlreadyInactive`). 3. **INV-AEP3 (Integridad del Inquilino):** Las políticas deben asignarse explícitamente a un `TenantId` para garantizar la seguridad de la partición de inquilinos. ### Entidades Relacionadas / Objetos de Valor + | Entidad / VO | Tipo | Descripción | -|---|---|---| +| --- | --- | --- | | `AccessEnforcementPolicyId` | Objeto de Valor | Identificador único del agregado | | `TenantId` | Objeto de Valor | Identificador del inquilino propietario | | `ProfileId` | Objeto de Valor | Perfil de autorización de destino (opcional) | @@ -41,7 +46,8 @@ El agregado raíz `AccessEnforcementPolicy` establece reglas generales de restri ## 2. Modelo de Dominio ### Clases / Entidades / Objetos de Valor -``` + +```text AccessEnforcementPolicy (Aggregate Root) └── Props: AccessEnforcementPolicyProps ├── Id: AccessEnforcementPolicyId @@ -100,7 +106,7 @@ sequenceDiagram participant App as Servicio de Aplicación participant Policy as AccessEnforcementPolicy [Agregado] participant Repo as AccessEnforcementPolicyRepository - participant DB as SQL Server + participant DB as PostgreSQL Admin->>Portal: Configura nueva regla de aplicación Portal->>App: CreateAccessEnforcementPolicyCommand(ProfileId, Action) @@ -135,7 +141,8 @@ erDiagram ``` ### Reglas de Aislamiento de Inquilinos (Tenancy) -- Las políticas se particionan estrictamente por `TenantId`. Los filtros de inquilino deben aplicarse a todas las lecturas y escrituras para garantizar las fronteras operativas. + +* Las políticas se particionan estrictamente por `TenantId`. Los filtros de inquilino deben aplicarse a todas las lecturas y escrituras para garantizar las fronteras operativas. --- @@ -166,16 +173,18 @@ flowchart TD ## 7. Capa de Aplicación ### Comandos y Consultas -- **CreateAccessEnforcementPolicyCommand:** Configura una nueva política de cumplimiento bajo el contexto del inquilino activo. -- **DeactivateAccessEnforcementPolicyCommand:** Deshabilita una política activa, liberando las restricciones de acceso para los actores objetivo. -- **GetAccessEnforcementPolicyByIdQuery:** Recupera detalles de la política. -- **GetAllAccessEnforcementPoliciesQuery:** Enumera las políticas activas bajo el inquilino del administrador. + +* **CreateAccessEnforcementPolicyCommand:** Configura una nueva política de cumplimiento bajo el contexto del inquilino activo. +* **DeactivateAccessEnforcementPolicyCommand:** Deshabilita una política activa, liberando las restricciones de acceso para los actores objetivo. +* **GetAccessEnforcementPolicyByIdQuery:** Recupera detalles de la política. +* **GetAllAccessEnforcementPoliciesQuery:** Enumera las políticas activas bajo el inquilino del administrador. --- ## 8. Infraestructura/Persistencia ### Configuración del Mapeo de EF Core + ```csharp public class AccessEnforcementPolicyConfiguration : IEntityTypeConfiguration { @@ -202,14 +211,14 @@ public class AccessEnforcementPolicyConfiguration : IEntityTypeConfiguration **Idioma:** [English](../../domain/approvals/access-notification.md) | **Español** - Este es un documento estable de referencia para `AccessNotification` dentro del índice del Contexto de Aprobaciones. Las reglas detalladas permanecen documentadas dentro del documento del agregado raíz hasta que se requiera una página independiente más completa. **[Volver al Índice de Aprobaciones](./index.md)** diff --git a/docs/domain-es/approvals/approval-request.md b/docs/domain-es/approvals/approval-request.md index 32a820f7..76746b9f 100644 --- a/docs/domain-es/approvals/approval-request.md +++ b/docs/domain-es/approvals/approval-request.md @@ -10,27 +10,32 @@ ## 1. Visión General del Agregado ### Propósito + El agregado `ApprovalRequest` representa una ejecucion concreta en tiempo de ejecucion de un proceso de aprobacion. Cuando un usuario solicita una accion sensible, como asignacion de perfil, promocion de perfil o modificacion de configuracion de seguridad, UMS instancia una `ApprovalRequest` vinculada a un `ApprovalWorkflow` especifico para rastrear el estado y los detalles de auditoria de esa decision operativa. ### Responsabilidad de Negocio -- Registrar y rastrear las solicitudes de aprobación dinámicas. -- Prevenir la doble ejecución o transiciones de estado una vez resueltas. -- Autorizar transiciones de `Pending` a `Approved` o `Rejected` mediante firmas autenticadas. -- Soportar solicitudes de acceso a perfil de EP-09 usando `Rejected` como estado implementado que mapea al resultado de negocio `Denied`. -- Vincular la cuenta del usuario de destino y el perfil de destino. + +* Registrar y rastrear las solicitudes de aprobación dinámicas. +* Prevenir la doble ejecución o transiciones de estado una vez resueltas. +* Autorizar transiciones de `Pending` a `Approved` o `Rejected` mediante firmas autenticadas. +* Soportar solicitudes de acceso a perfil de EP-09 usando `Rejected` como estado implementado que mapea al resultado de negocio `Denied`. +* Vincular la cuenta del usuario de destino y el perfil de destino. ### Raíz de Agregado + `ApprovalRequest` es la raíz del agregado. Todas las transiciones de estado (Aprobación, Rechazo) deben fluir a través de él para garantizar el cumplimiento de las restricciones. ### Invariantes y Reglas de Consistencia + 1. Una solicitud nace en el estado `Pending` (Pendiente). 2. El estado solo puede pasar de `Pending` a `Approved` (Aprobado) o `Rejected` (Rechazado). Una vez que una solicitud se ha finalizado, su estado se bloquea permanentemente y no se puede editar. 3. Debe contener referencias válidas a `WorkflowId` y `TargetUserId`. 4. Un usuario no puede aprobar su propia solicitud de promoción (aplicado en la barrera de comandos de la aplicación/dominio para evitar colusión). ### Entidades Relacionadas / Objetos de Valor + | Entidad / VO | Tipo | Propietario | -|---|---|---| +| --- | --- | --- | | `ApprovalRequestId` | Objeto de Valor | Identificador de raíz de agregado basado en Guid | | `ApprovalStatus` | Enumerado | Pending · Approved · Rejected | | `SystemSuiteId` | Objeto de Valor | Alcance del sistema solicitado | @@ -39,29 +44,33 @@ El agregado `ApprovalRequest` representa una ejecucion concreta en tiempo de eje | `AuditValueObject` | Objeto de Valor | Rastrea metadatos de creación y modificación | ### Eventos de Dominio + | Evento | Desencadenante | -|---|---| +| --- | --- | | `ApprovalRequestCreatedEvent` | Se registra una nueva solicitud de aprobación y se establece en Pendiente | | `ApprovalRequestApprovedEvent` | La solicitud se marca como Aprobada, activando despliegues aguas abajo | | `ApprovalRequestRejectedEvent` | La solicitud se marca como Rejected. En solicitudes de perfil EP-09 se expone al usuario como Denegado | ### Comandos / Casos de Uso + | Comando | Descripción | -|---|---| +| --- | --- | | `CreateApprovalRequestCommand` | Instanciar una solicitud de acceso a perfil con sistema, sucursal, rol y justificación solicitados | | `ApproveRequestCommand` | Aprobar una solicitud pendiente con el identificador del actor autorizado | | `RejectRequestCommand` | Rechazar una solicitud pendiente. Los flujos de onboarding exponen este resultado final como Denegado | ### Límites de Repositorio / Servicio -- `IApprovalRequestRepository` — Gestiona el ciclo de vida de las solicitudes. -- Particionado estrictamente por la sesión de `TenantId` del llamador (heredado a través del flujo de trabajo y las configuraciones de usuario de destino). + +* `IApprovalRequestRepository` — Gestiona el ciclo de vida de las solicitudes. +* Particionado estrictamente por la sesión de `TenantId` del llamador (heredado a través del flujo de trabajo y las configuraciones de usuario de destino). --- ## 2. Modelo de Dominio ### Clases / Entidades / Objetos de Valor -``` + +```text ApprovalRequest (Raíz de Agregado) └── Props: ApprovalRequestProps ├── Id: ApprovalRequestId @@ -115,6 +124,7 @@ classDiagram ## 4. Diagramas de Secuencia ### Ciclo de Vida Completo de la Solicitud de Aprobación + ```mermaid sequenceDiagram participant U as Solicitante @@ -145,6 +155,7 @@ sequenceDiagram ``` ### Mapeo de Onboarding de Solicitud de Perfil + ```mermaid sequenceDiagram participant Usuario as Usuario en Lobby @@ -197,40 +208,47 @@ erDiagram ``` ### Reglas de Aislamiento de Inquilinos -- Evaluado mediante la cuenta del usuario de destino y los alcances del flujo de trabajo. Las operaciones se filtran por el límite del inquilino activo en la capa de repositorio. -- La aprobacion de solicitudes de perfil permanece limitada al tenant o sucursal delegada mediante verificaciones de autorizacion en capa de aplicacion. + +* Evaluado mediante la cuenta del usuario de destino y los alcances del flujo de trabajo. Las operaciones se filtran por el límite del inquilino activo en la capa de repositorio. +* La aprobacion de solicitudes de perfil permanece limitada al tenant o sucursal delegada mediante verificaciones de autorizacion en capa de aplicacion. ### Extension Requerida por EP-09 + El record `ApprovalRequest` implementado ya guarda el sistema solicitado, la sucursal, el rol solicitado, la justificacion, el rol otorgado y el motivo de decision. La entrega final de notificacion se maneja en la canalizacion de notificaciones, por lo que el seguimiento restante de FS-24 solo sera necesario si el diseno exige un campo persistido de resultado de notificacion. --- ## 6. Integración de Contexto Delimitado -- **Aguas Arriba**: Orquestado por flujos de trabajo del contexto de `Aprobaciones`. Se dirige directamente a identificadores de usuario de `Identidad` y perfiles de `Autorización`. -- **Aguas Abajo**: Las aprobaciones exitosas activan promociones de perfil de usuario dentro del contexto `IGA`. + +* **Aguas Arriba**: Orquestado por flujos de trabajo del contexto de `Aprobaciones`. Se dirige directamente a identificadores de usuario de `Identidad` y perfiles de `Autorización`. +* **Aguas Abajo**: Las aprobaciones exitosas activan promociones de perfil de usuario dentro del contexto `IGA`. --- ## 7. Capa de Aplicación -- `CreateApprovalRequestCommand` -> Entradas: `WorkflowId, TargetUserId, TargetProfileId?, RequestedSystemId, RequestedBranchId?, RequestedRoleId, Justification?` -> Retorna: `Guid` -- `ApproveRequestCommand` -> Entradas: `RequestId` -> Retorna: `void` -- `RejectRequestCommand` -> Entradas: `RequestId` -> Retorna: `void` + +* `CreateApprovalRequestCommand` -> Entradas: `WorkflowId, TargetUserId, TargetProfileId?, RequestedSystemId, RequestedBranchId?, RequestedRoleId, Justification?` -> Retorna: `Guid` +* `ApproveRequestCommand` -> Entradas: `RequestId` -> Retorna: `void` +* `RejectRequestCommand` -> Entradas: `RequestId` -> Retorna: `void` --- ## 8. Infraestructura/Persistencia -- Índice: Clave primaria agrupada en `RequestId`, con índice compuesto no agrupado en `TargetUserId, Status`. + +* Índice: Clave primaria agrupada en `RequestId`, con índice compuesto no agrupado en `TargetUserId, Status`. --- ## 9. Seguridad y Cumplimiento -- Las acciones de aprobación requieren credenciales administrativas distintas del iniciador de la solicitud (cumplimiento contra colusión). -- Auditoría: Las solicitudes finalizadas representan firmas digitales vinculantes y se almacenan permanentemente para auditorías de seguridad. + +* Las acciones de aprobación requieren credenciales administrativas distintas del iniciador de la solicitud (cumplimiento contra colusión). +* Auditoría: Las solicitudes finalizadas representan firmas digitales vinculantes y se almacenan permanentemente para auditorías de seguridad. --- ## 10. Decisiones Técnicas -- Mantener un esquema plano simple para las transiciones de estado de las solicitudes garantiza una latencia de persistencia extremadamente baja durante ejecuciones administrativas de alta velocidad. + +* Mantener un esquema plano simple para las transiciones de estado de las solicitudes garantiza una latencia de persistencia extremadamente baja durante ejecuciones administrativas de alta velocidad. --- diff --git a/docs/domain-es/approvals/approval-required-document.md b/docs/domain-es/approvals/approval-required-document.md index 343b67c8..59d20f0f 100644 --- a/docs/domain-es/approvals/approval-required-document.md +++ b/docs/domain-es/approvals/approval-required-document.md @@ -1,7 +1,5 @@ # Documento Requerido de Aprobación -> **Idioma:** [English](../../domain/approvals/approval-required-document.md) | **Español** - Este es un documento estable de referencia para `ApprovalRequiredDocument` dentro del índice del Contexto de Aprobaciones. Las reglas detalladas permanecen documentadas dentro del documento del agregado raíz hasta que se requiera una página independiente más completa. **[Volver al Índice de Aprobaciones](./index.md)** diff --git a/docs/domain-es/approvals/approval-workflow.md b/docs/domain-es/approvals/approval-workflow.md index 0ed0631c..32cf912b 100644 --- a/docs/domain-es/approvals/approval-workflow.md +++ b/docs/domain-es/approvals/approval-workflow.md @@ -10,19 +10,23 @@ ## 1. Visión General del Agregado ### Propósito + El agregado `ApprovalWorkflow` establece las reglas de enrutamiento dinámico y las listas de verificación de documentos para operaciones que requieren supervisión administrativa. Garantiza que ciertas acciones de usuario (como solicitar promociones de perfiles o subir archivos sensibles) desencadenen flujos correspondientes de autorización humana y definan qué archivos de respaldo son obligatorios. Las configuraciones específicas de qué documentos son obligatorios (ej. Prueba de Identidad) se manejan a través de su entidad hija `ApprovalRequiredDocument`. ### Responsabilidad de Negocio -- Registrar y coordinar esquemas de aprobación delimitados por inquilino. -- Orientar los flujos de trabajo a suites o clasificaciones de usuarios específicas. -- Declarar una lista de verificación de documentos de soporte requeridos mediante la gestión de entidades de `ApprovalRequiredDocument`. -- Determinar si la aprobación dinámica está activa. -- Identificar el `DocumentTypeId` explícito obligatorio para el contexto de un flujo de trabajo. + +* Registrar y coordinar esquemas de aprobación delimitados por inquilino. +* Orientar los flujos de trabajo a suites o clasificaciones de usuarios específicas. +* Declarar una lista de verificación de documentos de soporte requeridos mediante la gestión de entidades de `ApprovalRequiredDocument`. +* Determinar si la aprobación dinámica está activa. +* Identificar el `DocumentTypeId` explícito obligatorio para el contexto de un flujo de trabajo. ### Raíz de Agregado + `ApprovalWorkflow` es la raíz del agregado. Agregar o quitar documentos requeridos (entidades `ApprovalRequiredDocument`) debe fluir a través de él para mantener la integridad del modelo. La entidad hija no puede existir ni modificarse fuera del alcance de su agregado padre. ### Invariantes y Reglas de Consistencia + 1. Cada `ApprovalWorkflow` debe cumplir con la plantilla corporativa de código-nombre-descripción. 2. El parámetro `Code` debe ser único dentro del `TenantId` activo. 3. Si `RequiresApproval` es verdadero, el flujo de trabajo debe tener al menos un grupo de aprobadores o criterio de lista de verificación válido. @@ -31,8 +35,9 @@ El agregado `ApprovalWorkflow` establece las reglas de enrutamiento dinámico y 6. `ApprovalRequiredDocument` debe contener un `WorkflowId` y un `DocumentTypeId` válidos, además de tener un `Id` válido (basado en Guid `ApprovalRequiredDocumentId`). ### Entidades Relacionadas / Objetos de Valor + | Entidad / VO | Tipo | Propietario | Descripción | -|---|---|---|---| +| --- | --- | --- | --- | | `ApprovalWorkflowId` | Objeto de Valor | | Identificador de raíz de agregado | | `ApprovalRequiredDocument` | Entidad | Propia | Especifica asignaciones de clasificaciones de documentos requeridos obligatorios | | `ApprovalRequiredDocumentId` | Objeto de Valor | | Identificador único de la entidad hija | @@ -41,29 +46,33 @@ El agregado `ApprovalWorkflow` establece las reglas de enrutamiento dinámico y | `AuditValueObject` | Objeto de Valor | | Rastrea metadatos de creación y modificación | ### Eventos de Dominio + | Evento | Desencadenante | -|---|---| +| --- | --- | | `ApprovalWorkflowCreatedEvent` | Se registra una nueva definición de flujo de aprobación | | `RequiredDocumentAddedEvent` | Se añade un mapeo de requisito de documento a la lista de verificación | | `RequiredDocumentRemovedEvent` | Se elimina un mapeo de requisito de documento de la lista | ### Comandos / Casos de Uso + | Comando | Descripción | -|---|---| +| --- | --- | | `CreateApprovalWorkflowCommand` | Inicializar un nuevo mapeo de flujo de aprobación | | `AddRequiredDocumentToWorkflowCommand` | Vincular un DocumentType como mandato para completar el flujo | | `RemoveRequiredDocumentFromWorkflowCommand` | Eliminar una restricción de DocumentType de la lista de verificación | ### Límites de Repositorio / Servicio -- `IApprovalWorkflowRepository` — Persiste y carga flujos de trabajo. -- Las consultas están estrictamente aisladas y filtradas por la sesión de `TenantId` actual. + +* `IApprovalWorkflowRepository` — Persiste y carga flujos de trabajo. +* Las consultas están estrictamente aisladas y filtradas por la sesión de `TenantId` actual. --- ## 2. Modelo de Dominio ### Clases / Entidades / Objetos de Valor -``` + +```text ApprovalWorkflow (Raíz de Agregado) ├── Props: ApprovalWorkflowProps │ ├── Id: ApprovalWorkflowId @@ -128,6 +137,7 @@ classDiagram ## 4. Diagramas de Secuencia ### Flujo para Agregar Documento Requerido + ```mermaid sequenceDiagram participant C as AdministradorInquilino @@ -181,39 +191,45 @@ erDiagram ``` ### Reglas de Aislamiento de Inquilinos -- Todos los registros de `APPROVAL_WORKFLOW` están particionados por `TenantId`. Las consultas directas a la base de datos requieren filtrado en los repositorios de la aplicación (R-10). -- La entidad hija `APPROVAL_REQUIRED_DOCUMENT` hereda las reglas de delimitación y de aislamiento de base de datos de `APPROVAL_WORKFLOW`. + +* Todos los registros de `APPROVAL_WORKFLOW` están particionados por `TenantId`. Las consultas directas a la base de datos requieren filtrado en los repositorios de la aplicación (R-10). +* La entidad hija `APPROVAL_REQUIRED_DOCUMENT` hereda las reglas de delimitación y de aislamiento de base de datos de `APPROVAL_WORKFLOW`. --- ## 6. Integración de Contexto Delimitado -- **Aguas Arriba**: Obtiene un `SystemSuiteId` opcional del contexto de Autorización. Se dirige directamente a las configuraciones de `DocumentType`. -- **Aguas Abajo**: Consultado por `ApprovalRequest` para verificar las listas de verificación presentadas y por `PromotionRequest` en el contexto IGA para verificar los mandatos de autorización. + +* **Aguas Arriba**: Obtiene un `SystemSuiteId` opcional del contexto de Autorización. Se dirige directamente a las configuraciones de `DocumentType`. +* **Aguas Abajo**: Consultado por `ApprovalRequest` para verificar las listas de verificación presentadas y por `PromotionRequest` en el contexto IGA para verificar los mandatos de autorización. --- ## 7. Capa de Aplicación -- `CreateApprovalWorkflowCommand` -> Entradas: `TenantId, Code, Name, Description, UserCategory, RequiresApproval, SystemSuiteId?` -> Retorna: `Guid` -- `AddRequiredDocumentCommand` -> Entradas: `WorkflowId, DocumentTypeId, IsMandatory` -> Retorna: `void` -- `RemoveRequiredDocumentFromWorkflowCommand` -> Entradas: `WorkflowId, DocumentTypeId` -> Retorna: `void` + +* `CreateApprovalWorkflowCommand` -> Entradas: `TenantId, Code, Name, Description, UserCategory, RequiresApproval, SystemSuiteId?` -> Retorna: `Guid` +* `AddRequiredDocumentCommand` -> Entradas: `WorkflowId, DocumentTypeId, IsMandatory` -> Retorna: `void` +* `RemoveRequiredDocumentFromWorkflowCommand` -> Entradas: `WorkflowId, DocumentTypeId` -> Retorna: `void` --- ## 8. Infraestructura/Persistencia -- Índice: Índice único en `TenantId, Code` para evitar códigos duplicados en el padre. Para la entidad hija, clave primaria agrupada en `RequiredDocId` e índice compuesto en `WorkflowId, DocumentTypeId`. -- Transacción: Las modificaciones en la lista de verificación del flujo de trabajo se guardan de forma atómica en una única transacción de DbContext. + +* Índice: Índice único en `TenantId, Code` para evitar códigos duplicados en el padre. Para la entidad hija, clave primaria agrupada en `RequiredDocId` e índice compuesto en `WorkflowId, DocumentTypeId`. +* Transacción: Las modificaciones en la lista de verificación del flujo de trabajo se guardan de forma atómica en una única transacción de DbContext. --- ## 9. Seguridad y Cumplimiento -- Diseñar flujos de trabajo: Restringido a los roles de `Tenant:Admin` o superiores. (Las reglas se heredan a los mapeos de documentos requeridos). -- Cumplimiento: Cualquier cambio en una lista de verificación de aprobación desencadena bitácoras de auditoría para asegurar los caminos procedimentales. + +* Diseñar flujos de trabajo: Restringido a los roles de `Tenant:Admin` o superiores. (Las reglas se heredan a los mapeos de documentos requeridos). +* Cumplimiento: Cualquier cambio en una lista de verificación de aprobación desencadena bitácoras de auditoría para asegurar los caminos procedimentales. --- ## 10. Decisiones Técnicas -- Declarar tablas de unión de documentos requeridos independientes garantiza relaciones modulares entre los flujos de trabajo y los documentos sin bloquear las tablas principales. -- Mantener la entidad `ApprovalRequiredDocument` sin estado, aparte de los atributos relacionales, evita una sobrecarga excesiva durante las evaluaciones de flujos de trabajo. + +* Declarar tablas de unión de documentos requeridos independientes garantiza relaciones modulares entre los flujos de trabajo y los documentos sin bloquear las tablas principales. +* Mantener la entidad `ApprovalRequiredDocument` sin estado, aparte de los atributos relacionales, evita una sobrecarga excesiva durante las evaluaciones de flujos de trabajo. --- diff --git a/docs/domain-es/approvals/document-type.md b/docs/domain-es/approvals/document-type.md index 3dee1ae4..ae801979 100644 --- a/docs/domain-es/approvals/document-type.md +++ b/docs/domain-es/approvals/document-type.md @@ -10,19 +10,23 @@ ## 1. Visión General del Agregado ### Propósito + El agregado `DocumentType` gobierna las clasificaciones, reglas y esquemas de políticas para los documentos subidos por los usuarios (ej., Pasaportes, Documentos de certificación). Declara umbrales críticos (`Criticity`) y configura acciones proactivas de cumplimiento de seguridad (entidad `EnforcementPolicy`) para ejecutarse automáticamente cuando un documento obligatorio expira o se elimina. `NotificationRule` es un Aggregate Root independiente reutilizable para reglas de notificación y solo puede ser referenciado por `DocumentType`. ### Responsabilidad de Negocio -- Registrar y clasificar documentos de verificación corporativa. -- Establecer pautas de criticidad (Baja, Media, Alta, Crítica). -- Referenciar reglas de notificación dinámicas sin acoplar el ciclo de vida de `NotificationRule` al catálogo documental. -- Definir canales de transmisión de alertas (correo electrónico, SMS, etc.). -- Definir bloqueos automáticos de cumplimiento (ej., Bloqueo de acceso, restricción de perfiles) cuando fallan los elementos críticos de cumplimiento. + +* Registrar y clasificar documentos de verificación corporativa. +* Establecer pautas de criticidad (Baja, Media, Alta, Crítica). +* Referenciar reglas de notificación dinámicas sin acoplar el ciclo de vida de `NotificationRule` al catálogo documental. +* Definir canales de transmisión de alertas (correo electrónico, SMS, etc.). +* Definir bloqueos automáticos de cumplimiento (ej., Bloqueo de acceso, restricción de perfiles) cuando fallan los elementos críticos de cumplimiento. ### Raíz de Agregado + `DocumentType` es la raíz del agregado. Definir acciones de cumplimiento debe realizarse a través de él para aplicar las invariantes. `NotificationRule` no forma parte de su ciclo de vida y no puede modelarse como entidad hija. ### Invariantes y Reglas de Consistencia + 1. Cada `DocumentType` debe poseer un `Code` único dentro de su espacio de nombres de `TenantId`. 2. **Unicidad de DaysBefore (INV-DT2)**: Los umbrales de alerta (`DaysBefore`) en las `NotificationRules` referenciadas deben ser únicos en la lista de reglas asociadas. 3. **Mandatos Críticos (INV-DT1)**: Si un `DocumentType` se establece en `Critical`, debe tener exactamente una `EnforcementPolicy` activa definida para garantizar el cumplimiento del sistema. @@ -32,8 +36,9 @@ El agregado `DocumentType` gobierna las clasificaciones, reglas y esquemas de po 7. **Canales Válidos (INV-NR2)**: La colección `Channels` en `NotificationRule` debe contener al menos un canal de notificación válido (Email, SMS, WebPortal) y no puede ser nula ni vacía. ### Entidades Relacionadas / Objetos de Valor + | Entidad / VO | Tipo | Propietario | Descripción | -|---|---|---|---| +| --- | --- | --- | --- | | `DocumentTypeId` | Objeto de Valor | | Identificador de raíz de agregado | | `DocumentCriticity` | Enumerado | | LOW · MEDIUM · HIGH · CRITICAL | | `NotificationRule` | Agregado Raíz | Referencia externa | Define el umbral de advertencia reactivo de expiración | @@ -44,8 +49,9 @@ El agregado `DocumentType` gobierna las clasificaciones, reglas y esquemas de po | `AuditValueObject` | Objeto de Valor | | Rastrea metadatos de creación y modificación | ### Eventos de Dominio + | Evento | Desencadenante | -|---|---| +| --- | --- | | `DocumentTypeRegisteredEvent` | Se registra con éxito una nueva categoría de documento | | `NotificationRuleConfiguredEvent` | Se configura una regla de pre-alerta de vencimiento | | `NotificationRuleRemovedEvent` | Se elimina una regla de pre-alerta | @@ -53,8 +59,9 @@ El agregado `DocumentType` gobierna las clasificaciones, reglas y esquemas de po | `EnforcementPolicyUpdatedEvent` | Se actualizan los parámetros de cumplimiento | ### Comandos / Casos de Uso + | Comando | Descripción | -|---|---| +| --- | --- | | `CreateDocumentTypeCommand` | Registrar un nuevo tipo de documento con parámetros por defecto | | `ConfigureNotificationRuleCommand` | Configurar un nuevo umbral de alerta y sus canales en el agregado independiente `NotificationRule` | | `RemoveNotificationRuleCommand` | Eliminar una regla de pre-alerta de notificación existente | @@ -62,15 +69,17 @@ El agregado `DocumentType` gobierna las clasificaciones, reglas y esquemas de po | `UpdateEnforcementPolicyCommand` | Modificar las acciones o períodos de gracia de una política | ### Límites de Repositorio / Servicio -- `IDocumentTypeRepository` — Persiste los esquemas de clasificación. -- Acotado estrictamente por `TenantId` para evitar cruces de configuración entre inquilinos. + +* `IDocumentTypeRepository` — Persiste los esquemas de clasificación. +* Acotado estrictamente por `TenantId` para evitar cruces de configuración entre inquilinos. --- ## 2. Modelo de Dominio ### Clases / Entidades / Objetos de Valor -``` + +```text DocumentType (Raíz de Agregado) ├── Props: DocumentTypeProps │ ├── Id: DocumentTypeId @@ -123,6 +132,7 @@ classDiagram ## 4. Diagramas de Secuencia ### Flujo para Definir Política de Cumplimiento + ```mermaid sequenceDiagram participant C as AdministradorInquilino @@ -179,40 +189,46 @@ erDiagram ``` ### Reglas de Aislamiento de Inquilinos -- Los esquemas clasificados están particionados estrictamente por `TenantId`. Todas las consultas de enrutamiento de verificación imponen límites de aislamiento. -- `NotificationRule` es un Aggregate Root independiente y conserva su propio ciclo de vida. + +* Los esquemas clasificados están particionados estrictamente por `TenantId`. Todas las consultas de enrutamiento de verificación imponen límites de aislamiento. +* `NotificationRule` es un Aggregate Root independiente y conserva su propio ciclo de vida. --- ## 6. Integración de Contexto Delimitado -- **Aguas Arriba**: Hereda las reglas de contexto de `Identidad` (validando registros de inquilinos). -- **Aguas Abajo**: Consultado por `UserDocument` para verificar los umbrales de alerta, y por `AccessEnforcementPolicy` durante los pases de verificación de cumplimiento. Las alertas configuradas a través de `NotificationRule` son procesadas por ejecutores en segundo plano para notificar a los usuarios. + +* **Aguas Arriba**: Hereda las reglas de contexto de `Identidad` (validando registros de inquilinos). +* **Aguas Abajo**: Consultado por `UserDocument` para verificar los umbrales de alerta, y por `AccessEnforcementPolicy` durante los pases de verificación de cumplimiento. Las alertas configuradas a través de `NotificationRule` son procesadas por ejecutores en segundo plano para notificar a los usuarios. --- ## 7. Capa de Aplicación -- `CreateDocumentTypeCommand` -> Entradas: `TenantId, Code, Name, Description, Criticity` -> Retorna: `Guid` -- `ConfigureNotificationRuleCommand` -> Entradas: `NotificationRuleId, DaysBefore, Channels, Code, Description` -> Retorna: `void` -- `RemoveNotificationRuleCommand` -> Entradas: `NotificationRuleId` -> Retorna: `void` -- `DefineEnforcementPolicyCommand` -> Entradas: `DocumentTypeId, Action, GracePeriodDays?` -> Retorna: `void` + +* `CreateDocumentTypeCommand` -> Entradas: `TenantId, Code, Name, Description, Criticity` -> Retorna: `Guid` +* `ConfigureNotificationRuleCommand` -> Entradas: `NotificationRuleId, DaysBefore, Channels, Code, Description` -> Retorna: `void` +* `RemoveNotificationRuleCommand` -> Entradas: `NotificationRuleId` -> Retorna: `void` +* `DefineEnforcementPolicyCommand` -> Entradas: `DocumentTypeId, Action, GracePeriodDays?` -> Retorna: `void` --- ## 8. Infraestructura/Persistencia -- Índice: Índice único en `TenantId, Code`. En `NotificationRule`, índice compuesto en `TenantId, Code` para asegurar unicidad. -- Transacción: Las actualizaciones de hijos (políticas y entradas de reglas de pre-alerta) se almacenan de forma atómica dentro de la transacción de base de datos del padre `DOCUMENT_TYPE`. + +* Índice: Índice único en `TenantId, Code`. En `NotificationRule`, índice compuesto en `TenantId, Code` para asegurar unicidad. +* Transacción: Las actualizaciones de hijos (políticas y entradas de reglas de pre-alerta) se almacenan de forma atómica dentro de la transacción de base de datos del padre `DOCUMENT_TYPE`. --- ## 9. Seguridad y Cumplimiento -- Ajustar la clasificación o reglas críticas: Restringido estrictamente a los roles de `Tenant:Admin`. -- Cumplimiento: Alterar las reglas de cumplimiento representa un alto impacto de seguridad y desencadena un registro de auditoría de alta gravedad. + +* Ajustar la clasificación o reglas críticas: Restringido estrictamente a los roles de `Tenant:Admin`. +* Cumplimiento: Alterar las reglas de cumplimiento representa un alto impacto de seguridad y desencadena un registro de auditoría de alta gravedad. --- ## 10. Decisiones Técnicas -- Consolidar `DocumentType` como catálogo y `NotificationRule` como agregado independiente protege los límites del dominio contra restricciones divididas. -- Almacenar los canales de comunicación permitidos como una matriz serializada (`ChannelsJson`) garantiza la flexibilidad sin sobrecargar de consultas complejas. + +* Consolidar `DocumentType` como catálogo y `NotificationRule` como agregado independiente protege los límites del dominio contra restricciones divididas. +* Almacenar los canales de comunicación permitidos como una matriz serializada (`ChannelsJson`) garantiza la flexibilidad sin sobrecargar de consultas complejas. --- diff --git a/docs/domain-es/approvals/index.md b/docs/domain-es/approvals/index.md index 70fb8767..046108bb 100644 --- a/docs/domain-es/approvals/index.md +++ b/docs/domain-es/approvals/index.md @@ -1,24 +1,25 @@ # Contexto de Aprobaciones (Approvals BC) — Arquitectura de Agregados -> **Idioma:** [English](../../domain/approvals/index.md) | [Español](./index.md) - **Contexto Delimitado:** Aprobaciones (`Ums.Domain.Approvals`) **Raíces de Agregado (Aggregate Roots):** `ApprovalWorkflow`, `ApprovalRequest`, `DocumentType`, `NotificationRule`, `UserDocument`, `AccessEnforcementPolicy` --- -### Modelo de Flujos de Trabajo y Solicitudes +## Modelo de Flujos de Trabajo y Solicitudes + Los elementos centrales de los flujos de trabajo gobiernan el enrutamiento dinámico de aprobaciones y los eventos de verificación: -- [ApprovalWorkflow](./approval-workflow.md) (Raíz de Agregado) — Define los pasos secuenciales o paralelos de verificación requeridos para acciones sensibles. -- [ApprovalRequiredDocument](./approval-required-document.md) (Entidad Propia) — Asignación de tipos de documentos específicos necesarios para autorizar un flujo de trabajo. -- [ApprovalRequest](./approval-request.md) (Raíz de Agregado) — Una solicitud de ejecución en tiempo de ejecución concreta que contiene el estado de verificación y las firmas. - -### Clasificación y Políticas de Documentos -- [DocumentType](./document-type.md) (Raíz de Agregado) — Clasificación de documentos de verificación (ej., Identificación, Comprobante de Domicilio). -- [NotificationRule](./notification-rule.md) (Raíz de Agregado) — Define los días previos a la expiración, los canales y la audiencia de notificación. -- [UserDocument](./user-document.md) (Raíz de Agregado) — El registro físico del archivo subido que pertenece a un usuario, almacenando su estado de verificación. -- [AccessNotification](./access-notification.md) (Entidad Propia) — Historial de alertas enviadas para el cumplimiento de documentos. -- [AccessEnforcementPolicy](./access-enforcement-policy.md) (Raíz de Agregado) — Define los bloqueos automáticos de cuentas o rebajas de perfiles de seguridad ante el incumplimiento de documentos. + +* [ApprovalWorkflow](./approval-workflow.md) (Raíz de Agregado) — Define los pasos secuenciales o paralelos de verificación requeridos para acciones sensibles. +* [ApprovalRequiredDocument](./approval-required-document.md) (Entidad Propia) — Asignación de tipos de documentos específicos necesarios para autorizar un flujo de trabajo. +* [ApprovalRequest](./approval-request.md) (Raíz de Agregado) — Una solicitud de ejecución en tiempo de ejecución concreta que contiene el estado de verificación y las firmas. + +## Clasificación y Políticas de Documentos + +* [DocumentType](./document-type.md) (Raíz de Agregado) — Clasificación de documentos de verificación (ej., Identificación, Comprobante de Domicilio). +* [NotificationRule](./notification-rule.md) (Raíz de Agregado) — Define los días previos a la expiración, los canales y la audiencia de notificación. +* [UserDocument](./user-document.md) (Raíz de Agregado) — El registro físico del archivo subido que pertenece a un usuario, almacenando su estado de verificación. +* [AccessNotification](./access-notification.md) (Entidad Propia) — Historial de alertas enviadas para el cumplimiento de documentos. +* [AccessEnforcementPolicy](./access-enforcement-policy.md) (Raíz de Agregado) — Define los bloqueos automáticos de cuentas o rebajas de perfiles de seguridad ante el incumplimiento de documentos. --- diff --git a/docs/domain-es/approvals/notification-rule.md b/docs/domain-es/approvals/notification-rule.md index abbb34ad..e2f7c3fe 100644 --- a/docs/domain-es/approvals/notification-rule.md +++ b/docs/domain-es/approvals/notification-rule.md @@ -10,26 +10,30 @@ ## 1. Visión General del Agregado ### Propósito + `NotificationRule` es un Aggregate Root independiente que define cuándo, cómo y a quién se notifica por vencimiento o incumplimiento documental. Se reutiliza desde otros agregados, pero no depende del ciclo de vida de `DocumentType`. ### Responsabilidad de Negocio -- Definir umbrales de notificación previos al vencimiento. -- Configurar canales permitidos por regla. -- Mantener el ciclo de vida de la regla de forma independiente. + +* Definir umbrales de notificación previos al vencimiento. +* Configurar canales permitidos por regla. +* Mantener el ciclo de vida de la regla de forma independiente. ### Raíz de Agregado + `NotificationRule` no es entidad hija de `DocumentType`. `DocumentType` solo puede referenciarla por identificador. ### Invariantes + 1. `DaysBefore` debe ser mayor que cero. 2. La colección de canales no puede ser nula ni vacía. 3. El código debe ser único dentro del scope configurado. ### Comandos / Casos de Uso + | Comando | Descripción | -|---|---| +| --- | --- | | `ConfigureNotificationRuleCommand` | Crear o actualizar la regla independiente | | `RemoveNotificationRuleCommand` | Eliminar la regla independiente | --- - diff --git a/docs/domain-es/approvals/user-document.md b/docs/domain-es/approvals/user-document.md index 44b7f09e..8e330b08 100644 --- a/docs/domain-es/approvals/user-document.md +++ b/docs/domain-es/approvals/user-document.md @@ -10,33 +10,38 @@ ## 1. Vista General del Agregado ### Propósito + El agregado `UserDocument` representa una credencial digital o documento de cumplimiento cargado por un usuario (por ejemplo, verificación de identidad, certificaciones). Gestiona el ciclo de vida de verificación del documento, su estado de validez, estado de cumplimiento y el historial de notificaciones de vencimiento enviadas al usuario a través de la entidad `AccessNotification`. `AccessNotification` sirve como un registro cronológico inmutable de alertas generadas por el sistema. ### Responsabilidad de Negocio -- Encapsular los metadatos del documento, incluyendo fecha de emisión, fecha de vencimiento, ubicación de almacenamiento físico y suma de comprobación criptográfica (checksum). -- Controlar las transiciones de estado a lo largo del ciclo de vida del documento (Pending Review $\rightarrow$ Valid / Rejected / Expired $\rightarrow$ Re-uploaded). -- Albergar y administrar el historial de envíos de alertas (`AccessNotification`) como entidades de propiedad exclusiva, capturando los canales de comunicación y el índice de paso. -- Garantizar el mapeo estricto del contexto multi-inquilino. + +* Encapsular los metadatos del documento, incluyendo fecha de emisión, fecha de vencimiento, ubicación de almacenamiento físico y suma de comprobación criptográfica (checksum). +* Controlar las transiciones de estado a lo largo del ciclo de vida del documento (Pending Review $\rightarrow$ Valid / Rejected / Expired $\rightarrow$ Re-uploaded). +* Albergar y administrar el historial de envíos de alertas (`AccessNotification`) como entidades de propiedad exclusiva, capturando los canales de comunicación y el índice de paso. +* Garantizar el mapeo estricto del contexto multi-inquilino. ### Raíz del Agregado + `UserDocument` es una raíz de agregado soberana dentro del contexto de Approvals. Controla su estado interno y garantiza que todos los hijos (como `AccessNotification`) sean modificados exclusivamente a través de los métodos del dominio raíz. ### Invariantes y Reglas de Consistencia + 1. **INV-UD1 (Validez de la Secuencia de Fechas):** La fecha de vencimiento del documento (`ExpirationDate`) debe ser cronológicamente mayor que su fecha de emisión (`IssueDate`). 2. **INV-UD2 (Transiciones del Ciclo de Vida):** Las transiciones de estado deben seguir las reglas estrictas de la máquina de estados finitos (FSM): - - El estado inicial es siempre `PendingReview`. - - `PendingReview` puede transicionar a `Valid` (a través de `Validate`) o `Rejected` (a través de `Reject`). - - `Valid` puede transicionar a `Expired` (a través de `Expire`) cuando la fecha del calendario supera la fecha de vencimiento. - - Solo los documentos en estado `Expired` y `Rejected` pueden activar la recarga (`ReUpload`), lo que restablece el estado a `PendingReview` y reinicia el contador de pasos de notificación a cero. - - `Rejected` no puede transicionar directamente a `Valid` sin pasar por un nuevo ciclo de carga/verificación. + * El estado inicial es siempre `PendingReview`. + * `PendingReview` puede transicionar a `Valid` (a través de `Validate`) o `Rejected` (a través de `Reject`). + * `Valid` puede transicionar a `Expired` (a través de `Expire`) cuando la fecha del calendario supera la fecha de vencimiento. + * Solo los documentos en estado `Expired` y `Rejected` pueden activar la recarga (`ReUpload`), lo que restablece el estado a `PendingReview` y reinicia el contador de pasos de notificación a cero. + * `Rejected` no puede transicionar directamente a `Valid` sin pasar por un nuevo ciclo de carga/verificación. 3. **INV-UD3 (Verificación de Integridad):** Cada documento cargado debe proporcionar un hash criptográfico válido (`FileChecksum`) y hacer referencia a una estructura de tipo de documento (`DocumentTypeId`) existente. 4. **INV-AN1 (Historial Inmutable):** Una vez registrado un `AccessNotification`, sus propiedades no pueden ser modificadas. 5. **INV-AN2 (Días Restantes Positivos):** En la notificación, `DaysRemaining` debe ser un entero positivo o cero, que represente la ventana de validez restante. 6. **INV-AN3 (Coordinación de la Secuencia de Pasos):** El índice `Step` de la notificación debe corresponder a una fase de advertencia activa configurada en las reglas del tipo de documento. ### Entidades Relacionadas / Objetos de Valor + | Entidad / VO | Tipo | Descripción | -|---|---|---| +| --- | --- | --- | | `UserDocumentId` | Objeto de Valor | Identificador único del agregado | | `UserId` | Objeto de Valor | Referencia al propietario, vinculando con el Contexto de Identity | | `DocumentTypeId` | Objeto de Valor | Referencia al agregado de definición de plantilla | @@ -53,7 +58,8 @@ El agregado `UserDocument` representa una credencial digital o documento de cump ## 2. Modelo de Dominio ### Clases / Entidades / Objetos de Valor -``` + +```text UserDocument (Aggregate Root) ├── Props: UserDocumentProps │ ├── Id: UserDocumentId @@ -155,7 +161,7 @@ sequenceDiagram participant App as Servicio de Aplicación participant Doc as UserDocument [Agregado] participant Repo as UserDocumentRepository - participant DB as SQL Server + participant DB as PostgreSQL Reviewer->>Portal: Revisa detalles del documento Portal->>App: ValidateUserDocumentCommand(DocId) @@ -208,7 +214,8 @@ erDiagram ``` ### Reglas de Aislamiento de Inquilinos (Tenancy) -- Los documentos de usuario heredan la estructura de inquilino de la cuenta de usuario propietaria. Las lecturas entre inquilinos están prohibidas mediante filtros a nivel de capa de aplicación en el `UserId`. La seguridad para entidades hijas como `AccessNotification` está garantizada implícitamente por el padre. + +* Los documentos de usuario heredan la estructura de inquilino de la cuenta de usuario propietaria. Las lecturas entre inquilinos están prohibidas mediante filtros a nivel de capa de aplicación en el `UserId`. La seguridad para entidades hijas como `AccessNotification` está garantizada implícitamente por el padre. --- @@ -230,26 +237,29 @@ flowchart TD UD -->|instancia| DT UD *--|posee| AN ``` -- **Aguas Abajo**: Estos registros de notificación (históricos) son leídos por el motor de cumplimiento de seguridad para verificar si se cumplieron los protocolos de notificación correctos antes de invocar bloqueos de acceso. + +* **Aguas Abajo**: Estos registros de notificación (históricos) son leídos por el motor de cumplimiento de seguridad para verificar si se cumplieron los protocolos de notificación correctos antes de invocar bloqueos de acceso. --- ## 7. Capa de Aplicación ### Comandos y Consultas -- **UploadUserDocumentCommand:** Maneja el registro de un nuevo documento de usuario. Valida la secuencia de fechas de expiración y comprueba la plantilla del tipo de documento. -- **ValidateUserDocumentCommand:** Autorizado para que los Verificadores marquen los documentos como `Valid`. -- **RejectUserDocumentCommand:** Marca un documento como `Rejected`, incorporando los motivos del rechazo para su posterior corrección. -- **ReUploadUserDocumentCommand:** Reemplaza archivos inválidos o vencidos, devolviendo el estado de cumplimiento del documento a `PendingReview`. -- **RecordNotificationSentCommand (interno/método):** Registra el envío de la notificación en el agregado a través de `AccessNotification`. -- **GetUserDocumentByIdQuery:** Retorna los metadatos de un único documento. -- **GetAllUserDocumentsQuery:** Consulta orientada a auditorías de cumplimiento, filtrable por estado y userId. + +* **UploadUserDocumentCommand:** Maneja el registro de un nuevo documento de usuario. Valida la secuencia de fechas de expiración y comprueba la plantilla del tipo de documento. +* **ValidateUserDocumentCommand:** Autorizado para que los Verificadores marquen los documentos como `Valid`. +* **RejectUserDocumentCommand:** Marca un documento como `Rejected`, incorporando los motivos del rechazo para su posterior corrección. +* **ReUploadUserDocumentCommand:** Reemplaza archivos inválidos o vencidos, devolviendo el estado de cumplimiento del documento a `PendingReview`. +* **RecordNotificationSentCommand (interno/método):** Registra el envío de la notificación en el agregado a través de `AccessNotification`. +* **GetUserDocumentByIdQuery:** Retorna los metadatos de un único documento. +* **GetAllUserDocumentsQuery:** Consulta orientada a auditorías de cumplimiento, filtrable por estado y userId. --- ## 8. Infraestructura/Persistencia ### Configuración del Mapeo de EF Core + ```csharp public class UserDocumentConfiguration : IEntityTypeConfiguration { @@ -285,15 +295,15 @@ public class UserDocumentConfiguration : IEntityTypeConfiguration ## 9. Seguridad y Cumplimiento -- **Control de Acceso Basado en Roles (RBAC):** Solo los usuarios con el rol `Role.User` pueden cargar o volver a cargar documentos. Solo `Role.Reviewer` puede validar o rechazar. -- **Protección de Datos:** Los archivos físicos almacenados (`FileStoragePath`) deben residir en directorios protegidos. La validación criptográfica de `FileChecksum` protege contra alteraciones en el almacenamiento físico subyacente. -- **Auditoría inmutable:** Los registros de notificaciones son estrictamente de solo lectura después de su creación para evitar la alteración de las rutas de auditoría de seguridad. +* **Control de Acceso Basado en Roles (RBAC):** Solo los usuarios con el rol `Role.User` pueden cargar o volver a cargar documentos. Solo `Role.Reviewer` puede validar o rechazar. +* **Protección de Datos:** Los archivos físicos almacenados (`FileStoragePath`) deben residir en directorios protegidos. La validación criptográfica de `FileChecksum` protege contra alteraciones en el almacenamiento físico subyacente. +* **Auditoría inmutable:** Los registros de notificaciones son estrictamente de solo lectura después de su creación para evitar la alteración de las rutas de auditoría de seguridad. --- ## 10. Decisiones Técnicas -- **Notificaciones Anidadas:** Modelar `AccessNotification` como una colección anidada y persistirlos como entidades propias garantiza trazas de auditoría cronológicas consistentes. Mantener el historial dentro del documento padre proporciona verificaciones rápidas y asegura un alto rendimiento durante las comprobaciones de cumplimiento, sin requerir costosas consultas cruzadas contra logs generales de mensajería o motores externos de auditoría. +* **Notificaciones Anidadas:** Modelar `AccessNotification` como una colección anidada y persistirlos como entidades propias garantiza trazas de auditoría cronológicas consistentes. Mantener el historial dentro del documento padre proporciona verificaciones rápidas y asegura un alto rendimiento durante las comprobaciones de cumplimiento, sin requerir costosas consultas cruzadas contra logs generales de mensajería o motores externos de auditoría. --- diff --git a/docs/domain-es/audit/audit-record.md b/docs/domain-es/audit/audit-record.md index 68e2db9d..18d8fed0 100644 --- a/docs/domain-es/audit/audit-record.md +++ b/docs/domain-es/audit/audit-record.md @@ -10,28 +10,33 @@ ## 1. Vista General del Agregado ### Propósito + El agregado raíz `AuditRecord` modela una entrada de registro cronológico inmutable y con marca de tiempo de un evento crítico del sistema, actualización de configuración, cambio de límites de seguridad o transición de estado transaccional. Proporciona visibilidad y trazabilidad absolutas para auditorías de cumplimiento normativo y detección de amenazas. ### Responsabilidad de Negocio -- Registrar una traza completa y a prueba de alteraciones de las acciones administrativas y solicitudes de los usuarios. -- Rastrear las entidades afectadas, los identificadores de actores, los alcances del proceso y las cargas útiles de transición. -- Mantener un esquema de persistencia transaccional estricto e incremental (append-only). + +* Registrar una traza completa y a prueba de alteraciones de las acciones administrativas y solicitudes de los usuarios. +* Rastrear las entidades afectadas, los identificadores de actores, los alcances del proceso y las cargas útiles de transición. +* Mantener un esquema de persistencia transaccional estricto e incremental (append-only). ### Raíz del Agregado + `AuditRecord` es una raíz de agregado soberana y autocontenida. Debido a su importancia para la seguridad, no posee colecciones hijas y no expone mecanismos de edición o eliminación. ### Invariantes y Reglas de Consistencia + 1. **INV-AU1 (Almacenamiento Estricto Incremental):** Un registro de auditoría es completamente de solo lectura una vez guardado. El agregado de dominio no define métodos de establecimiento públicos (setters) ni mutadores de transición de estado. 2. **INV-AU2 (Validación de Integridad de la Traza):** Para garantizar el no repudio, las siguientes propiedades obligatorias deben establecerse y no pueden ser valores predeterminados durante la creación: - - `WhoActed` no puede estar vacío (`Guid.Empty`). - - `WhatChanged` debe ser una cadena de texto válida y no vacía (`DomainErrors.Audit.WhatChangedRequired`). - - `AffectedEntityId` no puede estar vacío. - - `AffectedEntityType` debe ser una cadena de texto válida y no vacía (`DomainErrors.Audit.AffectedEntityRequired`). - - `RootTenantId` debe asignarse a un identificador de inquilino válido. + * `WhoActed` no puede estar vacío (`Guid.Empty`). + * `WhatChanged` debe ser una cadena de texto válida y no vacía (`DomainErrors.Audit.WhatChangedRequired`). + * `AffectedEntityId` no puede estar vacío. + * `AffectedEntityType` debe ser una cadena de texto válida y no vacía (`DomainErrors.Audit.AffectedEntityRequired`). + * `RootTenantId` debe asignarse a un identificador de inquilino válido. ### Entidades Relacionadas / Objetos de Valor + | Entidad / VO | Tipo | Descripción | -|---|---|---| +| --- | --- | --- | | `AuditRecordId` | Objeto de Valor | Identificador único del agregado | | `SubjectType` | Enumerado | `User` · `SystemProcess` · `ExternalService` | | `AuditResult` | Enumerado | `Success` · `Failure` · `Warning` | @@ -41,7 +46,8 @@ El agregado raíz `AuditRecord` modela una entrada de registro cronológico inmu ## 2. Modelo de Dominio ### Clases / Entidades / Objetos de Valor -``` + +```text AuditRecord (Aggregate Root) └── Props: AuditRecordProps ├── Id: AuditRecordId @@ -114,7 +120,7 @@ sequenceDiagram participant AuditService as Servicio de Aplicación de Audit participant AR as AuditRecord [Agregado] participant Repo as AuditRecordRepository - participant DB as SQL Server + participant DB as PostgreSQL Admin->>Portal: Desactiva cuenta de usuario Portal->>IdentityService: DeactivateUserCommand(TargetUserId) @@ -153,7 +159,8 @@ erDiagram ``` ### Reglas de Aislamiento de Inquilinos (Tenancy) -- Delimitado estrictamente por `RootTenantId`. La lectura entre inquilinos está estrictamente bloqueada. Los inquilinos no pueden consultar las huellas de seguridad de otros inquilinos. + +* Delimitado estrictamente por `RootTenantId`. La lectura entre inquilinos está estrictamente bloqueada. Los inquilinos no pueden consultar las huellas de seguridad de otros inquilinos. --- @@ -182,15 +189,17 @@ flowchart TD ## 7. Capa de Aplicación ### Comandos y Consultas -- **RecordAuditCommand:** Comando de adición inmutable (append-only) que procesa mensajes de eventos de seguridad y los registra en la base de datos. -- **GetAllAuditRecordsQuery:** Proporciona listas legibles filtrables de eventos de seguridad, delimitadas por el `RootTenantId`. -- **GetAuditRecordByIdQuery:** Recupera un registro de traza inmutable para su posterior análisis de seguridad. + +* **RecordAuditCommand:** Comando de adición inmutable (append-only) que procesa mensajes de eventos de seguridad y los registra en la base de datos. +* **GetAllAuditRecordsQuery:** Proporciona listas legibles filtrables de eventos de seguridad, delimitadas por el `RootTenantId`. +* **GetAuditRecordByIdQuery:** Recupera un registro de traza inmutable para su posterior análisis de seguridad. --- ## 8. Infraestructura/Persistencia ### Configuración del Mapeo de EF Core + ```csharp public class AuditRecordConfiguration : IEntityTypeConfiguration { @@ -221,14 +230,14 @@ public class AuditRecordConfiguration : IEntityTypeConfiguration ## 9. Seguridad y Cumplimiento -- **Garantía de No Repudio:** La capa de la base de datos impone que los privilegios de actualización (`UPDATE`) y eliminación (`DELETE`) se denieguen en la tabla `AUDIT_RECORD` para las cadenas de conexión estándar de la aplicación, restringiéndolos únicamente a las llaves de seguridad de emergencia. -- **Desinfección de Cargas Útiles:** Los datos serializados dentro de `Metadata` deben desinfectarse y eliminar información sensible del usuario (como hashes de contraseñas, PIN o llaves criptográficas sin cifrar) antes de su serialización. +* **Garantía de No Repudio:** La capa de la base de datos impone que los privilegios de actualización (`UPDATE`) y eliminación (`DELETE`) se denieguen en la tabla `AUDIT_RECORD` para las cadenas de conexión estándar de la aplicación, restringiéndolos únicamente a las llaves de seguridad de emergencia. +* **Desinfección de Cargas Útiles:** Los datos serializados dentro de `Metadata` deben desinfectarse y eliminar información sensible del usuario (como hashes de contraseñas, PIN o llaves criptográficas sin cifrar) antes de su serialización. --- ## 10. Decisiones Técnicas -- **Carga Útil Dinámica de Metadatos:** El uso de una columna JSON `nvarchar(max)` para `Metadata` permite al motor de registro capturar métricas detalladas y específicas del contexto de cada acción (por ejemplo, propiedades anteriores y posteriores de perfiles de seguridad) sin forzar una estructura de unión de esquemas de bases de datos pesada y en constante evolución. +* **Carga Útil Dinámica de Metadatos:** El uso de una columna JSON `nvarchar(max)` para `Metadata` permite al motor de registro capturar métricas detalladas y específicas del contexto de cada acción (por ejemplo, propiedades anteriores y posteriores de perfiles de seguridad) sin forzar una estructura de unión de esquemas de bases de datos pesada y en constante evolución. --- diff --git a/docs/domain-es/audit/index.md b/docs/domain-es/audit/index.md index fadc11e5..c85f5e44 100644 --- a/docs/domain-es/audit/index.md +++ b/docs/domain-es/audit/index.md @@ -1,15 +1,15 @@ # Contexto de Auditoría (Audit BC) — Arquitectura de Agregados -> **Idioma:** [English](../../domain/audit/index.md) | [Español](./index.md) - **Contexto Acotado:** Auditoría de Seguridad y Cumplimiento (`Ums.Domain.Audit`) **Raíces de Agregado:** `AuditRecord` --- -### Auditoría de Actividades del Sistema +## Auditoría de Actividades del Sistema + Coordina registros cronológicos inmutables e incrementales (append-only) que registran operaciones críticas, cambios de autorización y modificaciones de seguridad multi-inquilino: -- [AuditRecord](./audit-record.md) (Raíz de Agregado) — Repositorios que realizan el seguimiento de la huella de los actores, los datos modificados, las entidades afectadas y el resultado de las acciones. Estrictamente de solo lectura una vez registrado. + +* [AuditRecord](./audit-record.md) (Raíz de Agregado) — Repositorios que realizan el seguimiento de la huella de los actores, los datos modificados, las entidades afectadas y el resultado de las acciones. Estrictamente de solo lectura una vez registrado. --- diff --git a/docs/domain-es/authorization/action.md b/docs/domain-es/authorization/action.md index bf305284..82ea1f48 100644 --- a/docs/domain-es/authorization/action.md +++ b/docs/domain-es/authorization/action.md @@ -1,7 +1,5 @@ # Acción -> **Idioma:** [English](../../domain/authorization/action.md) | **Español** - Este es un documento estable de referencia para `Action` dentro del índice del Contexto de Autorización. Las reglas detalladas permanecen documentadas dentro del documento del agregado raíz hasta que se requiera una página independiente más completa. **[Volver al Índice de Autorización](./index.md)** diff --git a/docs/domain-es/authorization/index.md b/docs/domain-es/authorization/index.md index 85103904..77cc01e5 100644 --- a/docs/domain-es/authorization/index.md +++ b/docs/domain-es/authorization/index.md @@ -1,29 +1,29 @@ # Contexto de Autorización (Authorization BC) — Arquitectura de Agregados -> **Idioma:** [English](../../domain/authorization/index.md) | [Español](./index.md) - **Contexto Delimitado:** Autorización (`Ums.Domain.Authorization`) **Raíces de Agregado (Aggregate Roots):** `SystemSuite`, `Role`, `PermissionTemplate`, `Profile` --- -### Modelo de Suite de Aplicaciones +## Modelo de Suite de Aplicaciones + La estructura de suites gobierna los menús de navegación y acciones en el sistema: -- [SystemSuite](./system-suite.md) (Raíz de Agregado) — Aplicaciones de nivel superior de la plataforma (ej. Portal de Administración, Portal de Sucursal). -- [Module](./module.md) (Entidad Propia) — Secciones funcionales modulares dentro de una suite. -- [Menu](./menu.md) (Entidad Propia) — Interfaces gráficas de menús. -- [SubMenu](./sub-menu.md) (Entidad Propia) — Bloques de submenús anidados. -- [Option](./option.md) (Entidad Propia) — Anclajes específicos de configuración de vistas/pantallas. -- [Action](./action.md) (Entidad Propia) — Tokens de acción granulares (ej. READ, WRITE, EXPORT) para asegurar comportamientos individuales. -- [Role](./role.md) (Raíz de Agregado) - Catalogo de responsabilidades acotado por tenant y jerarquia opcional definida por una suite. - -### Permisos y Plantillas -- [PermissionTemplate](./permission-template.md) (Raíz de Agregado) — Paquetes de permisos reutilizables y estandarizados. -- [PermissionTemplateItem](./permission-template-item.md) (Entidad Propia) — Mapeos específicos de acciones definidos dentro de una plantilla. - -### Perfiles de Seguridad -- [Profile](./profile.md) (Raíz de Agregado) — Roles asignados a los usuarios delimitados por su ámbito (GLOBAL, TENANT o BRANCH). -- [ProfilePermission](./profile-permission.md) (Entidad Propia) — Acciones permitidas específicas asignadas a un perfil. + +* [SystemSuite](./system-suite.md) (Raíz de Agregado) — Aplicaciones de nivel superior de la plataforma (ej. Portal de Administración, Portal de Sucursal). +* [Module](./module.md) (Entidad Propia) — Secciones funcionales modulares dentro de una suite. +* [MenuNode](./menu-node.md) (Entidad Propia) — Nodo del **árbol de navegación recursivo** de un módulo (ADR-0090): profundidad variable (`Menu`/`SubMenu`/`Option` como roles), funcionalidad N:M por `ActionCode` y metadatos de gobernanza SDLC por nodo. Reemplaza a las antiguas entidades rígidas Menú/Submenú/Opción. +* [Action](./action.md) (Entidad Propia) — Tokens de acción granulares (ej. READ, WRITE, EXPORT) para asegurar comportamientos individuales. +* [Role](./role.md) (Raíz de Agregado) - Catalogo de responsabilidades acotado por tenant y jerarquia opcional definida por una suite. + +## Permisos y Plantillas + +* [PermissionTemplate](./permission-template.md) (Raíz de Agregado) — Paquetes de permisos reutilizables y estandarizados. +* [PermissionTemplateItem](./permission-template-item.md) (Entidad Propia) — Mapeos específicos de acciones definidos dentro de una plantilla. + +## Perfiles de Seguridad + +* [Profile](./profile.md) (Raíz de Agregado) — Roles asignados a los usuarios delimitados por su ámbito (GLOBAL, TENANT o BRANCH). +* [ProfilePermission](./profile-permission.md) (Entidad Propia) — Acciones permitidas específicas asignadas a un perfil. --- diff --git a/docs/domain-es/authorization/menu-node.md b/docs/domain-es/authorization/menu-node.md new file mode 100644 index 00000000..634dbc3d --- /dev/null +++ b/docs/domain-es/authorization/menu-node.md @@ -0,0 +1,99 @@ +# MenuNode — Árbol de Navegación Recursivo + +**Contexto Delimitado:** Autorización +**Entidad Propia de:** `SystemSuite` → `Module` +**Módulo:** `Ums.Domain.Authorization.SystemSuite.MenuNode` +**Estado:** Producción +**Decisión de referencia:** ADR-0090 (Aceptado en `evolith-core`) · gap G-029 · decisión D-009 + +--- + +## 1. Propósito + +`MenuNode` es la entidad que modela la **topología de navegación de un módulo como un árbol de nodos recursivo**, con profundidad variable. Reemplaza a la antigua jerarquía rígida de cuatro niveles (Suite → Módulo → Menú → Submenú → Opción) que exigía un submenú obligatorio y una relación funcionalidad↔opción 1:1 débil. + +Cada `Module` posee una colección de nodos raíz; cada nodo puede anidar hijos recursivamente. El rol del nodo se clasifica con `NodeKind` (`Menu`, `SubMenu`, `Option`) **sin fijar la profundidad**: un `Menu` o `SubMenu` actúa como rama y una `Option` como hoja. + +## 2. Cambios respecto al modelo rígido (ADR-0090) + +| Dimensión | Modelo rígido (retirado) | Árbol de nodos (`MenuNode`) | +| --- | --- | --- | +| Profundidad | Fija de 4 niveles, submenú obligatorio | Variable; submenú opcional | +| Relación funcionalidad↔opción | 1:1 débil (`ActionCode` string sin FK) | **N:M** vía tabla puente `SystemSuiteNodeActions` | +| Metadatos de gobernanza | Solo `Status` en Suite/Módulo | **Metadatos SDLC por nodo** (VO `MenuNodeMetadata`) | +| Entidades | `Menu`, `SubMenu`, `Option` | `MenuNode` único y recursivo | + +## 3. Estructura del nodo + +* `Id: IdValueObject` +* `ModuleId: ModuleId` — módulo propietario. +* `ParentNodeId: IdValueObject?` — nulo en un nodo raíz; enlaza el árbol (lista de adyacencia). +* `Kind: NodeKind` — `Menu` (1), `SubMenu` (2), `Option` (3). +* `Code: Code`, `Label: Name`, `Description: Description`, `SortOrder: int`. +* `Status: ModuleStatus` — `Active` / `Inactive`. +* `ActionCodes: IReadOnlyCollection` — funcionalidades vinculadas (N:M) en nodos hoja. +* `Metadata: MenuNodeMetadata` — metadatos de gobernanza SDLC (VO). +* `Children: IReadOnlyCollection` — subárbol. + +### `MenuNodeMetadata` (VO de gobernanza SDLC) + +`Responsable`, `Criticidad`, `ProductoImpactado`, `ComponenteTecnico`, `Dependencias`, `Evidencias`, `TrazabilidadSdlc`. Todos opcionales; el conjunto se reemplaza de forma atómica. + +## 4. Operaciones (a través de la raíz `SystemSuite`) + +La raíz de agregado `SystemSuite` delega en `Module`/`MenuNode`: + +* `AddModuleRootNode(moduleId, kind, code, label, description, sortOrder, actor, metadata?)` +* `AddModuleChildNode(moduleId, parentNodeId, kind, code, label, description, sortOrder, actor, metadata?)` +* `UpdateModuleNode(moduleId, nodeId, label, description, sortOrder, actor)` +* `RemoveModuleNode(moduleId, nodeId, actor)` — elimina el nodo y su subárbol. +* `ActivateModuleNode` / `DeactivateModuleNode(moduleId, nodeId, actor)` +* `LinkModuleNodeAction` / `UnlinkModuleNodeAction(moduleId, nodeId, actionCode, actor)` — vínculo N:M. +* `SetModuleNodeMetadata(moduleId, nodeId, metadata, actor)` + +## 5. Persistencia + +* Tabla `ums_authorization.SystemSuiteNodes` — lista de adyacencia (`ParentNodeId`), con columnas de metadatos SDLC. +* Tabla puente `ums_authorization.SystemSuiteNodeActions` — vínculo N:M nodo↔`ActionCode`. +* La carga es plana; el árbol se reconstruye en memoria (`AuthorizationAggregateFactory.RehydrateNode`) agrupando por `ParentNodeId`. + +## 6. Diagrama + +```mermaid +classDiagram + direction TB + class Module { + +Guid Id + +Code Code + +List~MenuNode~ Nodes + } + class MenuNode { + +Guid Id + +Guid ModuleId + +Guid? ParentNodeId + +NodeKind Kind + +Code Code + +Name Label + +ModuleStatus Status + +int SortOrder + +List~ActionCode~ ActionCodes + +MenuNodeMetadata Metadata + +List~MenuNode~ Children + } + class MenuNodeMetadata { + +string? Responsable + +string? Criticidad + +string? ProductoImpactado + +string? ComponenteTecnico + +string? Dependencias + +string? Evidencias + +string? TrazabilidadSdlc + } + Module "1" *-- "0..*" MenuNode : raíces + MenuNode "1" *-- "0..*" MenuNode : hijos + MenuNode "1" o-- "1" MenuNodeMetadata : gobierna +``` + +--- + +**[Volver al Índice de Autorización](./index.md)** diff --git a/docs/domain-es/authorization/menu.md b/docs/domain-es/authorization/menu.md deleted file mode 100644 index 51273eba..00000000 --- a/docs/domain-es/authorization/menu.md +++ /dev/null @@ -1,7 +0,0 @@ -# Menú - -> **Idioma:** [English](../../domain/authorization/menu.md) | **Español** - -Este es un documento estable de referencia para `Menu` dentro del índice del Contexto de Autorización. Las reglas detalladas permanecen documentadas dentro del documento del agregado raíz hasta que se requiera una página independiente más completa. - -**[Volver al Índice de Autorización](./index.md)** diff --git a/docs/domain-es/authorization/module.md b/docs/domain-es/authorization/module.md index 0d3dac98..6be4b7f7 100644 --- a/docs/domain-es/authorization/module.md +++ b/docs/domain-es/authorization/module.md @@ -1,7 +1,5 @@ # Módulo -> **Idioma:** [English](../../domain/authorization/module.md) | **Español** - Este es un documento estable de referencia para `Module` dentro del índice del Contexto de Autorización. Las reglas detalladas permanecen documentadas dentro del documento del agregado raíz hasta que se requiera una página independiente más completa. **[Volver al Índice de Autorización](./index.md)** diff --git a/docs/domain-es/authorization/option.md b/docs/domain-es/authorization/option.md deleted file mode 100644 index 76604d1c..00000000 --- a/docs/domain-es/authorization/option.md +++ /dev/null @@ -1,7 +0,0 @@ -# Opción - -> **Idioma:** [English](../../domain/authorization/option.md) | **Español** - -Este es un documento estable de referencia para `Option` dentro del índice del Contexto de Autorización. Las reglas detalladas permanecen documentadas dentro del documento del agregado raíz hasta que se requiera una página independiente más completa. - -**[Volver al Índice de Autorización](./index.md)** diff --git a/docs/domain-es/authorization/permission-template-item.md b/docs/domain-es/authorization/permission-template-item.md index b0dffb8f..85a33227 100644 --- a/docs/domain-es/authorization/permission-template-item.md +++ b/docs/domain-es/authorization/permission-template-item.md @@ -1,7 +1,5 @@ # Elemento de Plantilla de Permisos -> **Idioma:** [English](../../domain/authorization/permission-template-item.md) | **Español** - Este es un documento estable de referencia para `PermissionTemplateItem` dentro del índice del Contexto de Autorización. Las reglas detalladas permanecen documentadas dentro del documento del agregado raíz hasta que se requiera una página independiente más completa. **[Volver al Índice de Autorización](./index.md)** diff --git a/docs/domain-es/authorization/permission-template.md b/docs/domain-es/authorization/permission-template.md index 229dbf7c..c7ead6b3 100644 --- a/docs/domain-es/authorization/permission-template.md +++ b/docs/domain-es/authorization/permission-template.md @@ -10,17 +10,21 @@ ## 1. Visión General del Agregado ### Propósito + El agregado `PermissionTemplate` define paquetes de derechos de acceso (permisos) estándar y reutilizables mapeados a varios roles del sistema (ej. "Empleado Estándar", "Administrador de Sucursal", "Administrador Financiero del Inquilino"). Actúa como un plano estandarizado que simplifica y automatiza el aprovisionamiento de perfiles (roles) dinámicos cuando se registran nuevos inquilinos o se incorporan nuevos usuarios. ### Responsabilidad de Negocio -- Crear y mantener plantillas de seguridad preempaquetadas. -- Vincular las Acciones de suite granulares a una plantilla con nombre mediante `PermissionTemplateItem`. -- Facilitar configuraciones de seguridad consistentes y reproducibles entre inquilinos. + +* Crear y mantener plantillas de seguridad preempaquetadas. +* Vincular las Acciones de suite granulares a una plantilla con nombre mediante `PermissionTemplateItem`. +* Facilitar configuraciones de seguridad consistentes y reproducibles entre inquilinos. ### Raíz de Agregado + `PermissionTemplate` es la raíz del agregado. Los detalles de configuración de elementos secundarios se administran dentro de la colección de entidades propias `PermissionTemplateItem`. ### Invariantes y Reglas de Consistencia + 1. El `Code` de la plantilla debe ser único en todo el sistema. 2. Una plantilla debe contener al menos un `PermissionTemplateItem` para estar activa. 3. Si una `Action` subyacente en `SystemSuite` se elimina, el `PermissionTemplateItem` correspondiente se elimina automáticamente en cascada. @@ -28,15 +32,17 @@ El agregado `PermissionTemplate` define paquetes de derechos de acceso (permisos 5. La `PermissionKey` almacenada debe coincidir exactamente con la clave calculada dentro del catálogo `Action` en el momento de la validación. ### Entidades Relacionadas / Objetos de Valor + | Entidad / VO | Tipo | Propietario | -|---|---|---| +| --- | --- | --- | | `PermissionTemplateItem` | Entidad | Propia | | `TemplateCode` | Objeto de Valor | Código de plantilla alfanumérico | | `TemplateName` | Objeto de Valor | Descripción y etiqueta de visualización | ### Eventos de Dominio + | Evento | Desencadenante | -|---|---| +| --- | --- | | `PermissionTemplateCreatedEvent` | Nueva plantilla creada | | `PermissionTemplateUpdatedEvent` | Detalles de plantilla modificados | | `PermissionTemplateDeletedEvent` | Plantilla eliminada | @@ -44,8 +50,9 @@ El agregado `PermissionTemplate` define paquetes de derechos de acceso (permisos | `PermissionTemplateItemRemovedEvent` | Elemento mapeado removido de la plantilla | ### Comandos / Casos de Uso + | Comando | Descripción | -|---|---| +| --- | --- | | `CreatePermissionTemplateCommand` | Crear una nueva plantilla | | `AddTemplateItemCommand` | Agrega un mapeo de acción (elemento) a la plantilla | @@ -54,7 +61,8 @@ El agregado `PermissionTemplate` define paquetes de derechos de acceso (permisos ## 2. Modelo de Dominio ### Clases / Entidades / Objetos de Valor -``` + +```text PermissionTemplate (Raíz de Agregado) ├── Props: PermissionTemplateProps │ ├── Id: IdValueObject @@ -73,8 +81,9 @@ PermissionTemplate (Raíz de Agregado) ``` ### Atributos Principales + | Entidad | Atributo | Tipo | Notas | -|---|---|---|---| +| --- | --- | --- | --- | | `PermissionTemplate` | `Id` | `Guid` | PK | | `PermissionTemplate` | `Code` | `string` | Único | | `PermissionTemplate` | `IsActive` | `bool` | Flag de estado | @@ -115,6 +124,7 @@ classDiagram ## 4. Diagramas de Secuencia ### Flujo para Crear una Plantilla + ```mermaid sequenceDiagram participant C as Cliente @@ -162,39 +172,45 @@ erDiagram ``` ### Reglas de Aislamiento de Inquilinos -- Las plantillas se pueden configurar como **Globales** (disponibles en toda la plataforma para todos los inquilinos) o **Delimitadas por Inquilino** (disponibles solo para el inquilino que las creó). Las tablas delimitadas por inquilino incluyen una columna `TenantId` que admite valores nulos. -- `PermissionTemplateItem` hereda el alcance de aislamiento del agregado padre `PermissionTemplate`. + +* Las plantillas se pueden configurar como **Globales** (disponibles en toda la plataforma para todos los inquilinos) o **Delimitadas por Inquilino** (disponibles solo para el inquilino que las creó). Las tablas delimitadas por inquilino incluyen una columna `TenantId` que admite valores nulos. +* `PermissionTemplateItem` hereda el alcance de aislamiento del agregado padre `PermissionTemplate`. --- ## 6. Integración de Contexto Delimitado -- Consume metadatos de `Action` del agregado `SystemSuite` y mapea sus identificadores dinámicos. -- Los perfiles de seguridad aguas abajo consumen estas plantillas para inicializar permisos de perfiles predeterminados. + +* Consume metadatos de `Action` del agregado `SystemSuite` y mapea sus identificadores dinámicos. +* Los perfiles de seguridad aguas abajo consumen estas plantillas para inicializar permisos de perfiles predeterminados. --- ## 7. Capa de Aplicación -- `CreatePermissionTemplateCommand` -> Entradas: `Code, Name, Description` -> Retorna: `Guid` -- `AddTemplateItemCommand` -> Entradas: `TemplateId, ActionId, PermissionKey` -> Retorna: `Guid` + +* `CreatePermissionTemplateCommand` -> Entradas: `Code, Name, Description` -> Retorna: `Guid` +* `AddTemplateItemCommand` -> Entradas: `TemplateId, ActionId, PermissionKey` -> Retorna: `Guid` --- ## 8. Infraestructura/Persistencia -- Límite de transacción: `PermissionTemplateItem` se guarda como parte de la persistencia de `PermissionTemplate`. -- Índice: Índice único en `Code` e índice en `TenantId` para la plantilla. Índice único en `TemplateId, ActionId` para los elementos. + +* Límite de transacción: `PermissionTemplateItem` se guarda como parte de la persistencia de `PermissionTemplate`. +* Índice: Índice único en `Code` e índice en `TenantId` para la plantilla. Índice único en `TemplateId, ActionId` para los elementos. --- ## 9. Seguridad y Cumplimiento -- La edición de plantillas globales está restringida al rol `Platform:Admin`. -- La creación de plantillas delimitadas por inquilino está restringida al rol `Tenant:Admin`. -- El alcance administrativo sobre los elementos coincide con las reglas de la plantilla padre. + +* La edición de plantillas globales está restringida al rol `Platform:Admin`. +* La creación de plantillas delimitadas por inquilino está restringida al rol `Tenant:Admin`. +* El alcance administrativo sobre los elementos coincide con las reglas de la plantilla padre. --- ## 10. Decisiones Técnicas -- El uso de plantillas de inicialización estándar evita la fatiga de configuración manual durante el registro de nuevas organizaciones. -- Duplicar la `PermissionKey` calculada directamente dentro de la tabla de elementos sirve como una optimización de caché desnormalizada para los cálculos de permisos de alta velocidad. + +* El uso de plantillas de inicialización estándar evita la fatiga de configuración manual durante el registro de nuevas organizaciones. +* Duplicar la `PermissionKey` calculada directamente dentro de la tabla de elementos sirve como una optimización de caché desnormalizada para los cálculos de permisos de alta velocidad. --- diff --git a/docs/domain-es/authorization/profile-permission.md b/docs/domain-es/authorization/profile-permission.md index cc22aa6e..4d71bfe6 100644 --- a/docs/domain-es/authorization/profile-permission.md +++ b/docs/domain-es/authorization/profile-permission.md @@ -1,7 +1,5 @@ # Permiso de Perfil -> **Idioma:** [English](../../domain/authorization/profile-permission.md) | **Español** - Este es un documento estable de referencia para `ProfilePermission` dentro del índice del Contexto de Autorización. Las reglas detalladas permanecen documentadas dentro del documento del agregado raíz hasta que se requiera una página independiente más completa. **[Volver al Índice de Autorización](./index.md)** diff --git a/docs/domain-es/authorization/profile.md b/docs/domain-es/authorization/profile.md index a3130f4a..18aaf28c 100644 --- a/docs/domain-es/authorization/profile.md +++ b/docs/domain-es/authorization/profile.md @@ -10,19 +10,23 @@ ## 1. Visión General del Agregado ### Propósito + El agregado `Profile` representa una asignación efectiva de autorización para un usuario dentro de un tenant. Vincula un `UserId` con un `RoleId` y opcionalmente con un `BranchId`, y luego materializa permisos efectivos a partir de definiciones publicadas de `PermissionTemplate`. Es el contenedor padre de las entidades propias `ProfilePermission` y la fuente operativa usada por los validadores de acceso aguas abajo. ### Responsabilidad de Negocio -- Representar la huella de autorización activa de un usuario en un tenant. -- Hacer cumplir los límites de alcance entre acceso organizacional y acceso por sucursal. -- Materializar elementos de plantillas publicadas en permisos efectivos `ProfilePermission`. -- Permitir anulaciones controladas por permiso sin mutar la plantilla fuente. -- Controlar el ciclo de vida completo del perfil (`Active` / `Inactive`). + +* Representar la huella de autorización activa de un usuario en un tenant. +* Hacer cumplir los límites de alcance entre acceso organizacional y acceso por sucursal. +* Materializar elementos de plantillas publicadas en permisos efectivos `ProfilePermission`. +* Permitir anulaciones controladas por permiso sin mutar la plantilla fuente. +* Controlar el ciclo de vida completo del perfil (`Active` / `Inactive`). ### Raíz de Agregado + `Profile` es la raíz del agregado. El enlace de plantillas, las anulaciones de permisos, la activación/desactivación de permisos y los cambios de estado del agregado deben pasar por `Profile`. ### Invariantes y Reglas de Consistencia + 1. `TenantId`, `UserId` y `RoleId` son obligatorios para todo `Profile`. 2. `Scope` se deriva de `BranchId`: sin sucursal es `OrgWide`; con sucursal es `BranchScoped`. 3. Un `Profile` solo puede enlazar instancias de `PermissionTemplate` del mismo tenant. @@ -32,8 +36,9 @@ El agregado `Profile` representa una asignación efectiva de autorización para 7. La identidad de `ProfilePermission` se materializa por cada elemento de plantilla y conserva trazabilidad mediante `TemplateId`. ### Entidades Relacionadas / Objetos de Valor + | Entidad / VO | Tipo | Propiedad | Descripción | -|---|---|---|---| +| --- | --- | --- | --- | | `ProfilePermission` | Entidad | Propia | Permiso efectivo materializado desde un elemento de plantilla | | `ProfileScope` | Enumeración | - | `OrgWide` o `BranchScoped` | | `TenantId` | Objeto de Valor | - | Límite de pertenencia del tenant | @@ -43,8 +48,9 @@ El agregado `Profile` representa una asignación efectiva de autorización para | `TemplateId` | Objeto de Valor | - | Trazabilidad hacia la plantilla origen | ### Eventos de Dominio + | Evento | Disparador | -|---|---| +| --- | --- | | `ProfileCreatedEvent` | Nuevo perfil creado | | `TemplateLinkedToProfileEvent` | Plantilla publicada enlazada y materializada en permisos | | `PermissionOverriddenEvent` | Se aplica allow / deny / neutral / activate / deactivate sobre un permiso | @@ -56,6 +62,7 @@ El agregado `Profile` representa una asignación efectiva de autorización para ## 2. Modelo de Dominio ### Clases / Entidades / Objetos de Valor + ```text Profile (Raíz de Agregado) ├── Props: ProfileProps @@ -129,6 +136,7 @@ classDiagram ## 4. Diagramas de Secuencia ### Flujo de Creación de Perfil y Asignación de Plantilla + ```mermaid sequenceDiagram participant C as Cliente @@ -154,6 +162,7 @@ sequenceDiagram ``` ### Flujo de Anulación de Permiso + ```mermaid sequenceDiagram participant C as Cliente @@ -221,48 +230,54 @@ erDiagram ``` ### Reglas de Aislamiento por Tenant -- `Profile` siempre pertenece a un tenant en la implementación actual; `TenantId` es obligatorio. -- El comportamiento organizacional se modela con `ScopeId = OrgWide`, no con `TenantId` nulo. -- `PROFILE_PERMISSION` hereda el aislamiento desde su `Profile` padre. + +* `Profile` siempre pertenece a un tenant en la implementación actual; `TenantId` es obligatorio. +* El comportamiento organizacional se modela con `ScopeId = OrgWide`, no con `TenantId` nulo. +* `PROFILE_PERMISSION` hereda el aislamiento desde su `Profile` padre. --- ## 6. Integración entre Contextos Delimitados -- **Aguas arriba**: consume `TenantId`, `UserId` y `BranchId` del Contexto de Identidad. -- Consume `RoleId` y definiciones publicadas de `PermissionTemplate` dentro del contexto de Autorización. -- Consume `ActionId` y la topología objetivo desde `SystemSuite`. -- Es consumido por Aprobaciones, IGA y evaluadores de autorización en runtime. + +* **Aguas arriba**: consume `TenantId`, `UserId` y `BranchId` del Contexto de Identidad. +* Consume `RoleId` y definiciones publicadas de `PermissionTemplate` dentro del contexto de Autorización. +* Consume `ActionId` y la topología objetivo desde `SystemSuite`. +* Es consumido por Aprobaciones, IGA y evaluadores de autorización en runtime. --- ## 7. Capa de Aplicación -- `CreateProfileCommand` -> Entradas: `TenantId, UserId, RoleId, BranchId?` -> Retorna: `Guid` -- La asignación de plantillas, las anulaciones de permisos y los cambios de estado de permisos se exponen por endpoints REST junto con los comandos centrales del ciclo de vida del perfil. + +* `CreateProfileCommand` -> Entradas: `TenantId, UserId, RoleId, BranchId?` -> Retorna: `Guid` +* La asignación de plantillas, las anulaciones de permisos y los cambios de estado de permisos se exponen por endpoints REST junto con los comandos centrales del ciclo de vida del perfil. --- ## 8. Infraestructura / Persistencia -- Se guarda dentro del límite transaccional de `Profile`. -- Tabla actual en SQL Server: `[ums_authorization].[Profiles]` -- Tabla hija actual en SQL Server: `[ums_authorization].[ProfilePermissions]` -- Índices actuales de `Profile`: `TenantId`, `UserId`, `(TenantId, UserId, RoleId, BranchId)` -- Índices actuales de `ProfilePermission`: `ProfileId`, `(ProfileId, TemplateId, ActionId, TargetId)` -- La metadata de auditoría se persiste tanto en la raíz como en cada permiso. + +* Se guarda dentro del límite transaccional de `Profile`. +* Tabla actual en PostgreSQL: `ums_authorization.profiles` +* Tabla hija actual en PostgreSQL: `ums_authorization.profile_permissions` +* Índices actuales de `Profile`: `TenantId`, `UserId`, `(TenantId, UserId, RoleId, BranchId)` +* Índices actuales de `ProfilePermission`: `ProfileId`, `(ProfileId, TemplateId, ActionId, TargetId)` +* La metadata de auditoría se persiste tanto en la raíz como en cada permiso. --- ## 9. Seguridad y Cumplimiento -- La mutación de perfiles es una operación sensible y actualiza la metadata de auditoría del agregado en cada cambio. -- La autorización efectiva puede endurecerse mediante overrides `deny` o `neutral` sin cambiar la plantilla fuente. -- Los evaluadores aguas abajo deben tratar perfiles y permisos inactivos como no efectivos. + +* La mutación de perfiles es una operación sensible y actualiza la metadata de auditoría del agregado en cada cambio. +* La autorización efectiva puede endurecerse mediante overrides `deny` o `neutral` sin cambiar la plantilla fuente. +* Los evaluadores aguas abajo deben tratar perfiles y permisos inactivos como no efectivos. --- ## 10. Decisiones Técnicas -- `Profile` representa una asignación efectiva de autorización, no un catálogo de roles nombrados. -- `Scope` se persiste como identificador de enumeración (`ScopeId`) y se deriva desde `BranchId` al crear el agregado. -- Los permisos efectivos conservan trazabilidad mediante `TemplateId`, habilitando reevaluación, auditoría y reconstrucción futura. -- Los cambios manuales se expresan con `IsOverride` y toggles de estado en `ProfilePermission`, en lugar de mutar `PermissionTemplate`. + +* `Profile` representa una asignación efectiva de autorización, no un catálogo de roles nombrados. +* `Scope` se persiste como identificador de enumeración (`ScopeId`) y se deriva desde `BranchId` al crear el agregado. +* Los permisos efectivos conservan trazabilidad mediante `TemplateId`, habilitando reevaluación, auditoría y reconstrucción futura. +* Los cambios manuales se expresan con `IsOverride` y toggles de estado en `ProfilePermission`, en lugar de mutar `PermissionTemplate`. --- diff --git a/docs/domain-es/authorization/role.md b/docs/domain-es/authorization/role.md index 28da38e4..744a9ced 100644 --- a/docs/domain-es/authorization/role.md +++ b/docs/domain-es/authorization/role.md @@ -12,7 +12,7 @@ ## Contrato de Catalogo | Campo | Regla | -|---|---| +| --- | --- | | `Code` | Requerido y unico dentro de `SystemSuiteId`; codigo estable de maquina. | | `Value` | Valor visible requerido para administradores. | | `Description` | Explicacion funcional mantenida con el elemento del catalogo. | @@ -58,15 +58,15 @@ classDiagram ## Contrato de Aplicacion -- Comandos: REST `POST /system-suites/{systemSuiteId}/roles`, `PUT /system-suites/{systemSuiteId}/roles/{roleId}` y endpoints de estado. -- Consulta: GraphQL `rolesBySystemSuite(systemSuiteId)`. -- Interfaz: la pestana `Roles` pertenece al panel de detalle de la Suite del Sistema seleccionada. -- Las fallas aptas para el usuario indican la causa corregible y exponen `ErrorId` para soporte; trazas y detalles de implementacion se registran solamente mediante Serilog/Loki. +* Comandos: REST `POST /system-suites/{systemSuiteId}/roles`, `PUT /system-suites/{systemSuiteId}/roles/{roleId}` y endpoints de estado. +* Consulta: REST `GET /system-suites/{systemSuiteId}/roles`. +* Interfaz: la pestana `Roles` pertenece al panel de detalle de la Suite del Sistema seleccionada. +* Las fallas aptas para el usuario indican la causa corregible y exponen `ErrorId` para soporte; trazas y detalles de implementacion se registran solamente mediante Serilog/Loki. ## Persistencia y Aislamiento -- Tabla SQL Server: `[ums_authorization].[Roles]`. -- Los filtros de consulta de aplicacion restringen registros por `TenantId`; los resguardos de SQL Server son controles secundarios. -- Las FK aseguran pertenencia a la suite y la relacion propia opcional al padre. +* Tabla PostgreSQL: `ums_authorization.roles`. +* El aislamiento es por global query filters de EF Core sobre `TenantId`/`OrganizationId` (mecanismo primario); el failsafe RLS a nivel de BD no esta activo con PostgreSQL (ver [G-020](../../../../GAPS.md)). +* Las FK aseguran pertenencia a la suite y la relacion propia opcional al padre. **[Volver al Indice de Autorizacion](./index.md)** diff --git a/docs/domain-es/authorization/sub-menu.md b/docs/domain-es/authorization/sub-menu.md deleted file mode 100644 index 72f61c49..00000000 --- a/docs/domain-es/authorization/sub-menu.md +++ /dev/null @@ -1,7 +0,0 @@ -# SubMenú - -> **Idioma:** [English](../../domain/authorization/sub-menu.md) | **Español** - -Este es un documento estable de referencia para `SubMenu` dentro del índice del Contexto de Autorización. Las reglas detalladas permanecen documentadas dentro del documento del agregado raíz hasta que se requiera una página independiente más completa. - -**[Volver al Índice de Autorización](./index.md)** diff --git a/docs/domain-es/authorization/system-suite.md b/docs/domain-es/authorization/system-suite.md index ddc88ddb..3b840844 100644 --- a/docs/domain-es/authorization/system-suite.md +++ b/docs/domain-es/authorization/system-suite.md @@ -10,31 +10,39 @@ ## 1. Visión General del Agregado ### Propósito -El agregado `SystemSuite` representa una superficie de aplicacion perteneciente a un tenant y registrada en UMS. Define la topologia funcional consumida por los modelos de autorizacion aguas abajo y almacena configuraciones operativas a nivel de suite. En la implementacion actual, posee `Module`, topologia de menus, `DomainResource` (Agregados, Entidades y Métodos de Dominio), `AppSetting` y `Action`. El agregado independiente `Role` se mantiene en el contexto de la suite seleccionada y la referencia mediante `SystemSuiteId`. Durante el bootstrap, `UMS` es la suite base canonica para la superficie de gestion del tenant. + +El agregado `SystemSuite` representa una superficie de aplicacion perteneciente a un tenant y registrada en UMS. Define la topologia funcional consumida por los modelos de autorizacion aguas abajo y almacena configuraciones operativas a nivel de suite. En la implementacion actual, posee `Module`, un **árbol de navegación recursivo de nodos** (`MenuNode`, ADR-0090), `DomainResource` (Agregados, Entidades y Métodos de Dominio), `AppSetting` y `Action`. El agregado independiente `Role` se mantiene en el contexto de la suite seleccionada y la referencia mediante `SystemSuiteId`. Durante el bootstrap, `UMS` es la suite base canonica para la superficie de gestion del tenant. + +> **Topología de navegación (ADR-0090):** cada `Module` posee un árbol recursivo de [`MenuNode`](./menu-node.md) de profundidad variable, con funcionalidad **N:M** por `ActionCode` y metadatos de gobernanza SDLC por nodo. Este modelo reemplaza a la antigua jerarquía rígida Menú → Submenú → Opción, ya retirada de todas las capas. ### Responsabilidad de Negocio -- Registrar una suite de software asociada a un tenant. -- Mantener la identidad de la suite: `Code`, `Name`, `Description`, `Status`. -- Poseer módulos funcionales, recursos de dominio (Agregados, Entidades y Métodos de Dominio) y configuraciones operativas de la suite. -- Exponer la superficie de acciones consumida por `PermissionTemplate` y por los flujos de autorización efectiva. -- Definir el limite propietario del catalogo de roles mantenido por Autorizacion. -- Controlar el estado de activación mediante `SystemStatus`. + +* Registrar una suite de software asociada a un tenant. +* Mantener la identidad de la suite: `Code`, `Name`, `Description`, `Status`. +* Poseer módulos funcionales, recursos de dominio (Agregados, Entidades y Métodos de Dominio) y configuraciones operativas de la suite. +* Exponer la superficie de acciones consumida por `PermissionTemplate` y por los flujos de autorización efectiva. +* Definir el limite propietario del catalogo de roles mantenido por Autorizacion. +* Controlar el estado de activación mediante `SystemStatus`. ### Raíz de Agregado + `SystemSuite` es la raíz del agregado. Los cambios sobre identidad, módulos, recursos de dominio, configuraciones y estado deben pasar por la raíz. ### Invariantes y Reglas de Consistencia + 1. `TenantId`, `Code`, `Name` y `Description` son obligatorios. 2. `Code` debe ser único dentro del tenant propietario. -3. `Module.Code` debe ser único dentro de la suite. +3. `Module.Code` debe ser único dentro de la suite; el `Code` de un nodo raíz debe ser único dentro de su módulo. 4. Las configuraciones no pueden duplicar la misma `ConfigurationKey` para el mismo `ConfigurationScope`. 5. La activación y desactivación de módulos se controla desde el agregado padre. 6. Las acciones referenciadas por plantillas de permisos aguas abajo deben pertenecer a la topología de la suite gobernada por este agregado. ### Entidades Relacionadas / Objetos de Valor + | Entidad / VO | Tipo | Propiedad | Descripción | -|---|---|---|---| +| --- | --- | --- | --- | | `Module` | Entidad | Propia | Subsistema funcional dentro de la suite | +| [`MenuNode`](./menu-node.md) | Entidad | Propia (vía `Module`) | Nodo del árbol de navegación recursivo (ADR-0090): profundidad variable, N:M por `ActionCode`, metadatos SDLC | | `AppSetting` | Entidad | Propia | Configuración a nivel de suite | | `Action` | Entidad | Propia / catalogada | Tokens de acción expuestos para targeting de autorización | | `Role` | Raiz de Agregado | Relacionada por `SystemSuiteId` | Catalogo de responsabilidades y jerarquia definido para la suite | @@ -45,8 +53,9 @@ El agregado `SystemSuite` representa una superficie de aplicacion perteneciente | `SystemStatus` | Enumeración | - | `Active`, `Inactive`, `Beta`, etc. | ### Eventos de Dominio + | Evento | Disparador | -|---|---| +| --- | --- | | `SystemSuiteRegisteredEvent` | Nueva suite creada | | `SystemSuiteStatusChangedEvent` | Cambio de estado de la suite | | `SystemSuiteModuleAddedEvent` | Módulo agregado | @@ -69,6 +78,10 @@ SystemSuite (Raíz de Agregado) │ └── Audit: AuditValueObject ├── Hijos │ ├── IReadOnlyCollection +│ │ └── IReadOnlyCollection // árbol recursivo (ADR-0090) +│ │ ├── IReadOnlyCollection // N:M funcionalidad↔nodo +│ │ ├── MenuNodeMetadata // metadatos SDLC +│ │ └── IReadOnlyCollection // hijos (recursivo) │ └── IReadOnlyCollection └── Superficie de Catálogo └── IReadOnlyCollection @@ -113,6 +126,18 @@ classDiagram +Description Description +int SortOrder +ModuleStatus Status + +List~MenuNode~ Nodes + } + class MenuNode { + +Guid Id + +Guid? ParentNodeId + +NodeKind Kind + +Code Code + +Name Label + +ModuleStatus Status + +List~ActionCode~ ActionCodes + +MenuNodeMetadata Metadata + +List~MenuNode~ Children } class DomainResource { +Guid Id @@ -134,6 +159,8 @@ classDiagram +ActionCode Code } SystemSuite "1" *-- "0..*" Module : contiene + Module "1" *-- "0..*" MenuNode : raíces + MenuNode "1" *-- "0..*" MenuNode : hijos SystemSuite "1" *-- "0..*" DomainResource : posee SystemSuite "1" *-- "0..*" AppSetting : configura SystemSuite "1" *-- "0..*" Action : expone @@ -144,6 +171,7 @@ classDiagram ## 4. Diagramas de Secuencia ### Flujo de Alta de Módulo + ```mermaid sequenceDiagram participant C as Cliente @@ -170,6 +198,9 @@ sequenceDiagram erDiagram TENANT ||--o{ SYSTEM_SUITE : "posee" SYSTEM_SUITE ||--o{ MODULE : "contiene" + MODULE ||--o{ SYSTEM_SUITE_NODE : "contiene (árbol recursivo)" + SYSTEM_SUITE_NODE ||--o{ SYSTEM_SUITE_NODE : "hijos (ParentNodeId)" + SYSTEM_SUITE_NODE ||--o{ SYSTEM_SUITE_NODE_ACTION : "vincula (N:M)" SYSTEM_SUITE ||--o{ APP_SETTING : "define" SYSTEM_SUITE ||--o{ ACTION : "expone" SYSTEM_SUITE ||--o{ ROLE : "define" @@ -190,43 +221,49 @@ erDiagram ``` ### Reglas de Aislamiento por Tenant -- `SystemSuite` pertenece a un tenant en la implementación actual. -- Módulos, configuraciones y acciones heredan la pertenencia a través del límite del agregado. + +* `SystemSuite` pertenece a un tenant en la implementación actual. +* Módulos, configuraciones y acciones heredan la pertenencia a través del límite del agregado. --- ## 6. Integración entre Contextos Delimitados -- Aguas arriba: contexto de tenant desde Identity. -- Aguas abajo: consumido por `PermissionTemplate` y por la resolución de autorización efectiva. -- Las acciones expuestas por la suite son referenciadas por plantillas y perfiles. + +* Aguas arriba: contexto de tenant desde Identity. +* Aguas abajo: consumido por `PermissionTemplate` y por la resolución de autorización efectiva. +* Las acciones expuestas por la suite son referenciadas por plantillas y perfiles. --- ## 7. Capa de Aplicación -- `CreateSystemSuiteCommand` -> Entradas: `TenantId, Code, Name, Description` -> Retorna: `Guid` -- `UpdateSystemSuiteCommand` -> Entradas: `SystemSuiteId, Name, Description` -> Retorna: `void` -- `SetSystemSuiteStatusCommand` -> Entradas: `SystemSuiteId, Status` -> Retorna: `void` -- `CreateRoleCommand`, `UpdateRoleCommand` y `SetRoleStatusCommand` operan sobre roles bajo la suite seleccionada. -- GraphQL expone `rolesBySystemSuite(systemSuiteId)` para la pestana Roles del detalle. + +* `CreateSystemSuiteCommand` -> Entradas: `TenantId, Code, Name, Description` -> Retorna: `Guid` +* `UpdateSystemSuiteCommand` -> Entradas: `SystemSuiteId, Name, Description` -> Retorna: `void` +* `SetSystemSuiteStatusCommand` -> Entradas: `SystemSuiteId, Status` -> Retorna: `void` +* `CreateRoleCommand`, `UpdateRoleCommand` y `SetRoleStatusCommand` operan sobre roles bajo la suite seleccionada. +* REST expone `GET /api/v1/system-suites/{systemSuiteId}/roles` para la pestana Roles del detalle. --- ## 8. Infraestructura / Persistencia -- Existen implementaciones de repositorio SQL Server e in-memory para modos de desarrollo y ejecucion de suite y roles. -- `[ums_authorization].[Roles]` referencia `[ums_authorization].[SystemSuites]` y soporta una FK nullable al rol padre. -- El filtrado de tenant en la aplicacion es el mecanismo primario de aislamiento. + +* Existen implementaciones de repositorio PostgreSQL e in-memory para modos de desarrollo y ejecucion de suite y roles. +* `ums_authorization.roles` referencia `ums_authorization.system_suites` y soporta una FK nullable al rol padre. +* El filtrado de tenant en la aplicacion es el mecanismo primario de aislamiento. --- ## 9. Seguridad y Cumplimiento -- La definición de suites es una capacidad administrativa. -- Los cambios de módulos y estado afectan el comportamiento de autorización aguas abajo y deben auditarse. + +* La definición de suites es una capacidad administrativa. +* Los cambios de módulos y estado afectan el comportamiento de autorización aguas abajo y deben auditarse. --- ## 10. Decisiones Técnicas -- `SystemSuite` pertenece a un tenant en el modelo de dominio actual, aunque documentación previa lo describiera como catálogo global. -- El agregado actual prioriza gestión de módulos, configuraciones y una superficie plana de acciones por encima de la narrativa anterior del árbol profundo de menús. + +* `SystemSuite` pertenece a un tenant en el modelo de dominio actual, aunque documentación previa lo describiera como catálogo global. +* **ADR-0090 (Aceptado):** la topología de navegación es un **árbol de nodos recursivo** por módulo ([`MenuNode`](./menu-node.md)) de profundidad variable, con funcionalidad **N:M** por `ActionCode` y **metadatos de gobernanza SDLC** por nodo. La antigua jerarquía rígida Menú → Submenú → Opción fue retirada de las seis capas (frontend, endpoints, aplicación, persistencia, dominio y documentación) y los datos existentes se proyectaron a nodos sin pérdida (migración `ProjectHierarchyToNodes`; drop `DropSystemSuiteMenusSubMenusOptions`). Ver gap G-029 y decisión D-009. --- diff --git a/docs/domain-es/configuration/app-configuration.md b/docs/domain-es/configuration/app-configuration.md index 6132d33b..fd0c412d 100644 --- a/docs/domain-es/configuration/app-configuration.md +++ b/docs/domain-es/configuration/app-configuration.md @@ -10,19 +10,23 @@ ## 1. Visión General del Agregado ### Propósito + El agregado `AppConfiguration` representa una entrada individual de configuración jerárquica en UMS. Sigue el patrón corporativo obligatorio `code / value / description` y puede quedar acotado globalmente, por tenant, por suite o por módulo. ### Responsabilidad de Negocio -- Persistir entradas de configuración con significado explícito de negocio. -- Resolver y preservar el alcance de configuración. -- Soportar banderas de herencia y cifrado. -- Controlar el ciclo de vida desde draft a published y archived. -- Versionar cambios de configuración a lo largo del tiempo. + +* Persistir entradas de configuración con significado explícito de negocio. +* Resolver y preservar el alcance de configuración. +* Soportar banderas de herencia y cifrado. +* Controlar el ciclo de vida desde draft a published y archived. +* Versionar cambios de configuración a lo largo del tiempo. ### Raíz de Agregado + `AppConfiguration` es una raíz de agregado independiente. Cada fila de configuración se administra de forma autónoma. ### Invariantes y Reglas de Consistencia + 1. Toda entrada debe contener `Code`, `Value` y `Description`. 2. El alcance se deriva de la presencia de `TenantId`, `SystemSuiteId` y `ModuleId`. 3. Las nuevas configuraciones nacen en `Draft`. @@ -31,8 +35,9 @@ El agregado `AppConfiguration` representa una entrada individual de configuraci 6. Las actualizaciones incrementan la versión semántica. ### Entidades Relacionadas / Objetos de Valor + | Entidad / VO | Tipo | Propiedad | -|---|---|---| +| --- | --- | --- | | `AppConfigurationId` | Objeto de Valor | Identificador del agregado | | `TenantId` | Objeto de Valor | Alcance opcional por tenant | | `SystemSuiteId` | Objeto de Valor | Alcance opcional por suite | @@ -44,8 +49,9 @@ El agregado `AppConfiguration` representa una entrada individual de configuraci | `ConfigStatus` | Enumeración | `Draft`, `Published`, `Archived` | ### Eventos de Dominio + | Evento | Disparador | -|---|---| +| --- | --- | | `AppConfigCreatedEvent` | Nueva configuración creada | | `AppConfigUpdatedEvent` | Configuración draft actualizada | | `AppConfigPublishedEvent` | Configuración publicada | @@ -104,6 +110,7 @@ classDiagram ## 4. Diagramas de Secuencia ### Flujo de Publicación + ```mermaid sequenceDiagram participant C as Cliente @@ -149,36 +156,42 @@ erDiagram ``` ### Reglas de Aislamiento por Tenant -- Las entradas globales pueden tener `TenantId` nulo. -- Las entradas por tenant, suite y módulo se resuelven mediante sus campos explícitos de alcance. + +* Las entradas globales pueden tener `TenantId` nulo. +* Las entradas por tenant, suite y módulo se resuelven mediante sus campos explícitos de alcance. --- ## 6. Integración entre Contextos Delimitados -- Consumido por la resolución de configuración en runtime. -- Puede servir comportamiento global, por tenant, por suite o por módulo. + +* Consumido por la resolución de configuración en runtime. +* Puede servir comportamiento global, por tenant, por suite o por módulo. --- ## 7. Capa de Aplicación -- El agregado de dominio se expone mediante comandos de aplicación y endpoints REST para los flujos de creación, actualización, publicación, archivado y consulta. + +* El agregado de dominio se expone mediante comandos de aplicación y endpoints REST para los flujos de creación, actualización, publicación, archivado y consulta. --- ## 8. Infraestructura / Persistencia -- La persistencia en SQL Server ya soporta el agregado, y la capa de presentación expone los endpoints REST de este contexto. + +* La persistencia en PostgreSQL ya soporta el agregado, y la capa de presentación expone los endpoints REST de este contexto (`GET /api/v1/app-configurations`, `GET /api/v1/app-configurations/{id}`). --- ## 9. Seguridad y Cumplimiento -- `IsEncrypted` identifica entradas que deben tratarse como datos sensibles. -- `Description` debe explicar propósito, impacto, comportamiento esperado y alcance aplicable. + +* `IsEncrypted` identifica entradas que deben tratarse como datos sensibles. +* `Description` debe explicar propósito, impacto, comportamiento esperado y alcance aplicable. --- ## 10. Decisiones Técnicas -- `AppConfiguration` está modelado como una entrada de configuración por agregado, no como una hoja por ambiente con hijos. -- El alcance se resuelve estructuralmente desde los campos de pertenencia y no desde una dimensión libre de ambiente. + +* `AppConfiguration` está modelado como una entrada de configuración por agregado, no como una hoja por ambiente con hijos. +* El alcance se resuelve estructuralmente desde los campos de pertenencia y no desde una dimensión libre de ambiente. --- diff --git a/docs/domain-es/configuration/feature-flag-criteria.md b/docs/domain-es/configuration/feature-flag-criteria.md index 4d9faa05..63604eb4 100644 --- a/docs/domain-es/configuration/feature-flag-criteria.md +++ b/docs/domain-es/configuration/feature-flag-criteria.md @@ -1,7 +1,5 @@ # FeatureFlagCriteria — Modelo de Criterios de Evaluación -> **Idioma:** [English](../../domain/configuration/feature-flag-criteria.md) | [Español](./feature-flag-criteria.md) - **Contexto Delimitado:** Configuración (`Ums.Domain.Configuration`) **Agregado Propietario:** `FeatureFlag` **Tipo de Entidad:** Entidad Propia (sin ciclo de vida independiente) @@ -15,9 +13,9 @@ La colección de criterios es **opcional y dinámica**: -- Una bandera con una colección de criterios **vacía** está activa para todos los llamantes del sistema, independientemente del contexto. -- Una bandera con **uno o más** criterios está activa únicamente cuando el contexto de evaluación satisface las condiciones definidas. -- Los criterios pueden agregarse o eliminarse en cualquier momento mientras la bandera esté en estado `Inactive` o `Active`. +* Una bandera con una colección de criterios **vacía** está activa para todos los llamantes del sistema, independientemente del contexto. +* Una bandera con **uno o más** criterios está activa únicamente cuando el contexto de evaluación satisface las condiciones definidas. +* Los criterios pueden agregarse o eliminarse en cualquier momento mientras la bandera esté en estado `Inactive` o `Active`. Los criterios no tienen estados propios de ciclo de vida. Existen mientras están vinculados a la bandera y se eliminan mediante el `RemoveFeatureFlagCriteriaCommand`. @@ -26,7 +24,7 @@ Los criterios no tienen estados propios de ciclo de vida. Existen mientras está ## 2. Tipos de Criterio (CriteriaTypes) | CriteriaType | Descripción | Tipo JSON del Value | Operadores Soportados | Ejemplo de Value | -|---|---|---|---|---| +| --- | --- | --- | --- | --- | | `TenantId` | Coincide por identificador de tenant | `string` (GUID) | `Equals`, `NotEquals` | `"a3f2e1d0-..."` | | `BranchId` | Coincide por identificador de sucursal | `string` (GUID) | `Equals`, `NotEquals` | `"b7c9a2e1-..."` | | `UserProfileId` | Coincide por identificador de perfil de usuario | `string` (GUID) | `Equals`, `NotEquals` | `"c1d4f5a2-..."` | @@ -38,10 +36,10 @@ Los criterios no tienen estados propios de ciclo de vida. Existen mientras está **Notas sobre el Value JSON:** -- Para el operador `In`, el Value debe ser un array JSON de strings: `["VALOR_A","VALOR_B"]`. -- Para `DateRange`, la fecha de inicio debe ser estrictamente anterior a la de fin (INV-FF5). -- Para `PercentageHash`, el hash se calcula a partir de una identidad de usuario estable (por ejemplo, `UserProfileId`) en módulo 100; el criterio pasa cuando `hash % 100 <= umbral`. -- Para `CustomRule`, la capa de infraestructura resuelve el nombre de la regla a un evaluador concreto registrado en el contenedor de dependencias. +* Para el operador `In`, el Value debe ser un array JSON de strings: `["VALOR_A","VALOR_B"]`. +* Para `DateRange`, la fecha de inicio debe ser estrictamente anterior a la de fin (INV-FF5). +* Para `PercentageHash`, el hash se calcula a partir de una identidad de usuario estable (por ejemplo, `UserProfileId`) en módulo 100; el criterio pasa cuando `hash % 100 <= umbral`. +* Para `CustomRule`, la capa de infraestructura resuelve el nombre de la regla a un evaluador concreto registrado en el contenedor de dependencias. --- @@ -50,14 +48,15 @@ Los criterios no tienen estados propios de ciclo de vida. Existen mientras está La colección de criterios sigue un modelo booleano de dos niveles: | Nivel | Regla | -|---|---| +| --- | --- | | Dentro del mismo `CriteriaType` | **OR** — la condición pasa si cualquier criterio individual de ese tipo coincide | | Entre diferentes grupos de `CriteriaType` | **AND** — todos los grupos de tipos deben pasar para que el resultado global sea `true` | **Ejemplo:** una bandera tiene: -- `TenantId Equals T1` -- `TenantId Equals T2` -- `RoleCode Equals ADMIN` + +* `TenantId Equals T1` +* `TenantId Equals T2` +* `RoleCode Equals ADMIN` Lógica de evaluación: `(TenantId == T1 OR TenantId == T2) AND (RoleCode == ADMIN)` @@ -72,7 +71,7 @@ Si el contexto de evaluación no provee un valor para un `CriteriaType` requerid **Fundamento:** Es más seguro denegar el acceso a la funcionalidad que concederlo con información incompleta. Esto evita activaciones inadvertidas cuando el contexto está parcialmente poblado (por ejemplo, un llamante anónimo o una solicitud entre servicios sin contexto de tenant). | Escenario | Resultado | -|---|---| +| --- | --- | | El contexto provee todos los campos requeridos | Evaluar normalmente | | El contexto omite un campo requerido | `false` (postura segura) | | La colección de criterios está vacía | `true` (activa para todos) | @@ -82,7 +81,7 @@ Si el contexto de evaluación no provee un valor para un `CriteriaType` requerid ## 5. Algoritmo de Evaluación (Pseudocódigo) -``` +```text función Evaluate(flag: FeatureFlag, context: EvaluationContext) -> bool: si flag.Criteria está vacío: @@ -142,8 +141,9 @@ public interface IFeatureFlagEvaluator ### Escenario 3 — Combinación AND de múltiples tipos **Configuración:** La bandera `ADVANCED_REPORTS` tiene: -- `TenantId Equals acme-corp-id` -- `RoleCode In ["ADMIN", "MANAGER"]` + +* `TenantId Equals acme-corp-id` +* `RoleCode In ["ADMIN", "MANAGER"]` **Contexto A:** `{ TenantId: "acme-corp-id", RoleCode: "ADMIN" }` → `true` **Contexto B:** `{ TenantId: "acme-corp-id", RoleCode: "VIEWER" }` → `false` (rol no está en la lista) @@ -164,9 +164,10 @@ public interface IFeatureFlagEvaluator ### Escenario 5 — OR dentro del mismo tipo **Configuración:** La bandera `MULTI_TENANT_PILOT` tiene: -- `TenantId Equals tenant-A` -- `TenantId Equals tenant-B` -- `Environment Equals Staging` + +* `TenantId Equals tenant-A` +* `TenantId Equals tenant-B` +* `Environment Equals Staging` **Contexto A:** `{ TenantId: "tenant-A", Environment: "Staging" }` → `true` (tenant coincide con T-A o T-B; ambiente coincide) **Contexto B:** `{ TenantId: "tenant-C", Environment: "Staging" }` → `false` (ningún tenant coincide) @@ -176,9 +177,9 @@ public interface IFeatureFlagEvaluator ## 7. Referencia -- Ver [`FeatureFlag`](./feature-flag.md) para el agregado propietario, ciclo de vida y comandos. -- Ver [ADR-0068](../../architecture/adrs/0068-feature-flag-system-scope.md) para la decisión arquitectónica que introdujo el modelo de criterios. -- `IFeatureFlagEvaluator` se registra en `Ums.Infrastructure.Configuration`. +* Ver [`FeatureFlag`](./feature-flag.md) para el agregado propietario, ciclo de vida y comandos. +* Ver [ADR-UMS-068](../../../../reference/architecture/adrs/UMS-068-alcance-sistema-feature-flags.es.md) para la decisión arquitectónica que introdujo el modelo de criterios. +* `IFeatureFlagEvaluator` se registra en `Ums.Infrastructure.Configuration`. --- diff --git a/docs/domain-es/configuration/feature-flag.md b/docs/domain-es/configuration/feature-flag.md index 2d6a3038..1f015559 100644 --- a/docs/domain-es/configuration/feature-flag.md +++ b/docs/domain-es/configuration/feature-flag.md @@ -1,7 +1,5 @@ # FeatureFlag — Arquitectura de Agregado -> **Idioma:** [English](../../domain/configuration/feature-flag.md) | [Español](./feature-flag.md) - **Contexto Delimitado:** Configuración (`Ums.Domain.Configuration`) **Raíz de Agregado:** `FeatureFlag` **Módulo:** `Ums.Domain.Configuration.FeatureFlag` @@ -17,11 +15,11 @@ El agregado `FeatureFlag` controla la habilitación de funcionalidades en runtim ### Responsabilidad de Negocio -- Registrar switches de funcionalidades acotados a un `SystemSuite`. -- Controlar el ciclo de vida de activación, desactivación y archivado. -- Soportar semánticas de bandera booleana, por targeting o por porcentaje. -- Gestionar una colección dinámica de criterios de evaluación que determinan cuándo la bandera está activa para un contexto dado. -- Registrar historial de evaluaciones en memoria dentro de la instancia del agregado. +* Registrar switches de funcionalidades acotados a un `SystemSuite`. +* Controlar el ciclo de vida de activación, desactivación y archivado. +* Soportar semánticas de bandera booleana, por targeting o por porcentaje. +* Gestionar una colección dinámica de criterios de evaluación que determinan cuándo la bandera está activa para un contexto dado. +* Registrar historial de evaluaciones en memoria dentro de la instancia del agregado. ### Raíz de Agregado @@ -30,18 +28,18 @@ El agregado `FeatureFlag` controla la habilitación de funcionalidades en runtim ### Invariantes y Reglas de Consistencia | ID | Regla | Fuente | -|----|-------|--------| -| INV-FF1 | `FlagCode` es único dentro de `(SystemSuiteId, FlagCode)` — no globalmente | ADR-0068 | +| ---- | ------- | -------- | +| INV-FF1 | `FlagCode` es único dentro de `(SystemSuiteId, FlagCode)` — no globalmente | ADR-UMS-068 | | INV-FF2 | Las banderas de tipo porcentaje requieren `RolloutPercentage` entre `0` y `100` | FS-08 | | INV-FF3 | Las banderas archivadas no pueden reactivarse, desactivarse ni tener sus criterios modificados | FS-08 | -| INV-FF4 | `SystemSuiteId` es obligatorio e inmutable una vez creada la bandera | ADR-0068 | -| INV-FF5 | Los criterios de tipo `DateRange` requieren que la fecha de inicio sea estrictamente anterior a la de fin | ADR-0068 | -| INV-FF6 | No se permite una combinación duplicada de `(CriteriaType, Operator, Value)` dentro de la misma bandera | ADR-0068 | +| INV-FF4 | `SystemSuiteId` es obligatorio e inmutable una vez creada la bandera | ADR-UMS-068 | +| INV-FF5 | Los criterios de tipo `DateRange` requieren que la fecha de inicio sea estrictamente anterior a la de fin | ADR-UMS-068 | +| INV-FF6 | No se permite una combinación duplicada de `(CriteriaType, Operator, Value)` dentro de la misma bandera | ADR-UMS-068 | ### Entidades Relacionadas / Objetos de Valor | Entidad / VO | Tipo | Propiedad | -|---|---|---| +| --- | --- | --- | | `FeatureFlagId` | Objeto de Valor | Identificador del agregado | | `SystemSuiteId` | Objeto de Valor (ref BC-B) | Scope obligatorio; inmutable | | `TenantId` | Objeto de Valor | Scope de tenant opcional | @@ -53,7 +51,7 @@ El agregado `FeatureFlag` controla la habilitación de funcionalidades en runtim ### Eventos de Dominio | Evento | Disparador | -|---|---| +| --- | --- | | `FeatureFlagCreatedEvent(Guid FlagId, string FlagCode, Guid SystemSuiteId)` | Nueva bandera creada | | `FeatureFlagActivatedEvent` | Bandera activada | | `FeatureFlagDeactivatedEvent` | Bandera desactivada | @@ -149,7 +147,7 @@ stateDiagram-v2 **Resumen de transiciones permitidas:** | Desde | Hacia | Comando | -|---|---|---| +| --- | --- | --- | | — | `Inactive` | `CreateFeatureFlagCommand` | | `Inactive` | `Active` | `ActivateFlagCommand` | | `Active` | `Inactive` | `DeactivateFlagCommand` | @@ -234,9 +232,9 @@ erDiagram **Restricciones de base de datos:** -- `FEATURE_FLAG`: Restricción única en `(SystemSuiteId, FlagCode)` reemplaza la restricción global anterior sobre `FlagCode`. -- `FEATURE_FLAG.SystemSuiteId`: FK hacia `ums_authorization.SystemSuites.Id`. -- `FEATURE_FLAG_CRITERIA`: Sin restricción única sobre `CriteriaType` solo — una bandera puede tener múltiples criterios del mismo tipo. Un duplicado de `(FeatureFlagId, CriteriaType, Operator, Value)` es rechazado por INV-FF6. +* `FEATURE_FLAG`: Restricción única en `(SystemSuiteId, FlagCode)` reemplaza la restricción global anterior sobre `FlagCode`. +* `FEATURE_FLAG.SystemSuiteId`: FK hacia `ums_authorization.SystemSuites.Id`. +* `FEATURE_FLAG_CRITERIA`: Sin restricción única sobre `CriteriaType` solo — una bandera puede tener múltiples criterios del mismo tipo. Un duplicado de `(FeatureFlagId, CriteriaType, Operator, Value)` es rechazado por INV-FF6. --- @@ -244,9 +242,9 @@ erDiagram `FeatureFlag` (BC-C, Configuración) referencia a `SystemSuite` (BC-B, Autorización) a través de una relación Customer-Supplier: -- **Proveedor (Supplier):** BC-B publica `SystemSuite.Id` como identificador externo estable. -- **Cliente (Customer):** BC-C almacena `SystemSuiteId` como FK y valida su existencia en el momento de la creación. -- No hay acoplamiento en tiempo de evaluación. El `SystemSuiteId` se resuelve una vez en la creación; la evaluación no llama a BC-B. +* **Proveedor (Supplier):** BC-B publica `SystemSuite.Id` como identificador externo estable. +* **Cliente (Customer):** BC-C almacena `SystemSuiteId` como FK y valida su existencia en el momento de la creación. +* No hay acoplamiento en tiempo de evaluación. El `SystemSuiteId` se resuelve una vez en la creación; la evaluación no llama a BC-B. El puerto `IFeatureFlagEvaluator` se define en la capa de dominio y se implementa en la capa de infraestructura, manteniendo la lógica de evaluación libre de dependencias externas. @@ -257,7 +255,7 @@ El puerto `IFeatureFlagEvaluator` se define en la capa de dominio y se implement ### Comandos | Comando | Descripción | -|---|---| +| --- | --- | | `CreateFeatureFlagCommand(systemSuiteId, tenantId, flagCode, flagType, flagTargets, rolloutPercentage, actor)` | Crea una nueva bandera acotada a un SystemSuite | | `UpdateFeatureFlagCommand(flagId, flagTargets, rolloutPercentage, actor)` | Actualiza las propiedades mutables de una bandera existente | | `ActivateFlagCommand(flagId, actor)` | Transiciona la bandera de Inactive a Active | @@ -270,7 +268,7 @@ El puerto `IFeatureFlagEvaluator` se define en la capa de dominio y se implement ### Queries | Query | Descripción | -|---|---| +| --- | --- | | `GetFeatureFlagsBySystemSuiteQuery(systemSuiteId)` | Devuelve todas las banderas de un SystemSuite | | `GetFeatureFlagCriteriaQuery(flagId)` | Devuelve todos los criterios de una bandera específica | | `GetFeatureFlagByIdQuery(flagId)` | Devuelve una única bandera por identificador | @@ -282,7 +280,7 @@ El puerto `IFeatureFlagEvaluator` se define en la capa de dominio y se implement Todas las tablas residen en el esquema `ums_configuration`. | Tabla | Notas | -|---|---| +| --- | --- | | `ums_configuration.FeatureFlags` | Almacena la raíz de agregado; FK `SystemSuiteId` referencia `ums_authorization.SystemSuites`; UK sobre `(SystemSuiteId, FlagCode)` | | `ums_configuration.FeatureFlagCriteria` | Almacena las entidades de criterios propias; FK a `FeatureFlags.Id` con eliminación en cascada | | `ums_configuration.FlagEvaluationLogs` | Almacena el historial de evaluaciones; FK a `FeatureFlags.Id` | @@ -294,7 +292,7 @@ FK entre esquemas: `ums_configuration.FeatureFlags.SystemSuiteId → ums_authori ## 10. Seguridad y Permisos | Código de Permiso | Descripción | -|---|---| +| --- | --- | | `FEATURE_FLAG_VIEW` | Leer banderas y sus criterios para un SystemSuite dado | | `FEATURE_FLAG_CREATE` | Crear una nueva bandera dentro de un SystemSuite | | `FEATURE_FLAG_UPDATE` | Actualizar las propiedades mutables y criterios de una bandera existente | diff --git a/docs/domain-es/configuration/flag-evaluation-log.md b/docs/domain-es/configuration/flag-evaluation-log.md index b20f6ce9..bf980463 100644 --- a/docs/domain-es/configuration/flag-evaluation-log.md +++ b/docs/domain-es/configuration/flag-evaluation-log.md @@ -1,7 +1,5 @@ # Bitácora de Evaluación de Flags -> **Idioma:** [English](../../domain/configuration/flag-evaluation-log.md) | **Español** - Este es un documento estable de referencia para `FlagEvaluationLog` dentro del índice del Contexto de Configuración. Las reglas detalladas permanecen documentadas dentro del documento del agregado raíz hasta que se requiera una página independiente más completa. **[Volver al Índice de Configuración](./index.md)** diff --git a/docs/domain-es/configuration/idp-configuration.md b/docs/domain-es/configuration/idp-configuration.md index 4bd6476b..94c30fe2 100644 --- a/docs/domain-es/configuration/idp-configuration.md +++ b/docs/domain-es/configuration/idp-configuration.md @@ -10,19 +10,23 @@ ## 1. Visión General del Agregado ### Propósito + El agregado `IdpConfiguration` almacena una regla de resolución de proveedor de identidad asociada a un tenant y a una suite. Encapsula tipo de proveedor, domain hints, payload externo de configuración, referencia de secreto, estado de activación, encadenamiento por fallback, prioridad de resolución y versionado. ### Responsabilidad de Negocio -- Registrar entradas de configuración de proveedores de identidad por tenant y por suite. -- Almacenar metadata de resolución del proveedor y referencias de payload. -- Controlar el ciclo de vida de activación y desactivación. -- Permitir actualizaciones mientras la configuración siga siendo mutable. -- Soportar comportamiento de resolución ordenada con fallback. + +* Registrar entradas de configuración de proveedores de identidad por tenant y por suite. +* Almacenar metadata de resolución del proveedor y referencias de payload. +* Controlar el ciclo de vida de activación y desactivación. +* Permitir actualizaciones mientras la configuración siga siendo mutable. +* Soportar comportamiento de resolución ordenada con fallback. ### Raíz de Agregado + `IdpConfiguration` es la raíz del agregado. Las referencias de secreto, cambios de payload, domain hints y transiciones de ciclo de vida se coordinan a través de ella. ### Invariantes y Reglas de Consistencia + 1. `TenantId`, `SystemSuiteId` y `ProviderType` son obligatorios. 2. `ConfigPayload` no puede estar vacío. 3. Las nuevas configuraciones nacen en `Draft`. @@ -32,8 +36,9 @@ El agregado `IdpConfiguration` almacena una regla de resolución de proveedor de 7. Toda actualización incrementa la `Version` numérica. ### Entidades Relacionadas / Objetos de Valor + | Entidad / VO | Tipo | Propiedad | -|---|---|---| +| --- | --- | --- | | `IdpConfigurationId` | Objeto de Valor | Identificador del agregado | | `TenantId` | Objeto de Valor | Límite de pertenencia del tenant | | `SystemSuiteId` | Objeto de Valor | Límite de pertenencia de la suite | @@ -41,8 +46,9 @@ El agregado `IdpConfiguration` almacena una regla de resolución de proveedor de | `IdpConfigStatus` | Enumeración | `Draft`, `Active`, `Inactive` | ### Eventos de Dominio + | Evento | Disparador | -|---|---| +| --- | --- | | `IdpConfigRegisteredEvent` | Nueva configuración creada | | `IdpConfigActivatedEvent` | Configuración activada | | `IdpConfigDeactivatedEvent` | Configuración desactivada | @@ -99,6 +105,7 @@ classDiagram ## 4. Diagramas de Secuencia ### Flujo de Actualización de Configuración IdP + ```mermaid sequenceDiagram participant C as Cliente @@ -146,36 +153,42 @@ erDiagram ``` ### Reglas de Aislamiento por Tenant -- Pertenece estrictamente al tenant mediante `TenantId`. -- Además se acota a un `SystemSuiteId` concreto. + +* Pertenece estrictamente al tenant mediante `TenantId`. +* Además se acota a un `SystemSuiteId` concreto. --- ## 6. Integración entre Contextos Delimitados -- Une la configuración de identidad del tenant con el comportamiento de resolución a nivel de suite. -- Puede participar en ruteo multi-proveedor mediante `ResolutionPriority` y `FallbackToId`. + +* Une la configuración de identidad del tenant con el comportamiento de resolución a nivel de suite. +* Puede participar en ruteo multi-proveedor mediante `ResolutionPriority` y `FallbackToId`. --- ## 7. Capa de Aplicación -- El agregado de dominio se expone mediante comandos de aplicación y endpoints REST para los flujos de creación, actualización, activación, desactivación y resolución. + +* El agregado de dominio se expone mediante comandos de aplicación y endpoints REST para los flujos de creación, actualización, activación, desactivación y resolución. --- ## 8. Infraestructura / Persistencia -- La persistencia ya está implementada en SQL Server, y la capa de presentación expone los endpoints REST de este contexto. + +* La persistencia ya está implementada en PostgreSQL, y la capa de presentación expone los endpoints REST de este contexto (`GET /api/v1/idp-configurations`). --- ## 9. Seguridad y Cumplimiento -- `SecretRef` es la referencia sensible de integración y debe resolverse mediante infraestructura segura de gestión de secretos. -- `ConfigPayload` es autoritativo para el comportamiento runtime del proveedor y debe controlarse administrativamente. + +* `SecretRef` es la referencia sensible de integración y debe resolverse mediante infraestructura segura de gestión de secretos. +* `ConfigPayload` es autoritativo para el comportamiento runtime del proveedor y debe controlarse administrativamente. --- ## 10. Decisiones Técnicas -- El modelo implementado es un agregado de resolución de proveedor consciente de la suite, no el modelo documental antiguo centrado en client-id/authority/claim-mappings. -- Este documento ahora refleja el agregado implementado actualmente como fuente de verdad. + +* El modelo implementado es un agregado de resolución de proveedor consciente de la suite, no el modelo documental antiguo centrado en client-id/authority/claim-mappings. +* Este documento ahora refleja el agregado implementado actualmente como fuente de verdad. --- diff --git a/docs/domain-es/configuration/index.md b/docs/domain-es/configuration/index.md index 111a26d2..2483b395 100644 --- a/docs/domain-es/configuration/index.md +++ b/docs/domain-es/configuration/index.md @@ -1,23 +1,23 @@ # Contexto de Configuración (Configuration BC) — Arquitectura de Agregados -> **Idioma:** [English](../../domain/configuration/index.md) | [Español](./index.md) - **Contexto Delimitado:** Configuración (`Ums.Domain.Configuration`) **Raíces de Agregado (Aggregate Roots):** `AppConfiguration`, `FeatureFlag`, `IdpConfiguration`, `ParameterDefinition`, `ParameterGlobalValue`, `ParameterTenantValue` --- -### Configuraciones y Alternadores de Aplicaciones -- [AppConfiguration](./app-configuration.md) (Raíz de Agregado) — Controla los parámetros de configuración del inquilino, tiempos de expiración de sesión, políticas de MFA y variables de entorno. -- [FeatureFlag](./feature-flag.md) (Raíz de Agregado) — Define las banderas operativas y de lanzamiento de características a nivel de plataforma o inquilino. -- [FlagEvaluationLog](./flag-evaluation-log.md) (Entidad Propia) — Registra los contextos de evaluación y resultados en tiempo de ejecución para depuración y auditoría. -- [FeatureFlagCriteria](./feature-flag-criteria.md) (Entidad Propia) — Criterios de evaluación dinámicos que determinan cuándo una feature flag está activa para un contexto dado. -- [ParameterDefinition](./parameter-definition.md) (Raíz de Agregado) — Define el esquema canónico del parámetro configurable. -- [ParameterGlobalValue](./parameter-global-value.md) (Raíz de Agregado) — Publica el valor global por defecto del parámetro. -- [ParameterTenantValue](./parameter-tenant-value.md) (Raíz de Agregado) — Mantiene el override específico por tenant. - -### Configuraciones de Integración -- [IdpConfiguration](./idp-configuration.md) (Raíz de Agregado) — Mapeos de secretos técnicos, claves privadas, IDs de cliente y endpoints para proveedores de identidad federados (OIDC, SAML, WS-Fed). +## Configuraciones y Alternadores de Aplicaciones + +* [AppConfiguration](./app-configuration.md) (Raíz de Agregado) — Controla los parámetros de configuración del inquilino, tiempos de expiración de sesión, políticas de MFA y variables de entorno. +* [FeatureFlag](./feature-flag.md) (Raíz de Agregado) — Define las banderas operativas y de lanzamiento de características a nivel de plataforma o inquilino. +* [FlagEvaluationLog](./flag-evaluation-log.md) (Entidad Propia) — Registra los contextos de evaluación y resultados en tiempo de ejecución para depuración y auditoría. +* [FeatureFlagCriteria](./feature-flag-criteria.md) (Entidad Propia) — Criterios de evaluación dinámicos que determinan cuándo una feature flag está activa para un contexto dado. +* [ParameterDefinition](./parameter-definition.md) (Raíz de Agregado) — Define el esquema canónico del parámetro configurable. +* [ParameterGlobalValue](./parameter-global-value.md) (Raíz de Agregado) — Publica el valor global por defecto del parámetro. +* [ParameterTenantValue](./parameter-tenant-value.md) (Raíz de Agregado) — Mantiene el override específico por tenant. + +## Configuraciones de Integración + +* [IdpConfiguration](./idp-configuration.md) (Raíz de Agregado) — Mapeos de secretos técnicos, claves privadas, IDs de cliente y endpoints para proveedores de identidad federados (OIDC, SAML, WS-Fed). --- diff --git a/docs/domain-es/configuration/parameter-definition.md b/docs/domain-es/configuration/parameter-definition.md index 96611af1..9c5d9ebc 100644 --- a/docs/domain-es/configuration/parameter-definition.md +++ b/docs/domain-es/configuration/parameter-definition.md @@ -9,13 +9,15 @@ `ParameterDefinition` define el esquema canónico de un parámetro configurable. Controla el `code`, el tipo de valor permitido, la descripción funcional y el scope donde puede existir. -### Responsabilidad de Negocio -- Definir el contrato del parámetro. -- Establecer el significado funcional del valor. -- Proteger la consistencia del catálogo de configuración. +## Responsabilidad de Negocio -### Invariantes -- El `code` debe ser único dentro de su scope. -- El `DataType` determina qué valores son válidos en `ParameterGlobalValue` y `ParameterTenantValue`. -- Los overrides por tenant solo son válidos cuando el scope lo permite. -- La definición no puede archivarse si aún existen valores globales o de tenant activos. +* Definir el contrato del parámetro. +* Establecer el significado funcional del valor. +* Proteger la consistencia del catálogo de configuración. + +## Invariantes + +* El `code` debe ser único dentro de su scope. +* El `DataType` determina qué valores son válidos en `ParameterGlobalValue` y `ParameterTenantValue`. +* Los overrides por tenant solo son válidos cuando el scope lo permite. +* La definición no puede archivarse si aún existen valores globales o de tenant activos. diff --git a/docs/domain-es/configuration/parameter-global-value.md b/docs/domain-es/configuration/parameter-global-value.md index edf91660..cbce50ce 100644 --- a/docs/domain-es/configuration/parameter-global-value.md +++ b/docs/domain-es/configuration/parameter-global-value.md @@ -9,11 +9,13 @@ `ParameterGlobalValue` almacena el valor base global de un parámetro. Actúa como valor por defecto para todos los tenants salvo override explícito. -### Responsabilidad de Negocio -- Publicar el valor global del parámetro. -- Servir como baseline para resolución jerárquica. +## Responsabilidad de Negocio -### Invariantes -- Debe referenciar una `ParameterDefinition`. -- El valor debe ser compatible con el `DataType` de la definición. -- No puede archivarse si todavía existen overrides activos por tenant. +* Publicar el valor global del parámetro. +* Servir como baseline para resolución jerárquica. + +## Invariantes + +* Debe referenciar una `ParameterDefinition`. +* El valor debe ser compatible con el `DataType` de la definición. +* No puede archivarse si todavía existen overrides activos por tenant. diff --git a/docs/domain-es/configuration/parameter-tenant-value.md b/docs/domain-es/configuration/parameter-tenant-value.md index d3b6725d..6cb53fcc 100644 --- a/docs/domain-es/configuration/parameter-tenant-value.md +++ b/docs/domain-es/configuration/parameter-tenant-value.md @@ -9,11 +9,13 @@ `ParameterTenantValue` almacena el override específico de un tenant. Solo puede existir cuando la política del sistema permite sobreescribir el valor global. -### Responsabilidad de Negocio -- Ajustar el valor de un parámetro para un tenant específico. -- Respetar la precedencia frente al valor global. +## Responsabilidad de Negocio -### Invariantes -- Debe referenciar una `ParameterDefinition`. -- El valor debe ser compatible con el `DataType` de la definición. -- El `ParameterDefinition` debe permitir overrides por tenant. +* Ajustar el valor de un parámetro para un tenant específico. +* Respetar la precedencia frente al valor global. + +## Invariantes + +* Debe referenciar una `ParameterDefinition`. +* El valor debe ser compatible con el `DataType` de la definición. +* El `ParameterDefinition` debe permitir overrides por tenant. diff --git a/docs/domain-es/consistency-rules/approvals-bc.md b/docs/domain-es/consistency-rules/approvals-bc.md index 35b93223..cd2bcf5f 100644 --- a/docs/domain-es/consistency-rules/approvals-bc.md +++ b/docs/domain-es/consistency-rules/approvals-bc.md @@ -5,7 +5,6 @@ ## ApprovalWorkflow | Operación | Regla | Broken Rule | -|---|---|---| +| --- | --- | --- | | `Create()` | Si `RequiresApproval=true`, debe existir al menos un required document | `approvals.requires_documents_if_approval_required` | | `RemoveRequiredDocument()` | Si `RequiresApproval=true`, debe quedar al menos un documento | `approvals.requires_documents_if_approval_required` | - diff --git a/docs/domain-es/consistency-rules/authorization-bc.md b/docs/domain-es/consistency-rules/authorization-bc.md index ecd11af7..fd759fa5 100644 --- a/docs/domain-es/consistency-rules/authorization-bc.md +++ b/docs/domain-es/consistency-rules/authorization-bc.md @@ -5,7 +5,7 @@ ## PermissionTemplate | Operación | Regla | Broken Rule | -|---|---|---| +| --- | --- | --- | | `Publish()` | Debe tener al menos un item | `authorization.template_items_required` | | `Delete()` | Solo `Draft` o `Deprecated` | `authorization.template_not_deletable` | | `Delete(activeProfileCount > 0)` | No puede haber profiles activos dependientes | `TEMPLATE_HAS_ACTIVE_PROFILES` | @@ -13,14 +13,13 @@ ## Role | Operación | Regla | Broken Rule | -|---|---|---| +| --- | --- | --- | | `Deactivate(activeProfileCount > 0)` | No puede dejar profiles activos | `ROLE_HAS_ACTIVE_PROFILES` | | `Deactivate(activeChildRoleCount > 0)` | No puede dejar roles hijos activos | `ROLE_HAS_ACTIVE_CHILD_ROLES` | ## SystemSuite | Operación | Regla | Broken Rule | -|---|---|---| +| --- | --- | --- | | `RemoveModule(activeMenuCount > 0)` | No puede eliminar módulos con menús activos | `MODULE_HAS_ACTIVE_MENUS` | | `RemoveDomainResource(templateItemCount > 0)` | No puede eliminar recursos con items de template | `DOMAIN_RESOURCE_HAS_TEMPLATE_ITEMS` | - diff --git a/docs/domain-es/consistency-rules/broken-rules-registry.md b/docs/domain-es/consistency-rules/broken-rules-registry.md index e1baca84..e5a714f6 100644 --- a/docs/domain-es/consistency-rules/broken-rules-registry.md +++ b/docs/domain-es/consistency-rules/broken-rules-registry.md @@ -5,7 +5,7 @@ ## Implementados recientemente | Codigo | Estado | -|---|---| +| --- | --- | | `authorization.template_items_required` | | | `authorization.template_not_deletable` | | | `TEMPLATE_HAS_ACTIVE_PROFILES` | | diff --git a/docs/domain-es/consistency-rules/configuration-bc.md b/docs/domain-es/consistency-rules/configuration-bc.md index 4af2b294..46089f0c 100644 --- a/docs/domain-es/consistency-rules/configuration-bc.md +++ b/docs/domain-es/consistency-rules/configuration-bc.md @@ -5,7 +5,7 @@ ## Parameter Domain Model | Aggregate Root | Rol | -|---|---| +| --- | --- | | `ParameterDefinition` | Esquema canónico del parámetro | | `ParameterGlobalValue` | Valor global por defecto | | `ParameterTenantValue` | Override por tenant | @@ -13,27 +13,27 @@ ## ParameterDefinition | Operación | Regla | Broken Rule | -|---|---|---| +| --- | --- | --- | | `Create()` | El código debe ser único dentro del scope | `configuration.parameter_code_not_unique` | | `Archive(globalValueCount, tenantValueCount)` | No puede archivarse si existen valores activos | `configuration.parameter_has_active_values` | ## ParameterGlobalValue | Operación | Regla | Broken Rule | -|---|---|---| +| --- | --- | --- | | `Create()` / `UpdateValue()` | El valor debe coincidir con el `DataType` | `configuration.parameter_value_invalid_type` | | `Archive(activeTenantValueCount)` | No puede archivarse si sigue en uso por tenants | `configuration.parameter_global_value_in_use` | ## ParameterTenantValue | Operación | Regla | Broken Rule | -|---|---|---| +| --- | --- | --- | | `Create()` / `UpdateValue()` | El valor debe coincidir con el `DataType` | `configuration.parameter_value_invalid_type` | | `Create()` | El scope debe permitir overrides por tenant | `configuration.parameter_override_not_allowed` | ## FeatureFlag | Operación | Regla | Broken Rule | -|---|---|---| +| --- | --- | --- | | `AddCriteria()` | La tupla `(CriteriaType, Operator, Value)` debe ser única | `configuration.duplicate_criteria` | | `UpdateTargeting()` | Si el tipo es Percentage, el porcentaje debe estar entre 0 y 100 | `configuration.flag_percentage_out_of_range` | diff --git a/docs/domain-es/consistency-rules/identity-bc.md b/docs/domain-es/consistency-rules/identity-bc.md index 26eae62a..6edbf86e 100644 --- a/docs/domain-es/consistency-rules/identity-bc.md +++ b/docs/domain-es/consistency-rules/identity-bc.md @@ -5,7 +5,7 @@ ## Tenant | Operación | Regla | Broken Rule | -|---|---|---| +| --- | --- | --- | | `Archive(activeIdpCount > 0)` | No puede archivarse con IdP activos | `TENANT_HAS_ACTIVE_IDP` | | `Suspend(activeUserCount > 0)` | No puede suspenderse con usuarios activos | `TENANT_HAS_ACTIVE_USERS` | | `Suspend(activeBranchCount > 0)` | No puede suspenderse con sucursales activas | `TENANT_HAS_ACTIVE_BRANCHES` | @@ -15,4 +15,3 @@ | Operación | Regla | Broken Rule | |---|---|---| | `Delete(activeProfileCount > 0)` | No puede eliminarse con profiles activos | `USER_HAS_ACTIVE_PROFILES` | - diff --git a/docs/domain-es/consistency-rules/iga-bc.md b/docs/domain-es/consistency-rules/iga-bc.md index a3118a60..58fab74e 100644 --- a/docs/domain-es/consistency-rules/iga-bc.md +++ b/docs/domain-es/consistency-rules/iga-bc.md @@ -5,8 +5,7 @@ ## PromotionRequest | Operación | Regla | Broken Rule | -|---|---|---| +| --- | --- | --- | | `Submit()` | Solo en Draft | `iga.promotion_not_in_draft` | | `ManagerApprove()` | Solo en estado pendiente de manager | `iga.promotion_not_pending_manager` | | `SecurityReview()` | Solo en estado pendiente de security | `iga.promotion_not_pending_security` | - diff --git a/docs/domain-es/consistency-rules/index.md b/docs/domain-es/consistency-rules/index.md index c978ecf7..71238ec3 100644 --- a/docs/domain-es/consistency-rules/index.md +++ b/docs/domain-es/consistency-rules/index.md @@ -1,6 +1,5 @@ # Reglas de Consistencia de Dominio - Indice Principal -> **Idioma:** Español | [English](../../domain/consistency-rules/index.md) > > **Fuente de verdad:** el código fuente de `Ums.Domain`. @@ -10,7 +9,7 @@ La traducción completa de cada Bounded Context se mantiene en el mismo director ## Contextos Delimitados | BC | Documento | -|---|---| +| --- | --- | | Identity | [identity-bc.md](./identity-bc.md) | | Authorization | [authorization-bc.md](./authorization-bc.md) | | Configuration | [configuration-bc.md](./configuration-bc.md) | @@ -20,4 +19,3 @@ La traducción completa de cada Bounded Context se mantiene en el mismo director ## Registro de Broken Rules Consulta [broken-rules-registry.md](./broken-rules-registry.md) para el inventario completo de códigos y su estado de implementación. - diff --git a/docs/domain-es/identity/auth-graph.md b/docs/domain-es/identity/auth-graph.md index f6d9065f..645a9557 100644 --- a/docs/domain-es/identity/auth-graph.md +++ b/docs/domain-es/identity/auth-graph.md @@ -1,7 +1,5 @@ # Grafo de Autorización — Estructura y Semántica -> **Idioma:** [English](../../domain/identity/auth-graph.md) | [Español](./auth-graph.md) - **Bounded Context:** Transversal — Identity (auth) + Authorization (grafo) **Propietario:** `AuthorizationGraphBuilderService` (capa Application) **Estado:** Producción @@ -18,43 +16,46 @@ El cliente **no necesita volver a consultar UMS** para tomar decisiones de acces ## 2. Estructura del Grafo -``` +```text AuthorizationGraph │ +├── onboardingPending ← true = grafo lobby: aún sin perfil activo (G-043) +│ ├── context ← Contexto del principal -│ ├── user { id, email, username, displayName, status } -│ ├── tenant { id, code, name, status, isManagementOwner } -│ ├── systemSuite { id, code, name, status } -│ ├── role { id, code, name, hierarchyLevel, parentRoleId? } -│ ├── profile { id, scope: "OrgWide"|"BranchScoped", isActive } -│ └── branch { id, code, name } | null +│ ├── user { id?, email, username, value, status } +│ ├── tenant { id?, code, value, status, isManagementOwner } +│ ├── systemSuite { id?, code, value, status } | null +│ ├── role { id?, code, value, hierarchyLevel } | null +│ ├── profile { id?, scope: "OrgWide"|"BranchScoped", isActive } | null +│ └── branch { id?, code, value } | null │ ├── authentication ← Cómo se autenticó │ ├── method "Local" | "IDP" -│ ├── provider { name, code, strategy } | null +│ ├── provider { id?, code, name, value } | null │ ├── mfaRequired bool │ ├── issuedAt DateTime (UTC) │ └── sessionExpiresAt DateTime │ ├── actions[] ← Catálogo completo de acciones del SystemSuite -│ └── { id, code, name } +│ └── { code, value } │ -├── menuAccess[] ← Árbol de menús con permisos efectivos -│ └── module { id, code, name, sortOrder, status } -│ └── menus[] { id, code, label, sortOrder } -│ └── subMenus[] { id, code, label, sortOrder } -│ └── options[] { id, code, label, actionCode, -│ effect: "Allow"|"Deny"|"NotGranted", -│ source: "Template"|"Override" } +├── menuAccess[] ← Lista PLANA de módulos con permisos efectivos +│ └── { id?, code, value, sortOrder, status } +│ └── nodes[] { id?, code, value, kind: "Menu"|"SubMenu"|"Option", +│ sortOrder, icon, route, +│ actions[] { actionCode, effect: "Allow"|"Deny", +│ source: "Template"|"Override" }, +│ children[] ← misma forma, recursiva (ADR-0090) } │ -├── domainPermissions[] ← Recursos de dominio con acciones autorizadas -│ └── resource { id, type: "Aggregate"|"Entity", code, name, moduleId? } -│ └── actions[] { actionId, actionCode, actionName, +├── domainPermissions[] ← Lista PLANA de recursos de dominio +│ └── { resourceId?, resourceType: "Aggregate"|"Entity"|"DomainMethod", +│ resourceCode, value, moduleId?, parentResourceId? } +│ └── actions[] { actionCode, value, │ effect: "Allow"|"Deny"|"NotGranted", │ source: "Template"|"Override" } │ ├── featureFlags[] ← Flags evaluados al momento de autenticación -│ └── { flagCode, systemSuiteId, isEnabled, matchedCriteriaType? } +│ └── { systemSuiteId?, flagCode, isEnabled, matchedCriteriaType } │ ├── effectiveConfig ← Configuración efectiva del tenant │ ├── sessionTimeoutMinutes @@ -77,7 +78,7 @@ AuthorizationGraph Para cada par `(TargetId, ActionId)` en el SystemSuite: | Prioridad | Regla | -|---|---| +| --- | --- | | 1 | `ProfilePermission.IsActive = false` → **EXCLUIR** | | 2 | `IsOverride = true` → usar `IsAllowed`/`IsDenied` del PP (Source: Override) | | 3 | `IsOverride = false` → usar valores del TemplateItem original (Source: Template) | @@ -92,7 +93,7 @@ Para cada par `(TargetId, ActionId)` en el SystemSuite: El grafo se serializa según el parámetro `AUTH_GRAPH_DEFAULT_FORMAT` del tenant (default: JSON). | Formato | ContentType | Override | -|---|---|---| +| --- | --- | --- | | JSON (default) | `application/json` | `?format=json` o `Accept: application/json` | | XML | `application/xml` | `?format=xml` o `Accept: application/xml` | | YAML | `application/x-yaml` | `?format=yaml` o `Accept: text/yaml` | @@ -105,9 +106,11 @@ Nuevos formatos se agregan registrando un `IAuthorizationGraphSerializer` en `Au ## 5. Endpoints ### `POST /api/v1/auth/login` + Para el frontend web UMS. Retorna cookie de sesión + respuesta enriquecida con el grafo. Este flujo se resuelve con `AuthAccessScope.PortalManagement`, por lo que siempre usa autenticación local incluso cuando la API externa del tenant está federada. **Request:** + ```json { "tenantCode": "INTERNAL_ADMIN", "username": "admin@ums.local", "password": "...", "rememberMe": false } ``` @@ -117,9 +120,11 @@ Para el frontend web UMS. Retorna cookie de sesión + respuesta enriquecida con --- ### `POST /api/v1/client/authenticate` + Para sistemas cliente externos. Sin cookie. JWT inline con claims del grafo. Este flujo se resuelve con `AuthAccessScope.ExternalApi`, por lo que puede honrar el IDP configurado por tenant sin afectar el acceso de gestión del portal. **Request:** + ```json { "tenantCode": "TECHNO", @@ -130,6 +135,7 @@ Para sistemas cliente externos. Sin cookie. JWT inline con claims del grafo. Est ``` **Response:** + ```json { "token": "eyJ...", @@ -154,7 +160,7 @@ El grafo es válido hasta `validUntil = generatedAt + SESSION_TIMEOUT_MINUTES`. El grafo es un **snapshot autocontenido** del universo de autorización de un usuario al momento de la autenticación. Las relaciones entre sus piezas son las siguientes: -``` +```text ┌──────────────────────┐ │ Tenant │ ← raíz organizacional │ (INTERNAL_ADMIN) │ define aislamiento @@ -197,7 +203,7 @@ El grafo es un **snapshot autocontenido** del universo de autorización de un us ``` | Componente | Rol en el grafo | Origen | -|---|---|---| +| --- | --- | --- | | **Tenant** | Define el alcance organizacional. Todos los demás datos se resuelven dentro de su frontera. | `context.tenant` | | **Usuario autenticado** | Principal cuya identidad ya fue verificada por el `AuthMethod`. | `context.user` | | **Profile** | Enlaza al usuario con un `Role` y un conjunto de `ProfilePermission` materializados. Es el pivote desde el cual se resuelven todos los permisos. | `context.profile` | @@ -219,49 +225,44 @@ Ejemplo representativo de `POST /api/v1/client/authenticate` para una autenticac ```json { - "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.PLACEHOLDER.SIGNATURE", + "token": "", "tokenType": "Bearer", "expiresIn": 3600, "issuedAt": "2026-05-31T14:30:00Z", "format": "JSON", "requestId": "8c3f1b2a-9e44-4d7a-b8c1-2a1f4e5d6c7b", "graph": { + "onboardingPending": false, "context": { "user": { - "id": "7a1d4e22-0001-4f00-9a00-100000000001", "email": "ana.flores@logistics-corp.example", "username": "ana.flores", - "displayName": "Ana Flores", - "status": "ACTIVE" + "value": "Ana Flores", + "status": "Active" }, "tenant": { - "id": "11111111-1111-4111-8111-111111111111", "code": "INTERNAL_ADMIN", - "name": "Logistics Corp", - "status": "ACTIVE" + "value": "Logistics Corp", + "status": "Active", + "isManagementOwner": true }, "systemSuite": { - "id": "22222222-2222-4222-8222-222222222222", "code": "WMS_SUITE", - "name": "Warehouse Management Suite", - "status": "PUBLISHED" + "value": "Warehouse Management Suite", + "status": "Active" }, "role": { - "id": "33333333-3333-4333-8333-333333333333", "code": "WAREHOUSE_SUPERVISOR", - "name": "Warehouse Supervisor", - "hierarchyLevel": 3, - "parentRoleId": "33333333-3333-4333-8333-333333333330" + "value": "Warehouse Supervisor", + "hierarchyLevel": 3 }, "profile": { - "id": "44444444-4444-4444-8444-444444444444", "scope": "BranchScoped", "isActive": true }, "branch": { - "id": "55555555-5555-4555-8555-555555555555", "code": "CALLAO_DC", - "name": "Callao Distribution Center" + "value": "Callao Distribution Center" } }, "authentication": { @@ -272,57 +273,53 @@ Ejemplo representativo de `POST /api/v1/client/authenticate` para una autenticac "sessionExpiresAt": "2026-05-31T15:30:00Z" }, "actions": [ - { "id": "a0000001-0000-4000-8000-000000000001", "code": "VIEW", "name": "View" }, - { "id": "a0000001-0000-4000-8000-000000000002", "code": "CREATE", "name": "Create" }, - { "id": "a0000001-0000-4000-8000-000000000003", "code": "UPDATE", "name": "Update" }, - { "id": "a0000001-0000-4000-8000-000000000004", "code": "DELETE", "name": "Delete" }, - { "id": "a0000001-0000-4000-8000-000000000005", "code": "APPROVE", "name": "Approve" } + { "code": "APPROVE", "value": "Aprobar" }, + { "code": "CREATE", "value": "Crear" }, + { "code": "DELETE", "value": "Eliminar" }, + { "code": "UPDATE", "value": "Actualizar" }, + { "code": "VIEW", "value": "Ver" } ], "menuAccess": [ { - "module": { - "id": "m0000001-0000-4000-8000-000000000001", - "code": "INVENTORY", - "name": "Inventory", - "sortOrder": 1, - "status": "PUBLISHED" - }, - "menus": [ + "code": "INVENTORY", + "value": "Inventario", + "sortOrder": 1, + "status": "Active", + "nodes": [ { "id": "n0000001-0000-4000-8000-000000000001", "code": "STOCK", - "label": "Stock Management", + "value": "Stock Management", + "kind": "Menu", "sortOrder": 1, - "subMenus": [ + "icon": null, + "route": null, + "actions": [], + "children": [ { "id": "s0000001-0000-4000-8000-000000000001", "code": "STOCK_OPS", - "label": "Operations", + "value": "Operations", + "kind": "SubMenu", "sortOrder": 1, - "options": [ + "icon": null, + "route": null, + "actions": [], + "children": [ { "id": "o0000001-0000-4000-8000-000000000001", "code": "STOCK_VIEW", - "label": "View Stock", - "actionCode": "VIEW", - "effect": "Allow", - "source": "Template" - }, - { - "id": "o0000001-0000-4000-8000-000000000002", - "code": "STOCK_ADJUST", - "label": "Adjust Stock", - "actionCode": "UPDATE", - "effect": "Allow", - "source": "Override" - }, - { - "id": "o0000001-0000-4000-8000-000000000003", - "code": "STOCK_DELETE", - "label": "Delete Stock Record", - "actionCode": "DELETE", - "effect": "Deny", - "source": "Override" + "value": "View Stock", + "kind": "Option", + "sortOrder": 1, + "icon": null, + "route": "/stock", + "actions": [ + { "actionCode": "VIEW", "effect": "Allow", "source": "Template" }, + { "actionCode": "UPDATE", "effect": "Allow", "source": "Override" }, + { "actionCode": "DELETE", "effect": "Deny", "source": "Override" } + ], + "children": [] } ] } @@ -333,26 +330,20 @@ Ejemplo representativo de `POST /api/v1/client/authenticate` para una autenticac ], "domainPermissions": [ { - "resource": { - "id": "r0000001-0000-4000-8000-000000000001", - "type": "Aggregate", - "code": "PURCHASE_ORDER", - "name": "Purchase Order", - "moduleId": "m0000001-0000-4000-8000-000000000001" - }, + "resourceType": "Aggregate", + "resourceCode": "PURCHASE_ORDER", + "value": "Orden de Compra", "actions": [ { - "actionId": "a0000001-0000-4000-8000-000000000001", - "actionCode": "VIEW", - "actionName": "View", - "effect": "Allow", + "actionCode": "APPROVE", + "value": "Aprobar", + "effect": "NotGranted", "source": "Template" }, { - "actionId": "a0000001-0000-4000-8000-000000000005", - "actionCode": "APPROVE", - "actionName": "Approve", - "effect": "NotGranted", + "actionCode": "VIEW", + "value": "Ver", + "effect": "Allow", "source": "Template" } ] @@ -361,13 +352,11 @@ Ejemplo representativo de `POST /api/v1/client/authenticate` para una autenticac "featureFlags": [ { "flagCode": "WMS_NEW_PICKING_UI", - "systemSuiteId": "22222222-2222-4222-8222-222222222222", "isEnabled": true, "matchedCriteriaType": "BranchId" }, { "flagCode": "WMS_BULK_EXPORT", - "systemSuiteId": "22222222-2222-4222-8222-222222222222", "isEnabled": false, "matchedCriteriaType": null } @@ -377,13 +366,14 @@ Ejemplo representativo de `POST /api/v1/client/authenticate` para una autenticac "maxLoginAttempts": 5, "minPasswordLength": 12, "mfaRequiredForAdmin": true, + "mfaAllowedMethods": ["Totp"], "accessTokenDurationMs": 3600000, "authUseExternalIdp": false }, "scopes": [ - "STOCK_VIEW.VIEW", - "STOCK_ADJUST.UPDATE", - "PURCHASE_ORDER.VIEW" + "purchase_order.view", + "stock_adjust.update", + "stock_view.view" ], "generatedAt": "2026-05-31T14:30:00Z", "validUntil": "2026-05-31T15:30:00Z" @@ -392,15 +382,17 @@ Ejemplo representativo de `POST /api/v1/client/authenticate` para una autenticac ``` > **Variante IDP**: para una autenticación federada, la sección `authentication` cambia a: +> > ```json > "authentication": { > "method": "IDP", -> "provider": { "name": "Azure AD - Logistics", "code": "AZURE_AD_LOGISTICS", "strategy": "AZURE_AD" }, +> "provider": { "code": "AZURE_AD_LOGISTICS", "name": "Azure AD - Logistics", "value": "AZURE_AD" }, > "mfaRequired": true, > "issuedAt": "2026-05-31T14:30:00Z", > "sessionExpiresAt": "2026-05-31T15:30:00Z" > } > ``` +> > El resto del grafo es idéntico — la fuente de la identidad es transparente para los consumidores del grafo. --- @@ -485,12 +477,12 @@ function isFlagEnabled(graph: AuthGraph, code: string): boolean { El grafo está diseñado para ser **seguro de exponer al sistema cliente**, pero debe respetar las siguientes reglas de contenido: | Regla | Detalle | -|---|---| +| --- | --- | | **No exponer secretos** | El grafo NUNCA debe incluir `PasswordHash`, `ApiCredentialHash`, claves de firma JWT, secretos de cliente OAuth, ni ningún material criptográfico. | | **No exponer tokens de proveedores IDP** | `id_token`, `access_token`, `refresh_token` o assertions SAML del IDP externo se consumen únicamente dentro de UMS durante la autenticación y se descartan. No deben aparecer en `authentication.provider`. | | **No exponer credenciales** | Ni en plano ni hasheadas. El campo `authentication.method` indica `Local` o `IDP` pero nunca acompaña el secreto utilizado. | | **No exponer configuración sensible** | `effectiveConfig` sólo expone parámetros operacionales del sistema cliente (timeouts, longitudes mínimas, flags booleanos). Connection strings, claves de servicios externos, endpoints internos y secretos de infraestructura están explícitamente excluidos. | -| **Datos personales mínimos** | El grafo expone los datos de identidad mínimos necesarios para que el sistema cliente atribuya acciones (`id`, `email`, `displayName`). Datos sensibles de RRHH, dirección, documentos de identidad u otros campos PII NO forman parte del grafo. | +| **Datos personales mínimos** | El grafo expone los datos de identidad mínimos necesarios para que el sistema cliente atribuya acciones (`email`, `username`, `value` —el nombre para mostrar—). Datos sensibles de RRHH, dirección, documentos de identidad u otros campos PII NO forman parte del grafo. | | **Sin información de otros tenants** | Cada grafo está scopeado a un único tenant. Resolución, configuración y permisos de otros tenants nunca se incluyen, incluso si el usuario tiene acceso a múltiples organizaciones (cada tenant requiere su propia autenticación). | | **Sin metadatos internos de UMS** | IDs internos de infraestructura, nombres de schemas SQL, nombres de tablas, hashes de partición y otros detalles de implementación no se exponen. | | **Auditoría** | El `requestId` correlaciona la respuesta con un `AuthenticationAttemptedEvent` en el audit trail. El cliente puede registrar este ID para soporte sin exponer detalles internos. | @@ -501,13 +493,13 @@ El grafo está diseñado para ser **seguro de exponer al sistema cliente**, pero ## 11. Referencias -- [ADR-0071: Motor del Grafo de Autorización](../../architecture/adrs/0071-auth-graph-engine.es.md) -- [ADR-0072: Resolución Dinámica del Método de Autenticación](../../architecture/adrs/0072-dynamic-auth-method-resolution.es.md) -- [ADR-0073: UMS SDK Multi-Runtime](../../architecture/adrs/0073-ums-sdk-multi-runtime.es.md) — superficie oficial de consumo client-side -- [ADR-0074: Política de Versionado del Schema del Grafo](../../architecture/adrs/0074-auth-graph-schema-versioning.es.md) -- [Contrato Semantico del Auth Graph](../../sdk-es/contracts/semantic-client-contract.md) -- [Resolución del Método de Autenticación](./auth-method-resolution.md) -- [Portal UMS SDK](../../sdk-es/index.md) — deserialización tipada, validador, atributos/decorators para .NET, TypeScript y NestJS -- [Schema Overview](../../sdk-es/contracts/schema-overview.md) -- [AuthorizationGraphBuilderService](../../../src/apps/ums.api/Ums.Application/Authorization/Graph/AuthorizationGraphBuilderService.cs) -- [ClientAuthEndpoints](../../../src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Auth/ClientAuthEndpoints.cs) +* [ADR-UMS-088: Motor del Grafo de Autorización](../../../../reference/architecture/adrs/UMS-088-motor-grafo-autorizacion.es.md) +* [ADR-UMS-072: Resolución Dinámica del Método de Autenticación](../../../../reference/architecture/adrs/UMS-072-resolucion-dinamica-metodo-autenticacion.es.md) +* [ADR-UMS-073: UMS SDK Multi-Runtime](../../../../reference/architecture/adrs/UMS-073-sdk-ums-multi-runtime.es.md) — superficie oficial de consumo client-side +* [ADR-UMS-074: Política de Versionado del Schema del Grafo](../../../../reference/architecture/adrs/UMS-074-versionado-esquema-grafo-autorizacion.es.md) +* [Contrato Semantico del Auth Graph](../../../../reference/sdk/contracts/contrato-cliente-semantico.md) +* [Resolución del Método de Autenticación](./auth-method-resolution.md) +* [Portal UMS SDK](../../../../reference/sdk/index.md) — deserialización tipada, validador, atributos/decorators para .NET, TypeScript y NestJS +* [Schema Overview](../../../../reference/sdk/contracts/resumen-esquema.md) +* [AuthorizationGraphBuilderService](../../../../src/apps/ums.api/Ums.Application/Authorization/Graph/AuthorizationGraphBuilderService.cs) +* [ClientAuthEndpoints](../../../../src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Auth/ClientAuthEndpoints.cs) diff --git a/docs/domain-es/identity/auth-method-resolution.md b/docs/domain-es/identity/auth-method-resolution.md index 94bee9ca..e7dd5111 100644 --- a/docs/domain-es/identity/auth-method-resolution.md +++ b/docs/domain-es/identity/auth-method-resolution.md @@ -1,7 +1,5 @@ # Resolución del Método de Autenticación — Cómo UMS Selecciona la Estrategia de Autenticación -> **Idioma:** [English](../../domain/identity/auth-method-resolution.md) | [Español](./auth-method-resolution.md) - **Bounded Context:** Identity (`Ums.Domain.Identity`) **Propietario:** `AuthMethodResolverService` (capa Application) **Estado:** Producción @@ -13,7 +11,7 @@ UMS soporta dos métodos de autenticación por tenant, pero no se aplican de forma uniforme a todos los puntos de entrada: | Método | Descripción | -|---|---| +| --- | --- | | **Local** | Validación de credencial BCrypt. Hash de contraseña almacenado en la entidad `PasswordCredential`. Se usa principalmente para el acceso de gestión del portal. | | **IDP** | Autenticación federada delegada a un Proveedor de Identidad externo (Azure AD, Okta, SAML2, etc.). Se usa principalmente para la autenticación de la API externa. | @@ -26,13 +24,13 @@ El método aplicable para un tenant dado se **resuelve en tiempo de login desde El parámetro `AUTH_USE_EXTERNAL_IDP` (Booleano, con scope de tenant) gobierna la selección del método solo para el scope de API externa: | Valor | Resultado | -|---|---| +| --- | --- | | `false` | `AuthMethod.Local()` — validación BCrypt | | `true` + IDP activo | `AuthMethod.Idp(activeProvider)` — autenticación federada | | `true` + sin IDP activo | `Result.Failure("AUTH_011")` — error de configuración | Para `AuthAccessScope.PortalManagement`, el resolver siempre devuelve `AuthMethod.Local()` y no requiere la configuración IDP del tenant. -La frontera de autorizacion que mantiene la gestion del portal en modo local esta definida en [ADR-0077](../../architecture/adrs/0077-tenant-portal-management-authorization-boundary.es.md). +La frontera de autorizacion que mantiene la gestion del portal en modo local esta definida en [ADR-UMS-077](../../../../reference/architecture/adrs/UMS-077-limite-autorizacion-gestion-portal-tenant.es.md). Este parámetro reside en el `ParameterCatalog` y se carga en `IConfigurationProvider` al iniciar la aplicación. Los cambios aplicados mediante la UI del IdpPanel se persisten en la base de datos y disparan un refresco del proveedor — el nuevo método toma efecto en el **siguiente login** sin reiniciar el servicio. @@ -40,7 +38,7 @@ Este parámetro reside en el `ParameterCatalog` y se carga en `IConfigurationPro ## 3. Flujo de Resolución -``` +```text LoginCommand recibido │ ▼ @@ -111,7 +109,7 @@ Task> AuthenticateAsync( `IConfigurationProvider` combina parámetros de dos niveles: | Nivel | Alcance | Precedencia | -|---|---|---| +| --- | --- | --- | | Default global | `TenantId = NULL` (raíz) | Menor | | Override por tenant | `TenantId = ` | Mayor | @@ -124,6 +122,7 @@ Cuando un tenant no tiene un override explícito de `AUTH_USE_EXTERNAL_IDP`, apl `IConfigurationProvider` se pobla una vez al inicio (o al refresco explícito) y sirve las búsquedas posteriores como lecturas O(1) en diccionario. El resolver por lo tanto agrega **latencia cero** por request de autenticación más allá de la lógica de capa de aplicación. Cuando el toggle del IdpPanel dispara `UpdateAuthModeCommand`, el command handler: + 1. Actualiza el parámetro en la base de datos 2. Llama a `IConfigurationProvider.RefreshAsync()` para re-poblar la caché en memoria 3. El nuevo método toma efecto para el siguiente login — sin reinicio requerido @@ -139,9 +138,10 @@ Cuando el toggle del IdpPanel dispara `UpdateAuthModeCommand`, el command handle ## 8. Relación con el Grafo de Autorización Después de que el método de autenticación se resuelve y el usuario es autenticado, `AuthorizationGraphBuilderService` construye el `AuthorizationGraph` completo. La sección `authentication` del grafo registra: -- El método resuelto (`Local` o `IDP`) -- El nombre y estrategia del proveedor (cuando es IDP) -- `mfaRequired`, `issuedAt`, `sessionExpiresAt` + +* El método resuelto (`Local` o `IDP`) +* El nombre y estrategia del proveedor (cuando es IDP) +* `mfaRequired`, `issuedAt`, `sessionExpiresAt` Ver [Grafo de Autorización](./auth-graph.md) para la estructura completa del grafo. @@ -150,7 +150,7 @@ Ver [Grafo de Autorización](./auth-graph.md) para la estructura completa del gr ## 9. Códigos de Error | Código | Disparador | -|---|---| +| --- | --- | | AUTH_001 | Error de validación — campos requeridos faltantes | | AUTH_002 | Tenant no encontrado | | AUTH_003 | Tenant no activo | @@ -164,9 +164,9 @@ Ver [Grafo de Autorización](./auth-graph.md) para la estructura completa del gr ## 10. Referencias -- [ADR-0072: Resolución Dinámica del Método de Autenticación](../../architecture/adrs/0072-dynamic-auth-method-resolution.es.md) -- [ADR-0071: Motor del Grafo de Autorización](../../architecture/adrs/0071-auth-graph-engine.es.md) -- [Grafo de Autorización](./auth-graph.md) -- [Aggregate Tenant](./tenant.md) -- [`AuthMethodResolverService`](../../../src/apps/ums.api/Ums.Application/Identity/Auth/AuthMethodResolverService.cs) -- [`IdpAuthAdapterFactorySetup`](../../../src/apps/ums.api/Ums.Infrastructure/Identity/Auth/IdpAuthAdapterFactorySetup.cs) +* [ADR-UMS-072: Resolución Dinámica del Método de Autenticación](../../../../reference/architecture/adrs/UMS-072-resolucion-dinamica-metodo-autenticacion.es.md) +* [ADR-UMS-088: Motor del Grafo de Autorización](../../../../reference/architecture/adrs/UMS-088-motor-grafo-autorizacion.es.md) +* [Grafo de Autorización](./auth-graph.md) +* [Aggregate Tenant](./tenant.md) +* [`AuthMethodResolverService`](../../../../src/apps/ums.api/Ums.Application/Identity/Auth/AuthMethodResolverService.cs) +* [`IdpAuthAdapterFactorySetup`](../../../../src/apps/ums.api/Ums.Infrastructure/Identity/Auth/IdpAuthAdapterFactorySetup.cs) diff --git a/docs/domain-es/identity/branch.md b/docs/domain-es/identity/branch.md index 2a842b14..e3e28367 100644 --- a/docs/domain-es/identity/branch.md +++ b/docs/domain-es/identity/branch.md @@ -1,7 +1,5 @@ # Sucursal -> **Idioma:** [English](../../domain/identity/branch.md) | **Español** - Este es un documento estable de referencia para `Branch` dentro del índice del Contexto de Identidad. Las reglas detalladas permanecen documentadas dentro del documento del agregado raíz hasta que se requiera una página independiente más completa. **[Volver al Índice de Identidad](./index.md)** diff --git a/docs/domain-es/identity/identity-provider.md b/docs/domain-es/identity/identity-provider.md index 217caee4..c4684676 100644 --- a/docs/domain-es/identity/identity-provider.md +++ b/docs/domain-es/identity/identity-provider.md @@ -1,7 +1,5 @@ # Proveedor de Identidad -> **Idioma:** [English](../../domain/identity/identity-provider.md) | **Español** - Este es un documento estable de referencia para `IdentityProvider` dentro del índice del Contexto de Identidad. Las reglas detalladas permanecen documentadas dentro del documento del agregado raíz hasta que se requiera una página independiente más completa. **[Volver al Índice de Identidad](./index.md)** diff --git a/docs/domain-es/identity/index.md b/docs/domain-es/identity/index.md index 17e9d8ea..2a469bd5 100644 --- a/docs/domain-es/identity/index.md +++ b/docs/domain-es/identity/index.md @@ -1,18 +1,15 @@ # Identity BC — Arquitectura de Agregados -> **Idioma:** [English](../../domain/identity/index.md) | [Español](./index.md) - **Bounded Context:** Identity (`Ums.Domain.Identity`) **Aggregate Roots:** `Tenant`, `TenantSignupRequest`, `UserAccount`, `UserManagementDelegation` --- | Agregado / Entidad | Tipo | Estado | -|---|---|---| +| --- | --- | --- | | [Tenant](./tenant.md) | Aggregate Root | Produccion | | [TenantSignupRequest](./tenant-signup-request.md) | Aggregate Root | Implementado para onboarding de tenant | | [Branch](./branch.md) | Entidad Propia (Tenant) | Produccion | -| [Branding](./branding.md) | Entidad Propia (Tenant) | Produccion | | [IdentityProvider](./identity-provider.md) | Entidad Propia (Tenant) | Produccion | | [UserAccount](./user-account.md) | Aggregate Root | Produccion | | [PasswordCredential](./password-credential.md) | Entidad Propia (UserAccount) | Mantenimiento activo (FS-18) | @@ -24,7 +21,7 @@ ## Documentacion Transversal de Identity | Documento | Descripcion | -|---|---| +| --- | --- | | [Grafo de Autorizacion](./auth-graph.md) | Estructura y semantica del `AuthorizationGraph` retornado en el login | | [Resolucion del Metodo de Autenticacion](./auth-method-resolution.md) | Como UMS resuelve dinamicamente la estrategia de autenticacion (Local vs IDP) por tenant | diff --git a/docs/domain-es/identity/mfa-enrollment.md b/docs/domain-es/identity/mfa-enrollment.md index 5af18b45..ed0fa0e5 100644 --- a/docs/domain-es/identity/mfa-enrollment.md +++ b/docs/domain-es/identity/mfa-enrollment.md @@ -1,7 +1,5 @@ # Inscripción MFA -> **Idioma:** [English](../../domain/identity/mfa-enrollment.md) | **Español** - Este es un documento estable de referencia para `MfaEnrollment` dentro del índice del Contexto de Identidad. Las reglas detalladas permanecen documentadas dentro del documento del agregado raíz hasta que se requiera una página independiente más completa. **[Volver al Índice de Identidad](./index.md)** diff --git a/docs/domain-es/identity/password-credential.md b/docs/domain-es/identity/password-credential.md index 7baa7182..8e734ab8 100644 --- a/docs/domain-es/identity/password-credential.md +++ b/docs/domain-es/identity/password-credential.md @@ -1,7 +1,5 @@ # PasswordCredential - Diseño de Entidad Propia -> **Idioma:** [English](../../domain/identity/password-credential.md) | [Español](./password-credential.md) - **Bounded Context:** Identity **Agregado Propietario:** `UserAccount` **Trazabilidad Funcional:** FS-18 @@ -31,12 +29,12 @@ stateDiagram-v2 | Superficie | Contrato | Comportamiento | | :--- | :--- | :--- | | Comando REST | `POST /user-accounts/{userAccountId}/passwords` | Establece o rota la contraseña local. La API genera el hash BCrypt. | -| Consulta GraphQL | Campos de usuario `hasActivePassword`, `passwordUpdatedAtUtc` | Retorna únicamente información de estado. | +| Consulta REST | `GET /api/v1/user-accounts/{userAccountId}` con los campos `hasActivePassword`, `passwordUpdatedAtUtc` | Retorna únicamente información de estado. | | Vista web | `Cuentas de Usuario > Credenciales` | Ofrece un formulario compacto para establecer o rotar contraseña en cuentas internas elegibles. | ## Seguridad y Observabilidad -- La solicitud entrega la contraseña temporal únicamente mediante transporte seguro hacia la API; la API persiste el hash BCrypt. -- `PasswordHash` nunca se retorna en proyecciones REST o GraphQL. -- Las cuentas federadas muestran orientación para administrar sus credenciales mediante el proveedor externo. -- Las respuestas de error seguras muestran una razón comprensible y `ErrorId`; el diagnóstico completo permanece en logs Serilog/Grafana Loki. +* La solicitud entrega la contraseña temporal únicamente mediante transporte seguro hacia la API; la API persiste el hash BCrypt. +* `PasswordHash` nunca se retorna en proyecciones REST. +* Las cuentas federadas muestran orientación para administrar sus credenciales mediante el proveedor externo. +* Las respuestas de error seguras muestran una razón comprensible y `ErrorId`; el diagnóstico completo permanece en logs Serilog/Grafana Loki. diff --git a/docs/domain-es/identity/tenant-signup-request.md b/docs/domain-es/identity/tenant-signup-request.md index 2b6d0279..23d1bb10 100644 --- a/docs/domain-es/identity/tenant-signup-request.md +++ b/docs/domain-es/identity/tenant-signup-request.md @@ -10,24 +10,28 @@ ## 1. Vista General del Agregado ### Proposito + `TenantSignupRequest` representa una solicitud publica de onboarding de empresa enviada antes de que exista el tenant. Un administrador global la revisa y, cuando se aprueba, queda vinculada al tenant creado. ### Responsabilidad de Negocio -- Capturar nombre de empresa, referencia de empresa, nombre de contacto y correo de contacto desde el formulario publico de alta de tenant. -- Mantener la solicitud en `Pending` hasta que un administrador global la apruebe. -- Vincular la solicitud con el tenant creado mediante `ApprovedTenantId`. -- Proveer el registro origen para la bandeja global de onboarding. + +* Capturar nombre de empresa, referencia de empresa, nombre de contacto y correo de contacto desde el formulario publico de alta de tenant. +* Mantener la solicitud en `Pending` hasta que un administrador global la apruebe. +* Vincular la solicitud con el tenant creado mediante `ApprovedTenantId`. +* Proveer el registro origen para la bandeja global de onboarding. ### Modelo de Estados Implementado + | Estado | Valor de Codigo | Significado | Transicion Implementada | -|---|---:|---|---| +| --- | ---: | --- | --- | | `Pending` | 1 | Solicitud enviada y en espera de revision global. | Creada por `TenantSignupRequest.Create`. | | `Approved` | 2 | El tenant fue creado y vinculado a la solicitud. | `Approve(tenantId, updatedBy)`. | | `Rejected` | 3 | Reservado en el enum para solicitudes de empresa denegadas. | El enum existe; el comando del agregado aun no esta implementado. | ### Entidades / Value Objects Relacionados + | Entidad / VO | Tipo | Propiedad | -|---|---|---| +| --- | --- | --- | | `TenantSignupRequestStatus` | Enumeracion | Pending, Approved, Rejected | | `CompanyReference` | Value Object | RUC/codigo/referencia de empresa | | `Name` | Value Object | Nombres de empresa y contacto | @@ -88,8 +92,9 @@ erDiagram ``` ### Mapeo de Persistencia + | Artefacto de Codigo | Mapeo | -|---|---| +| --- | --- | | EF record | `TenantSignupRequestRecord` | | Tabla | `identity.TenantSignupRequests` | | Indice unico | `CompanyReference` | diff --git a/docs/domain-es/identity/tenant.md b/docs/domain-es/identity/tenant.md index f5dcf8aa..d6ef59b3 100644 --- a/docs/domain-es/identity/tenant.md +++ b/docs/domain-es/identity/tenant.md @@ -1,7 +1,5 @@ # Tenant — Arquitectura del Agregado -> **Idioma:** [English](../../domain/identity/tenant.md) | [Español](./tenant.md) - **Bounded Context:** Identity **Aggregate Root:** `Tenant` **Modulo:** `Ums.Domain.Identity.Tenant` @@ -12,21 +10,24 @@ ## 1. Descripcion del Agregado ### Proposito -`Tenant` es la unidad organizativa raiz del sistema. Representa a una empresa o division que usa UMS como plataforma de gestion de identidades. Agrupa a todos los usuarios, ramas (`Branch`), configuraciones de branding (`Branding`) y proveedores de identidad (`IdentityProvider`) bajo un espacio de nombres unico y aislado, y tambien indica si el tenant es el responsable de gestion de su propio portal interno de UMS. + +`Tenant` es la unidad organizativa raiz del sistema. Representa a una empresa o division que usa UMS como plataforma de gestion de identidades. Agrupa a todos los usuarios, ramas (`Branch`) y proveedores de identidad (`IdentityProvider`) bajo un espacio de nombres unico y aislado, y tambien indica si el tenant es el responsable de gestion de su propio portal interno de UMS. ### Responsabilidad de Negocio -- Proveer aislamiento multi-tenant para todos los datos del sistema. -- Gestionar el ciclo de vida del tenant: registro, suspension y activacion. -- Ser el propietario de `Branch`, `Branding` e `IdentityProvider` como entidades propias. -- Definir la estrategia de autenticacion (`IdpStrategy`) a nivel de dominio. -- Marcar si el tenant es el propietario principal de la gestion interna del portal UMS. + +* Proveer aislamiento multi-tenant para todos los datos del sistema. +* Gestionar el ciclo de vida del tenant: registro, suspension y activacion. +* Ser el propietario de `Branch` e `IdentityProvider` como entidades propias. +* Definir la estrategia de autenticacion (`IdpStrategy`) a nivel de dominio. +* Marcar si el tenant es el propietario principal de la gestion interna del portal UMS. ### Acceso de Gestion Interna + El portal interno de UMS utiliza su propia ruta de autorizacion para los administradores del tenant. Esta ruta es distinta del flujo externo de autenticacion por API y se gobierna por scope de tenant, roles, permisos e `IsManagementOwner`. -Esta frontera se formaliza en [ADR-0077](../../architecture/adrs/0077-tenant-portal-management-authorization-boundary.es.md). +Esta frontera se formaliza en [ADR-UMS-077](../../../../reference/architecture/adrs/UMS-077-limite-autorizacion-gestion-portal-tenant.es.md). | Escenario | Regla | -|---|---| +| --- | --- | | Un administrador del tenant entra al portal de UMS | Permitido solo si pertenece al tenant y tiene el conjunto de roles/permisos requerido | | Un administrador modifica datos dentro de su propio tenant | Permitido solo dentro del scope del tenant y solo cuando `IsManagementOwner = true` | | Un administrador intenta modificar datos de otro tenant | Rechazado aunque tenga acceso al portal | @@ -34,66 +35,63 @@ Esta frontera se formaliza en [ADR-0077](../../architecture/adrs/0077-tenant-por | Auditoria | Toda mutacion interna de tenant debe registrarse | **Branch**: Representa una unidad de ubicacion fisica o logica. Provee un ambito geográfico u organizacional. Aplica reglas de geocercado y habilita delegación de administración. -**Branding**: Contiene la configuración de identidad visual (logo, colores, textos) y dominio personalizado con verificación DNS. Controla cómo se renderiza el portal de login. **IdentityProvider**: Representa un proveedor de autenticación externo (OIDC, SAML2, WS_FED). Registra la intención estratégica y el contrato a nivel de negocio para el tenant. ### Aggregate Root -`Tenant` es su propio aggregate root. Todas las mutaciones de `Branch`, `Branding` e `IdentityProvider` pasan por comandos de `Tenant`. + +`Tenant` es su propio aggregate root. Todas las mutaciones de `Branch` e `IdentityProvider` pasan por comandos de `Tenant`. ### Invariantes y Reglas de Consistencia + 1. **Tenant**: `Code` debe ser globalmente unico en todo el sistema. 2. **Tenant**: Un `Tenant` con `TenantStatus = Suspended` bloquea todos los flujos de autenticacion de sus usuarios. 3. **Tenant**: `IdpStrategy` debe ser coherente: si es `FEDERATED`, debe existir al menos un `IdentityProvider` activo. 4. **Tenant**: Un tenant hijo (`ParentTenantId != null`) hereda politicas del tenant padre. -5. **Branch**: `Code` debe ser unico dentro del `Tenant` propietario. -6. **Branch**: Una `Branch` no puede ser eliminada si existen registros activos de `UserAccount` o `Profile` asociados. +5. **Branch**: `Code` debe ser unico dentro del `Tenant` propietario, **incluidas las ramas cerradas**: el codigo de una rama cerrada no se libera nunca (ADR-0164 §2.3), porque liberarlo volveria ambigua cualquier consulta historica. +6. **Branch**: Una `Branch` no puede cerrarse si existen registros ACTIVOS de `UserAccount` o `Profile` asociados. Los ya eliminados o desactivados no bloquean (ADR-0164 §2.2). 7. **Branch**: `GeofencingMetadata` debe ser JSON valido cuando se proporciona. -8. **Branch**: La desactivacion no elimina; los registros se conservan para trazabilidad historica. -9. **Branding**: Solo puede existir un registro `Branding` por Tenant (relacion 1:1). -10. **Branding**: `CustomDomain` debe ser un hostname valido cuando se proporciona. -11. **Branding**: `DnsVerificationStatus` comienza en `PENDING` cuando se establece `CustomDomain` y no puede establecerse manualmente a `VERIFIED` (solo por el servicio de verificacion DNS). -12. **Branding**: `LogoFormat` debe coincidir con el formato real del URI de `Logo` subido. -13. **IdentityProvider**: `Code` debe ser unico dentro del Tenant propietario. -14. **IdentityProvider**: Un `IdentityProvider` debe ser desactivado antes de ser eliminado. -15. **IdentityProvider**: Desactivar un `IdentityProvider` que es el unico IdP activo para un tenant Federado no esta permitido a menos que se cambie primero el `IdpStrategy`. -16. **IdentityProvider**: `Strategy` no puede cambiarse despues del registro — es inmutable una vez establecida. -17. **Tenant**: `IsManagementOwner` identifica los tenants que pueden administrar su propio scope interno de UMS sin depender obligatoriamente del flujo IDP de la API externa. +8. **Branch**: No existe borrado fisico. El cierre es logico y terminal: la fila permanece para poder explicar operaciones pasadas (ADR-0164 §2.1). +9. **Branch**: Desactivar y cerrar son verbos DISTINTOS. Una rama cerrada no se reactiva ni se desactiva; al estado terminal no se llega manipulando `IsActive` (ADR-0164 §2.4). +10. **Branch**: Cada episodio del ciclo de vida —apertura, desactivacion, reactivacion, cierre— se anota en la bitacora `TenantBranchLifecycleEntries` dentro de la misma transaccion, con fecha, actor y la foto (nombre y geocerca) de esa epoca. +11. **IdentityProvider**: `Code` debe ser unico dentro del Tenant propietario. +12. **IdentityProvider**: Un `IdentityProvider` debe ser desactivado antes de ser eliminado. +13. **IdentityProvider**: Desactivar un `IdentityProvider` que es el unico IdP activo para un tenant Federado no esta permitido a menos que se cambie primero el `IdpStrategy`. +14. **IdentityProvider**: `Strategy` no puede cambiarse despues del registro — es inmutable una vez establecida. +15. **Tenant**: `IsManagementOwner` identifica los tenants que pueden administrar su propio scope interno de UMS sin depender obligatoriamente del flujo IDP de la API externa. ### Entidades Relacionadas / Value Objects + | Entidad / VO | Tipo | Notas | -|---|---|---| +| --- | --- | --- | | `Code` | Value Object | Identificador unico global del tenant | | `Name` | Value Object | Nombre para mostrar | | `OrganizationType` | Enum | COMPANY · DIVISION · BRANCH_OFFICE | -| `IdpStrategy` | Enum | LOCAL · FEDERATED · HYBRID | +| `IdpStrategy` | Enum | InternalBcrypt · Zitadel · AzureAd · Okta · Keycloak · Auth0 · Google · Ldap · Saml2 · GenericOidc (defecto `InternalBcrypt`) | | `IsManagementOwner` | Flag | Identifica el tenant responsable de la gestion interna de UMS | | `CompanyReference` | Value Object | Referencia al sistema ERP (nullable) | -| `TenantStatus` | Enum | Active · Suspended · Inactive | +| `TenantStatus` | Enum | Active · Suspended · Archived | | `AuditValueObject` | Value Object | CreatedAt/By, UpdatedAt/By | ### Eventos de Dominio + | Evento | Disparador | -|---|---| +| --- | --- | | `TenantCreatedEvent` | Nuevo tenant registrado | | `TenantSuspendedEvent` | Tenant suspendido por admin de plataforma | | `TenantActivatedEvent` | Tenant reactivado | | `BranchCreatedEvent` | Nueva rama agregada al tenant | | `BranchDeactivatedEvent` | Rama desactivada | | `BranchReactivatedEvent` | Rama reactivada | -| `BranchRemovedEvent` | Rama eliminada definitivamente | -| `BrandingCreatedEvent` | Branding configurado por primera vez | -| `BrandingUpdatedEvent` | Atributos de branding actualizados | -| `BrandingRemovedEvent` | Configuracion de branding eliminada | -| `BrandingDnsVerifiedEvent` | Dominio personalizado verificado por DNS | -| `BrandingDnsFailedEvent` | Intento de verificacion DNS fallido | +| `BranchClosedEvent` | Rama cerrada definitivamente (borrado logico, ADR-0164) | | `IdentityProviderRegisteredEvent` | Nuevo IdP registrado | | `IdentityProviderActivatedEvent` | IdP activado | | `IdentityProviderDeactivatedEvent` | IdP desactivado | | `IdentityProviderRemovedEvent` | IdP eliminado definitivamente | ### Comandos / Casos de Uso + | Comando | Descripcion | -|---|---| +| --- | --- | | `RegisterTenantCommand` | Crear un nuevo tenant | | `SuspendTenantCommand` | Suspender un tenant activo | | `ActivateTenantCommand` | Reactivar un tenant suspendido | @@ -101,28 +99,24 @@ Esta frontera se formaliza en [ADR-0077](../../architecture/adrs/0077-tenant-por | `UpdateBranchCommand` | Actualizar nombre o metadatos de geocercado | | `DeactivateBranchCommand` | Desactivar una rama | | `ReactivateBranchCommand` | Reactivar una rama | -| `RemoveBranchCommand` | Eliminar una rama sin dependientes | -| `ConfigureBrandingCommand` | Configurar branding por primera vez | -| `UpdateBrandingCommand` | Actualizar atributos de branding | -| `SetCustomDomainCommand` | Agregar o reemplazar el dominio personalizado | -| `RemoveBrandingCommand` | Eliminar la configuracion de branding | -| `MarkDnsVerifiedCommand` | Interno — llamado por el servicio de verificacion DNS | -| `MarkDnsFailedCommand` | Interno — llamado por el servicio de verificacion DNS | +| `CloseBranchCommand` | Cerrar definitivamente una rama sin referencias vivas (ADR-0164) | | `RegisterIdentityProviderCommand` | Registrar un IdP externo | | `ActivateIdentityProviderCommand` | Activar un IdP | | `DeactivateIdentityProviderCommand` | Desactivar un IdP | | `RemoveIdentityProviderCommand` | Eliminar definitivamente un IdP inactivo | ### Limites de Repositorio / Servicio -- Acceso via `ITenantRepository`. -- `IBranchDependencyChecker` — servicio de dominio para validar dependencias antes de eliminar rama. -- `IIdpStrategyConsistencyService` — valida que la desactivacion de un IdP no deje al tenant sin ruta de autenticacion. + +* Acceso via `ITenantRepository`. +* Guarda de cascada del cierre de rama: la aplicacion cuenta las referencias vivas con `IUserAccountRepository.CountActiveByBranchAsync` e `IProfileRepository.CountActiveByBranchAsync` y se las pasa al dominio. Es obligatorio que lo haga la aplicacion porque `UserAccounts.BranchId` y `Profiles.BranchId` no tienen clave ajena contra `TenantBranches`: la base no restringe nada. +* `ITenantRepository.GetBranchLifecycleAsync` — lectura de la bitacora, fuera del agregado a proposito (ninguna invariante depende de la historia y la lectura del tenant es ruta caliente). +* `IIdpStrategyConsistencyService` — valida que la desactivacion de un IdP no deje al tenant sin ruta de autenticacion. --- ## 2. Modelo de Objetos -``` +```text Tenant (Aggregate Root) ├── Props: TenantProps │ ├── Id: IdValueObject @@ -135,32 +129,21 @@ Tenant (Aggregate Root) │ ├── ParentTenantId?: TenantId │ ├── Status: TenantStatus │ └── Audit: AuditValueObject -├── Branch (Entidad Propia, 0..N) -│ └── Props: BranchProps -│ ├── Id: IdValueObject -│ ├── TenantId: TenantId -│ ├── Code: Code -│ ├── Name: Name -│ ├── GeofencingMetadata?: Value (JSON) -│ ├── IsActive: bool -│ └── Audit: AuditValueObject -├── Branding (Entidad Propia, 0..1) -│ └── Props: BrandingProps -│ ├── Id: IdValueObject -│ ├── TenantId: TenantId -│ ├── Logo: Logo -│ ├── LogoFormat: LogoFormat -│ ├── PrimaryColor: HexColor -│ ├── BackgroundStyle: BackgroundStyle -│ ├── HeadlineText: LoginText -│ ├── SecondaryText: LoginText -│ ├── PrimaryButtonLabel: LoginText -│ ├── FooterText: LoginText -│ ├── CustomDomain?: CustomDomain -│ ├── DnsVerificationStatus: DnsVerificationStatus -│ ├── DnsCnameTarget: DnsCnameTarget -│ ├── MagicLinkFallbackEnabled: bool -│ └── Audit: AuditValueObject +├── Branch (Entidad Propia, 0..N — la coleccion NUNCA encoge) +│ ├── Props: BranchProps +│ │ ├── Id: IdValueObject +│ │ ├── TenantId: TenantId +│ │ ├── Code: Code +│ │ ├── Name: Name +│ │ ├── GeofencingMetadata?: Value (JSON) +│ │ ├── IsActive: bool (eje REVERSIBLE) +│ │ ├── IsClosed: bool (eje TERMINAL, ADR-0164) +│ │ ├── ClosedAtUtc?: DateTime +│ │ ├── ClosedBy?: string +│ │ └── Audit: AuditValueObject +│ └── PendingLifecycleEntries: BranchLifecycleEntry[] +│ (episodios anotados en ESTA unidad de trabajo; el repositorio los vuelca en la misma +│ transaccion. La bitacora historica no viaja con el agregado: se lee aparte) └── IdentityProvider (Entidad Propia, 0..N) └── Props: IdentityProviderProps ├── Id: IdValueObject @@ -174,25 +157,31 @@ Tenant (Aggregate Root) ``` ### Ciclo de Vida + **Tenant**: -``` + +```text Active ──► Suspended ──► Active -Active ──► Inactive (terminal) +Active ──► Archived (terminal) ``` + **Branch**: + +Dos ejes independientes. El de arriba es reversible; el de abajo, terminal. No hay transicion del +uno al otro: cerrar no exige desactivar antes, y desactivar no acerca al cierre (ADR-0164 §2.4). + +```text +Activo (IsActive = true) ◄──► Desactivado (IsActive = false) + │ │ + └──────────────┬───────────────┘ + ▼ + Cerrada (IsClosed = true) — TERMINAL, sin vuelta + (la fila permanece; el Code queda ocupado para siempre) ``` -Activo (IsActive = true) ──► Desactivado (IsActive = false) ──► Activo - └──► Eliminado (si no tiene dependientes) -``` -**Branding (DNS)**: -``` -(CustomDomain establecido) -> DnsVerificationStatus = Pending - ├──► Verified (CNAME DNS coincide) - └──► Failed (CNAME faltante o incorrecto) - └──► Pending (en reintento) -``` + **IdentityProvider**: -``` + +```text Registrado (IsActive = false) ──► Activado (IsActive = true) ──► Desactivado ──► Eliminado ``` @@ -201,6 +190,7 @@ Registrado (IsActive = false) ──► Activado (IsActive = true) ──► Des ## 3. Diagramas de Secuencia ### Flujo: Registrar Tenant + ```mermaid sequenceDiagram participant C as Cliente @@ -217,6 +207,7 @@ sequenceDiagram ``` ### Flujo: Suspender Tenant + ```mermaid sequenceDiagram participant C as Cliente @@ -236,6 +227,7 @@ sequenceDiagram ``` ### Flujo: Agregar Rama + ```mermaid sequenceDiagram participant C as Cliente @@ -255,69 +247,37 @@ sequenceDiagram H-->>C: BranchId ``` -### Flujo: Eliminar Rama -```mermaid -sequenceDiagram - participant C as Cliente - participant H as RemoveBranchHandler - participant R as ITenantRepository - participant T as Tenant (AR) - participant D as IBranchDependencyChecker +### Flujo: Cerrar Rama (borrado logico, ADR-0164) - C->>H: RemoveBranchCommand(tenantId, branchId, actorId) - H->>D: HasDependents(branchId) - D-->>H: false - H->>R: GetById(tenantId) - R-->>H: Tenant - H->>T: tenant.RemoveBranch(branchId, actorId) - T->>T: Eliminar Branch de coleccion - T->>T: Emitir BranchRemovedEvent - H->>R: Update(tenant) - H-->>C: void -``` +El cierre es TERMINAL y distinto de desactivar: la fila permanece, el codigo queda ocupado para +siempre y la rama no vuelve. Se rechaza con 409 si quedan cuentas o perfiles ACTIVOS asignados a +ella; lo ya eliminado no bloquea. -### Flujo: Configurar Branding ```mermaid sequenceDiagram participant C as Cliente - participant H as ConfigureBrandingHandler - participant R as ITenantRepository - participant T as Tenant (AR) - participant DNS as IDnsVerificationService - - C->>H: ConfigureBrandingCommand(tenantId, logo, colores, textos, customDomain?) - H->>R: GetById(tenantId) - R-->>H: Tenant - H->>T: tenant.ConfigureBranding(props, createdBy) - T->>T: Guardia: Branding no debe existir - T->>T: Crear Branding (DnsVerificationStatus = Pending si customDomain) - T->>T: Emitir BrandingCreatedEvent - H->>R: Update(tenant) - alt customDomain proporcionado - H->>DNS: ScheduleVerification(tenantId, cnameTarget, customDomain) - end - H-->>C: BrandingId -``` - -### Flujo: Verificacion DNS (Branding) -```mermaid -sequenceDiagram - participant DNS as IDnsVerificationService - participant H as MarkDnsVerifiedHandler + participant H as CloseBranchHandler participant R as ITenantRepository participant T as Tenant (AR) + participant U as IUserAccountRepository + participant P as IProfileRepository - DNS->>H: MarkDnsVerifiedCommand(tenantId, brandingId) + C->>H: CloseBranchCommand(tenantId, branchId, reason?) H->>R: GetById(tenantId) R-->>H: Tenant - H->>T: tenant.MarkDnsVerified(brandingId) - T->>T: Branding.DnsVerificationStatus = Verified - T->>T: Emitir BrandingDnsVerifiedEvent + H->>U: CountActiveByBranch(branchId) + U-->>H: 0 + H->>P: CountActiveByBranch(branchId) + P-->>H: 0 + H->>T: tenant.CloseBranch(branchId, actorId, 0, 0, reason) + T->>T: Marcar IsClosed y anotar el episodio en la bitacora + T->>T: Emitir BranchClosedEvent H->>R: Update(tenant) - DNS-->>H: ok + H-->>C: void ``` ### Flujo: Registrar IdP + ```mermaid sequenceDiagram participant C as Cliente @@ -337,6 +297,7 @@ sequenceDiagram ``` ### Flujo: Activar IdP + ```mermaid sequenceDiagram participant C as Cliente @@ -365,7 +326,7 @@ sequenceDiagram ```mermaid erDiagram TENANT ||--o{ BRANCH : "opera" - TENANT ||--o| BRANDING : "configura" + BRANCH ||--o{ BRANCH_LIFECYCLE_ENTRY : "bitacora" TENANT ||--o{ IDENTITY_PROVIDER : "registra" TENANT ||--o{ USER_ACCOUNT : "tiene" TENANT |o--o{ TENANT : "es_padre_de" @@ -391,35 +352,29 @@ erDiagram BRANCH { uniqueidentifier BranchId PK uniqueidentifier TenantId FK - nvarchar Code "Unico por TenantId" + nvarchar Code "Unico por TenantId - incluye las cerradas" nvarchar Name nvarchar GeofencingMetadata "JSON Nullable" bit IsActive + bit IsClosed "Cierre definitivo ADR-0164" + datetime2 ClosedAtUtc "Nullable" + nvarchar ClosedBy "Nullable" datetime2 CreatedAt uniqueidentifier CreatedBy datetime2 UpdatedAt uniqueidentifier UpdatedBy } - BRANDING { - uniqueidentifier BrandingId PK - uniqueidentifier TenantId FK "Unico - 1:1" - nvarchar Logo "URI Storage Path" - nvarchar LogoFormat "PNG-SVG-JPEG" - nvarchar PrimaryColor "Hex Color" - nvarchar BackgroundStyle "Glassmorphism-SleekDark" - nvarchar HeadlineText - nvarchar SecondaryText - nvarchar PrimaryButtonLabel - nvarchar FooterText - nvarchar CustomDomain "FQDN Nullable" - nvarchar DnsVerificationStatus "PENDING-VERIFIED-FAILED" - nvarchar DnsCnameTarget "CNAME de Plataforma" - bit MagicLinkFallbackEnabled - datetime2 CreatedAt - uniqueidentifier CreatedBy - datetime2 UpdatedAt - uniqueidentifier UpdatedBy + BRANCH_LIFECYCLE_ENTRY { + uniqueidentifier Id PK + uniqueidentifier TenantId + uniqueidentifier BranchId FK + int EpisodeId "1 Opened 2 Deactivated 3 Reactivated 4 Closed" + datetime2 OccurredAtUtc + nvarchar ActorId + nvarchar NameSnapshot "Foto de la epoca" + nvarchar GeofencingSnapshot "Nullable" + nvarchar Reason "Nullable" } IDENTITY_PROVIDER { @@ -446,11 +401,9 @@ flowchart TD subgraph Identity["Identity BC"] T[Tenant AR] B[Branch Entity] - BR[Branding Entity] IDP[IdentityProvider Entity] UA[UserAccount AR] T --> B - T --> BR T --> IDP UA -->|TenantId| T UA -->|BranchId opcional| B @@ -466,8 +419,6 @@ flowchart TD end subgraph Infrastructure["Infrastructure"] - DNS[DNS Verification Service] - STORE[File Storage - Logo URI] EXTIDP[IdP Externo - Azure AD, Okta] end @@ -478,12 +429,8 @@ flowchart TD IDP -->|IdentityProviderRegisteredEvent| IDPC IDP -->|IdentityProviderDeactivatedEvent| IDPC EXTIDP -->|Contrato de Protocolo| IDP - DNS -->|MarkDnsVerifiedCommand| BR - DNS -->|MarkDnsFailedCommand| BR - STORE -->|Logo URI almacenado| BR T -->|eventos de dominio| AUD B -->|eventos de dominio| AUD - BR -->|eventos de dominio| AUD IDP -->|eventos de dominio| AUD ``` @@ -492,8 +439,9 @@ flowchart TD ## 6. Contrato de Capa de Aplicacion ### Comandos + | Comando | Entrada | Salida | -|---|---|---| +| --- | --- | --- | | `RegisterTenantCommand` | `code, name, orgType, idpStrategy, createdBy` | `Guid tenantId` | | `SuspendTenantCommand` | `tenantId, actorId` | `void` | | `ActivateTenantCommand` | `tenantId, actorId` | `void` | @@ -501,39 +449,35 @@ flowchart TD | `UpdateBranchCommand` | `tenantId, branchId, name?, geofencingMetadata?, updatedBy` | `void` | | `DeactivateBranchCommand` | `tenantId, branchId, actorId` | `void` | | `ReactivateBranchCommand` | `tenantId, branchId, actorId` | `void` | -| `RemoveBranchCommand` | `tenantId, branchId, actorId` | `void` | -| `ConfigureBrandingCommand` | `tenantId, logo, logoFormat, primaryColor, backgroundStyle, headlineText, secondaryText, primaryButtonLabel, footerText, customDomain?, cnameTarget, magicLinkFallback, createdBy` | `Guid brandingId` | -| `UpdateBrandingCommand` | `tenantId, brandingId, campos..., updatedBy` | `void` | -| `SetCustomDomainCommand` | `tenantId, brandingId, customDomain, updatedBy` | `void` | -| `RemoveBrandingCommand` | `tenantId, brandingId, actorId` | `void` | -| `MarkDnsVerifiedCommand` | `tenantId, brandingId` | `void` | -| `MarkDnsFailedCommand` | `tenantId, brandingId, reason` | `void` | +| `CloseBranchCommand` | `tenantId, branchId, reason?` | `void` | | `RegisterIdentityProviderCommand` | `tenantId, code, name, description, strategy, createdBy` | `Guid idpId` | | `ActivateIdentityProviderCommand` | `tenantId, idpId, actorId` | `void` | | `DeactivateIdentityProviderCommand` | `tenantId, idpId, actorId` | `void` | | `RemoveIdentityProviderCommand` | `tenantId, idpId, actorId` | `void` | ### Consultas + | Consulta | Retorna | -|---|---| +| --- | --- | | `GetTenantByIdQuery(tenantId)` | `TenantDto?` | | `GetTenantByCodeQuery(code)` | `TenantDto?` | +| `GetBranchesByTenantIdQuery(tenantId, includeClosed = false)` | `IReadOnlyList` — sin las cerradas salvo peticion expresa | +| `GetBranchLifecycleQuery(tenantId, branchId)` | `IReadOnlyList` — bitacora; responde tambien para ramas cerradas | ### Casos de Error + | Codigo | Condicion | -|---|---| +| --- | --- | | `TENANT_CODE_DUPLICATE` | Code ya existe globalmente | | `TENANT_NOT_FOUND` | tenantId desconocido | | `TENANT_NOT_ACTIVE` | Operacion requiere tenant activo | | `TENANT_SUSPENDED` | Tenant actualmente suspendido | | `BRANCH_CODE_DUPLICATE` | Code ya existe en el tenant | | `BRANCH_NOT_FOUND` | branchId desconocido en el tenant | -| `BRANCH_HAS_DEPENDENTS` | Eliminacion bloqueada por usuarios o perfiles activos | +| `BRANCH_HAS_LIVE_REFERENCES` | Cierre bloqueado por usuarios o perfiles ACTIVOS; la respuesta 409 desglosa cuantos de cada clase | | `BRANCH_ALREADY_INACTIVE` | Desactivar una rama ya inactiva | -| `BRANDING_ALREADY_EXISTS` | ConfigureBranding llamado dos veces | -| `BRANDING_NOT_FOUND` | Sin branding configurado para el tenant | -| `DNS_ALREADY_VERIFIED` | Intento de re-verificar un dominio ya verificado | -| `INVALID_CUSTOM_DOMAIN` | No es un formato FQDN valido | +| `tenant.branch_already_closed` | Reintentar el cierre de una rama ya cerrada (409) | +| `tenant.branch_closed` | Desactivar o reactivar una rama cerrada (409) | | `IDP_CODE_DUPLICATE` | Code existe en el tenant | | `IDP_NOT_FOUND` | idpId desconocido en el tenant | | `IDP_STRATEGY_IMMUTABLE` | Intento de cambiar Strategy | @@ -545,49 +489,49 @@ flowchart TD ## 7. Notas de Persistencia ### Indices + | Indice | Columnas | Tipo | -|---|---|---| +| --- | --- | --- | | `IX_Tenant_Code` | `Code` | Unico | | `IX_Tenant_ParentTenantId` | `ParentTenantId` | No unico | | `IX_Branch_TenantId` | `TenantId` | No unico | -| `IX_Branch_TenantId_Code` | `TenantId, Code` | Unico | +| `IX_Branch_TenantId_Code` | `TenantId, Code` | Unico — **sin filtrar por el estado de cierre** (ADR-0164 §2.3) | | `IX_Branch_IsActive` | `IsActive` | No unico | -| `IX_Branding_TenantId` | `TenantId` | Unico (impone 1:1) | -| `IX_Branding_CustomDomain` | `CustomDomain` | Unico (parcial - no nulo) | +| `IX_TenantBranches_IsClosed` | `IsClosed` | No unico, parcial (`IsClosed = false`) | +| `IX_TenantBranchLifecycleEntries_BranchId_OccurredAtUtc` | `BranchId, OccurredAtUtc` | No unico | | `IX_IdentityProvider_TenantId_Code` | `TenantId, Code` | Unico | | `IX_IdentityProvider_TenantId_IsActive` | `TenantId, IsActive` | No unico | ### Consideraciones Multi-Tenant -- Todas las consultas de entidades hijas deben estar filtradas por `TenantId`. -- `Code` en Tenant es clave unica global — no por tenant. -- `CustomDomain` unico entre todos los tenants (un dominio no puede ser reclamado por dos tenants). + +* Todas las consultas de entidades hijas deben estar filtradas por `TenantId`. +* `Code` en Tenant es clave unica global — no por tenant. --- ## 8. Seguridad y Auditoria ### Reglas de Autorizacion + | Operacion | Rol Requerido | -|---|---| +| --- | --- | | Registrar Tenant | Platform:Admin | | Suspender / Activar Tenant | Platform:Admin | | Agregar / Eliminar Branch | Tenant:Admin | | Desactivar / Reactivar Branch | Tenant:Admin | | Listar Branches | Tenant:Admin · Tenant:UserManager | -| Configurar / Actualizar Branding | Tenant:Admin | -| Establecer Dominio Personalizado | Tenant:Admin | -| Marcar DNS Verificado/Fallido | Solo servicio interno | | Registrar / Eliminar IdP | Tenant:Admin | | Activar / Desactivar IdP | Tenant:Admin | ### Eventos de Auditoria -- Tenant: `TENANT_CREATED`, `TENANT_SUSPENDED`, `TENANT_ACTIVATED` -- Branch: `BRANCH_CREATED`, `BRANCH_DEACTIVATED`, `BRANCH_REACTIVATED`, `BRANCH_REMOVED` -- Branding: `BRANDING_CONFIGURED`, `BRANDING_UPDATED`, `BRANDING_REMOVED`, `DNS_VERIFIED`, `DNS_FAILED` -- IdentityProvider: `IDP_REGISTERED`, `IDP_ACTIVATED`, `IDP_DEACTIVATED`, `IDP_REMOVED` + +* Tenant: `TENANT_CREATED`, `TENANT_SUSPENDED`, `TENANT_ACTIVATED` +* Branch: `BRANCH_CREATED`, `BRANCH_DEACTIVATED`, `BRANCH_REACTIVATED`, `BRANCH_REMOVED` +* IdentityProvider: `IDP_REGISTERED`, `IDP_ACTIVATED`, `IDP_DEACTIVATED`, `IDP_REMOVED` ### Datos Sensibles -- `IdentityProvider` en si no almacena credenciales. Los secretos viven en `IDP_CONFIGURATION.SecretRef` (ruta al vault). + +* `IdentityProvider` en si no almacena credenciales. Los secretos viven en `IDP_CONFIGURATION.SecretRef` (ruta al vault). --- @@ -597,9 +541,9 @@ flowchart TD El UMS soporta un tenant especial llamado `INTERNAL_ADMIN` (ID: `11111111-1111-1111-1111-111111111111`) que está reservado para administradores internos de la plataforma. Los usuarios pertenecientes a este tenant tienen privilegios elevados que les permiten: -- Ver y gestionar todos los tenants del sistema -- Cambiar el contexto de tenant para realizar operaciones administrativas -- Acceder a datos de múltiples tenants para soporte y mantenimiento +* Ver y gestionar todos los tenants del sistema +* Cambiar el contexto de tenant para realizar operaciones administrativas +* Acceder a datos de múltiples tenants para soporte y mantenimiento ### Modos de Acceso @@ -614,8 +558,8 @@ El UMS soporta un tenant especial llamado `INTERNAL_ADMIN` (ID: `11111111-1111-1 2. **JWT Claim**: El claim `is_internal_admin` se añade al token 3. **Tenant Context**: `ITenantContext.Initialize()` se llama con `isInternalAdmin=true` 4. **Operaciones Cross-Tenant**: El admin puede llamar a `POST /api/v1/auth/switch-tenant` para: - - Establecer contexto a un tenant específico: `{ "tenantId": "...", "enableCrossTenantAccess": false }` - - Habilitar acceso completo cross-tenant: `{ "tenantId": "...", "enableCrossTenantAccess": true }` + * Establecer contexto a un tenant específico: `{ "tenantId": "...", "enableCrossTenantAccess": false }` + * Habilitar acceso completo cross-tenant: `{ "tenantId": "...", "enableCrossTenantAccess": true }` ### Interfaz ITenantContext @@ -635,50 +579,39 @@ public interface ITenantContext ### Endpoints Clave | Método | Endpoint | Descripción | -|--------|----------|-------------| +| -------- | ---------- | ------------- | | POST | `/api/v1/auth/login` | Retorna flag `isInternalAdmin` en la respuesta | | POST | `/api/v1/auth/switch-tenant` | Cambiar contexto de tenant (solo admins) | | GET | `/api/v1/auth/session` | Retorna sesión actual con flag de admin | ### Consideraciones de Seguridad -- Los usuarios regulares no pueden cambiar su `OrganizationId` — enforced en `TenantContext.SetOrganizationId()` -- Solo usuarios en tenant `INTERNAL_ADMIN` pueden llamar al endpoint `switch-tenant` -- El acceso cross-tenant es auditado via entradas de `AuditRecord` -- Los query filters se short-circuit cuando `OrganizationId` es `null` (muestra todos los tenants a admins) +* Los usuarios regulares no pueden cambiar su `OrganizationId` — enforced en `TenantContext.SetOrganizationId()` +* Solo usuarios en tenant `INTERNAL_ADMIN` pueden llamar al endpoint `switch-tenant` +* El acceso cross-tenant es auditado via entradas de `AuditRecord` +* Los query filters se short-circuit cuando `OrganizationId` es `null` (muestra todos los tenants a admins) ### Notas de Implementación -**Consulta GraphQL (Recomendado)** -El endpoint GraphQL `tenantBranches(tenantId: UUID!)` es la forma recomendada para acceder a datos de branches de cualquier tenant. Los admins internos pueden consultar branches de cualquier tenant pasando el parámetro `tenantId` directamente. Este enfoque funciona correctamente sin necesidad de cambiar el contexto de tenant. +**Consulta REST (Recomendado)** +El endpoint `GET /api/v1/tenants/{tenantId}/branches` es la forma recomendada para acceder a datos de branches de cualquier tenant. Los admins internos pueden consultar branches de cualquier tenant pasando `tenantId` en la ruta. Este enfoque funciona correctamente sin necesidad de cambiar el contexto de tenant. **Endpoint REST `/api/v1/auth/switch-tenant`** -- El endpoint valida tokens JWT directamente (bypass al middleware de autenticación de ASP.NET Core) -- Esto permite que funcione en modo desarrollo donde `Authentication:Enabled` puede estar en `false` -- El endpoint inicializa manualmente `ITenantContext` desde los claims del JWT después de la validación -- Requiere que el JWT contenga el claim `is_internal_admin=true` - -**Query Splitting de EF Core (SQLite)** -EF Core 7+ usa split query mode por defecto cuando se usan múltiples sentencias `Include()`. Esto causa `SQLite Error: 'near "EXEC": syntax error'` porque las split queries usan sentencias `EXEC` no soportadas por SQLite. Usar `.AsSingleQuery()` para forzar modo single-query: -```csharp -var record = await dbContext.Tenants - .AsSingleQuery() // Fuerza single query en lugar de split - .Include(x => x.Branches) - .Include(x => x.IdentityProviders) - .Include(x => x.Branding) - .FirstOrDefaultAsync(x => x.Id == id); -``` +* El endpoint valida tokens JWT directamente (bypass al middleware de autenticación de ASP.NET Core) +* Esto permite que funcione en modo desarrollo donde `Authentication:Enabled` puede estar en `false` +* El endpoint inicializa manualmente `ITenantContext` desde los claims del JWT después de la validación +* Requiere que el JWT contenga el claim `is_internal_admin=true` ### Estado Actual | Componente | Estado | Notas | -|-----------|--------|-------| +| ----------- | -------- | ------- | | Login con `INTERNAL_ADMIN` | Funcionando | Retorna `isInternalAdmin: true` en la respuesta | -| Query GraphQL `tenantBranches` | Funcionando | El admin puede consultar branches de cualquier tenant | +| `GET /api/v1/tenants/{tenantId}/branches` | Funcionando | El admin puede consultar branches de cualquier tenant | | Endpoint REST `switch-tenant` | Funcionando (con fix) | JWT validado directamente, TenantContext inicializado manualmente | -| Query GraphQL `getTenants` | No disponible | Usar endpoint REST u otra consulta alternativa | -| CRUD de Branch via GraphQL | Funcionando | Modo single query previene problemas con SQLite | +| `GET /api/v1/tenants` | Funcionando | Lista los tenants del sistema | +| CRUD de Branch via REST | Funcionando | Comandos y consultas REST sobre `/api/v1/tenants/{tenantId}/branches` | --- @@ -691,21 +624,21 @@ Los administradores con permisos apropiados pueden restablecer contraseñas de u ### Tipos de Admin y Su Alcance | Tipo de Admin | Asociación de Tenant | Alcance Operativo | -|------------|-------------------|-------------------| +| ------------ | ------------------- | ------------------- | | **Admin de Plataforma Interno** | Pertenece al tenant `INTERNAL_ADMIN` | Puede operar sobre usuarios en **cualquier tenant** | | **Admin de Tenant** | Pertenece a un tenant específico | Puede operar sobre usuarios en **su propio tenant únicamente** | ### Reglas de Autorización 1. **Autorización de Admin Interno** - - Debe tener el permiso `CAN_RESET_PASSWORD` asignado a su rol - - Debe tener el permiso `CAN_MODIFY_VALIDITY_PERIOD` asignado a su rol - - Puede realizar operaciones sobre cualquier usuario en el sistema + * Debe tener el permiso `CAN_RESET_PASSWORD` asignado a su rol + * Debe tener el permiso `CAN_MODIFY_VALIDITY_PERIOD` asignado a su rol + * Puede realizar operaciones sobre cualquier usuario en el sistema 2. **Autorización de Admin de Tenant** - - Debe tener los permisos requeridos (`CAN_RESET_PASSWORD` y/o `CAN_MODIFY_VALIDITY_PERIOD`) - - El usuario objetivo debe pertenecer al mismo tenant que el admin (`targetUser.TenantId == OrganizationId`) - - Las operaciones cross-tenant son rechazadas con error `AUTH_010` + * Debe tener los permisos requeridos (`CAN_RESET_PASSWORD` y/o `CAN_MODIFY_VALIDITY_PERIOD`) + * El usuario objetivo debe pertenecer al mismo tenant que el admin (`targetUser.TenantId == OrganizationId`) + * Las operaciones cross-tenant son rechazadas con error `AUTH_010` ### Feature Flags @@ -721,7 +654,7 @@ Estas capacidades son controladas por feature flags (configurables a nivel siste Cada reset de contraseña y modificación de período de vigencia DEBE generar un registro de auditoría con: | Campo | Descripción | -|-------|-------------| +| ------- | ------------- | | `adminUserId` | ID del administrador que realizó la acción | | `targetUserId` | ID del usuario afectado por la acción | | `targetTenantId` | ID del tenant del usuario afectado | @@ -741,7 +674,7 @@ Cada reset de contraseña y modificación de período de vigencia DEBE generar u ### Códigos de Error | Código | Descripción | -|------|-------------| +| ------ | ------------- | | `AUTH_009` | Administrador carece del permiso requerido | | `AUTH_010` | Usuario objetivo fuera del alcance del administrador | | `USER_015` | Usuario federado no puede tener contraseña local restablecida | @@ -750,17 +683,17 @@ Cada reset de contraseña y modificación de período de vigencia DEBE generar u ### Parámetros de Configuración (Configurables vía UMS) | Parámetro | Ubicación de Config | Default | Descripción | -|-----------|-----------------|---------|-------------| +| ----------- | ----------------- | --------- | ------------- | | `MAX_VALIDITY_PERIOD_DAYS` | `AppConfiguration` | 365 | Período de vigencia máximo permitido | | `MIN_PASSWORD_LENGTH` | `AppConfiguration` | 12 | Requisito de longitud mínima de contraseña | | `PASSWORD_RESET_NOTIFICATION_CHANNEL` | `AppConfiguration` | email | Canal para notificar a usuarios | -### Consideraciones de Seguridad +### Consideraciones de Seguridad del Reset de Contraseña -- Los valores de contraseñas nunca son mostrados o incluidos en mensajes operativos -- Los usuarios afectados deben ser notificados cuando su contraseña es restablecida o su período de vigencia es modificado -- Las operaciones cross-tenant por admins de tenant son bloqueadas en la capa de autorización -- Todas las operaciones son registradas para cumplimiento y propósitos de auditoría +* Los valores de contraseñas nunca son mostrados o incluidos en mensajes operativos +* Los usuarios afectados deben ser notificados cuando su contraseña es restablecida o su período de vigencia es modificado +* Las operaciones cross-tenant por admins de tenant son bloqueadas en la capa de autorización +* Todas las operaciones son registradas para cumplimiento y propósitos de auditoría --- @@ -773,7 +706,7 @@ UMS proporciona una capacidad centralizada de gestión de parámetros del sistem ### Scopes de Configuración | Scope | Descripción | Quién Puede Gestionar | -|-------|-------------|----------------| +| ------- | ------------- | ---------------- | | **Global** | Parámetros a nivel de sistema sin asociación de tenant | Solo admins internos | | **Tenant** | Parámetros específicos de tenant | Admins internos (cualquier tenant), Admins de tenant (solo su propio tenant) | | **Suite** | Parámetros scope a un system suite específico | Solo admins internos | @@ -782,20 +715,20 @@ UMS proporciona una capacidad centralizada de gestión de parámetros del sistem ### Reglas de Autorización para Gestión de Configuración 1. **Acceso a Configuración Global** - - Solo usuarios con `IsInternalAdmin=true` pueden acceder a configuraciones globales - - Los admins de tenant no pueden ver, crear, modificar ni eliminar configuraciones globales - - Intentar acceder a configs globales retorna `403 Forbidden` + * Solo usuarios con `IsInternalAdmin=true` pueden acceder a configuraciones globales + * Los admins de tenant no pueden ver, crear, modificar ni eliminar configuraciones globales + * Intentar acceder a configs globales retorna `403 Forbidden` 2. **Acceso a Configuración de Tenant** - - Los admins internos pueden gestionar las configuraciones de cualquier tenant - - Los admins de tenant solo pueden gestionar las configuraciones de su propio tenant - - El acceso cross-tenant por admins de tenant retorna `403 Forbidden` + * Los admins internos pueden gestionar las configuraciones de cualquier tenant + * Los admins de tenant solo pueden gestionar las configuraciones de su propio tenant + * El acceso cross-tenant por admins de tenant retorna `403 Forbidden` 3. **Verificaciones de Autorización en API** Todos los endpoints de AppConfiguration verifican: - - `ITenantContext.IsInternalAdmin` determina si el usuario tiene acceso cross-tenant - - `ITenantContext.OrganizationId` determina el scope de tenant del usuario - - El scope de configuración se deriva de la presencia de `TenantId`, `SystemSuiteId`, y `ModuleId` + * `ITenantContext.IsInternalAdmin` determina si el usuario tiene acceso cross-tenant + * `ITenantContext.OrganizationId` determina el scope de tenant del usuario + * El scope de configuración se deriva de la presencia de `TenantId`, `SystemSuiteId`, y `ModuleId` ### Modelo de Parámetro @@ -818,7 +751,7 @@ public class AppConfiguration : AggregateRoot **Idioma:** [English](../../domain/identity/user-account.md) | [Español](./user-account.md) - **Bounded Context:** Identity **Aggregate Root:** `UserAccount` **Modulo:** `Ums.Domain.Identity.UserAccount` @@ -12,23 +10,27 @@ ## 1. Descripcion del Agregado ### Proposito + `UserAccount` representa la identidad digital de un usuario dentro de un Tenant. Es el punto central de autenticacion, gestion del ciclo de vida, borrado logico, credenciales y configuracion de MFA. Posee `PasswordCredential` y `MfaEnrollment` como entidades propias. ### Responsabilidad de Negocio -- Gestionar el ciclo de vida implementado del usuario: Pending -> Active -> Blocked -> Active, mas Deleted como estado terminal de borrado logico. -- Soportar Phase 3 user signup representando solicitudes de alta como cuentas `Pending`. -- Soportar el lobby de onboarding como estado derivado cuando el usuario esta `Active` pero no tiene `Profile` activo. -- Proveer la identidad central para autenticacion local y federada. -- Controlar credenciales de contrasena (`PasswordCredential`) con historial de rotacion y asegurar rotacion segura. -- Administrar metodos MFA (`MfaEnrollment`) enrollados por el usuario de forma independiente (TOTP, SMS, Email, WebAuthn). + +* Gestionar el ciclo de vida implementado del usuario: Pending -> Active -> Blocked -> Active, mas Deleted como estado terminal de borrado logico. +* Soportar Phase 3 user signup representando solicitudes de alta como cuentas `Pending`. +* Soportar el lobby de onboarding como estado derivado cuando el usuario esta `Active` pero no tiene `Profile` activo. +* Proveer la identidad central para autenticacion local y federada. +* Controlar credenciales de contrasena (`PasswordCredential`) con historial de rotacion y asegurar rotacion segura. +* Administrar metodos MFA (`MfaEnrollment`) enrollados por el usuario de forma independiente (TOTP, SMS, Email, WebAuthn). **PasswordCredential**: Almacena el hash BCrypt de la contrasena para autenticacion local. Soporta rotacion de credenciales con registros historicos (inactivos). **MfaEnrollment**: Registra el enrolamiento de un usuario en un metodo MFA especifico. Se pueden enrolar multiples metodos por usuario, cada uno con su propio ciclo de vida (`NotEnrolled`, `Enrolled`, `Verified`). ### Aggregate Root + `UserAccount` es su propio aggregate root. Todas las mutaciones de `PasswordCredential` y `MfaEnrollment` pasan por comandos de `UserAccount`. ### Invariantes y Reglas de Consistencia + 1. **UserAccount**: `Email` debe ser unico dentro del mismo `TenantId`. 2. **UserAccount**: Un usuario en estado `Blocked` no puede autenticarse. 3. **UserAccount/PasswordCredential**: Un usuario federado (con `IdentityReference`) no debe tener `PasswordCredential` activa. @@ -42,8 +44,9 @@ 11. **MfaEnrollment**: Al menos un metodo enrolado debe permanecer si el tenant requiere MFA. ### Entidades Relacionadas / Value Objects + | Entidad / VO | Tipo | Notas | -|---|---|---| +| --- | --- | --- | | `TenantId` | Value Object | FK al Tenant propietario | | `BranchId` | Value Object | FK opcional a Branch. Ya esta soportado en props, persistencia, contratos de aplicacion y flujo de creacion del agregado. | | `Email` | Value Object | Unico por TenantId | @@ -54,8 +57,9 @@ | `AuditValueObject` | Value Object | CreatedAt/By, UpdatedAt/By | ### Eventos de Dominio + | Evento | Disparador | -|---|---| +| --- | --- | | `UserRegisteredEvent` | Usuario registrado en el sistema | | `UserActivatedEvent` | Usuario activado (Pending o Blocked -> Active) | | `UserBlockedEvent` | Usuario bloqueado | @@ -65,11 +69,12 @@ | `MfaVerifiedEvent` | Desafio MFA completado exitosamente | | `AuthenticationAttemptedEvent` | Intento de autenticacion registrado | -*(Nota: Las operaciones de contrasena alimentan la auditoria y no tienen un evento dedicado propio)* +> Nota: Las operaciones de contrasena alimentan la auditoria y no tienen un evento dedicado propio. ### Comandos / Casos de Uso + | Comando | Descripcion | -|---|---| +| --- | --- | | `RegisterUserCommand` | Registrar nuevo usuario | | `ActivateUserCommand` | Activar usuario pendiente o bloqueado | | `BlockUserCommand` | Bloquear usuario activo | @@ -84,7 +89,7 @@ ## 2. Modelo de Objetos -``` +```text UserAccount (Aggregate Root) ├── Props: UserAccountProps │ ├── Id: IdValueObject @@ -114,19 +119,25 @@ UserAccount (Aggregate Root) ``` ### Ciclo de Vida + **UserAccount**: -``` + +```text Pending -> Active -> Blocked -> Active Active -> Deleted ``` + **PasswordCredential**: -``` + +```text Nueva Credencial (IsActive = true) ↓ (en SetPassword) Credencial Anterior (IsActive = false) — retenida para historial ``` + **MfaEnrollment**: -``` + +```text NotEnrolled ──► Enrolled ──► Verified ``` @@ -135,6 +146,7 @@ NotEnrolled ──► Enrolled ──► Verified ## 3. Diagramas de Secuencia ### Flujo: Registrar Usuario + ```mermaid sequenceDiagram participant C as Cliente @@ -151,6 +163,7 @@ sequenceDiagram ``` ### Flujo: Bloquear Usuario + ```mermaid sequenceDiagram participant C as Cliente @@ -170,6 +183,7 @@ sequenceDiagram ``` ### Flujo: Establecer Contrasena + ```mermaid sequenceDiagram participant C as Cliente @@ -192,6 +206,7 @@ sequenceDiagram ``` ### Flujo: Desactivar Credencial (en federacion) + ```mermaid sequenceDiagram participant H as LinkExternalIdentityHandler @@ -208,6 +223,7 @@ sequenceDiagram ``` ### Flujo: Enrolar MFA + ```mermaid sequenceDiagram participant C as Cliente @@ -231,6 +247,7 @@ sequenceDiagram ``` ### Flujo: Verificar MFA + ```mermaid sequenceDiagram participant C as Cliente @@ -355,8 +372,9 @@ flowchart TD ## 6. Contrato de Capa de Aplicacion ### Comandos + | Comando | Entrada | Salida | -|---|---|---| +| --- | --- | --- | | `RegisterUserCommand` | `tenantId, email, category, createdBy` | `Guid userId` | | `ActivateUserCommand` | `userId, actorId` | `void` | | `BlockUserCommand` | `userId, reason, actorId` | `void` | @@ -365,14 +383,16 @@ flowchart TD | `VerifyMfaCommand` | `userId, enrollmentId, otp, actorId` | `void` | ### Consultas + | Consulta | Retorna | -|---|---| +| --- | --- | | `GetUserMfaEnrollmentsQuery(userId)` | `List` | | `GetUserAccountByIdQuery(userId)` | Estado `hasActivePassword` y `passwordUpdatedAtUtc`, nunca `PasswordHash` | ### Casos de Error + | Codigo | Condicion | -|---|---| +| --- | --- | | `USER_EMAIL_DUPLICATE` | Email ya existe en el tenant | | `USER_NOT_FOUND` | userId desconocido | | `USER_NOT_ACTIVE` | Operacion requiere usuario activo | @@ -387,35 +407,39 @@ flowchart TD ## 7. Notas de Persistencia ### Mapeo de Estado de Onboarding + | Concepto de Negocio | Estado Persistido | Notas | -|---|---|---| +| --- | --- | --- | | Solicitud de alta de usuario pendiente | `UserStatus.Pending` | El flujo publico de Phase 3 crea un `UserAccount` pendiente. | | Alta de usuario aprobada | `UserStatus.Active` | La aprobacion se implementa mediante activacion. | | Usuario activo sin perfil | `UserStatus.Active` mas ausencia de `Profile` activo | Es un estado derivado de lobby, no un `UserStatus` persistido. | | Alta de usuario denegada | Resultado requerido por EP-09 | Aun se requiere comando dedicado de denegacion y motivo de ciclo de vida; `Blocked` existe pero no es semanticamente identico a denegacion de signup. | ### Indices + | Indice | Columnas | Tipo | -|---|---|---| +| --- | --- | --- | | `IX_UserAccount_TenantId_Email` | `TenantId, Email` | Unico | | `IX_UserAccount_TenantId` | `TenantId` | No unico | | `IX_PasswordCredential_UserAccountId_IsActive` | `UserAccountId, IsActive` | No unico | | `IX_MfaEnrollment_UserAccountId_Method` | `UserAccountId, Method` | Unico (solo activos) | ### Seguridad y Restricciones Unicas -- `(UserAccountId, Method)` — solo un enrolamiento por metodo por usuario activo. -- La columna `PasswordHash` nunca debe aparecer en proyecciones de consultas retornadas a clientes. -- `PasswordHash` nunca debe aparecer en payloads `AuditRecord.WhatChanged`. -- La columna debe estar encriptada en reposo (SQL Server Always Encrypted o TDE). -- El cliente web entrega una contraseña temporal mediante transporte seguro; la API genera el hash BCrypt antes de persistir. + +* `(UserAccountId, Method)` — solo un enrolamiento por metodo por usuario activo. +* La columna `PasswordHash` nunca debe aparecer en proyecciones de consultas retornadas a clientes. +* `PasswordHash` nunca debe aparecer en payloads `AuditRecord.WhatChanged`. +* La columna debe estar encriptada en reposo (cifrado a nivel de columna con pgcrypto o cifrado del volumen/almacenamiento de PostgreSQL). +* El cliente web entrega una contraseña temporal mediante transporte seguro; la API genera el hash BCrypt antes de persistir. --- ## 8. Seguridad y Auditoria ### Reglas de Autorizacion + | Operacion | Rol Requerido | -|---|---| +| --- | --- | | Registrar Usuario | Tenant:Admin · Tenant:UserManager | | Bloquear / Restaurar | Tenant:Admin | | Establecer Contrasena | Usuario mismo o Tenant:Admin | @@ -425,16 +449,19 @@ flowchart TD | Verificar MFA | Usuario mismo | ### Datos Sensibles -- `PasswordHash` es el campo mas sensible del sistema. El acceso de lectura debe ser bloqueado a nivel de repositorio. -- `Email` es PII — enmascarado en logs. + +* `PasswordHash` es el campo mas sensible del sistema. El acceso de lectura debe ser bloqueado a nivel de repositorio. +* `Email` es PII — enmascarado en logs. ### Eventos de Auditoria -- `USER_REGISTERED`, `USER_ACTIVATED`, `USER_BLOCKED`, `USER_RESTORED`, `USER_DELETED` -- `PASSWORD_SET` — registrado con `actorId`, `userId`, timestamp. Hash nunca registrado. -- `MFA_ENROLLED`, `MFA_VERIFIED` + +* `USER_REGISTERED`, `USER_ACTIVATED`, `USER_BLOCKED`, `USER_RESTORED`, `USER_DELETED` +* `PASSWORD_SET` — registrado con `actorId`, `userId`, timestamp. Hash nunca registrado. +* `MFA_ENROLLED`, `MFA_VERIFIED` ### Cumplimiento -- GDPR: El hash no es PII, pero la presencia de un registro de credencial implica cuenta local. Al borrar cuenta, el hash debe ser anulado. -- NIST 800-63B: BCrypt con factor de costo apropiado requerido. -- Los registros de enrolamiento MFA deben conservarse para trazabilidad de auditoria incluso despues de la revocacion. -- Las credenciales WebAuthn (passkeys) nunca deben almacenar datos raw de atestigamiento FIDO en el modelo de dominio — eso pertenece a la capa de infraestructura. + +* GDPR: El hash no es PII, pero la presencia de un registro de credencial implica cuenta local. Al borrar cuenta, el hash debe ser anulado. +* NIST 800-63B: BCrypt con factor de costo apropiado requerido. +* Los registros de enrolamiento MFA deben conservarse para trazabilidad de auditoria incluso despues de la revocacion. +* Las credenciales WebAuthn (passkeys) nunca deben almacenar datos raw de atestigamiento FIDO en el modelo de dominio — eso pertenece a la capa de infraestructura. diff --git a/docs/domain-es/identity/user-management-delegation.md b/docs/domain-es/identity/user-management-delegation.md index 08015325..21639267 100644 --- a/docs/domain-es/identity/user-management-delegation.md +++ b/docs/domain-es/identity/user-management-delegation.md @@ -1,14 +1,525 @@ -# User Management Delegation (Espanol) +# UserManagementDelegation — Arquitectura del agregado -> Esta pagina es el espejo en espanol de [user-management-delegation.md](user-management-delegation.md). -> El contenido detallado permanece en la version en ingles hasta completar la traduccion completa. +**Bounded Context:** Identity +**Aggregate Root:** `UserManagementDelegation` +**Módulo:** `Ums.Domain.Identity.UserManagementDelegation` +**Esquema:** `delegation` +**Estado:** Producción +**Historia funcional:** [FS-14 — Delegar la gestión de usuarios entre administradores](../../historias-funcionales/fs-14-gestion-delegada.md) +**Épica:** EP-06 (Post-MVP) -## Idioma +--- -- Ingles: [user-management-delegation.md](user-management-delegation.md) -- Espanol: [user-management-delegation.es.md] +## 1. Visión general del agregado -## Resumen +### Propósito -- Ver la version en ingles para el contenido completo. -- Esta pagina existe para mantener la paridad bilingue del portal documental. +`UserManagementDelegation` permite que un administrador (`DelegatingAdmin`) transfiera un subconjunto controlado y acotado en el tiempo de su autoridad de gestión de usuarios a otro administrador (`DelegatedAdmin`). La autoridad delegada está restringida por tipo de alcance, acciones permitidas y una compuerta de aprobación opcional. El agregado hace cumplir el principio de **no-elevación**: un delegante nunca puede otorgar más autoridad de la que él mismo posee. + +### Responsabilidad de negocio + +* Registrar qué administrador delegó qué autoridad y a quién. +* Hacer cumplir las restricciones de alcance (`TENANT`, `ORGANIZATION`, `DEPARTMENT`, `SYSTEM`, `TEAM`) para que el administrador delegado opere solo dentro del límite autorizado. +* Hacer cumplir la lista de acciones permitidas (`CREATE_USER`, `BLOCK_USER`, `ASSIGN_PROFILE`, `RESET_PASSWORD`, `REVOKE_MFA`). +* Gestionar la validez temporal (`valid_from` / `valid_until`). +* Encaminar opcionalmente a través de un `ApprovalWorkflow` antes de la activación. +* Proveer un contrato `IDelegationScopeValidator` consumido por los handlers de la capa de aplicación de los comandos de `UserAccount`. +* Emitir eventos de auditoría por cada transición del ciclo de vida. + +### Raíz del agregado + +`UserManagementDelegation` es la única raíz. No posee entidades hijas; los objetos relacionados (`DelegatingAdmin`, `DelegatedAdmin`) se referencian solo por ID. + +### Diagrama + +```mermaid +classDiagram + direction TB + class UserManagementDelegation { + <> + +Guid Id + +Guid TenantId + +Guid DelegatingAdminId + +Guid DelegatedAdminId + +DelegationScopeType ScopeType + +Guid? ScopeId + +AllowedActions AllowedActions + +DateTimeOffset ValidFrom + +DateTimeOffset ValidUntil + +int? MaxDurationDays + +bool RequiresApproval + +Guid? ApprovalRequestId + +DelegationStatus Status + +DateTimeOffset? RevokedAt + +Guid? RevokedBy + +string? RevocationReason + +UserCategory? RestrictedToUserCategory + +Guid? RestrictedToOrganizationId + } +``` + +### Máquina de estados + +```mermaid +stateDiagram-v2 + [*] --> DRAFT : CreateDelegation + DRAFT --> PENDING_APPROVAL : SubmitForApproval (RequiresApproval=true) + DRAFT --> ACTIVE : Activate (RequiresApproval=false) + PENDING_APPROVAL --> ACTIVE : ApproveDelegation + PENDING_APPROVAL --> REJECTED : RejectDelegation + ACTIVE --> REVOKED : RevokeDelegation (manual) + ACTIVE --> EXPIRED : valid_until reached (Background Worker) + ACTIVE --> COMPLETED : Period ends naturally + REVOKED --> ARCHIVED : ArchiveDelegation + EXPIRED --> ARCHIVED : ArchiveDelegation + COMPLETED --> ARCHIVED : ArchiveDelegation + REJECTED --> ARCHIVED : ArchiveDelegation + ARCHIVED --> [*] + note right of ACTIVE : IDelegationScopeValidator resuelve\nsolo delegaciones ACTIVE + note right of ARCHIVED : Terminal — sin transiciones +``` + +### Invariantes y reglas de consistencia + +| ID | Regla | Fuente | +| --- | --- | --- | +| INV-DEL1 | `DelegatingAdmin` no puede otorgar acciones que no posee — sin escalamiento de privilegios | FS-14 §6.1 | +| INV-DEL2 | `DelegatingAdmin` y `DelegatedAdmin` no pueden ser el mismo usuario | FS-14 §6 | +| INV-DEL3 | `ValidUntil > ValidFrom` | FS-14 §3 | +| INV-DEL4 | `AllowedActions` debe ser un subconjunto no vacío de la autoridad real de `DelegatingAdmin` | FS-14 §6.2 | +| INV-DEL5 | La delegación circular está prohibida: B no puede delegar a A si A ya delega a B (directa) | FS-14 §5.B | +| INV-DEL6 | Una delegación `DRAFT` no es visible para el administrador delegado hasta que esté `ACTIVE` | EP-06 §2.2 | +| INV-DEL7 | Una delegación `REVOKED` o `EXPIRED` no puede reactivarse; se debe crear una nueva | EP-06 §2.2 | +| INV-DEL8 | `MaxDurationDays`, si se establece, limita `ValidUntil − ValidFrom`; no puede sobrescribirse en la creación | EP-06 §2.1 | +| INV-DEL9 | Si `RequiresApproval = true`, la delegación no puede transicionar a `ACTIVE` sin un `ApprovalRequestId` con `status = APPROVED` | EP-06 §2.1 | +| INV-DEL10 | `ScopeId` debe establecerse cuando `ScopeType` no es `TENANT` | decisión de diseño | + +### Objetos de valor + +| Objeto de valor | Tipo | Valores | +| --- | --- | --- | +| `DelegationScopeType` | Enum | `TENANT · ORGANIZATION · DEPARTMENT · SYSTEM · TEAM` | +| `AllowedActions` | Objeto de valor (lista) | `CREATE_USER · BLOCK_USER · ASSIGN_PROFILE · RESET_PASSWORD · REVOKE_MFA` | +| `DelegationStatus` | Enum | `DRAFT · PENDING_APPROVAL · ACTIVE · REVOKED · EXPIRED · COMPLETED · REJECTED · ARCHIVED` | +| `ValidFrom` | DateTimeOffset | Debe ser ≤ `ValidUntil` | +| `ValidUntil` | DateTimeOffset | Debe ser > `ValidFrom` | +| `RevocationReason` | string? | Requerido cuando `Status → REVOKED` | + +### Entidades relacionadas / referencias + +| Referencia | Tipo | Notas | +| --- | --- | --- | +| `DelegatingAdminId` | FK → `UserAccount` | El administrador que otorga la autoridad | +| `DelegatedAdminId` | FK → `UserAccount` | El administrador que recibe la autoridad | +| `ApprovalRequestId` | FK → `ApprovalRequest` | Se establece cuando `RequiresApproval = true` | +| `ScopeId` | FK → entidad tenant/org/dept/system/team | Anulable; requerido cuando `ScopeType ≠ TENANT` | + +### Eventos de dominio + +| Evento | Disparador | +| --- | --- | +| `DelegationCreatedEvent` | Nueva delegación en borrador `{ delegationId, delegatingAdminId, delegatedAdminId, scopeType, allowedActions }` | +| `DelegationSubmittedForApprovalEvent` | Enviada al `ApprovalWorkflow` `{ delegationId, approvalRequestId }` | +| `DelegationActivatedEvent` | La delegación pasa a `ACTIVE` `{ delegationId, validFrom, validUntil }` | +| `DelegationRevokedEvent` | Revocación manual `{ delegationId, revokedBy, reason }` | +| `DelegationExpiredEvent` | El worker en segundo plano expira la delegación `{ delegationId, expiredAt }` | +| `DelegationRejectedEvent` | Aprobación rechazada `{ delegationId, rejectionReason }` | +| `DelegationArchivedEvent` | Estado terminal `{ delegationId, previousStatus }` | + +### Comandos / casos de uso + +| Comando | Actor | Descripción | +| --- | --- | --- | +| `CreateDelegationCommand` | Administrador delegante | Crear en borrador una nueva delegación con alcance y acciones permitidas | +| `SubmitDelegationForApprovalCommand` | Administrador delegante | Encaminar al flujo de aprobación si `RequiresApproval = true` | +| `ActivateDelegationCommand` | Sistema / Aprobador | Activar tras la aprobación o directamente si no se requiere aprobación | +| `RevokeDelegationCommand` | Administrador delegante o administrador superior | Desactivar de inmediato con un motivo | +| `ExpireDelegationCommand` | Worker en segundo plano | Expirar las delegaciones donde `valid_until < now` | +| `CompleteDelegationCommand` | Worker en segundo plano | Finalización natural al término del período | +| `ArchiveDelegationCommand` | Worker en segundo plano | Mover a `ARCHIVED` las delegaciones en estado terminal | + +### Límites de repositorio / servicio + +* `IUserManagementDelegationRepository` — persiste y recupera delegaciones. +* `IDelegationScopeValidator` — servicio de aplicación; usado por los command handlers de `UserAccount` para verificar si el administrador que actúa tiene una delegación `ACTIVE` que cubra `(targetUserId, requestedAction, scopeId)`. +* `IDelegationAuthorityChecker` — servicio de dominio; valida INV-DEL1 (sin escalamiento) e INV-DEL5 (delegación circular). + +--- + +## 2. Modelo de objetos + +```text +UserManagementDelegation (Aggregate Root) +├── Props: UserManagementDelegationProps +│ ├── Id: IdValueObject +│ ├── TenantId: TenantId +│ ├── DelegatingAdminId: UserId +│ ├── DelegatedAdminId: UserId +│ ├── ScopeType: DelegationScopeType +│ ├── ScopeId?: ScopeId +│ ├── AllowedActions: AllowedActions -- VO lista no vacía +│ ├── ValidFrom: DateTimeOffset +│ ├── ValidUntil: DateTimeOffset +│ ├── MaxDurationDays?: int +│ ├── RequiresApproval: bool +│ ├── ApprovalRequestId?: ApprovalRequestId +│ ├── Status: DelegationStatus +│ ├── RevokedAt?: DateTimeOffset +│ ├── RevokedBy?: UserId +│ ├── RevocationReason?: string +│ ├── RestrictedToUserCategory?: UserCategory +│ ├── RestrictedToOrganizationId?: Guid +│ └── Audit: AuditValueObject +└── DomainEvents: UserManagementDelegationEventsManager +``` + +### Atributos principales + +| Atributo | Tipo | Notas | +| --- | --- | --- | +| `Id` | `Guid` | PK | +| `TenantId` | `Guid` | FK — alcance RLS | +| `DelegatingAdminId` | `Guid` | FK → `UserAccount` | +| `DelegatedAdminId` | `Guid` | FK → `UserAccount` | +| `ScopeType` | `DelegationScopeType` | Límite de la autoridad | +| `ScopeId` | `Guid?` | Entidad org/dept/system objetivo; null cuando el alcance es `TENANT` | +| `AllowedActions` | `string` (JSON) | `["CREATE_USER","ASSIGN_PROFILE",...]` | +| `ValidFrom` | `DateTimeOffset` | Inicio de la ventana de autoridad | +| `ValidUntil` | `DateTimeOffset` | Fin de la ventana de autoridad | +| `Status` | `DelegationStatus` | Estado del ciclo de vida | +| `RevocationReason` | `string?` | Requerido en la revocación | + +--- + +## 3. Diagramas de secuencia + +### Crear delegación (sin aprobación requerida) + +```mermaid +sequenceDiagram + participant A as Administrador delegante + participant H as CreateDelegationHandler + participant C as IDelegationAuthorityChecker + participant D as UserManagementDelegation (AR) + participant R as IDelegationRepository + + A->>H: CreateDelegationCommand(delegatedAdminId, scopeType, scopeId, allowedActions, validUntil) + H->>C: HasAuthority(delegatingAdminId, allowedActions, scopeType, scopeId) + C-->>H: true + H->>C: IsCircular(delegatingAdminId, delegatedAdminId) + C-->>H: false + H->>D: UserManagementDelegation.Create(...) + D->>D: guardas INV-DEL1..10 + D->>D: Status = DRAFT + D->>D: Raise DelegationCreatedEvent + H->>D: delegation.Activate() + D->>D: Status = ACTIVE + D->>D: Raise DelegationActivatedEvent + H->>R: Add(delegation) + H-->>A: delegationId +``` + +### Crear delegación (con aprobación requerida) + +```mermaid +sequenceDiagram + participant A as Administrador delegante + participant H as CreateDelegationHandler + participant D as UserManagementDelegation (AR) + participant AW as ApprovalWorkflow (BC Approvals) + participant AP as Aprobador + + A->>H: CreateDelegationCommand(..., RequiresApproval=true) + H->>D: Create(...) → Status=DRAFT + H->>D: SubmitForApproval(approvalRequestId) + D->>D: Status = PENDING_APPROVAL + D->>D: Raise DelegationSubmittedForApprovalEvent + Note over AW: ApprovalWorkflow toma el evento vía Outbox + AW->>AP: Notificar al aprobador + AP->>AW: Aprobar + AW-->>D: ApproveDelegationCommand(delegationId) + D->>D: Status = ACTIVE + D->>D: Raise DelegationActivatedEvent +``` + +### Validación del alcance en la ejecución del comando + +```mermaid +sequenceDiagram + participant B as Administrador delegado + participant H as RegisterUserHandler + participant V as IDelegationScopeValidator + participant U as UserAccount (AR) + + B->>H: RegisterUserCommand(actorId=B, tenantId, newUserData) + H->>V: Validate(actorId=B, action=CREATE_USER, targetScopeId) + V->>V: Buscar delegación ACTIVE donde DelegatedAdminId=B\n Y action en AllowedActions\n Y el alcance cubre targetScopeId + V-->>H: ScopeValidationResult.Valid(delegationId) + H->>U: UserAccount.Create(...) + U->>U: Raise UserRegisteredEvent { createdByDelegationId } + H-->>B: userId +``` + +### Expiración en segundo plano + +```mermaid +sequenceDiagram + participant BW as Background Worker (cada hora) + participant R as IDelegationRepository + participant D as UserManagementDelegation (AR) + + BW->>R: FindActive(validUntil < now) + R-->>BW: [delegation1, delegation2] + loop Cada delegación expirada + BW->>D: ExpireDelegationCommand(delegationId) + D->>D: Status = EXPIRED + D->>D: Raise DelegationExpiredEvent + end + BW->>R: SaveAll() +``` + +--- + +## 4. Modelo de entidad / relación + +> **Patrón dual self-join:** `USER_MANAGEMENT_DELEGATION` referencia a `USER_ACCOUNT` **dos veces** con roles distintos. El diagrama usa los alias `UA_GRANTOR` y `UA_GRANTEE` para que Mermaid pueda trazar ambas líneas; en BD ambos alias mapean a la misma tabla `ums_identity.useraccounts`. La misma cuenta puede aparecer como grantor en N filas y como grantee en M filas simultáneamente. La anti-circularidad (A→B activo + B→A activo) se bloquea en aplicación mediante `IDelegationAuthorityChecker` (INV-DEL5); la auto-delegación se bloquea en BD con `CHECK (DelegatingAdminId <> DelegatedAdminId)` (INV-DEL2). + +```mermaid +erDiagram + %% UA_GRANTOR y UA_GRANTEE son el mismo UserAccounts — roles distintos + UA_GRANTOR ||--o{ USER_MANAGEMENT_DELEGATION : "grants (DelegatingAdminId)" + UA_GRANTEE ||--o{ USER_MANAGEMENT_DELEGATION : "receives (DelegatedAdminId)" + USER_MANAGEMENT_DELEGATION }o--o| APPROVAL_REQUEST : "requires_approval" + + UA_GRANTOR { + uuid Id PK + varchar Email + varchar Status "ACTIVE para poder delegar" + } + + UA_GRANTEE { + uuid Id PK + varchar Email + varchar Status "ACTIVE para recibir delegación" + } + + USER_MANAGEMENT_DELEGATION { + uuid Id PK + uuid TenantId FK "RLS" + uuid DelegatingAdminId FK "→ useraccounts (grantor) · INV-DEL2 ≠ DelegatedAdminId" + uuid DelegatedAdminId FK "→ useraccounts (grantee) · INV-DEL2 ≠ DelegatingAdminId" + integer ScopeTypeId "1=TENANT 2=ORGANIZATION 3=DEPARTMENT 4=SYSTEM 5=TEAM" + uuid ScopeId "Nullable — required when ScopeTypeId ≠ 1" + text AllowedActionsJson "JSON: [CREATE_USER, BLOCK_USER, ...]" + timestamptz ValidFrom + timestamptz ValidUntil "CHECK ValidUntil > ValidFrom" + integer MaxDurationDays "Nullable" + boolean RequiresApproval + uuid ApprovalRequestId "Nullable FK" + integer StatusId "1=DRAFT 2=PENDING_APPROVAL 3=ACTIVE 4=REVOKED 5=EXPIRED 6=COMPLETED 7=REJECTED 8=ARCHIVED" + timestamptz RevokedAt "Nullable" + uuid RevokedBy "Nullable FK → useraccounts" + text RevocationReason "Nullable — required on revocation" + timestamptz CreatedAt + varchar CreatedBy + timestamptz UpdatedAt + varchar UpdatedBy + } + + APPROVAL_REQUEST { + uuid Id PK + uuid TenantId FK "RLS" + varchar Status "PENDING-APPROVED-REJECTED" + } +``` + +--- + +## 5. Modelo de bounded context + +```mermaid +flowchart TD + subgraph Identity["Identity BC"] + UMD[UserManagementDelegation AR] + UA[UserAccount AR] + end + + subgraph Approvals["Approvals BC"] + AW[ApprovalWorkflow] + AR[ApprovalRequest] + end + + subgraph Authorization["Authorization BC"] + PROF[Profile] + end + + subgraph Audit["Audit BC"] + AREC[AuditRecord] + end + + subgraph Infrastructure["Servicios de aplicación"] + DSV[IDelegationScopeValidator] + DAC[IDelegationAuthorityChecker] + BWK[Background Worker — Expiración] + end + + UMD -->|DelegationCreatedEvent| AREC + UMD -->|DelegationActivatedEvent| AREC + UMD -->|DelegationRevokedEvent| AREC + UMD -->|DelegationSubmittedForApprovalEvent| AW + AR -->|ApproveDelegationCommand| UMD + DSV -->|verifica delegación ACTIVE| UMD + DSV -->|controla RegisterUser / BlockUser / AssignProfile| UA + DAC -->|valida autoridad + chequeo circular| UMD + BWK -->|ExpireDelegationCommand| UMD + UA -->|UserRegisteredEvent con delegationId| AREC + PROF -->|ProfileAssignedEvent| AREC +``` + +--- + +## 6. Contrato de la capa de API / aplicación + +### Comandos + +| Comando | Entrada | Salida | Notas | +| --- | --- | --- | --- | +| `CreateDelegationCommand` | `delegatingAdminId, delegatedAdminId, scopeType, scopeId?, allowedActions[], validFrom, validUntil, requiresApproval, restrictedToUserCategory?` | `Guid delegationId` | INV-DEL1..10 validadas | +| `SubmitDelegationForApprovalCommand` | `delegationId, approvalRequestId` | `void` | Solo desde `DRAFT` | +| `ActivateDelegationCommand` | `delegationId` | `void` | Desde `DRAFT` (sin aprobación) o `PENDING_APPROVAL` (aprobada) | +| `RevokeDelegationCommand` | `delegationId, revokedBy, reason` | `void` | Solo desde `ACTIVE` | +| `ExpireDelegationCommand` | `delegationId` | `void` | Solo Background Worker | +| `CompleteDelegationCommand` | `delegationId` | `void` | Solo Background Worker | +| `ArchiveDelegationCommand` | `delegationId` | `void` | Solo estados terminales | + +### Consultas + +| Consulta | Devuelve | +| --- | --- | +| `GetDelegationByIdQuery` | `DelegationDetailDto` | +| `ListDelegationsGrantedByQuery` | `PagedList` — delegaciones que este administrador ha otorgado | +| `ListDelegationsReceivedByQuery` | `PagedList` — delegaciones que este administrador posee | +| `GetActiveDelegationForActionQuery` | `ActiveDelegationDto?` — usada por `IDelegationScopeValidator` | +| `ListActiveDelegationsByDelegatedAdminQuery` | `List` — toda la autoridad activa de un administrador | + +### Contrato del servicio de aplicación + +```csharp +public interface IDelegationScopeValidator +{ + /// + /// Returns a successful result if has an ACTIVE + /// UserManagementDelegation that covers + /// within the scope containing . + /// + Task> ValidateAsync( + Guid actorId, + DelegatedAction action, + Guid tenantId, + Guid? targetScopeId, + CancellationToken ct); +} +``` + +--- + +## 7. Notas de persistencia + +### Tabla + +```sql +CREATE TABLE delegation.user_management_delegations ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + root_tenant_id uuid NOT NULL, + delegating_admin_id uuid NOT NULL, + delegated_admin_id uuid NOT NULL, + scope_type varchar(32) NOT NULL, -- TENANT | ORGANIZATION | DEPARTMENT | SYSTEM | TEAM + scope_id uuid NULL, + allowed_actions text NOT NULL, -- JSON: ["CREATE_USER","ASSIGN_PROFILE",...] + valid_from timestamptz NOT NULL, + valid_until timestamptz NOT NULL, + max_duration_days integer NULL, + requires_approval boolean NOT NULL DEFAULT false, + approval_request_id uuid NULL, + status varchar(32) NOT NULL DEFAULT 'DRAFT', + revoked_at timestamptz NULL, + revoked_by uuid NULL, + revocation_reason text NULL, + restricted_to_user_category varchar(32) NULL, + restricted_to_org_id uuid NULL, + created_by uuid NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_by uuid NULL, + updated_at timestamptz NULL, + + CONSTRAINT pk_user_management_delegations + PRIMARY KEY (id, root_tenant_id), + CONSTRAINT fk_delegation_delegating_admin + FOREIGN KEY (delegating_admin_id, root_tenant_id) + REFERENCES identity.users(id, root_tenant_id), + CONSTRAINT fk_delegation_delegated_admin + FOREIGN KEY (delegated_admin_id, root_tenant_id) + REFERENCES identity.users(id, root_tenant_id), + CONSTRAINT fk_delegation_approval + FOREIGN KEY (approval_request_id, root_tenant_id) + REFERENCES approval.approval_requests(id, root_tenant_id), + CONSTRAINT chk_valid_until_after_valid_from + CHECK (valid_until > valid_from), + CONSTRAINT chk_no_self_delegation + CHECK (delegating_admin_id <> delegated_admin_id) +); +``` + +### Índices + +| Índice | Columnas | Propósito | +| --- | --- | --- | +| `IX_Delegation_DelegatedAdmin_Active` | `delegated_admin_id, root_tenant_id` WHERE `status='ACTIVE'` | Ruta caliente de `IDelegationScopeValidator` | +| `IX_Delegation_DelegatingAdmin` | `delegating_admin_id, root_tenant_id` | Listar delegaciones otorgadas | +| `IX_Delegation_Scope` | `scope_type, scope_id, root_tenant_id` | Búsquedas basadas en alcance | +| `IX_Delegation_ValidUntil_Active` | `valid_until, root_tenant_id` WHERE `status='ACTIVE'` | Worker de expiración en segundo plano | +| `IX_Delegation_Status_Tenant` | `status, root_tenant_id` | Tableros operativos | + +### Límite transaccional + +`UserManagementDelegation` se guarda en una única llamada `SaveChanges()`. El `DelegationSubmittedForApprovalEvent` se despacha vía Outbox transaccional al BC de Approvals — nunca mediante llamada directa. + +### RLS + +`root_tenant_id` lo establece el contexto de persistencia con alcance de la solicitud. Las políticas de seguridad a nivel de fila (row-level security) de PostgreSQL filtran por `root_tenant_id` como salvaguarda secundaria (RLS de dos capas — ADR-UMS-089). + +--- + +## 8. Seguridad y auditoría + +### Reglas de autorización + +| Operación | Rol requerido | Notas | +| --- | --- | --- | +| Crear delegación | `Tenant:Admin` | Sujeto a INV-DEL1 (sin escalamiento) | +| Enviar a aprobación | `Tenant:Admin` (solo el administrador delegante) | Solo el creador puede enviar | +| Revocar delegación | `Tenant:Admin` (administrador delegante) o `Platform:SuperAdmin` | El administrador superior también puede revocar | +| Ver delegaciones recibidas | `Tenant:Admin` (administrador delegado) | Solo delegaciones propias | +| Ver delegaciones otorgadas | `Tenant:Admin` (administrador delegante) | Solo delegaciones propias | +| Expirar / Archivar | Identidad de sistema del Background Worker | Nunca de cara al usuario | + +### Eventos de auditoría + +* `DELEGATION_CREATED` — `{ delegationId, delegatingAdminId, delegatedAdminId, scopeType, allowedActions }` +* `DELEGATION_SUBMITTED_FOR_APPROVAL` — `{ delegationId, approvalRequestId }` +* `DELEGATION_ACTIVATED` — `{ delegationId, activatedAt, validUntil }` +* `DELEGATION_REVOKED` — `{ delegationId, revokedBy, reason }` +* `DELEGATION_EXPIRED` — `{ delegationId, expiredAt }` +* `DELEGATION_REJECTED` — `{ delegationId, rejectedBy, reason }` +* `DELEGATION_ARCHIVED` — `{ delegationId, previousStatus }` +* `DELEGATION_SCOPE_VALIDATED` — emitido por `IDelegationScopeValidator` para cada comando controlado por delegación `{ delegationId, actorId, action, targetScopeId, result }` + +### Invariantes de seguridad + +* `PasswordHash` y los secretos de MFA de `UserAccount` **nunca** son legibles vía delegación — la delegación solo otorga comandos de gestión, no acceso a credenciales. +* El registro de auditoría `DELEGATION_SCOPE_VALIDATED` se escribe incluso ante una validación fallida, lo que permite detectar intentos de abuso de privilegios. +* La regla de `no-elevación` (INV-DEL1) la hace cumplir `IDelegationAuthorityChecker`, que consulta los permisos del `Profile` del administrador delegante mediante el read model del BC de Authorization antes de crear la delegación. + +--- + +**[← UserAccount](./user-account.md)** | **[Índice del dominio Identity](./index.md)** | **[Índice de agregados de dominio](../index.md)** | **[FS-14](../../historias-funcionales/fs-14-gestion-delegada.md)** diff --git a/docs/domain-es/iga/index.md b/docs/domain-es/iga/index.md index b0d05da0..77f7b3c1 100644 --- a/docs/domain-es/iga/index.md +++ b/docs/domain-es/iga/index.md @@ -1,17 +1,17 @@ # Contexto IGA (Identity Governance & Administration) — Arquitectura de Agregados -> **Idioma:** [English](../../domain/iga/index.md) | [Español](./index.md) - **Contexto Acotado:** Identity Governance & Administration (`Ums.Domain.IGA`) **Raíces de Agregado:** `PromotionRequest`, `RoleMaturityStatus` --- -### Ciclo de Vida de Ascenso de Accesos e Identidad +## Ciclo de Vida de Ascenso de Accesos e Identidad + Gobierna el ascenso seguro de un rol a otro, la nivelación de madurez y los análisis automáticos de riesgo de accesos tóxicos: -- [PromotionRequest](./promotion-request.md) (Raíz de Agregado) — Maneja la creación en borrador, aprobaciones de gerentes, evaluaciones de riesgo, controles de seguridad y ejecuciones verificadas de roles. -- [PromotionImpactAnalysis](./promotion-impact-analysis.md) (Entidad Propia) — Registra puntuaciones dinámicas de riesgo de permisos tóxicos y sistemas afectados. -- [RoleMaturityStatus](./role-maturity-status.md) (Raíz de Agregado) — Administra umbrales de rendimiento, revisiones de cumplimiento, contadores de certificación y criterios de elegibilidad para ascensos según el nivel de madurez del rol (Junior $\rightarrow$ Principal). + +* [PromotionRequest](./promotion-request.md) (Raíz de Agregado) — Maneja la creación en borrador, aprobaciones de gerentes, evaluaciones de riesgo, controles de seguridad y ejecuciones verificadas de roles. +* [PromotionImpactAnalysis](./promotion-impact-analysis.md) (Entidad Propia) — Registra puntuaciones dinámicas de riesgo de permisos tóxicos y sistemas afectados. +* [RoleMaturityStatus](./role-maturity-status.md) (Raíz de Agregado) — Administra umbrales de rendimiento, revisiones de cumplimiento, contadores de certificación y criterios de elegibilidad para ascensos según el nivel de madurez del rol (Junior $\rightarrow$ Principal). --- diff --git a/docs/domain-es/iga/promotion-impact-analysis.md b/docs/domain-es/iga/promotion-impact-analysis.md index f5fdb64a..285e3298 100644 --- a/docs/domain-es/iga/promotion-impact-analysis.md +++ b/docs/domain-es/iga/promotion-impact-analysis.md @@ -1,7 +1,5 @@ # Análisis de Impacto de Promoción -> **Idioma:** [English](../../domain/iga/promotion-impact-analysis.md) | **Español** - Este es un documento estable de referencia para `PromotionImpactAnalysis` dentro del índice del Contexto IGA. Las reglas detalladas permanecen documentadas dentro del documento del agregado raíz hasta que se requiera una página independiente más completa. **[Volver al Índice IGA](./index.md)** diff --git a/docs/domain-es/iga/promotion-request.md b/docs/domain-es/iga/promotion-request.md index 045461a6..7dec2ad7 100644 --- a/docs/domain-es/iga/promotion-request.md +++ b/docs/domain-es/iga/promotion-request.md @@ -2,48 +2,61 @@ **Contexto Acotado:** IGA **Raíz del Agregado:** Sí -**Módulo:** `Ums.Domain.IGA.PromotionRequest` -**Estado:** Producción +**Módulo:** `Ums.Domain.IGA.RolePromotionRequest` +**Estado:** En desarrollo + +> **Estado de implementación (ADR-UMS-093, G-052).** La máquina de estados de promoción de rol está +> implementada end-to-end: dominio (`RolePromotionRequest`), aplicación (comandos de cada transición +> — Create, Submit, ConfirmEligibility, Manager/Security Approve/Reject, Execute, Verify, Cancel — y +> queries `GetById`/`List`), infraestructura (persistencia EF, tabla `iga.RolePromotionRequests`) y +> presentación (endpoints REST bajo `/api/v1/role-promotion-requests`, todos autenticados +> (`RequireAuthorization`) y acotados por inquilino). El happy-path E2E completo depende del _seed_ de +> `RoleMaturityStatus` para la confirmación de elegibilidad (_fail-closed_), aún pendiente. --- ## 1. Vista General del Agregado ### Propósito + El agregado `PromotionRequest` coordina los ascensos de acceso, lo que permite a los usuarios solicitar de manera segura transiciones desde su rol actual hacia un rol de destino más privilegiado. Impone una ruta estricta y auditada de verificación que incluye puntajes de riesgo automáticos, aprobación de gerentes, evaluaciones de seguridad, ejecución de roles y verificación posterior a la ejecución. ### Responsabilidad de Negocio -- Registrar la intención de un usuario de adquirir un rol de destino más senior o privilegiado. -- Controlar el flujo de trabajo de aprobación de múltiples pasos. -- Incrustar los resultados del análisis de impacto de permisos tóxicos (`PromotionImpactAnalysis`). -- Cuantificar los riesgos del ascenso de accesos en una puntuación unificada (0 a 100), identificando conflictos de permisos, combinaciones tóxicas y sistemas afectados. -- Coordinar los estados de ejecución y confirmación posterior al cambio de rol. -- Proporcionar a los auditores de seguridad directrices recomendadas de mitigación. + +* Registrar la intención de un usuario de adquirir un rol de destino más senior o privilegiado. +* Controlar el flujo de trabajo de aprobación de múltiples pasos. +* Incrustar los resultados del análisis de impacto de permisos tóxicos (`PromotionImpactAnalysis`). +* Cuantificar los riesgos del ascenso de accesos en una puntuación unificada (0 a 100), identificando conflictos de permisos, combinaciones tóxicas y sistemas afectados. +* Coordinar los estados de ejecución y confirmación posterior al cambio de rol. +* Proporcionar a los auditores de seguridad directrices recomendadas de mitigación. ### Raíz del Agregado + `PromotionRequest` sirve como la raíz del agregado, gestionando el ciclo de vida del proceso de ascenso y albergando a `PromotionImpactAnalysis` como una entidad de propiedad exclusiva. ### Invariantes y Reglas de Consistencia + 1. **INV-PR1 (Transiciones de Estado del Flujo de Trabajo):** Las transiciones de estado están estrictamente gobernadas por las siguientes reglas de FSM: - - La creación coloca la solicitud en `Draft`. - - `Draft` $\rightarrow$ `PendingManagerApproval` (a través de `Submit`). - - `PendingManagerApproval` $\rightarrow$ `PendingSecurityReview` (a través de `ManagerApprove`) O `Rejected` (a través de `ManagerReject`). - - `PendingSecurityReview` $\rightarrow$ `ApprovedReadyToExecute` (a través de `SecurityReviewLowRisk` si la puntuación de riesgo analizada es baja) O `PendingSecurityApproval` (a través de `SecurityReviewHighRisk` si la puntuación de riesgo es alta) O `Rejected` (a través de `SecurityReject`). - - `PendingSecurityApproval` $\rightarrow$ `ApprovedReadyToExecute` (a través de `SecurityApprove`) O `Rejected` (a través de `SecurityReject`). - - `ApprovedReadyToExecute` $\rightarrow$ `Executed` (a través de `Execute`). - - `Executed` $\rightarrow$ `Verified` (a través de `Verify`) O `VerificationFailed` (a través de `MarkVerificationFailed`). + * La creación coloca la solicitud en `Draft`. + * `Draft` $\rightarrow$ `PendingManagerApproval` (a través de `Submit`). + * `PendingManagerApproval` $\rightarrow$ `PendingSecurityReview` (a través de `ManagerApprove`) O `Rejected` (a través de `ManagerReject`). + * `PendingSecurityReview` $\rightarrow$ `ApprovedReadyToExecute` (a través de `SecurityReviewLowRisk` si la puntuación de riesgo analizada es baja) O `PendingSecurityApproval` (a través de `SecurityReviewHighRisk` si la puntuación de riesgo es alta) O `Rejected` (a través de `SecurityReject`). + * `PendingSecurityApproval` $\rightarrow$ `ApprovedReadyToExecute` (a través de `SecurityApprove`) O `Rejected` (a través de `SecurityReject`). + * `ApprovedReadyToExecute` $\rightarrow$ `Executed` (a través de `Execute`). + * `Executed` $\rightarrow$ `Verified` (a través de `Verify`) O `VerificationFailed` (a través de `MarkVerificationFailed`). 2. **INV-PR2 (Unicidad del Análisis de Impacto):** Solo se puede registrar un análisis de impacto por cada solicitud de ascenso para evitar la reescritura de historiales (`DomainErrors.IGA.ImpactAnalysisAlreadyExists`). 3. **INV-PIA1 (Límites de la Puntuación de Riesgo):** El valor de `RiskScore` en el análisis de impacto debe ser un decimal estrictamente entre `0` y `100` inclusive (`DomainErrors.IGA.InvalidPerformanceScore`). 4. **INV-PIA2 (Inmutabilidad de los Análisis):** Una vez calculado y guardado, un análisis de impacto no puede ser editado. Si los alcances de acceso cambian, debe iniciarse un nuevo ciclo completo de ascenso. ### Entidades Relacionadas / Objetos de Valor + | Entidad / VO | Tipo | Descripción | -|---|---|---| +| --- | --- | --- | | `PromotionRequestId` | Objeto de Valor | Identificador único del agregado | | `TenantId` | Objeto de Valor | Identificador de partición asignado al contexto del inquilino | | `UserId` | Objeto de Valor | Referencia al usuario objetivo (Contexto de Identity) | | `RoleId` | Objeto de Valor | Referencia a los roles actual y objetivo (Contexto de Autorización) | -| `PromotionStatus` | Enumerado | Enumerado del estado de la FSM (`Draft`, `PendingManagerApproval`, etc.) | +| `RolePromotionStatus` | Enumerado | Enumerado del estado de la FSM (`Draft`, `PendingManagerApproval`, etc.) | | `ApprovalDecision` | Enumerado | `None` · `Approved` · `Rejected` | | `PromotionImpactAnalysis` | Entidad | Entidad hija de propiedad exclusiva que contiene métricas de riesgo | | `PromotionImpactAnalysisId` | Objeto de Valor | Identificador único de la entidad hija de análisis de impacto | @@ -54,6 +67,7 @@ El agregado `PromotionRequest` coordina los ascensos de acceso, lo que permite a ## 2. Modelo de Dominio ### Clases / Entidades / Objetos de Valor + ```text PromotionRequest (Aggregate Root) ├── Props: PromotionRequestProps @@ -70,7 +84,7 @@ PromotionRequest (Aggregate Root) │ ├── ManagerDecisionAt: DateTime? │ ├── SecurityApprovalStatus: ApprovalDecision │ ├── SecurityDecisionAt: DateTime? -│ ├── Status: PromotionStatus +│ ├── Status: RolePromotionStatus │ ├── ExecutedAt: DateTime? │ ├── ExecutedBy: ActorId? │ ├── VerifiedAt: DateTime? @@ -126,7 +140,7 @@ classDiagram +UserId ManagerId +ApprovalDecision ManagerApprovalStatus +ApprovalDecision SecurityApprovalStatus - +PromotionStatus Status + +RolePromotionStatus Status +AuditValueObject Audit } class PromotionImpactAnalysis { @@ -144,7 +158,7 @@ classDiagram +TextValueObject AnalyzedBy +Create() Result~PromotionImpactAnalysis~ } - class PromotionStatus { + class RolePromotionStatus { <> Draft PendingManagerApproval @@ -159,7 +173,7 @@ classDiagram PromotionRequest *-- PromotionRequestProps PromotionRequest "1" *-- "0..1" PromotionImpactAnalysis : posee - PromotionRequestProps --> PromotionStatus + PromotionRequestProps --> RolePromotionStatus ``` --- @@ -168,7 +182,7 @@ classDiagram ### Proceso de Ascenso de Alto Riesgo -*Nota: Las secuencias de creación y validación para el análisis de impacto se coordinan exclusivamente a través del agregado raíz.* +_Nota: Las secuencias de creación y validación para el análisis de impacto se coordinan exclusivamente a través del agregado raíz._ ```mermaid sequenceDiagram @@ -178,7 +192,7 @@ sequenceDiagram participant App as Servicio de Aplicación participant PR as PromotionRequest [Agregado] participant Repo as PromotionRequestRepository - participant DB as SQL Server + participant DB as PostgreSQL Note over Mgr, PR: La solicitud está en estado PendingManagerApproval Mgr->>App: ApprovePromotionRequest(RequestId) @@ -255,8 +269,9 @@ erDiagram ``` ### Reglas de Aislamiento de Inquilinos (Tenancy) -- Particionado por `TenantId`. Los envíos se verifican contra las propiedades de configuración del inquilino para evitar la falsificación de solicitudes entre inquilinos. -- La entidad `PromotionImpactAnalysis` hereda las reglas de delimitación de su agregado raíz padre `PromotionRequest`. El acceso entre inquilinos está implícitamente bloqueado. + +* Particionado por `TenantId`. Los envíos se verifican contra las propiedades de configuración del inquilino para evitar la falsificación de solicitudes entre inquilinos. +* La entidad `PromotionImpactAnalysis` hereda las reglas de delimitación de su agregado raíz padre `PromotionRequest`. El acceso entre inquilinos está implícitamente bloqueado. --- @@ -291,19 +306,21 @@ flowchart TD ## 7. Capa de Aplicación ### Comandos y Consultas -- **CreatePromotionRequestCommand:** Crea una solicitud en estado `Draft`. -- **SubmitPromotionRequestCommand:** Envía una solicitud a la revisión de la gerencia. -- **ManagerApprovePromotionRequestCommand:** Registra la verificación de un gerente. -- **SecurityReviewPromotionRequestCommand:** Registra el análisis de rendimiento dinámico y activa el ramificado de riesgo. -- **AddImpactAnalysisCommand:** Coordinado por los manejadores de aplicación de `PromotionRequest` para adjuntar los datos del análisis de impacto. -- **ExecutePromotionRequestCommand:** Ejecuta el cambio de rol en los sistemas de destino. -- **VerifyPromotionRequestCommand:** Firma de cumplimiento final que valida la propagación exitosa del ascenso. + +* **CreatePromotionRequestCommand:** Crea una solicitud en estado `Draft`. +* **SubmitPromotionRequestCommand:** Envía una solicitud a la revisión de la gerencia. +* **ManagerApprovePromotionRequestCommand:** Registra la verificación de un gerente. +* **SecurityReviewPromotionRequestCommand:** Registra el análisis de rendimiento dinámico y activa el ramificado de riesgo. +* **AddImpactAnalysisCommand:** Coordinado por los manejadores de aplicación de `PromotionRequest` para adjuntar los datos del análisis de impacto. +* **ExecutePromotionRequestCommand:** Ejecuta el cambio de rol en los sistemas de destino. +* **VerifyPromotionRequestCommand:** Firma de cumplimiento final que valida la propagación exitosa del ascenso. --- ## 8. Infraestructura/Persistencia ### Configuración del Mapeo de EF Core + ```csharp public class PromotionRequestConfiguration : IEntityTypeConfiguration { @@ -347,15 +364,15 @@ public class PromotionRequestConfiguration : IEntityTypeConfiguration **Estado de implementación (ADR-UMS-093, G-052).** El agregado está implementado end-to-end: +> dominio, aplicación (queries `GetRoleMaturityStatusByUser`), infraestructura (persistencia EF, +> tabla `iga.RoleMaturityStatuses`) y presentación (endpoint REST +> `GET /api/v1/role-maturity-status/users/{userId}`, autenticado y acotado por inquilino). +> Queda pendiente el _seed_ de estados de madurez para poder ejercitar el happy-path E2E completo +> de promoción (la confirmación de elegibilidad es _fail-closed_ sin un `RoleMaturityStatus` sembrado). --- ## 1. Vista General del Agregado ### Propósito + El agregado raíz `RoleMaturityStatus` rastrea y evalúa el nivel de madurez operativa de un usuario dentro de un rol de seguridad asignado. Gobierna la elegibilidad para ascensos corporativos coordinando la finalización de certificaciones, el seguimiento de capacitaciones, las evaluaciones de desempeño y los controles activos de cumplimiento de seguridad. ### Responsabilidad de Negocio -- Registrar el nivel de madurez actual y el siguiente objetivo del usuario (por ejemplo, Junior $\rightarrow$ Principal). -- Rastrear indicadores de habilitación profesional (capacitaciones y certificaciones completadas). -- Evaluar las reglas de elegibilidad según el tiempo en el nivel, las puntuaciones de desempeño y los bloqueos de cumplimiento. -- Proporcionar bloqueos automáticos que impidan a los usuarios con problemas de cumplimiento activos solicitar ascensos de acceso. + +* Registrar el nivel de madurez actual y el siguiente objetivo del usuario (por ejemplo, Junior $\rightarrow$ Principal). +* Rastrear indicadores de habilitación profesional (capacitaciones y certificaciones completadas). +* Evaluar las reglas de elegibilidad según el tiempo en el nivel, las puntuaciones de desempeño y los bloqueos de cumplimiento. +* Proporcionar bloqueos automáticos que impidan a los usuarios con problemas de cumplimiento activos solicitar ascensos de acceso. ### Raíz del Agregado + `RoleMaturityStatus` es una raíz de agregado soberana que orquesta las métricas de cumplimiento y realiza el seguimiento de la elegibilidad de los usuarios. ### Invariantes y Reglas de Consistencia + 1. **INV-RMS1 (Límites de la Puntuación de Rendimiento):** La puntuación de rendimiento debe ser un valor decimal estrictamente entre `0` y `5` inclusive (`DomainErrors.IGA.InvalidPerformanceScore`). 2. **INV-RMS2 (Conflicto de Transición de Madurez):** Una actualización de nivel de madurez debe apuntar a un nivel diferente al nivel actual (`DomainErrors.IGA.MaturityLevelUnchanged`). 3. **INV-RMS3 (Estándares de Elegibilidad):** Para ser elegible para un ascenso de madurez de rol, el usuario debe cumplir con: - - Cero problemas de cumplimiento activos (`HasNoComplianceIssues == true`). - - Una puntuación mínima de rendimiento de `3.0`. - - La duración mínima requerida desde que ingresó al nivel actual: - - **Junior $\rightarrow$ Intermediate:** 6 meses. - - **Intermediate $\rightarrow$ Senior:** 12 meses. - - **Senior $\rightarrow$ Lead:** 18 meses. - - **Lead $\rightarrow$ Principal:** 24 meses. - - **Principal:** No elegible para ascensos adicionales. + * Cero problemas de cumplimiento activos (`HasNoComplianceIssues == true`). + * Una puntuación mínima de rendimiento de `3.0`. + * La duración mínima requerida desde que ingresó al nivel actual: + * **Junior $\rightarrow$ Intermediate:** 6 meses. + * **Intermediate $\rightarrow$ Senior:** 12 meses. + * **Senior $\rightarrow$ Lead:** 18 meses. + * **Lead $\rightarrow$ Principal:** 24 meses. + * **Principal:** No elegible para ascensos adicionales. ### Entidades Relacionadas / Objetos de Valor + | Entidad / VO | Tipo | Descripción | -|---|---|---| +| --- | --- | --- | | `RoleMaturityStatusId` | Objeto de Valor | Identificador único del agregado | | `TenantId` | Objeto de Valor | Asignación del contexto de inquilino propietario | | `UserId` | Objeto de Valor | Cuenta de usuario propietaria (Contexto de Identity) | @@ -49,7 +61,8 @@ El agregado raíz `RoleMaturityStatus` rastrea y evalúa el nivel de madurez ope ## 2. Modelo de Dominio ### Clases / Entidades / Objetos de Valor -``` + +```text RoleMaturityStatus (Aggregate Root) └── Props: RoleMaturityStatusProps ├── Id: RoleMaturityStatusId @@ -132,7 +145,7 @@ sequenceDiagram participant App as Servicio de Aplicación participant RMS as RoleMaturityStatus [Agregado] participant Repo as RoleMaturityStatusRepository - participant DB as SQL Server + participant DB as PostgreSQL Auditor->>App: ReviewPromotionEligibility(MaturityStatusId) App->>Repo: GetByIdAsync(MaturityStatusId) @@ -176,7 +189,8 @@ erDiagram ``` ### Reglas de Aislamiento de Inquilinos (Tenancy) -- Delimitado estrictamente por `TenantId`. Los mecanismos de seguridad multi-inquilino particionan las trayectorias corporativas de evaluación de rendimiento para evitar fugas entre organizaciones. + +* Delimitado estrictamente por `TenantId`. Los mecanismos de seguridad multi-inquilino particionan las trayectorias corporativas de evaluación de rendimiento para evitar fugas entre organizaciones. --- @@ -205,18 +219,20 @@ flowchart TD ## 7. Capa de Aplicación ### Comandos y Consultas -- **CreateRoleMaturityStatusCommand:** Registra una nueva asignación de rol de usuario con marcadores de madurez. -- **UpdateRoleMaturityLevelCommand:** Eleva el nivel del rol después de una verificación exitosa de ascenso. -- **UpdatePerformanceScoreCommand:** Registra las revisiones periódicas de evaluación de rendimiento. -- **MarkComplianceIssueCommand:** Bloquea las capacidades de ascenso debido a violaciones de cumplimiento. -- **ResolveComplianceIssueCommand:** Desbloquea la elegibilidad de ascenso. -- **ReviewEligibilityCommand:** Ejecuta las aserciones de reglas estándar para activar los temporizadores de ascenso. + +* **CreateRoleMaturityStatusCommand:** Registra una nueva asignación de rol de usuario con marcadores de madurez. +* **UpdateRoleMaturityLevelCommand:** Eleva el nivel del rol después de una verificación exitosa de ascenso. +* **UpdatePerformanceScoreCommand:** Registra las revisiones periódicas de evaluación de rendimiento. +* **MarkComplianceIssueCommand:** Bloquea las capacidades de ascenso debido a violaciones de cumplimiento. +* **ResolveComplianceIssueCommand:** Desbloquea la elegibilidad de ascenso. +* **ReviewEligibilityCommand:** Ejecuta las aserciones de reglas estándar para activar los temporizadores de ascenso. --- ## 8. Infraestructura/Persistencia ### Configuración del Mapeo de EF Core + ```csharp public class RoleMaturityStatusConfiguration : IEntityTypeConfiguration { @@ -252,14 +268,14 @@ public class RoleMaturityStatusConfiguration : IEntityTypeConfiguration **Idioma:** [English](../domain/index.md) | [Español](./index.md) - -Documentos de arquitectura detallados para cada Aggregate Root en el modelo de dominio UMS, organizados por Bounded Context. Las entidades hijas (Branch, Branding, IdentityProvider, PasswordCredential, MfaEnrollment, ProfilePermission, módulos/menús/opciones/acciones funcionales, bitácoras de evaluación de banderas, análisis de impacto de promoción, etc.) se documentan dentro de su respectivo documento de Agregado Raíz (Aggregate Root) — no como documentos independientes. +Documentos de arquitectura detallados para cada Aggregate Root en el modelo de dominio UMS, organizados por Bounded Context. Las entidades hijas (Branch, IdentityProvider, PasswordCredential, MfaEnrollment, ProfilePermission, módulos/menús/opciones/acciones funcionales, bitácoras de evaluación de banderas, análisis de impacto de promoción, etc.) se documentan dentro de su respectivo documento de Agregado Raíz (Aggregate Root) — no como documentos independientes. --- ## Identity BC — `Ums.Domain.Identity` | Agregado Raíz | Documento | Entidades Hijas Propias (documentadas inline) | -|---|---|---| -| `Tenant` | [tenant.md](./identity/tenant.md) | `Branch`, `Branding`, `IdentityProvider` | +| --- | --- | --- | +| `Tenant` | [tenant.md](./identity/tenant.md) | `Branch`, `IdentityProvider` | | `UserAccount` | [user-account.md](./identity/user-account.md) | `PasswordCredential`, `MfaEnrollment` | | `UserManagementDelegation` | [user-management-delegation.md](./identity/user-management-delegation.md) | Ninguna | @@ -19,8 +17,8 @@ Documentos de arquitectura detallados para cada Aggregate Root en el modelo de d ## Authorization BC — `Ums.Domain.Authorization` | Agregado Raíz | Documento | Entidades Hijas Propias (documentadas inline) | -|---|---|---| -| `SystemSuite` | [system-suite.md](./authorization/system-suite.md) | `Module`, `Menu`, `SubMenu`, `Option`, `Action` | +| --- | --- | --- | +| `SystemSuite` | [system-suite.md](./authorization/system-suite.md) | `Module`, `MenuNode` (árbol recursivo, ADR-0090), `Action`, `DomainResource`, `AppSetting` | | `Role` | [role.md](./authorization/role.md) | Ninguna | | `PermissionTemplate` | [permission-template.md](./authorization/permission-template.md) | `PermissionTemplateItem` | | `Profile` | [profile.md](./authorization/profile.md) | `ProfilePermission` | @@ -30,7 +28,7 @@ Documentos de arquitectura detallados para cada Aggregate Root en el modelo de d ## Configuration BC — `Ums.Domain.Configuration` | Agregado Raíz | Documento | Entidades Hijas Propias (documentadas inline) | -|---|---|---| +| --- | --- | --- | | `IdpConfiguration` | [idp-configuration.md](./configuration/idp-configuration.md) | Ninguna | | `AppConfiguration` | [app-configuration.md](./configuration/app-configuration.md) | Ninguna | | `FeatureFlag` | [feature-flag.md](./configuration/feature-flag.md) | `FlagEvaluationLog` | @@ -43,7 +41,7 @@ Documentos de arquitectura detallados para cada Aggregate Root en el modelo de d ## Approvals BC — `Ums.Domain.Approvals` | Agregado Raíz | Documento | Entidades Hijas Propias (documentadas inline) | -|---|---|---| +| --- | --- | --- | | `ApprovalWorkflow` | [approval-workflow.md](./approvals/approval-workflow.md) | `ApprovalRequiredDocument` | | `ApprovalRequest` | [approval-request.md](./approvals/approval-request.md) | `ApprovalLog` (inline) | | `DocumentType` | [document-type.md](./approvals/document-type.md) | `EnforcementPolicy` | @@ -56,7 +54,7 @@ Documentos de arquitectura detallados para cada Aggregate Root en el modelo de d ## IGA BC — `Ums.Domain.IGA` | Agregado Raíz | Documento | Entidades Hijas Propias (documentadas inline) | -|---|---|---| +| --- | --- | --- | | `PromotionRequest` | [promotion-request.md](./iga/promotion-request.md) | `PromotionImpactAnalysis` | | `RoleMaturityStatus` | [role-maturity-status.md](./iga/role-maturity-status.md) | Ninguna | @@ -78,4 +76,6 @@ Reglas de consistencia por Bounded Context, registro de broken rules y riesgos d --- -**[Volver al Índice Maestro](../MASTER_INDEX.es.md)** | **[Portal DDD](../governance/construction/ddd-design/index.md)** +**[Volver al Índice Maestro](../../../reference/indices/indice-maestro.md)** | **[Portal DDD](../../../reference/gobernanza/construction/ddd-design/index.md)** + +- [Sala de Espera de Onboarding](./onboarding-lobby.md) — Ficha transversal del lobby previo a la asignación de perfil. diff --git a/docs/domain-es/onboarding-lobby.md b/docs/domain-es/onboarding-lobby.md new file mode 100644 index 00000000..99ac79a0 --- /dev/null +++ b/docs/domain-es/onboarding-lobby.md @@ -0,0 +1,63 @@ +# Grafo lobby (onboarding pendiente) — G-043 + +## El caso + +Un usuario puede quedar **autenticado y aprobado pero sin perfil activo**: el flujo de alta +(`signup` → `approve`/`activate`) crea la cuenta, le fija contraseña, la activa y la aprueba, pero +**no le asigna un perfil**. Un _perfil_ es lo que vincula al usuario con un rol, una suite de sistema +y una plantilla de permisos; sin él, el sistema no puede resolver qué puede ver ni hacer. + +Antes de G-043, ese usuario **no podía iniciar sesión**: al construir su Grafo de Autorización, +`AuthorizationGraphBuilderService` devolvía `Result.Failure("No active profile found…")`, un error que +`AuthEndpoints.MapAuthError` no reconocía y mapeaba al cajón genérico **`401 AUTH_000` («No pudimos +iniciar sesión. Intente nuevamente.»)** — opaco y sin pista de la causa. La auditoría BMAD Tester lo +reprodujo en 3 cuentas independientes. + +## Cómo se maneja ahora + +En lugar de fallar, cuando no hay perfil activo el servicio devuelve un **grafo lobby**: un +`AuthorizationGraph` válido que representa "usuario dentro, pero sin app todavía". + +| Campo del grafo | En el lobby | +| --- | --- | +| `onboardingPending` | **`true`** (bandera nueva; por defecto `false`) | +| `context.user`, `context.tenant` | **reales** (el usuario y su inquilino) | +| `context.systemSuite`, `context.role`, `context.profile` | **`null`** | +| `actions`, `menuAccess`, `domainPermissions`, `scopes` | **vacíos** | +| `authentication`, `effectiveConfig` | poblados normalmente | + +El login **responde 200** con este grafo (no 401). El token Bearer de grafo se emite igual, **omitiendo** +los claims `sys_suite`, `role`, `role_name`, `profile_id` (un token lobby no otorga rol/suite/perfil, +coherente con "sin perfil aún"). + +## Contrato con el cliente (frontend) + +El grafo es consumido por el web-app. Con el lobby, el cliente **debe**: + +1. **Detectar `onboardingPending === true`** y mostrar el flujo de onboarding (p. ej. "tu cuenta está + activa, falta asignarte un perfil / completar tu alta") en vez de la aplicación. +2. **Tolerar `systemSuite`/`role`/`profile` en `null`** en `context` (antes siempre venían). Igual que + `branch` ya podía ser `null`, ahora estos tres también lo son en el estado lobby. + +Ambos cambios son **aditivos y compatibles**: `onboardingPending` es un campo nuevo (default `false`) y +los nulos solo aparecen en el estado lobby; un cliente que ignore la bandera pero maneje los nulos verá +simplemente una app sin menús. + +## Dónde vive + +* Backend: `AuthorizationGraphBuilderService.BuildLobbyGraph` (rama "sin perfil activo") y el campo + `AuthorizationGraph.OnboardingPending`. +* Serialización null-safe de `systemSuite`/`role`/`profile` y emisión de `onboardingPending` en + `JsonAuthorizationGraphSerializer` (y su equivalente XML). +* Emisión de token null-safe en `JwtTokenService.GenerateGraphToken` / `GenerateSemanticGraphToken`. + +## Alcance y pendientes + +Este cambio resuelve la **causa raíz A** de G-043 (login ya no se rompe por falta de perfil) y la **B** +(el estado deja de ser un error opaco). Quedan como trabajo separado: + +* **Materialización de permisos** cuando sí se asigna una plantilla: `CreateProfileCommandHandler` + `TryAutoAssignTemplateAsync` se traga el fallo en silencio (registrado en G-043 como residual). +* **Verificación E2E conductual**: sembrar un usuario aprobado sin perfil y ejercer el login real contra + el host de integración (hoy verificado a nivel unit en + `AuthorizationGraphBuilderServiceTests.BuildAsync_NoActiveProfileForUser_ReturnsLobbyGraph`). diff --git a/docs/domain/authorization/index.md b/docs/domain/authorization/index.md index 49f63812..f627165f 100644 --- a/docs/domain/authorization/index.md +++ b/docs/domain/authorization/index.md @@ -11,9 +11,7 @@ The suite structures govern the navigational and action menus of the system: - [SystemSuite](./system-suite.md) (Aggregate Root) — Top-level system applications (e.g. Admin Portal, Branch Portal). - [Module](./module.md) (Owned Entity) — Modular functional sections within a suite. -- [Menu](./menu.md) (Owned Entity) — Graphical menu interfaces. -- [SubMenu](./sub-menu.md) (Owned Entity) — Nested submenu blocks. -- [Option](./option.md) (Owned Entity) — Specific screen/view configuration anchors. +- [MenuNode](./menu-node.md) (Owned Entity) — Node of a module's **recursive navigation tree** (ADR-0090): variable depth (`Menu`/`SubMenu`/`Option` become roles, not types), N:M functionality via `ActionCode` and per-node SDLC governance metadata. Replaces the former rigid Menu/SubMenu/Option entities. - [Action](./action.md) (Owned Entity) — Fine-grained action tokens (e.g., READ, WRITE, EXPORT) to secure individual behaviors. - [Role](./role.md) (Aggregate Root) - Tenant-scoped responsibility catalog and optional hierarchy defined by a system suite. diff --git a/docs/domain/authorization/menu-node.md b/docs/domain/authorization/menu-node.md new file mode 100644 index 00000000..28ed65dd --- /dev/null +++ b/docs/domain/authorization/menu-node.md @@ -0,0 +1,101 @@ +> **Traducción pendiente.** Esta ficha se incorporó en la resincronización con la plataforma de origen y aún no tiene versión en inglés; el texto normativo es el de `docs/domain-es/`. + +# MenuNode — Árbol de Navegación Recursivo + +**Contexto Delimitado:** Autorización +**Entidad Propia de:** `SystemSuite` → `Module` +**Módulo:** `Ums.Domain.Authorization.SystemSuite.MenuNode` +**Estado:** Producción +**Decisión de referencia:** ADR-0090 (Aceptado en `evolith-core`) · gap G-029 · decisión D-009 + +--- + +## 1. Propósito + +`MenuNode` es la entidad que modela la **topología de navegación de un módulo como un árbol de nodos recursivo**, con profundidad variable. Reemplaza a la antigua jerarquía rígida de cuatro niveles (Suite → Módulo → Menú → Submenú → Opción) que exigía un submenú obligatorio y una relación funcionalidad↔opción 1:1 débil. + +Cada `Module` posee una colección de nodos raíz; cada nodo puede anidar hijos recursivamente. El rol del nodo se clasifica con `NodeKind` (`Menu`, `SubMenu`, `Option`) **sin fijar la profundidad**: un `Menu` o `SubMenu` actúa como rama y una `Option` como hoja. + +## 2. Cambios respecto al modelo rígido (ADR-0090) + +| Dimensión | Modelo rígido (retirado) | Árbol de nodos (`MenuNode`) | +| --- | --- | --- | +| Profundidad | Fija de 4 niveles, submenú obligatorio | Variable; submenú opcional | +| Relación funcionalidad↔opción | 1:1 débil (`ActionCode` string sin FK) | **N:M** vía tabla puente `SystemSuiteNodeActions` | +| Metadatos de gobernanza | Solo `Status` en Suite/Módulo | **Metadatos SDLC por nodo** (VO `MenuNodeMetadata`) | +| Entidades | `Menu`, `SubMenu`, `Option` | `MenuNode` único y recursivo | + +## 3. Estructura del nodo + +* `Id: IdValueObject` +* `ModuleId: ModuleId` — módulo propietario. +* `ParentNodeId: IdValueObject?` — nulo en un nodo raíz; enlaza el árbol (lista de adyacencia). +* `Kind: NodeKind` — `Menu` (1), `SubMenu` (2), `Option` (3). +* `Code: Code`, `Label: Name`, `Description: Description`, `SortOrder: int`. +* `Status: ModuleStatus` — `Active` / `Inactive`. +* `ActionCodes: IReadOnlyCollection` — funcionalidades vinculadas (N:M) en nodos hoja. +* `Metadata: MenuNodeMetadata` — metadatos de gobernanza SDLC (VO). +* `Children: IReadOnlyCollection` — subárbol. + +### `MenuNodeMetadata` (VO de gobernanza SDLC) + +`Responsable`, `Criticidad`, `ProductoImpactado`, `ComponenteTecnico`, `Dependencias`, `Evidencias`, `TrazabilidadSdlc`. Todos opcionales; el conjunto se reemplaza de forma atómica. + +## 4. Operaciones (a través de la raíz `SystemSuite`) + +La raíz de agregado `SystemSuite` delega en `Module`/`MenuNode`: + +* `AddModuleRootNode(moduleId, kind, code, label, description, sortOrder, actor, metadata?)` +* `AddModuleChildNode(moduleId, parentNodeId, kind, code, label, description, sortOrder, actor, metadata?)` +* `UpdateModuleNode(moduleId, nodeId, label, description, sortOrder, actor)` +* `RemoveModuleNode(moduleId, nodeId, actor)` — elimina el nodo y su subárbol. +* `ActivateModuleNode` / `DeactivateModuleNode(moduleId, nodeId, actor)` +* `LinkModuleNodeAction` / `UnlinkModuleNodeAction(moduleId, nodeId, actionCode, actor)` — vínculo N:M. +* `SetModuleNodeMetadata(moduleId, nodeId, metadata, actor)` + +## 5. Persistencia + +* Tabla `ums_authorization.SystemSuiteNodes` — lista de adyacencia (`ParentNodeId`), con columnas de metadatos SDLC. +* Tabla puente `ums_authorization.SystemSuiteNodeActions` — vínculo N:M nodo↔`ActionCode`. +* La carga es plana; el árbol se reconstruye en memoria (`AuthorizationAggregateFactory.RehydrateNode`) agrupando por `ParentNodeId`. + +## 6. Diagrama + +```mermaid +classDiagram + direction TB + class Module { + +Guid Id + +Code Code + +List~MenuNode~ Nodes + } + class MenuNode { + +Guid Id + +Guid ModuleId + +Guid? ParentNodeId + +NodeKind Kind + +Code Code + +Name Label + +ModuleStatus Status + +int SortOrder + +List~ActionCode~ ActionCodes + +MenuNodeMetadata Metadata + +List~MenuNode~ Children + } + class MenuNodeMetadata { + +string? Responsable + +string? Criticidad + +string? ProductoImpactado + +string? ComponenteTecnico + +string? Dependencias + +string? Evidencias + +string? TrazabilidadSdlc + } + Module "1" *-- "0..*" MenuNode : raíces + MenuNode "1" *-- "0..*" MenuNode : hijos + MenuNode "1" o-- "1" MenuNodeMetadata : gobierna +``` + +--- + +**[Volver al Índice de Autorización](./index.md)** diff --git a/docs/domain/authorization/menu.md b/docs/domain/authorization/menu.md deleted file mode 100644 index 09dd0e14..00000000 --- a/docs/domain/authorization/menu.md +++ /dev/null @@ -1,7 +0,0 @@ -# Menu - -> **Language:** [English](./menu.md) | [Español](../../domain-es/authorization/menu.md) - -This is a stable placeholder document for the `Menu` owned by the Authorization BC aggregate index. The detailed rules remain documented inline in the parent aggregate root document until a fuller standalone page is required. - -**[Back to Authorization Index](./index.md)** diff --git a/docs/domain/authorization/option.md b/docs/domain/authorization/option.md deleted file mode 100644 index ff3006d6..00000000 --- a/docs/domain/authorization/option.md +++ /dev/null @@ -1,7 +0,0 @@ -# Option - -> **Language:** [English](./option.md) | [Español](../../domain-es/authorization/option.md) - -This is a stable placeholder document for the `Option` owned by the Authorization BC aggregate index. The detailed rules remain documented inline in the parent aggregate root document until a fuller standalone page is required. - -**[Back to Authorization Index](./index.md)** diff --git a/docs/domain/authorization/permission-template.md b/docs/domain/authorization/permission-template.md index 64724dd1..7fc6eb5a 100644 --- a/docs/domain/authorization/permission-template.md +++ b/docs/domain/authorization/permission-template.md @@ -86,9 +86,8 @@ stateDiagram-v2 | `SetItemAllowCommand` | Set `IsAllowed=true, IsDenied=false` | Status = Draft | | `SetItemDenyCommand` | Set `IsAllowed=false, IsDenied=true` | Status = Draft | | `SetItemNeutralCommand` | Set `IsAllowed=false, IsDenied=false` (inherit from parent scope) | Status = Draft | -| `ActivateItemCommand` | Set `IsActive=true` | Status = Draft | -| `DeactivateItemCommand` | Set `IsActive=false` | Status = Draft | -| `RemoveTemplateItemCommand` | Remove an item from the template | Status = Draft | +| `ActivateTemplateItemCommand` | Set `IsActive=true` | Status = Draft | +| `DeactivateTemplateItemCommand` | Set `IsActive=false`. Replaces the withdrawn `RemoveTemplateItemCommand`: an item is retired, never deleted | Status = Draft | | `DeprecatePermissionTemplateCommand` | Transition `Published → Deprecated` | Status = Published | | `DeletePermissionTemplateCommand` | Remove a template from storage | Status = Draft or Deprecated; no active profile dependencies | @@ -390,9 +389,8 @@ flowchart TD | `SetItemAllowCommand` | `templateId, itemId` | `void` | Status must be Draft | | `SetItemDenyCommand` | `templateId, itemId` | `void` | Status must be Draft | | `SetItemNeutralCommand` | `templateId, itemId` | `void` | Status must be Draft | -| `ActivateItemCommand` | `templateId, itemId` | `void` | Status must be Draft | -| `DeactivateItemCommand` | `templateId, itemId` | `void` | Status must be Draft | -| `RemoveTemplateItemCommand` | `templateId, itemId` | `void` | Status must be Draft | +| `ActivateTemplateItemCommand` | `templateId, itemId` | `void` | Status must be Draft | +| `DeactivateTemplateItemCommand` | `templateId, itemId` | `void` | Status must be Draft | ### Queries diff --git a/docs/domain/authorization/sub-menu.md b/docs/domain/authorization/sub-menu.md deleted file mode 100644 index 3c2a55fb..00000000 --- a/docs/domain/authorization/sub-menu.md +++ /dev/null @@ -1,7 +0,0 @@ -# SubMenu - -> **Language:** [English](./sub-menu.md) | [Español](../../domain-es/authorization/sub-menu.md) - -This is a stable placeholder document for the `SubMenu` owned by the Authorization BC aggregate index. The detailed rules remain documented inline in the parent aggregate root document until a fuller standalone page is required. - -**[Back to Authorization Index](./index.md)** diff --git a/docs/domain/authorization/system-suite.md b/docs/domain/authorization/system-suite.md index f341cc5c..8bbc084c 100644 --- a/docs/domain/authorization/system-suite.md +++ b/docs/domain/authorization/system-suite.md @@ -10,7 +10,12 @@ ## 1. Aggregate Overview ### Purpose -The `SystemSuite` aggregate represents a tenant-owned application surface registered in UMS. It defines the functional topology used by downstream authorization models and stores suite-level operational settings. In the current implementation, it owns `Module`, menu topology, `DomainResource` (Aggregates, Entities, and DomainMethods), `AppSetting`, and `Action` children. The independent `Role` aggregate is maintained in the selected suite context and references it through `SystemSuiteId`. During bootstrap, `UMS` is the canonical base suite for the tenant-management surface. +The `SystemSuite` aggregate represents a tenant-owned application surface registered in UMS. It defines the functional topology used by downstream authorization models and stores suite-level operational settings. In the current implementation, it owns `Module`, a **recursive navigation node tree** (`MenuNode`, ADR-0090), `DomainResource` (Aggregates, Entities, and DomainMethods), `AppSetting`, and `Action` children. The independent `Role` aggregate is maintained in the selected suite context and references it through `SystemSuiteId`. During bootstrap, `UMS` is the canonical base suite for the tenant-management surface. + +> **Navigation topology (ADR-0090):** every `Module` owns a recursive [`MenuNode`](./menu-node.md) +> tree of variable depth, with **N:M** functionality binding through `ActionCode` and per-node SDLC +> governance metadata. This model replaces the former rigid Menu → SubMenu → Option hierarchy, +> now withdrawn from every layer. ### Business Responsibility - Register a tenant-scoped software suite. @@ -35,6 +40,7 @@ The `SystemSuite` aggregate represents a tenant-owned application surface regist | Entity / VO | Type | Ownership | Description | |---|---|---|---| | `Module` | Entity | Owned | Functional subsystem inside the suite | +| [`MenuNode`](./menu-node.md) | Entity | Owned (via `Module`) | Node of the recursive navigation tree (ADR-0090): variable depth, N:M by `ActionCode`, SDLC metadata | | `AppSetting` | Entity | Owned | Suite-scoped configuration entry | | `Action` | Entity | Owned / catalogued | Action tokens exposed for authorization targeting | | `Role` | Aggregate Root | Related by `SystemSuiteId` | Responsibility catalog and hierarchy defined for the suite | diff --git a/docs/domain/consistency-rules/authorization-bc.md b/docs/domain/consistency-rules/authorization-bc.md index a2b16457..9410b2a3 100644 --- a/docs/domain/consistency-rules/authorization-bc.md +++ b/docs/domain/consistency-rules/authorization-bc.md @@ -140,10 +140,8 @@ Inactive ──ActivateModule()──► Active | `AddModule()` | Module code must be unique | `system_suite.module_code_not_unique` | | `ActivateModule()` | Module must not be active | `system_suite.module_already_active` | | `DeactivateModule()` | Module must not be inactive | `system_suite.module_already_inactive` | -| `AddMenu()` | Module must be Active | `system_suite.module_inactive_cannot_add_menu` | -| `AddMenu()` | Menu code must be unique within module | `system_suite.menu_code_not_unique` | -| `AddSubMenu()` | SubMenu code must be unique within menu | `system_suite.submenu_code_not_unique` | -| `AddOption()` | Option code must be unique within submenu | `system_suite.option_code_not_unique` | +| `AddModuleRootNode()` / `AddModuleChildNode()` | Module must be Active | `system_suite.module_inactive_cannot_add_menu` | +| `AddModuleRootNode()` / `AddModuleChildNode()` | Node code must be unique within the module | `system_suite.menu_code_not_unique` | | `AddDomainResource()` DomainMethod | Parent must be provided | `authorization.domain_method_requires_parent` | | `AddDomainResource()` DomainMethod | Parent resource must exist | `authorization.parent_resource_not_found` | | `AddDomainResource()` DomainMethod | Parent must not itself be a DomainMethod | `authorization.domain_method_cannot_be_parent` | ### Cross-Aggregate Dependency Guards diff --git a/docs/domain/consistency-rules/broken-rules-registry.md b/docs/domain/consistency-rules/broken-rules-registry.md index cf5bedc8..ac501fcf 100644 --- a/docs/domain/consistency-rules/broken-rules-registry.md +++ b/docs/domain/consistency-rules/broken-rules-registry.md @@ -16,7 +16,7 @@ | `tenant.already_active` | `DomainErrors.Tenant.AlreadyActive` | `Activate()` when already Active | | | `tenant.already_suspended` | `DomainErrors.Tenant.AlreadySuspended` | `Suspend()` when already Suspended | | | `tenant.branch_code_not_unique` | `DomainErrors.Tenant.BranchCodeNotUnique` | `AddBranch()` duplicate code | | -| `tenant.branch_active` | `DomainErrors.Tenant.BranchActive` | `RemoveBranch()` on active branch | | +| `BRANCH_HAS_LIVE_REFERENCES` | `DomainErrors.Tenant.BranchHasLiveReferences` | `CloseBranch()` with live accounts or profiles (ADR-0164 §2.2) | | | `tenant.idp_code_not_unique` | `DomainErrors.Tenant.IdpCodeNotUnique` | `RegisterIdentityProvider()` | | | `tenant.idp_already_active` | `DomainErrors.Tenant.IdpAlreadyActive` | `ActivateIdentityProvider()` | | | `tenant.idp_already_inactive` | `DomainErrors.Tenant.IdpAlreadyInactive` | `DeactivateIdentityProvider()` | | @@ -94,9 +94,7 @@ | `system_suite.module_already_inactive` | `DomainErrors.SystemSuite.ModuleAlreadyInactive` | `DeactivateModule()` | | | `system_suite.module_inactive_cannot_add_menu` | `DomainErrors.SystemSuite.ModuleInactiveCannotAddMenu` | `AddMenu()` inactive module | | | `system_suite.module_code_not_unique` | `DomainErrors.SystemSuite.ModuleCodeNotUnique` | `AddModule()` duplicate | | -| `system_suite.menu_code_not_unique` | `DomainErrors.SystemSuite.MenuCodeNotUnique` | `AddMenu()` duplicate | | -| `system_suite.submenu_code_not_unique` | `DomainErrors.SystemSuite.SubMenuCodeNotUnique` | `AddSubMenu()` duplicate | | -| `system_suite.option_code_not_unique` | `DomainErrors.SystemSuite.OptionCodeNotUnique` | `AddOption()` duplicate | | +| `system_suite.menu_code_not_unique` | `DomainErrors.SystemSuite.MenuCodeNotUnique` | `AddModuleRootNode()` / `AddModuleChildNode()` duplicate code (ADR-0090) | | | `authorization.domain_method_requires_parent` | `DomainErrors.Authorization.DomainMethodRequiresParent` | `AddDomainResource()` DomainMethod without parent | | | `authorization.parent_resource_not_found` | `DomainErrors.Authorization.ParentResourceNotFound` | `AddDomainResource()` parent missing | | | `authorization.domain_method_cannot_be_parent` | `DomainErrors.Authorization.DomainMethodCannotBeParent` | `AddDomainResource()` invalid hierarchy | | diff --git a/docs/domain/consistency-rules/identity-bc.md b/docs/domain/consistency-rules/identity-bc.md index 6e61eacf..f1b740ca 100644 --- a/docs/domain/consistency-rules/identity-bc.md +++ b/docs/domain/consistency-rules/identity-bc.md @@ -27,7 +27,8 @@ Suspended ──Archive()──► Archived (terminal) | Operation | Guard | Broken Rule | |-----------|-------|-------------| | `AddBranch()` | Branch code must be unique within tenant | `tenant.branch_code_not_unique` | -| `RemoveBranch()` | Branch must be inactive before removal | `tenant.branch_active` | +| `CloseBranch()` | No live `UserAccount` or `Profile` may reference the branch (ADR-0164 §2.2) | `BRANCH_HAS_LIVE_REFERENCES` | +| `AddBranch()` | Code uniqueness includes **closed** branches: a closed code is never freed (ADR-0164 §2.3) | `tenant.branch_code_not_unique` | | `RegisterIdentityProvider()` | IdP code must be unique within tenant | `tenant.idp_code_not_unique` | | `ActivateIdentityProvider()` | IdP must not already be active | `tenant.idp_already_active` | | `DeactivateIdentityProvider()` | IdP must not already be inactive | `tenant.idp_already_inactive` | diff --git a/docs/domain/identity/auth-graph.md b/docs/domain/identity/auth-graph.md index 7a5b7fef..e12db49d 100644 --- a/docs/domain/identity/auth-graph.md +++ b/docs/domain/identity/auth-graph.md @@ -40,13 +40,12 @@ AuthorizationGraph │ └── { id, code, name } │ ├── menuAccess[] ← Árbol de menús con permisos efectivos -│ └── module { id, code, name, sortOrder, status } -│ └── menus[] { id, code, label, sortOrder } -│ └── subMenus[] { id, code, label, sortOrder } -│ └── options[] { id, code, label, actionCode, -│ effect: "Allow"|"Deny"|"NotGranted", -│ source: "Template"|"Override" } -│ +│ └── module { id, code, value, sortOrder, status, icon } +│ └── nodes[] { id, code, value, kind: "Menu"|"SubMenu"|"Option", +│ sortOrder, icon, route, +│ actions[] { actionCode, effect: "Allow"|"Deny", +│ source: "Template"|"Override" }, +│ children[] ← same shape, recursive (ADR-0090) } ├── domainPermissions[] ← Recursos de dominio con acciones autorizadas │ └── resource { id, type: "Aggregate"|"Entity", code, name, moduleId? } │ └── actions[] { actionId, actionCode, actionName, @@ -287,42 +286,41 @@ Ejemplo representativo de `POST /api/v1/client/authenticate` para una autenticac "sortOrder": 1, "status": "PUBLISHED" }, - "menus": [ + "nodes": [ { "id": "n0000001-0000-4000-8000-000000000001", "code": "STOCK", - "label": "Stock Management", + "value": "Stock Management", + "kind": "Menu", "sortOrder": 1, - "subMenus": [ + "icon": null, + "route": null, + "actions": [], + "children": [ { "id": "s0000001-0000-4000-8000-000000000001", "code": "STOCK_OPS", - "label": "Operations", + "value": "Operations", + "kind": "SubMenu", "sortOrder": 1, - "options": [ + "icon": null, + "route": null, + "actions": [], + "children": [ { "id": "o0000001-0000-4000-8000-000000000001", "code": "STOCK_VIEW", - "label": "View Stock", - "actionCode": "VIEW", - "effect": "Allow", - "source": "Template" - }, - { - "id": "o0000001-0000-4000-8000-000000000002", - "code": "STOCK_ADJUST", - "label": "Adjust Stock", - "actionCode": "UPDATE", - "effect": "Allow", - "source": "Override" - }, - { - "id": "o0000001-0000-4000-8000-000000000003", - "code": "STOCK_DELETE", - "label": "Delete Stock Record", - "actionCode": "DELETE", - "effect": "Deny", - "source": "Override" + "value": "View Stock", + "kind": "Option", + "sortOrder": 1, + "icon": null, + "route": "/stock", + "actions": [ + { "actionCode": "VIEW", "effect": "Allow", "source": "Template" }, + { "actionCode": "UPDATE", "effect": "Allow", "source": "Override" }, + { "actionCode": "DELETE", "effect": "Deny", "source": "Override" } + ], + "children": [] } ] } diff --git a/docs/domain/identity/tenant.md b/docs/domain/identity/tenant.md index 90f8f9f9..ebf00f9c 100644 --- a/docs/domain/identity/tenant.md +++ b/docs/domain/identity/tenant.md @@ -104,7 +104,7 @@ This boundary is formalized in [ADR-0077](../../architecture/adrs/0077-tenant-po | `UpdateBranchCommand` | Update name or geofencing metadata of a branch | | `DeactivateBranchCommand` | Deactivate an existing branch | | `ReactivateBranchCommand` | Reactivate a branch | -| `RemoveBranchCommand` | Remove a branch | +| `CloseBranchCommand` | Close a branch permanently. Logical and terminal: the row stays so past operations remain explainable, and its code is never freed (ADR-0164) | | `ConfigureBrandingCommand` | Set the tenant's visual identity | | `UpdateBrandingCommand` | Update branding attributes | | `SetCustomDomainCommand` | Add or replace the custom domain | @@ -392,7 +392,7 @@ flowchart TD | `UpdateBranchCommand` | `void` | | `DeactivateBranchCommand` | `void` | | `ReactivateBranchCommand` | `void` | -| `RemoveBranchCommand` | `void` | +| `CloseBranchCommand` | `void` | Blocked by live accounts or profiles (`BRANCH_HAS_LIVE_REFERENCES`) | | `ConfigureBrandingCommand` | `Guid brandingId` | | `UpdateBrandingCommand` | `void` | | `SetCustomDomainCommand` | `void` | @@ -523,8 +523,8 @@ The GraphQL endpoint `tenantBranches(tenantId: UUID!)` is the recommended way to - The endpoint manually initializes `ITenantContext` from JWT claims after validation - Requires the JWT to contain `is_internal_admin=true` claim -**EF Core Query Splitting (SQLite)** -EF Core 7+ defaults to split query mode when multiple `Include()` statements are used. This causes `SQLite Error: 'near "EXEC": syntax error'` because split queries use `EXEC` statements not supported by SQLite. Use `.AsSingleQuery()` to force single-query mode: +**EF Core Query Splitting** +EF Core 7+ defaults to split query mode when multiple `Include()` statements are used. UMS forces single-query mode with `.AsSingleQuery()` where the aggregate is loaded with several collections at once, to keep one round trip and a stable result shape: ```csharp var record = await dbContext.Tenants @@ -543,7 +543,7 @@ var record = await dbContext.Tenants | GraphQL `tenantBranches` query | Working | Admin can query any tenant's branches | | REST `switch-tenant` endpoint | Working (with fix) | JWT validated directly, TenantContext initialized manually | | GraphQL `getTenants` query | Not available | Use REST endpoint or alternate query | -| Branch CRUD via GraphQL | Working | Single query mode prevents SQLite issues | +| Branch CRUD via GraphQL | Working | Single query mode keeps the load to one round trip | --- diff --git a/docs/domain/iga/promotion-request.md b/docs/domain/iga/promotion-request.md index fa9bd9e2..9a4419dd 100644 --- a/docs/domain/iga/promotion-request.md +++ b/docs/domain/iga/promotion-request.md @@ -45,7 +45,7 @@ The `PromotionRequest` aggregate coordinates access promotions, allowing users t | `TenantId` | Value Object | Partition identifier mapping to the tenant context | | `UserId` | Value Object | Target user reference (Identity Context) | | `RoleId` | Value Object | Reference to current and target roles (Authorization Context) | -| `PromotionStatus` | Enum | FSM status enum (`Draft`, `PendingManagerApproval`, etc.) | +| `RolePromotionStatus` | Enum | FSM status enum (`Draft`, `PendingManagerApproval`, etc.) | | `ApprovalDecision` | Enum | `None` · `Approved` · `Rejected` | | `PromotionImpactAnalysis` | Entity | Owned child entity containing risk metrics | | `TextValueObject` | Value Object | General string properties (RequestReason, RiskLevel, Mitigations, ConflictingPermissions) | @@ -71,7 +71,7 @@ PromotionRequest (Aggregate Root) │ ├── ManagerDecisionAt: DateTime? │ ├── SecurityApprovalStatus: ApprovalDecision │ ├── SecurityDecisionAt: DateTime? -│ ├── Status: PromotionStatus +│ ├── Status: RolePromotionStatus │ ├── ExecutedAt: DateTime? │ ├── ExecutedBy: ActorId? │ ├── VerifiedAt: DateTime? @@ -127,7 +127,7 @@ classDiagram +UserId ManagerId +ApprovalDecision ManagerApprovalStatus +ApprovalDecision SecurityApprovalStatus - +PromotionStatus Status + +RolePromotionStatus Status +AuditValueObject Audit } class PromotionImpactAnalysis { @@ -144,7 +144,7 @@ classDiagram +TextValueObject AnalyzedBy +Create() Result~PromotionImpactAnalysis~ } - class PromotionStatus { + class RolePromotionStatus { <> Draft PendingManagerApproval @@ -159,7 +159,7 @@ classDiagram PromotionRequest *-- PromotionRequestProps PromotionRequest "1" *-- "0..1" PromotionImpactAnalysis : owns - PromotionRequestProps --> PromotionStatus + PromotionRequestProps --> RolePromotionStatus ``` --- diff --git a/docs/domain/index.md b/docs/domain/index.md index 3033931b..3b3139ec 100644 --- a/docs/domain/index.md +++ b/docs/domain/index.md @@ -21,7 +21,7 @@ Detailed architecture documents for every Aggregate Root in the UMS domain model | Aggregate Root | Document | Owned Child Entities (documented inline) | |---|---|---| -| `SystemSuite` | [system-suite.md](./authorization/system-suite.md) | `Module`, `Menu`, `SubMenu`, `Option`, `Action` | +| `SystemSuite` | [system-suite.md](./authorization/system-suite.md) | `Module`, `MenuNode` (recursive tree, ADR-0090), `Action`, `DomainResource`, `AppSetting` | | `Role` | [role.md](./authorization/role.md) | None | | `PermissionTemplate` | [permission-template.md](./authorization/permission-template.md) | `PermissionTemplateItem` | | `Profile` | [profile.md](./authorization/profile.md) | `ProfilePermission` | @@ -82,3 +82,5 @@ Domain-wide state-machine rules, dependency guards, broken rules registry, and o --- **[Back to Master Index](../MASTER_INDEX.md)** | **[DDD Design Portal](../governance/construction/ddd-design/index.md)** + +- [Onboarding Lobby](./onboarding-lobby.md) — Cross-cutting note on the lobby before profile assignment. diff --git a/docs/domain/onboarding-lobby.md b/docs/domain/onboarding-lobby.md new file mode 100644 index 00000000..12f83d35 --- /dev/null +++ b/docs/domain/onboarding-lobby.md @@ -0,0 +1,65 @@ +> **Traducción pendiente.** Esta ficha se incorporó en la resincronización con la plataforma de origen y aún no tiene versión en inglés; el texto normativo es el de `docs/domain-es/`. + +# Grafo lobby (onboarding pendiente) — G-043 + +## El caso + +Un usuario puede quedar **autenticado y aprobado pero sin perfil activo**: el flujo de alta +(`signup` → `approve`/`activate`) crea la cuenta, le fija contraseña, la activa y la aprueba, pero +**no le asigna un perfil**. Un _perfil_ es lo que vincula al usuario con un rol, una suite de sistema +y una plantilla de permisos; sin él, el sistema no puede resolver qué puede ver ni hacer. + +Antes de G-043, ese usuario **no podía iniciar sesión**: al construir su Grafo de Autorización, +`AuthorizationGraphBuilderService` devolvía `Result.Failure("No active profile found…")`, un error que +`AuthEndpoints.MapAuthError` no reconocía y mapeaba al cajón genérico **`401 AUTH_000` («No pudimos +iniciar sesión. Intente nuevamente.»)** — opaco y sin pista de la causa. La auditoría BMAD Tester lo +reprodujo en 3 cuentas independientes. + +## Cómo se maneja ahora + +En lugar de fallar, cuando no hay perfil activo el servicio devuelve un **grafo lobby**: un +`AuthorizationGraph` válido que representa "usuario dentro, pero sin app todavía". + +| Campo del grafo | En el lobby | +| --- | --- | +| `onboardingPending` | **`true`** (bandera nueva; por defecto `false`) | +| `context.user`, `context.tenant` | **reales** (el usuario y su inquilino) | +| `context.systemSuite`, `context.role`, `context.profile` | **`null`** | +| `actions`, `menuAccess`, `domainPermissions`, `scopes` | **vacíos** | +| `authentication`, `effectiveConfig` | poblados normalmente | + +El login **responde 200** con este grafo (no 401). El token Bearer de grafo se emite igual, **omitiendo** +los claims `sys_suite`, `role`, `role_name`, `profile_id` (un token lobby no otorga rol/suite/perfil, +coherente con "sin perfil aún"). + +## Contrato con el cliente (frontend) + +El grafo es consumido por el web-app. Con el lobby, el cliente **debe**: + +1. **Detectar `onboardingPending === true`** y mostrar el flujo de onboarding (p. ej. "tu cuenta está + activa, falta asignarte un perfil / completar tu alta") en vez de la aplicación. +2. **Tolerar `systemSuite`/`role`/`profile` en `null`** en `context` (antes siempre venían). Igual que + `branch` ya podía ser `null`, ahora estos tres también lo son en el estado lobby. + +Ambos cambios son **aditivos y compatibles**: `onboardingPending` es un campo nuevo (default `false`) y +los nulos solo aparecen en el estado lobby; un cliente que ignore la bandera pero maneje los nulos verá +simplemente una app sin menús. + +## Dónde vive + +* Backend: `AuthorizationGraphBuilderService.BuildLobbyGraph` (rama "sin perfil activo") y el campo + `AuthorizationGraph.OnboardingPending`. +* Serialización null-safe de `systemSuite`/`role`/`profile` y emisión de `onboardingPending` en + `JsonAuthorizationGraphSerializer` (y su equivalente XML). +* Emisión de token null-safe en `JwtTokenService.GenerateGraphToken` / `GenerateSemanticGraphToken`. + +## Alcance y pendientes + +Este cambio resuelve la **causa raíz A** de G-043 (login ya no se rompe por falta de perfil) y la **B** +(el estado deja de ser un error opaco). Quedan como trabajo separado: + +* **Materialización de permisos** cuando sí se asigna una plantilla: `CreateProfileCommandHandler` + `TryAutoAssignTemplateAsync` se traga el fallo en silencio (registrado en G-043 como residual). +* **Verificación E2E conductual**: sembrar un usuario aprobado sin perfil y ejercer el login real contra + el host de integración (hoy verificado a nivel unit en + `AuthorizationGraphBuilderServiceTests.BuildAsync_NoActiveProfileForUser_ReturnsLobbyGraph`). diff --git a/docs/governance/architecture-es/persistence-phase1/database-and-messaging-testing-strategy.md b/docs/governance/architecture-es/persistence-phase1/database-and-messaging-testing-strategy.md index 66b78106..d553bdaa 100644 --- a/docs/governance/architecture-es/persistence-phase1/database-and-messaging-testing-strategy.md +++ b/docs/governance/architecture-es/persistence-phase1/database-and-messaging-testing-strategy.md @@ -1,5 +1,12 @@ # Estrategia de Pruebas de Base de Datos y Mensajeria +> **Documento histórico de planificación.** Este árbol recoge el análisis de persistencia de la +> Fase 1, escrito cuando SQL Server era la línea base supuesta y se estudiaba una migración a doble +> proveedor. Esa fase está cerrada: **PostgreSQL es el único proveedor relacional** (ADR-0082), SQL +> Server y SQLite se retiraron del código y del despliegue, y el esquema lo aplican las migraciones +> de EF Core — el `SqlServerSchemaBootstrapper` sobre el que planifican estos documentos ya no +> existe. Léelos por el razonamiento, no por el estado actual. + > Espejo en espanol de [database-and-messaging-testing-strategy.md](../../architecture/persistence-phase1/database-and-messaging-testing-strategy.md). ## Estado diff --git a/docs/governance/architecture-es/persistence-phase1/database-compatibility-matrix.md b/docs/governance/architecture-es/persistence-phase1/database-compatibility-matrix.md index 67b5828f..87a36134 100644 --- a/docs/governance/architecture-es/persistence-phase1/database-compatibility-matrix.md +++ b/docs/governance/architecture-es/persistence-phase1/database-compatibility-matrix.md @@ -1,5 +1,12 @@ # Matriz de Compatibilidad de Base de Datos +> **Documento histórico de planificación.** Este árbol recoge el análisis de persistencia de la +> Fase 1, escrito cuando SQL Server era la línea base supuesta y se estudiaba una migración a doble +> proveedor. Esa fase está cerrada: **PostgreSQL es el único proveedor relacional** (ADR-0082), SQL +> Server y SQLite se retiraron del código y del despliegue, y el esquema lo aplican las migraciones +> de EF Core — el `SqlServerSchemaBootstrapper` sobre el que planifican estos documentos ya no +> existe. Léelos por el razonamiento, no por el estado actual. + > Espejo en espanol de [database-compatibility-matrix.md](../../architecture/persistence-phase1/database-compatibility-matrix.md). ## Estado diff --git a/docs/governance/architecture-es/persistence-phase1/database-provider-current-state-assessment.md b/docs/governance/architecture-es/persistence-phase1/database-provider-current-state-assessment.md index 97c90d43..a7ed657e 100644 --- a/docs/governance/architecture-es/persistence-phase1/database-provider-current-state-assessment.md +++ b/docs/governance/architecture-es/persistence-phase1/database-provider-current-state-assessment.md @@ -1,5 +1,12 @@ # Evaluacion del Estado Actual del Proveedor de Base de Datos +> **Documento histórico de planificación.** Este árbol recoge el análisis de persistencia de la +> Fase 1, escrito cuando SQL Server era la línea base supuesta y se estudiaba una migración a doble +> proveedor. Esa fase está cerrada: **PostgreSQL es el único proveedor relacional** (ADR-0082), SQL +> Server y SQLite se retiraron del código y del despliegue, y el esquema lo aplican las migraciones +> de EF Core — el `SqlServerSchemaBootstrapper` sobre el que planifican estos documentos ya no +> existe. Léelos por el razonamiento, no por el estado actual. + > Espejo en espanol de [database-provider-current-state-assessment.md](../../architecture/persistence-phase1/database-provider-current-state-assessment.md). ## Resumen diff --git a/docs/governance/architecture-es/persistence-phase1/database-provider-strategy.md b/docs/governance/architecture-es/persistence-phase1/database-provider-strategy.md index 1028ab4b..32fb65d3 100644 --- a/docs/governance/architecture-es/persistence-phase1/database-provider-strategy.md +++ b/docs/governance/architecture-es/persistence-phase1/database-provider-strategy.md @@ -1,5 +1,12 @@ # Estrategia de Proveedor de Base de Datos +> **Documento histórico de planificación.** Este árbol recoge el análisis de persistencia de la +> Fase 1, escrito cuando SQL Server era la línea base supuesta y se estudiaba una migración a doble +> proveedor. Esa fase está cerrada: **PostgreSQL es el único proveedor relacional** (ADR-0082), SQL +> Server y SQLite se retiraron del código y del despliegue, y el esquema lo aplican las migraciones +> de EF Core — el `SqlServerSchemaBootstrapper` sobre el que planifican estos documentos ya no +> existe. Léelos por el razonamiento, no por el estado actual. + > Espejo en espanol de [database-provider-strategy.md](../../architecture/persistence-phase1/database-provider-strategy.md). ## Decision Vigente diff --git a/docs/governance/architecture-es/persistence-phase1/database-schema-ownership.md b/docs/governance/architecture-es/persistence-phase1/database-schema-ownership.md index 3e173799..b55734dd 100644 --- a/docs/governance/architecture-es/persistence-phase1/database-schema-ownership.md +++ b/docs/governance/architecture-es/persistence-phase1/database-schema-ownership.md @@ -1,5 +1,12 @@ # Propiedad de Esquemas de Base de Datos +> **Documento histórico de planificación.** Este árbol recoge el análisis de persistencia de la +> Fase 1, escrito cuando SQL Server era la línea base supuesta y se estudiaba una migración a doble +> proveedor. Esa fase está cerrada: **PostgreSQL es el único proveedor relacional** (ADR-0082), SQL +> Server y SQLite se retiraron del código y del despliegue, y el esquema lo aplican las migraciones +> de EF Core — el `SqlServerSchemaBootstrapper` sobre el que planifican estos documentos ya no +> existe. Léelos por el razonamiento, no por el estado actual. + > Espejo en espanol de [database-schema-ownership.md](../../architecture/persistence-phase1/database-schema-ownership.md). ## Baseline diff --git a/docs/governance/architecture-es/persistence-phase1/in-memory-service-bus-transactionality.md b/docs/governance/architecture-es/persistence-phase1/in-memory-service-bus-transactionality.md index 235bd21e..5d4a77bc 100644 --- a/docs/governance/architecture-es/persistence-phase1/in-memory-service-bus-transactionality.md +++ b/docs/governance/architecture-es/persistence-phase1/in-memory-service-bus-transactionality.md @@ -1,5 +1,12 @@ # Transaccionalidad del Bus In-Memory +> **Documento histórico de planificación.** Este árbol recoge el análisis de persistencia de la +> Fase 1, escrito cuando SQL Server era la línea base supuesta y se estudiaba una migración a doble +> proveedor. Esa fase está cerrada: **PostgreSQL es el único proveedor relacional** (ADR-0082), SQL +> Server y SQLite se retiraron del código y del despliegue, y el esquema lo aplican las migraciones +> de EF Core — el `SqlServerSchemaBootstrapper` sobre el que planifican estos documentos ya no +> existe. Léelos por el razonamiento, no por el estado actual. + > Espejo en espanol de [in-memory-service-bus-transactionality.md](../../architecture/persistence-phase1/in-memory-service-bus-transactionality.md). ## Resumen diff --git a/docs/governance/architecture-es/persistence-phase1/outbox-inbox-decision.md b/docs/governance/architecture-es/persistence-phase1/outbox-inbox-decision.md index 107100d6..314b811e 100644 --- a/docs/governance/architecture-es/persistence-phase1/outbox-inbox-decision.md +++ b/docs/governance/architecture-es/persistence-phase1/outbox-inbox-decision.md @@ -1,5 +1,12 @@ # Decision Outbox e Inbox +> **Documento histórico de planificación.** Este árbol recoge el análisis de persistencia de la +> Fase 1, escrito cuando SQL Server era la línea base supuesta y se estudiaba una migración a doble +> proveedor. Esa fase está cerrada: **PostgreSQL es el único proveedor relacional** (ADR-0082), SQL +> Server y SQLite se retiraron del código y del despliegue, y el esquema lo aplican las migraciones +> de EF Core — el `SqlServerSchemaBootstrapper` sobre el que planifican estos documentos ya no +> existe. Léelos por el razonamiento, no por el estado actual. + > Espejo en espanol de [outbox-inbox-decision.md](../../architecture/persistence-phase1/outbox-inbox-decision.md). ## Baseline diff --git a/docs/governance/architecture-es/persistence-phase1/phase-1-persistence-implementation-plan.md b/docs/governance/architecture-es/persistence-phase1/phase-1-persistence-implementation-plan.md index 2782e68a..3e6f3cb9 100644 --- a/docs/governance/architecture-es/persistence-phase1/phase-1-persistence-implementation-plan.md +++ b/docs/governance/architecture-es/persistence-phase1/phase-1-persistence-implementation-plan.md @@ -1,5 +1,12 @@ # Plan de Implementacion de Persistencia Fase 1 +> **Documento histórico de planificación.** Este árbol recoge el análisis de persistencia de la +> Fase 1, escrito cuando SQL Server era la línea base supuesta y se estudiaba una migración a doble +> proveedor. Esa fase está cerrada: **PostgreSQL es el único proveedor relacional** (ADR-0082), SQL +> Server y SQLite se retiraron del código y del despliegue, y el esquema lo aplican las migraciones +> de EF Core — el `SqlServerSchemaBootstrapper` sobre el que planifican estos documentos ya no +> existe. Léelos por el razonamiento, no por el estado actual. + > Espejo en espanol de [phase-1-persistence-implementation-plan.md](../../architecture/persistence-phase1/phase-1-persistence-implementation-plan.md). ## Objetivo diff --git a/docs/governance/architecture-es/persistence-phase1/transaction-boundary-design.md b/docs/governance/architecture-es/persistence-phase1/transaction-boundary-design.md index 9da44a9a..037eac94 100644 --- a/docs/governance/architecture-es/persistence-phase1/transaction-boundary-design.md +++ b/docs/governance/architecture-es/persistence-phase1/transaction-boundary-design.md @@ -1,5 +1,12 @@ # Diseno de Limites Transaccionales +> **Documento histórico de planificación.** Este árbol recoge el análisis de persistencia de la +> Fase 1, escrito cuando SQL Server era la línea base supuesta y se estudiaba una migración a doble +> proveedor. Esa fase está cerrada: **PostgreSQL es el único proveedor relacional** (ADR-0082), SQL +> Server y SQLite se retiraron del código y del despliegue, y el esquema lo aplican las migraciones +> de EF Core — el `SqlServerSchemaBootstrapper` sobre el que planifican estos documentos ya no +> existe. Léelos por el razonamiento, no por el estado actual. + > Espejo en espanol de [transaction-boundary-design.md](../../architecture/persistence-phase1/transaction-boundary-design.md). ## Baseline diff --git a/docs/governance/architecture/persistence-phase1/database-and-messaging-testing-strategy.md b/docs/governance/architecture/persistence-phase1/database-and-messaging-testing-strategy.md index f07170c5..04b49d80 100644 --- a/docs/governance/architecture/persistence-phase1/database-and-messaging-testing-strategy.md +++ b/docs/governance/architecture/persistence-phase1/database-and-messaging-testing-strategy.md @@ -1,5 +1,12 @@ # Database and Messaging Testing Strategy +> **Historical planning document.** This tree records the Phase-1 persistence analysis, written +> when SQL Server was the assumed baseline and a dual-provider migration was on the table. That +> phase is closed: **PostgreSQL is the single relational provider** (ADR-0082), SQL Server and +> SQLite were withdrawn from code and deployment, and the schema is applied by EF Core migrations — +> the `SqlServerSchemaBootstrapper` these documents plan around no longer exists. Read for the +> reasoning, not for the current state. + ## Philosophy Testing persistence and transactionality cannot rely solely on `Microsoft.EntityFrameworkCore.InMemory` or SQLite in memory, as these do not enforce schema isolation, true transactions, row locks, or concurrency exactly like a real relational database. diff --git a/docs/governance/architecture/persistence-phase1/database-compatibility-matrix.md b/docs/governance/architecture/persistence-phase1/database-compatibility-matrix.md index 357032c0..a75e1983 100644 --- a/docs/governance/architecture/persistence-phase1/database-compatibility-matrix.md +++ b/docs/governance/architecture/persistence-phase1/database-compatibility-matrix.md @@ -1,5 +1,12 @@ # Database Compatibility Matrix +> **Historical planning document.** This tree records the Phase-1 persistence analysis, written +> when SQL Server was the assumed baseline and a dual-provider migration was on the table. That +> phase is closed: **PostgreSQL is the single relational provider** (ADR-0082), SQL Server and +> SQLite were withdrawn from code and deployment, and the schema is applied by EF Core migrations — +> the `SqlServerSchemaBootstrapper` these documents plan around no longer exists. Read for the +> reasoning, not for the current state. + | Capacidad | SQL Server | PostgreSQL | Estado | Evidencia | Brecha | Acción | | ---------------------- | ---------: | ---------: | ------ | --------- | ------ | ------ | | Creación de schemas | Sí | Sí | IMPLEMENTED | EF Core `ToTable` annotations | Ninguna | Mantener convenciones agnósticas | diff --git a/docs/governance/architecture/persistence-phase1/database-provider-current-state-assessment.md b/docs/governance/architecture/persistence-phase1/database-provider-current-state-assessment.md index 67653bb2..236d3b15 100644 --- a/docs/governance/architecture/persistence-phase1/database-provider-current-state-assessment.md +++ b/docs/governance/architecture/persistence-phase1/database-provider-current-state-assessment.md @@ -1,5 +1,12 @@ # Database Provider Current State Assessment +> **Historical planning document.** This tree records the Phase-1 persistence analysis, written +> when SQL Server was the assumed baseline and a dual-provider migration was on the table. That +> phase is closed: **PostgreSQL is the single relational provider** (ADR-0082), SQL Server and +> SQLite were withdrawn from code and deployment, and the schema is applied by EF Core migrations — +> the `SqlServerSchemaBootstrapper` these documents plan around no longer exists. Read for the +> reasoning, not for the current state. + ## Current State The current UMS monorepo implementation heavily relies on **SQL Server**. diff --git a/docs/governance/architecture/persistence-phase1/database-provider-strategy.md b/docs/governance/architecture/persistence-phase1/database-provider-strategy.md index 2bad6192..34b38687 100644 --- a/docs/governance/architecture/persistence-phase1/database-provider-strategy.md +++ b/docs/governance/architecture/persistence-phase1/database-provider-strategy.md @@ -1,5 +1,12 @@ # Database Provider Strategy (ADR) +> **Historical planning document.** This tree records the Phase-1 persistence analysis, written +> when SQL Server was the assumed baseline and a dual-provider migration was on the table. That +> phase is closed: **PostgreSQL is the single relational provider** (ADR-0082), SQL Server and +> SQLite were withdrawn from code and deployment, and the schema is applied by EF Core migrations — +> the `SqlServerSchemaBootstrapper` these documents plan around no longer exists. Read for the +> reasoning, not for the current state. + ## Status Proposed diff --git a/docs/governance/architecture/persistence-phase1/database-schema-ownership.md b/docs/governance/architecture/persistence-phase1/database-schema-ownership.md index 35198e73..86fd1ae9 100644 --- a/docs/governance/architecture/persistence-phase1/database-schema-ownership.md +++ b/docs/governance/architecture/persistence-phase1/database-schema-ownership.md @@ -1,5 +1,12 @@ # Database Schema Ownership +> **Historical planning document.** This tree records the Phase-1 persistence analysis, written +> when SQL Server was the assumed baseline and a dual-provider migration was on the table. That +> phase is closed: **PostgreSQL is the single relational provider** (ADR-0082), SQL Server and +> SQLite were withdrawn from code and deployment, and the schema is applied by EF Core migrations — +> the `SqlServerSchemaBootstrapper` these documents plan around no longer exists. Read for the +> reasoning, not for the current state. + ## Objective Implement a Progressive Modular Monolith where the database is physically shared but logically partitioned by functional modules. diff --git a/docs/governance/architecture/persistence-phase1/in-memory-service-bus-transactionality.md b/docs/governance/architecture/persistence-phase1/in-memory-service-bus-transactionality.md index c791650c..69a03c81 100644 --- a/docs/governance/architecture/persistence-phase1/in-memory-service-bus-transactionality.md +++ b/docs/governance/architecture/persistence-phase1/in-memory-service-bus-transactionality.md @@ -1,5 +1,12 @@ # In-Memory Service Bus Transactionality +> **Historical planning document.** This tree records the Phase-1 persistence analysis, written +> when SQL Server was the assumed baseline and a dual-provider migration was on the table. That +> phase is closed: **PostgreSQL is the single relational provider** (ADR-0082), SQL Server and +> SQLite were withdrawn from code and deployment, and the schema is applied by EF Core migrations — +> the `SqlServerSchemaBootstrapper` these documents plan around no longer exists. Read for the +> reasoning, not for the current state. + ## Context During Phase 1, an **In-Memory Service Bus** (MediatR) is utilized as the default transport for Application Events. However, the architecture strictly abstracts the bus, allowing an immediate switch to **Redis**, RabbitMQ, or Azure Service Bus without touching domain or application logic. diff --git a/docs/governance/architecture/persistence-phase1/outbox-inbox-decision.md b/docs/governance/architecture/persistence-phase1/outbox-inbox-decision.md index 82f28a72..5e0e65b3 100644 --- a/docs/governance/architecture/persistence-phase1/outbox-inbox-decision.md +++ b/docs/governance/architecture/persistence-phase1/outbox-inbox-decision.md @@ -1,5 +1,12 @@ # Outbox / Inbox Decision +> **Historical planning document.** This tree records the Phase-1 persistence analysis, written +> when SQL Server was the assumed baseline and a dual-provider migration was on the table. That +> phase is closed: **PostgreSQL is the single relational provider** (ADR-0082), SQL Server and +> SQLite were withdrawn from code and deployment, and the schema is applied by EF Core migrations — +> the `SqlServerSchemaBootstrapper` these documents plan around no longer exists. Read for the +> reasoning, not for the current state. + ## Context Even though we are using an In-Memory Service Bus (MediatR) for Phase 1, we need resilience against process crashes or handler failures immediately after a database commit. diff --git a/docs/governance/architecture/persistence-phase1/phase-1-persistence-implementation-plan.md b/docs/governance/architecture/persistence-phase1/phase-1-persistence-implementation-plan.md index a008ff4c..325dd50f 100644 --- a/docs/governance/architecture/persistence-phase1/phase-1-persistence-implementation-plan.md +++ b/docs/governance/architecture/persistence-phase1/phase-1-persistence-implementation-plan.md @@ -1,5 +1,12 @@ # Phase 1 Persistence Implementation Plan +> **Historical planning document.** This tree records the Phase-1 persistence analysis, written +> when SQL Server was the assumed baseline and a dual-provider migration was on the table. That +> phase is closed: **PostgreSQL is the single relational provider** (ADR-0082), SQL Server and +> SQLite were withdrawn from code and deployment, and the schema is applied by EF Core migrations — +> the `SqlServerSchemaBootstrapper` these documents plan around no longer exists. Read for the +> reasoning, not for the current state. + ## Overview This plan dictates the actions required to fully align the current UMS Monolith with the Phase 1 persistence and messaging requirements outlined in the governance documents. diff --git a/docs/governance/architecture/persistence-phase1/transaction-boundary-design.md b/docs/governance/architecture/persistence-phase1/transaction-boundary-design.md index 149b6754..f89621b9 100644 --- a/docs/governance/architecture/persistence-phase1/transaction-boundary-design.md +++ b/docs/governance/architecture/persistence-phase1/transaction-boundary-design.md @@ -1,5 +1,12 @@ # Transaction Boundary Design +> **Historical planning document.** This tree records the Phase-1 persistence analysis, written +> when SQL Server was the assumed baseline and a dual-provider migration was on the table. That +> phase is closed: **PostgreSQL is the single relational provider** (ADR-0082), SQL Server and +> SQLite were withdrawn from code and deployment, and the schema is applied by EF Core migrations — +> the `SqlServerSchemaBootstrapper` these documents plan around no longer exists. Read for the +> reasoning, not for the current state. + ## Philosophy Transactions must align with the **Aggregate Root** boundary. A single transaction should generally modify exactly one aggregate. diff --git a/docs/governance/construction/ddd-design/04-authorization-context.md b/docs/governance/construction/ddd-design/04-authorization-context.md index 95b41b1a..7f25dc5a 100644 --- a/docs/governance/construction/ddd-design/04-authorization-context.md +++ b/docs/governance/construction/ddd-design/04-authorization-context.md @@ -1,5 +1,10 @@ # BC-B — Authorization Context +> **Parcialmente superado (ADR-0090).** Los comandos `AddMenuCommand`, `AddSubMenuCommand` y +> `AddOptionCommand` que se listan más abajo ya no existen. Los sustituyen `AddModuleRootNode` y +> `AddModuleChildNode` sobre el árbol recursivo `MenuNode`. Véase +> [ADR-0090](../../../architecture/adrs/0090-recursive-menu-node-tree.es.md). + > **Idioma:** Español | *Versión en inglés no disponible* **Schema:** `[ums_authorization]` | **Owner:** UMS Core API .NET 10 diff --git a/docs/governance/construction/ddd-design/08-iga-context.md b/docs/governance/construction/ddd-design/08-iga-context.md index 707c0839..772edca9 100644 --- a/docs/governance/construction/ddd-design/08-iga-context.md +++ b/docs/governance/construction/ddd-design/08-iga-context.md @@ -98,7 +98,7 @@ Representa la solicitud de promoción de rol activa de un usuario. Gestióna la | Tipo | Nombre | Regla / Valores | |------|--------|-----------------| -| `enum` | `PromotionStatus` | `Draft / PendingManagerApproval / PendingSecurityReview / PendingSecurityApproval / ApprovedReadyToExecute / Executed / Verified / Rejected / VerificationFailed` | +| `enum` | `RolePromotionStatus` | `Draft / PendingManagerApproval / PendingSecurityReview / PendingSecurityApproval / ApprovedReadyToExecute / Executed / Verified / Rejected / VerificationFailed` | | `enum` | `ApprovalDecisión` | `None / Approved / Rejected` | ### Invariantes @@ -122,7 +122,7 @@ classDiagram +Guid TenantId +Guid CurrentRoleId +Guid TargetRoleId - +PromotionStatus Status + +RolePromotionStatus Status +ApprovalDecision ManagerDecision +ApprovalDecision SecurityDecision +string RejectionReason diff --git a/docs/governance/construction/ddd-design/index.md b/docs/governance/construction/ddd-design/index.md index 73038b47..255fd948 100644 --- a/docs/governance/construction/ddd-design/index.md +++ b/docs/governance/construction/ddd-design/index.md @@ -40,7 +40,7 @@ Documentos detallados por Aggregate Root con modelo de datos completo, secuencia | Contexto | Agregados documentados (Hijas inline) | |----------|----------------------| | Identity | [Tenant](../../../domain/identity/tenant.md) *(Branch · Branding · IdP)* · [UserAccount](../../../domain/identity/user-account.md) *(Password · MFA)* | -| Authorization | [SystemSuite](../../../domain/authorization/system-suite.md) *(Module · Menu · SubMenu · Option · Action)* · [PermissionTemplate](../../../domain/authorization/permission-template.md) *(TemplateItem)* · [Profile](../../../domain/authorization/profile.md) *(ProfilePermission)* | +| Authorization | [SystemSuite](../../../domain/authorization/system-suite.md) *(Module · MenuNode — árbol recursivo, ADR-0090 · Action · DomainResource · AppSetting)* · [PermissionTemplate](../../../domain/authorization/permission-template.md) *(TemplateItem)* · [Profile](../../../domain/authorization/profile.md) *(ProfilePermission)* | | Configuration | [AppConfiguration](../../../domain/configuration/app-configuration.md) · [FeatureFlag](../../../domain/configuration/feature-flag.md) *(FlagEvaluationLog)* · [IdpConfiguration](../../../domain/configuration/idp-configuration.md) | | Approvals | [ApprovalWorkflow](../../../domain/approvals/approval-workflow.md) *(RequiredDocument)* · [ApprovalRequest](../../../domain/approvals/approval-request.md) · [DocumentType](../../../domain/approvals/document-type.md) *(NotificationRule)* · [UserDocument](../../../domain/approvals/user-document.md) *(AccessNotification)* · [AccessEnforcementPolicy](../../../domain/approvals/access-enforcement-policy.md) | | IGA | [PromotionRequest](../../../domain/iga/promotion-request.md) *(ImpactAnalysis)* · [RoleMaturityStatus](../../../domain/iga/role-maturity-status.md) | diff --git a/docs/governance/product-es/backlog-agil.md b/docs/governance/product-es/backlog-agil.md new file mode 100644 index 00000000..21546ca6 --- /dev/null +++ b/docs/governance/product-es/backlog-agil.md @@ -0,0 +1,93 @@ +# Backlog de Producto MVP + +## Proposito + +Este backlog organiza el trabajo de producto de UMS en epicas e historias de usuario, tomando como base el orden definido en la [Priorizacion de Historias Funcionales para MVP](../../reference/gobernanza/roadmap/priorizacion-funcional-mvp.md). + +El backlog está orientado a Product Owner, Analista de Negocio, Direccion Ejecutiva y equipo de entrega. Se enfoca en valor de negocio y secuencia de construccion, manteniendo trazabilidad hacia las Functional Stories. + +## Reglas de Prioridad + +* `P1` es la maxima prioridad de producto. +* Los numeros de prioridad mas bajos deben refinarse y construirse primero. +* Las historias marcadas como `MVP Core` son necesarias para demostrar el funcionamiento minimo de punta a punta. +* Las historias marcadas como `Post-MVP` o `Posterior` no deben bloquear el MVP salvo que exista una condicion de cliente o regulatoria. + +## Resumen de Epicas + +| Epica | Nombre | Alcance | Rol en MVP | +| --- | --- | --- | --- | +| EP-01 | Base de Tenant e Identidad | Registro de organizacion, limite de tenant, estrategia IdP, autenticacion corporativa | MVP Core | +| EP-02 | Catalogo de Sistemas Gobernados | Topologia de sistema, modulo, menu, opcion y accion | MVP Core | +| EP-03 | Diseno y Asignacion de Autorizacion | Plantillas de autorizacion, perfiles, asignacion manual, autoasignacion posterior | MVP Core / Posterior | +| EP-04 | Base de Configuracion | Parametros jerarquicos por tenant/sistema y configuracion operativa | MVP Core | +| EP-05 | Experiencia y Diagnostico de Acceso | Diagnostico por grafo y experiencia de login hospedado | Estabilizacion MVP | +| EP-06 | Seguridad, Acceso Externo y Delegacion | MFA/passwordless, acceso B2B, administracion delegada | Post-MVP | +| EP-07 | Ciclo de Vida de Cumplimiento | Documentos de usuario, notificaciones de expiracion, politica de acceso por expiracion | Post-MVP | +| EP-08 | Automatizacion IGA Avanzada | Promocion de roles y madurez de gobierno | Posterior | +| EP-09 | Bandeja de Aprobacion de Onboarding | Solicitudes de alta de empresa, solicitudes de alta de usuario, aprobaciones por alcance, preparacion futura para verificacion de pago | Preparacion de Lanzamiento MVP | +| EP-10 | Gobierno Avanzado de Acceso | Campanas de revision de acceso, paquetes de entitlements, elevacion de acceso privilegiado | Fase 1 | +| EP-11 | Integracion y Confiabilidad Operativa | Conectores de provisioning, proteccion contra solicitudes duplicadas, guardrails de concurrencia, entrega segura por tenant | Fase 1 | +| EP-12 | Inteligencia de Gobierno Explicable | Simulacion de grafo de autorizacion, paquetes con semantica de negocio, salud continua de acceso | Fase 1 | + +## Backlog Priorizado de Historias de Usuario + +| Orden | ID Historia | Epica | FS Origen | Fase | Historia de Usuario | Resultado de Negocio | Enfoque Inicial de Aceptacion | +| ---: | --- | --- | --- | --- | --- | --- | --- | +| 1 | US-001 | EP-01 | FS-03 | MVP Core | Como Administrador de Plataforma, quiero registrar una organizacion/tenant para que UMS gestione usuarios y accesos dentro de un limite de negocio claro. | UMS tiene un alcance de tenant controlado. | La organizacion se crea con identidad, responsable, estado y auditoria requeridos. | +| 2 | US-002 | EP-01 | FS-03 | MVP Core | Como Administrador de Plataforma, quiero configurar la estrategia IdP del tenant para que los usuarios autentiquen con el proveedor corporativo correcto. | La estrategia de autenticacion queda gobernada por tenant. | La configuracion IdP se registra, valida, activa y queda trazable. | +| 3 | US-003 | EP-02 | FS-04 | MVP Core | Como Responsable de Sistema, quiero registrar un sistema gobernado para que UMS pueda administrar su acceso. | UMS conoce que sistema gobierna. | El sistema se crea bajo la organizacion/tenant correcto con estado y responsable. | +| 4 | US-004 | EP-02 | FS-04 | MVP Core | Como Responsable de Sistema, quiero definir modulos, menus, opciones y acciones para que los permisos de negocio se asignen sobre capacidades reales. | La autorizacion se basa en un catalogo funcional gobernado. | La topologia funcional está completa, ordenada, unica y disponible para plantillas. | +| 5 | US-005 | EP-01 | FS-01 | MVP Core | Como Usuario Corporativo, quiero autenticarme mediante mi IdP externo para acceder a UMS sin credenciales separadas. | Los usuarios pueden ingresar con identidad corporativa. | Usuarios validos inician sesion y reciben el contexto correcto de tenant/sesion. | +| 6 | US-006 | EP-01 | FS-01 | MVP Core | Como Administrador de Seguridad, quiero que los intentos invalidos de autenticacion se manejen claramente para mantener el acceso controlado. | Las fallas de autenticacion son entendibles y auditables. | Usuarios invalidos, tenants inactivos y errores IdP generan resultados controlados y auditoria. | +| 7 | US-007 | EP-03 | FS-02 | MVP Core | Como Administrador de Autorizacion, quiero crear una plantilla de autorizacion para reutilizar patrones estándar de acceso. | El diseno de accesos se vuelve repetible. | La plantilla tiene nombre, alcance, estado, version, descripcion y permisos seleccionados. | +| 8 | US-008 | EP-03 | FS-02 | MVP Core | Como Administrador de Autorizacion, quiero instanciar una plantilla para un tenant/sistema para asignarla de forma consistente. | Los permisos estándar pueden aplicarse de forma controlada. | La instancia conserva version, alcance, permisos y trazabilidad. | +| 9 | US-009 | EP-03 | FS-05 | MVP Core | Como Administrador, quiero crear un perfil de usuario para que el usuario tenga una identidad de acceso dentro de UMS. | Los usuarios pueden representarse para gobierno de acceso. | El perfil queda vinculado al contexto tenant/usuario y tiene estado de ciclo de vida. | +| 10 | US-010 | EP-03 | FS-05 | MVP Core | Como Administrador, quiero asignar manualmente una plantilla de autorizacion a un perfil para otorgar los permisos previstos. | El primer flujo de otorgamiento de acceso queda operativo. | La asignacion se valida, traza, audita y se refleja en permisos efectivos. | +| 11 | US-011 | EP-04 | FS-13 | MVP Core | Como Administrador de Configuracion, quiero definir parametros jerarquicos para variar el comportamiento de UMS por tenant, sistema o alcance. | El comportamiento puede configurarse sin cambios de codigo. | Los parametros incluyen code, value, description, alcance, estado, version y auditoria. | +| 12 | US-012 | EP-04 | FS-13 | MVP Core | Como Product Owner, quiero que la herencia de configuracion sea predecible para que negocio entienda que valor aplica. | La configuracion es explicable. | La resolucion del valor efectivo sigue la jerarquia documentada y muestra su origen. | +| 13 | US-013 | EP-05 | FS-07 | Estabilizacion MVP | Como Administrador de Soporte, quiero diagnosticar por que un usuario tiene acceso para resolver incidentes rapidamente. | El soporte de acceso se vuelve transparente. | El diagnostico muestra el camino desde perfil/plantilla hasta permiso efectivo. | +| 14 | US-014 | EP-05 | FS-07 | Estabilizacion MVP | Como Auditor, quiero ver por que un usuario no tiene acceso para explicar accesos rechazados. | La denegacion de acceso es explicable. | Se visualizan permisos faltantes, plantillas inactivas, concesiones vencidas y diferencias de alcance. | +| 15 | US-015 | EP-05 | FS-08 | Preparacion de Lanzamiento MVP | Como Administrador de Tenant, quiero configurar el login hospedado para que los usuarios reconozcan el contexto de su organizacion. | El login se percibe confiable y contextual al tenant. | Las pistas de tenant y la redireccion se configuran dentro de limites aprobados. | +| 16 | US-016 | EP-05 | FS-08 | Preparacion de Lanzamiento MVP | Como Usuario Corporativo, quiero regresar a la aplicacion correcta despues del login para tener una experiencia continua. | La autenticacion soporta una experiencia limpia de producto. | El login exitoso redirige a la aplicacion prevista y conserva contexto valido. | +| 17 | US-017 | EP-06 | FS-09 | Seguridad Post-MVP | Como Administrador de Seguridad, quiero reglas de MFA adaptativo para exigir mayor verificacion en accesos de riesgo. | La postura de seguridad mejora sin friccion uniforme para todos. | El requerimiento MFA cambia segun riesgo, tenant, usuario o accion configurada. | +| 18 | US-018 | EP-06 | FS-09 | Seguridad Post-MVP | Como Usuario Corporativo, quiero autenticacion passwordless cuando esté permitida para iniciar sesion con seguridad y baja friccion. | La experiencia mejora manteniendo control. | Las opciones passwordless están disponibles solo cuando aplican al tenant/usuario. | +| 19 | US-019 | EP-06 | FS-10 | Expansion Post-MVP | Como Usuario Patrocinador, quiero solicitar acceso B2B externo para que un socio colabore bajo aprobacion controlada. | El acceso externo se solicita sin procesos informales. | La solicitud captura patrocinador, identidad externa, motivo, acceso objetivo y expiracion. | +| 20 | US-020 | EP-06 | FS-10 | Expansion Post-MVP | Como Aprobador, quiero aprobar o rechazar solicitudes B2B para gobernar el acceso externo. | El onboarding de socios es controlado y auditable. | La decision registra resultado, justificacion, alcance, expiracion y auditoria. | +| 21 | US-021 | EP-06 | FS-14 | Gobierno Post-MVP | Como Administrador Senior, quiero delegar gestion de usuarios para que administradores locales operen dentro de limites controlados. | La administracion escala sin perder gobierno. | La delegacion define alcance, acciones permitidas, vigencia y responsable. | +| 22 | US-022 | EP-06 | FS-14 | Gobierno Post-MVP | Como Administrador Delegado, quiero gestionar solo usuarios dentro de mi alcance asignado para mantener responsabilidades claras. | Las operaciones delegadas respetan limites organizacionales. | El administrador delegado solo opera dentro de su tenant/sistema/alcance asignado. | +| 23 | US-023 | EP-07 | FS-11 | Cumplimiento Post-MVP | Como Usuario o Administrador, quiero cargar documentos requeridos para respaldar elegibilidad de acceso con evidencia. | La evidencia de cumplimiento puede recolectarse. | El documento se carga, clasifica, vincula al usuario y recibe estado de validacion. | +| 24 | US-024 | EP-07 | FS-11 | Cumplimiento Post-MVP | Como Validador, quiero aprobar o rechazar documentos para que solo evidencia valida afecte el gobierno de acceso. | Las decisiones de cumplimiento quedan gobernadas. | La validacion captura decision, razon, validador, fecha y estado del documento. | +| 25 | US-025 | EP-07 | FS-15 | Cumplimiento Post-MVP | Como Administrador de Cumplimiento, quiero reglas de notificacion de vencimiento para alertar antes de un impacto. | El riesgo de expiracion se reduce proactivamente. | La regla incluye code, value, description, audiencia, tiempo, canal y alcance. | +| 26 | US-026 | EP-07 | FS-15 | Cumplimiento Post-MVP | Como Usuario Responsable, quiero recibir notificaciones de vencimiento para actuar antes de que el acceso sea afectado. | Usuarios y administradores pueden actuar antes de enforcement. | La notificacion se entrega segun regla configurada y queda trazada. | +| 27 | US-027 | EP-07 | FS-16 | Cumplimiento Post-MVP | Como Administrador de Cumplimiento, quiero definir comportamiento de acceso ante expiracion para que UMS aplique cumplimiento consistentemente. | Las condiciones vencidas generan resultados predecibles de acceso. | La politica define advertencia, restricción, suspension o excepcion por alcance. | +| 28 | US-028 | EP-07 | FS-16 | Cumplimiento Post-MVP | Como Auditor, quiero trazabilidad de cambios de acceso por expiracion para revisar el enforcement. | El enforcement de cumplimiento es explicable. | El registro incluye razon, usuario afectado, acceso afectado, politica y fecha. | +| 29 | US-029 | EP-03 | FS-06 | Automatizacion Posterior | Como Administrador, quiero que las plantillas se autoasignen al crear un perfil para reducir trabajo repetitivo. | La administracion se acelera despues de estabilizar la asignacion manual. | La autoasignacion sigue reglas configuradas y puede revisarse antes de activarse cuando aplique. | +| 30 | US-030 | EP-03 | FS-06 | Automatizacion Posterior | Como Product Owner, quiero que las reglas de autoasignacion sean transparentes para confiar en decisiones automaticas de acceso. | El acceso automatizado sigue siendo entendible. | El resultado explica que regla aplico y por que. | +| 31 | US-031 | EP-08 | FS-12 | IGA Avanzado Posterior | Como Administrador IGA, quiero promover un rol mediante un proceso gobernado para evolucionar roles sin cambios no controlados. | El gobierno de roles madura de forma controlada. | La solicitud captura rol actual, estado objetivo, razon, revision y aprobacion. | +| 32 | US-032 | EP-08 | FS-12 | IGA Avanzado Posterior | Como Aprobador, quiero revisar el impacto de una promocion de rol para no aprobar cambios riesgosos a ciegas. | Los cambios de rol consideran riesgo e impacto. | La revision muestra perfiles, permisos, sistemas e impacto de negocio esperado. | +| 33 | TE-07 | INFRA | ADR-UMS-058 | Evolucion SaaS — Deuda Tecnica | Como Arquitecto de Plataforma, quiero introducir un API Gateway centralizado con YARP para que todos los clientes futuros (web, movil) compartan un unico punto de entrada gobernado para politicas de seguridad y enrutamiento. | UMS puede escalar a multiples superficies de cliente sin duplicar configuracion de seguridad. | El gateway enruta `/api/**` hacia `ums-api`; cabeceras de seguridad centralizadas; nginx reducido a servidor de archivos estaticos; el cliente movil puede conectarse sin configuracion especial. | +| 34 | US-033 | EP-09 | FS-21 | Preparacion de Lanzamiento MVP | Como Administrador del Sistema, quiero una unica bandeja de onboarding para solicitudes de alta de empresa para que las nuevas companias puedan revisarse y aprobarse en un solo lugar. | El onboarding de companias queda gobernado centralmente. | La solicitud se ve como pendiente, solo los aprobadores globales pueden revisarla y la aprobacion crea el tenant mas la primera cuenta administradora. | +| 35 | US-034 | EP-09 | FS-22 | Preparacion de Lanzamiento MVP | Como Administrador de Tenant, quiero una bandeja de onboarding con alcance del tenant para solicitudes de alta de usuario para poder aprobar o denegar accesos de mi propia organizacion. | El onboarding de usuarios permanece local al tenant y llega a un resultado final. | La solicitud solo es visible dentro del tenant, el Tenant Admin la cierra como aprobada o denegada y el solicitante recibe notificacion del resultado final. | +| 36 | US-035 | EP-09 | FS-23 | Preparacion de Lanzamiento MVP | Como usuario autenticado sin perfil, quiero solicitar acceso a sistema, sucursal y rol desde el lobby para que los administradores sepan que acceso necesito. | La admision al tenant permanece separada de la asignacion de entitlements. | El usuario ve un lobby, envia una solicitud de perfil, no recibe menus operativos antes de la aprobacion y puede seguir la solicitud hasta su cierre final. | +| 37 | US-036 | EP-09 | FS-24 | Preparacion de Lanzamiento MVP | Como aprobador de tenant o sucursal, quiero aprobar, modificar o denegar solicitudes de perfil para que los usuarios reciban solo el acceso apropiado. | La asignacion de perfiles queda gobernada, auditable y cerrada. | La decision registra aprobador, fecha, rol solicitado, rol otorgado cuando se aprueba, resultado final, motivo cuando exista y resultado de notificacion. | +| 38 | US-041 | EP-11 | FS-32 | Fase 1 | Como Administrador de Plataforma, quiero guardrails operativos para acciones de gobierno para que los reintentos, la concurrencia y el alcance de tenant no creen estados inconsistentes. | Las operaciones administrativas son mas confiables bajo condiciones reales de produccion. | Las solicitudes duplicadas, los conflictos de concurrencia y los errores de tenant producen resultados claros y no hay corrupcion silenciosa. | +| 39 | US-037 | EP-10 | FS-28 | Fase 1 | Como Administrador de Gobierno de Acceso, quiero campanas recurrentes de revision de acceso para que el acceso obsoleto pueda recertificarse y eliminarse. | El acceso se mantiene justificado en el tiempo. | El alcance de revision, los revisores, las decisiones y los cambios de acceso resultantes son auditables. | +| 40 | US-038 | EP-10 | FS-29 | Fase 1 | Como Administrador de Entitlements, quiero paquetes de entitlements gobernados para que los bloques de acceso comunes puedan solicitarse y asignarse como una sola unidad. | El acceso se agrupa en bloques reutilizables. | El paquete contiene entitlements controlados, sigue aprobacion y permanece auditable por version. | +| 41 | US-039 | EP-11 | FS-30 | Fase 1 | Como Administrador de Operaciones de Identidad, quiero conectores de provisioning descendente para que los cambios de acceso se reflejen en sistemas externos. | UMS permanece sincronizado con aplicaciones descendentes. | Las acciones de provisioning y deprovisioning se registran, reintentan y pueden auditarse. | +| 42 | US-040 | EP-10 | FS-31 | Fase 1 | Como Administrador de Seguridad, quiero acceso privilegiado limitado en el tiempo para que el acceso elevado sea temporal y justificado. | El acceso privilegiado es mas seguro y facil de revisar. | Cada elevacion tiene motivo, duracion, vencimiento y decision auditable. | +| 43 | US-042 | EP-12 | FS-33 | Fase 1 | Como Administrador de Autorizacion, quiero previsualizar y simular el grafo de autorizacion para aprobar cambios con confianza antes de llevarlos a produccion. | Los cambios de acceso se vuelven explicables y mas seguros de aprobar. | Los grafos actual y propuesto son comparables y la vista previa no cambia el acceso real. | +| 44 | US-043 | EP-12 | FS-34 | Fase 1 | Como Arquitecto de Entitlements, quiero paquetes de acceso con semantica de negocio para que el acceso pueda componerse y gobernarse en lenguaje que el negocio entienda. | Los paquetes de acceso se alinean con operaciones de negocio reales. | Los paquetes estan versionados, conocen el alcance y pueden reutilizarse sin perder historial. | +| 45 | US-044 | EP-12 | FS-35 | Fase 1 | Como Administrador de Seguridad, quiero salud continua de acceso para ver accesos riesgosos u obsoletos antes de que se conviertan en incidentes. | El gobierno se vuelve proactivo y no solo reactivo. | Las senales de salud, las recomendaciones y las rutas de remediacion son visibles y auditables. | + +## Linea de Corte MVP + +El backlog MVP recomendado incluye `US-001` hasta `US-012` como alcance core. + +`US-013` hasta `US-016` deben tratarse como alcance de estabilización de lanzamiento: no siempre son obligatorias para un MVP tecnico, pero son altamente recomendables antes de un piloto productivo real. + +`US-017` en adelante debe planificarse despues del MVP, salvo que el cliente de lanzamiento requiera seguridad reforzada, acceso externo o cumplimiento desde el primer dia. + +`US-033` a `US-036` deben tratarse como preparacion de lanzamiento para onboarding. Si el onboarding autoservicio entra en produccion, estas historias se convierten en condicion de salida porque controlan la admision de companias, la activacion de cuentas por tenant y la asignacion de entitlements. + +`US-037` en adelante es Fase 1. Esta ordenada por prioridad de entrega y debe comenzar con guardrails operativos, luego gobierno de acceso, provisioning descendente, acceso privilegiado temporal e inteligencia de gobierno explicable. diff --git a/docs/governance/product-es/index.md b/docs/governance/product-es/index.md index 647f5acf..0dd7037f 100644 --- a/docs/governance/product-es/index.md +++ b/docs/governance/product-es/index.md @@ -8,6 +8,8 @@ Documentación que define el "Por qué" y el "Qué" del User Management System ( - [Alcance y Límites](./scope.md) - [Objetivos (OKRs)](./objectives.md) - [Stakeholders](./stakeholders.md) +- [PRD UMS-001](./prd-ums-001.md) +- [Backlog Ágil](./backlog-agil.md) - [Matriz Competitiva](../product/competitive-matrix.es.md) - [Posicionamiento](../product/positioning.es.md) - [Hoja de Ruta de Innovacion](../product/innovation-roadmap.es.md) diff --git a/docs/governance/product-es/prd-ums-001.md b/docs/governance/product-es/prd-ums-001.md new file mode 100644 index 00000000..4bf7f9d6 --- /dev/null +++ b/docs/governance/product-es/prd-ums-001.md @@ -0,0 +1,494 @@ +# PRD — ums (Sistema de Gestión de Autenticación y Autorizaciones) + +**Identificador:** `PRD-UMS-001` · **Producto:** UMS — Sistema de Gestión de Autenticación y Autorizaciones · **Suite:** BEYONDNET + +> **Estado:** Producto (alcance completo reconciliado contra el código fuente) | **Propietario:** BeyondNet S.A.C. | **Reglas:** S-02, S-04, S-05, S-06, SD-08 +> **Versión:** 0.2.0 · **Fecha:** 2026-07-21 · **Reconcilia:** [G-075](../../GAPS.md) (parte PRD) + +Documento de Requisitos de Producto (PRD) del satélite **ums**. Es un +producto _brownfield_: el código ya existe (monolito modular .NET 10 + frontend +React/TS) y este PRD especifica el **producto completo** (no solo el piloto/MVP), +manteniendo todas las capacidades (FR-001…FR-072) y anotando el **estado real de +implementación de cada una**, verificado contra el código. La **fuente de verdad +es el código**: cuando la documentación de dominio o la aspiración contradice lo +construido, este PRD se **reconcilia contra el código**, no al revés (mandato del +propietario, 2026-07-21; regla operativa P7 de [G-075](../../GAPS.md), evidencia SD-05). +La doc de dominio en [docs/02-diseno/dominio/](../02-diseno/dominio/) y la arquitectura en +[reference/architecture/](../../reference/architecture/vision-general.md) son contexto +de apoyo, no la fuente autoritativa de este PRD. + +> **Convención de estado de implementación** (derivada del código, no de la +> aspiración — SD-05). Cada FR anota su estado real: +> +> * **[Implementado]** — la capacidad existe en el código y opera. +> * **[Parcial]** — existe parcialmente: contrato/dominio presente pero con +> ejecución, activación o cobertura pendiente (se detalla el residual). +> * **[Planificado]** — especificado y con decisión/ADR, sin implementación +> operativa aún. + +## Tabla de Navegación + +* [#actores](#2-actores-y-stakeholders) +* [#funcionales](#4-requisitos-funcionales) +* [#no-funcionales](#5-requisitos-no-funcionales) +* [#trazabilidad](#6-trazabilidad-y-decisiones) + +## 1. Visión y Alcance + +UMS es el **bloque de autenticación y autorización** de la plataforma BeyondNet. +Gobierna quién es cada usuario, en qué inquilino opera y qué puede hacer, y +entrega esa decisión a los sistemas cliente en un artefacto autocontenido (el +**Grafo de Autorización**). Puede funcionar **de forma autónoma** (autenticación +local BCrypt) o **integrado con proveedores de identidad externos**, resolviendo +el método en tiempo de login desde configuración (`AUTH_USE_EXTERNAL_IDP`). La +federación tiene hoy **adaptador OIDC real** (Authorization Code + PKCE, validación +estricta del `id_token` vía JWKS) cableado en producción para la familia OIDC +(Keycloak, GenericOidc, Azure AD, Okta, Zitadel, Auth0, Google); **SAML 2.0** y +**LDAP** están declarados como estrategias pero **sin adaptador** aún (retornan +`AUTH_012`). **WS-Federation no está soportado** (no existe estrategia en el código). + +Este PRD describe el **producto completo**. El **piloto interno** habilita un +subconjunto (identidad/tenant, autenticación local, topología, plantillas, perfiles, +configuración, y — endurecido en 2026-07-21 — IGA de promoción de rol); su criterio +de corte vive en el Definition of Release +[TE-09](../../reference/architecture/blueprints/technical-enablers/te-09-definition-of-release-piloto.md) +([G-074](../../GAPS.md)). El estado por FR de este documento refleja el **código**, +no el recorte del piloto. + +**En alcance (producto):** identidad y ciclo de vida de cuentas, autenticación +local y federada (OIDC), bloqueo temporal por intentos fallidos, MFA, multi-tenancy +con aislamiento, autorización basada en roles y plantillas de permisos, grafo de +autorización, refresh de sesión y refresh token configurable con revocación, +configuración jerárquica y feature flags, flujos de aprobación y cumplimiento +documental, gobernanza de accesos (IGA) y auditoría inmutable. + +**Refresh y revocación (FR-015/FR-016):** existen **dos mecanismos distintos** +([D-019](../../DECISIONS.md)): (a) el **refresh de sesión por cookie deslizante** +(`/auth/refresh`), que regenera el Grafo de Autorización completo en espejo del +login — **implementado y en uso**; y (b) el **refresh token opaco** +(`/auth/refresh-token`, [ADR-UMS-091](../../reference/architecture/adrs/UMS-091-refresh-token-configurable-revocacion.es.md)), +parametrizable por inquilino y con revocación — **implementado pero apagado por +defecto** (`AUTH_REFRESH_TOKEN_ENABLED=false`, fail-closed): es **opt-in por +inquilino**. + +**Fuera de alcance (por ahora):** provisión de identidades hacia sistemas +externos (SCIM), single sign-out federado, y adaptadores SAML 2.0 / LDAP / WS-Fed. + +**Objetivos medibles:** + +* Emitir y validar la decisión de autorización con latencia baja mediante un + pipeline interno, sin saltos a servicios externos en el camino crítico. +* Aislamiento estricto por inquilino: cero lecturas o escrituras cruzadas. +* Trazabilidad no repudiable de toda transición crítica. + +## 2. Actores y Stakeholders + +### Actor Principal + +* **Usuario final:** se autentica (local BCrypt o IdP federado), enrola y verifica + sus métodos MFA, gestiona su contraseña y solicita accesos o promociones de rol. + +### Actores Secundarios + +* **Administrador de Inquilino (`Tenant:Admin`):** gestiona usuarios, ramas + e IdPs, define flujos de aprobación y políticas dentro de su inquilino. +* **Gestor de Usuarios (`Tenant:UserManager`):** registra usuarios y consulta ramas + (subconjunto del administrador). +* **Administrador Interno de Plataforma (`INTERNAL_ADMIN`):** privilegios + cross-tenant; crea o suspende inquilinos, gestiona configuración global y cambia + de contexto mediante `switch-tenant`. +* **Solicitante y Aprobador:** el primero pide una acción sensible; el segundo la + aprueba o rechaza desde una bandeja acotada, sin poder aprobar la propia. +* **Gerente, Auditor de Seguridad y Auditor de Cumplimiento:** niveles de revisión + en promociones de rol (IGA) y análisis de riesgo. +* **Verificador (`Role.Reviewer`):** valida o rechaza los documentos cargados. +* **Proveedor de Identidad externo (IdP):** verifica credenciales federadas. +* **Sistema cliente / API externa:** consume el Grafo de Autorización para decidir + el acceso localmente. +* **Actores de sistema:** motor de cumplimiento, retos MFA, + hashing de contraseñas y el suscriptor de auditoría. + +### Diagrama de Interacción + +```mermaid +flowchart LR + U["Usuario final"] -->|autentica| API["UMS API"] + ADM["Admin de Inquilino"] -->|gestiona| API + IA["Admin Interno
cross-tenant"] -->|opera| API + API -->|delega si federado| IDP["IdP externo
OIDC/SAML/WS-Fed"] + API -->|emite| AG["Grafo de
Autorizacion"] + AG -->|decide acceso| CLI["Sistema cliente"] + API -->|publica eventos| BUS["Bus de eventos"] + BUS -->|suscribe| AUD["Auditoria
inmutable"] + APR["Solicitante"] -->|solicita| API + API -->|bandeja| APB["Aprobador"] + + style U fill:#e3f2fd,stroke:#1565c0,color:#000 + style AG fill:#fff3e0,stroke:#e65100,color:#000 + style AUD fill:#e8f5e9,stroke:#2e7d32,color:#000 +``` + +## 3. Contexto y Requisitos Técnicos + +### Bounded Context + +UMS se organiza en contextos acotados con integración **solo por identificadores +centrales** (nunca referencias directas entre contextos): + +* **Identity:** inquilinos, ramas, cuentas de usuario, credenciales, + MFA, proveedores de identidad y onboarding. +* **Authorization:** system suites, módulos, acciones, recursos de dominio, roles, + plantillas de permisos, perfiles y grafo de autorización. +* **Configuration:** parámetros jerárquicos, feature flags y configuración de IdP. +* **Approvals:** flujos de aprobación, tipos de documento, documentos de usuario, + reglas de notificación y enforcement de acceso. +* **IGA:** promociones de rol, análisis de impacto/riesgo y madurez de rol. +* **Audit:** traza inmutable transversal (suscriptor de todos los contextos). + +### Dependencias + +* **Runtime:** .NET 10 LTS (backend) y Node.js 24 LTS + React 18/Vite (frontend). +* **Persistencia:** PostgreSQL vía EF Core + Npgsql, con esquema por módulo. +* **Mensajería:** bus de eventos con _Transactional Outbox_ (consistencia eventual + sin 2PC). +* **Librerías transversales:** shells corporativos `BeyondNetCode.Shell.*` (DDD, AOP, + Factory, Bootstrapper), ver [DECISIONS.md](../../DECISIONS.md). +* **Integración externa:** proveedores de identidad (OIDC/SAML/WS-Fed) y la + proyección de inquilinos del sistema MMS (contrato de mensajería, ver + [G-011](../../GAPS.md)). + +### Restricciones + +* **Idioma único español** en documentación (SD-08). +* **Dominio 100 % POCO** sin dependencias NuGet en la capa Domain. +* **Result Pattern** (sin excepciones para control de flujo) y _Broken Rules + Registry_ para invariantes. +* **Guardas de dependencia:** no se desactiva, archiva ni elimina un agregado con + dependencias activas. +* **Fechas en UTC**; zona horaria e idioma se resuelven en el cliente. +* **Decisiones técnicas trazables** a ADRs aceptados (S-06); el retrazado del + corpus importado (ADR-0050…0083) a `evolith-core` es deuda ([G-012](../../GAPS.md)). + +## 4. Requisitos Funcionales + +Los requisitos se agrupan por capacidad; cada uno tiene un identificador estable +`FR-NNN` y anota su **estado de implementación** ([Implementado]/[Parcial]/ +[Planificado]) **verificado contra el código** (símbolos entre paréntesis). El +detalle de invariantes vive en [docs/02-diseno/dominio/](../02-diseno/dominio/), pero prevalece el +código donde discrepen. + +### 4.1 Identidad y cuentas + +* **FR-001** [Implementado] — El sistema debe registrar cuentas de usuario + asociadas a un inquilino (y opcionalmente a una rama), con email único por + inquilino. _(`UserAccount.Create`.)_ +* **FR-002** [Implementado] — El sistema debe gestionar el ciclo de vida de la + cuenta e impedir autenticar cuentas no activas. Estados reales + (`UserStatus`): `Pending`, `Active`, `Blocked`, `Deleted`, **`Denied`**. + Transiciones: `Pending → Active` (`Activate`), **`Pending → Denied`** (`Deny`, + el admin de inquilino rechaza el alta — terminal, omitido en versiones previas + de este PRD), `→ Blocked` (`Block`, desde cualquier estado no bloqueado), + `Blocked → Active` (`Restore`) y `→ Deleted` (`Delete`, borrado lógico terminal, + bloqueado si hay perfiles activos). El **bloqueo temporal por intentos fallidos** + (FR-017) es un mecanismo aparte del `Blocked` administrativo. +* **FR-003** [Implementado] — El sistema debe permitir el alta pública (signup) + creando cuentas en `Pending` y activarlas al aprobar; una cuenta `Active` sin + perfil se representa como "lobby de onboarding". _(`SignupUserCommand`, + `TenantSignupRequest`.)_ +* **FR-004** [Implementado] — El sistema debe borrar lógicamente cuentas + anonimizando PII y anulando el hash de credenciales (GDPR). _(`UserAccount.Delete` + → `UserStatus.Deleted` + `SoftDeleteAsync` anonimiza en persistencia.)_ + +### 4.2 Autenticación + +* **FR-010** [Implementado] — El sistema debe almacenar la contraseña como hash + BCrypt generado en la API (el cliente nunca envía el hash), con una sola + credencial activa por usuario e historial inmutable de las anteriores. + _(`PasswordCredential`; `UserAccount.AddPassword` desactiva las previas.)_ +* **FR-011** [Parcial] — El sistema debe registrar proveedores de identidad + externos por inquilino, con estrategia inmutable, y delegar la autenticación + federada al adaptador correspondiente. **Estado real:** el contrato + `IIdpAuthAdapter`, la resolución por estrategia y el **adaptador OIDC real** + (Authorization Code + PKCE, validación estricta del `id_token` vía JWKS) están + **implementados y cableados en producción** (`IdpAuthAdapterFactorySetup`) para + la familia OIDC — Keycloak, GenericOidc, Azure AD, Okta, Zitadel, Auth0, Google + ([ADR-UMS-094](../../reference/architecture/adrs/UMS-094-idp-federado-adaptador-real-y-arnes-keycloak.es.md), + slice 1; [G-049](../../GAPS.md)). **Residual:** **SAML 2.0** y **LDAP** son + estrategias registradas **sin adaptador** (retornan `AUTH_012`); **WS-Federation + no existe** en el código. En entornos no-producción se usa `StubIdpAuthAdapter` + (credenciales `MOCK-*`). +* **FR-012** [Implementado] — El sistema debe impedir que un usuario federado + tenga una contraseña local activa, desactivándola al vincular la identidad + externa. _(`UserAccount.AddPassword` rechaza si `IdentityReference` está fijado.)_ +* **FR-013** [Implementado] — El sistema debe resolver dinámicamente, en tiempo de + login y desde configuración (`AUTH_USE_EXTERNAL_IDP`), si el inquilino usa + autenticación local o federada, forzando siempre local para el portal de gestión + interna. _(La resolución **por motor de reglas** con prioridad/fallback por suite + es FR-042, hoy [Implementado] — ver §4.5.)_ +* **FR-014** [Implementado] — El sistema debe enrolar múltiples métodos MFA por + usuario (TOTP, WebAuthn, SMS-OTP, Email-OTP) con ciclo `NotEnrolled → Enrolled → + Verified` y respetar la exigencia de MFA del inquilino (`MfaRequiredForAdmin`). + _(`MfaMethod`: `Totp`/`WebAuthn`/`SmsOtp`/`EmailOtp`; `UserAccount.EnrollMfa` / + `VerifyMfaChallenge`.)_ +* **FR-015** [Parcial] — El sistema debe, **cuando el inquilino lo active por + configuración** + (`AUTH_REFRESH_TOKEN_ENABLED`, jerarquía Global>Suite>Tenant>Module; fail-closed: + apagado ⇒ solo expiración), emitir un **refresh token** parametrizable por tenant + (vida, rotación en cada uso, detección de reuso, tope de renovaciones) y, al vencer + el grafo (`validUntil`), **regenerar el Grafo de Autorización COMPLETO** desde el + estado actual (permisos, roles, plantillas, overrides, feature flags, config + efectiva) al presentar un refresh válido. Ante reuso de un refresh consumido debe + invalidar toda la familia y forzar re-login. El refresh nunca aparece en logs, + proyecciones ni en el grafo; se guarda hasheado/cifrado. Revisa el límite «sin + refresh» de FR-034 y de **ADR-UMS-088**; cumple + **[ADR-UMS-091](../../reference/architecture/adrs/UMS-091-refresh-token-configurable-revocacion.es.md)** + (Aceptado, [G-034](../../GAPS.md)). Verificable con la historia + [fs-36](../02-diseno/historias-funcionales/fs-36-refresh-token-configurable.md). + **Estado real ([D-019](../../DECISIONS.md)):** coexisten **dos mecanismos**. (a) El + **refresh de sesión por cookie deslizante** (`POST /auth/refresh`, + `RefreshSessionCommand`) **está implementado y en uso**: regenera el Grafo de + Autorización completo en espejo del login y emite un graph JWT con los permisos + vigentes, con corte en caliente si el inquilino/usuario deja de estar activo. (b) + El **refresh token opaco** (`POST /auth/refresh-token`, grant de ADR-UMS-091, + regeneración completa + rotación + detección de reuso) **está implementado pero + apagado por defecto** (`AuthRefreshTokenEnabled=false`, `RefreshTokenPolicyProvider` + fail-closed): su **activación es opt-in por inquilino** — de ahí el [Parcial]. +* **FR-016** [Implementado] — El sistema debe permitir **revocar** refresh tokens + (logout real / invalidación de sesión, por usuario, por cuenta bloqueada o + suspendida y por cambio crítico de permisos): un refresh revocado no puede + regenerar grafo y la siguiente renovación falla exigiendo re-autenticación + completa. **Alcance explícito:** la revocación actúa sobre la capacidad de + **renovar**; un grafo ya emitido sigue válido hasta su `validUntil` salvo que el + cliente valide sesión por operación. Todo evento de revocación se audita + (append-only, acotado por inquilino). _(`/auth/logout` → + `IRefreshTokenStore.RevokeAllForUserAsync`.)_ Verificable con + [fs-37](../02-diseno/historias-funcionales/fs-37-revocacion-refresh-token.md). +* **FR-017** [Implementado] — El sistema debe **bloquear temporalmente** una cuenta + tras alcanzar el máximo de intentos de autenticación fallidos, de forma + **auto-expirable** (sin intervención administrativa) y **distinta** del bloqueo + permanente (`Blocked`). El dominio (`UserAccount.RecordAuthenticationAttempt` / + `IsLockedOut`) lleva `FailedLoginAttempts` y `LockedUntil`; los parámetros + (`MAX_LOGIN_ATTEMPTS`, `ACCOUNT_LOCKOUT_DURATION_MINUTES`) salen de la config + efectiva. Enforcement: en `/auth/login` una cuenta bloqueada devuelve + **HTTP 423 (`AUTH_017`)** con mensaje accionable; en el **endpoint de cliente** + (público y anónimo) colapsa a **401 genérico** e indistinguible de credenciales + inválidas (anti-enumeración, [G-053](../../GAPS.md)). Cumple + **[ADR-UMS-095](../../reference/architecture/adrs/UMS-095-bloqueo-cuenta-intentos-fallidos.es.md)**. + +### 4.3 Multi-tenancy + +* **FR-020** [Implementado] — El sistema debe registrar inquilinos con `Code` + globalmente único, gestionar su ciclo de vida y bloquear la autenticación de los + usuarios de un inquilino suspendido. Estados reales (`TenantStatus`): `Active`, + `Suspended`, `Archived`. Transiciones: `Active ↔ Suspended` (`Suspend` / + `Activate`) y `Archived` como estado **terminal** (no se puede suspender ni + reactivar). _(El PRD previo decía «Inactive»; el estado real es `Archived`.)_ +* **FR-021** [Implementado] — El sistema debe gestionar ramas por inquilino con + código único y geocercado válido. _(`Tenant.AddBranch` con `geofencingMetadata`.)_ +* **FR-022** [Implementado] — El sistema debe aislar todos los datos por inquilino + mediante _global query filters_ de EF Core sobre `OrganizationId` en la capa de + aplicación (mecanismo primario y suficiente); el _failsafe_ RLS a nivel de base de + datos no está activo con PostgreSQL ([G-020](../../GAPS.md)). Toda operación + cross-tenant se audita y se permite solo a `INTERNAL_ADMIN`. + +### 4.4 Autorización + +* **FR-030** [Parcial] — El sistema debe modelar la topología funcional por suite + (`SystemSuite → Module → MenuNode`, árbol recursivo de profundidad variable, ADR-0090) y exponer acciones granulares + (`Action`: READ, WRITE, EXPORT…). **Residual:** el rediseño a un **árbol de nodos + recursivo** (`MenuNode` auto-referente, profundidad variable, N:M + funcionalidad↔opción) está en curso ([D-009](../../DECISIONS.md), cumple ADR-0090; + [G-029](../../GAPS.md)). +* **FR-031** [Implementado] — El sistema debe mantener un catálogo de roles por + inquilino y suite, con jerarquía opcional acíclica. +* **FR-032** [Implementado] — El sistema debe definir plantillas de permisos + reutilizables (globales o por inquilino) que mapean acciones a objetivos, + publicables solo con al menos un ítem. +* **FR-033** [Implementado] — El sistema debe materializar perfiles (usuario + rol + * rama) que derivan permisos efectivos desde plantillas publicadas, con overrides + controlados (allow/deny/neutral) sin mutar la plantilla fuente. _(`Profile`, + `PermissionEffect` Allow/Deny/Neutral; modelo de perfiles fijado en + [D-011](../../DECISIONS.md).)_ +* **FR-034** [Implementado] — El sistema debe construir, tras autenticar, un + **Grafo de Autorización** inmutable y autocontenido (contexto, acciones, accesos + de menú, permisos de dominio, feature flags, configuración efectiva y scopes) con + vigencia `validUntil = generatedAt + sessionTimeoutMinutes`. Cada instancia del + grafo es inmutable; **FR-015** revisa el modelo de vigencia para permitir + **regenerar** un grafo nuevo por refresh (no extender el previo) en vez de exigir + re-autenticación completa al expirar. +* **FR-035** [Parcial] — El sistema debe resolver la autorización por precedencia + (`Deny > Allow`, `Override > Template`, ausencia = no concedido) y traducir los + permisos a scopes OAuth2 para validación rápida en el cliente. **Residual:** la + resolución multi-fuente (perfil + plantilla + regla) con _deny-wins_ y ciclos del + grafo efectivo está solo **parcialmente probada** ([G-085](../../GAPS.md)). + +### 4.5 Configuración + +* **FR-040** [Implementado] — El sistema debe persistir configuración jerárquica + (`code/value/description`) con alcance derivado, ciclo `Draft → Published → + Archived`, versionado y banderas de herencia y cifrado. Alcances reales + (`ConfigurationScope`): `Global`, `Suite`, `Tenant`, `Module` y también `User`; + la cascada de resolución usa `Global > Suite > Tenant > Module`. +* **FR-041** [Implementado] — El sistema debe gestionar feature flags acotados a + una suite (nunca globales), con criterios dinámicos evaluados con lógica `OR` + intra-tipo y `AND` entre tipos, postura _fail-closed_ y registro de cada + evaluación. +* **FR-042** [Implementado] — El sistema debe registrar reglas de resolución de IdP por + inquilino y suite, con prioridad y fallback encadenado. **Estado real (2026-07-21, + épica [G-096]/[ADR-UMS-097](../../reference/architecture/adrs/UMS-097-resolucion-idp-prioridad-suite-fallback.es.md)):** + el login consume el motor de reglas (`IdpConfigurationSelector`) por **prioridad, + suite y dominio**, unificado con el endpoint OIDC; el **fallback encadenado** recorre + `FallbackToId` **solo ante indisponibilidad de infra**, nunca ante fallo de + credenciales (fail-closed, anti credential-spraying), con detección de ciclos, tope y + auditoría por intento (cadena agotada → 503). Probado a fondo en unit (selector, + clasificador, cadena). **Residuales (gaps propios):** la **fuente de la suite + pre-autenticación** aún no existe → el scoping por suite está cableado pero no + alimentado ([G-110](../../GAPS.md)); el **arnés Keycloak real** (validar el adapter OIDC + contra un IdP real, deuda de ADR-UMS-094) está diferido ([G-109](../../GAPS.md)); y la + clasificación 4xx/5xx del token endpoint ([G-108](../../GAPS.md)). + +### 4.6 Aprobaciones y cumplimiento + +> **Nota de cobertura (§4.6):** el backend (dominio + handlers) de Aprobaciones y +> cumplimiento está implementado; **carece de esquemas/pantallas en el frontend** +> (no hay contexto `approvals` en `ums.web-app`), de **pruebas de contrato** +> ([G-082](../../GAPS.md)) y de **E2E de UI** para los flujos irreversibles +> ([G-084](../../GAPS.md)). El estado [Implementado] es a nivel de capacidad de dominio. + +* **FR-050** [Implementado] — El sistema debe permitir definir flujos de aprobación + por inquilino con su checklist de documentos obligatorios, e instanciar + solicitudes (`Pending → Approved/Rejected`, irreversibles) que no puede aprobar el + propio solicitante. _(`ApprovalWorkflow`, `ApprovalRequest`.)_ +* **FR-051** [Implementado] — El sistema debe clasificar documentos por criticidad + y exigir, para los críticos, exactamente una política de enforcement activa + (bloqueo/degradación ante vencimiento) con período de gracia. _(`DocumentType`, + `DocumentCriticity`, `AccessEnforcementPolicy`.)_ +* **FR-052** [Implementado] — El sistema debe gestionar la carga y verificación de + documentos de usuario (`PendingReview → Valid/Rejected → Expired → ReUpload`) con + integridad por checksum, y notificar por umbrales configurables + (Email/SMS/portal/push). _(`UserDocument`, `DocumentStatus`, `NotificationRule`.)_ +* **FR-053** [Implementado] — El sistema debe aplicar políticas de enforcement que + bloqueen, degraden o restrinjan el acceso ante incumplimiento (documento crítico + vencido). _(`AccessEnforcementPolicy`, `AccessEnforcementAction`.)_ + +### 4.7 Gobernanza de accesos (IGA) + +> **Nota de cobertura (§4.7):** IGA está implementado en el backend (dominio + +> handlers + un E2E de integración del efecto); **carece de frontend y de pruebas +> de contrato** ([G-087](../../GAPS.md)). El estado [Implementado] es a nivel de +> capacidad de dominio/aplicación. + +* **FR-060** [Implementado] — El sistema debe gestionar promociones de rol mediante + una máquina de estados auditada y **aplicar el cambio de rol** al ejecutarse. + Máquina real (`RolePromotionStatus`): `Draft → PendingEligibilityCheck → + PendingManagerApproval → (PendingSecurityReview si RiskScore ≥ umbral) → Approved + → Executed → Verified`, con cortes terminales `Rejected` y `Cancelled`. La + **elegibilidad es fail-closed** (`PendingEligibilityCheck`: no elegible ⇒ + `Rejected`, nunca avanza — omitido en versiones previas de este PRD). El **efecto** + de `Execute` (reasignar `Profile.RoleId` del objetivo) — que antes **no existía** + ([G-093](../../GAPS.md)) — hoy **se aplica con entrega garantizada por Transactional + Outbox**: `ExecuteRolePromotionCommandHandler` publica el evento de integración + antes de `SaveEntitiesAsync` y `RolePromotionRoleAssignmentConsumer` lo + materializa con reintentos/dead-letter, nunca en descarte silencioso + ([ADR-UMS-096](../../reference/architecture/adrs/UMS-096-iga-sod-ejecutor-y-efecto-promocion.es.md), + [D-020](../../DECISIONS.md), [G-094](../../GAPS.md)). +* **FR-061** [Implementado] — El sistema debe calcular un análisis de impacto/riesgo + tóxico (`RiskScore` 0–100, permisos nuevos/removidos, conflictos) inmutable por + solicitud. _(`RiskScore` se congela en `Submit` — INV-RPR2.)_ +* **FR-062** [Implementado] — El sistema debe evaluar la elegibilidad de promoción + (sin incidencias de cumplimiento, desempeño ≥ 3.0, tiempo mínimo en el nivel — + 6/12/18/24 meses por salto, nivel `Principal` no promocionable) y **garantizar + segregación de funciones (SoD)**. SoD real endurecida (INV-RPR3, + [ADR-UMS-096](../../reference/architecture/adrs/UMS-096-iga-sod-ejecutor-y-efecto-promocion.es.md)): + `solicitante ≠ objetivo`; `aprobador ≠ objetivo ≠ solicitante`; `revisor ≠ objetivo + ≠ aprobador`; **`ejecutor ≠ objetivo ≠ aprobador ≠ revisor`**; `verificador ≠ + ejecutor ≠ objetivo` (más estricta que el «aprobador ≠ objetivo ≠ auditor» de + versiones previas; [G-091](../../GAPS.md)). _(`RoleMaturityStatus.EvaluateEligibility`, + `RolePromotionRequest` invariantes.)_ + +### 4.8 Auditoría + +* **FR-070** [Implementado] — El sistema debe registrar una traza cronológica, + inmutable (append-only) y no repudiable de todo evento crítico, capturando actor, + instante, cambio, resultado, entidad e inquilino afectados. _(`AuditRecord` + append-only; endurecimiento de no-repudio/aislamiento en [G-040](../../GAPS.md).)_ +* **FR-071** [Implementado] — El sistema debe recibir los eventos de auditoría vía + Transactional Outbox desde todos los contextos, permitir su consulta acotada por + inquilino e impedir la lectura cruzada. _(`AuditTrailOutboxSink` → + `AuditTrailPersistenceConsumer`, entrega post-commit por el outbox EF; + [G-040](../../GAPS.md)/[G-066](../../GAPS.md).)_ +* **FR-072** [Implementado] — El sistema debe desinfectar los metadatos serializados + eliminando datos sensibles (hashes, PIN, llaves) antes de persistir. + _(`RecordAuditCommandValidator`.)_ + +## 5. Requisitos No Funcionales + +* **NFR-Seguridad:** contraseñas y secretos nunca en logs, proyecciones ni grafo; + columnas sensibles cifradas en reposo; PII redactada en logs; sin GUIDs crudos en + la UI; UPDATE/DELETE de auditoría denegado en cadenas estándar (no repudio). + El modelo de amenazas formal y la gestión de secretos son [G-001](../../GAPS.md). +* **NFR-Multitenancy:** aislamiento en capa de aplicación por _global query filters_ + de EF Core sobre `OrganizationId` (primario y suficiente); el _failsafe_ RLS a + nivel de base de datos no está activo con PostgreSQL ([G-020](../../GAPS.md)); toda + lectura cross-tenant bloqueada y auditada. +* **NFR-Rendimiento:** CQRS en la capa de aplicación con API REST-only (lectura por + `GET`, escritura por `POST/PUT/PATCH/DELETE`); emisión y validación del grafo por + pipeline interno de baja latencia; el gateway aplica límites de complejidad, + timeouts y rate limiting. +* **NFR-Confiabilidad:** consistencia eventual por Outbox sin 2PC; idempotencia ante + reintentos mediante middleware de `Idempotency-Key`. +* **NFR-Observabilidad:** trazas y métricas con OpenTelemetry; propagación de + contexto de correlación; errores accionables con id de diagnóstico. +* **NFR-Cumplimiento:** bitácora inmutable de transiciones críticas; fechas en UTC. +* **NFR-Mensajería:** eventos de integración por bus como puerto inyectable; + auditoría como suscriptor downstream. + +**Contra-métrica:** ninguna optimización de rendimiento puede introducir una ruta +que lea u opere datos fuera del inquilino del solicitante. + +## 6. Trazabilidad y Decisiones + +Las decisiones técnicas de UMS residen en su corpus de ADRs importado +([reference/architecture/](../../reference/architecture/index.md), ADR-0050 a +ADR-0083). Ejemplos con impacto directo en este PRD: motor del Grafo de +Autorización (ADR-UMS-088), resolución dinámica del método de autenticación +(ADR-UMS-072), alcance de feature flags (ADR-UMS-068), audit trail inmutable (ADR-UMS-052), +guardas de dependencia (ADR-UMS-079) y persistencia PostgreSQL (ADR-UMS-089). + +Capacidades reconciliadas en esta versión y su ADR/decisión de respaldo: +**bloqueo temporal de cuenta** ([ADR-UMS-095](../../reference/architecture/adrs/UMS-095-bloqueo-cuenta-intentos-fallidos.es.md), +FR-017); **refresh token configurable + revocación** ([ADR-UMS-091](../../reference/architecture/adrs/UMS-091-refresh-token-configurable-revocacion.es.md), +[D-012](../../DECISIONS.md)/[D-019](../../DECISIONS.md), FR-015/016); **autoridad de +escritura del operador** ([ADR-UMS-092](../../reference/architecture/adrs/UMS-092-autoridad-escritura-operador-inquilinos-gestionados.es.md), +[D-013](../../DECISIONS.md)); **máquina de estados IGA** ([ADR-UMS-093](../../reference/architecture/adrs/UMS-093-iga-maquina-estados-promocion-rol.es.md), +[D-014](../../DECISIONS.md), FR-060/061/062); **adaptador OIDC real** +([ADR-UMS-094](../../reference/architecture/adrs/UMS-094-idp-federado-adaptador-real-y-arnes-keycloak.es.md), +[D-015](../../DECISIONS.md), FR-011); **SoD endurecida + efecto de promoción** +([ADR-UMS-096](../../reference/architecture/adrs/UMS-096-iga-sod-ejecutor-y-efecto-promocion.es.md), +[D-020](../../DECISIONS.md), FR-060/062). + +Estos ADRs son de origen importado; su **retrazado a ADRs aceptados de +`evolith-core`** (S-06) está registrado como [G-012](../../GAPS.md). Los criterios de +aceptación verificables por FR se elaborarán en las historias (épicas e +historias), registrado como [G-009](../../GAPS.md). + +Dos decisiones locales fijan el estado actual del transporte y la persistencia: la +API es **REST-only** ([D-007](../../DECISIONS.md); la lectura por `GET` y la escritura +por `POST/PUT/PATCH/DELETE`, con el CQRS confinado a la capa de aplicación) y la +persistencia es **PostgreSQL únicamente** ([D-008](../../DECISIONS.md), que hace +cumplir ADR-UMS-089; `InMemory` solo en tests). + +## Historial de Cambios + +| Versión | Fecha | Autor | Descripción | +| :--- | :--- | :--- | :--- | +| 0.2.0 | 2026-07-21 | BeyondNet S.A.C. | **Reconciliación producto-completo contra el código fuente** (fuente de verdad, SD-05), cerrando la parte PRD de [G-075]. Reencuadre de MVP/piloto a **producto completo**: se conservan todas las capacidades (FR-001…FR-072) y cada FR anota su estado real ([Implementado]/[Parcial]/[Planificado]) con evidencia en código. Nuevo/corregido: **FR-017** bloqueo temporal por intentos fallidos (ADR-UMS-095, AUTH_017→423 en `/login`, 401 genérico en cliente); **FR-011** [Parcial] — adaptador OIDC real cableado en producción (ADR-UMS-094/G-049), SAML2/LDAP como estrategias sin adaptador, **WS-Federation retirada** (sin respaldo en código); **FR-015/016** dos mecanismos de refresh (cookie deslizante `/refresh` que regenera el grafo, D-019; token opaco `/refresh-token` opt-in, apagado por defecto, ADR-UMS-091); **FR-002** estado `Denied` desde `Pending`; **FR-020** ciclo de inquilino `Active↔Suspended`/`Archived` (antes «Inactive»); **FR-060** máquina IGA con `PendingEligibilityCheck` (fail-closed) y `Cancelled`; **FR-062** SoD endurecida (`ejecutor ≠ objetivo ≠ aprobador ≠ revisor`) y efecto de promoción por Transactional Outbox (ADR-UMS-096/D-020, G-091/G-093/G-094); **FR-030/035/042** marcados [Parcial]. El documento ahora **se reconcilia contra el código**, no deriva de la doc de dominio | +| 0.1.2 | 2026-07-21 | BeyondNet S.A.C. | Reconciliación con la fuente ([G-075]): las capacidades MVP (US-001…US-012: identidad/tenant, autenticación local, topología, plantillas, perfiles, configuración) están **verificadas y gateadas en CI** — E2E de caminos MVP + subconjunto de integración (ver [TE-09](../../reference/architecture/blueprints/technical-enablers/te-09-definition-of-release-piloto.md), Definition of Release del piloto). Ajuste FR-013 confirmado en vivo: login local del portal de gestión (BEYONDNET) operativo | +| 0.1.1 | 2026-07-14 | BeyondNet S.A.C. | Alineación a API REST-only + PostgreSQL-only (D-007, D-008): NFR-Rendimiento sin GraphQL, FR-022/NFR-Multitenancy por global query filters con RLS no activo (G-020) | +| 0.1.0 | 2026-07-13 | BeyondNet S.A.C. | Borrador inicial del PRD brownfield, derivado de la documentación de dominio importada. Cierra la ausencia registrada en G-006 | + +--- + +

+ © BeyondNet S.A.C. · RUC 20100412447 · Operador Logístico Aduanero desde 1978 +

diff --git a/docs/governance/project-es/api-aggregate-implementation-tracker.md b/docs/governance/project-es/api-aggregate-implementation-tracker.md index b50e1d2c..a8803328 100644 --- a/docs/governance/project-es/api-aggregate-implementation-tracker.md +++ b/docs/governance/project-es/api-aggregate-implementation-tracker.md @@ -90,7 +90,7 @@ Este documento registra el estado actual de implementacion de la API UMS por agr - Capacidades API faltantes: - ciclo de vida de modules - ciclo de vida de menus - - ciclo de vida de submenus + - ciclo de vida de nodos (`MenuNode` recursivo, ADR-0090) - ciclo de vida de options - ciclo de vida de actions - ciclo de vida de app settings diff --git a/docs/governance/project-es/functional-story-gap-tracker.md b/docs/governance/project-es/functional-story-gap-tracker.md index 9fd60982..f9dadb8a 100644 --- a/docs/governance/project-es/functional-story-gap-tracker.md +++ b/docs/governance/project-es/functional-story-gap-tracker.md @@ -20,7 +20,7 @@ Este documento mantiene una vista dinamica de lo que ya esta implementado, lo qu | Estado | Cantidad | IDs de historia | |---|---:|---| -| Implementado / utilizable | 25 | [FS-01](../requirements-es/functional-stories/fs-01-user-authentication.md), [FS-02](../requirements-es/functional-stories/fs-02-create-authorization-template.md), [FS-03](../requirements-es/functional-stories/fs-03-register-organization.md), [FS-04](../requirements-es/functional-stories/fs-04-register-system-topology.md), [FS-05](../requirements-es/functional-stories/fs-05-create-profile-manual-template.md), [FS-06](../requirements-es/functional-stories/fs-06-auto-assign-template.md), [FS-07](../requirements-es/functional-stories/fs-07-visual-graph-resolver.md), [FS-08](../requirements-es/functional-stories/fs-08-hosted-login-redirection.md), [FS-09](../requirements-es/functional-stories/fs-09-mfa-passwordless-adaptive-auth.md), [FS-10](../requirements-es/functional-stories/fs-10-external-b2b-access-request-approval.md), [FS-11](../requirements-es/functional-stories/fs-11-user-document-upload.md), [FS-13](../requirements-es/functional-stories/fs-13-hierarchical-config.md), [FS-14](../requirements-es/functional-stories/fs-14-delegated-management.md), [FS-15](../requirements-es/functional-stories/fs-15-notification-rules.md), [FS-16](../requirements-es/functional-stories/fs-16-access-enforcement-policy.md), [FS-17](../requirements-es/functional-stories/fs-17-maintain-system-roles.md), [FS-18](../requirements-es/functional-stories/fs-18-manage-local-user-password.md), [FS-19](../requirements-es/functional-stories/fs-19-admin-password-reset-validity-management.md), [FS-20](../requirements-es/functional-stories/fs-20-system-parameter-management.md), [FS-21](../requirements-es/functional-stories/fs-21-tenant-signup-request-approval.md), [FS-22](../requirements-es/functional-stories/fs-22-user-signup-request-approval.md), [FS-24](../requirements-es/functional-stories/fs-24-profile-request-approval.es.md), [FS-25](../requirements/functional-stories/fs-25-ddd-domain-resource-hierarchy.es.md), [FS-26](../requirements/functional-stories/fs-26-auth-graph-preview-from-profile.es.md), [FS-27](../requirements/functional-stories/fs-27-state-change-consistency-broken-rules.es.md)] | +| Implementado / utilizable | 25 | [FS-01](../requirements-es/functional-stories/fs-01-user-authentication.md), [FS-02](../requirements-es/functional-stories/fs-02-create-authorization-template.md), [FS-03](../requirements-es/functional-stories/fs-03-register-organization.md), [FS-04](../requirements-es/functional-stories/fs-04-register-system-topology.md), [FS-05](../requirements-es/functional-stories/fs-05-create-profile-manual-template.md), [FS-06](../requirements-es/functional-stories/fs-06-auto-assign-template.md), [FS-07](../requirements-es/functional-stories/fs-07-visual-graph-resolver.md), [FS-08](../requirements-es/functional-stories/fs-08-hosted-login-redirection.md), [FS-09](../requirements-es/functional-stories/fs-09-mfa-passwordless-adaptive-auth.md), [FS-10](../requirements-es/functional-stories/fs-10-external-b2b-access-request-approval.md), [FS-11](../requirements-es/functional-stories/fs-11-user-document-upload.md), [FS-13](../requirements-es/functional-stories/fs-13-hierarchical-config.md), [FS-14](../requirements-es/functional-stories/fs-14-delegated-management.md), [FS-15](../requirements-es/functional-stories/fs-15-notification-rules.md), [FS-16](../requirements-es/functional-stories/fs-16-access-enforcement-policy.md), [FS-17](../requirements-es/functional-stories/fs-17-maintain-system-roles.md), [FS-18](../requirements-es/functional-stories/fs-18-manage-local-user-password.md), [FS-19](../requirements-es/functional-stories/fs-19-admin-password-reset-validity-management.md), [FS-20](../requirements-es/functional-stories/fs-20-system-parameter-management.md), [FS-21](../requirements-es/functional-stories/fs-21-tenant-signup-request-approval.md), [FS-22](../requirements-es/functional-stories/fs-22-user-signup-request-approval.md), [FS-24](../requirements-es/functional-stories/fs-24-profile-request-approval.es.md), [FS-38](../requirements/functional-stories/fs-38-ddd-domain-resource-hierarchy.es.md), [FS-39](../requirements/functional-stories/fs-39-auth-graph-preview-from-profile.es.md), [FS-40](../requirements/functional-stories/fs-40-state-change-consistency-broken-rules.es.md)] | | Parcial | 0 | — | | Diferido | 9 | [FS-12](../requirements-es/functional-stories/fs-12-role-promotion-process.md), [FS-28](../requirements-es/functional-stories/fs-28-access-review-campaigns.md), [FS-29](../requirements-es/functional-stories/fs-29-entitlement-packages.md), [FS-30](../requirements-es/functional-stories/fs-30-provisioning-deprovisioning-connectors.md), [FS-31](../requirements-es/functional-stories/fs-31-privileged-access-time-bound-elevation.md), [FS-32](../requirements-es/functional-stories/fs-32-operational-reliability-guardrails.md), [FS-33](../requirements-es/functional-stories/fs-33-authorization-graph-explorer.md), [FS-34](../requirements-es/functional-stories/fs-34-business-semantic-access-packages.md), [FS-35](../requirements-es/functional-stories/fs-35-continuous-access-health.md) | diff --git a/docs/governance/project/api-aggregate-implementation-tracker.md b/docs/governance/project/api-aggregate-implementation-tracker.md index 3ccf9b3d..5cd87381 100644 --- a/docs/governance/project/api-aggregate-implementation-tracker.md +++ b/docs/governance/project/api-aggregate-implementation-tracker.md @@ -90,7 +90,7 @@ This document captures the current implementation status of the UMS API by aggre - Missing API capabilities: - module lifecycle - menu lifecycle - - submenu lifecycle + - node lifecycle (recursive `MenuNode`, ADR-0090) - option lifecycle - action lifecycle - app setting lifecycle diff --git a/docs/governance/project/functional-story-gap-tracker.md b/docs/governance/project/functional-story-gap-tracker.md index 944b7e64..d8979d99 100644 --- a/docs/governance/project/functional-story-gap-tracker.md +++ b/docs/governance/project/functional-story-gap-tracker.md @@ -20,7 +20,7 @@ This document keeps a dynamic view of what is already implemented, what is parti | Status | Count | Story IDs | |---|---:|---| -| Implemented / usable | 25 | [FS-01](../requirements/functional-stories/fs-01-user-authentication.md), [FS-02](../requirements/functional-stories/fs-02-create-authorization-template.md), [FS-03](../requirements/functional-stories/fs-03-register-organization.md), [FS-04](../requirements/functional-stories/fs-04-register-system-topology.md), [FS-05](../requirements/functional-stories/fs-05-create-profile-manual-template.md), [FS-06](../requirements/functional-stories/fs-06-auto-assign-template.md), [FS-07](../requirements/functional-stories/fs-07-visual-graph-resolver.md), [FS-08](../requirements/functional-stories/fs-08-hosted-login-redirection.md), [FS-09](../requirements/functional-stories/fs-09-mfa-passwordless-adaptive-auth.md), [FS-10](../requirements/functional-stories/fs-10-external-b2b-access-request-approval.md), [FS-11](../requirements/functional-stories/fs-11-user-document-upload.md), [FS-13](../requirements/functional-stories/fs-13-hierarchical-config.md), [FS-14](../requirements/functional-stories/fs-14-delegated-management.md), [FS-15](../requirements/functional-stories/fs-15-notification-rules.md), [FS-16](../requirements/functional-stories/fs-16-access-enforcement-policy.md), [FS-17](../requirements/functional-stories/fs-17-maintain-system-roles.md), [FS-18](../requirements/functional-stories/fs-18-manage-local-user-password.md), [FS-19](../requirements/functional-stories/fs-19-admin-password-reset-validity-management.md), [FS-20](../requirements/functional-stories/fs-20-system-parameter-management.md), [FS-21](../requirements/functional-stories/fs-21-tenant-signup-request-approval.md), [FS-22](../requirements/functional-stories/fs-22-user-signup-request-approval.md), [FS-24](../requirements/functional-stories/fs-24-profile-request-approval.md), [FS-25](../requirements/functional-stories/fs-25-ddd-domain-resource-hierarchy.md), [FS-26](../requirements/functional-stories/fs-26-auth-graph-preview-from-profile.md), [FS-27](../requirements/functional-stories/fs-27-state-change-consistency-broken-rules.md) | +| Implemented / usable | 25 | [FS-01](../requirements/functional-stories/fs-01-user-authentication.md), [FS-02](../requirements/functional-stories/fs-02-create-authorization-template.md), [FS-03](../requirements/functional-stories/fs-03-register-organization.md), [FS-04](../requirements/functional-stories/fs-04-register-system-topology.md), [FS-05](../requirements/functional-stories/fs-05-create-profile-manual-template.md), [FS-06](../requirements/functional-stories/fs-06-auto-assign-template.md), [FS-07](../requirements/functional-stories/fs-07-visual-graph-resolver.md), [FS-08](../requirements/functional-stories/fs-08-hosted-login-redirection.md), [FS-09](../requirements/functional-stories/fs-09-mfa-passwordless-adaptive-auth.md), [FS-10](../requirements/functional-stories/fs-10-external-b2b-access-request-approval.md), [FS-11](../requirements/functional-stories/fs-11-user-document-upload.md), [FS-13](../requirements/functional-stories/fs-13-hierarchical-config.md), [FS-14](../requirements/functional-stories/fs-14-delegated-management.md), [FS-15](../requirements/functional-stories/fs-15-notification-rules.md), [FS-16](../requirements/functional-stories/fs-16-access-enforcement-policy.md), [FS-17](../requirements/functional-stories/fs-17-maintain-system-roles.md), [FS-18](../requirements/functional-stories/fs-18-manage-local-user-password.md), [FS-19](../requirements/functional-stories/fs-19-admin-password-reset-validity-management.md), [FS-20](../requirements/functional-stories/fs-20-system-parameter-management.md), [FS-21](../requirements/functional-stories/fs-21-tenant-signup-request-approval.md), [FS-22](../requirements/functional-stories/fs-22-user-signup-request-approval.md), [FS-24](../requirements/functional-stories/fs-24-profile-request-approval.md), [FS-38](../requirements/functional-stories/fs-38-ddd-domain-resource-hierarchy.md), [FS-39](../requirements/functional-stories/fs-39-auth-graph-preview-from-profile.md), [FS-40](../requirements/functional-stories/fs-40-state-change-consistency-broken-rules.md) | | Partial | 0 | — | | Deferred | 9 | [FS-12](../requirements/functional-stories/fs-12-role-promotion-process.md), [FS-28](../requirements/functional-stories/fs-28-access-review-campaigns.md), [FS-29](../requirements/functional-stories/fs-29-entitlement-packages.md), [FS-30](../requirements/functional-stories/fs-30-provisioning-deprovisioning-connectors.md), [FS-31](../requirements/functional-stories/fs-31-privileged-access-time-bound-elevation.md), [FS-32](../requirements/functional-stories/fs-32-operational-reliability-guardrails.md), [FS-33](../requirements/functional-stories/fs-33-authorization-graph-explorer.md), [FS-34](../requirements/functional-stories/fs-34-business-semantic-access-packages.md), [FS-35](../requirements/functional-stories/fs-35-continuous-access-health.md) | diff --git a/docs/governance/requirements-es/functional-stories/fs-01-user-authentication.md b/docs/governance/requirements-es/functional-stories/fs-01-user-authentication.md index 9183b9a7..def732dd 100644 --- a/docs/governance/requirements-es/functional-stories/fs-01-user-authentication.md +++ b/docs/governance/requirements-es/functional-stories/fs-01-user-authentication.md @@ -1,4 +1,4 @@ -# Functional Story 1: Autenticación Corporativa vía IdP Externo +# Historia Funcional 1: Autenticación Corporativa vía IdP Externo ## 1. Propósito de Negocio @@ -13,12 +13,13 @@ Los usuarios corporativos necesitan acceder a los sistemas cliente usando el pro | **Usuario Corporativo** | Intenta iniciar sesión en un sistema cliente. | | **Proveedor de Identidad Externo** | Confirma la identidad corporativa del usuario. | | **UMS** | Valida la identidad contra registros activos y establece la sesión de aplicación. | -| **Administrador TI** | Puede usar acceso de emergencia cuando el proveedor externo no estáá disponible. +| **Administrador TI** | Puede usar acceso de emergencia cuando el proveedor externo no estáá disponible. | + ## 3. Precondiciones de Negocio -- El usuario existe en UMS y estáá vinculado a una referencia de identidad corporativa válida. -- La cuenta del usuario estáá activa. -- La organización tiene configurado un proveedor de identidad. +* El usuario existe en UMS y estáá vinculado a una referencia de identidad corporativa válida. +* La cuenta del usuario estáá activa. +* La organización tiene configurado un proveedor de identidad. --- @@ -71,17 +72,17 @@ Si el usuario existe pero estáá suspendido o terminado, el sistema bloquea el ## 8. Requisitos Técnicos -- Soportar OAuth 2.0 Authorization Code con PKCE para autenticación externa. -- Validar tokens firmados y claims obligatorios de identidad. -- Vincular el claim de identidad externa con la referencia de identidad en UMS. -- Establecer una sesión segura mediante cookies HTTP-only, SameSite o el mecanismo aprobado. -- Retornar fallo de autorización cuando la identidad no estáá vinculada o activa. -- Emitir eventos inmutables de auditoría para fallos y advertencias. +* Soportar OAuth 2.0 Authorization Code con PKCE para autenticación externa. +* Validar tokens firmados y claims obligatorios de identidad. +* Vincular el claim de identidad externa con la referencia de identidad en UMS. +* Establecer una sesión segura mediante cookies HTTP-only, SameSite o el mecanismo aprobado. +* Retornar fallo de autorización cuando la identidad no estáá vinculada o activa. +* Emitir eventos inmutables de auditoría para fallos y advertencias. --- ## 9. Trazabilidad -- Entidades: `USER_ACCOUNT`, `IDP_CONFIGURATION`, `PROFILE` -- ADRs: ADR-0020, ADR-0022, ADR-0026 -- Historias relacionadas: FS-08, FS-09 +* Entidades: `USER_ACCOUNT`, `IDP_CONFIGURATION`, `PROFILE` +* ADRs: ADR-0020, ADR-0022, ADR-0026 +* Historias relacionadas: FS-08, FS-09 diff --git a/docs/governance/requirements-es/functional-stories/fs-02-create-authorization-template.md b/docs/governance/requirements-es/functional-stories/fs-02-create-authorization-template.md index 18dce377..da149f54 100644 --- a/docs/governance/requirements-es/functional-stories/fs-02-create-authorization-template.md +++ b/docs/governance/requirements-es/functional-stories/fs-02-create-authorization-template.md @@ -1,4 +1,4 @@ -# Functional Story 2: Crear e Instanciar Plantilla de Autorización +# Historia Funcional 2: Crear e Instanciar Plantilla de Autorización ## 1. Propósito de Negocio @@ -9,11 +9,13 @@ Los administradores necesitan plantillas de autorización reutilizables para gob | Actor | Responsabilidad | | :--- | :--- | | **Administrador Global de TI** | Crea y mantiene plantillas reutilizables de autorización. | -| **Administrador de Tenant/Sistema** | Asigna plantillas aprobadas a perfiles cuando estáá permitido. | ## 3. Precondiciones de Negocio +| **Administrador de Tenant/Sistema** | Asigna plantillas aprobadas a perfiles cuando estáá permitido. | -- La topología del sistema destino estáá registrada. -- Las acciones disponibles estáán definidas. -- El administrador tiene permiso para gestionar plantillas. +## 3. Precondiciones de Negocio + +* La topología del sistema destino estáá registrada. +* Las acciones disponibles estáán definidas. +* El administrador tiene permiso para gestionar plantillas. ## 4. Flujo Funcional Principal @@ -51,14 +53,14 @@ Si una nueva versión de plantilla entra en conflicto con sobrescrituras locales ## 8. Requisitos Técnicos -- Persistir plantillas y permisos usando `PERMISSION_TEMPLATE`, `PROFILE` y `PROFILE_PERMISSION`. -- Validar integridad de jerarquía de recursos antes de publicar. -- Invalidar caché del grafo de autorización compilado para usuarios afectados tras asignación o actualización. -- Emitir eventos de auditoría por creación, publicación, asignación y cambio de versión. -- Preservar linaje de versionado semántico. +* Persistir plantillas y permisos usando `PERMISSION_TEMPLATE`, `PROFILE` y `PROFILE_PERMISSION`. +* Validar integridad de jerarquía de recursos antes de publicar. +* Invalidar caché del grafo de autorización compilado para usuarios afectados tras asignación o actualización. +* Emitir eventos de auditoría por creación, publicación, asignación y cambio de versión. +* Preservar linaje de versionado semántico. ## 9. Trazabilidad -- Entidades: `PERMISSION_TEMPLATE`, `PROFILE`, `PROFILE_PERMISSION`, `ACTION` -- ADRs: ADR-0039, ADR-0042, ADR-0021 -- Technical Enabler: TE-01 +* Entidades: `PERMISSION_TEMPLATE`, `PROFILE`, `PROFILE_PERMISSION`, `ACTION` +* ADRs: ADR-0039, ADR-0042, ADR-0021 +* Technical Enabler: TE-01 diff --git a/docs/governance/requirements-es/functional-stories/fs-03-register-organization.md b/docs/governance/requirements-es/functional-stories/fs-03-register-organization.md index ac779a53..ac092023 100644 --- a/docs/governance/requirements-es/functional-stories/fs-03-register-organization.md +++ b/docs/governance/requirements-es/functional-stories/fs-03-register-organization.md @@ -1,4 +1,4 @@ -# Functional Story 3: Registrar Organización y Configurar Estrategia de IdP +# Historia Funcional 3: Registrar Organización y Configurar Estrategia de IdP ## 1. Propósito de Negocio @@ -13,10 +13,10 @@ UMS debe permitir que administradores de seguridad incorporen una nueva organiza ## 3. Precondiciones de Negocio -- El actor estáá autenticado como administrador global. -- La organización estáá aprobada para onboarding. -- La información empresarial requerida estáá disponible. -- El tenant se crea o se marca con `IsManagementOwner=true` cuando debe administrar su propio scope interno de UMS. +* El actor estáá autenticado como administrador global. +* La organización estáá aprobada para onboarding. +* La información empresarial requerida estáá disponible. +* El tenant se crea o se marca con `IsManagementOwner=true` cuando debe administrar su propio scope interno de UMS. ## 4. Flujo Funcional Principal @@ -56,15 +56,15 @@ Si la referencia empresarial ya existe, el sistema evita crear una organización ## 8. Requisitos Técnicos -- Persistir datos del tenant en el Agregado Root `Tenant`. -- Persistir configuración de proveedor de identidad en la Entidad hija `IdentityProvider`. -- Persistir el flag de responsable de gestión en `Tenant` e inicializarlo para tenants que administrarán su propio scope de UMS. -- Aplicar unicidad para referencias externas de compañía. -- Emitir `TenantCreatedEvent`. -- Validar configuración IdP según el tipo de proveedor seleccionado. +* Persistir datos del tenant en el Agregado Root `Tenant`. +* Persistir configuración de proveedor de identidad en la Entidad hija `IdentityProvider`. +* Persistir el flag de responsable de gestión en `Tenant` e inicializarlo para tenants que administrarán su propio scope de UMS. +* Aplicar unicidad para referencias externas de compañía. +* Emitir `TenantCreatedEvent`. +* Validar configuración IdP según el tipo de proveedor seleccionado. ## 9. Trazabilidad -- Entidades: `Tenant` (AR), `Branch` (Entidad Hija), `IdentityProvider` (Entidad Hija), `UserAccount` (AR) -- ADRs: ADR-0031, ADR-0032, ADR-0034, ADR-0010 -- Technical Enabler: TE-03 +* Entidades: `Tenant` (AR), `Branch` (Entidad Hija), `IdentityProvider` (Entidad Hija), `UserAccount` (AR) +* ADRs: ADR-0031, ADR-0032, ADR-0034, ADR-0010 +* Technical Enabler: TE-03 diff --git a/docs/governance/requirements-es/functional-stories/fs-04-register-system-topology.md b/docs/governance/requirements-es/functional-stories/fs-04-register-system-topology.md index 5364c466..bce6d491 100644 --- a/docs/governance/requirements-es/functional-stories/fs-04-register-system-topology.md +++ b/docs/governance/requirements-es/functional-stories/fs-04-register-system-topology.md @@ -1,4 +1,4 @@ -# Functional Story 4: Registrar Sistema y Definir Topología de Menú +# Historia Funcional 4: Registrar Sistema y Definir Topología de Menú ## 1. Propósito de Negocio @@ -9,10 +9,12 @@ UMS debe permitir que los administradores registren sistemas cliente y describan | Actor | Responsabilidad | | :--- | :--- | | **Administrador de Seguridad Global** | Registra sistemas cliente y define su topología de menú. | -| **Dueño del Sistema Cliente** | Proporciona la estructura del sistema y sus acciones de acceso. | ## 3. Precondiciones de Negocio +| **Dueño del Sistema Cliente** | Proporciona la estructura del sistema y sus acciones de acceso. | -- El administrador esta autorizado para registrar sistemas. -- El dueño del sistema ha proporcionado módulos, menús, opciones y acciones esperadas. +## 3. Precondiciones de Negocio + +* El administrador esta autorizado para registrar sistemas. +* El dueño del sistema ha proporcionado módulos, menús, opciones y acciones esperadas. ## 4. Flujo Funcional Principal @@ -53,12 +55,18 @@ Si un nodo de topologia esta incompleto, UMS puede guardarlo como borrador pero > **ESTADO DE IMPLEMENTACION: ACTIVO** > `SystemSuite` y su topologia de menus estan implementados en el dominio de Autorizacion. El mantenimiento del catalogo de roles asociado a la suite seleccionada esta cubierto por FS-17. -- Asegurar la persistencia del identificador y metadatos del sistema. -- Asegurar la unicidad de los códigos de sistema. -- Emitir eventos de dominio cuando se registran metadatos de sistema. + + +> [!NOTE] +> **REVISION (ADR-0090, Aceptado 2026-07-15):** la topologia deja de ser una jerarquia rigida de 4 niveles (Menu→SubMenu→Opcion, submenu obligatorio) y pasa a un **arbol de nodos recursivo** (`MenuNode` auto-referente, `NodeKind`, **profundidad variable**, **submenu opcional**), con **funcionalidad↔opcion N:M** y **metadatos SDLC por nodo** (responsable, criticidad, producto impactado, componente tecnico, dependencias, evidencias, trazabilidad). Un modulo puede tener opciones directas o jerarquias mas profundas. Implementacion en curso: satelite `ums` (D-009 / G-029). + +* Asegurar la persistencia del identificador y metadatos del sistema. +* Asegurar la unicidad de los códigos de sistema (por ámbito del padre: `(ParentId, Code)` dentro del módulo). +* Emitir eventos de dominio cuando se registran metadatos de sistema. +* Soportar profundidad variable y submenús opcionales; una funcionalidad puede relacionarse con una o varias opciones. ## 9. Trazabilidad -- Entidades: `SystemSuite`, `Module`, `Menu`, `SubMenu`, `Option`, `Action` -- ADRs: ADR-0032, ADR-0034, ADR-0047 -- Historias relacionadas: FS-02, FS-07, FS-17 +* Entidades: `SystemSuite`, `Module`, `MenuNode` (recursivo; reemplaza `Menu`/`SubMenu`/`Option`), `Action`, `NodeAction` (puente N:M) +* ADRs: **ADR-0090** (forma de la topología: árbol recursivo flexible), ADR-0032, ADR-0034, ADR-0047 +* Historias relacionadas: FS-02, FS-07, FS-17 diff --git a/docs/governance/requirements-es/functional-stories/fs-05-create-profile-manual-template.md b/docs/governance/requirements-es/functional-stories/fs-05-create-profile-manual-template.md index fd33d555..de5c50ab 100644 --- a/docs/governance/requirements-es/functional-stories/fs-05-create-profile-manual-template.md +++ b/docs/governance/requirements-es/functional-stories/fs-05-create-profile-manual-template.md @@ -1,4 +1,4 @@ -# Functional Story 5: Crear Perfil y Asignar Manualmente Plantilla de Autorización +# Historia Funcional 5: Crear Perfil y Asignar Manualmente Plantilla de Autorización ## 1. Propósito de Negocio @@ -9,11 +9,13 @@ Los administradores necesitan crear perfiles que representen responsabilidades r | Actor | Responsabilidad | | :--- | :--- | | **Administrador de Seguridad** | Crea perfiles y asigna plantillas. | -| **Gestáor de Operaciones de Tenant** | Administra perfiles locales dentro de su alcance delegado. | ## 3. Precondiciones de Negocio +| **Gestáor de Operaciones de Tenant** | Administra perfiles locales dentro de su alcance delegado. | -- La organización destino existe. -- La sede destino existe cuando se requiere alcance por sede. -- Existe al menos una plantilla de autorización disponible. +## 3. Precondiciones de Negocio + +* La organización destino existe. +* La sede destino existe cuando se requiere alcance por sede. +* Existe al menos una plantilla de autorización disponible. ## 4. Flujo Funcional Principal @@ -50,14 +52,14 @@ Si el perfil ya tiene una plantilla activa, el administrador debe confirmar si l ## 8. Requisitos Técnicos -- Persistir perfiles en `PROFILE`. -- Vincular plantillas mediante `PROFILE_PERMISSION` / relación de asignación de plantilla. -- Invalidar caché del grafo de autorización para usuarios afectados. -- Emitir `ProfileCreatedEvent` y `TemplateAssignedEvent`. -- Preservar metadata de asignación, incluyendo si fue manual. +* Persistir perfiles en `PROFILE`. +* Vincular plantillas mediante `PROFILE_PERMISSION` / relación de asignación de plantilla. +* Invalidar caché del grafo de autorización para usuarios afectados. +* Emitir `ProfileCreatedEvent` y `TemplateAssignedEvent`. +* Preservar metadata de asignación, incluyendo si fue manual. ## 9. Trazabilidad -- Entidades: `PROFILE`, `PERMISSION_TEMPLATE`, `PROFILE_PERMISSION` -- ADRs: ADR-0039, ADR-0042, ADR-0043, ADR-0035 -- Technical Enabler: TE-01 +* Entidades: `PROFILE`, `PERMISSION_TEMPLATE`, `PROFILE_PERMISSION` +* ADRs: ADR-0039, ADR-0042, ADR-0043, ADR-0035 +* Technical Enabler: TE-01 diff --git a/docs/governance/requirements-es/functional-stories/fs-06-auto-assign-template.md b/docs/governance/requirements-es/functional-stories/fs-06-auto-assign-template.md index cac46755..d7551b68 100644 --- a/docs/governance/requirements-es/functional-stories/fs-06-auto-assign-template.md +++ b/docs/governance/requirements-es/functional-stories/fs-06-auto-assign-template.md @@ -1,4 +1,4 @@ -# Functional Story 6: Auto-Asignar Plantilla de Autorización al Crear Perfil +# Historia Funcional 6: Auto-Asignar Plantilla de Autorización al Crear Perfil ## 1. Propósito de Negocio @@ -9,11 +9,13 @@ UMS debe reducir administración manual asignando la plantilla correcta cuando u | Actor | Responsabilidad | | :--- | :--- | | **Administrador de Seguridad** | Configura reglas de asignación. | -| **Motor de Reglas UMS** | Aplica reglas coincidentes durante la creación del perfil. | ## 3. Precondiciones de Negocio +| **Motor de Reglas UMS** | Aplica reglas coincidentes durante la creación del perfil. | -- Existe al menos una regla de asignación activa. -- El perfil creado contiene atributos evaluables. -- Existe una plantilla coincidente activa. +## 3. Precondiciones de Negocio + +* Existe al menos una regla de asignación activa. +* El perfil creado contiene atributos evaluables. +* Existe una plantilla coincidente activa. ## 4. Flujo Funcional Principal @@ -54,12 +56,12 @@ Si más de una regla coincide, UMS aplica la regla de mayor prioridad y registra > **ESTADO DE IMPLEMENTACIÓN: Verde — Completado (2026-06-03)** > El agregado `TemplateAssignmentRule` está implementado. `CreateProfileCommandHandler` asigna automáticamente la plantilla coincidente de mayor prioridad al crear el perfil. Los endpoints REST para gestión de reglas están expuestos en `/template-assignment-rules`. -- Persistir el estado de asignación en la relación perfil/plantilla. Hecho (entradas `ProfilePermission` creadas mediante `Profile.AssignTemplate`) -- Invalidar la caché del grafo de autorización para usuarios afectados. (invalidación de caché gestionada por el observador existente de `TemplateLinkedToProfileEvent`) -- Emitir eventos de dominio y auditoría para las asignaciones de plantillas. Hecho (`TemplateAutoAssignedEvent` emitido) +* Persistir el estado de asignación en la relación perfil/plantilla. Hecho (entradas `ProfilePermission` creadas mediante `Profile.AssignTemplate`) +* Invalidar la caché del grafo de autorización para usuarios afectados. (invalidación de caché gestionada por el observador existente de `TemplateLinkedToProfileEvent`) +* Emitir eventos de dominio y auditoría para las asignaciones de plantillas. Hecho (`TemplateAutoAssignedEvent` emitido) ## 9. Trazabilidad -- Entidades: `Profile` (AR), `PermissionTemplate` (AR), `TemplateAssignmentRule` (Implementado) -- ADRs: ADR-0042, ADR-0043, ADR-0035 -- Technical Enabler: TE-01 +* Entidades: `Profile` (AR), `PermissionTemplate` (AR), `TemplateAssignmentRule` (Implementado) +* ADRs: ADR-0042, ADR-0043, ADR-0035 +* Technical Enabler: TE-01 diff --git a/docs/governance/requirements-es/functional-stories/fs-07-visual-graph-resolver.md b/docs/governance/requirements-es/functional-stories/fs-07-visual-graph-resolver.md index b835512b..2590c865 100644 --- a/docs/governance/requirements-es/functional-stories/fs-07-visual-graph-resolver.md +++ b/docs/governance/requirements-es/functional-stories/fs-07-visual-graph-resolver.md @@ -1,4 +1,4 @@ -# Functional Story 7: Diagnosticar Permisos vía Visualizador de Grafos +# Historia Funcional 7: Diagnosticar Permisos vía Visualizador de Grafos ## 1. Propósito de Negocio @@ -9,11 +9,13 @@ Los equipos de soporte y seguridad necesitan entender por qué un usuario puede | Actor | Responsabilidad | | :--- | :--- | | **SRE / Ingeniero de Soporte** | Investiga problemas de permisos. | -| **Administrador de Seguridad** | Revisa configuración y decisiones de autorización. | ## 3. Precondiciones de Negocio +| **Administrador de Seguridad** | Revisa configuración y decisiones de autorización. | -- El actor tiene permisos de diagnóstico. -- El usuario objetivo existe. -- El usuario objetivo tiene al menos un perfil. +## 3. Precondiciones de Negocio + +* El actor tiene permisos de diagnóstico. +* El usuario objetivo existe. +* El usuario objetivo tiene al menos un perfil. ## 4. Flujo Funcional Principal @@ -50,13 +52,13 @@ Si aplican reglas de permitir y denegar, el sistema explica que la denegación e ## 8. Requisitos Técnicos -- Resolver el grafo diagnóstico de autorización sin mutar permisos. -- Incluir reglas fuente y razones de decisión en la respuestá diagnóstica. -- Omitir o refrescar caché cuando la precisión diagnóstica requiera datos fuente actuales. -- Emitir eventos de auditoría por acceso diagnóstico. +* Resolver el grafo diagnóstico de autorización sin mutar permisos. +* Incluir reglas fuente y razones de decisión en la respuestá diagnóstica. +* Omitir o refrescar caché cuando la precisión diagnóstica requiera datos fuente actuales. +* Emitir eventos de auditoría por acceso diagnóstico. ## 9. Trazabilidad -- Entidades: `PROFILE`, `PROFILE_PERMISSION`, `PERMISSION_TEMPLATE`, `ACTION` -- ADRs: ADR-0021, ADR-0039 -- Technical Enabler: TE-01 +* Entidades: `PROFILE`, `PROFILE_PERMISSION`, `PERMISSION_TEMPLATE`, `ACTION` +* ADRs: ADR-0021, ADR-0039 +* Technical Enabler: TE-01 diff --git a/docs/governance/requirements-es/functional-stories/fs-08-hosted-login-redirection.md b/docs/governance/requirements-es/functional-stories/fs-08-hosted-login-redirection.md index 3e68cbf7..970d9ff4 100644 --- a/docs/governance/requirements-es/functional-stories/fs-08-hosted-login-redirection.md +++ b/docs/governance/requirements-es/functional-stories/fs-08-hosted-login-redirection.md @@ -1,8 +1,8 @@ -# Functional Story 8: Autenticar vía Página de Inicio Personalizable +# Historia Funcional 8: Autenticar vía Página de Inicio Hospedada ## 1. Propósito de Negocio -Los sistemas cliente necesitan una experiencia centralizada de inicio de sesión que pueda reflejar la marca de cada tenant o sistema, manteniendo la autenticación gobernada por UMS. +Los sistemas cliente necesitan una experiencia centralizada de inicio de sesión, coherente con el contexto de cada tenant o sistema, manteniendo la autenticación gobernada por UMS. ## 2. Actores @@ -10,26 +10,28 @@ Los sistemas cliente necesitan una experiencia centralizada de inicio de sesión | :--- | :--- | | **Usuario Final** | Inicia sesión desde un sistema cliente. | | **Sistema Cliente** | Redirige usuarios al login hospedado y recibe el resultado. | -| **Administrador de Tenant/Sistema** | Configura branding y comportamiento de login. | ## 3. Precondiciones de Negocio +| **Administrador de Tenant/Sistema** | Configura el comportamiento de login. | -- El sistema cliente estáá registrado en UMS. -- Las ubicaciones de retorno estáán configuradas para el sistema cliente. -- Existen configuraciones de branding/login o se usan valores por defecto. +## 3. Precondiciones de Negocio + +* El sistema cliente estáá registrado en UMS. +* Las ubicaciones de retorno estáán configuradas para el sistema cliente. +* Existen configuraciones de login o se usan valores por defecto. ## 4. Flujo Funcional Principal 1. El usuario inicia login desde un sistema cliente. 2. El usuario es enviado a la página hospedada de UMS. -3. UMS muestra el branding y opciones de inicio de sesión correspondientes. +3. UMS muestra las opciones de inicio de sesión correspondientes. 4. El usuario completa autenticación mediante el método configurado. 5. UMS devuelve al usuario al sistema cliente. 6. El sistema cliente recibe el contexto autenticado y continúa la experiencia del usuario. ## 5. Flujos Alternativos y Excepciones -### A. Branding No Configurado +### A. Configuración de Login No Presente -Si no existe branding personalizado, UMS usa la experiencia por defecto aprobada. +Si no existe configuración específica de login, UMS usa la experiencia por defecto aprobada. ### B. Ubicación de Retorno Inválida @@ -37,28 +39,27 @@ Si el sistema cliente solicita una ubicación de retorno no aprobada, UMS bloque ## 6. Reglas de Negocio -1. El login hospedado debe soportar branding por tenant y sistema. -2. Solo pueden usarse ubicaciones de retorno aprobadas. -3. La página de login no debe exponer branding o configuración de otro tenant. -4. La selección del método de autenticación sigue la política configurada por tenant/sistema. +1. Solo pueden usarse ubicaciones de retorno aprobadas. +2. La página de login no debe exponer configuración de otro tenant. +3. La selección del método de autenticación sigue la política configurada por tenant/sistema. ## 7. Criterios de Aceptación 1. Los usuarios ven la experiencia correcta para su contexto tenant/sistema. -2. Si no hay branding, se usan valores por defecto. +2. Si no hay configuración específica, se usan valores por defecto. 3. Las ubicaciones de retorno no autorizadas son rechazadas. 4. Una autenticación exitosa devuelve al usuario al sistema cliente. ## 8. Requisitos Técnicos -- Resolver branding y comportamiento de login desde configuración de sistema. -- Soportar IdP configurado y estrategias de fallback nativo. -- Validar ubicaciones de redirect/callback. -- Emitir resultado de sesión/token aprobado tras autenticación. -- Auditar intentos exitosos y fallidos de login hospedado. +* Resolver el comportamiento de login desde configuración de sistema. +* Soportar IdP configurado y estrategias de fallback nativo. +* Validar ubicaciones de redirect/callback. +* Emitir resultado de sesión/token aprobado tras autenticación. +* Auditar intentos exitosos y fallidos de login hospedado. ## 9. Trazabilidad -- Entidades: `SYSTEM_CONFIGURATION`, `IDP_CONFIGURATION`, `FEATURE_FLAG` -- ADRs: ADR-0020, ADR-0022 -- Historias relacionadas: FS-01, FS-09, FS-13 +* Entidades: `SYSTEM_CONFIGURATION`, `IDP_CONFIGURATION`, `FEATURE_FLAG` +* ADRs: ADR-0020, ADR-0022 +* Historias relacionadas: FS-01, FS-09, FS-13 diff --git a/docs/governance/requirements-es/functional-stories/fs-09-mfa-passwordless-adaptive-auth.md b/docs/governance/requirements-es/functional-stories/fs-09-mfa-passwordless-adaptive-auth.md index ea4cb201..db259f75 100644 --- a/docs/governance/requirements-es/functional-stories/fs-09-mfa-passwordless-adaptive-auth.md +++ b/docs/governance/requirements-es/functional-stories/fs-09-mfa-passwordless-adaptive-auth.md @@ -1,4 +1,4 @@ -# Functional Story 9: Autenticación Adaptativa Multifactor y Sin Contraseña +# Historia Funcional 9: Autenticación Adaptativa Multifactor y Sin Contraseña ## 1. Propósito de Negocio @@ -10,11 +10,13 @@ UMS debe reforzar la autenticación cuando el riesgo o la política del tenant l | :--- | :--- | | **Usuario Final** | Completa verificación adicional o inicio sin contraseña. | | **Administrador de Tenant** | Configura la política de autenticación. | -| **UMS** | Evalúa política y riesgo antes de otorgar acceso. | ## 3. Precondiciones de Negocio +| **UMS** | Evalúa política y riesgo antes de otorgar acceso. | -- El usuario tiene cuenta en UMS. -- La política del tenant permite o exige MFA/passwordless. -- El usuario tiene o puede registrar un método aprobado de verificación. +## 3. Precondiciones de Negocio + +* El usuario tiene cuenta en UMS. +* La política del tenant permite o exige MFA/passwordless. +* El usuario tiene o puede registrar un método aprobado de verificación. ## 4. Flujo Funcional Principal @@ -55,14 +57,14 @@ Si el contexto de riesgo es elevado, UMS exige verificación más fuerte antes d ## 8. Requisitos Técnicos -- Soportar mecanismos MFA y passwordless aprobados, incluyendo WebAuthn/passkeys cuando estáé configurado. -- Evaluar política de seguridad tenant/sistema antes de emitir sesión. -- Persistir estado de enrolamiento y resultados de verificación. -- Emitir eventos de auditoría por registro, desafío, éxito, fallo y escalamiento de riesgo. -- Retornar fallo de autorización cuando la verificación requerida no se completa. +* Soportar mecanismos MFA y passwordless aprobados, incluyendo WebAuthn/passkeys cuando estáé configurado. +* Evaluar política de seguridad tenant/sistema antes de emitir sesión. +* Persistir estado de enrolamiento y resultados de verificación. +* Emitir eventos de auditoría por registro, desafío, éxito, fallo y escalamiento de riesgo. +* Retornar fallo de autorización cuando la verificación requerida no se completa. ## 9. Trazabilidad -- Entidades: `USER_ACCOUNT`, `IDP_CONFIGURATION`, `SYSTEM_CONFIGURATION` -- ADRs: ADR-0026 -- Historias relacionadas: FS-01, FS-08 +* Entidades: `USER_ACCOUNT`, `IDP_CONFIGURATION`, `SYSTEM_CONFIGURATION` +* ADRs: ADR-0026 +* Historias relacionadas: FS-01, FS-08 diff --git a/docs/governance/requirements-es/functional-stories/fs-10-external-b2b-access-request-approval.md b/docs/governance/requirements-es/functional-stories/fs-10-external-b2b-access-request-approval.md index 672b6b92..762b9507 100644 --- a/docs/governance/requirements-es/functional-stories/fs-10-external-b2b-access-request-approval.md +++ b/docs/governance/requirements-es/functional-stories/fs-10-external-b2b-access-request-approval.md @@ -1,4 +1,4 @@ -# Functional Story 10: Flujo de Aprobación y Petición de Acceso Externo B2B +# Historia Funcional 10: Flujo de Aprobación y Petición de Acceso Externo B2B ## 1. Propósito de Negocio @@ -12,12 +12,13 @@ Los usuarios internos necesitan una forma controlada de solicitar acceso para so | :--- | :--- | | **Usuario Patrocinador** | Solicita y justifica el acceso para un usuario externo. | | **Administrador PAP** | Revisa, aprueba o rechaza la solicitud. | -| **Usuario Externo** | Recibe el onboarding después de la aprobación. +| **Usuario Externo** | Recibe el onboarding después de la aprobación. | + ## 3. Precondiciones de Negocio -- El patrocinador es un usuario corporativo interno autenticado. -- El patrocinador tiene permiso para solicitar acceso externo. -- El perfil solicitado estáá disponible para usuarios externos. +* El patrocinador es un usuario corporativo interno autenticado. +* El patrocinador tiene permiso para solicitar acceso externo. +* El perfil solicitado estáá disponible para usuarios externos. --- @@ -73,17 +74,17 @@ Si la organización ya existe, el sistema vincula el nuevo usuario a la organiza ## 8. Requisitos Técnicos -- Persistir la solicitud como `EXTERNAL_ACCESS_REQUEST` / `APPROVAL_REQUEST` con estados pendiente, aprobado y rechazado. -- Registrar auditoría inmutable con patrocinador, aprobador, justificación, estado y timestaamps. -- Validar perfiles en el límite del servicio/API. -- Usar filtrado por tenant en capa de aplicación como control primario y PostgreSQL row-level security y politicas de base de datos como endurecimiento de infraestructura. -- Emitir eventos de aprovisionamiento y auditoría después de aprobar. -- Retornar fallo de autorización ante intentos de escalamiento de privilegios. +* Persistir la solicitud como `EXTERNAL_ACCESS_REQUEST` / `APPROVAL_REQUEST` con estados pendiente, aprobado y rechazado. +* Registrar auditoría inmutable con patrocinador, aprobador, justificación, estado y timestaamps. +* Validar perfiles en el límite del servicio/API. +* Usar filtrado por tenant en capa de aplicación como control primario y PostgreSQL row-level security y politicas de base de datos como endurecimiento de infraestructura. +* Emitir eventos de aprovisionamiento y auditoría después de aprobar. +* Retornar fallo de autorización ante intentos de escalamiento de privilegios. --- ## 9. Trazabilidad -- Entidades: `APPROVAL_REQUEST`, `APPROVAL_WORKFLOW`, `USER_ACCOUNT`, `PROFILE`, `TENANT` -- ADRs: ADR-0031, ADR-0032, ADR-0038, ADR-0044 -- Historias relacionadas: FS-03, FS-14 +* Entidades: `APPROVAL_REQUEST`, `APPROVAL_WORKFLOW`, `USER_ACCOUNT`, `PROFILE`, `TENANT` +* ADRs: ADR-0031, ADR-0032, ADR-0038, ADR-0044 +* Historias relacionadas: FS-03, FS-14 diff --git a/docs/governance/requirements-es/functional-stories/fs-11-user-document-upload.md b/docs/governance/requirements-es/functional-stories/fs-11-user-document-upload.md index e9a77300..57d90cd4 100644 --- a/docs/governance/requirements-es/functional-stories/fs-11-user-document-upload.md +++ b/docs/governance/requirements-es/functional-stories/fs-11-user-document-upload.md @@ -1,4 +1,4 @@ -# Functional Story 11: Cargar y Validar Documento de Usuario +# Historia Funcional 11: Cargar y Validar Documento de Usuario ## 1. Propósito de Negocio @@ -10,11 +10,13 @@ Los usuarios y administradores necesitan entregar documentos requeridos para que | :--- | :--- | | **Usuario** | Carga su propio documento requerido. | | **Administrador de Identidad** | Carga o revisa documentos en nombre de usuarios. | -| **Revisor de Cumplimiento** | Confirma si el documento es aceptable. | ## 3. Precondiciones de Negocio +| **Revisor de Cumplimiento** | Confirma si el documento es aceptable. | -- El usuario existe. -- El tipo de documento estáá configurado. -- El actor tiene permiso para cargar o revisar el documento. +## 3. Precondiciones de Negocio + +* El usuario existe. +* El tipo de documento estáá configurado. +* El actor tiene permiso para cargar o revisar el documento. ## 4. Flujo Funcional Principal @@ -53,13 +55,13 @@ Si el archivo estáá corrupto, no puede leerse o incumple reglas de carga, el s > [!NOTE] > En la implementación real de C# (base de código), los agregados de cumplimiento y aprobación están unificados bajo el espacio de nombres **Ums.Domain.Approvals**. -- Persistir metadatos en el Agregado Root `UserDocument`. -- Clasificar documentos mediante el Agregado Root `DocumentType`. -- Guardar ubicación del archivo y checksum para recuperación e integridad. -- Emitir eventos de dominio y auditoría por carga, validación, rechazo y cambios de estado. +* Persistir metadatos en el Agregado Root `UserDocument`. +* Clasificar documentos mediante el Agregado Root `DocumentType`. +* Guardar ubicación del archivo y checksum para recuperación e integridad. +* Emitir eventos de dominio y auditoría por carga, validación, rechazo y cambios de estado. ## 9. Trazabilidad -- Entidades: `UserDocument` (AR), `DocumentType` (AR) -- ADRs: ADR-0045, ADR-0016 -- Historias relacionadas: FS-15, FS-16 +* Entidades: `UserDocument` (AR), `DocumentType` (AR) +* ADRs: ADR-0045, ADR-0016 +* Historias relacionadas: FS-15, FS-16 diff --git a/docs/governance/requirements-es/functional-stories/fs-12-role-promotion-process.md b/docs/governance/requirements-es/functional-stories/fs-12-role-promotion-process.md index 5859ace0..b243c25c 100644 --- a/docs/governance/requirements-es/functional-stories/fs-12-role-promotion-process.md +++ b/docs/governance/requirements-es/functional-stories/fs-12-role-promotion-process.md @@ -1,4 +1,4 @@ -# Functional Story 12: Ejecutar Proceso de Promoción de Rol +# Historia Funcional 12: Ejecutar Proceso de Promoción de Rol ## 1. Propósito de Negocio @@ -10,11 +10,13 @@ UMS debe soportar evolución controlada de roles para que los usuarios avancen c | :--- | :--- | | **Evaluador de Promoción** | Detecta usuarios elegibles para promoción. | | **Administrador Aprobador** | Revisa y aprueba o rechaza la promoción. | -| **Usuario** | Recibe el cambio de rol resultante. | ## 3. Precondiciones de Negocio +| **Usuario** | Recibe el cambio de rol resultante. | -- La jerarquía de roles estáá definida. -- Los criterios de promoción estáán configurados. -- El usuario tiene un perfil activo elegible para promoción. +## 3. Precondiciones de Negocio + +* La jerarquía de roles estáá definida. +* Los criterios de promoción estáán configurados. +* El usuario tiene un perfil activo elegible para promoción. ## 4. Flujo Funcional Principal @@ -53,16 +55,17 @@ Si el administrador rechaza la promoción, el usuario permanece en el rol actual > [!NOTE] > En la implementación real de C# (base de código), el motor de promociones está implementado mediante dos agregados independientes en el espacio de nombres **Ums.Domain.IGA**: +> > 1. **RoleMaturityStatus**: Mantiene las capacitaciones, certificaciones, score de desempeño e invariantes de elegibilidad del usuario. > 2. **PromotionRequest**: Orquesta el flujo de aprobación transaccional y realiza análisis de riesgo automatizados. -- Monitorear la elegibilidad y métricas del usuario en el Agregado Root `RoleMaturityStatus`. -- Gestionar las etapas de la transacción de promoción y el análisis de impacto de riesgo en el Agregado Root `PromotionRequest` (con su entidad hija `PromotionImpactAnalysis`). -- Hacer cumplir las invariantes de elegibilidad (antigüedad mínima en nivel: Junior 6 meses, Intermediate 12 meses, Senior 18 meses, Lead 24 meses; score de desempeño >= 3.0; sin bloqueos de cumplimiento) antes del envío. -- Emitir Eventos de Dominio específicos: `PromotionRequestCreated`, `PromotionRequestSubmitted`, `PromotionRequestApproved`, `PromotionRequestExecuted`, `PromotionRequestVerified`. +* Monitorear la elegibilidad y métricas del usuario en el Agregado Root `RoleMaturityStatus`. +* Gestionar las etapas de la transacción de promoción y el análisis de impacto de riesgo en el Agregado Root `PromotionRequest` (con su entidad hija `PromotionImpactAnalysis`). +* Hacer cumplir las invariantes de elegibilidad (antigüedad mínima en nivel: Junior 6 meses, Intermediate 12 meses, Senior 18 meses, Lead 24 meses; score de desempeño >= 3.0; sin bloqueos de cumplimiento) antes del envío. +* Emitir Eventos de Dominio específicos: `PromotionRequestCreated`, `PromotionRequestSubmitted`, `PromotionRequestApproved`, `PromotionRequestExecuted`, `PromotionRequestVerified`. ## 9. Trazabilidad -- Entidades: `RoleMaturityStatus` (AR), `PromotionRequest` (AR), `PromotionImpactAnalysis` (Entidad Hija) -- ADRs: ADR-0046, ADR-0036 -- Historias relacionadas: FS-11, FS-14 +* Entidades: `RoleMaturityStatus` (AR), `PromotionRequest` (AR), `PromotionImpactAnalysis` (Entidad Hija) +* ADRs: ADR-0046, ADR-0036 +* Historias relacionadas: FS-11, FS-14 diff --git a/docs/governance/requirements-es/functional-stories/fs-13-hierarchical-config.md b/docs/governance/requirements-es/functional-stories/fs-13-hierarchical-config.md index 2341e220..1799324a 100644 --- a/docs/governance/requirements-es/functional-stories/fs-13-hierarchical-config.md +++ b/docs/governance/requirements-es/functional-stories/fs-13-hierarchical-config.md @@ -1,4 +1,4 @@ -# Functional Story 13: Configurar Parámetros Jerárquicos del Sistema +# Historia Funcional 13: Configurar Parámetros Jerárquicos del Sistema ## 1. Propósito de Negocio @@ -13,12 +13,13 @@ Los administradores necesitan configurar el comportamiento del sistema sin solic | **Administrador Global** | Define valores por defecto aplicables a toda la plataforma. | | **Administrador de Tenant** | Ajusta comportamiento para un tenant cuando la política global lo permite. | | **Administrador de Sistema** | Ajusta comportamiento para un sistema o suite registrada. | -| **Sistema Cliente** | Consume la configuración efectiva resuelta por UMS. +| **Sistema Cliente** | Consume la configuración efectiva resuelta por UMS. | + ## 3. Precondiciones de Negocio -- El administrador estáá autenticado. -- El administrador tiene permiso para gestionar configuración en el alcance seleccionado. -- El tenant, sistema o módulo destino estáá registrado y activo. +* El administrador estáá autenticado. +* El administrador tiene permiso para gestionar configuración en el alcance seleccionado. +* El tenant, sistema o módulo destino estáá registrado y activo. --- @@ -76,14 +77,14 @@ Si ya existe un parámetro con el mismo identificador en el alcance seleccionado > **ESTADO DE IMPLEMENTACIÓN: DIFERIDO / FUERA DE ALCANCE** > En la fase actual, la gestión activa de parámetros jerárquicos de configuración (`AppConfiguration`) está **diferida** y no está implementada dentro del proyecto principal de dominio de C#. -- Asegurar estructura para parámetros de configuración según su alcance. -- Soportar el rastreo de auditoría en sobrescrituras de configuración. +* Asegurar estructura para parámetros de configuración según su alcance. +* Soportar el rastreo de auditoría en sobrescrituras de configuración. --- ## 9. Trazabilidad -- Entidades: `AppConfiguration` (AR Diferido) -- ADRs: ADR-0024, ADR-0047 -- Technical Enabler: TE-02 Resolve Hierarchical System Configuration -- Estándar: Estándar de Redacción de Historias Funcionales; Estándar de Catálogos Paramétricos +* Entidades: `AppConfiguration` (AR Diferido) +* ADRs: ADR-0024, ADR-0047 +* Technical Enabler: TE-02 Resolve Hierarchical System Configuration +* Estándar: Estándar de Redacción de Historias Funcionales; Estándar de Catálogos Paramétricos diff --git a/docs/governance/requirements-es/functional-stories/fs-14-delegated-management.md b/docs/governance/requirements-es/functional-stories/fs-14-delegated-management.md index 658950b3..7820309f 100644 --- a/docs/governance/requirements-es/functional-stories/fs-14-delegated-management.md +++ b/docs/governance/requirements-es/functional-stories/fs-14-delegated-management.md @@ -8,25 +8,25 @@ La delegación aplica únicamente al scope interno de UMS y debe mantenerse dent ## 2. Alcance Funcional -- La delegación ocurre siempre dentro del mismo tenant. -- El usuario delegante debe ser un usuario autorizado del tenant con acceso vigente a UMS. -- El usuario receptor debe pertenecer al mismo tenant y ser elegible para recibir permisos UMS. -- La delegación puede incluir permisos para crear usuarios, gestionar usuarios, asignar perfiles permitidos, modificar permisos permitidos dentro del alcance del delegante, y revocar o bloquear usuarios si el delegante posee esas capacidades. -- La delegación puede requerir aprobación según el tipo de permiso, el nivel de riesgo o la política del tenant. -- La delegación debe ser aprobada, trazada, auditada y revocable. +* La delegación ocurre siempre dentro del mismo tenant. +* El usuario delegante debe ser un usuario autorizado del tenant con acceso vigente a UMS. +* El usuario receptor debe pertenecer al mismo tenant y ser elegible para recibir permisos UMS. +* La delegación puede incluir permisos para crear usuarios, gestionar usuarios, asignar perfiles permitidos, modificar permisos permitidos dentro del alcance del delegante, y revocar o bloquear usuarios si el delegante posee esas capacidades. +* La delegación puede requerir aprobación según el tipo de permiso, el nivel de riesgo o la política del tenant. +* La delegación debe ser aprobada, trazada, auditada y revocable. ## 3. Fuera de Alcance -- Delegación cross-tenant. -- Delegación global de administración fuera del tenant. -- Delegación por `Organization`, `Department`, `System` o `Team` si esos scopes no están habilitados explícitamente para esta historia. -- Otorgar permisos que el delegante no posee. -- Convertir la delegación en un mecanismo para saltar el modelo de aprobación de UMS. +* Delegación cross-tenant. +* Delegación global de administración fuera del tenant. +* Delegación por `Organization`, `Department`, `System` o `Team` si esos scopes no están habilitados explícitamente para esta historia. +* Otorgar permisos que el delegante no posee. +* Convertir la delegación en un mecanismo para saltar el modelo de aprobación de UMS. ## 4. Actores | Actor | Responsabilidad | -|---|---| +| --- | --- | | Delegante | Concede, modifica o revoca una delegación limitada dentro de su propio alcance UMS. | | Receptor | Ejecuta acciones de gestión UMS dentro de los permisos delegados y del tenant correspondiente. | | Aprobador autorizado | Aprueba la delegación cuando la política del tenant lo exige. | @@ -35,12 +35,12 @@ La delegación aplica únicamente al scope interno de UMS y debe mantenerse dent ## 5. Precondiciones de Negocio -- El delegante pertenece al mismo tenant que el receptor. -- El delegante tiene acceso vigente y efectivo a UMS. -- El delegante posee los permisos que pretende delegar. -- El receptor es elegible para recibir permisos UMS. -- No existe una restricción activa que invalide la delegación por pérdida de acceso, desactivación o revocación previa. -- La política del tenant define si la delegación requiere aprobación, vigencia temporal o validación adicional. +* El delegante pertenece al mismo tenant que el receptor. +* El delegante tiene acceso vigente y efectivo a UMS. +* El delegante posee los permisos que pretende delegar. +* El receptor es elegible para recibir permisos UMS. +* No existe una restricción activa que invalide la delegación por pérdida de acceso, desactivación o revocación previa. +* La política del tenant define si la delegación requiere aprobación, vigencia temporal o validación adicional. ## 6. Flujo Funcional Principal @@ -80,7 +80,7 @@ Si la operación produce auto-delegación, ciclo de delegación o encadenamiento ## 8. Reglas de Negocio | Regla | Descripción | -|---|---| +| --- | --- | | BR-01 | La delegación solo aplica al scope interno de UMS dentro del tenant. | | BR-02 | No se permite delegación cross-tenant. | | BR-03 | El delegante solo puede delegar permisos que ya posee en su perfil UMS efectivo. | @@ -97,7 +97,7 @@ Si la operación produce auto-delegación, ciclo de delegación o encadenamiento ## 9. Criterios de Aceptación | # | Criterio de Aceptación | -|---|---| +| --- | --- | | 1 | El sistema permite delegar gestión UMS solo dentro del mismo tenant. | | 2 | El sistema impide la delegación cross-tenant. | | 3 | El sistema impide que el delegado reciba permisos que el delegante no posee. | @@ -111,24 +111,24 @@ Si la operación produce auto-delegación, ciclo de delegación o encadenamiento ## 10. Requisitos de Auditoría y Trazabilidad -- Registrar tenant, delegante, receptor, permisos delegados, alcance, vigencia, motivo y estado. -- Registrar quién aprobó, activó, modificó, revocó, rechazó o expiró la delegación. -- Registrar fecha y hora de cada transición de estado. -- Conservar evidencia del permiso original del delegante al momento de crear o modificar la delegación. -- Mantener trazabilidad de suspensión o revisión forzada cuando el delegante pierda autoridad. +* Registrar tenant, delegante, receptor, permisos delegados, alcance, vigencia, motivo y estado. +* Registrar quién aprobó, activó, modificó, revocó, rechazó o expiró la delegación. +* Registrar fecha y hora de cada transición de estado. +* Conservar evidencia del permiso original del delegante al momento de crear o modificar la delegación. +* Mantener trazabilidad de suspensión o revisión forzada cuando el delegante pierda autoridad. ## 11. Requisitos Técnicos -- Si existe una estructura técnica previa como `UserManagementDelegation`, debe alinearse a este alcance mínimo: tenant, usuario delegante, usuario receptor, permisos UMS delegados, aprobación, auditoría y revocación. -- El modelo debe validar que el conjunto de permisos delegados sea subconjunto de los permisos efectivos del delegante. -- El modelo debe soportar estado de aprobación, activación, rechazo, revocación, expiración y suspensión. -- La lógica de prevención de ciclos y auto-delegación debe resolverse en la capa de aplicación y reforzarse en el dominio. -- No introducir scopes adicionales como Organization, Department, System o Team salvo que una historia futura los habilite explícitamente. +* Si existe una estructura técnica previa como `UserManagementDelegation`, debe alinearse a este alcance mínimo: tenant, usuario delegante, usuario receptor, permisos UMS delegados, aprobación, auditoría y revocación. +* El modelo debe validar que el conjunto de permisos delegados sea subconjunto de los permisos efectivos del delegante. +* El modelo debe soportar estado de aprobación, activación, rechazo, revocación, expiración y suspensión. +* La lógica de prevención de ciclos y auto-delegación debe resolverse en la capa de aplicación y reforzarse en el dominio. +* No introducir scopes adicionales como Organization, Department, System o Team salvo que una historia futura los habilite explícitamente. ## 12. Trazabilidad | Tipo | Referencias | -|---|---| +| --- | --- | | Entidades | `UserManagementDelegation`, `UserAccount`, `Profile` | | Permisos UMS relacionados | Gestión de usuarios, bloqueo/revocación, asignación de perfiles, modificación de permisos permitidos | | Eventos de dominio | Creación, modificación, aprobación, activación, rechazo, revocación y expiración de la delegación | diff --git a/docs/governance/requirements-es/functional-stories/fs-15-notification-rules.md b/docs/governance/requirements-es/functional-stories/fs-15-notification-rules.md index 2a1b65ff..68bf3df7 100644 --- a/docs/governance/requirements-es/functional-stories/fs-15-notification-rules.md +++ b/docs/governance/requirements-es/functional-stories/fs-15-notification-rules.md @@ -1,4 +1,4 @@ -# Functional Story 15: Configurar Reglas de Notificación por Vencimiento +# Historia Funcional 15: Configurar Reglas de Notificación por Vencimiento ## 1. Propósito de Negocio @@ -12,11 +12,12 @@ Los administradores de cumplimiento necesitan advertir a los usuarios antes de q | :--- | :--- | | **Administrador de Cumplimiento** | Define tiempos y canales de notificación. | | **Usuario** | Recibe recordatorios de renovación. | -| **Motor de Cumplimiento** | Aplica reglas activas cuando los documentos se acercan al vencimiento. +| **Motor de Cumplimiento** | Aplica reglas activas cuando los documentos se acercan al vencimiento. | + ## 3. Precondiciones de Negocio -- El tipo de documento existe. -- El administrador tiene permiso para gestionar reglas de notificación. +* El tipo de documento existe. +* El administrador tiene permiso para gestionar reglas de notificación. --- @@ -62,16 +63,16 @@ Si ya existe una regla idéntica para el mismo documento, tenant, anticipación > [!NOTE] > En la implementación real de C# (base de código), `NotificationRule` es una Entidad hija encapsulada dentro del Agregado **DocumentType**, bajo el espacio de nombres unificado **Ums.Domain.Approvals**. -- Persistir reglas como parte del Agregado Root `DocumentType`. -- Campos obligatorios: `Code`, `Value` (JSON con tiempos y canales), `Description`. -- Aplicar unicidad por `Code`, `TenantId` y `DocumentTypeId`. -- Registrar trazabilidad de entrega de notificaciones. -- Soportar invalidación de caché cuando cambian reglas de notificación. +* Persistir reglas como parte del Agregado Root `DocumentType`. +* Campos obligatorios: `Code`, `Value` (JSON con tiempos y canales), `Description`. +* Aplicar unicidad por `Code`, `TenantId` y `DocumentTypeId`. +* Registrar trazabilidad de entrega de notificaciones. +* Soportar invalidación de caché cuando cambian reglas de notificación. --- ## 9. Trazabilidad -- Entidades: `DocumentType` (AR), `NotificationRule` (Entidad Hija), `UserDocument` (AR) -- ADRs: ADR-0045, ADR-0016 -- Historias relacionadas: FS-11, FS-16 +* Entidades: `DocumentType` (AR), `NotificationRule` (Entidad Hija), `UserDocument` (AR) +* ADRs: ADR-0045, ADR-0016 +* Historias relacionadas: FS-11, FS-16 diff --git a/docs/governance/requirements-es/functional-stories/fs-16-access-enforcement-policy.md b/docs/governance/requirements-es/functional-stories/fs-16-access-enforcement-policy.md index e5263aae..d4c26f26 100644 --- a/docs/governance/requirements-es/functional-stories/fs-16-access-enforcement-policy.md +++ b/docs/governance/requirements-es/functional-stories/fs-16-access-enforcement-policy.md @@ -1,4 +1,4 @@ -# Functional Story 16: Definir Política de Acceso por Vencimiento +# Historia Funcional 16: Definir Política de Acceso por Vencimiento > **Estado:** Implementado @@ -15,11 +15,12 @@ Los equipos de seguridad y cumplimiento necesitan definir qué debe ocurrir cuan | **Arquitecto de Seguridad** | Define el impacto de acceso por documentos críticos vencidos. | | **Administrador Global** | Publica o actualiza políticas de cumplimiento. | | **Usuario Afectado** | Recibe restricciones o advertencias según la política. | + ## 3. Precondiciones de Negocio -- La validación de cumplimiento documental está habilitada. -- El tipo de documento está marcado como relevante para control de acceso. -- El actor tiene permiso para gestionar políticas de enforcement. +* La validación de cumplimiento documental está habilitada. +* El tipo de documento está marcado como relevante para control de acceso. +* El actor tiene permiso para gestionar políticas de enforcement. --- @@ -70,25 +71,25 @@ Si el tipo de documento seleccionado no es crítico para acceso, el sistema impi > [!NOTE] > En la implementación real de C# (base de código), `AccessEnforcementPolicy` es una Entidad hija encapsulada dentro del Agregado **DocumentType**, bajo el espacio de nombres unificado **Ums.Domain.Approvals**. -- Persistir políticas como parte del Agregado Root `DocumentType`. -- Campos obligatorios: `Code`, `Value` (JSON con acciones de la política), `Description`. -- Aplicar unicidad por `Code`, alcance de tenant y `DocumentTypeId`. -- Acciones soportadas: `BLOCK_USER`, `RESTRICT_PROFILE` y `LOG_ONLY`. -- Permitir actualizaciones de la acción de la política mediante `PUT /access-enforcement-policies/{policyId}/action`. -- Registrar la ejecucion del enforcement a traves del flujo de documento de usuario para que el resultado aplicado siga siendo trazable. -- Emitir eventos de dominio y auditoría cuando se aplican o revierten restricciones. +* Persistir políticas como parte del Agregado Root `DocumentType`. +* Campos obligatorios: `Code`, `Value` (JSON con acciones de la política), `Description`. +* Aplicar unicidad por `Code`, alcance de tenant y `DocumentTypeId`. +* Acciones soportadas: `BLOCK_USER`, `RESTRICT_PROFILE` y `LOG_ONLY`. +* Permitir actualizaciones de la acción de la política mediante `PUT /access-enforcement-policies/{policyId}/action`. +* Registrar la ejecucion del enforcement a traves del flujo de documento de usuario para que el resultado aplicado siga siendo trazable. +* Emitir eventos de dominio y auditoría cuando se aplican o revierten restricciones. --- ## 9. Trazabilidad -- Entidades: `DocumentType` (AR), `AccessEnforcementPolicy` (Entidad Hija), `UserAccount` (AR), `Profile` (AR) -- ADRs: ADR-0045, ADR-0035 -- Historias relacionadas: FS-11, FS-15 +* Entidades: `DocumentType` (AR), `AccessEnforcementPolicy` (Entidad Hija), `UserAccount` (AR), `Profile` (AR) +* ADRs: ADR-0045, ADR-0035 +* Historias relacionadas: FS-11, FS-15 ## 10. Evidencia de Pruebas de Aceptacion -- [`AccessEnforcementPolicyE2ETests.cs`](../../../../src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/AccessEnforcementPolicyE2ETests.cs) cubre creacion de la politica, GET por ID, actualizacion de la accion, desactivacion y la validacion cuando no se suministra `ProfileId` ni `RoleId`. -- [`UpdateAccessEnforcementActionCommandValidatorTests.cs`](../../../../src/apps/ums.api/Ums.Application.Test/Approvals/AccessEnforcementPolicy/Commands/UpdateAccessEnforcementActionCommandValidatorTests.cs) verifica que el comando acepte los nombres de accion del dominio `BlockUser`, `RestrictProfile` y `LogOnly`. -- [`AccessEnforcementPolicyCommandHandlerTests.cs`](../../../../src/apps/ums.api/Ums.Application.Test/Approvals/AccessEnforcementPolicy/AccessEnforcementPolicyCommandHandlerTests.cs) cubre los handlers de crear, desactivar y actualizar. -- [`UserDocumentEndpoints.cs`](../../../../src/apps/ums.api/Ums.Presentation/Endpoints/Approvals/UserDocument/UserDocumentEndpoints.cs) expone la ruta de ejecucion de enforcement usada para preservar la trazabilidad cuando se aplica la politica. +* [`AccessEnforcementPolicyE2ETests.cs`](../../../src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/AccessEnforcementPolicyE2ETests.cs) cubre creacion de la politica, GET por ID, actualizacion de la accion, desactivacion y la validacion cuando no se suministra `ProfileId` ni `RoleId`. +* [`UpdateAccessEnforcementActionCommandValidatorTests.cs`](../../../src/apps/ums.api/Ums.Application.Test/Approvals/AccessEnforcementPolicy/Commands/UpdateAccessEnforcementActionCommandValidatorTests.cs) verifica que el comando acepte los nombres de accion del dominio `BlockUser`, `RestrictProfile` y `LogOnly`. +* [`AccessEnforcementPolicyCommandHandlerTests.cs`](../../../src/apps/ums.api/Ums.Application.Test/Approvals/AccessEnforcementPolicy/AccessEnforcementPolicyCommandHandlerTests.cs) cubre los handlers de crear, desactivar y actualizar. +* [`UserDocumentEndpoints.cs`](../../../src/apps/ums.api/Ums.Presentation/Endpoints/Approvals/UserDocument/UserDocumentEndpoints.cs) expone la ruta de ejecucion de enforcement usada para preservar la trazabilidad cuando se aplica la politica. diff --git a/docs/governance/requirements-es/functional-stories/fs-17-maintain-system-roles.md b/docs/governance/requirements-es/functional-stories/fs-17-maintain-system-roles.md index ba38aae2..4d0d779f 100644 --- a/docs/governance/requirements-es/functional-stories/fs-17-maintain-system-roles.md +++ b/docs/governance/requirements-es/functional-stories/fs-17-maintain-system-roles.md @@ -1,4 +1,4 @@ -# Functional Story 17: Mantener Roles de una Suite del Sistema +# Historia Funcional 17: Mantener Roles de una Suite del Sistema > **Estado:** Implementado @@ -15,8 +15,8 @@ UMS debe permitir que los administradores de seguridad mantengan el catalogo de ## 3. Precondiciones de Negocio -- Existe una suite del sistema registrada. -- El administrador esta autorizado para mantener catalogos de autorizacion. +* Existe una suite del sistema registrada. +* El administrador esta autorizado para mantener catalogos de autorizacion. ## 4. Flujo Funcional Principal @@ -55,24 +55,24 @@ Si el padre seleccionado pertenece a otra suite o genera un ciclo, UMS rechaza e ## 8. Requisitos Tecnicos -- Agregado de dominio: `Ums.Domain.Authorization.Role.Role`. -- Los comandos usan endpoints REST anidados bajo `/system-suites/{systemSuiteId}/roles`. -- Las consultas usan GraphQL `rolesBySystemSuite(systemSuiteId)`. -- SQL Server persiste `Roles` con FK a `SystemSuites` y relacion propia opcional al padre. -- El filtrado de tenant en la aplicacion es obligatorio; los controles de base de datos son resguardos secundarios. -- Los comandos de rol usan Result Pattern y emiten eventos de ciclo de vida del rol. -- Las fallas de validacion o negocio retornan causas aptas para el usuario; los detalles tecnicos inesperados quedan solo en logs Serilog/Loki correlacionados por `ErrorId`. -- La vista React se localiza en espanol e ingles y valida las respuestas en tiempo de ejecucion. +* Agregado de dominio: `Ums.Domain.Authorization.Role.Role`. +* Los comandos usan endpoints REST anidados bajo `/system-suites/{systemSuiteId}/roles`. +* Las consultas usan el endpoint REST `GET /system-suites/{systemSuiteId}/roles`, resuelto por la query CQRS `GetRolesBySystemSuiteQuery` en la capa de aplicacion. +* PostgreSQL persiste `Roles` con FK a `SystemSuites` y relacion propia opcional al padre. +* El filtrado de tenant en la aplicacion es obligatorio; los controles de base de datos son resguardos secundarios. +* Los comandos de rol usan Result Pattern y emiten eventos de ciclo de vida del rol. +* Las fallas de validacion o negocio retornan causas aptas para el usuario; los detalles tecnicos inesperados quedan solo en logs Serilog/Loki correlacionados por `ErrorId`. +* La vista React se localiza en espanol e ingles y valida las respuestas en tiempo de ejecucion. ## 9. Trazabilidad -- Entidades: `SystemSuite`, `Role`, `PermissionTemplate`, `Profile` -- Historias relacionadas: FS-02, FS-04, FS-05, FS-12 -- Estandares: catalogo `code/value/description`, respuesta de errores segura para usuario, regla de aislamiento por tenant +* Entidades: `SystemSuite`, `Role`, `PermissionTemplate`, `Profile` +* Historias relacionadas: FS-02, FS-04, FS-05, FS-12 +* Estandares: catalogo `code/value/description`, respuesta de errores segura para usuario, regla de aislamiento por tenant ## 10. Evidencia de Pruebas de Aceptacion -- [`RoleE2ETests.cs`](../../../../src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/RoleE2ETests.cs) cubre creacion de roles, visibilidad via GraphQL, actualizacion, desactivacion, reactivacion y proteccion por codigo duplicado. -- [`RoleCommandHandlerTests.cs`](../../../../src/apps/ums.api/Ums.Application.Test/Authorization/Role/RoleCommandHandlerTests.cs) cubre creacion, rechazo por codigo duplicado, validacion de alcance por tenant, rechazo de ciclos y cambios de estado. -- [`RoleQueries.cs`](../../../../src/apps/ums.api/Ums.Presentation/GraphQL/Authorization/RoleQueries.cs) y [`RoleEndpoints.cs`](../../../../src/apps/ums.api/Ums.Presentation/Endpoints/Authorization/Role/RoleEndpoints.cs) exponen el contrato de consulta `rolesBySystemSuite` y las rutas de comando usadas por la UI. -- [`SystemSuiteRolesPanel.tsx`](../../../../src/apps/ums.web-app/src/presentation/authorization/system-suite/components/SystemSuiteRolesPanel.tsx) proporciona la interfaz localizada para mantener roles en la suite del sistema seleccionada. +* [`RoleE2ETests.cs`](../../../src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/RoleE2ETests.cs) cubre creacion de roles, visibilidad via API REST, actualizacion, desactivacion, reactivacion y proteccion por codigo duplicado. +* [`RoleCommandHandlerTests.cs`](../../../src/apps/ums.api/Ums.Application.Test/Authorization/Role/RoleCommandHandlerTests.cs) cubre creacion, rechazo por codigo duplicado, validacion de alcance por tenant, rechazo de ciclos y cambios de estado. +* [`GetRolesBySystemSuiteQueryHandler.cs`](../../../src/apps/ums.api/Ums.Application/Authorization/Role/Queries/GetRolesBySystemSuiteQueryHandler.cs) y [`RoleEndpoints.cs`](../../../src/apps/ums.api/Ums.Presentation/Endpoints/Authorization/Role/RoleEndpoints.cs) exponen el endpoint REST `GET /system-suites/{systemSuiteId}/roles` y las rutas de comando usadas por la UI. +* [`SystemSuiteRolesPanel.tsx`](../../../src/apps/ums.web-app/src/presentation/authorization/system-suite/components/SystemSuiteRolesPanel.tsx) proporciona la interfaz localizada para mantener roles en la suite del sistema seleccionada. diff --git a/docs/governance/requirements-es/functional-stories/fs-18-manage-local-user-password.md b/docs/governance/requirements-es/functional-stories/fs-18-manage-local-user-password.md index ab51dece..3e8a5313 100644 --- a/docs/governance/requirements-es/functional-stories/fs-18-manage-local-user-password.md +++ b/docs/governance/requirements-es/functional-stories/fs-18-manage-local-user-password.md @@ -1,4 +1,4 @@ -# Functional Story 18: Gestionar la Contraseña Local de un Usuario +# Historia Funcional 18: Gestionar la Contraseña Local de un Usuario ## 1. Propósito de Negocio @@ -13,9 +13,9 @@ UMS debe permitir que un administrador autorizado establezca o rote la contrase ## 3. Precondiciones de Negocio -- La cuenta de usuario existe en el tenant seleccionado. -- La cuenta usa autenticación interna y no está vinculada a un proveedor de identidad externo. -- El administrador está autorizado para gestionar credenciales de usuarios. +* La cuenta de usuario existe en el tenant seleccionado. +* La cuenta usa autenticación interna y no está vinculada a un proveedor de identidad externo. +* El administrador está autorizado para gestionar credenciales de usuarios. ## 4. Flujo Funcional Principal @@ -58,17 +58,17 @@ Si la operación no puede completarse, UMS muestra una razón clara cuando está ## 8. Requisitos Técnicos -- `PasswordCredential` permanece como entidad propiedad del agregado `UserAccount` en el bounded context Identity. -- Los comandos son REST-first mediante `POST /user-accounts/{userAccountId}/passwords`; la solicitud lleva una contraseña temporal en texto claro sobre el transporte seguro y la API la protege con BCrypt antes de persistirla. -- Las consultas pueden exponer `hasActivePassword` y `passwordUpdatedAtUtc`; `PasswordHash` y los identificadores de credenciales históricas no deben exponerse al cliente web. -- SQL Server con EF Core persiste credenciales dentro del límite transaccional de `UserAccount`. -- El filtrado de tenant en aplicacion permanece como control primario; PostgreSQL row-level security y politicas de base de datos permanecen como resguardos secundarios. -- Los errores operativos siguen el estándar de respuesta segura y correlacionan el diagnóstico Serilog/Loki mediante `ErrorId`. -- La vista React está localizada en español e inglés y valida la regla mínima de contraseña antes de ejecutar el comando. +* `PasswordCredential` permanece como entidad propiedad del agregado `UserAccount` en el bounded context Identity. +* Los comandos son REST-first mediante `POST /user-accounts/{userAccountId}/passwords`; la solicitud lleva una contraseña temporal en texto claro sobre el transporte seguro y la API la protege con BCrypt antes de persistirla. +* Las consultas pueden exponer `hasActivePassword` y `passwordUpdatedAtUtc`; `PasswordHash` y los identificadores de credenciales históricas no deben exponerse al cliente web. +* PostgreSQL con EF Core persiste credenciales dentro del límite transaccional de `UserAccount`. +* El filtrado de tenant en aplicacion permanece como control primario; PostgreSQL row-level security y politicas de base de datos permanecen como resguardos secundarios. +* Los errores operativos siguen el estándar de respuesta segura y correlacionan el diagnóstico Serilog/Loki mediante `ErrorId`. +* La vista React está localizada en español e inglés y valida la regla mínima de contraseña antes de ejecutar el comando. ## 9. Trazabilidad -- Entidades: `UserAccount`, `PasswordCredential`, `IdentityProvider` -- Historias relacionadas: FS-01, FS-03, FS-08, FS-09 -- ADR relacionado: ADR-0066 contrato de errores accionables para usuario -- Actualización de diagrama: `docs/domain-es/identity/password-credential.md`, ciclo de vida y propiedad de UserAccount, motivada por FS-18 +* Entidades: `UserAccount`, `PasswordCredential`, `IdentityProvider` +* Historias relacionadas: FS-01, FS-03, FS-08, FS-09 +* ADR relacionado: ADR-UMS-066 contrato de errores accionables para usuario +* Actualización de diagrama: `docs/domain-es/identity/password-credential.md`, ciclo de vida y propiedad de UserAccount, motivada por FS-18 diff --git a/docs/governance/requirements-es/functional-stories/fs-19-admin-password-reset-validity-management.md b/docs/governance/requirements-es/functional-stories/fs-19-admin-password-reset-validity-management.md index a00b83f4..18c968bc 100644 --- a/docs/governance/requirements-es/functional-stories/fs-19-admin-password-reset-validity-management.md +++ b/docs/governance/requirements-es/functional-stories/fs-19-admin-password-reset-validity-management.md @@ -14,11 +14,11 @@ UMS debe permitir que administradores autorizados puedan restablecer contraseña ## 3. Precondiciones de Negocio -- El administrador que realiza la acción está autenticado en el portal interno de UMS o en una sesión administrativa confiable y tiene un rol ADMIN en su alcance operativo. -- La cuenta de usuario objetivo existe y pertenece a un tenant dentro del alcance operativo del administrador. -- El administrador tiene el permiso requerido (`CAN_RESET_PASSWORD` y/o `CAN_MODIFY_VALIDITY_PERIOD`) asignado a su rol. -- Los feature flags que controlan estas capacidades están habilitados para el sistema o tenant. -- Las acciones administrativas limitadas al tenant solo están permitidas cuando el tenant está marcado como `IsManagementOwner=true`. +* El administrador que realiza la acción está autenticado en el portal interno de UMS o en una sesión administrativa confiable y tiene un rol ADMIN en su alcance operativo. +* La cuenta de usuario objetivo existe y pertenece a un tenant dentro del alcance operativo del administrador. +* El administrador tiene el permiso requerido (`CAN_RESET_PASSWORD` y/o `CAN_MODIFY_VALIDITY_PERIOD`) asignado a su rol. +* Los feature flags que controlan estas capacidades están habilitados para el sistema o tenant. +* Las acciones administrativas limitadas al tenant solo están permitidas cuando el tenant está marcado como `IsManagementOwner=true`. ## 4. Flujo Funcional Principal @@ -107,33 +107,33 @@ Si la operación no puede completarse debido a un error del sistema, UMS muestra ### 8.1 Autorización -- El flujo de gestión del portal debe resolver la autorización mediante el scope interno de UMS, no mediante el flujo IDP externo del tenant. -- `ITenantContext.OrganizationId` determina el alcance de tenant para operaciones limitadas al tenant. -- El flag `Tenant.IsManagementOwner` determina si el tenant puede realizar sus propias acciones de gestión interna. -- Las verificaciones de autorización en handlers deben validar: - - `targetUser.TenantId == OrganizationId` para operaciones limitadas al tenant - - el scope de plataforma permite operaciones cross-tenant desde el portal interno - - El rol admin tiene los permisos requeridos (`CAN_RESET_PASSWORD`, `CAN_MODIFY_VALIDITY_PERIOD`) -- Feature flags para estas capacidades se almacenan en tabla `FeatureFlags` con claves `ALLOW_PASSWORD_RESET_BY_ADMIN` y `ALLOW_VALIDITY_PERIOD_MODIFICATION`. +* El flujo de gestión del portal debe resolver la autorización mediante el scope interno de UMS, no mediante el flujo IDP externo del tenant. +* `ITenantContext.OrganizationId` determina el alcance de tenant para operaciones limitadas al tenant. +* El flag `Tenant.IsManagementOwner` determina si el tenant puede realizar sus propias acciones de gestión interna. +* Las verificaciones de autorización en handlers deben validar: + * `targetUser.TenantId == OrganizationId` para operaciones limitadas al tenant + * el scope de plataforma permite operaciones cross-tenant desde el portal interno + * El rol admin tiene los permisos requeridos (`CAN_RESET_PASSWORD`, `CAN_MODIFY_VALIDITY_PERIOD`) +* Feature flags para estas capacidades se almacenan en tabla `FeatureFlags` con claves `ALLOW_PASSWORD_RESET_BY_ADMIN` y `ALLOW_VALIDITY_PERIOD_MODIFICATION`. ### 8.2 Comandos | Comando | Endpoint | Descripción | -|---|---|---| +| --- | --- | --- | | `ResetUserPasswordCommand` | `POST /user-accounts/{userAccountId}/passwords/reset` | Restablecer contraseña para usuario objetivo | | `ModifyUserValidityPeriodCommand` | `PATCH /user-accounts/{userAccountId}/validity` | Modificar período de vigencia | ### 8.3 Eventos de Auditoría | Evento | Campos | -|---|---| +| --- | --- | | `PASSWORD_RESET` | `adminUserId`, `targetUserId`, `targetTenantId`, `timestamp`, `reason`, `effectiveImmediately` | | `VALIDITY_PERIOD_MODIFIED` | `adminUserId`, `targetUserId`, `targetTenantId`, `timestamp`, `previousExpiresAt`, `newExpiresAt`, `reason` | ### 8.4 Configuración (Parámetros Configurables) | Parámetro | Ubicación de Config | Default | -|---|---|---| +| --- | --- | --- | | `MAX_VALIDITY_PERIOD_DAYS` | `AppConfiguration` | 365 | | `MIN_PASSWORD_LENGTH` | `AppConfiguration` | 12 | | `PASSWORD_RESET_NOTIFICATION_CHANNEL` | `AppConfiguration` | email | @@ -142,14 +142,14 @@ Si la operación no puede completarse debido a un error del sistema, UMS muestra ### 8.5 Modelo de Datos -- `UserAccount` aggregate incluye `ValidityPeriod` value object con `CreatedAt`, `ExpiresAt`, `LastActivityAt`, `IsActive`. -- `PasswordCredential` es una entidad owned con `IsActive`, `CreatedAt`, `Historical` flag. -- Registros de auditoría almacenados en tabla `AuditRecords` con `OperationType`, `ActorId`, `TargetId`, `TargetType`, `TenantId`, `Timestamp`, `Details` (JSON). +* `UserAccount` aggregate incluye `ValidityPeriod` value object con `CreatedAt`, `ExpiresAt`, `LastActivityAt`, `IsActive`. +* `PasswordCredential` es una entidad owned con `IsActive`, `CreatedAt`, `Historical` flag. +* Registros de auditoría almacenados en tabla `AuditRecords` con `OperationType`, `ActorId`, `TargetId`, `TargetType`, `TenantId`, `Timestamp`, `Details` (JSON). ### 8.6 Códigos de Error | Código | Descripción | -|---|---| +| --- | --- | | `AUTH_009` | Administrador carece del permiso requerido | | `AUTH_010` | Usuario objetivo fuera del alcance del administrador | | `USER_015` | Usuario federado no puede tener contraseña local restablecida | @@ -157,7 +157,7 @@ Si la operación no puede completarse debido a un error del sistema, UMS muestra ## 9. Trazabilidad -- Entidades: `UserAccount`, `PasswordCredential`, `AuditRecord`, `FeatureFlag`, `AppConfiguration` -- Historias relacionadas: FS-01 (autenticación de usuario), FS-03 (registrar organización), FS-18 (gestionar contraseña local) -- ADRs relacionadas: ADR-0012 control de acceso basado en roles, ADR-0019 requisitos de historial de auditoría -- Actualización de diagrama: `docs/domain/identity/user-account.md` - agregar gestión de período de vigencia, `docs/governance/audit/audit-events.md` - agregar nuevos tipos de eventos +* Entidades: `UserAccount`, `PasswordCredential`, `AuditRecord`, `FeatureFlag`, `AppConfiguration` +* Historias relacionadas: FS-01 (autenticación de usuario), FS-03 (registrar organización), FS-18 (gestionar contraseña local) +* ADRs relacionadas: ADR-0012 control de acceso basado en roles, ADR-0019 requisitos de historial de auditoría +* Actualización de diagrama: `docs/domain/identity/user-account.md` - agregar gestión de período de vigencia, `docs/governance/audit/audit-events.md` - agregar nuevos tipos de eventos diff --git a/docs/governance/requirements-es/functional-stories/fs-20-system-parameter-management.md b/docs/governance/requirements-es/functional-stories/fs-20-system-parameter-management.md index d006af1f..47d21e36 100644 --- a/docs/governance/requirements-es/functional-stories/fs-20-system-parameter-management.md +++ b/docs/governance/requirements-es/functional-stories/fs-20-system-parameter-management.md @@ -14,10 +14,10 @@ UMS debe proporcionar un mecanismo seguro, configurable y auditable para gestion ## 3. Precondiciones de Negocio -- El administrador está autenticado y tiene una sesión válida en el portal interno de UMS o en una sesión administrativa confiable. -- El administrador tiene el permiso `CAN_MANAGE_GLOBAL_CONFIGURATION` (para config global) o `CAN_MANAGE_TENANT_CONFIGURATION` (para config específica de tenant). -- Para operaciones de tenant gestionadas desde el portal, `Tenant.IsManagementOwner=true`. -- Para admins de tenant, `OrganizationId` coincide con el tenant objetivo para operaciones específicas de tenant. +* El administrador está autenticado y tiene una sesión válida en el portal interno de UMS o en una sesión administrativa confiable. +* El administrador tiene el permiso `CAN_MANAGE_GLOBAL_CONFIGURATION` (para config global) o `CAN_MANAGE_TENANT_CONFIGURATION` (para config específica de tenant). +* Para operaciones de tenant gestionadas desde el portal, `Tenant.IsManagementOwner=true`. +* Para admins de tenant, `OrganizationId` coincide con el tenant objetivo para operaciones específicas de tenant. ## 4. Flujo Funcional Principal @@ -91,8 +91,9 @@ Si un admin interno intenta crear un parámetro con Scope Tenant sin especificar 5. **Versionado**: Los parámetros usan versionado semántico (`Major.Minor.Patch`). Las actualizaciones a Draft incrementan la versión menor. 6. **Ciclo de Vida del Estado**: Draft → Published → Archived. Solo parámetros en Draft pueden ser modificados. 7. **Matriz de Autorización**: + | Scope de Configuración | Admin Interno | Admin de Tenant | - |---------------------|----------------|----------------| + | --------------------- | ---------------- | ---------------- | | Global | Puede gestionar | No puede acceder | | Tenant (propio) | Puede gestionar | Puede gestionar | | Tenant (otro) | Puede gestionar | No puede acceder | @@ -132,7 +133,7 @@ if (targetTenantId != organizationId && !hasInternalPortalScope) ### 8.2 Endpoints | Método | Endpoint | Descripción | Autorización | -|--------|----------|-------------|---------------| +| -------- | ---------- | ------------- | --------------- | | GET | `/app-configurations` | Listar configuraciones (filtradas por scope y permisos de usuario) | Basado en scope | | GET | `/app-configurations/{id}` | Obtener una configuración | Basado en scope y propiedad | | POST | `/app-configurations` | Crear nueva configuración | Admin interno o admin de tenant para su propio scope de gestión | @@ -143,7 +144,7 @@ if (targetTenantId != organizationId && !hasInternalPortalScope) ### 8.3 Filtros de Consulta | Parámetro | Comportamiento | -|-----------|----------| +| ----------- | ---------- | | `scope=Global` | Solo administradores del portal interno pueden consultar; retorna todas las configs globales | | `scope=Tenant&tenantId={id}` | Administradores del portal interno obtienen todas las configs de tenant; admins de tenant obtienen solo su tenant cuando está permitido | | Sin filtro de scope | Retorna configs basadas en los permisos del usuario | @@ -151,7 +152,7 @@ if (targetTenantId != organizationId && !hasInternalPortalScope) ### 8.4 Valores Hardcodeados a Migrar | Hardcode Actual | Código de Parámetro | Valor Default | -|-----------------|-------------------|---------------| +| ----------------- | ------------------- | --------------- | | `ACCESS_TOKEN_DURATION` (frontend) | `ACCESS_TOKEN_DURATION_MS` | 3600000 (1 hora) | | `REFRESH_TOKEN_DURATION` (frontend) | `REFRESH_TOKEN_DURATION_MS` | 604800000 (7 días) | | `MIN_PASSWORD_LENGTH` | `MIN_PASSWORD_LENGTH` | 12 | @@ -160,7 +161,7 @@ if (targetTenantId != organizationId && !hasInternalPortalScope) ### 8.5 Eventos de Auditoría | Evento | Campos | -|---|---| +| --- | --- | | `APP_CONFIG_CREATED` | adminUserId, configId, code, scope, timestamp | | `APP_CONFIG_UPDATED` | adminUserId, configId, code, oldVersion, newVersion, timestamp | | `APP_CONFIG_PUBLISHED` | adminUserId, configId, code, version, timestamp | @@ -175,7 +176,7 @@ if (targetTenantId != organizationId && !hasInternalPortalScope) ## 9. Trazabilidad -- Entidades: `AppConfiguration`, `AuditRecord` -- Historias relacionadas: FS-01 (autenticación), FS-17 (roles de sistema), FS-19 (reset de contraseña admin) -- ADRs relacionadas: ADR-0012 (RBAC), ADR-0019 (trail de auditoría) -- Actualización de diagrama: `docs/domain/identity/tenant.md` sección 10 - agregar reglas de gestión de parámetros +* Entidades: `AppConfiguration`, `AuditRecord` +* Historias relacionadas: FS-01 (autenticación), FS-17 (roles de sistema), FS-19 (reset de contraseña admin) +* ADRs relacionadas: ADR-0012 (RBAC), ADR-0019 (trail de auditoría) +* Actualización de diagrama: `docs/domain/identity/tenant.md` sección 10 - agregar reglas de gestión de parámetros diff --git a/docs/governance/requirements-es/functional-stories/fs-21-tenant-signup-request-approval.md b/docs/governance/requirements-es/functional-stories/fs-21-tenant-signup-request-approval.md index 93080896..1bd2e44b 100644 --- a/docs/governance/requirements-es/functional-stories/fs-21-tenant-signup-request-approval.md +++ b/docs/governance/requirements-es/functional-stories/fs-21-tenant-signup-request-approval.md @@ -7,16 +7,16 @@ Las nuevas empresas necesitan una forma controlada de solicitar acceso a UMS e i ## 2. Actores | Actor | Responsabilidad | -|---|---| +| --- | --- | | System Admin | Revisa las solicitudes de alta de empresa y decide si una nueva compania ingresa a UMS. | | Contacto de la Empresa | Envia los datos de la compania y recibe el resultado del onboarding. | | Usuario Administrador del Tenant | Recibe la primera cuenta administrativa creada para la compania aprobada. | ## 3. Precondiciones de Negocio -- La compania aun no esta registrada como tenant activo en UMS. -- El solicitante cuenta con nombre de empresa, codigo de referencia, nombre de contacto y correo de contacto validos. -- El System Admin tiene acceso al area de revision de onboarding. +* La compania aun no esta registrada como tenant activo en UMS. +* El solicitante cuenta con nombre de empresa, codigo de referencia, nombre de contacto y correo de contacto validos. +* El System Admin tiene acceso al area de revision de onboarding. ## 4. Flujo Funcional Principal @@ -46,7 +46,7 @@ Si mas adelante el negocio requiere verificacion de pago antes de admitir la emp ## 6. Reglas de Negocio | Regla | Descripcion | -|---|---| +| --- | --- | | BR-01 | Las solicitudes de alta de empresa pertenecen al alcance global y solo las revisan System Admins. | | BR-02 | Un tenant solo se crea despues de aprobar la solicitud. | | BR-03 | El tenant aprobado debe recibir su primer usuario administrativo como parte del mismo resultado de aprobacion. | @@ -56,7 +56,7 @@ Si mas adelante el negocio requiere verificacion de pago antes de admitir la emp ## 7. Criterios de Aceptacion | # | Criterio de Aceptacion | -|---|---| +| --- | --- | | 1 | Una empresa puede enviar una solicitud de alta con los datos requeridos de compania y contacto. | | 2 | La solicitud aparece como pendiente en la bandeja global de onboarding. | | 3 | Solo los System Admins pueden revisar y aprobar o rechazar la solicitud. | @@ -66,19 +66,18 @@ Si mas adelante el negocio requiere verificacion de pago antes de admitir la emp ## 8. Requisitos Tecnicos -- Persistir las solicitudes de onboarding en el agregado `TenantSignupRequest` con estados `Pending`, `Approved` y `Rejected`. -- Mantener anonimo y sin contexto de tenant el punto de entrada publico de la solicitud. -- Usar un read model compuesto de bandeja de aprobacion en la UI en lugar de una tabla generica adicional de inbox. -- Crear el tenant y el primer usuario administrativo como parte del comando de aprobacion. -- Enviar la notificacion de aprobacion con la contrasena temporal generada y los datos de la cuenta. -- Reservar espacio para futuros estados de verificacion de pago en el modelo de estados y la documentacion. +* Persistir las solicitudes de onboarding en el agregado `TenantSignupRequest` con estados `Pending`, `Approved` y `Rejected`. +* Mantener anonimo y sin contexto de tenant el punto de entrada publico de la solicitud. +* Usar un read model compuesto de bandeja de aprobacion en la UI en lugar de una tabla generica adicional de inbox. +* Crear el tenant y el primer usuario administrativo como parte del comando de aprobacion. +* Enviar la notificacion de aprobacion con la contrasena temporal generada y los datos de la cuenta. +* Reservar espacio para futuros estados de verificacion de pago en el modelo de estados y la documentacion. ## 9. Trazabilidad | Tipo | Referencias | -|---|---| +| --- | --- | | Historias Funcionales | FS-22 | | Entidades de Dominio | `TenantSignupRequest`, `Tenant`, `UserAccount` | | Notificaciones | `TenantSignupRequestReceived`, `TenantSignupApproved` | -| ADRs | ADR-0075 | - +| ADRs | ADR-UMS-075 | diff --git a/docs/governance/requirements-es/functional-stories/fs-22-user-signup-request-approval.md b/docs/governance/requirements-es/functional-stories/fs-22-user-signup-request-approval.md index 8957e8f9..ccb68a8f 100644 --- a/docs/governance/requirements-es/functional-stories/fs-22-user-signup-request-approval.md +++ b/docs/governance/requirements-es/functional-stories/fs-22-user-signup-request-approval.md @@ -9,16 +9,16 @@ Los usuarios que desean unirse a un tenant existente necesitan una ruta controla ## 2. Actores | Actor | Responsabilidad | -|---|---| +| --- | --- | | Solicitante | Pide acceso a un tenant existente y envia los datos de identidad requeridos. | | Tenant Admin | Revisa las solicitudes pendientes de acceso del tenant y aprueba o deniega cuando corresponde. | | Solicitante | Recibe la notificacion final de onboarding despues de que la solicitud es aprobada o denegada. | ## 3. Precondiciones de Negocio -- El tenant objetivo ya existe en UMS. -- El solicitante tiene nombre, correo electronico y contrasena que cumplen las reglas de alta. -- El Tenant Admin tiene acceso a la bandeja de onboarding del tenant. +* El tenant objetivo ya existe en UMS. +* El solicitante tiene nombre, correo electronico y contrasena que cumplen las reglas de alta. +* El Tenant Admin tiene acceso a la bandeja de onboarding del tenant. ## 4. Flujo Funcional Principal @@ -52,7 +52,7 @@ Si el Tenant Admin deniega la solicitud, el requerimiento llega a un estado term ## 6. Reglas de Negocio | Regla | Descripcion | -|---|---| +| --- | --- | | BR-01 | Las solicitudes de alta de usuario pertenecen solo al tenant objetivo. | | BR-02 | Los Tenant Admins solo pueden revisar solicitudes de su propio tenant. | | BR-03 | Una solicitud pendiente no otorga acceso hasta ser aprobada. | @@ -66,7 +66,7 @@ Si el Tenant Admin deniega la solicitud, el requerimiento llega a un estado term ## 7. Criterios de Aceptacion | # | Criterio de Aceptacion | -|---|---| +| --- | --- | | 1 | Un solicitante puede enviar una solicitud de alta completa para un tenant existente. | | 2 | La solicitud se guarda como pendiente y aparece en la bandeja de onboarding del tenant. | | 3 | Solo el tenant objetivo puede ver la solicitud. | @@ -78,26 +78,26 @@ Si el Tenant Admin deniega la solicitud, el requerimiento llega a un estado term ## 8. Requisitos Tecnicos -- Persistir la solicitud como un `UserAccount` en estado `Pending` en lugar de crear una tabla separada de onboarding. -- Mantener anonimo el endpoint publico de solicitud y usar el cliente publico sin encabezados de tenant. -- Usar scope por tenant para asegurar que las consultas de la bandeja solo devuelvan las cuentas pendientes del tenant actual. -- Reutilizar el flujo de activacion existente para aprobar la solicitud y emitir la notificacion de aprobacion. -- Agregar un comando explicito de denegacion o una operacion equivalente de aplicacion que registre denegacion terminal sin activar la cuenta. -- Persistir metadata de ciclo de vida para envio, decision, aprobador, estado final y motivo opcional de denegacion en forma auditable. -- Mantener la bandeja como una composicion UI sobre el listado de cuentas para que el flujo siga alineado con el modelo central de cuentas. -- Agregar o mapear plantillas de notificacion para resultados finales aprobados y denegados. +* Persistir la solicitud como un `UserAccount` en estado `Pending` en lugar de crear una tabla separada de onboarding. +* Mantener anonimo el endpoint publico de solicitud y usar el cliente publico sin encabezados de tenant. +* Usar scope por tenant para asegurar que las consultas de la bandeja solo devuelvan las cuentas pendientes del tenant actual. +* Reutilizar el flujo de activacion existente para aprobar la solicitud y emitir la notificacion de aprobacion. +* Agregar un comando explicito de denegacion o una operacion equivalente de aplicacion que registre denegacion terminal sin activar la cuenta. +* Persistir metadata de ciclo de vida para envio, decision, aprobador, estado final y motivo opcional de denegacion en forma auditable. +* Mantener la bandeja como una composicion UI sobre el listado de cuentas para que el flujo siga alineado con el modelo central de cuentas. +* Agregar o mapear plantillas de notificacion para resultados finales aprobados y denegados. ## 9. Trazabilidad | Tipo | Referencias | -|---|---| +| --- | --- | | Historias Funcionales | FS-22 | | Entidades de Dominio | `UserAccount`, `Tenant` | | Notificaciones | `UserSignupRequestReceived`, `UserSignupApproved`, `UserSignupDenied` | -| ADRs | ADR-0075 | +| ADRs | ADR-UMS-075 | ## 10. Evidencia de Pruebas de Aceptacion -- [`UserAccountOnboardingCommandHandlerTests.cs`](../../../../src/apps/ums.api/Ums.Application.Test/Identity/UserAccount/UserAccountOnboardingCommandHandlerTests.cs) cubre visibilidad de solicitudes pendientes, denegacion, alcance por tenant y manejo de estados finales. -- [`UserAccountCommandHandlerTests.cs`](../../../../src/apps/ums.api/Ums.Application.Test/Identity/UserAccount/UserAccountCommandHandlerTests.cs) cubre la activacion de una cuenta pendiente y la transicion al estado aprobado usada por el flujo de onboarding. -- [`UserAccountEndpoints.cs`](../../../../src/apps/ums.api/Ums.Presentation/Endpoints/Identity/UserAccount/UserAccountEndpoints.cs) y [`OnboardingInboxEndpoints.cs`](../../../../src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Onboarding/OnboardingInboxEndpoints.cs) exponen la bandeja y las rutas de accion terminal usadas por la historia. +* [`UserAccountOnboardingCommandHandlerTests.cs`](../../../src/apps/ums.api/Ums.Application.Test/Identity/UserAccount/UserAccountOnboardingCommandHandlerTests.cs) cubre visibilidad de solicitudes pendientes, denegacion, alcance por tenant y manejo de estados finales. +* [`UserAccountCommandHandlerTests.cs`](../../../src/apps/ums.api/Ums.Application.Test/Identity/UserAccount/UserAccountCommandHandlerTests.cs) cubre la activacion de una cuenta pendiente y la transicion al estado aprobado usada por el flujo de onboarding. +* [`UserAccountEndpoints.cs`](../../../src/apps/ums.api/Ums.Presentation/Endpoints/Identity/UserAccount/UserAccountEndpoints.cs) y [`OnboardingInboxEndpoints.cs`](../../../src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Onboarding/OnboardingInboxEndpoints.cs) exponen la bandeja y las rutas de accion terminal usadas por la historia. diff --git a/docs/governance/requirements-es/functional-stories/fs-23-profile-access-request.md b/docs/governance/requirements-es/functional-stories/fs-23-profile-access-request.md index 3641cf04..bfc19f5f 100644 --- a/docs/governance/requirements-es/functional-stories/fs-23-profile-access-request.md +++ b/docs/governance/requirements-es/functional-stories/fs-23-profile-access-request.md @@ -1,89 +1,89 @@ -# FS-23: Solicitud de Perfil desde Usuario en Lobby +# FS-23: Solicitud de Acceso a Perfil desde Usuario en Lobby -## 1. Proposito de Negocio +## 1. Propósito de Negocio -Los usuarios admitidos a un tenant aun pueden necesitar un perfil de negocio antes de usar menus operativos. UMS debe permitir que un usuario autenticado sin perfil asignado solicite el acceso necesario para su trabajo, sin otorgar permisos automaticamente, y debe trazar la solicitud hasta que un aprobador autorizado la cierre. +Los usuarios admitidos a un tenant todavía pueden necesitar el perfil de negocio correcto antes de usar los menús operativos. UMS debe permitir que un usuario autenticado sin perfil asignado solicite el acceso que necesita para su trabajo, sin conceder permisos automáticamente, y debe rastrear la solicitud hasta que un aprobador autorizado la cierre. ## 2. Actores | Actor | Responsabilidad | -|---|---| -| Usuario en Lobby | Usuario autenticado y admitido al tenant, pero sin perfil activo. | -| Tenant Admin | Recibe solicitudes de perfil y valida si el acceso corresponde. | -| Gerente de Sucursal | Puede revisar solicitudes de usuarios de una sucursal especifica cuando exista delegacion del tenant. | +| --- | --- | +| Usuario en Lobby | Usuario autenticado admitido al tenant pero sin un perfil activo. | +| Administrador de Tenant | Recibe solicitudes de perfil y valida si el acceso es apropiado. | +| Administrador de Sucursal | Puede revisar solicitudes para usuarios que trabajan en una sucursal específica cuando el tenant lo delega. | ## 3. Precondiciones de Negocio -- La cuenta del usuario esta activa para el tenant. -- El usuario no tiene perfil activo para el sistema o alcance solicitado. -- El tenant tiene al menos un sistema, una sucursal y un rol disponibles para solicitud. +* La cuenta del usuario está activa para el tenant. +* El usuario no tiene un perfil activo para el sistema o alcance solicitado. +* El tenant tiene al menos un sistema, una sucursal y un rol disponibles para solicitar. ## 4. Flujo Funcional Principal -1. El usuario inicia sesion correctamente y llega a la pantalla de lobby. -2. El lobby explica que el usuario pertenece al tenant pero aun no tiene perfil asignado. +1. El usuario inicia sesión correctamente y llega a la pantalla de lobby. +2. El lobby explica que el usuario pertenece al tenant, pero aún no tiene un perfil asignado. 3. El usuario abre el formulario de solicitud de perfil. -4. El usuario selecciona el sistema, luego la sucursal y finalmente el rol sugerido. -5. El usuario puede ingresar una justificacion de negocio. -6. El sistema guarda la solicitud como pendiente de asignacion. +4. El usuario selecciona el sistema, luego la sucursal y luego el rol sugerido. +5. El usuario puede ingresar una justificación de negocio. +6. El sistema almacena la solicitud como pendiente de asignación. 7. La solicitud aparece en la bandeja de solicitudes de perfil del aprobador responsable. -8. El usuario puede ver que la solicitud sigue pendiente hasta que exista una decision final aprobada o denegada. +8. El usuario puede ver que la solicitud está pendiente hasta que se tome una decisión final aprobada o denegada. ## 5. Flujos Alternativos y Excepciones -### A. Sin Roles Disponibles +### A. No hay roles disponibles -Si no existe un rol disponible para el sistema y la sucursal seleccionados, el usuario no puede enviar la solicitud y se le indica contactar al administrador del tenant. +Si no hay ningún rol disponible para el sistema y la sucursal seleccionados, el usuario no puede enviar la solicitud y se le indica que contacte al administrador del tenant. -### B. Perfil Activo Existente +### B. Perfil activo existente -Si el usuario ya tiene un perfil activo para el mismo sistema y sucursal, el sistema evita una solicitud duplicada y muestra el estado de acceso existente. +Si el usuario ya tiene un perfil activo para el mismo sistema y la misma sucursal, el sistema impide una solicitud duplicada y muestra el estado de acceso existente. -### C. Solicitud Ya Pendiente +### C. Solicitud ya pendiente -Si el mismo usuario ya tiene una solicitud pendiente para el sistema y sucursal seleccionados, el sistema muestra la solicitud pendiente en vez de crear otra. +Si el mismo usuario ya tiene una solicitud pendiente para el sistema y la sucursal seleccionados, el sistema muestra la solicitud pendiente en lugar de crear otra. ## 6. Reglas de Negocio -| Regla | Descripcion | -|---|---| -| BR-01 | El acceso al tenant y la autorizacion por perfil son fases separadas. | -| BR-02 | Un usuario en lobby puede autenticarse pero no debe ver menus operativos hasta tener un perfil asignado. | -| BR-03 | La solicitud debe capturar sistema, sucursal, rol sugerido y justificacion opcional. | +| Regla | Descripción | +| --- | --- | +| BR-01 | El acceso al tenant y la autorización de perfil son fases separadas. | +| BR-02 | Un usuario en lobby puede autenticarse, pero no debe ver menús operativos hasta que se asigne un perfil. | +| BR-03 | La solicitud debe capturar sistema, sucursal, rol sugerido y justificación opcional. | | BR-04 | La solicitud permanece pendiente hasta que un aprobador autorizado decida. | | BR-05 | No se permiten solicitudes pendientes duplicadas para el mismo usuario, sistema y sucursal. | -| BR-06 | El ciclo de vida de cada solicitud de perfil debe ser trazable dentro del tenant desde el envio hasta el cierre final. | -| BR-07 | Los resultados finales de negocio son Aprobado y Denegado; la aprobacion puede otorgar el rol solicitado o un rol modificado. | -| BR-08 | El solicitante debe ser notificado automaticamente cuando la solicitud de perfil llegue a un resultado final. | +| BR-06 | Cada ciclo de vida de la solicitud de perfil debe ser trazable dentro del tenant desde el envío hasta el cierre final. | +| BR-07 | Los resultados finales de negocio son Aprobado y Denegado; la aprobación puede otorgar el rol solicitado o un rol modificado. | +| BR-08 | El solicitante debe ser notificado automáticamente cuando la solicitud de perfil alcance un resultado final. | -## 7. Criterios de Aceptacion +## 7. Criterios de Aceptación -| # | Criterio de Aceptacion | -|---|---| -| 1 | Un usuario activo sin perfil aterriza en el lobby despues del login. | +| # | Criterio de Aceptación | +| --- | --- | +| 1 | Un usuario activo sin perfil aterriza en el lobby después de iniciar sesión. | | 2 | El usuario en lobby puede solicitar un perfil seleccionando sistema, sucursal y rol sugerido. | | 3 | Las opciones de sucursal y rol dependen de las selecciones previas. | -| 4 | El usuario puede incluir una justificacion de negocio. | -| 5 | La solicitud se guarda como pendiente de asignacion. | -| 6 | El usuario no recibe acceso a menus operativos antes de la aprobacion. | -| 7 | El usuario puede ver el estado pendiente mientras la solicitud espera decision. | -| 8 | El usuario recibe notificacion cuando la solicitud es aprobada o denegada. | - -## 8. Requisitos Tecnicos - -- Introducir un modelo de solicitud de acceso a perfil o reutilizar el modelo existente de aprobaciones con un tipo de solicitud de asignacion de perfil. -- Persistir sistema solicitado, sucursal, rol solicitado, justificacion, solicitante, tenant y estado. -- Persistir metadata de ciclo de vida para envio, estado actual, resultado final, aprobador, fecha de decision y motivo de decision. -- Retornar una respuesta controlada de lobby cuando el grafo de autorizacion no pueda resolver un perfil activo para el usuario autenticado. -- Mantener la ruta de lobby fuera de los menus operativos y disponible solo despues de autenticacion exitosa. -- Aplicar filtrado por tenant en capa de aplicacion como mecanismo primario de aislamiento. -- Agregar o mapear plantillas de notificacion para aprobacion y denegacion final de solicitud de perfil. +| 4 | El usuario puede incluir una justificación de negocio. | +| 5 | La solicitud se almacena como asignación pendiente. | +| 6 | El usuario no recibe acceso a menús operativos antes de la aprobación. | +| 7 | El usuario puede ver el estado pendiente mientras la solicitud espera una decisión. | +| 8 | El usuario es notificado cuando la solicitud es aprobada o denegada. | + +## 8. Requisitos Técnicos + +* Introducir un modelo de solicitud de acceso a perfil o reutilizar el modelo de solicitud de aprobación existente con un tipo de solicitud de asignación de perfil. +* Persistir sistema solicitado, sucursal solicitada, rol solicitado, justificación, solicitante, tenant y estado. +* Persistir metadatos del ciclo de vida para envío, estado actual, resultado final, aprobador, fecha de decisión y motivo de decisión. +* Devolver una respuesta controlada del lobby cuando el grafo de autorización no pueda resolver un perfil activo para el usuario autenticado. +* Mantener la ruta del lobby fuera de los menús operativos y disponible solo después de autenticación correcta. +* Aplicar el filtrado de tenant en la capa de aplicación como mecanismo primario de aislamiento. +* Agregar o mapear plantillas de notificación para la aprobación y la denegación final de la solicitud de perfil. ## 9. Trazabilidad | Tipo | Referencias | -|---|---| -| Historias Relacionadas | FS-22, FS-24, FS-05 | -| Entidades de Dominio | `UserAccount`, `Profile`, `Role`, `SystemSuite`, `Branch`, `ApprovalRequest` | +| --- | --- | +| Historias relacionadas | FS-22, FS-24, FS-05 | +| Entidades de dominio | `UserAccount`, `Profile`, `Role`, `SystemSuite`, `Branch`, `ApprovalRequest` | | Notificaciones | `ProfileRequestApproved`, `ProfileRequestDenied` | -| ADRs | ADR-0075, ADR-0071 | +| ADRs | ADR-UMS-075, ADR-UMS-088 | diff --git a/docs/governance/requirements-es/functional-stories/fs-24-profile-request-approval.md b/docs/governance/requirements-es/functional-stories/fs-24-profile-request-approval.md index 32ea0368..6c462e7d 100644 --- a/docs/governance/requirements-es/functional-stories/fs-24-profile-request-approval.md +++ b/docs/governance/requirements-es/functional-stories/fs-24-profile-request-approval.md @@ -1,92 +1,92 @@ -# FS-24: Aprobacion de Solicitud de Perfil y Asignacion Manual +# FS-24: Aprobación de Solicitud de Perfil y Asignación Manual -## 1. Proposito de Negocio +## 1. Propósito de Negocio -Los administradores de tenant y gerentes de sucursal delegados necesitan revisar solicitudes de perfil antes de que los usuarios reciban acceso operativo. UMS debe soportar aprobacion, modificacion y denegacion para que el perfil asignado corresponda a la necesidad real de negocio, el ciclo de vida de la solicitud quede cerrado y la decision quede auditada. +Los administradores de tenant y los administradores delegados de sucursal necesitan revisar las solicitudes de perfil antes de que los usuarios reciban acceso operativo. UMS debe soportar aprobación, modificación y denegación para que el perfil asignado coincida con la necesidad real del negocio, el ciclo de vida de la solicitud se cierre y la decisión quede auditable. ## 2. Actores | Actor | Responsabilidad | -|---|---| -| Tenant Admin | Revisa solicitudes de perfil en todo el tenant y asigna el perfil final. | -| Gerente de Sucursal | Revisa solicitudes de perfil dentro del alcance de sucursal delegado. | -| Usuario en Lobby | Recibe la decision y obtiene acceso solo despues de que se asigna un perfil. | -| Auditor | Revisa quien aprobo el acceso, cuando y que rol fue otorgado. | +| --- | --- | +| Administrador de Tenant | Revisa solicitudes de perfil en todo el tenant y asigna el perfil final. | +| Administrador de Sucursal | Revisa solicitudes de perfil dentro del alcance delegado de la sucursal. | +| Usuario en Lobby | Recibe la decisión y obtiene acceso solo después de que se asigne un perfil. | +| Auditor | Revisa quién aprobó el acceso, cuándo y qué rol se otorgó. | ## 3. Precondiciones de Negocio -- Existe una solicitud de perfil en estado pendiente de asignacion. -- El aprobador tiene autoridad sobre el tenant o la sucursal. -- El sistema, la sucursal y el rol solicitados siguen activos. +* Existe una solicitud de perfil en estado pendiente de asignación. +* El aprobador tiene autoridad sobre el alcance del tenant o de la sucursal. +* El sistema, la sucursal y el rol solicitados siguen activos. ## 4. Flujo Funcional Principal 1. El aprobador abre la bandeja de solicitudes de perfil. -2. El aprobador revisa usuario, sistema, sucursal, rol sugerido y justificacion. +2. El aprobador revisa el usuario, el sistema, la sucursal, el rol sugerido y la justificación. 3. El sistema advierte al aprobador sobre conflictos visibles o condiciones de riesgo de rol. -4. El aprobador elige uno de tres resultados: aprobar como fue solicitado, aprobar con un rol diferente o denegar. +4. El aprobador elige uno de tres resultados: aprobar tal como se solicitó, aprobar con un rol diferente o denegar. 5. Si se aprueba, el sistema asigna el perfil final al usuario. -6. El sistema registra quien aprobo la asignacion, cuando ocurrio y que rol fue otorgado. -7. Si se deniega, el sistema cierra la solicitud sin asignar perfil. -8. El usuario recibe una notificacion indicando que la decision del perfil fue completada. +6. El sistema registra quién aprobó la asignación, cuándo ocurrió y qué rol se otorgó. +7. Si se deniega, el sistema cierra la solicitud sin asignar un perfil. +8. El usuario es notificado de que la decisión del perfil está completa. ## 5. Flujos Alternativos y Excepciones -### A. Aprobacion con Rol Modificado +### A. Aprobación con rol modificado -El aprobador puede aprobar la solicitud con un rol distinto al solicitado cuando se requiere un nivel de acceso menor o mas adecuado. +El aprobador puede aprobar la solicitud con un rol distinto del solicitado cuando se requiere un nivel de acceso más bajo o más apropiado. -### B. Denegacion +### B. Denegación -El aprobador puede denegar la solicitud. El usuario permanece sin acceso operativo para ese sistema y sucursal. +El aprobador puede denegar la solicitud. El usuario permanece sin acceso operativo para ese sistema y esa sucursal. -### C. Advertencia de Conflicto +### C. Advertencia de conflicto -Si el rol seleccionado entra en conflicto con roles existentes o crea un riesgo de segregacion de funciones, el sistema advierte al aprobador antes de completar la decision. +Si el rol seleccionado entra en conflicto con roles existentes o crea un riesgo de segregación de funciones, el sistema advierte al aprobador antes de completar la decisión. ## 6. Reglas de Negocio -| Regla | Descripcion | -|---|---| -| BR-01 | La aprobacion debe limitarse al alcance de tenant o sucursal delegada del aprobador. | -| BR-02 | El aprobador puede otorgar el rol solicitado o un rol distinto dentro de su autoridad. | -| BR-03 | La denegacion debe mantener al usuario sin acceso operativo para el alcance solicitado. | -| BR-04 | El rol final, aprobador, fecha de decision y motivo deben quedar auditados. | -| BR-05 | Las advertencias de conflicto de roles y segregacion de funciones deben mostrarse antes de la aprobacion final cuando sean detectables. | +| Regla | Descripción | +| --- | --- | +| BR-01 | La aprobación debe limitarse al alcance del tenant o de la sucursal delegada del aprobador. | +| BR-02 | El aprobador puede otorgar el rol solicitado o un rol diferente dentro de su autoridad. | +| BR-03 | La denegación debe mantener al usuario sin acceso operativo para el alcance solicitado. | +| BR-04 | El rol final, el aprobador, la fecha de decisión y el motivo de la decisión deben ser auditables. | +| BR-05 | Las advertencias de conflicto de rol y segregación de funciones deben mostrarse antes de la aprobación final cuando puedan detectarse. | | BR-06 | Toda solicitud de perfil debe llegar a un estado terminal Aprobado o Denegado. | -| BR-07 | La decision final debe disparar una notificacion automatica al solicitante. | -| BR-08 | Las solicitudes no pueden eliminarse u ocultarse como sustituto de una decision final. | +| BR-07 | Una decisión final debe activar una notificación automática al solicitante. | +| BR-08 | Las solicitudes no pueden eliminarse ni ocultarse como sustituto de una decisión final. | -## 7. Criterios de Aceptacion +## 7. Criterios de Aceptación -| # | Criterio de Aceptacion | -|---|---| +| # | Criterio de Aceptación | +| --- | --- | | 1 | Un aprobador autorizado puede ver solicitudes de perfil pendientes dentro de su alcance. | | 2 | El aprobador puede aprobar el rol solicitado. | | 3 | El aprobador puede aprobar la solicitud con un rol diferente. | | 4 | El aprobador puede denegar la solicitud. | -| 5 | El sistema registra aprobador, fecha, rol otorgado y resultado de la decision. | -| 6 | El usuario recibe notificacion despues de aprobacion o denegacion. | -| 7 | El sistema advierte sobre conflictos de roles detectables antes de aprobar. | +| 5 | El sistema registra aprobador, fecha, rol otorgado y resultado de la decisión. | +| 6 | El usuario es notificado después de la aprobación o la denegación. | +| 7 | El sistema advierte sobre conflictos de rol detectables antes de la aprobación. | | 8 | Una solicitud decidida queda cerrada y ya no aparece como pendiente. | -## 8. Requisitos Tecnicos +## 8. Requisitos Técnicos -- Reutilizar o extender el modelo de aprobaciones para decisiones `PROFILE_ASSIGNMENT`. -- Guardar el rol solicitado y el rol otorgado por separado. -- Registrar metadata de decision en registros de auditoria inmutables. -- Invocar la asignacion de perfil solo despues de la aprobacion. -- Invalidar el grafo de autorizacion del usuario despues de asignar el perfil. -- Preparar el modelo de ruteo de aprobaciones para delegacion por sucursal. -- Exponer estados terminales como Aprobado y Denegado en vistas de usuario y auditoria, aunque eventos internos reutilicen terminologia existente de rechazo. -- Emitir eventos o plantillas de notificacion para resultados finales aprobados y denegados. +* Reutilizar o extender el modelo de solicitud de aprobación para decisiones de `PROFILE_ASSIGNMENT`. +* Almacenar por separado el rol solicitado y el rol otorgado. +* Registrar metadatos de decisión en registros de auditoría inmutables. +* Invocar la asignación de perfil solo después de aprobar. +* Invalidar el grafo de autorización del usuario después de la asignación de perfil. +* Preparar el modelo de enrutamiento de aprobación para la delegación con alcance de sucursal. +* Exponer los estados terminales como Aprobado y Denegado en las vistas de usuario y auditoría, aunque los eventos internos reutilicen la terminología de rechazo existente. +* Emitir eventos y plantillas de notificación para ambos resultados finales: aprobado y denegado. ## 9. Trazabilidad | Tipo | Referencias | -|---|---| -| Historias Relacionadas | FS-23, FS-05, FS-07, FS-14 | -| Entidades de Dominio | `ApprovalRequest`, `Profile`, `Role`, `UserAccount`, `Branch` | -| Eventos de Dominio | `ProfileAssignedToUserEvent`, `ApprovalRequestApprovedEvent`, `ApprovalRequestDeniedEvent` | +| --- | --- | +| Historias relacionadas | FS-23, FS-05, FS-07, FS-14 | +| Entidades de dominio | `ApprovalRequest`, `Profile`, `Role`, `UserAccount`, `Branch` | +| Eventos de dominio | `ProfileAssignedToUserEvent`, `ApprovalRequestApprovedEvent`, `ApprovalRequestDeniedEvent` | | Notificaciones | `ProfileRequestApproved`, `ProfileRequestDenied` | -| ADRs | ADR-0075, ADR-0071 | +| ADRs | ADR-UMS-075, ADR-UMS-088 | diff --git a/docs/governance/requirements-es/functional-stories/fs-25-seed-dataset.md b/docs/governance/requirements-es/functional-stories/fs-25-seed-dataset.md new file mode 100644 index 00000000..c81460a9 --- /dev/null +++ b/docs/governance/requirements-es/functional-stories/fs-25-seed-dataset.md @@ -0,0 +1,270 @@ +# Historia Funcional 25: Dataset de Datos de Prueba Orientado al Negocio de BEYONDNET + +> **Estado:** Propuesta +> +> **Tipo:** Historia técnica SDLC (semilla de datos / _seed dataset_). Sigue el [Estándar de Redacción de Historias Funcionales](./estandar-redaccion-historias-funcionales.md), separando la intención de negocio de los requisitos técnicos. + +## 1. Propósito de Negocio + +BEYONDNET es un operador logístico aduanero (RUC 20100412447, en operación desde 1978) con dos sucursales: Lima (sede central) y Paita (puerto norte, agroexportación). Ofrece a sus clientes —importadores, exportadores y terminales— una suite de sistemas a la que ellos acceden como cuentas externas acotadas. UMS provee la identidad y la autorización de toda esa suite. + +El dataset de arranque (_seed_) que UMS carga hoy en entornos de desarrollo describe una organización genérica que no representa el negocio real de BEYONDNET: el RUC y las sucursales del operador son incorrectos y solo existen dos suites (UMS, WMS) con roles genéricos en inglés. Esto obliga a cada demo, prueba de aceptación y sesión de descubrimiento a explicar por qué los datos no coinciden con el dominio, y erosiona la confianza en las demostraciones. + +Esta historia especifica un **dataset demo compacto pero representativo** del negocio de BEYONDNET: el operador con sus dos sucursales reales, seis suites logístico-aduaneras, roles y perfiles del oficio (agente de aduanas, despachador, jefe de almacén, etc.), usuarios internos por sucursal, clientes externos importadores/exportadores y los parámetros operativos de impo/expo. El objetivo de negocio es que cualquier demo, prueba de aceptación o incorporación (_onboarding_) parta de datos creíbles y coherentes con la operación real, sin inventar detalles que no puedan justificarse. + +## 2. Actores + +| Actor | Tipo | Responsabilidad en la historia | +| :--- | :--- | :--- | +| **Super-administrador (`admin@ums.local`)** | Principal | Administra globalmente la plataforma desde el tenant `INTERNAL_ADMIN`. Debe permanecer intacto tras cargar el nuevo dataset (misma credencial y acceso). | +| **Operador interno BEYONDNET (por sucursal)** | Principal | Personal de BEYONDNET en Lima (`UNI_LIMA`) o Paita (`BN_PAITA`) que opera las suites logístico-aduaneras según su rol (agente de aduanas, despachador, jefe de almacén, operario, coordinador de transporte, ejecutivo de cuenta, analista documentario, auditor, administrador). | +| **Cliente externo (importador/exportador/terminal)** | Secundario | Usuario de una empresa cliente (`CLIENT`) que accede de forma acotada, típicamente solo al Portal del Cliente, para consultar el estado de sus expedientes y documentos. | +| **Administrador de seguridad de BEYONDNET** | Secundario | Mantiene el catálogo de suites, roles, perfiles y plantillas del operador una vez sembrados. | +| **Sembrador del sistema (proceso de arranque)** | Secundario | Actor técnico (`ActorId` de sistema) que materializa el dataset de forma idempotente durante el arranque cuando `Persistence.SeedDevData=true`. | + +### 2.1 Diagrama de Interacción + +```mermaid +sequenceDiagram + actor SA as Super-administrador + actor OP as Operador BEYONDNET (por sucursal) + actor CE as Cliente externo + participant SEED as Sembrador (arranque) + participant UMS as UMS (Identidad + Autorización) + + SEED->>UMS: Materializa dataset (operador, suites, roles, perfiles, usuarios, parámetros) + Note over SEED,UMS: Idempotente — no altera al super-administrador ni datos existentes + + SA->>UMS: Inicia sesión (admin@ums.local) + UMS-->>SA: 200 OK (acceso global intacto) + + OP->>UMS: Inicia sesión (usuario interno @beyondnet.com.pe) + UMS-->>OP: Perfil + permisos de su sucursal y suites + + CE->>UMS: Inicia sesión (usuario cliente) + UMS-->>CE: Acceso acotado (solo Portal del Cliente) +``` + +## 3. Precondiciones de Negocio + +* UMS está desplegado en un entorno de desarrollo o demostración con la carga de datos de prueba habilitada (`Persistence.SeedDevData=true`). +* El tenant de administración interna (`INTERNAL_ADMIN`) y el super-administrador (`admin@ums.local`) existen y deben conservarse sin cambios. +* El catálogo de acciones estándar (VIEW, CREATE, READ, UPDATE, DELETE, APPROVE, SEARCH) está disponible para componer las suites. + +### 3.1 Contexto Acotado, Dependencias y Restricciones (S-04) + +* **Contexto acotado principal:** `Identity` (tenants, sucursales, cuentas de usuario) y `Authorization` (suites, módulos, menús, opciones, acciones, roles, plantillas y perfiles). Contextos de soporte: `Configuration` (parámetros, banderas de característica) y `Approvals` (flujos de aprobación). Referencias de dominio: [tenant](../dominio/identity/tenant.md), [sucursal](../dominio/identity/branch.md), [suite del sistema](../dominio/authorization/system-suite.md), [rol](../dominio/authorization/role.md), [perfil](../dominio/authorization/profile.md), [plantilla de permiso](../dominio/authorization/permission-template.md). +* **Dependencias:** el dataset consume los invariantes ya definidos en [FS-03 (Registrar Organización)](./fs-03-registrar-organizacion.md), [FS-04 (Registrar Sistema y Topología de Menú)](./fs-04-registrar-topologia-sistema.md), [FS-17 (Mantener Roles de una Suite)](./fs-17-mantener-roles-sistema.md), [FS-20 (Gestionar Parámetros)](./fs-20-gestion-parametros-sistema.md) y [FS-21 (Alta de Empresa)](./fs-21-aprobacion-solicitud-alta-tenant.md). No introduce reglas de dominio nuevas: solo instancia datos que respetan las existentes. +* **Restricciones:** + * El operador BEYONDNET se identifica por su GUID de tenant estable ya presente en el _seed_ (`5f4e3d2c-1b0a-9f8e-7d6c-543210987654`); el dataset **corrige** su RUC y sus sucursales, no crea un tenant nuevo. + * Alcance "demo compacto": no se modela la totalidad de la operación aduanera, sino un árbol plausible y suficiente para demostración y pruebas. + * La documentación y los nombres visibles de este dataset se mantienen en español (SD-08); los códigos técnicos (`code`) usan mayúsculas ASCII por convención de catálogo. + +## 4. Flujo Funcional Principal + +1. Al arrancar UMS con la carga de datos habilitada, el sembrador materializa el operador **BEYONDNET** como tenant proveedor (`SUPPLIER`) con RUC 20100412447 y exactamente dos sucursales: `UNI_LIMA` (Sede Central Lima) y `BN_PAITA` (Sucursal Paita). +2. El sembrador registra para BEYONDNET **seis suites del sistema**, cada una con su árbol de módulos → menús → opciones y su conjunto de acciones, cubriendo el ciclo logístico-aduanero: transporte, almacén, sistema integral logístico, aduanas, portal del cliente y facturación. +3. El sembrador registra el **catálogo de roles del oficio** de BEYONDNET y los agrupa en **perfiles reutilizables** con **plantillas de permiso** publicadas. +4. El sembrador registra las **empresas cliente** (`CLIENT`): las cinco actuales y dos o tres importadores/exportadores típicos, cada una con una sucursal. +5. El sembrador crea los **usuarios**: el super-administrador (intacto), los usuarios internos de BEYONDNET por sucursal y los usuarios cliente externos, todos con contraseña de desarrollo uniforme salvo el super-administrador que conserva la suya. +6. El sembrador registra los **parámetros de operación impo/expo** (incoterms, monedas, tipos de DUA), la bandera de característica `PAITA_AGROEXPORT` y uno o dos flujos de aprobación (alta de cliente y acceso a expediente). +7. Al finalizar, cualquier consulta de solo lectura sobre BEYONDNET refleja el negocio real y el super-administrador sigue accediendo con su credencial original. + +### 4.1 Operador, Sucursales y Clientes + +| Elemento | Código | Detalle | +| :--- | :--- | :--- | +| Operador | `BEYONDNET` | BeyondNet S.A.C., tipo `SUPPLIER`, RUC 20100412447. | +| Sucursal 1 | `BN_CALLAO` | Operaciones Callao (puerto principal). | +| Sucursal 2 | `BN_PAITA` | Sucursal Paita (puerto norte, agroexport). | + +Clientes `CLIENT` conservados: `RANSA` (Ransa Comercial), `NEPTUNIA`, `APM_CALLAO`, `PAITA_PORT`, `INTRADEVCO`. Clientes añadidos (importadores/exportadores típicos, una sucursal cada uno): + +| Cliente | Código | Perfil de negocio | Sucursal | +| :--- | :--- | :--- | :--- | +| Comercializadora Andina S.A.C. | `COMEX_ANDINA` | Importador (Lima) | `CAND_LIMA` (Almacén Lima) | +| Agroexportadora del Norte S.A.C. | `AGRONORTE` | Exportador agro (Paita) | `AGRN_PAITA` (Planta Paita) | +| Frutícola Piura S.A.C. | `FRUPIURA` | Exportador agro (Paita, opcional) | `FRPI_PAITA` (Packing Paita) | +| Importadora Sub-Cliente | `IMPO_ANDINA_SUB` | **Cliente del cliente**: tenant hijo (`ParentTenantId` = `COMEX_ANDINA`) | `IASUB_LIMA` | + +**Personas de acceso** (para probar; password dev `BeyondNet.Dev.2026`, salvo el super-admin de plataforma): + +| Persona | Tenant | Usuario | Sucursal | +| :--- | :--- | :--- | :--- | +| Super-admin de plataforma | `INTERNAL_ADMIN` | `admin@ums.local` (`root`) | — | +| **BEYONDNET root admin** | `BEYONDNET` | `admin@beyondnet.com.pe` | — (transversal) | +| BEYONDNET sucursal Callao | `BEYONDNET` | p. ej. `jefe.almacen.callao@beyondnet.com.pe` | `BN_CALLAO` | +| BEYONDNET sucursal Paita | `BEYONDNET` | p. ej. `jefe.almacen.paita@beyondnet.com.pe` | `BN_PAITA` | +| Cliente externo | `COMEX_ANDINA` | `usuario.impo@comexandina.com.pe` | `CAND_LIMA` | +| Cliente de mi cliente | `IMPO_ANDINA_SUB` | `usuario@impo-subcliente.com.pe` | `IASUB_LIMA` | + +### 4.2 Suites del Sistema y Árbol de Módulos + +Las seis suites y su árbol de módulos → menús → opciones propuesto (cada suite expone las acciones VIEW, CREATE, READ, UPDATE, DELETE, APPROVE, SEARCH): + +| Suite | Código | Módulos (código) | Menús principales por módulo | +| :--- | :--- | :--- | :--- | +| Transporte | `TMS` | Planificación (`PLAN`), Flota (`FLEET`), Seguimiento (`TRACK`) | `PLAN`: Órdenes de Transporte, Rutas · `FLEET`: Vehículos, Conductores · `TRACK`: Monitoreo, Hitos | +| Almacén | `WMS` | Inventario (`INV`), Recepción/Despacho (`RCV`), Reportes (`REPORTS`) | `INV`: Stock, Operaciones · `RCV`: Recepciones, Despachos · `REPORTS`: Reportes de Inventario, Importar/Exportar | +| Sistema Integral Logístico | `SIL` | Expedientes (`FILES`), Costos (`COST`), Trazabilidad (`TRACE`) | `FILES`: Expedientes Impo, Expedientes Expo · `COST`: Liquidación de Costos · `TRACE`: Línea de Tiempo | +| Aduanas | `ADUANAS` | Declaraciones (`DECL`), Canales (`CHANNEL`), Agentes (`AGENT`) | `DECL`: DUA Importación, DAM Exportación · `CHANNEL`: Rojo, Naranja, Verde · `AGENT`: Agentes de Aduana, Poderes | +| Portal del Cliente | `PORTAL_CLIENTE` | Consultas (`QUERY`), Notificaciones (`NOTIF`) | `QUERY`: Estado de Expediente, Documentos · `NOTIF`: Avisos | +| Facturación | `FACTURACION` | Facturación (`BILL`), Liquidación (`SETTLE`), Cobranzas (`COLLECT`) | `BILL`: Facturas, Notas de Crédito · `SETTLE`: Liquidaciones · `COLLECT`: Estado de Cuenta | + +### 4.3 Roles, Perfiles y Plantillas + +Roles del oficio (~10): + +| Rol | Código | Suite(s) de anclaje | +| :--- | :--- | :--- | +| Agente de Aduanas | `AGENTE_ADUANAS` | `ADUANAS` | +| Despachador | `DESPACHADOR` | `ADUANAS`, `SIL` | +| Jefe de Almacén | `JEFE_ALMACEN` | `WMS` | +| Operario de Almacén | `OPERARIO_ALMACEN` | `WMS` | +| Coordinador de Transporte | `COORD_TRANSPORTE` | `TMS` | +| Ejecutivo de Cuenta | `EJECUTIVO_CUENTA` | `SIL`, `PORTAL_CLIENTE` | +| Analista Documentario | `ANALISTA_DOC` | `SIL`, `ADUANAS` | +| Cliente Externo | `CLIENTE_EXTERNO` | `PORTAL_CLIENTE` (exclusivo) | +| Auditor | `AUDITOR` | Transversal (solo lectura) | +| Administrador | `ADMINISTRADOR` | Transversal (acceso total) | + +Perfiles reutilizables (~5) que agrupan roles y referencian plantillas de permiso publicadas: + +| Perfil | Código | Roles agrupados | +| :--- | :--- | :--- | +| Operaciones Aduanas | `PERF_OP_ADUANAS` | Agente de Aduanas, Despachador, Analista Documentario | +| Almacén | `PERF_ALMACEN` | Jefe de Almacén, Operario de Almacén | +| Transporte | `PERF_TRANSPORTE` | Coordinador de Transporte | +| Comercial | `PERF_COMERCIAL` | Ejecutivo de Cuenta | +| Cliente Externo | `PERF_CLIENTE_EXT` | Cliente Externo (solo Portal) | +| Administración | `PERF_ADMIN` | Administrador, Auditor | + +Cada rol posee una **plantilla de permiso** publicada y reutilizable, alineada con su alcance (p. ej. `CLIENTE_EXTERNO` solo obtiene navegación y lectura en `PORTAL_CLIENTE`; `AUDITOR` obtiene lectura transversal sin acciones de escritura). + +### 4.4 Usuarios (~14 + super-administrador) + +| Sucursal / origen | Correo | Rol | +| :--- | :--- | :--- | +| INTERNAL_ADMIN | `admin@ums.local` | Super-administrador (intacto) | +| UNI_LIMA | `admin.lima@beyondnet.com.pe` | Administrador | +| UNI_LIMA | `agente.aduanas.lima@beyondnet.com.pe` | Agente de Aduanas | +| UNI_LIMA | `despachador.lima@beyondnet.com.pe` | Despachador | +| UNI_LIMA | `jefe.almacen.lima@beyondnet.com.pe` | Jefe de Almacén | +| UNI_LIMA | `coordinador.transporte.lima@beyondnet.com.pe` | Coordinador de Transporte | +| UNI_LIMA | `ejecutivo.cuenta.lima@beyondnet.com.pe` | Ejecutivo de Cuenta | +| UNI_LIMA | `analista.doc.lima@beyondnet.com.pe` | Analista Documentario | +| UNI_LIMA | `auditor.lima@beyondnet.com.pe` | Auditor | +| BN_PAITA | `jefe.almacen.paita@beyondnet.com.pe` | Jefe de Almacén | +| BN_PAITA | `operario.almacen.paita@beyondnet.com.pe` | Operario de Almacén | +| BN_PAITA | `agente.aduanas.paita@beyondnet.com.pe` | Agente de Aduanas | +| BN_PAITA | `ejecutivo.cuenta.paita@beyondnet.com.pe` | Ejecutivo de Cuenta (agroexport) | +| COMEX_ANDINA (cliente) | `usuario.impo@comexandina.com.pe` | Cliente Externo | +| AGRONORTE (cliente) | `usuario.expo@agronorte.com.pe` | Cliente Externo | + +Todos los usuarios internos y de cliente reciben una contraseña de desarrollo uniforme (mínimo 12 caracteres; p. ej. `BeyondNet.Dev.2026`). El super-administrador conserva su credencial actual (`admin@ums.local` / `root`) sin alteración. + +### 4.5 Operación Impo/Expo + +* **Parámetros:** incoterms (`INCOTERM`: FOB, CIF, EXW, FCA, CFR, CPT), monedas (`MONEDA`: USD, PEN), tipos de declaración (`TIPO_DUA`: Importación Definitiva, Exportación Definitiva, Admisión Temporal). +* **Bandera de característica:** `PAITA_AGROEXPORT`, acotada al tenant BEYONDNET (habilita comportamiento específico de agroexportación en la sucursal Paita). +* **Flujos de aprobación:** `ALTA_CLIENTE` (aprobación de alta de una empresa cliente) y `ACCESO_EXPEDIENTE` (aprobación de acceso externo a un expediente). + +## 5. Flujos Alternativos y Excepciones + +### A. Dataset ya materializado (idempotencia) + +Si el dataset ya fue cargado en un arranque previo, un nuevo arranque no duplica suites, roles, perfiles, usuarios ni parámetros: cada elemento se reconcilia por su código o identificador estable (upsert), conservando los identificadores que otros datos ya referencian. + +### B. Snapshot heredado con datos antiguos del operador + +Si la base de datos contiene una versión previa de BEYONDNET con RUC y sucursales incorrectas, el dataset corrige el RUC a 20100412447 y reconcilia las sucursales al par `UNI_LIMA`/`BN_PAITA` sin recrear el tenant ni romper referencias existentes. + +### C. Carga de datos deshabilitada + +Si `Persistence.SeedDevData` no está activo, no se materializa ningún dato; el entorno permanece con lo que exista, sin efectos colaterales. + +### D. Fallo parcial durante la carga + +Si un contexto (identidad, autorización, configuración o aprobaciones) falla al sembrar, el fallo se aísla y se reporta sin dejar al super-administrador inaccesible ni corromper los datos ya cargados por otros contextos. + +## 6. Reglas de Negocio + +1. El operador BEYONDNET es un tenant `SUPPLIER` único, con RUC 20100412447 y exactamente dos sucursales activas (`UNI_LIMA`, `BN_PAITA`). +2. El super-administrador (`admin@ums.local`, tenant `INTERNAL_ADMIN`) nunca se altera, bloquea ni pierde su perfil por efecto del dataset. +3. BEYONDNET expone exactamente seis suites con códigos `TMS`, `WMS`, `SIL`, `ADUANAS`, `PORTAL_CLIENTE` y `FACTURACION`; cada código de suite es único dentro del tenant. +4. Cada rol pertenece a una sola suite y a un tenant, con `code`, `value` y `description` definidos y código único dentro de su suite (coherente con FS-17). +5. El rol Cliente Externo solo concede navegación y lectura en `PORTAL_CLIENTE`; nunca perfiles administrativos internos. +6. Cada empresa cliente (`CLIENT`) tiene al menos una sucursal; los importadores/exportadores añadidos representan un caso impo (Lima) y un caso expo agro (Paita). +7. Los parámetros de operación impo/expo (incoterms, monedas, tipos de DUA) existen a nivel del tenant BEYONDNET con valores válidos del dominio. +8. La bandera `PAITA_AGROEXPORT` está acotada al tenant BEYONDNET. +9. La materialización del dataset es idempotente: repetir el arranque no crea duplicados ni cambia identificadores estables ya referenciados. + +## 7. Criterios de Aceptación + +Los criterios son verificables mediante los endpoints REST de UMS, ejecutados en el contexto del tenant correspondiente. `{beyondNetId}` = `5f4e3d2c-1b0a-9f8e-7d6c-543210987654`. + +1. **Super-administrador intacto:** `POST /api/v1/auth/login` con `admin@ums.local` / `root` devuelve `200 OK` y una sesión válida. +2. **Operador correcto:** `GET /tenants/{beyondNetId}` devuelve un tenant tipo `SUPPLIER` cuyo RUC es `20100412447`. +3. **Dos sucursales:** `GET /tenants/{beyondNetId}/branches` devuelve exactamente 2 sucursales con códigos `{UNI_LIMA, BN_PAITA}`. +4. **Seis suites:** en el contexto del tenant BEYONDNET, `GET /system-suites` devuelve 6 suites cuyos códigos son exactamente `{TMS, WMS, SIL, ADUANAS, PORTAL_CLIENTE, FACTURACION}`. +5. **Árbol de suite no vacío:** para cada una de las 6 suites, `GET /system-suites/{systemSuiteId}` reporta al menos 2 módulos activos, y cada módulo al menos 1 menú con al menos 1 opción. +6. **Roles de aduanas:** `GET /system-suites/{aduanasId}/roles` incluye roles con códigos `AGENTE_ADUANAS` y `DESPACHADOR`. +7. **Rol de almacén asociable a Paita:** existe un usuario interno de BEYONDNET con correo `jefe.almacen.paita@beyondnet.com.pe` cuyo perfil referencia el rol `JEFE_ALMACEN`, verificable vía `GET /user-accounts` (filtrado por tenant BEYONDNET) y `GET /profiles`. +8. **Perfiles y plantillas:** `GET /profiles` (contexto BEYONDNET) devuelve perfiles **por-usuario** que encarnan los arquetipos operativos (Operaciones Aduanas, Almacén, Transporte, Comercial, Cliente Externo, Administración), cada uno con sus roles/plantillas correctos; `GET /permission-templates` devuelve al menos 6 plantillas en estado publicado. _Nota:_ el agregado `Profile` no expone un `code` de arquetipo ([G-022](../../../GAPS.md)); la verificación por código de perfil no aplica con el modelo actual. +9. **Cliente externo acotado:** existe un usuario cliente (p. ej. `usuario.impo@comexandina.com.pe`) cuyo perfil concede acceso únicamente a la suite `PORTAL_CLIENTE` (ninguna plantilla suya referencia otra suite). +10. **Clientes importador/exportador:** `GET /tenants` incluye tenants `CLIENT` con códigos `COMEX_ANDINA` (importador Lima) y `AGRONORTE` (exportador Paita), además de los cinco preexistentes `{RANSA, NEPTUNIA, APM_CALLAO, PAITA_PORT, INTRADEVCO}`. +11. **Parámetros impo/expo:** las consultas de parámetros del tenant BEYONDNET exponen definiciones con códigos `INCOTERM`, `MONEDA` y `TIPO_DUA`; la moneda incluye los valores `USD` y `PEN`. +12. **Bandera de característica:** `GET /feature-flags` (contexto BEYONDNET) incluye una bandera con código `PAITA_AGROEXPORT`. +13. **Flujos de aprobación:** existen flujos de aprobación del tenant BEYONDNET con códigos `ALTA_CLIENTE` y `ACCESO_EXPEDIENTE`, verificables vía las consultas de aprobación. +14. **Idempotencia:** ejecutar el arranque dos veces con la carga habilitada no incrementa el número de suites, sucursales, roles ni perfiles reportados por los endpoints anteriores (los conteos de los criterios 3, 4, 8 permanecen constantes). + +## 8. Requisitos Técnicos + +* La materialización vive en los _seeders_ code-first bajo [`src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/`](../../../src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/), orquestados por [`CoreDevDataSeeder.SeedAllAsync`](../../../src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/CoreDevDataSeeder.cs) y ejecutados en el arranque cuando `Persistence.SeedDevData=true`. +* _Seeders_ impactados y su alcance: + * [`IdentityDevDataSeeder`](../../../src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/IdentityDevDataSeeder.cs): corrige el tenant BEYONDNET (RUC, sucursales `UNI_LIMA`/`BN_PAITA`), añade los clientes importador/exportador y crea los usuarios internos por sucursal y los usuarios cliente. El super-administrador y el patrón de upsert por correo/GUID se conservan. + * [`AuthorizationDevDataSeeder`](../../../src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/AuthorizationDevDataSeeder.cs): sustituye las 2 suites genéricas por las 6 suites del dominio con su árbol módulos → menús → opciones, registra los ~10 roles del oficio, sus plantillas de permiso publicadas y los ~6 perfiles. + * [`ConfigurationDevDataSeeder`](../../../src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/ConfigurationDevDataSeeder.cs) y [`ParameterCatalogSeeder`](../../../src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/ParameterCatalogSeeder.cs): añaden los parámetros impo/expo (`INCOTERM`, `MONEDA`, `TIPO_DUA`) y la bandera `PAITA_AGROEXPORT` acotada a BEYONDNET. + * [`ApprovalsDevDataSeeder`](../../../src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/ApprovalsDevDataSeeder.cs): añade los flujos `ALTA_CLIENTE` y `ACCESO_EXPEDIENTE` para el tenant BEYONDNET. + * [`AuditDevDataSeeder`](../../../src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/AuditDevDataSeeder.cs): sin cambios funcionales; solo debe seguir corriendo sin romperse ante los nuevos datos. +* **Idempotencia (invariante técnico):** cada entidad se reconcilia por su clave estable —código de tenant/suite/rol/perfil o GUID determinista— usando upsert; nunca se recrean identificadores que perfiles, plantillas o cuentas ya referencian. El tenant BEYONDNET se reconcilia por su GUID existente `5f4e3d2c-1b0a-9f8e-7d6c-543210987654`; el super-administrador (`22222222-2222-2222-2222-222222222222`) y su perfil se preservan explícitamente. +* **Aislamiento por tenant:** todos los datos sembrados llevan el `TenantId` de BEYONDNET (o del cliente correspondiente); el filtrado por tenant en la capa de aplicación permanece como control primario. +* **Contrato de lectura:** las suites se consultan con `GET /system-suites` y `GET /system-suites/{systemSuiteId}`; roles con `GET /system-suites/{systemSuiteId}/roles`; perfiles con `GET /profiles`; plantillas con `GET /permission-templates`; tenant y sucursales con `GET /tenants/{tenantId}` y `GET /tenants/{tenantId}/branches`; banderas con `GET /feature-flags`; cuentas con `GET /user-accounts`. +* **Entidades persistidas:** PostgreSQL con EF Core; los `code` de catálogo usan mayúsculas ASCII y los nombres visibles (`value`/`Name`) se mantienen en español. + +## 9. Trazabilidad + +* Entidades: `Tenant`, `Branch`, `UserAccount`, `SystemSuite`, `Module`, `Menu`, `Option`, `Role`, `PermissionTemplate`, `Profile`, `TenantParameter`, `FeatureFlag`, `ApprovalWorkflow`. +* Historias relacionadas: [FS-03](./fs-03-registrar-organizacion.md), [FS-04](./fs-04-registrar-topologia-sistema.md), [FS-17](./fs-17-mantener-roles-sistema.md), [FS-20](./fs-20-gestion-parametros-sistema.md), [FS-21](./fs-21-aprobacion-solicitud-alta-tenant.md). +* Documentación de solución y producto: [PRD BEYONDNET UMS](../../01-concepcion/PRD-UMS-001.es.md), [Arquitectura de la solución](../arquitectura-solucion.md). +* Artefactos operativos: _seeders_ code-first en [`.../Persistence/Seeders/`](../../../src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/), activados por `Persistence.SeedDevData=true`. + +## 10. Modelo del Dataset (Operador → Suites → Clientes/Usuarios) + +```mermaid +graph TD + BEYONDNET["BEYONDNET (SUPPLIER) - RUC 20100412447"] + BEYONDNET --> LIMA["Sucursal UNI_LIMA"] + BEYONDNET --> PAITA["Sucursal BN_PAITA"] + + BEYONDNET --> SUITES{{"6 Suites del Sistema"}} + SUITES --> TMS[TMS] + SUITES --> WMS[WMS] + SUITES --> SIL[SIL] + SUITES --> ADU[ADUANAS] + SUITES --> PORTAL[PORTAL_CLIENTE] + SUITES --> FACT[FACTURACION] + + LIMA --> UINT_L["Usuarios internos Lima (Agente, Despachador, Jefe Almacen, Coord. Transporte, Ejecutivo, Analista, Auditor, Admin)"] + PAITA --> UINT_P["Usuarios internos Paita (Jefe/Operario Almacen, Agente, Ejecutivo agro)"] + + BEYONDNET --> CLIENTES{{"Empresas Cliente (CLIENT)"}} + CLIENTES --> C5["5 preexistentes: RANSA, NEPTUNIA, APM_CALLAO, PAITA_PORT, INTRADEVCO"] + CLIENTES --> IMPO["COMEX_ANDINA (impo Lima)"] + CLIENTES --> EXPO["AGRONORTE (expo Paita)"] + + IMPO --> UEXT_I["usuario.impo (Cliente Externo)"] + EXPO --> UEXT_E["usuario.expo (Cliente Externo)"] + UEXT_I -.acceso acotado.-> PORTAL + UEXT_E -.acceso acotado.-> PORTAL +``` diff --git a/docs/governance/requirements-es/functional-stories/fs-26-admin-root-tenant-owner.md b/docs/governance/requirements-es/functional-stories/fs-26-admin-root-tenant-owner.md new file mode 100644 index 00000000..dd7819e1 --- /dev/null +++ b/docs/governance/requirements-es/functional-stories/fs-26-admin-root-tenant-owner.md @@ -0,0 +1,160 @@ +# Historia Funcional 26: BEYONDNET como Admin Root y Tenant Owner en una sola identidad + +> **Estado:** Propuesta +> +> **Tipo:** Historia técnica SDLC (modelo de identidad multi-tenant). Sigue el [Estándar de Redacción de Historias Funcionales](./estandar-redaccion-historias-funcionales.md), separando la intención de negocio de los requisitos técnicos. + +## 1. Propósito de Negocio + +BEYONDNET opera el ecosistema UMS con **dos sombreros a la vez**: es el **administrador global** que da de alta y gobierna a todos los tenants (clientes, proveedores, sub-clientes), y es también **una organización operativa propia** —con RUC 20100412447, sus sucursales de Callao y Paita, sus usuarios internos, su configuración y sus parámetros— que necesita administrarse a sí misma como cualquier otro tenant. + +Hoy esos dos sombreros están repartidos entre **dos tenants distintos** que no coinciden. Al iniciar sesión como «Admin UMS» se entra al tenant sintético `INTERNAL_ADMIN` (el único con el atributo de propietario de gestión, que otorga la visión global), mientras que `BEYONDNET` existe como un tenant `SUPPLIER` separado, sin ese atributo. La consecuencia práctica es que **el administrador global no puede verse ni administrarse como el tenant BEYONDNET**: no accede a los datos generales de BEYONDNET, sus sucursales, usuarios, configuración, parámetros ni a su auditoría desde su propia sesión. La organización que gobierna el sistema es, paradójicamente, invisible para sí misma. + +Esta historia especifica la **unificación de la identidad de BEYONDNET**: un único tenant que es a la vez **Admin Root** (propietario de gestión, con gobierno transversal) y **Tenant Owner** (organización operativa administrable), con una **separación clara y controlada entre la administración global de la plataforma y la administración de la propia casa**. El objetivo de negocio es que BEYONDNET, sin perder su rol de administrador del ecosistema, pueda ver y administrar su propio tenant —incluidas sus sucursales y datos operativos— manteniendo el aislamiento multi-tenant, la trazabilidad y una gobernanza correcta. + +## 2. Actores + +| Actor | Tipo | Responsabilidad en la historia | +| :--- | :--- | :--- | +| **Admin Root BEYONDNET** | Principal | Administrador de plataforma: da de alta y gobierna a todos los tenants del ecosistema. Su poder transversal deriva de que su tenant (BEYONDNET) es propietario de gestión (`IsManagementOwner`). Activa explícitamente el modo cross-tenant para operar sobre otros tenants. | +| **Tenant Admin BEYONDNET** | Principal | Administra la propia organización BEYONDNET: datos generales, sucursales, usuarios internos, configuración, parámetros y auditoría, acotado al `OrganizationId` de BEYONDNET (sin bypass global). | +| **Branch Admin BEYONDNET** | Secundario | Administra una sucursal concreta (Callao o Paita); su perfil está acotado por `BranchId` (`ProfileScope = BranchScoped`). | +| **Usuario interno BEYONDNET** | Secundario | Personal de BEYONDNET con roles del oficio (agente de aduanas, jefe de almacén, etc.), acotado a su tenant y sucursal. | +| **Usuario externo (cliente / cliente del cliente)** | Secundario | Usuario de una empresa cliente (`CLIENT`), acotado a su propio tenant y típicamente solo al Portal del Cliente. | +| **Cuenta de emergencia de plataforma (break-glass)** | Secundario | Cuenta técnica de último recurso (`admin@ums.local` en el tenant `INTERNAL_ADMIN` degradado) para recuperación si BEYONDNET quedara inaccesible. No es la vía ordinaria de administración. | + +### 2.1 Diagrama de Interacción + +```mermaid +sequenceDiagram + actor AR as Admin Root BEYONDNET + actor TA as Tenant Admin BEYONDNET + participant UMS as UMS (Identidad + Autorización) + + AR->>UMS: Inicia sesión (admin@beyondnet.com.pe) + UMS-->>AR: Sesión con is_internal_admin=true (BEYONDNET es propietario de gestión) + + Note over AR,UMS: Sombrero Owner (por defecto) — acotado a BEYONDNET + AR->>UMS: GET /tenants/{beyondNetId} · sucursales · usuarios · parámetros · auditoría + UMS-->>AR: Datos de la propia organización BEYONDNET + + Note over AR,UMS: Sombrero Root (explícito) — cross-tenant + AR->>UMS: Activa modo global (switch-tenant / cross-tenant) + AR->>UMS: GET /tenants (todos) · alta y gobierno de otros tenants + UMS-->>AR: Visión transversal del ecosistema + + TA->>UMS: Inicia sesión (tenant-admin de BEYONDNET, sin gestión global) + UMS-->>TA: Acceso acotado a BEYONDNET y sus sucursales (sin ver otros tenants) +``` + +## 3. Precondiciones de Negocio + +* UMS está desplegado con el dataset de BEYONDNET materializado ([FS-25](./fs-25-dataset-semilla-beyondnet.md)): el operador BEYONDNET, sus dos sucursales, sus suites, roles, perfiles y usuarios existen. +* El tenant de administración interna (`INTERNAL_ADMIN`) y su super-administrador (`admin@ums.local`) existen y se conservan como cuenta de emergencia. +* El agregado `Tenant` ya soporta los conceptos necesarios: `IsManagementOwner`, `ParentTenantId`, `Type`, `Status`, y como hijos `Branch`, `TenantParameter`, `IdentityProvider`. + +### 3.1 Contexto Acotado, Dependencias y Restricciones (S-04) + +* **Contexto acotado principal:** `Identity` (tenants, sucursales, cuentas de usuario) y `Authorization` (suites, roles, plantillas, perfiles). Contextos de soporte: `Configuration` (parámetros, banderas) y `Audit` (auditoría por tenant). Referencias de dominio: [tenant](../dominio/identity/tenant.md), [sucursal](../dominio/identity/branch.md). +* **Dependencias:** consume el dataset de [FS-25](./fs-25-dataset-semilla-beyondnet.md), el registro de organización de [FS-03](./fs-03-registrar-organizacion.md), la configuración jerárquica de [FS-13](./fs-13-configuracion-jerarquica.md) y la gestión delegada de [FS-14](./fs-14-gestion-delegada.md). No introduce un agregado nuevo: reconfigura la identidad y abre operaciones de administración sobre entidades existentes. +* **Restricciones:** + * **Invariante de unicidad:** existe **exactamente un** tenant propietario de gestión (Admin Root) en el ecosistema. La promoción de BEYONDNET implica que ningún otro tenant conserve ese atributo. + * **Separación de sombreros:** administrar la propia casa de BEYONDNET opera acotado a su `OrganizationId`; el gobierno de otros tenants exige activar el modo cross-tenant de forma explícita y auditable. La administración ordinaria de BEYONDNET **no** debe requerir el bypass global. + * La documentación y los nombres visibles se mantienen en español (SD-08); los códigos técnicos usan mayúsculas ASCII. + * **Trazabilidad a decisión arquitectónica (S-06):** la promoción de BEYONDNET a Admin Root único es una decisión de arquitectura que requiere un ADR **aceptado** en `evolith-core` antes de ejecutarse en producción. No se resuelve inventando la decisión en este satélite; la dependencia queda registrada en [GAPS.md](../../../GAPS.md). + +## 4. Flujo Funcional Principal + +1. **Unificación de identidad.** El tenant `BEYONDNET` pasa a ser el **propietario de gestión** del ecosistema (`Type = INTERNAL`, `IsManagementOwner = true`, `ParentTenantId = null` → raíz). El tenant sintético `INTERNAL_ADMIN` se degrada a cuenta de emergencia (deja de ser propietario de gestión). +2. **Admin Root — sombrero Owner (por defecto).** Al iniciar sesión, el Admin Root de BEYONDNET opera acotado a su propio tenant: consulta y administra los datos generales de BEYONDNET, sus sucursales, sus usuarios internos, su configuración, sus parámetros y su auditoría, como cualquier tenant-admin. +3. **Admin Root — sombrero Root (explícito).** Cuando necesita gobernar el ecosistema, activa el modo cross-tenant de forma explícita: entonces `GET /tenants` devuelve todos los tenants y puede darlos de alta y administrarlos. Al salir del modo, vuelve a quedar acotado a BEYONDNET. +4. **Tenant Admin y Branch Admin.** Un administrador de BEYONDNET sin gestión global administra la organización (o su sucursal) sin ver otros tenants. El alcance de sucursal lo determina `BranchId`/`ProfileScope`. +5. **Interfaz separada.** La interfaz distingue de forma explícita **«Administración Global»** (solo Admin Root, en modo cross-tenant) de **«Mi Organización»** (el propio tenant BEYONDNET con sus sucursales, usuarios, configuración y auditoría), e indica visualmente que BEYONDNET es el **Tenant Root**. +6. **Persistencia de la administración.** Editar los datos generales de BEYONDNET y de sus sucursales **persiste** contra el backend (no queda como cambio local). + +## 5. Flujos Alternativos y Excepciones + +### A. Intento de crear un segundo propietario de gestión + +Si se intenta marcar un segundo tenant como propietario de gestión, la operación se rechaza: el invariante de unicidad garantiza un único Admin Root. + +### B. Administración de otro tenant sin activar el modo global + +Si el Admin Root intenta administrar otro tenant sin haber activado el modo cross-tenant, la operación queda acotada a BEYONDNET y no afecta al otro tenant. + +### C. BEYONDNET inaccesible (recuperación) + +Si BEYONDNET quedara inaccesible (p. ej. suspensión accidental), la cuenta de emergencia (`admin@ums.local` en `INTERNAL_ADMIN`) permite recuperar el acceso y reactivar BEYONDNET. + +### D. Tenant Admin intenta ver el ecosistema + +Un tenant-admin de BEYONDNET sin gestión global que consulta `GET /tenants` solo obtiene su propio tenant y sus hijos; nunca la lista completa. + +## 6. Reglas de Negocio + +1. Existe **exactamente un** tenant propietario de gestión (Admin Root) en el ecosistema; tras esta historia es **BEYONDNET**. +2. El poder de administración global (`is_internal_admin`) deriva del atributo del tenant (`IsManagementOwner`), no de un usuario suelto ni del tipo de organización. +3. El Admin Root de BEYONDNET opera **por defecto acotado** a su propio tenant; la visión y administración cross-tenant requiere activación explícita. +4. BEYONDNET es un tenant raíz (`ParentTenantId = null`) de tipo `INTERNAL`, con sus dos sucursales (`BN_CALLAO`, `BN_PAITA`) y su RUC 20100412447 intactos. +5. Editar los datos generales de un tenant o de una sucursal es una operación que **persiste** y queda auditada. +6. La administración de la propia casa de BEYONDNET está disponible para su Tenant Admin **sin** requerir el atributo de propietario de gestión. +7. La cuenta de emergencia `admin@ums.local` (`INTERNAL_ADMIN`) se conserva, degradada a último recurso, y nunca se elimina. +8. El aislamiento multi-tenant se mantiene: un tenant no propietario de gestión solo ve su propio tenant y sus hijos. + +## 7. Criterios de Aceptación + +Verificables mediante los endpoints REST de UMS en el contexto del tenant correspondiente. `{beyondNetId}` = `5f4e3d2c-1b0a-9f8e-7d6c-543210987654`; `{internalAdminId}` = `11111111-1111-1111-1111-111111111111`. + +1. **BEYONDNET es Admin Root:** `GET /tenants/{beyondNetId}` (contexto Admin Root) devuelve `isManagementOwner = true`, `type = INTERNAL` y `parentTenantId = null`. +2. **Unicidad de propietario de gestión:** `GET /tenants` (contexto Admin Root, modo global) devuelve **exactamente un** tenant con `isManagementOwner = true`, y su código es `BEYONDNET`. +3. **INTERNAL_ADMIN degradado:** `GET /tenants/{internalAdminId}` devuelve `isManagementOwner = false`. +4. **Login otorga gobierno global:** `POST /api/v1/auth/login` con `admin@beyondnet.com.pe` devuelve una sesión con `isInternalAdmin = true`. +5. **Sombrero Owner — visión propia:** en el contexto de BEYONDNET (sin modo global), `GET /tenants/{beyondNetId}/branches` devuelve las 2 sucursales `{BN_CALLAO, BN_PAITA}`, y `GET /user-accounts` devuelve los usuarios internos de BEYONDNET. +6. **Sombrero Root — visión global:** con el modo cross-tenant activo, `GET /tenants` devuelve la totalidad de los tenants del ecosistema (los 11 del dataset FS-25). +7. **Edición persistente de tenant:** una petición de actualización de los datos generales de BEYONDNET (nombre, referencia fiscal) se refleja en una consulta posterior `GET /tenants/{beyondNetId}` (no es un cambio solo-cliente). +8. **Edición persistente de sucursal:** una petición de actualización de una sucursal de BEYONDNET se refleja en `GET /tenants/{beyondNetId}/branches`. +9. **Aislamiento del tenant-admin:** en el contexto de un tenant-admin de BEYONDNET **sin** gestión global, `GET /tenants` devuelve únicamente BEYONDNET (y sus hijos, si los hubiera), nunca otros tenants. +10. **Auditoría por tenant:** las consultas de auditoría en el contexto de BEYONDNET exponen los registros de auditoría acotados a ese tenant. +11. **Break-glass operativo:** `POST /api/v1/auth/login` con `admin@ums.local` / `root` sigue devolviendo `200 OK` con acceso de recuperación. +12. **Indicador visual de Tenant Root (interfaz):** en la pantalla de administración de tenants, el tenant BEYONDNET se muestra con un indicador de «Tenant Root / Admin Root», y la navegación separa «Administración Global» de «Mi Organización». + +## 8. Requisitos Técnicos + +* **Fuente del poder global.** `is_internal_admin` se deriva en el login de `Tenant.IsManagementOwner` ([AuthEndpoints.cs](../../../src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Auth/AuthEndpoints.cs)); no requiere cambio de mecanismo, solo trasladar el atributo de propietario de gestión de `INTERNAL_ADMIN` a `BEYONDNET`. +* **Seed / migración de identidad.** Ajustar los _seeders_ ([IdentityDevDataSeeder.cs](../../../src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/IdentityDevDataSeeder.cs), [CoreDevDataSeeder.cs](../../../src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/CoreDevDataSeeder.cs)) para que `BEYONDNET` sea `INTERNAL` + propietario de gestión y `INTERNAL_ADMIN` deje de serlo, de forma idempotente y sin recrear GUID estables. Para entornos ya desplegados, la transición equivalente es una migración de datos, no de esquema. +* **Invariante de unicidad (BD).** Añadir un índice único filtrado sobre la tabla de tenants `WHERE is_management_owner = true`, de modo que la base de datos garantice un único Admin Root. No requiere columnas nuevas. +* **Persistencia de administración (huecos actuales).** Hoy la edición de datos generales de tenant y de sucursales **no persiste** (no existen endpoints de actualización; el frontend aplica un _override_ local). Introducir los comandos y endpoints de actualización de tenant y de sucursal, con su método de repositorio. Registrado en [GAPS.md](../../../GAPS.md). +* **Separación de sombreros (scoping).** La administración de la propia casa de BEYONDNET opera con `OrganizationId = BEYONDNET`; el gobierno de otros tenants usa la activación explícita de cross-tenant ya existente en [TenantContext.cs](../../../src/apps/ums.api/Ums.Infrastructure/Persistence/TenantContext.cs) (`EnableCrossTenantAccess`). La política [TenantScopePolicy.cs](../../../src/apps/ums.api/Ums.Application/Common/Services/TenantScopePolicy.cs) (`EnsureManagementOwnerScopeAsync`) debe permitir que el Admin Root, en modo global, administre sucursales de otros tenants; el tenant-admin ordinario permanece acotado a su casa. +* **Aislamiento.** El control primario de aislamiento son los _query filters_ de EF Core ([UmsPlatformDbContext.cs](../../../src/apps/ums.api/Ums.Infrastructure/Persistence/UmsPlatformDbContext.cs)); el interceptor de BD es un no-op bajo PostgreSQL ([G-020](../../../GAPS.md)). El diseño no debilita ese control: la visión global sigue exigiendo `OrganizationId = null` bajo el sombrero Root explícito. +* **Contexto de tenant en producción.** El envío de `X-Tenant-Id` desde el frontend solo inyecta el tenant de sesión en modo desarrollo; en producción cae a un tenant por defecto. Debe configurarse el proveedor de contexto de producción desde la sesión. Registrado en [GAPS.md](../../../GAPS.md). +* **Interfaz.** El frontend ya dispone del andamiaje (master-detail de tenants con pestañas de sucursales, proveedores y configuraciones; `switchTenant` en el store; _toggle_ de propietario de gestión). Falta: una vista «Mi Organización» (atajo al propio tenant), el indicador visual de Tenant Root, las pestañas de usuarios y auditoría dentro del detalle del tenant, y un cambiador de tenant global para el Admin Root. + +## 9. Trazabilidad + +* Entidades: `Tenant`, `Branch`, `UserAccount`, `Profile`, `TenantParameter`, `AuditRecord`. +* Historias relacionadas: [FS-03](./fs-03-registrar-organizacion.md), [FS-13](./fs-13-configuracion-jerarquica.md), [FS-14](./fs-14-gestion-delegada.md), [FS-17](./fs-17-mantener-roles-sistema.md), [FS-25](./fs-25-dataset-semilla-beyondnet.md). +* Documentación de solución y producto: [PRD BEYONDNET UMS](../../01-concepcion/PRD-UMS-001.es.md), [Arquitectura de la solución](../arquitectura-solucion.md). +* **Decisión arquitectónica (S-06):** «BEYONDNET como único Admin Root con sombreros Owner/Root separados» requiere un ADR **aceptado** en `evolith-core`. No existe aún; la dependencia se registra como gap y la ejecución en producción queda condicionada a su aceptación. +* Gaps abiertos por esta historia en [GAPS.md](../../../GAPS.md): identidad dividida de BEYONDNET, edición no persistente de tenant/sucursal, unicidad de propietario de gestión, contexto de tenant en producción, auditoría por tenant no expuesta. + +## 10. Modelo de Identidad Propuesto (Admin Root + Tenant Owner) + +```mermaid +graph TD + subgraph ECOSISTEMA["Ecosistema UMS"] + BEYONDNET["BEYONDNET (INTERNAL) - Admin Root + Tenant Owner\nIsManagementOwner = true · ParentTenantId = null"] + BG["INTERNAL_ADMIN (degradado)\nBreak-glass · IsManagementOwner = false"] + CLI["Tenants CLIENT / SUPPLIER\n(COMEX_ANDINA, AGRONORTE, RANSA, ...)"] + SUB["IMPO_ANDINA_SUB\n(cliente del cliente, ParentTenantId)"] + end + + BEYONDNET --> CALLAO["Sucursal BN_CALLAO"] + BEYONDNET --> PAITA["Sucursal BN_PAITA"] + + BEYONDNET -. "sombrero Root (cross-tenant explícito)" .-> CLI + BEYONDNET -. gobierna .-> BG + CLI --> SUB + + BEYONDNET --> OWNER{{"Sombrero Owner (por defecto)\nDatos, sucursales, usuarios,\nconfiguración, parámetros, auditoría"}} + BEYONDNET --> ROOT{{"Sombrero Root (explícito)\nAlta y gobierno de todos los tenants"}} +``` diff --git a/docs/governance/requirements-es/functional-stories/fs-28-access-review-campaigns.md b/docs/governance/requirements-es/functional-stories/fs-28-access-review-campaigns.md index 0eeb944c..b8b2b537 100644 --- a/docs/governance/requirements-es/functional-stories/fs-28-access-review-campaigns.md +++ b/docs/governance/requirements-es/functional-stories/fs-28-access-review-campaigns.md @@ -9,7 +9,7 @@ Los responsables de seguridad y negocio necesitan campanas recurrentes para conf ## 2. Actores | Actor | Responsabilidad | -|---|---| +| --- | --- | | Administrador de Gobierno de Acceso | Crea y administra las campanas de revision. | | Propietario del Recurso / Jefe | Revisa el acceso asignado a los usuarios dentro del alcance. | | Revisor | Confirma, reduce o elimina el acceso durante la campana. | @@ -18,9 +18,9 @@ Los responsables de seguridad y negocio necesitan campanas recurrentes para conf ## 3. Precondiciones de Negocio -- El tenant tiene asignaciones activas que requieren revision periodica. -- Los revisores y propietarios del recurso estan definidos para el alcance objetivo. -- La organizacion tiene una cadencia de revision o una regla de revision disparada por evento. +* El tenant tiene asignaciones activas que requieren revision periodica. +* Los revisores y propietarios del recurso estan definidos para el alcance objetivo. +* La organizacion tiene una cadencia de revision o una regla de revision disparada por evento. ## 4. Flujo Funcional Principal @@ -48,7 +48,7 @@ Si el elemento de acceso ya fue eliminado o esta inactivo, el sistema lo omite y ## 6. Reglas de Negocio | Regla | Descripcion | -|---|---| +| --- | --- | | BR-01 | El acceso sensible debe recertificarse periodicamente. | | BR-02 | Los revisores solo pueden actuar sobre items dentro de su alcance asignado. | | BR-03 | Una decision de revision debe cerrarse con un resultado final. | @@ -59,7 +59,7 @@ Si el elemento de acceso ya fue eliminado o esta inactivo, el sistema lo omite y ## 7. Criterios de Aceptacion | # | Criterio de Aceptacion | -|---|---| +| --- | --- | | 1 | Un administrador puede crear una campana de revision para un alcance definido. | | 2 | El revisor puede ver los items de acceso que pertenecen a la campana. | | 3 | El revisor puede aprobar, reducir o eliminar acceso para cada item. | @@ -69,16 +69,16 @@ Si el elemento de acceso ya fue eliminado o esta inactivo, el sistema lo omite y ## 8. Requisitos Tecnicos -- Introducir un modelo de campana de revision que pueda registrar alcance, asignacion de revisores, estado de items y resultado final. -- Persistir cada decision con revisor, fecha, motivo y accion de acceso resultante. -- Soportar reglas de escalamiento a nivel de campana o cierre automatico para revisiones vencidas. -- Emitir eventos auditables para la creacion de campana, la decision del item, el cierre y los cambios de enforcement. -- Mantener un tenant scoping estricto para que un revisor solo vea items dentro del tenant y del alcance de negocio asignado. +* Introducir un modelo de campana de revision que pueda registrar alcance, asignacion de revisores, estado de items y resultado final. +* Persistir cada decision con revisor, fecha, motivo y accion de acceso resultante. +* Soportar reglas de escalamiento a nivel de campana o cierre automatico para revisiones vencidas. +* Emitir eventos auditables para la creacion de campana, la decision del item, el cierre y los cambios de enforcement. +* Mantener un tenant scoping estricto para que un revisor solo vea items dentro del tenant y del alcance de negocio asignado. ## 9. Trazabilidad | Tipo | Referencias | -|---|---| +| --- | --- | | Entidades de Dominio | `Role`, `Profile`, `PermissionTemplate`, `AccessEnforcementPolicy`, `AuditRecord` | | Historias Funcionales | FS-16, FS-24 | | ADRs | ADR-0016, ADR-0033, ADR-0035 | diff --git a/docs/governance/requirements-es/functional-stories/fs-29-entitlement-packages.md b/docs/governance/requirements-es/functional-stories/fs-29-entitlement-packages.md index 4d9268d4..3e37fc07 100644 --- a/docs/governance/requirements-es/functional-stories/fs-29-entitlement-packages.md +++ b/docs/governance/requirements-es/functional-stories/fs-29-entitlement-packages.md @@ -9,7 +9,7 @@ Los responsables de negocio necesitan bloques de acceso reutilizables que agrupe ## 2. Actores | Actor | Responsabilidad | -|---|---| +| --- | --- | | Administrador de Entitlements | Disena y mantiene los paquetes de acceso. | | Solicitante | Solicita un paquete en lugar de solicitar entitlements individuales. | | Aprobador | Decide si el paquete puede ser otorgado. | @@ -17,9 +17,9 @@ Los responsables de negocio necesitan bloques de acceso reutilizables que agrupe ## 3. Precondiciones de Negocio -- El catalogo de sistemas y el catalogo de autorizacion ya estan definidos. -- El propietario del paquete sabe que roles, permisos o alcances pertenecen juntos. -- La ruta de aprobacion esta configurada para el tenant o sistema objetivo. +* El catalogo de sistemas y el catalogo de autorizacion ya estan definidos. +* El propietario del paquete sabe que roles, permisos o alcances pertenecen juntos. +* La ruta de aprobacion esta configurada para el tenant o sistema objetivo. ## 4. Flujo Funcional Principal @@ -47,7 +47,7 @@ Si el paquete tiene vigencia limitada y ya vencio, el sistema no lo otorga e inf ## 6. Reglas de Negocio | Regla | Descripcion | -|---|---| +| --- | --- | | BR-01 | Un paquete debe representar un bloque de negocio gobernado, no una lista arbitraria. | | BR-02 | Un paquete solo puede incluir entitlements permitidos por el alcance objetivo. | | BR-03 | La aprobacion debe decidir el paquete completo, no un estado parcial ambiguo. | @@ -58,7 +58,7 @@ Si el paquete tiene vigencia limitada y ya vencio, el sistema no lo otorga e inf ## 7. Criterios de Aceptacion | # | Criterio de Aceptacion | -|---|---| +| --- | --- | | 1 | Un administrador puede definir un paquete con multiples entitlements gobernados. | | 2 | Un solicitante puede pedir el paquete en lugar de seleccionar cada entitlement. | | 3 | Un aprobador puede aprobar o denegar la solicitud del paquete. | @@ -68,16 +68,16 @@ Si el paquete tiene vigencia limitada y ya vencio, el sistema no lo otorga e inf ## 8. Requisitos Tecnicos -- Introducir un modelo de paquete con definiciones versionadas y membresia de items. -- Persistir el alcance del paquete, los entitlements incluidos, el estado de aprobacion y el estado efectivo de asignacion. -- Reutilizar la ruta de aprobacion y el registro de auditoria para que las decisiones del paquete sigan siendo trazables. -- Soportar reglas de vencimiento y revocacion del paquete en la capa de enforcement de acceso. -- Mantener la composicion del paquete alineada con los catalogos de sistema y autorizacion. +* Introducir un modelo de paquete con definiciones versionadas y membresia de items. +* Persistir el alcance del paquete, los entitlements incluidos, el estado de aprobacion y el estado efectivo de asignacion. +* Reutilizar la ruta de aprobacion y el registro de auditoria para que las decisiones del paquete sigan siendo trazables. +* Soportar reglas de vencimiento y revocacion del paquete en la capa de enforcement de acceso. +* Mantener la composicion del paquete alineada con los catalogos de sistema y autorizacion. ## 9. Trazabilidad | Tipo | Referencias | -|---|---| +| --- | --- | | Entidades de Dominio | `SystemSuite`, `Role`, `Profile`, `PermissionTemplate`, `ApprovalRequest` | | Historias Funcionales | FS-02, FS-05, FS-24 | | ADRs | ADR-0012, ADR-0015, ADR-0035 | diff --git a/docs/governance/requirements-es/functional-stories/fs-30-provisioning-deprovisioning-connectors.md b/docs/governance/requirements-es/functional-stories/fs-30-provisioning-deprovisioning-connectors.md index bd40bbe3..4ef1d2b6 100644 --- a/docs/governance/requirements-es/functional-stories/fs-30-provisioning-deprovisioning-connectors.md +++ b/docs/governance/requirements-es/functional-stories/fs-30-provisioning-deprovisioning-connectors.md @@ -9,7 +9,7 @@ Cuando el acceso cambia en UMS, los sistemas descendentes deben recibir automati ## 2. Actores | Actor | Responsabilidad | -|---|---| +| --- | --- | | Administrador de Operaciones de Identidad | Configura el conector y sus reglas de mapeo. | | Propietario del Sistema Externo | Confirma el destino descendente y el modelo de acceso. | | Conector de Integracion | Aplica la accion de provisioning o deprovisioning. | @@ -17,9 +17,9 @@ Cuando el acceso cambia en UMS, los sistemas descendentes deben recibir automati ## 3. Precondiciones de Negocio -- Un sistema o aplicacion descendente ya fue registrado como destino. -- Existen reglas de mapeo para los datos que deben sincronizarse. -- El tenant tiene un flujo aprobado para provisioning y deprovisioning. +* Un sistema o aplicacion descendente ya fue registrado como destino. +* Existen reglas de mapeo para los datos que deben sincronizarse. +* El tenant tiene un flujo aprobado para provisioning y deprovisioning. ## 4. Flujo Funcional Principal @@ -46,7 +46,7 @@ Si un usuario pierde el acceso, la accion de deprovisioning debe intentarse y se ## 6. Reglas de Negocio | Regla | Descripcion | -|---|---| +| --- | --- | | BR-01 | Todo cambio de acceso gobernado debe reflejarse en el sistema descendente cuando el conector este habilitado. | | BR-02 | El deprovisioning es obligatorio cuando el acceso se revoca o expira. | | BR-03 | Los fallos deben ser visibles y reintentables; no pueden mantener acceso en silencio. | @@ -57,7 +57,7 @@ Si un usuario pierde el acceso, la accion de deprovisioning debe intentarse y se ## 7. Criterios de Aceptacion | # | Criterio de Aceptacion | -|---|---| +| --- | --- | | 1 | Se puede registrar un conector para un sistema descendente. | | 2 | Los cambios de acceso generan la accion de provisioning o deprovisioning esperada. | | 3 | La accion permanece trazable hasta que el sistema descendente confirma el cambio o se corrige. | @@ -67,16 +67,16 @@ Si un usuario pierde el acceso, la accion de deprovisioning debe intentarse y se ## 8. Requisitos Tecnicos -- Introducir un modelo de conector para destinos de provisioning descendente y sus mapeos. -- Persistir acciones de provisioning, estado de entrega, conteo de reintentos y resultado final. -- Emitir y consumir eventos de outbox para cambios de perfil, rol y entitlement. -- Soportar reintento manual y visibilidad operacional para entregas fallidas. -- Mantener los conectores aislados mediante adaptadores tipo ACL para que los esquemas externos no contaminen el dominio. +* Introducir un modelo de conector para destinos de provisioning descendente y sus mapeos. +* Persistir acciones de provisioning, estado de entrega, conteo de reintentos y resultado final. +* Emitir y consumir eventos de outbox para cambios de perfil, rol y entitlement. +* Soportar reintento manual y visibilidad operacional para entregas fallidas. +* Mantener los conectores aislados mediante adaptadores tipo ACL para que los esquemas externos no contaminen el dominio. ## 9. Trazabilidad | Tipo | Referencias | -|---|---| +| --- | --- | | Entidades de Dominio | `UserAccount`, `Profile`, `Role`, `SystemSuite`, `IdentityProvider`, `AuditRecord` | | Historias Funcionales | FS-03, FS-05, FS-24 | -| ADRs | ADR-0015, ADR-0033, ADR-0072 | +| ADRs | ADR-0015, ADR-0033, ADR-UMS-072 | diff --git a/docs/governance/requirements-es/functional-stories/fs-31-privileged-access-time-bound-elevation.md b/docs/governance/requirements-es/functional-stories/fs-31-privileged-access-time-bound-elevation.md index c826183e..956cf7c7 100644 --- a/docs/governance/requirements-es/functional-stories/fs-31-privileged-access-time-bound-elevation.md +++ b/docs/governance/requirements-es/functional-stories/fs-31-privileged-access-time-bound-elevation.md @@ -9,7 +9,7 @@ El acceso privilegiado debe otorgarse solo durante el tiempo necesario y luego r ## 2. Actores | Actor | Responsabilidad | -|---|---| +| --- | --- | | Solicitante de Acceso Privilegiado | Solicita acceso elevado temporal. | | Aprobador de Seguridad | Revisa y aprueba o deniega la elevacion. | | Administrador de Seguridad | Configura la politica de acceso privilegiado. | @@ -17,9 +17,9 @@ El acceso privilegiado debe otorgarse solo durante el tiempo necesario y luego r ## 3. Precondiciones de Negocio -- El usuario ya existe en UMS y tiene una identidad base. -- El rol privilegiado o el alcance elevado ya esta definido. -- La organizacion tiene una politica de aprobacion para elevacion temporal. +* El usuario ya existe en UMS y tiene una identidad base. +* El rol privilegiado o el alcance elevado ya esta definido. +* La organizacion tiene una politica de aprobacion para elevacion temporal. ## 4. Flujo Funcional Principal @@ -46,7 +46,7 @@ Si el alcance solicitado es especialmente sensible, el sistema puede requerir un ## 6. Reglas de Negocio | Regla | Descripcion | -|---|---| +| --- | --- | | BR-01 | El acceso privilegiado debe ser temporal y estar aprobado de forma explicita. | | BR-02 | Cada elevacion debe tener hora de inicio, hora de fin y motivo. | | BR-03 | El acceso debe eliminarse automaticamente cuando la elevacion vence. | @@ -57,7 +57,7 @@ Si el alcance solicitado es especialmente sensible, el sistema puede requerir un ## 7. Criterios de Aceptacion | # | Criterio de Aceptacion | -|---|---| +| --- | --- | | 1 | Un solicitante puede pedir acceso privilegiado temporal con motivo y duracion. | | 2 | Un aprobador puede aprobar o denegar la solicitud. | | 3 | El acceso aprobado expira automaticamente al final de la ventana aprobada. | @@ -67,16 +67,16 @@ Si el alcance solicitado es especialmente sensible, el sistema puede requerir un ## 8. Requisitos Tecnicos -- Introducir un modelo de elevacion con limite de tiempo que pueda guardar solicitante, aprobador, alcance, inicio, fin y estado. -- Integrar las decisiones de elevacion con el flujo de aprobacion y la politica de enforcement de acceso. -- Emitir eventos auditables para otorgamiento, vencimiento y retiro del acceso elevado. -- Soportar limites de duracion y umbrales de aprobacion definidos por politica. -- Mantener el modelo de acceso privilegiado separado de la asignacion permanente de roles. +* Introducir un modelo de elevacion con limite de tiempo que pueda guardar solicitante, aprobador, alcance, inicio, fin y estado. +* Integrar las decisiones de elevacion con el flujo de aprobacion y la politica de enforcement de acceso. +* Emitir eventos auditables para otorgamiento, vencimiento y retiro del acceso elevado. +* Soportar limites de duracion y umbrales de aprobacion definidos por politica. +* Mantener el modelo de acceso privilegiado separado de la asignacion permanente de roles. ## 9. Trazabilidad | Tipo | Referencias | -|---|---| +| --- | --- | | Entidades de Dominio | `ApprovalWorkflow`, `ApprovalRequest`, `AccessEnforcementPolicy`, `Role`, `Profile` | | Historias Funcionales | FS-10, FS-16, FS-24 | | ADRs | ADR-0012, ADR-0016, ADR-0035 | diff --git a/docs/governance/requirements-es/functional-stories/fs-32-operational-reliability-guardrails.md b/docs/governance/requirements-es/functional-stories/fs-32-operational-reliability-guardrails.md index cf9b2376..f0d0eb61 100644 --- a/docs/governance/requirements-es/functional-stories/fs-32-operational-reliability-guardrails.md +++ b/docs/governance/requirements-es/functional-stories/fs-32-operational-reliability-guardrails.md @@ -9,7 +9,7 @@ Los administradores necesitan operaciones de gobierno predecibles incluso cuando ## 2. Actores | Actor | Responsabilidad | -|---|---| +| --- | --- | | Administrador de Plataforma | Realiza acciones de gobierno de tenant y sistema. | | Administrador de Tenant | Realiza administracion con alcance limitado al tenant. | | Ingeniero de Soporte | Investiga acciones fallidas o duplicadas. | @@ -17,9 +17,9 @@ Los administradores necesitan operaciones de gobierno predecibles incluso cuando ## 3. Precondiciones de Negocio -- El actor esta autenticado con el alcance correcto de tenant o plataforma. -- El elemento objetivo existe y es elegible para la accion solicitada. -- El contexto de tenant esta disponible antes de enviar la operacion. +* El actor esta autenticado con el alcance correcto de tenant o plataforma. +* El elemento objetivo existe y es elegible para la accion solicitada. +* El contexto de tenant esta disponible antes de enviar la operacion. ## 4. Flujo Funcional Principal @@ -47,7 +47,7 @@ Si la solicitud no trae un contexto de tenant valido, la accion se rechaza y no ## 6. Reglas de Negocio | Regla | Descripcion | -|---|---| +| --- | --- | | BR-01 | Los envios repetidos no deben crear estado de gobierno duplicado. | | BR-02 | Las actualizaciones concurrentes no deben sobrescribir silenciosamente cambios mas nuevos. | | BR-03 | El alcance del tenant siempre debe conocerse antes de aceptar un cambio. | @@ -58,7 +58,7 @@ Si la solicitud no trae un contexto de tenant valido, la accion se rechaza y no ## 7. Criterios de Aceptacion | # | Criterio de Aceptacion | -|---|---| +| --- | --- | | 1 | Repetir la misma accion no crea estado de negocio duplicado. | | 2 | Un cambio concurrente se detecta en lugar de sobrescribirse en silencio. | | 3 | Una solicitud sin contexto de tenant se rechaza. | @@ -68,16 +68,16 @@ Si la solicitud no trae un contexto de tenant valido, la accion se rechaza y no ## 8. Requisitos Tecnicos -- Introducir deduplicacion de solicitudes para los flujos de comando soportados. -- Agregar verificacion de concurrencia optimista para registros mutables que pueden editarse al mismo tiempo. -- Aplicar tenant scoping antes de que la operacion entre en el flujo de aplicacion. -- Mantener observable la entrega de outbox o eventos para que operaciones vea cuando un cambio sigue pendiente. -- Exponer feedback accionable de conflicto y reintento en lugar de fallos genericos. +* Introducir deduplicacion de solicitudes para los flujos de comando soportados. +* Agregar verificacion de concurrencia optimista para registros mutables que pueden editarse al mismo tiempo. +* Aplicar tenant scoping antes de que la operacion entre en el flujo de aplicacion. +* Mantener observable la entrega de outbox o eventos para que operaciones vea cuando un cambio sigue pendiente. +* Exponer feedback accionable de conflicto y reintento en lugar de fallos genericos. ## 9. Trazabilidad | Tipo | Referencias | -|---|---| +| --- | --- | | Entidades de Dominio | `Tenant`, `UserAccount`, `Profile`, `ApprovalRequest`, `AppConfiguration`, `AuditRecord` | | Historias Funcionales | FS-03, FS-05, FS-13, FS-24 | -| ADRs | ADR-0010, ADR-0033, ADR-0063, ADR-0066 | +| ADRs | ADR-0010, ADR-0033, ADR-UMS-063, ADR-UMS-066 | diff --git a/docs/governance/requirements-es/functional-stories/fs-33-authorization-graph-explorer.md b/docs/governance/requirements-es/functional-stories/fs-33-authorization-graph-explorer.md index 338914b4..5f8a0740 100644 --- a/docs/governance/requirements-es/functional-stories/fs-33-authorization-graph-explorer.md +++ b/docs/governance/requirements-es/functional-stories/fs-33-authorization-graph-explorer.md @@ -9,16 +9,16 @@ Los administradores de autorizacion y los equipos de soporte necesitan entender ## 2. Actores | Actor | Responsabilidad | -|---|---| +| --- | --- | | Administrador de Autorizacion | Verifica el grafo efectivo antes de aprobar cambios. | | Ingeniero de Soporte | Diagnostica problemas de acceso usando una simulacion segura. | | Auditor | Revisa por que el acceso cambio o no cambio. | ## 3. Precondiciones de Negocio -- El perfil, plantilla o paquete objetivo existe o esta siendo propuesto. -- El actor tiene acceso de diagnostico al alcance del tenant relevante. -- El grafo de autorizacion actual puede resolverse para el alcance seleccionado. +* El perfil, plantilla o paquete objetivo existe o esta siendo propuesto. +* El actor tiene acceso de diagnostico al alcance del tenant relevante. +* El grafo de autorizacion actual puede resolverse para el alcance seleccionado. ## 4. Flujo Funcional Principal @@ -46,7 +46,7 @@ Si la simulacion no tiene suficiente contexto para evaluar el grafo, el sistema ## 6. Reglas de Negocio | Regla | Descripcion | -|---|---| +| --- | --- | | BR-01 | El explorador no debe cambiar el acceso real por si mismo. | | BR-02 | Los grafos actual y propuesto deben distinguirse claramente. | | BR-03 | La vista previa debe explicar las rutas efectivas que otorgan acceso. | @@ -56,7 +56,7 @@ Si la simulacion no tiene suficiente contexto para evaluar el grafo, el sistema ## 7. Criterios de Aceptacion | # | Criterio de Aceptacion | -|---|---| +| --- | --- | | 1 | Un actor autorizado puede previsualizar el grafo efectivo actual. | | 2 | El actor puede simular un cambio propuesto antes de aprobarlo. | | 3 | El explorador muestra la diferencia entre el acceso actual y el propuesto. | @@ -65,16 +65,16 @@ Si la simulacion no tiene suficiente contexto para evaluar el grafo, el sistema ## 8. Requisitos Tecnicos -- Reutilizar el motor de grafo de autorizacion tanto para la evaluacion en vivo como para la simulada. -- Soportar una vista diff de solo lectura que compare el grafo resuelto antes y despues del cambio propuesto. -- Mantener el endpoint o la consulta de vista previa aislado de las operaciones de escritura. -- Emitir eventos de auditoria de diagnostico para el acceso de vista previa y simulacion. -- Preservar el alcance del tenant y los permisos durante la resolucion del grafo. +* Reutilizar el motor de grafo de autorizacion tanto para la evaluacion en vivo como para la simulada. +* Soportar una vista diff de solo lectura que compare el grafo resuelto antes y despues del cambio propuesto. +* Mantener el endpoint o la consulta de vista previa aislado de las operaciones de escritura. +* Emitir eventos de auditoria de diagnostico para el acceso de vista previa y simulacion. +* Preservar el alcance del tenant y los permisos durante la resolucion del grafo. ## 9. Trazabilidad | Tipo | Referencias | -|---|---| +| --- | --- | | Entidades de Dominio | `Profile`, `PermissionTemplate`, `Role`, `SystemSuite`, `AuditRecord` | | Historias Funcionales | FS-07, FS-24, FS-29 | -| ADRs | ADR-0021, ADR-0071, ADR-0074 | +| ADRs | ADR-0021, ADR-UMS-088, ADR-UMS-074 | diff --git a/docs/governance/requirements-es/functional-stories/fs-34-business-semantic-access-packages.md b/docs/governance/requirements-es/functional-stories/fs-34-business-semantic-access-packages.md index 56979fb7..7dd32d42 100644 --- a/docs/governance/requirements-es/functional-stories/fs-34-business-semantic-access-packages.md +++ b/docs/governance/requirements-es/functional-stories/fs-34-business-semantic-access-packages.md @@ -9,7 +9,7 @@ Los responsables de negocio necesitan paquetes de acceso definidos en lenguaje d ## 2. Actores | Actor | Responsabilidad | -|---|---| +| --- | --- | | Arquitecto de Entitlements | Disena la composicion del paquete. | | Propietario de Negocio | Valida que el paquete coincide con la necesidad del negocio. | | Aprobador | Confirma si el paquete puede publicarse o asignarse. | @@ -17,9 +17,9 @@ Los responsables de negocio necesitan paquetes de acceso definidos en lenguaje d ## 3. Precondiciones de Negocio -- Ya existen el catalogo de sistemas y el catalogo de autorizacion. -- El paquete puede mapearse a alcances de negocio reales como tenant, sucursal, sistema o tipo de socio. -- La aprobacion y la politica de expiracion estan configuradas. +* Ya existen el catalogo de sistemas y el catalogo de autorizacion. +* El paquete puede mapearse a alcances de negocio reales como tenant, sucursal, sistema o tipo de socio. +* La aprobacion y la politica de expiracion estan configuradas. ## 4. Flujo Funcional Principal @@ -46,7 +46,7 @@ Si una version anterior ya no es valida, el sistema no permite reutilizarla sin ## 6. Reglas de Negocio | Regla | Descripcion | -|---|---| +| --- | --- | | BR-01 | Los paquetes deben nombrarse y modelarse en lenguaje de negocio. | | BR-02 | Las versiones del paquete deben ser inmutables una vez publicadas. | | BR-03 | El paquete debe reflejar solo el alcance de negocio permitido. | @@ -56,7 +56,7 @@ Si una version anterior ya no es valida, el sistema no permite reutilizarla sin ## 7. Criterios de Aceptacion | # | Criterio de Aceptacion | -|---|---| +| --- | --- | | 1 | Un paquete puede modelarse con alcance de negocio y entitlements tecnicos juntos. | | 2 | Un paquete puede versionarse y publicarse sin perder historial. | | 3 | Un propietario de negocio puede confirmar que el paquete coincide con el patron de acceso esperado. | @@ -65,16 +65,16 @@ Si una version anterior ya no es valida, el sistema no permite reutilizarla sin ## 8. Requisitos Tecnicos -- Agregar metadatos del paquete para terminos de negocio, alcance, version y estado de ciclo de vida. -- Mantener inmutable la composicion del paquete despues de su publicacion. -- Soportar la composicion por roles, permisos y reglas de acceso. -- Reutilizar aprobacion y auditoria para que la publicacion y asignacion del paquete sean trazables. -- Permitir que la resolucion del paquete se evalúe por alcance de negocio sin filtrar detalles de implementacion interna. +* Agregar metadatos del paquete para terminos de negocio, alcance, version y estado de ciclo de vida. +* Mantener inmutable la composicion del paquete despues de su publicacion. +* Soportar la composicion por roles, permisos y reglas de acceso. +* Reutilizar aprobacion y auditoria para que la publicacion y asignacion del paquete sean trazables. +* Permitir que la resolucion del paquete se evalúe por alcance de negocio sin filtrar detalles de implementacion interna. ## 9. Trazabilidad | Tipo | Referencias | -|---|---| +| --- | --- | | Entidades de Dominio | `SystemSuite`, `Role`, `PermissionTemplate`, `Profile`, `ApprovalRequest` | | Historias Funcionales | FS-02, FS-24, FS-29 | | ADRs | ADR-0012, ADR-0015, ADR-0035 | diff --git a/docs/governance/requirements-es/functional-stories/fs-35-continuous-access-health.md b/docs/governance/requirements-es/functional-stories/fs-35-continuous-access-health.md index 25cbcb54..9c1c64a4 100644 --- a/docs/governance/requirements-es/functional-stories/fs-35-continuous-access-health.md +++ b/docs/governance/requirements-es/functional-stories/fs-35-continuous-access-health.md @@ -9,16 +9,16 @@ Los equipos de seguridad y gobierno necesitan una vista continua de la calidad d ## 2. Actores | Actor | Responsabilidad | -|---|---| +| --- | --- | | Administrador de Seguridad | Revisa las senales de salud y los umbrales. | | Administrador IGA | Usa las recomendaciones para lanzar acciones de limpieza o revision. | | Auditor | Revisa la tendencia de salud y el historial de remediacion. | ## 3. Precondiciones de Negocio -- UMS tiene suficientes datos de acceso, auditoria y revision para evaluar senales de salud. -- El tenant tiene una politica que define que es saludable, riesgoso o obsoleto. -- El actor puede acceder al area de diagnostico de gobierno. +* UMS tiene suficientes datos de acceso, auditoria y revision para evaluar senales de salud. +* El tenant tiene una politica que define que es saludable, riesgoso o obsoleto. +* El actor puede acceder al area de diagnostico de gobierno. ## 4. Flujo Funcional Principal @@ -45,7 +45,7 @@ Si el acceso esta saludable, el sistema lo explica y mantiene el puntaje visible ## 6. Reglas de Negocio | Regla | Descripcion | -|---|---| +| --- | --- | | BR-01 | El puntaje de salud del acceso debe ser explicable. | | BR-02 | Las recomendaciones deben derivarse de senales observables de acceso y auditoria. | | BR-03 | El sistema no debe remover acceso automaticamente salvo que la politica lo permita explicitamente. | @@ -55,7 +55,7 @@ Si el acceso esta saludable, el sistema lo explica y mantiene el puntaje visible ## 7. Criterios de Aceptacion | # | Criterio de Aceptacion | -|---|---| +| --- | --- | | 1 | Un actor puede ver un puntaje de salud para el acceso dentro de un tenant o alcance. | | 2 | El sistema explica las senales que influyeron en el puntaje. | | 3 | El sistema recomienda una siguiente accion de gobierno. | @@ -64,16 +64,16 @@ Si el acceso esta saludable, el sistema lo explica y mantiene el puntaje visible ## 8. Requisitos Tecnicos -- Calcular la salud del acceso a partir de auditoria, asignacion, revision y vencimiento. -- Proveer reglas y umbrales de puntuacion explicables. -- Soportar recomendaciones sin cambiar automaticamente el acceso. -- Permitir que los resultados de salud alimenten campanas de revision, limpieza de paquetes y expiracion de accesos privilegiados. -- Preservar el aislamiento por tenant y el recalculo deterministico con las mismas entradas. +* Calcular la salud del acceso a partir de auditoria, asignacion, revision y vencimiento. +* Proveer reglas y umbrales de puntuacion explicables. +* Soportar recomendaciones sin cambiar automaticamente el acceso. +* Permitir que los resultados de salud alimenten campanas de revision, limpieza de paquetes y expiracion de accesos privilegiados. +* Preservar el aislamiento por tenant y el recalculo deterministico con las mismas entradas. ## 9. Trazabilidad | Tipo | Referencias | -|---|---| +| --- | --- | | Entidades de Dominio | `AuditRecord`, `Profile`, `Role`, `ApprovalRequest`, `AccessEnforcementPolicy` | | Historias Funcionales | FS-16, FS-28, FS-31, FS-32 | -| ADRs | ADR-0016, ADR-0033, ADR-0066 | +| ADRs | ADR-0016, ADR-0033, ADR-UMS-066 | diff --git a/docs/governance/requirements-es/functional-stories/fs-36-configurable-refresh-token.md b/docs/governance/requirements-es/functional-stories/fs-36-configurable-refresh-token.md new file mode 100644 index 00000000..09832684 --- /dev/null +++ b/docs/governance/requirements-es/functional-stories/fs-36-configurable-refresh-token.md @@ -0,0 +1,95 @@ +# Historia Funcional 36: Refresh Token Configurable por Inquilino con Regeneración del Grafo + +> **Trazabilidad:** FR-015 · [D-012](../../../DECISIONS.md) · [G-034](../../../GAPS.md) · revisa **ADR-UMS-088** ([evolith-core#17](https://github.com/beyondnetcode/evolith-core/issues/17)) · relacionada con [fs-01](./fs-01-autenticacion-usuario.md), [fs-37](./fs-37-revocacion-refresh-token.md) + +## Tabla de Navegación + +* [#2-actores](#2-actores) +* [#4-flujo](#4-flujo-funcional-principal) +* [#7-criterios](#7-criterios-de-aceptación) +* [#8-tecnicos](#8-requisitos-técnicos) + +## 1. Propósito de Negocio + +Hoy el Grafo de Autorización vence por expiración (`validUntil`) y el cliente debe **re-autenticarse por completo** al caducar. Los inquilinos que operan sesiones largas quieren **renovar sin re-login**, y al mismo tiempo que la renovación **refleje el estado de permisos actual** (no uno congelado). Esta capacidad, **activable por configuración del propio inquilino**, emite un refresh token y, al renovar, **regenera el grafo completo** desde el estado vigente — cerrando la brecha de latencia de permisos sin perder la inmutabilidad por instancia de ADR-UMS-088. + +## 2. Actores + +| Actor | Responsabilidad | +| :--- | :--- | +| **Usuario autenticado** | Mantiene una sesión que puede renovarse sin re-login. | +| **Sistema cliente / API** | Presenta el refresh token al vencer el grafo y consume el grafo regenerado. | +| **Administrador del inquilino** | Activa la capacidad y parametriza su comportamiento (vida, rotación, reuso, tope). | +| **UMS** | Emite/rota el refresh, valida su vigencia y **regenera el grafo completo** desde el estado actual. | + +## 3. Precondiciones de Negocio + +* El usuario se autenticó correctamente (local o federado, [fs-01](./fs-01-autenticacion-usuario.md)). +* El inquilino tiene resuelta su configuración jerárquica (Global > Suite > Tenant > Module). +* El flag `AUTH_REFRESH_TOKEN_ENABLED` está **publicado** para el inquilino; si está apagado o mal configurado, aplica el modelo actual (solo expiración) — **fail-closed**. + +## 4. Flujo Funcional Principal + +```mermaid +sequenceDiagram + participant C as Cliente + participant U as UMS + participant CFG as Configuration + C->>U: Autenticación (fs-01) + U->>CFG: ¿AUTH_REFRESH_TOKEN_ENABLED (efectivo)? + CFG-->>U: ON + parámetros (vida, rotación, reuso, tope) + U-->>C: Grafo de Autorización + refresh token + Note over C: Opera con el grafo hasta validUntil + C->>U: Renovar (presenta refresh token) + U->>U: Validar refresh (vigente, no revocado, no reusado) + U->>U: Regenerar GRAFO COMPLETO (estado actual) + alt rotación activada + U-->>C: Grafo nuevo + refresh rotado + else sin rotación + U-->>C: Grafo nuevo (mismo refresh) + end +``` + +1. El usuario se autentica; UMS resuelve la configuración efectiva del inquilino. +2. Si el flag está **ON**, UMS emite, junto al grafo, un **refresh token** según los parámetros del tenant. +3. El cliente opera con el grafo hasta `validUntil`. +4. Al vencer, el cliente **renueva** presentando el refresh token. +5. UMS valida el refresh (vigente, no revocado, no previamente consumido) y **regenera el grafo COMPLETO** consultando permisos, roles, plantillas, overrides, feature flags y configuración efectiva **actuales**. +6. Si la rotación está activada, UMS emite un refresh nuevo e invalida el anterior; si no, conserva el mismo. + +## 5. Flujos Alternativos y Excepciones + +* **A. Flag OFF (default):** no se emite refresh; al vencer el grafo el cliente **re-autentica por completo** (modelo actual del PRD). +* **B. Refresh reusado:** si llega un refresh ya consumido (rotación activa), UMS **invalida toda la familia** de tokens y fuerza re-login (mitigación de robo). +* **C. Refresh expirado o revocado:** la renovación falla; se exige re-autenticación completa (revocación en [fs-37](./fs-37-revocacion-refresh-token.md)). +* **D. Tope de renovaciones alcanzado:** si se configuró un máximo, agotarlo fuerza re-login. + +## 6. Reglas de Negocio + +1. El comportamiento (vida, rotación, detección de reuso, tope) lo fija la **configuración del inquilino**, versionada (Draft → Published → Archived). +2. La renovación **regenera** el grafo entero desde el estado vigente; **nunca** extiende ni reutiliza el grafo previo. Cada grafo emitido sigue siendo **inmutable** (ADR-UMS-088). +3. El refresh token **nunca** aparece en logs, proyecciones ni en el grafo; se guarda **hasheado/cifrado** en reposo. +4. Refresh y renovación **nunca cruzan de inquilino** (aislamiento multi-tenant). +5. Compatible con autenticación **local y federada**, resuelta por configuración en tiempo de login. + +## 7. Criterios de Aceptación + +Verificables por el agente BMAD Tester en **ambos modos**: + +1. **OFF (default):** con el flag apagado, la autenticación **no** devuelve refresh token; al expirar el grafo, la renovación no está disponible y se exige re-login → grafo nuevo. +2. **ON — emisión:** con el flag publicado, la autenticación devuelve grafo **y** refresh token acorde a los parámetros del tenant. +3. **ON — regeneración:** tras `validUntil`, renovar con un refresh válido devuelve un **grafo regenerado que refleja el estado actual** — caso: revocar un permiso o cambiar una plantilla y confirmar que la renovación **ya lo aplica** (con OFF, solo tras re-login). +4. **ON — rotación:** si la rotación está activada, cada renovación entrega un refresh nuevo y el anterior deja de servir. +5. **ON — expiración del refresh:** un refresh vencido no renueva → re-login. +6. **ON — reuso:** presentar un refresh ya consumido **invalida la familia** y fuerza re-login. +7. **Aislamiento:** un refresh de un inquilino no renueva en otro. +8. **Auditoría:** emisión, rotación, renovación, expiración y reuso quedan registrados (append-only, acotado por inquilino), sin exponer el token. +9. **Seguridad:** el refresh no aparece en ningún log, proyección ni en el grafo. + +## 8. Requisitos Técnicos + +* **Bounded context principal:** `Identity`/`Authentication` (emisión y validación del refresh, regeneración del grafo). **Soporte:** `Configuration` (flag + parámetros jerárquicos), `Audit` (eventos). +* **Dependencias:** [fs-01](./fs-01-autenticacion-usuario.md) (login), [fs-13](./fs-13-configuracion-jerarquica.md)/[fs-20](./fs-20-gestion-parametros-sistema.md) (configuración jerárquica), motor del grafo (ADR-UMS-088). +* **Restricciones:** fail-closed ante flag ausente o inválido; inmutabilidad por instancia del grafo; secreto del refresh hasheado/cifrado; sin cruce de inquilino. +* **ADRs relevantes:** **ADR-UMS-088** (motor/vigencia del grafo — **revisado** por esta capacidad), ADR-UMS-074 (versionado del esquema del grafo), ADR-UMS-072 (resolución dinámica del método de auth). La decisión técnica **cumple [ADR-UMS-091](../../../reference/architecture/adrs/UMS-091-refresh-token-configurable-revocacion.es.md) (Aceptado)**, que revisa ADR-UMS-088 (procedencia [evolith-core#17](https://github.com/beyondnetcode/evolith-core/issues/17), S-06). +* **Notas:** con ADR-UMS-091 aceptado, la **implementación** (endpoints de emisión/renovación, familia de tokens, persistencia cifrada, auditoría) está **en curso** ([G-034](../../../GAPS.md)). diff --git a/docs/governance/requirements-es/functional-stories/fs-37-refresh-token-revocation.md b/docs/governance/requirements-es/functional-stories/fs-37-refresh-token-revocation.md new file mode 100644 index 00000000..cdda860c --- /dev/null +++ b/docs/governance/requirements-es/functional-stories/fs-37-refresh-token-revocation.md @@ -0,0 +1,82 @@ +# Historia Funcional 37: Revocación de Refresh Token y Sesión + +> **Trazabilidad:** FR-016 · [D-012](../../../DECISIONS.md) · [G-034](../../../GAPS.md) · revisa **ADR-UMS-088** ([evolith-core#17](https://github.com/beyondnetcode/evolith-core/issues/17)) · depende de [fs-36](./fs-36-refresh-token-configurable.md) + +## Tabla de Navegación + +* [#2-actores](#2-actores) +* [#4-flujo](#4-flujo-funcional-principal) +* [#7-criterios](#7-criterios-de-aceptación) +* [#8-tecnicos](#8-requisitos-técnicos) + +## 1. Propósito de Negocio + +Cuando el flag de refresh está activo ([fs-36](./fs-36-refresh-token-configurable.md)), una sesión puede renovarse sin re-login. El negocio necesita poder **cortar esa capacidad en caliente**: un logout real, el bloqueo o suspensión de una cuenta, o un cambio crítico de permisos deben **impedir que el refresh siga regenerando grafos**. Sin revocación, un token robado o una cuenta comprometida seguirían renovando hasta la expiración del refresh. + +## 2. Actores + +| Actor | Responsabilidad | +| :--- | :--- | +| **Usuario** | Cierra sesión (logout real) o ve su acceso cortado. | +| **Administrador del inquilino / seguridad** | Revoca por bloqueo/suspensión o por cambio crítico de permisos. | +| **Sistema cliente / API** | Deja de poder renovar cuando el refresh está revocado. | +| **UMS** | Marca el refresh como revocado y rechaza toda renovación posterior; audita el evento. | + +## 3. Precondiciones de Negocio + +* La capacidad de refresh está **activa** para el inquilino ([fs-36](./fs-36-refresh-token-configurable.md)); sin refresh no hay nada que revocar (el modelo de solo expiración ya obliga a re-login). +* Existe un refresh token vigente asociado a la sesión/usuario/cuenta objetivo. + +## 4. Flujo Funcional Principal + +```mermaid +sequenceDiagram + participant A as Actor (usuario/admin/evento) + participant U as UMS + participant C as Cliente + A->>U: Revocar (logout / bloqueo / cambio de permisos) + U->>U: Marcar refresh (o familia) como revocado + U->>U: Auditar evento de revocación (por tenant) + Note over C: El grafo emitido sigue válido hasta validUntil + C->>U: Renovar (presenta refresh revocado) + U-->>C: Falla → re-autenticación completa requerida +``` + +1. Un actor (usuario en logout, admin por bloqueo/suspensión, o un evento de cambio crítico de permisos) dispara la revocación. +2. UMS marca el refresh (o la **familia** completa) como **revocado** y audita el evento acotado al inquilino. +3. El grafo ya emitido **sigue válido hasta su `validUntil`** (alcance explícito; ver Reglas). +4. En la siguiente renovación, el refresh revocado **falla** y UMS exige re-autenticación completa. + +## 5. Flujos Alternativos y Excepciones + +* **A. Logout real:** cerrar sesión revoca el refresh de esa sesión; renovar después falla. +* **B. Cuenta bloqueada/suspendida:** revoca todos los refresh de la cuenta; ninguna sesión puede renovar. +* **C. Cambio crítico de permisos:** revoca para forzar que la próxima sesión parta de permisos actuales (con refresh activo, la renovación de [fs-36](./fs-36-refresh-token-configurable.md) también los aplicaría al regenerar; la revocación es el corte inmediato). +* **D. Flag OFF:** no aplica — no hay refresh; el corte de acceso se da por expiración + re-login. + +## 6. Reglas de Negocio + +1. Un refresh **revocado no puede regenerar grafo**: la renovación falla y exige re-login. +2. **Alcance explícito:** la revocación actúa sobre la **capacidad de renovar** (el refresh), **no** sobre un grafo ya emitido, que sigue válido hasta su `validUntil` — salvo que el cliente valide la sesión en cada operación. Este límite se documenta para los sistemas cliente. +3. La revocación puede ser por **token, familia, sesión, usuario o cuenta**, y **nunca cruza de inquilino**. +4. Todo evento de revocación se **audita** (append-only, no repudiable, acotado por inquilino), sin exponer el token. + +## 7. Criterios de Aceptación + +Verificables por el agente BMAD Tester en **ambos modos**: + +1. **ON — logout:** tras logout real, renovar con ese refresh **falla** → re-login. +2. **ON — bloqueo:** al bloquear/suspender la cuenta, **ningún** refresh de esa cuenta renueva. +3. **ON — cambio de permisos:** revocar por cambio crítico corta la renovación con el refresh previo; la nueva sesión refleja los permisos actuales. +4. **Alcance:** un grafo ya emitido **sigue funcionando hasta `validUntil`** tras revocar el refresh (comportamiento documentado, no un fallo). +5. **Aislamiento:** revocar en un inquilino no afecta a otro. +6. **Auditoría:** cada revocación queda registrada (append-only, por inquilino), sin exponer el token. +7. **OFF (default):** con el flag apagado no hay refresh que revocar; el corte se da por expiración + re-login (sin endpoint de revocación de refresh). + +## 8. Requisitos Técnicos + +* **Bounded context principal:** `Identity`/`Authentication` (estado de revocación del refresh/familia). **Soporte:** `Audit` (eventos), `Configuration` (la capacidad depende del flag de [fs-36](./fs-36-refresh-token-configurable.md)). +* **Dependencias:** [fs-36](./fs-36-refresh-token-configurable.md) (emisión/rotación del refresh), ciclo de vida de cuenta ([fs-07](./fs-07-resolvedor-grafo-visual.md) no; ver bloqueo/suspensión en gestión de usuarios). +* **Restricciones:** la revocación es sobre la renovación, no sobre grafos vivos; sin cruce de inquilino; auditoría obligatoria. +* **ADRs relevantes:** **ADR-UMS-088** (vigencia del grafo — **revisado**), ADR-UMS-052 (auditoría inmutable). Cumple **[ADR-UMS-091](../../../reference/architecture/adrs/UMS-091-refresh-token-configurable-revocacion.es.md) (Aceptado)**, que revisa ADR-UMS-088 (procedencia [evolith-core#17](https://github.com/beyondnetcode/evolith-core/issues/17), S-06). +* **Notas:** con ADR-UMS-091 aceptado, la implementación (estado de revocación, su verificación en la renovación y la auditoría) está **en curso** ([G-034](../../../GAPS.md)). diff --git a/docs/governance/requirements/functional-stories/fs-25-ddd-domain-resource-hierarchy.es.md b/docs/governance/requirements-es/functional-stories/fs-38-ddd-domain-resource-hierarchy.md similarity index 57% rename from docs/governance/requirements/functional-stories/fs-25-ddd-domain-resource-hierarchy.es.md rename to docs/governance/requirements-es/functional-stories/fs-38-ddd-domain-resource-hierarchy.md index 34fbd7bc..67d35b4c 100644 --- a/docs/governance/requirements/functional-stories/fs-25-ddd-domain-resource-hierarchy.es.md +++ b/docs/governance/requirements-es/functional-stories/fs-38-ddd-domain-resource-hierarchy.md @@ -1,12 +1,12 @@ # Fs 25 Ddd Domain Resource Hierarchy (Espanol) -> Esta pagina es el espejo en espanol de [fs-25-ddd-domain-resource-hierarchy.md](fs-25-ddd-domain-resource-hierarchy.md). +> Esta pagina es el espejo en espanol de [fs-38-ddd-domain-resource-hierarchy.md](fs-38-ddd-domain-resource-hierarchy.md). > El contenido detallado permanece en la version en ingles hasta completar la traduccion completa. ## Idioma -- Ingles: [fs-25-ddd-domain-resource-hierarchy.md](fs-25-ddd-domain-resource-hierarchy.md) -- Espanol: [fs-25-ddd-domain-resource-hierarchy.es.md] +- Ingles: [fs-38-ddd-domain-resource-hierarchy.md](fs-38-ddd-domain-resource-hierarchy.md) +- Espanol: [fs-38-ddd-domain-resource-hierarchy.es.md] ## Resumen diff --git a/docs/governance/requirements/functional-stories/fs-26-auth-graph-preview-from-profile.es.md b/docs/governance/requirements-es/functional-stories/fs-39-auth-graph-preview-from-profile.md similarity index 57% rename from docs/governance/requirements/functional-stories/fs-26-auth-graph-preview-from-profile.es.md rename to docs/governance/requirements-es/functional-stories/fs-39-auth-graph-preview-from-profile.md index a542b626..a6c2eb40 100644 --- a/docs/governance/requirements/functional-stories/fs-26-auth-graph-preview-from-profile.es.md +++ b/docs/governance/requirements-es/functional-stories/fs-39-auth-graph-preview-from-profile.md @@ -1,12 +1,12 @@ # Fs 26 Auth Graph Preview From Profile (Espanol) -> Esta pagina es el espejo en espanol de [fs-26-auth-graph-preview-from-profile.md](fs-26-auth-graph-preview-from-profile.md). +> Esta pagina es el espejo en espanol de [fs-39-auth-graph-preview-from-profile.md](fs-39-auth-graph-preview-from-profile.md). > El contenido detallado permanece en la version en ingles hasta completar la traduccion completa. ## Idioma -- Ingles: [fs-26-auth-graph-preview-from-profile.md](fs-26-auth-graph-preview-from-profile.md) -- Espanol: [fs-26-auth-graph-preview-from-profile.es.md] +- Ingles: [fs-39-auth-graph-preview-from-profile.md](fs-39-auth-graph-preview-from-profile.md) +- Espanol: [fs-39-auth-graph-preview-from-profile.es.md] ## Resumen diff --git a/docs/governance/requirements/functional-stories/fs-27-state-change-consistency-broken-rules.es.md b/docs/governance/requirements-es/functional-stories/fs-40-state-change-consistency-broken-rules.md similarity index 59% rename from docs/governance/requirements/functional-stories/fs-27-state-change-consistency-broken-rules.es.md rename to docs/governance/requirements-es/functional-stories/fs-40-state-change-consistency-broken-rules.md index 368b88a0..95c42f1b 100644 --- a/docs/governance/requirements/functional-stories/fs-27-state-change-consistency-broken-rules.es.md +++ b/docs/governance/requirements-es/functional-stories/fs-40-state-change-consistency-broken-rules.md @@ -1,12 +1,12 @@ # Fs 27 State Change Consistency Broken Rules (Espanol) -> Esta pagina es el espejo en espanol de [fs-27-state-change-consistency-broken-rules.md](fs-27-state-change-consistency-broken-rules.md). +> Esta pagina es el espejo en espanol de [fs-40-state-change-consistency-broken-rules.md](fs-40-state-change-consistency-broken-rules.md). > El contenido detallado permanece en la version en ingles hasta completar la traduccion completa. ## Idioma -- Ingles: [fs-27-state-change-consistency-broken-rules.md](fs-27-state-change-consistency-broken-rules.md) -- Espanol: [fs-27-state-change-consistency-broken-rules.es.md] +- Ingles: [fs-40-state-change-consistency-broken-rules.md](fs-40-state-change-consistency-broken-rules.md) +- Espanol: [fs-40-state-change-consistency-broken-rules.es.md] ## Resumen diff --git a/docs/governance/requirements-es/functional-stories/functional-story-standard.md b/docs/governance/requirements-es/functional-stories/functional-story-standard.md index 898c0c0e..e99ac9a4 100644 --- a/docs/governance/requirements-es/functional-stories/functional-story-standard.md +++ b/docs/governance/requirements-es/functional-stories/functional-story-standard.md @@ -1,6 +1,6 @@ # Estándar de Redacción de Historias Funcionales -> **Fuente corporativa:** Este estándar local de UMS implementa el estándar base EVOLITH/BMAD-METHOD definido en [evolith_arch32/reference/governance/sdlc-es/03-documentation/functional-story-writing-standard.md](https://github.com/beyondnetcode/evolith_arch32/blob/main/reference/governance/sdlc-es/03-documentation/functional-story-writing-standard.md). +> **Fuente corporativa:** Este estándar local de UMS implementa el estándar base BEYONDNET/BMAD-METHOD definido en [BeyondNet Arch/reference/governance/sdlc-es/03-documentation/functional-story-writing-standard.md](https://github.com/beyondnetcode/evolith-core/blob/main/reference/governance/sdlc-es/03-documentation/functional-story-writing-standard.md). Este estándar define cómo deben redactarse las Historias Funcionales de UMS para que Product Owners, Analistas de Negocio, QA y Desarrolladores puedan usar el mismo documento sin mezclar intención de negocio con detalle de implementación. @@ -28,14 +28,14 @@ Las secciones funcionales DEBEN usar lenguaje entendible para Product Owner o An Las secciones funcionales NO DEBEN iniciar con: -- rutas de API o métodos HTTP, -- nombres de protocolos, -- detalles de motor de base de datos, -- detalles de caché, -- ejemplos de payload, -- nombres de excepciones, -- frameworks o librerías, -- comportamiento específico de infraestructura. +* rutas de API o métodos HTTP, +* nombres de protocolos, +* detalles de motor de base de datos, +* detalles de caché, +* ejemplos de payload, +* nombres de excepciones, +* frameworks o librerías, +* comportamiento específico de infraestructura. Esos detalles pertenecen a **Requisitos Técnicos**. @@ -45,15 +45,15 @@ Esos detalles pertenecen a **Requisitos Técnicos**. La sección de Requisitos Técnicos DEBE capturar: -- APIs/endpoints, -- entidades y tablas, -- persistencia, caché e invalidación, -- controles de seguridad, -- eventos de auditoría, -- códigos de error, -- requisitos de protocolos o tokens, -- contratos de integración, -- restricciónes derivadas de ADRs o Technical Enablers. +* APIs/endpoints, +* entidades y tablas, +* persistencia, caché e invalidación, +* controles de seguridad, +* eventos de auditoría, +* códigos de error, +* requisitos de protocolos o tokens, +* contratos de integración, +* restricciónes derivadas de ADRs o Technical Enablers. Esta sección permite que desarrollo tenga precisión sin hacer más difícil la lectura funcional. @@ -65,13 +65,13 @@ Los criterios de aceptación DEBEN ser observables y validables desde negocio. D Correcto: -- "El patrocinador puede ver si la solicitud fue aprobada o rechazada." -- "El sistema evita que usuarios externos reciban perfiles administrativos internos." +* "El patrocinador puede ver si la solicitud fue aprobada o rechazada." +* "El sistema evita que usuarios externos reciban perfiles administrativos internos." Evitar en criterios funcionales: -- "La API retorna `403 Forbidden`." -- "Se invalidan llaves de Redis." -- "La base de datos escribe en `APPROVAL_REQUEST`." +* "La API retorna `403 Forbidden`." +* "Se invalidan llaves de Redis." +* "La base de datos escribe en `APPROVAL_REQUEST`." Mover esos detalles a Requisitos Técnicos. diff --git a/docs/governance/requirements-es/functional-stories/index.md b/docs/governance/requirements-es/functional-stories/index.md index d92187ce..4667eb88 100644 --- a/docs/governance/requirements-es/functional-stories/index.md +++ b/docs/governance/requirements-es/functional-stories/index.md @@ -34,6 +34,8 @@ Bienvenido al índice maestro de **Functional Stories** para el Sistema de Gesti * **[FS-22: Solicitud y Aprobacion de Alta de Usuario](./fs-22-user-signup-request-approval.md)** * **[FS-23: Solicitud de Perfil desde Usuario en Lobby](./fs-23-profile-access-request.es.md)** * **[FS-24: Aprobación de Solicitud de Perfil y Asignación Manual](./fs-24-profile-request-approval.es.md)** +* **[FS-25: Dataset Semilla de Desarrollo](./fs-25-seed-dataset.md)** +* **[FS-26: Admin Root — el Inquilino Raíz Administra su Propia Casa](./fs-26-admin-root-tenant-owner.md)** * **[FS-28: Campanas de Revision de Acceso para Recertificacion de Roles y Permisos](./fs-28-access-review-campaigns.md)** * **[FS-29: Paquetes de Entitlements para Bloques de Acceso Gobernados](./fs-29-entitlement-packages.md)** * **[FS-30: Conectores de Provisioning y Deprovisioning para Sistemas Descendentes](./fs-30-provisioning-deprovisioning-connectors.md)** @@ -42,6 +44,11 @@ Bienvenido al índice maestro de **Functional Stories** para el Sistema de Gesti * **[FS-33: Explorador de Grafo de Autorizacion con Simulacion What-If](./fs-33-authorization-graph-explorer.md)** * **[FS-34: Paquetes de Acceso con Semantica de Negocio y Compositor de Politicas](./fs-34-business-semantic-access-packages.md)** * **[FS-35: Salud Continua de Acceso y Recomendaciones](./fs-35-continuous-access-health.md)** +* **[FS-36: Refresh Token Configurable por Inquilino](./fs-36-configurable-refresh-token.md)** +* **[FS-37: Revocación de Refresh Token](./fs-37-refresh-token-revocation.md)** +* **[FS-38: Gestionar Recursos de Dominio con Jerarquía DDD](./fs-38-ddd-domain-resource-hierarchy.md)** +* **[FS-39: Previsualizar el Grafo de Autorización desde Mantenimiento de Perfil](./fs-39-auth-graph-preview-from-profile.md)** +* **[FS-40: Consistencia en Cambios de Estado — Broken Rules sobre Dependencias Activas](./fs-40-state-change-consistency-broken-rules.md)** --- diff --git a/docs/governance/requirements-es/permission-matrix-example.md b/docs/governance/requirements-es/permission-matrix-example.md index 1be6267a..9068dcbf 100644 --- a/docs/governance/requirements-es/permission-matrix-example.md +++ b/docs/governance/requirements-es/permission-matrix-example.md @@ -10,7 +10,7 @@ This document presents a practical demonstration of how the **ULPMS Resolution E Let's evaluate the permissions resolved for the following user session: * **User Name**: `Alex Arroyo` -* **Tenant / Organization**: `Unimar LIMA-01` +* **Tenant / Organization**: `BeyondNet LIMA-01` * **Assigned Profiles**: 1. `Terminal Operator Profile` (Linked to Template: `OperatorBaseline_v1.0.0`) 2. `Billing Guest Profile` (Custom Local Profile) diff --git a/docs/governance/requirements/conceptual-data-model.md b/docs/governance/requirements/conceptual-data-model.md index 6f8819ed..e1b433c8 100644 --- a/docs/governance/requirements/conceptual-data-model.md +++ b/docs/governance/requirements/conceptual-data-model.md @@ -1,5 +1,10 @@ # Conceptual Data Model +> **Superseded in part (ADR-0090).** Rows describing `MENU` / `SUBMENU` / `OPTION` as distinct +> required levels no longer reflect the model: navigation is one recursive entity, `MenuNode`, +> whose `kind` classifies the role without fixing the depth. See +> [ADR-0090](../../architecture/adrs/0090-recursive-menu-node-tree.md). + This document describes the **business-readable conceptual data model** for the User Management System (UMS). It intentionally uses business-friendly names, but every concept must map to the DDD aggregate model and to the physical ER model. diff --git a/docs/governance/requirements/functional-stories/fs-04-register-system-topology.md b/docs/governance/requirements/functional-stories/fs-04-register-system-topology.md index b7f34e52..dda13cb6 100644 --- a/docs/governance/requirements/functional-stories/fs-04-register-system-topology.md +++ b/docs/governance/requirements/functional-stories/fs-04-register-system-topology.md @@ -59,6 +59,6 @@ If a topology node is incomplete, UMS can save it as draft but prevents its use ## 9. Traceability -- Entities: `SystemSuite`, `Module`, `Menu`, `SubMenu`, `Option`, `Action` +- Entities: `SystemSuite`, `Module`, `MenuNode` (recursive tree, ADR-0090), `Action`, `DomainResource` - ADRs: ADR-0032, ADR-0034, ADR-0047 - Related Stories: FS-02, FS-07, FS-17 diff --git a/docs/governance/requirements/functional-stories/fs-25-ddd-domain-resource-hierarchy.md b/docs/governance/requirements/functional-stories/fs-38-ddd-domain-resource-hierarchy.md similarity index 100% rename from docs/governance/requirements/functional-stories/fs-25-ddd-domain-resource-hierarchy.md rename to docs/governance/requirements/functional-stories/fs-38-ddd-domain-resource-hierarchy.md diff --git a/docs/governance/requirements/functional-stories/fs-26-auth-graph-preview-from-profile.md b/docs/governance/requirements/functional-stories/fs-39-auth-graph-preview-from-profile.md similarity index 100% rename from docs/governance/requirements/functional-stories/fs-26-auth-graph-preview-from-profile.md rename to docs/governance/requirements/functional-stories/fs-39-auth-graph-preview-from-profile.md diff --git a/docs/governance/requirements/functional-stories/fs-27-state-change-consistency-broken-rules.md b/docs/governance/requirements/functional-stories/fs-40-state-change-consistency-broken-rules.md similarity index 100% rename from docs/governance/requirements/functional-stories/fs-27-state-change-consistency-broken-rules.md rename to docs/governance/requirements/functional-stories/fs-40-state-change-consistency-broken-rules.md diff --git a/docs/governance/requirements/functional-stories/index.md b/docs/governance/requirements/functional-stories/index.md index 889774cf..934a21bb 100644 --- a/docs/governance/requirements/functional-stories/index.md +++ b/docs/governance/requirements/functional-stories/index.md @@ -34,9 +34,8 @@ Welcome to the master **Functional Stories** index for the User Management Syste * **[FS-22: User Signup Request and Approval](./fs-22-user-signup-request-approval.md)** * **[FS-23: Profile Access Request from Lobby User](./fs-23-profile-access-request.md)** * **[FS-24: Profile Request Approval and Manual Assignment](./fs-24-profile-request-approval.md)** -* **[FS-25: Manage Domain Resources with DDD Hierarchy](./fs-25-ddd-domain-resource-hierarchy.md)** -* **[FS-26: Preview Auth Graph from Profile Maintenance](./fs-26-auth-graph-preview-from-profile.md)** -* **[FS-27: State-Change Consistency — Broken Rules on Active Dependencies](./fs-27-state-change-consistency-broken-rules.md)** +* **FS-25: Development Seed Dataset** — Spanish only: [fs-25-seed-dataset.md](../../requirements-es/functional-stories/fs-25-seed-dataset.md) +* **FS-26: Admin Root — the Root Tenant Administers its Own House** — Spanish only: [fs-26-admin-root-tenant-owner.md](../../requirements-es/functional-stories/fs-26-admin-root-tenant-owner.md) * **[FS-28: Access Review Campaigns for Role and Permission Recertification](./fs-28-access-review-campaigns.md)** * **[FS-29: Entitlement Packages for Governed Access Bundles](./fs-29-entitlement-packages.md)** * **[FS-30: Provisioning and Deprovisioning Connectors for Downstream Systems](./fs-30-provisioning-deprovisioning-connectors.md)** @@ -45,6 +44,11 @@ Welcome to the master **Functional Stories** index for the User Management Syste * **[FS-33: Authorization Graph Explorer with What-If Simulation](./fs-33-authorization-graph-explorer.md)** * **[FS-34: Business-Semantic Access Packages and Policy Composer](./fs-34-business-semantic-access-packages.md)** * **[FS-35: Continuous Access Health and Recommendations](./fs-35-continuous-access-health.md)** +* **FS-36: Tenant-Configurable Refresh Token** — Spanish only: [fs-36-configurable-refresh-token.md](../../requirements-es/functional-stories/fs-36-configurable-refresh-token.md) +* **FS-37: Refresh Token Revocation** — Spanish only: [fs-37-refresh-token-revocation.md](../../requirements-es/functional-stories/fs-37-refresh-token-revocation.md) +* **[FS-38: Manage Domain Resources with DDD Hierarchy](./fs-38-ddd-domain-resource-hierarchy.md)** +* **[FS-39: Preview Auth Graph from Profile Maintenance](./fs-39-auth-graph-preview-from-profile.md)** +* **[FS-40: State-Change Consistency — Broken Rules on Active Dependencies](./fs-40-state-change-consistency-broken-rules.md)** --- diff --git a/docs/governance/requirements/permission-matrix-example.md b/docs/governance/requirements/permission-matrix-example.md index 542c79ac..a79424ba 100644 --- a/docs/governance/requirements/permission-matrix-example.md +++ b/docs/governance/requirements/permission-matrix-example.md @@ -8,7 +8,7 @@ This document presents a practical demonstration of how the **ULPMS Resolution E Let's evaluate the permissions resolved for the following user session: * **User Name**: `Alex Arroyo` -* **Tenant / Organization**: `Unimar LIMA-01` +* **Tenant / Organization**: `BeyondNet LIMA-01` * **Assigned Profiles**: 1. `Terminal Operator Profile` (Linked to Template: `OperatorBaseline_v1.0.0`) 2. `Billing Guest Profile` (Custom Local Profile) diff --git a/docs/qa/phase1-authentication-e2e-plan.es.md b/docs/qa/phase1-authentication-e2e-plan.es.md new file mode 100644 index 00000000..dc4b043b --- /dev/null +++ b/docs/qa/phase1-authentication-e2e-plan.es.md @@ -0,0 +1,277 @@ +# Plan de pruebas E2E — Fase 1: autenticación UMS ↔ Tablero SDLC + +> Redactado el 2026-08-01 como **propuesta**. Alcance: **solo autenticación**; autorización (grafo, +> menús, controles por rol) queda para la Fase 2 y el diseño se hace modular para admitirla sin +> reestructurar. +> +> ## Estado — 2026-08-03: IMPLEMENTADO, y con partes de este plan retiradas +> +> Los 19 escenarios existen y corren: **carril D** del arnés, +> `src/tests/e2e-functional/robosoft/integracion-tablero/escenarios/`, enganchado a +> `scripts/certify-e2e.sh --carril d`. Medido contra el clúster: **19/19**. +> +> **Qué de este documento NO se implementó, y por qué** (cierra [G-221](../../GAPS.md)): +> +> * **§3.1 y §8 — CORS, la CSP de UMS y `SameSite=None`.** No aplican. El navegador nunca habla con +> UMS: la CSP del Tablero fija `connect-src 'self'` y la llamada la hace `fetch` de Node, servidor +> a servidor. Añadir el origen del Tablero a los orígenes permitidos de UMS abriría una superficie +> que la arquitectura cierra a propósito. No hay escenario multi-origen que probar. +> * **§5.1 — la comparativa de herramienta.** ADR-0109 ya decidió Playwright y el arnés ya lo usa. +> * **§5.2 — el árbol `tests/e2e/` nuevo.** Se construyó DENTRO del arnés existente. Un árbol +> paralelo es el mecanismo por el que se vuelve a construir lo ya construido, que es justo lo que +> documenta [G-189](../../GAPS.md). +> +> **Qué sobrevive y se respetó:** las cinco capas de §5.3 —visual, HTTP, API, almacenamiento y +> experiencia—, los datos sembrados por código de §5.4, y la simulación de §5.5 provocando la caída +> DE VERDAD (escalar réplicas a 0) en vez de interceptar. Esos dos escenarios de caída son **opt-in** +> (`E2E_PERMITE_CAIDA=1`) porque tumban UMS para todo el clúster; sin la variable se omiten y la +> omisión sale en el informe. + +## 1. Hallazgo que condicionaba la fase — INVALIDADO (2026-08-01) + +> **Corrección.** Lo que sigue en esta sección es **falso** y se conserva como registro. La +> comprobación se hizo sobre el árbol de `evolith-core` en la rama +> `arquitectura/design-review-baseline-onprem`, desactualizada; `develop` —en otro worktree— ya +> tenía el gate de identidad desde `3f51815` (ADR-0155): proxy servidor-a-servidor a +> `/client/authenticate`, cookie `httpOnly` con el JWT y verificación HS256. +> +> **Consecuencia para el plan:** la Fase 1a no había que construirla, solo completarla. Lo que +> faltaba —alta de cuenta y recuperación de contraseña— se añadió sobre `evolith-core@develop`. +> La secuencia real pasa a ser: **completar 1a → robot 1b**. Ver G-189 en [GAPS.md](../../GAPS.md). + +**El Tablero SDLC no tiene autenticación.** Verificado sobre +`evolith-core/reference/governance/tablero-ejecutivo/app`: + +| Comprobación | Resultado | +| :--- | :--- | +| Pantalla de acceso en `web/src` | No existe | +| Manejo de sesión o token en el cliente | No existe: `web/src/api.js` compone cabeceras con `Content-Type` y `baggage`, nunca `Authorization` | +| Endpoints de sesión en el servidor | No existen en `server/src` (no hay `login`, `session` ni verificación de JWT) | +| Middleware de autenticación | No existe; la API responde a cualquier llamante | + +De los 19 flujos pedidos —login, contraseña incorrecta, usuario inexistente, bloqueado, inactivo, +signup, confirmación, lost/reset password, refresh, logout, expiración de JWT, errores de +comunicación, timeouts, indisponibilidad de UMS, mensajes, redirecciones, almacenamiento de tokens +y cierre de sesión— **cero son ejecutables hoy contra el Tablero**: no hay superficie que ejercitar. + +Escribir el robot antes que la integración produciría 19 pruebas rojas que no describen un defecto, +sino una funcionalidad ausente. La secuencia correcta es: + +1. **Fase 1a — integración.** Dotar al Tablero de autenticación contra UMS. +2. **Fase 1b — robot.** Automatizar los 19 escenarios sobre esa integración. + +El resto de este plan detalla ambas, porque la 1b solo es diseñable sabiendo qué construye la 1a. + +## 2. Arquitectura actual verificada + +### 2.1 UMS + +| Aspecto | Estado | +| :--- | :--- | +| Backend | .NET 10, Clean Architecture + CQRS. Migraciones EF y siembra determinista al arrancar (`SeedDevData`) | +| Frontend | React 19 + Vite, SPA servida por nginx en producción | +| Autenticación | `POST /api/v1/auth/login` (BCrypt local o IdP federado), refresco, `POST /auth/switch-profile`, `POST /api/v1/client/authenticate` para sistemas satélite | +| Contrato de sesión | Grafo de autorización `2.3.0` — contexto, perfiles, navegación, permisos de dominio, banderas y `settings` | +| Despliegue | Chart Helm `src/infra/ums-helm`, imágenes `ums/backend` y `ums/frontend` con `pullPolicy: Never` (precargadas con `kind load`) | +| Clúster local | `kind` `evolith-ums-cluster`, namespaces `ums` (con observabilidad) y `ums-uat` | +| Ciclo reproducible | `scripts/uat-env.sh` (`up`, `reset`, `smoke`, `status`) — BD fresca → migraciones → siembra → smoke | + +**Se despliega de forma independiente: verificado.** El namespace `ums-uat` corre hoy backend, +frontend, PostgreSQL y Redis sin depender de ningún otro sistema. + +### 2.2 Tablero SDLC + +| Aspecto | Estado | +| :--- | :--- | +| Estructura | Monorepo npm con workspaces `shared`, `server`, `web` | +| Backend | Node.js, API REST en el puerto 4317 | +| Persistencia | PostgreSQL 16 (`docker-compose.yml`), con SQLite como origen histórico | +| Despliegue | Manifiestos en `k8s/` (`app.yaml`, `web.yaml`, `postgres.yaml`, `observability.yaml`) y `kind-cluster.yaml` del clúster `beyondnet-arch-management` | +| Autenticación | **Ausente** (ver §1) | + +**Se despliega de forma independiente: verificado.** Su clúster `kind` ya existe y es distinto del +de UMS. + +### 2.3 Consecuencia para el escenario de dos clústeres + +Ambos sistemas ya viven en clústeres `kind` separados —`evolith-ums-cluster` y +`beyondnet-arch-management`—, de modo que el punto 3 del encargo está cubierto en su mayor parte. Lo +que falta no es infraestructura sino **conectividad entre clústeres y una integración que la use**. + +## 3. Estrategia de comunicación entre clústeres + +Dos clústeres `kind` son dos redes Docker distintas. Tres opciones, con su veredicto: + +| Opción | Cómo | Veredicto | +| :--- | :--- | :--- | +| **A. Host como punto de encuentro** | Cada clúster expone su ingress en un puerto del host (`extraPortMappings`); el Tablero llama a UMS por `http://host.docker.internal:8080` | **Elegida.** Es la que menos piezas añade, funciona en macOS y Linux, y reproduce la topología real —dos sistemas que se hablan por HTTP a través de un borde— sin simularla | +| B. Red Docker compartida | Conectar ambos nodos `kind` a una red Docker común y resolver por nombre de contenedor | Frágil: `kind` recrea la red al recrear el clúster y el DNS entre nodos no es estable | +| C. Un solo clúster, dos namespaces | `ums` y `tablero` en el mismo clúster, comunicación por DNS interno | Más simple, pero **contradice el encargo** y esconde justo los problemas que la Fase 1 debe descubrir: CORS entre orígenes, certificados, latencia de borde | + +### 3.1 Problemas a resolver, ya identificados + +| Riesgo | Detalle | Mitigación | +| :--- | :--- | :--- | +| **CORS** | UMS restringe orígenes con `AllowedOrigins` (`appsettings`). El origen del Tablero no está en la lista | Añadir el origen del Tablero a la configuración del despliegue, no al código | +| **CSP** | La CSP del frontend de UMS limita `connect-src` a `'self' https: ws: wss:`. Un destino `http://` de otro origen **se bloquea en el navegador**, no en el servidor. Verificado en este entorno: costó un ciclo entero de diagnóstico | El Tablero debe llamar a UMS por su propio proxy (mismo origen) o por HTTPS | +| **DNS** | `host.docker.internal` no existe por defecto en Linux | `--add-host=host.docker.internal:host-gateway` en el nodo `kind`, o la IP del gateway | +| **Puertos** | `evolith-ums-cluster` ya mapea 8080; el Tablero mapea 4317 | Mantener el mapa de puertos documentado en un solo sitio | +| **Certificados** | Sin TLS entre clústeres, las cookies `Secure` de UMS no viajan | Fase 1: cookies no `Secure` en el perfil local, declarado explícitamente. Fase 2: `mkcert` + ingress TLS | +| **Cookie de sesión** | UMS emite cookie `HttpOnly` de sesión; entre orígenes distintos exige `SameSite=None; Secure` | Decidir en 1a: token en cabecera `Authorization` (recomendado para satélites) en vez de cookie | +| **Arranque** | El backend de UMS aplica migraciones y siembra al arrancar; el primer arranque tarda | El robot espera a `/health` antes de empezar, con reintento exponencial | + +## 4. Fase 1a — integración de autenticación en el Tablero + +Alcance mínimo para que los 19 escenarios existan: + +| Pieza | Descripción | +| :--- | :--- | +| Cliente UMS en `server/` | Envuelve `POST /api/v1/client/authenticate` (o `/auth/login`), refresco y cierre. Único punto que conoce UMS | +| Sesión | Token en memoria del cliente + `refresh` en cookie `HttpOnly` del propio Tablero, de modo que el token de UMS nunca toque `localStorage` | +| Middleware | Verifica el JWT en cada ruta de la API del Tablero; 401 con cuerpo tipado | +| Pantalla de acceso | Formulario, mensajes de error, bloqueo por intentos, redirección a la ruta pedida | +| Recuperación | Signup, confirmación, lost y reset password: **delegados a UMS**; el Tablero solo enlaza y recibe el retorno | +| Errores | Distinguir credencial inválida (401), usuario bloqueado/inactivo (403 con código), indisponibilidad (503/red) y timeout | + +**Restricción de fase:** nada de esto lee el grafo de autorización. El Tablero recibe el grafo y lo +guarda sin usarlo; la Fase 2 lo consumirá para menús y controles. Guardarlo desde ya evita rehacer +el contrato de sesión después. + +## 5. Fase 1b — el robot + +### 5.1 Tecnología: **Playwright** + +| Criterio | Playwright | Cypress | Selenium | +| :--- | :--- | :--- | :--- | +| Velocidad | Paralelismo real por _workers_ y navegador | Un navegador por proceso | Lento; depende de la malla | +| Estabilidad | Espera automática por estado, sin `sleep` | Buena, con reintentos | Frágil; esperas manuales | +| Interceptar red | `page.route` y `request` de primera clase: exige el contrato HTTP, no solo el píxel | `cy.intercept`, sólido | Requiere proxy externo | +| Evidencia | Vídeo, captura y **traza navegable** (DOM + red + consola por paso) | Vídeo y captura | Captura | +| Multi-origen | Soporta varios orígenes y pestañas en una prueba — **decisivo aquí**: la prueba cruza Tablero y UMS | Limitado históricamente entre dominios | Soportado | +| CI/CD | Imagen oficial con navegadores; `--shard` para repartir | Requiere servicio propio para paralelizar bien | Pesado | +| Mantenibilidad | TypeScript de origen, _fixtures_ componibles, POM natural | JS/TS | Verboso | + +**Decisión: Playwright.** Pesa sobre todo la traza —una prueba roja se diagnostica sin +reproducirla— y el soporte multi-origen, que aquí no es un lujo: el flujo de autenticación cruza +dos sistemas en dos orígenes distintos. + +Ya existe base instalada en el repositorio (`src/tests`), de modo que la decisión también evita +introducir una segunda herramienta. + +### 5.2 Estructura + +```text +tests/e2e/ +├─ playwright.config.ts # proyectos: chromium, webkit; shards; reporters +├─ fixtures/ +│ ├─ entorno.ts # URLs de ambos sistemas, resueltas por variable de entorno +│ ├─ usuarios.ts # personas sembradas: válido, bloqueado, inactivo, inexistente +│ ├─ sesion.ts # fixture que autentica por API y reutiliza estado +│ └─ red.ts # simulación de caída, latencia y timeout de UMS +├─ paginas/ # Page Objects — una clase por pantalla, sin aserciones +│ ├─ acceso.pagina.ts +│ └─ tablero.pagina.ts +├─ escenarios/ +│ ├─ 01-login.spec.ts +│ ├─ 02-credenciales.spec.ts # incorrecta, inexistente, bloqueado, inactivo +│ ├─ 03-alta.spec.ts # signup + confirmación +│ ├─ 04-password.spec.ts # lost + reset +│ ├─ 05-sesion.spec.ts # refresh, expiración, logout +│ ├─ 06-resiliencia.spec.ts # UMS caído, timeout, error de red +│ └─ 07-seguridad.spec.ts # almacenamiento del token, cierre de sesión +└─ soporte/ + ├─ aserciones.ts # aserciones de dominio reutilizables + └─ evidencia.ts # adjunta HAR, respuestas y capturas al informe +``` + +`escenarios/` numerado no impone orden de ejecución —las pruebas son independientes— sino orden de +lectura: quien abra la carpeta ve el recorrido del usuario. + +### 5.3 Qué valida cada escenario + +Cada prueba comprueba las **cinco capas** que pide el encargo, no solo la visual: + +1. **Visual** — el estado que ve el usuario (mensaje, redirección, elemento habilitado). +2. **HTTP** — método, URL, cabeceras y cuerpo de la llamada, capturados con `page.route`. +3. **API** — el código y el cuerpo que devuelve UMS, contrastados contra el contrato publicado. +4. **Almacenamiento** — que el token **no** esté en `localStorage`, que la cookie sea `HttpOnly`, y + que al cerrar sesión no quede rastro. +5. **Experiencia** — que el mensaje sea accionable y no filtre detalle interno (un «usuario + inexistente» y una «contraseña incorrecta» deben ser indistinguibles para no enumerar cuentas). + +### 5.4 Datos de prueba + +Sembrados por código, nunca preparados a mano (misma regla que `uat-env.sh`): un usuario válido, +uno bloqueado, uno inactivo y un correo inexistente, en el tenant `BEYONDNET`. Cada ejecución parte de +BD fresca; el robot **no** limpia lo que ensucia, se reconstruye el entorno. + +### 5.5 Simulación + +Solo donde el escenario lo exige y no se puede provocar de verdad: + +| Escenario | Cómo | +| :--- | :--- | +| UMS indisponible | Escalar el _deployment_ a 0 réplicas — indisponibilidad real, no simulada | +| Timeout | `page.route` con retardo por encima del umbral del cliente | +| JWT expirado | Emitir un token de vida corta por configuración del tenant, y esperar | + +Escalar réplicas en vez de interceptar es deliberado: prueba también que el Tablero distingue «no +responde» de «responde error», que es donde suelen fallar estos clientes. + +### 5.6 Informes, métricas y evidencia + +* Reporter HTML de Playwright + JUnit XML para integrarlo donde haga falta. +* Traza, vídeo y captura **solo de lo que falla** (`retain-on-failure`), para que el artefacto no + crezca sin control. +* HAR por escenario, adjunto al informe: deja auditable la conversación HTTP completa. +* Métricas por ejecución: duración por escenario, tasa de reintento y _flakiness_ acumulada. Una + prueba que necesita reintento no es verde: es una deuda registrada. + +### 5.7 Recuperación ante fallos + +* Reintento **1** en CI y **0** en local: en local un fallo debe verse. +* El robot verifica la salud de ambos sistemas antes de empezar y aborta con diagnóstico —qué + sistema, qué endpoint, qué código— en vez de encadenar 19 fallos que dicen lo mismo. +* Si un escenario deja el entorno inconsistente, la suite lo declara y fuerza `uat-env.sh reset` en + vez de continuar sobre datos sucios. + +## 6. Ciclo de ejecución + +```text +1. build imágenes de UMS y del Tablero +2. kind load precarga en cada clúster (pullPolicy: Never) +3. deploy helm upgrade --install (UMS) + kubectl apply (Tablero) +4. wait /health de ambos, con reintento exponencial +5. seed datos deterministas por código +6. e2e playwright test --shard +7. report HTML + JUnit + HAR + métricas +8. teardown opcional; por defecto el entorno queda en pie para inspección +``` + +Ejecutable en un solo comando (`scripts/e2e-env.sh up|test|down`), en la línea de `uat-env.sh` que +ya existe y funciona. + +## 7. Preparado para la Fase 2 + +Tres decisiones de este plan existen para que la autorización entre sin reestructurar: + +1. El Tablero **guarda el grafo desde la Fase 1** aunque no lo use: el contrato de sesión no cambia + al llegar la Fase 2. +2. Los Page Objects no conocen permisos; exponen elementos. Una prueba de Fase 2 afirmará sobre los + mismos objetos con otro usuario. +3. Las _fixtures_ de usuario ya llevan tenant y perfil, aunque la Fase 1 solo use el primero. + +## 8. Qué falta decidir + +| Punto | Opciones | +| :--- | :--- | +| Endpoint que usa el Tablero | `/api/v1/client/authenticate` (pensado para satélites, devuelve grafo) o `/auth/login` (portal). **Recomendado: el de cliente** | +| Transporte del token | Cabecera `Authorization` (recomendado entre orígenes) o cookie con `SameSite=None; Secure` (exige TLS local) | +| Signup y recuperación | ¿Los sirve UMS con su propia interfaz —y el Tablero solo enlaza— o el Tablero replica los formularios? **Recomendado: los sirve UMS** | +| Alcance del robot | ¿Solo el Tablero, o también el portal de UMS? El plan cubre el Tablero; añadir el portal es un proyecto más en la misma configuración | + +--- + +

+ © BeyondNet S.A.C. · RUC 20100412447 · Operador Logístico Aduanero desde 1978 +

diff --git a/docs/sdk-es/contracts/schema-overview.md b/docs/sdk-es/contracts/schema-overview.md index d8a059cf..d439e98c 100644 --- a/docs/sdk-es/contracts/schema-overview.md +++ b/docs/sdk-es/contracts/schema-overview.md @@ -105,29 +105,26 @@ Catálogo de todas las entidades `Action` registradas en el `SystemSuite`. Usado ### 3.5 `menuAccess` (array, requerido, puede estar vacío) -Árbol de navegación UI: `Module → Menu → SubMenu → Option`, con el `AccessEffect` resuelto y `source` a nivel de hoja. +Árbol de navegación recursivo (v2.0.0, ADR-0090). Cada módulo lleva `nodes`; cada nodo puede +anidar `children` a profundidad arbitraria. `kind` clasifica el papel del nodo (`Menu`, `SubMenu`, +`Option`) **sin fijar dónde aparece**: no presupongas tres niveles, recorre `children` hasta +agotarlo. Solo viaja lo ALCANZABLE: una hoja sin acción resuelta se omite, y una rama que se queda +sin hojas se omite con ella. La ausencia significa «no concedido». ```jsonc "menuAccess": [ { - "module": { "id": "uuid", "code": "string", "name": "string", - "sortOrder": 0, "status": "PUBLISHED" }, - "menus": [ + "id": "uuid", "code": "string", "value": "string", + "sortOrder": 0, "status": "Active", "icon": "string|null", + "nodes": [ { - "id": "uuid", "code": "string", "label": "string", "sortOrder": 0, - "subMenus": [ - { - "id": "uuid", "code": "string", "label": "string", "sortOrder": 0, - "options": [ - { - "id": "uuid", "code": "string", "label": "string", - "actionCode": "VIEW", - "effect": "Allow" | "Deny" | "NotGranted", - "source": "Template" | "Override" - } - ] - } - ] + "id": "uuid", "code": "string", "value": "string", + "kind": "Menu" | "SubMenu" | "Option", + "sortOrder": 0, "icon": "string|null", "route": "string|null", + "actions": [ + { "actionCode": "VIEW", "effect": "Allow" | "Deny", "source": "Template" | "Override" } + ], + "children": [ /* misma forma, recursivamente */ ] } ] } diff --git a/docs/sdk-es/dotnet/README.md b/docs/sdk-es/dotnet/README.md index dd39c8bf..b8b66b10 100644 --- a/docs/sdk-es/dotnet/README.md +++ b/docs/sdk-es/dotnet/README.md @@ -99,7 +99,7 @@ Mapea a la sección `scopes[]` del grafo. El aspecto verifica que el string de s public Task AdjustStockAsync(StockAdjustment adjustment) { ... } ``` -Mapea a la sección `menuAccess[].menus[].subMenus[].options[]` del grafo. El aspecto busca el código de opción; la decisión es `Allow` solo si `effect == "Allow"`. +Mapea a la sección `menuAccess[].nodes[] (recorrido recursivo por `children`, ADR-0090)` del grafo. El aspecto busca el código de opción; la decisión es `Allow` solo si `effect == "Allow"`. ### 4.3 `[RequiresDomainAccess]` diff --git a/docs/sdk/contracts/schema-overview.md b/docs/sdk/contracts/schema-overview.md index 2ab330c1..c0ad6724 100644 --- a/docs/sdk/contracts/schema-overview.md +++ b/docs/sdk/contracts/schema-overview.md @@ -105,29 +105,26 @@ Catalog of all `Action` entities registered in the `SystemSuite`. Used by client ### 3.5 `menuAccess` (array, required, may be empty) -UI navigation tree: `Module → Menu → SubMenu → Option`, with the resolved `AccessEffect` and `source` at the leaf level. +Recursive UI navigation tree (v2.0.0, ADR-0090). Each module carries `nodes`; every node may +nest `children` to arbitrary depth. `kind` classifies the node's role (`Menu`, `SubMenu`, +`Option`) **without fixing where it appears** — do not assume three levels; walk `children` until +it is exhausted. Only what is REACHABLE travels: a leaf with no resolved action is omitted, and a +branch left without leaves is omitted with it. Absence means "not granted". ```jsonc "menuAccess": [ { - "module": { "id": "uuid", "code": "string", "name": "string", - "sortOrder": 0, "status": "PUBLISHED" }, - "menus": [ + "id": "uuid", "code": "string", "value": "string", + "sortOrder": 0, "status": "Active", "icon": "string|null", + "nodes": [ { - "id": "uuid", "code": "string", "label": "string", "sortOrder": 0, - "subMenus": [ - { - "id": "uuid", "code": "string", "label": "string", "sortOrder": 0, - "options": [ - { - "id": "uuid", "code": "string", "label": "string", - "actionCode": "VIEW", - "effect": "Allow" | "Deny" | "NotGranted", - "source": "Template" | "Override" - } - ] - } - ] + "id": "uuid", "code": "string", "value": "string", + "kind": "Menu" | "SubMenu" | "Option", + "sortOrder": 0, "icon": "string|null", "route": "string|null", + "actions": [ + { "actionCode": "VIEW", "effect": "Allow" | "Deny", "source": "Template" | "Override" } + ], + "children": [ /* same shape, recursively */ ] } ] } diff --git a/docs/sdk/dotnet/README.md b/docs/sdk/dotnet/README.md index 6c4d895a..2469cf97 100644 --- a/docs/sdk/dotnet/README.md +++ b/docs/sdk/dotnet/README.md @@ -99,7 +99,7 @@ Maps to graph section `scopes[]`. The aspect verifies the scope string is in `gr public Task AdjustStockAsync(StockAdjustment adjustment) { ... } ``` -Maps to graph section `menuAccess[].menus[].subMenus[].options[]`. The aspect searches for the option code; the decision is `Allow` only if `effect == "Allow"`. +Maps to graph section `menuAccess[].nodes[]`, walked recursively through `children` (v2.0.0, ADR-0090). The aspect matches the code against the node or one of its `actions`; the decision is `Allow` only if `effect == "Allow"`. ### 4.3 `[RequiresDomainAccess]` diff --git a/src/.editorconfig b/src/.editorconfig new file mode 100644 index 00000000..ae6a7eaa --- /dev/null +++ b/src/.editorconfig @@ -0,0 +1,128 @@ +root = true + +[*] +indent_style = space +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.{cs,csx}] +indent_size = 4 +tab_width = 4 + +# Organizar usings: primero System, luego el resto +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = false + +# Preferir var cuando el tipo es evidente +csharp_style_var_for_built_in_types = true:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = true:suggestion + +# Expresiones compactas (expression-bodied) +csharp_style_expression_bodied_methods = when_on_single_line:suggestion +csharp_style_expression_bodied_properties = when_on_single_line:suggestion +csharp_style_expression_bodied_constructors = false:suggestion + +# Null-checking moderno +csharp_style_throw_expression = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_coalesce_expression = true:suggestion + +# Modificadores de acceso explícitos +dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning + +# Llaves en bloques de control +csharp_prefer_braces = when_multiline:suggestion + +# Nuevas líneas +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true + +# Espaciado +csharp_space_after_cast = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_around_binary_operators = before_and_after + +# Convención de nombres: campos privados con prefijo _ +dotnet_naming_rule.private_fields_underscore.severity = suggestion +dotnet_naming_rule.private_fields_underscore.symbols = private_fields +dotnet_naming_rule.private_fields_underscore.style = camel_case_underscore_style + +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private + +dotnet_naming_style.camel_case_underscore_style.required_prefix = _ +dotnet_naming_style.camel_case_underscore_style.capitalization = camel_case + +# Constantes en PascalCase +dotnet_naming_rule.constants_pascal_case.severity = suggestion +dotnet_naming_rule.constants_pascal_case.symbols = constants +dotnet_naming_rule.constants_pascal_case.style = pascal_case_style + +dotnet_naming_symbols.constants.applicable_kinds = field +dotnet_naming_symbols.constants.required_modifiers = const + +dotnet_naming_style.pascal_case_style.capitalization = pascal_case + +[*.{json,yml,yaml}] +indent_size = 2 + +[*.{xml,csproj,props,targets}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[*.cs] +dotnet_diagnostic.S1481.severity = error # variables locales sin usar +dotnet_diagnostic.S1172.severity = error # parámetros de método sin usar +dotnet_diagnostic.S2325.severity = error # miembros que pueden ser static +dotnet_diagnostic.S1144.severity = error # miembros privados sin usar +dotnet_diagnostic.S1186.severity = error # métodos vacíos +dotnet_diagnostic.S125.severity = error # código comentado +dotnet_diagnostic.S3358.severity = error # ternarios anidados +dotnet_diagnostic.S927.severity = error # nombres de parámetros coincidentes +dotnet_diagnostic.S4144.severity = error # implementaciones idénticas +dotnet_diagnostic.S6562.severity = error # DateTimeKind explícito al construir DateTime +dotnet_diagnostic.S3973.severity = error # llaves/indentación en bloques condicionales +dotnet_diagnostic.S6608.severity = error # indexación [0]/[^1] en lugar de First()/Last() +dotnet_diagnostic.S3400.severity = error # constante en lugar de método que devuelve un literal +dotnet_diagnostic.S6966.severity = error # await de la variante *Async (RunAsync/CancelAsync) +dotnet_diagnostic.S6444.severity = error # timeout explícito al construir Regex +dotnet_diagnostic.S6580.severity = error # IFormatProvider explícito al parsear fecha/hora +dotnet_diagnostic.S4487.severity = error # campos privados asignados pero nunca leídos +dotnet_diagnostic.S3928.severity = error # paramName válido en ArgumentException/ArgumentNullException +dotnet_diagnostic.S3903.severity = error # tipos en un namespace con nombre, no en el global +dotnet_diagnostic.S3458.severity = error # sin cláusulas 'case'/'default' vacías redundantes +dotnet_diagnostic.S3260.severity = error # clases privadas no derivadas marcadas como 'sealed' +dotnet_diagnostic.S4136.severity = error # sobrecargas de método adyacentes +dotnet_diagnostic.S2068.severity = error # credenciales hardcodeadas (excepciones: supresión justificada) +dotnet_diagnostic.S2077.severity = error # consultas parametrizadas, no formato de cadena (excep.: supresión justif.) +dotnet_diagnostic.S3011.severity = error # bypass de accesibilidad por reflexión: sancionado y suprimido con cita a ADR-UMS-099; un uso NUEVO sin suprimir rompe el build + +# S1135 (comentarios TODO) NO se gatea, y no es un olvido. +# El analizador casa «TODO» SIN distinguir mayúsculas, así que dispara sobre la palabra +# española «todo» en prosa corriente: 11 de los 17 hallazgos de la Tranche 6 eran comentarios +# como «y todo su subárbol» o «única en todo el sistema». Elevarlo a error obligaría a +# contorsionar el castellano para satisfacer a un analizador anglocéntrico, lo que choca de +# frente con SD-08 (documentación exclusivamente en español). El analizador Roslyn no expone +# configuración de la lista de marcadores, así que se queda como aviso no bloqueante y los +# TODO reales se gobiernan por convención: TODO(D-016) y TODO(G-NNN) trazan a DECISIONS.md +# y GAPS.md. Un TODO sin identificador de registro es la deuda invisible que SD-07 prohíbe. + +# ── Resolvers de GraphQL ───────────────────────────────────────────────────── +# S2325 («este método podría ser static») NO se gatea bajo Ums.Presentation/GraphQL. +# HotChocolate descubre los campos de un [ExtendObjectType] por reflexión sobre los métodos de +# INSTANCIA del tipo. Convertirlos en static los hace invisibles para esa reflexión: el tipo +# `Query` se queda sin un solo campo, el esquema no compila —«The object type `Query` has to at +# least define one field in order to be valid»— y la aplicación NO ARRANCA. Se comprobó del modo +# más caro posible: hacerlos static dejó 229 pruebas de integración en rojo porque el host no +# levantaba. El analizador tiene razón sobre el lenguaje y se equivoca sobre el framework. +[**/Ums.Presentation/GraphQL/**.cs] +dotnet_diagnostic.S2325.severity = suggestion diff --git a/src/.gitattributes b/src/.gitattributes new file mode 100644 index 00000000..d48d9676 --- /dev/null +++ b/src/.gitattributes @@ -0,0 +1,16 @@ +# GitAttributes - Persistence Integrity Standard + +# Handle line endings automatically +* text=auto eol=lf + +# Force UTF-8 for all text files +*.md text eol=lf working-tree-encoding=UTF-8 +*.txt text eol=lf +*.json text eol=lf +*.yaml text eol=lf +*.yml text eol=lf + +# Binary files +*.png binary +*.jpg binary +*.svg text diff --git a/src/.gitignore b/src/.gitignore new file mode 100644 index 00000000..4b0b55c5 --- /dev/null +++ b/src/.gitignore @@ -0,0 +1,61 @@ +# Dependencies +node_modules/ +.pnp +.pnp.js + +# Testing +coverage/ + +# Production +dist/ +build/ + +# Local env files +**/.env +**/.env.local +**/.env.*.local + +# Log files +**/*.log +**/npm-debug.log* +**/yarn-debug.log* +**/yarn-error.log* +**/pnpm-debug.log* +**/lerna-debug.log* + +# IDEs, editors and OS metadata +**/.idea/ +**/.vscode/ +**/.vs/ +**/*.suo +**/*.user +**/*.userosscache +**/*.sln.docstates +**/*.ntvs* +**/*.njsproj +**/*.sw? +**/.DS_Store +**/Thumbs.db + +# .NET build artifacts +**/bin/ +**/obj/ +**/*.nupkg +**/*.snupkg + +# Docker +**/.docker/ +**/docker-compose.override.yml + +# Nx and Vite cache/temp files +**/.nx/ +**/vite.config.*.timestamp* + + +# Local appsettings overrides — never commit real credentials +**/appsettings.Local.json +**/appsettings.*.Local.json + +# Agentes de IA y Metodología BMAD +.claude/ +.gemini/ diff --git a/src/AGENTS.md b/src/AGENTS.md new file mode 100644 index 00000000..2016a100 --- /dev/null +++ b/src/AGENTS.md @@ -0,0 +1,52 @@ +## Project +Enterprise Monorepo for User Management System (UMS). An authorization block prototype capable of working with third-party Identity Providers or operating standalone, using .NET 10, React 18, PostgreSQL, and BMAD-METHOD. + +## Build & Run +> [!IMPORTANT] +> The technical engine of this monorepo is located in `src/`. All technical commands must be executed relative to that directory. + +Commands for Frontend (run from `src/`): +- Frontend Install: `npm install` +- Frontend Start: `npx nx run app-web:dev` +- Setup Docs Context (Context7): `npx ctx7 setup` +- Markdown Encoding Sanitation: `python3 ../.bmad-core/scripts/cleanup_markdown_encoding.py` + +Commands for Backend (run from `./src/apps/ums.api/` or the root solution directory): +- Backend Build: `dotnet build` +- Backend Test: `dotnet test` +- Backend Run: `dotnet run` + +## Architecture +- Runtime: **.NET 10** (Backend) and React v18 + Vite (Frontend). +- Monorepo: Managed via Nx, npm Workspaces (Frontend) and standard .NET SLN. +- DB: PostgreSQL + Entity Framework Core (EF Core through Npgsql). +- Key Modules: `src/apps/ums.api` (.NET Backend), `src/apps/ums.web-app` (Frontend React Portal). +- Pattern: Modular Monolith, Clean Architecture, Explicit Bounded Contexts, CQRS-oriented reads, REST + GraphQL queries. + +## Conventions +- **Engineering Patterns**: Adhere to **Clean Architecture** (Hexagonal), **SOLID** principles, and strict **DDD** bounded contexts. +- **AI-Driven Strategy**: Utilize the **BMAD-METHOD** for spec-driven development and numerical sequential documentation (Phases 00 to 05). +- **Domain Purity**: Strictly isolate Domain rules from external frameworks. + - JS/TS: Hexagonal boundaries and strict linting. + - C#: `{BoundedContext}.Domain` project must be pure POCOs with zero NuGet references. +- **Flow Control**: Utilize the **Result Pattern** instead of throwing application exceptions for domain flow control. +- Enforce strict TypeScript and C# types with static analysis gates (SonarJS). + +## Agent Rules +- NEVER delete or bypass existing tests to make a fix pass. +- Before updating dependencies, verify strict dependency pinning. +- If modifying core logic, ensure architectural traceability to approved ADRs. +- Keep formatting clean, adhering to ESLint and Prettier configs in the workspace. +- **BMAD Rule Compliance:** Any agent working on this repository MUST read, prioritize, and strictly enforce the 14 rules defined in `.bmad-core/rules/global-rules.md`, `.bmad-core/rules/structuring-standard.md` (R-13), and `.harness/rules/project-rules.yaml`. If encoding artifacts (mojibake) or non-standard decorative characters (emojis/icons) are detected, the agent MUST run the appropriate cleanup utilities immediately to enforce rules R-03 and R-14. No code or documentation commits are permitted without validating against these rules. +- **BMAD Audit Skills:** Any agent performing analysis, implementation, refactoring, or verification MUST also use the project playbooks in `.harness/playbooks/` for API audits, frontend audits, documentation audits, and modular-monolith evolution reviews. +- **Multi-language Synchronization & Diagram Validation:** Whenever documentation is updated, the agent MUST ensure that both English and Spanish versions are synchronized in content, technical precision, and clarity. Additionally, all Mermaid diagrams MUST be validated for syntax and structural correctness before any commit. See [Documentation Control Agents](../reference/gobernanza/documentation-control-agents.md) for detailed bilingual consistency rules. +- **Context Retrieval:** Always use **Context7** (`npx ctx7`) to fetch updated, version-specific documentation for third-party libraries before implementing complex external integrations. +- **Corporate Standards Alignment:** Any agent making architectural design decisions MUST query the **Corporate Reference** via Context7 (`use context7 for beyondnetcode/evolith-core`) to ensure absolute compliance with baseline polyglot standards and authoritative patterns. +- **Tenancy Enforcement:** Application-layer tenant filtering is the primary isolation mechanism. PostgreSQL row-level security, schema ownership, constraints, and database policies are secondary infrastructure failsafes and must never replace application-layer filtering in requirements, ADRs, code, or documentation. +- **Functional Story Standard:** Functional stories must keep business narrative readable for Product Owners and Business Analysts, and place technical detail in a dedicated `Technical Requirements` section per `docs/governance/requirements/functional-stories/functional-story-standard.md`. +- **Configuration Catalog Standard:** Any parameter, configuration, policy, feature flag, workflow, or master catalog entity must follow the mandatory `code`, `value`, `description` standard and update model, ORM, migrations, and documentation together. + +## Out of Bounds +- DO NOT modify CI/CD GitHub workflows. +- DO NOT edit Git hooks (Husky configuration). +- DO NOT modify core corporate library standards unless authorized. diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md new file mode 100644 index 00000000..497c0768 --- /dev/null +++ b/src/CHANGELOG.md @@ -0,0 +1,114 @@ +## Unreleased (2026-06-03) + +### Features + +- **authorization:** DDD domain resource hierarchy — adds `DomainMethod` as a third `DomainResourceType` variant (`DomainMethod = 3`) with `ParentResourceId` nullable column; hierarchy invariants enforced in `SystemSuite.AddDomainResource`; auth graph builder includes DomainMethod nodes as addressable permission targets ([b4294be](https://github.com/beyondnetcode/ums/commit/b4294be)) +- **authorization:** consistent state-change and deletion dependency guard policy — application command handlers check active dependencies before executing guarded operations and return HTTP 409 with structured `BlockedOperationResponse` payload including `errorCode`, `message`, `brokenRule`, and `blockingDependencies[]` ([e22629c](https://github.com/beyondnetcode/ums/commit/e22629c)) + +### Thank You + +- Alberto Arroyo Raygada @nestjslatam + +--- + +## 0.0.9 (2026-05-10) + +### Features + +- **devops:** enforce 70% coverage gate for ADR 0018 ([7f957ec](https://github.com/beyondnetcode/ums/commit/7f957ec)) + +### Thank You + +- Alberto Arroyo Raygada @nestjslatam + +## 0.0.8 (2026-05-10) + +### Features + +- **devops:** provide FF dependencies for ADR 0017 ([d7eaef7](https://github.com/beyondnetcode/ums/commit/d7eaef7)) + +### Thank You + +- Alberto Arroyo Raygada @nestjslatam + +## 0.0.7 (2026-05-10) + +### Features + +- **devops:** provide context storage dependency for ADR 0016 ([c2a7d8b](https://github.com/beyondnetcode/ums/commit/c2a7d8b)) + +### Thank You + +- Alberto Arroyo Raygada @nestjslatam + +## 0.0.6 (2026-05-10) + +### Features + +- **devops:** provide EDA dependencies for ADR 0015 ([320f33e](https://github.com/beyondnetcode/ums/commit/320f33e)) + +### Thank You + +- Alberto Arroyo Raygada @nestjslatam + +## 0.0.5 (2026-05-10) + +### Features + +- **devops:** provide caching dependencies for ADR 0014 ([3d97238](https://github.com/beyondnetcode/ums/commit/3d97238)) + +### Thank You + +- Alberto Arroyo Raygada @nestjslatam + +## 0.0.4 (2026-05-10) + +### Features + +- **devops:** provide authentication dependencies for ADR 0012 ([0da5161](https://github.com/beyondnetcode/ums/commit/0da5161)) + +### Thank You + +- Alberto Arroyo Raygada @nestjslatam + +## 0.0.3 (2026-05-10) + +### Features + +- **bmad:** introduce Configuration & Feature Management Platform - Multi-IdP, System Config, Feature Flags, ADR-0024, bounded context update ([6f0161f](https://github.com/beyondnetcode/ums/commit/6f0161f)) +- **bmad:** enhance Feature Flag framework with pluggable IFeatureFlagPort - ADR-0025, LaunchDarkly/Unleash/ConfigCat/Azure adapters, provider selector strategy ([07d41fb](https://github.com/beyondnetcode/ums/commit/07d41fb)) +- **bmad:** integrate hierarchical config resolution & C4 updates - UC-09, overrides strategy, business-context alignment ([a7d036b](https://github.com/beyondnetcode/ums/commit/a7d036b)) +- **bmad:** update Strategic OKRs & Product Vision - optional external IdPs/FFs design mandate, native fallback core ([6330316](https://github.com/beyondnetcode/ums/commit/6330316)) +- **bmad:** decouple UMS from Product Planner - declare as 100% abstract, API/Message Bus driven standalone security kernel ([2202272](https://github.com/beyondnetcode/ums/commit/2202272)) +- **bmad:** update business-context diagram with pluggable auth/config/FF architecture ([00628d8](https://github.com/beyondnetcode/ums/commit/00628d8)) +- **bmad:** create master audit alignment and enterprise architecture spec - unified B-M-A-D pillars ([dfd76b4](https://github.com/beyondnetcode/ums/commit/dfd76b4)) +- **bmad:** introduce Customizable Hosted Login Page feature - UC-10, schema properties, scope, and alignment ([2388cf4](https://github.com/beyondnetcode/ums/commit/2388cf4)) +- **core:** add ADR 0008 for progressive multi-module evolution, API Gateway, and BFF patterns ([630a05c](https://github.com/beyondnetcode/ums/commit/630a05c)) +- **core:** implement ADR 0009 strict dependency pinning and CI vulnerability shield ([a813c68](https://github.com/beyondnetcode/ums/commit/a813c68)) +- **core:** implement Result pattern (ADR 0019) and update tracking matrix ([e5a4ee3](https://github.com/beyondnetcode/ums/commit/e5a4ee3)) +- **devops:** provide production-grade docker files and complete agnostic docker-compose stack ([4df1bb1](https://github.com/beyondnetcode/ums/commit/4df1bb1)) +- **devops:** integrate LGTM stack and circuit breaker patterns ([ef760b0](https://github.com/beyondnetcode/ums/commit/ef760b0)) + +### Fixes + +- **bmad:** quote Mermaid link labels in business-context to resolve parser error ([500dce0](https://github.com/beyondnetcode/ums/commit/500dce0)) +- **bmad:** simplify Mermaid arrow labels to alphanumeric/underscore to ensure 100% engine compatibility ([86ae67e](https://github.com/beyondnetcode/ums/commit/86ae67e)) +- **bmad:** remove sequence note over tags from flowchart and simplify cylinder shapes to fix parser error completely ([d668c0d](https://github.com/beyondnetcode/ums/commit/d668c0d)) + +### Thank You + +- Alberto Arroyo Raygada @nestjslatam + +## 0.0.2 (2026-05-08) + +### Features + +- **core:** initial enterprise architecture and bmad setup ([c277f8b](https://github.com/beyondnetcode/ums/commit/c277f8b)) + +### Fixes + +- **docs:** restore utf-8 encoding for emojis in readme ([c855866](https://github.com/beyondnetcode/ums/commit/c855866)) + +### Thank You + +- Alberto Arroyo Raygada @nestjslatam \ No newline at end of file diff --git a/src/Directory.Build.props b/src/Directory.Build.props new file mode 100644 index 00000000..0668e47a --- /dev/null +++ b/src/Directory.Build.props @@ -0,0 +1,20 @@ + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/src/Makefile b/src/Makefile index 94c8c317..bd34cf00 100644 --- a/src/Makefile +++ b/src/Makefile @@ -1,4 +1,16 @@ -.PHONY: up down build rebuild logs clean +.PHONY: up down build rebuild logs clean uat-up uat-reset uat-smoke uat-backup uat-status + +# ── Stage UAT en kind (G-127) — delega en scripts/uat-env.sh ────────────────── +uat-up: + ../scripts/uat-env.sh up +uat-reset: + ../scripts/uat-env.sh reset +uat-smoke: + ../scripts/uat-env.sh smoke +uat-backup: + ../scripts/uat-env.sh backup +uat-status: + ../scripts/uat-env.sh status up: docker compose up -d diff --git a/src/README.md b/src/README.md new file mode 100644 index 00000000..0f11c83c --- /dev/null +++ b/src/README.md @@ -0,0 +1,245 @@ +
+ +# UMS: Enterprise User Management System + +> **Bilingual Navigation:** [Versión en Español](../reference/indices/index.md) + +[![Status](https://img.shields.io/badge/Status-Active-brightgreen?style=for-the-badge)]() +[![Platform](https://img.shields.io/badge/.NET_10_%7C_PostgreSQL_%7C_React_18-informational?style=for-the-badge)]() +[![Architecture](https://img.shields.io/badge/BeyondNet-Satellite_Product-blueviolet?style=for-the-badge)](https://github.com/beyondnetcode/evolith-core) +[![ADRs](https://img.shields.io/badge/ADRs-66_decisions-orange?style=for-the-badge)](../reference/architecture/adrs/) +[![License](https://img.shields.io/badge/License-Proprietary-red?style=for-the-badge)]() + +
+ + + BeyondNet E2E Architecture - UMS Satellite Product + + +BeyondNet E2E Architecture Framework - UMS official satellite product - click to enlarge + +
+ +**UMS is a modular monolith for identity, authorization, configuration, approvals, compliance, IGA, and audit.**
+Built on **.NET 10, PostgreSQL, EF Core through Npgsql, React 18, TypeScript, and Nx**.
+It specializes the [BeyondNet](https://github.com/beyondnetcode/evolith-core) corporate architecture reference for a product-grade user management system. + +> *Inherit the standard, specialize the product.* + +
+ +--- + +## Start Here + +
+Primary entry points + +- [Product Vision](../reference/gobernanza/product/vision-producto.md) - strategy, product goals, and business positioning. +- [Architecture Portal](../reference/architecture/index.md) - architectural overview, ADRs, blueprints, and applied reference material. +- [Domain Model](../docs/02-diseno/dominio/index.md) - bounded contexts, aggregates, entities, and domain rules. +- [Functional Stories](../docs/02-diseno/historias-funcionales/index.md) - business-readable product backlog. +- [Master Index](../reference/indices/indice-maestro.md) - complete documentation navigation. +- [BeyondNet Upstream](https://github.com/beyondnetcode/evolith-core) - corporate reference base inherited by UMS. + +
+ +
+Getting started by role + +- **Architects:** start with [Architecture Portal](../reference/architecture/index.md), then review [ADR Registry](../reference/architecture/adrs/) and [Traceability Matrix](../reference/architecture/matriz-trazabilidad.md). +- **Backend developers:** start with [API .NET Reference](../reference/architecture/api-dotnet/README.md), then review [Domain Aggregates](../docs/02-diseno/dominio/index.md) and [.NET SDK](../reference/sdk/dotnet/README.md). +- **Frontend developers:** start with [Frontend Clean Architecture ADR](../reference/architecture/adrs/UMS-056-arquitectura-limpia-frontend.es.md), then review [TypeScript SDK](../reference/sdk/typescript/README.md) and [State Management ADR](../reference/architecture/adrs/UMS-057-estado-zustand-tanstack-query.es.md). +- **Product and PM:** start with [Product Vision](../reference/gobernanza/product/vision-producto.md), then review [Scope](../reference/gobernanza/product/alcance.md), [Objectives](../reference/gobernanza/product/objetivos.md), and [Gap Tracker](../reference/gobernanza/project/seguimiento-gaps-historias-funcionales.md). +- **DevOps and SRE:** start with [Infrastructure Plan](./infra/infrastructure_plan.md), then review [Operations Portal](../reference/operaciones/index.md), [Runbooks](../reference/operaciones/runbooks/index.md), and [Metrics](../reference/operaciones/metrics/index.md). +- **AI contributors:** start with [AGENTS.md](./AGENTS.md), then review [Documentation Control Agents](../reference/gobernanza/documentation-control-agents.md) and [ADR Template](../reference/gobernanza/sdlc/plantilla-adr.md). + +
+ +## SDLC Navigation + +Open the lifecycle area you are working in. Each section groups the documents and repository anchors that support its gate. + +
+Phase 00 - Product and Governance + +| Documento | Tipo | +| :--- | :--- | +| [Product Vision](../reference/gobernanza/product/vision-producto.md) | Guía | +| [Business Context](../reference/gobernanza/product/contexto-negocio.md) | Guía | +| [Scope and Boundaries](../reference/gobernanza/product/alcance.md) | Guía | +| [Objectives](../reference/gobernanza/product/objetivos.md) | Guía | +| [Governance Hub](../reference/gobernanza/index.md) | Índice | +| [Stakeholders](../reference/gobernanza/product/partes-interesadas.md) | Registro | + +
+ +
+Phase 01 - Requirements + +| Documento | Tipo | +| :--- | :--- | +| [Functional Story Standard](../docs/02-diseno/historias-funcionales/estandar-redaccion-historias-funcionales.md) | Estándar | +| [Requirements Hub](../reference/gobernanza/requirements/index.md) | Índice | +| [Functional Stories](../docs/02-diseno/historias-funcionales/index.md) | Índice | +| [Permission Matrix Example](../reference/gobernanza/requirements/ejemplo-matriz-permisos.md) | Matriz | +| [Conceptual Data Model](../reference/gobernanza/requirements/modelo-datos-conceptual.md) | Referencia | +| [Glossary](../reference/gobernanza/requirements/glosario.md) | Referencia | + +
+ +
+Phase 02 - Design and Architecture + +| Documento | Tipo | +| :--- | :--- | +| [Canonical Patterns](../reference/architecture/artifacts/canonical-patterns/index.md) | Guía | +| [Architecture Portal](../reference/architecture/index.md) | Índice | +| [ADR Registry](../reference/architecture/adrs/) | Índice | +| [DDD Design Hub](../reference/gobernanza/construction/ddd-design/index.md) | Índice | +| [Traceability Matrix](../reference/architecture/matriz-trazabilidad.md) | Matriz | +| [BeyondNet ADR Matrix](https://github.com/beyondnetcode/evolith-core/blob/main/reference/architecture/adrs/adr-matrix.md) | Matriz | +| [Architecture Overview](../reference/architecture/vision-general.md) | Referencia | +| [Blueprints](../reference/architecture/blueprints/) | Referencia | + +
+ +
+Phase 03 - Construction + +| Documento | Tipo | +| :--- | :--- | +| [Construction Hub](../reference/gobernanza/construction/index.md) | Índice | +| [SDK Portal](../reference/sdk/index.md) | Índice | +| [Bounded Context Map](../reference/gobernanza/construction/ddd-design/01-mapa-contextos-delimitados.md) | Referencia | +| [Cross-Context Flows](../reference/gobernanza/construction/ddd-design/10-flujos-entre-contextos.md) | Referencia | +| [DDD Primitives](../reference/gobernanza/construction/ddd-design/11-primitivas-ddd.md) | Referencia | +| [API .NET Applied Reference](../reference/architecture/api-dotnet/referencia-aplicada-api-dotnet.md) | Referencia | +| [Project Backlog](../reference/gobernanza/project/index.md) | Registro | + +
+ +
+Phase 04 - Validation and QA + +| Documento | Tipo | +| :--- | :--- | +| [Performance Testing Plan](../reference/gobernanza/testing/plan-pruebas-rendimiento.md) | Guía | +| [QA Report](../reference/qa/reporte-qa.md) | Registro | +| [Unit Testing Results](../reference/gobernanza/testing/resultados-pruebas-unitarias.md) | Registro | +| [Integration Testing Results](../reference/gobernanza/testing/resultados-pruebas-integracion.md) | Registro | +| [Performance Testing Results](../reference/gobernanza/testing/resultados-pruebas-rendimiento.md) | Registro | +| [QA Evidences](../reference/qa/evidences/) | Registro | + +
+ +
+Phase 05 - Delivery and Operations + +| Documento | Tipo | +| :--- | :--- | +| [Runbooks](../reference/operaciones/runbooks/index.md) | Guía | +| [Kubernetes Deployment Plan](./infra/UMS_K8s_Deployment_Plan.md) | Guía | +| [Infrastructure Plan](./infra/infrastructure_plan.md) | Guía | +| [Implementation Plan](./infra/implementation_plan.md) | Guía | +| [Documentation Release Process](../reference/releases/proceso-publicacion-documentacion.md) | Guía | +| [Operations Portal](../reference/operaciones/index.md) | Índice | +| [Metrics](../reference/operaciones/metrics/index.md) | Referencia | + +
+ +## Cross-Cutting References + +
+Architecture, domain, and product reference + +- [Identity Domain](../docs/02-diseno/dominio/identity/index.md) +- [Authorization Domain](../docs/02-diseno/dominio/authorization/index.md) +- [Configuration Domain](../docs/02-diseno/dominio/configuration/index.md) +- [Approvals Domain](../docs/02-diseno/dominio/approvals/index.md) +- [IGA Domain](../docs/02-diseno/dominio/iga/index.md) +- [Audit Domain](../docs/02-diseno/dominio/audit/index.md) +- [Consistency Rules](../docs/02-diseno/dominio/consistency-rules/index.md) +- [SDK Contracts](../reference/sdk/contracts/resumen-esquema.md) +- [Documentation Standards](../reference/indices/estandares.md) +- [Bilingual Documentation Control](../reference/gobernanza/documentation-control-agents.md) + +
+ +
+UMS and BeyondNet inheritance + +- UMS inherits reusable architecture standards, governance rules, ADR patterns, and documentation practices from [BeyondNet](https://github.com/beyondnetcode/evolith-core). +- UMS keeps product-specific implementation, bounded contexts, schemas, seed strategy, and runtime behavior in this repository. +- Product ADRs may be promoted upstream when UMS provides executable evidence that the decision is reusable across products. +- Multi-tenancy is enforced primarily at the application layer. PostgreSQL policies, constraints, schema ownership, and row-level security are secondary infrastructure failsafes. + +
+ +## Tools and Automation + +
+Local development commands + +Run technical commands from `src/` unless the command explicitly targets the backend solution. + +```bash +# Install frontend dependencies +cd src +npm install + +# Frontend: React 18 and Vite +npx nx run app-web:dev + +# Backend: .NET 10 +cd apps/ums.api +dotnet build +dotnet run + +# Backend tests +dotnet test +``` + +
+ +
+Documentation validation + +```bash +# From the repository root +python3 .bmad-core/scripts/cleanup_markdown_encoding.py + +# From src/, when Context7 setup is needed +cd src +npx ctx7 setup +``` + +Documentation changes must keep English and Spanish artifacts synchronized, preserve UTF-8 integrity, and avoid decorative icons or non-standard Markdown characters. + +
+ +--- + +## Contribution + +Before contributing, read: + +- [AGENTS.md](./AGENTS.md) - agent rules and repository conventions. +- [Standards](../reference/indices/estandares.md) - engineering and documentation standards. +- [ADR Template](../reference/gobernanza/sdlc/plantilla-adr.md) - how to propose a decision. +- [Child Repository Inheritance Guide](https://github.com/beyondnetcode/evolith-core/blob/main/reference/governance/standards/onboarding/child-repository-inheritance-guide.md) - how UMS inherits from BeyondNet. + +--- + +## License + +This repository is proprietary unless a separate license file states otherwise. + +--- + +
+ UMS - Enterprise User Management System | BeyondNet Satellite Product | .NET 10, React 18, PostgreSQL +
diff --git a/src/Ums.ReadModels/ReadModelDbContextFactory.cs b/src/Ums.ReadModels/ReadModelDbContextFactory.cs deleted file mode 100644 index 5dd3c19a..00000000 --- a/src/Ums.ReadModels/ReadModelDbContextFactory.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Design; - -namespace Ums.ReadModels; - -public class ReadModelDbContextFactory : IDesignTimeDbContextFactory -{ - public ReadModelDbContext CreateDbContext(string[] args) - { - var optionsBuilder = new DbContextOptionsBuilder(); - // TODO: Replace with actual connection string or use configuration. - optionsBuilder.UseNpgsql("Host=localhost;Port=5433;Database=UmsReadModel;Username=postgres;Password=root"); - return new ReadModelDbContext(optionsBuilder.Options); - } -} diff --git a/src/apps/ums.api/Dockerfile b/src/apps/ums.api/Dockerfile index 60762559..bcd27160 100644 --- a/src/apps/ums.api/Dockerfile +++ b/src/apps/ums.api/Dockerfile @@ -1,3 +1,4 @@ +# syntax=docker/dockerfile:1 # ────────────────────────────────────────────────────────────────────────── # Evolith UMS API (.NET 10) — multi-stage build. # @@ -6,6 +7,9 @@ # so the context must be `src/`, not apps/ums.api. # docker build -f apps/ums.api/Dockerfile -t evolith-ums-api:local src/ # +# NuGet: the BeyondNetCode.Shell.* shells resolve from nuget.org (see NuGet.Config). +# This satellite needs no private feed nor build-time credentials. +# # Entry point: apps/ums.api/Ums.Presentation. Kestrel on :8080 (aspnet default). # ────────────────────────────────────────────────────────────────────────── FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build diff --git a/src/apps/ums.api/Ums.Application.Test/Approvals/AccessEnforcementPolicy/AccessEnforcementPolicyCommandHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Approvals/AccessEnforcementPolicy/AccessEnforcementPolicyCommandHandlerTests.cs index cc085648..a6d9b1f8 100644 --- a/src/apps/ums.api/Ums.Application.Test/Approvals/AccessEnforcementPolicy/AccessEnforcementPolicyCommandHandlerTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Approvals/AccessEnforcementPolicy/AccessEnforcementPolicyCommandHandlerTests.cs @@ -231,5 +231,28 @@ public async Task UpdateAction_WithInvalidAction_ReturnsFailure() Assert.Contains("Invalid action", result.Error, StringComparison.OrdinalIgnoreCase); } + // Irreversibilidad (G-051): el handler debe propagar el rechazo del dominio cuando se + // intenta actualizar una politica ya desactivada (estado terminal) y NO debe persistir. + // No debilitar: si este test empieza a esperar exito, la invariante de irreversibilidad + // se habra roto de nuevo. + [Fact] + public async Task UpdateAction_WhenPolicyInactive_ReturnsFailureAndDoesNotPersist() + { + _ctx.Setup(u => u.UserId).Returns("user-001"); + var policy = MakePolicy(); + policy.Deactivate(ActorId.Create("user-001")); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(policy); + + var cmd = new UpdateAccessEnforcementActionCommand(policy.Props.Id.GetValue(), "RestrictProfile"); + var handler = new UpdateAccessEnforcementActionCommandHandler(_repo.Object, _ctx.Object); + var result = await handler.Handle(cmd, CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Approvals.PolicyInactiveCannotUpdate, result.Error); + _repo.Verify(r => r.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never); + _uow.Verify(u => u.SaveEntitiesAsync(It.IsAny()), Times.Never); + } + #endregion } diff --git a/src/apps/ums.api/Ums.Application.Test/Approvals/ApprovalRequest/ApprovalRequestCommandHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Approvals/ApprovalRequest/ApprovalRequestCommandHandlerTests.cs index 4d12eeca..e3772938 100644 --- a/src/apps/ums.api/Ums.Application.Test/Approvals/ApprovalRequest/ApprovalRequestCommandHandlerTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Approvals/ApprovalRequest/ApprovalRequestCommandHandlerTests.cs @@ -17,6 +17,7 @@ namespace Ums.Application.Test.Approvals.ApprovalRequest; using Ums.Domain.Kernel; using Ums.Domain.Kernel.ValueObjects; using ApprovalWorkflowAggregate = Ums.Domain.Approvals.ApprovalWorkflow.ApprovalWorkflow; +using UserDocumentAggregate = Ums.Domain.Approvals.UserDocument.UserDocument; using Moq; using Xunit; using System; @@ -28,6 +29,7 @@ public class ApprovalRequestCommandHandlerTests private readonly Mock _repo = new(); private readonly Mock _profileRepo = new(); private readonly Mock _workflowRepo = new(); + private readonly Mock _userDocumentRepo = new(); private readonly Mock _creationPolicyResolver = new(); private readonly Mock _userAccountRepo = new(); private readonly Mock _tenantRepo = new(); @@ -58,16 +60,30 @@ public ApprovalRequestCommandHandlerTests() _unitOfWorkScope.Setup(u => u.BeginAsync(It.IsAny())).ReturnsAsync(_transactionScope.Object); _transactionScope.Setup(t => t.CommitAsync(It.IsAny())).Returns(Task.CompletedTask); _transactionScope.Setup(t => t.RollbackAsync(It.IsAny())).Returns(Task.CompletedTask); + // G-117: el fake ejecuta la operación (como la ExecutionStrategy real hace begin+commit); sin + // esto Moq devolvería una tarea vacía y los saves del bloque transaccional no correrían. + _unitOfWorkScope + .Setup(u => u.ExecuteInTransactionAsync(It.IsAny>(), It.IsAny())) + .Returns((Func op, CancellationToken ct) => op(ct)); _ctx.Setup(u => u.UserId).Returns("user-001"); + + // Por defecto el checklist de documentos requeridos se satisface: workflow sin documentos + // obligatorios y sin documentos del usuario. Los tests de G-051 F4 sobrescriben esto. + _workflowRepo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(MakeWorkflow()); + _userDocumentRepo.Setup(r => r.GetByUserIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); } - private static ApprovalRequest MakeApprovalRequest(ProfileId? profileId = null) => + // G-119 (SoD): el creador es "requester-001", distinto del aprobador ("user-001", _ctx.UserId), + // para que el happy-path de approve no viole la segregación de deberes. + private static ApprovalRequest MakeApprovalRequest(ProfileId? profileId = null, string createdBy = "requester-001") => ApprovalRequest.Create( ApprovalWorkflowId.Load(Guid.NewGuid()), UserId.Load(Guid.NewGuid()), profileId, ValidSystemId, null, ValidRoleId, null, - ActorId.Create("user-001")).Value; + ActorId.Create(createdBy)).Value; private static ApprovalWorkflowAggregate MakeWorkflow(bool requiresApproval = true, UserCategory? category = null) => ApprovalWorkflowAggregate.Create( @@ -105,7 +121,7 @@ private static UserAccount MakeInternalUser() return user; } - private Domain.Identity.Tenant.Tenant MakeTenant() => + private static Domain.Identity.Tenant.Tenant MakeTenant() => Domain.Identity.Tenant.Tenant.Create( Code.Create("CORP"), Name.Create("Corp Inc"), OrganizationType.INTERNAL, ActorId.Create("sys"), @@ -115,7 +131,7 @@ private CreateApprovalRequestCommandHandler CreateHandler() => new(_repo.Object, _workflowRepo.Object, _creationPolicyResolver.Object, _userAccountRepo.Object, _ctx.Object); private ApproveRequestCommandHandler CreateApproveHandler() => - new(_repo.Object, _profileRepo.Object, _userAccountRepo.Object, _tenantRepo.Object, _delegationRepo.Object, _tenantScopePolicy.Object, _unitOfWorkScope.Object, _notifications.Object, _roleRepo.Object, _ctx.Object); + new(_repo.Object, _profileRepo.Object, _workflowRepo.Object, _userDocumentRepo.Object, _userAccountRepo.Object, _tenantRepo.Object, _delegationRepo.Object, _tenantScopePolicy.Object, _unitOfWorkScope.Object, _notifications.Object, _roleRepo.Object, _ctx.Object); // G-160: por defecto el repo de roles resuelve un rol válido, para que las aprobaciones happy-path // no fallen por la nueva guarda de existencia del rol concedido. @@ -126,6 +142,39 @@ private static RoleAggregate MakeRole() => Code.Create("ROLE_TEST"), Name.Create("Rol de Prueba"), Description.Create("rol"), null, 0, 0, ActorId.Create("sys")).Value; + private static ApprovalWorkflowAggregate MakeWorkflowRequiring(DocumentTypeId documentTypeId, bool isMandatory = true) + { + var workflow = MakeWorkflow(); + workflow.AddRequiredDocument(documentTypeId, isMandatory, ActorId.Create("user-001")); + return workflow; + } + + private static UserDocumentAggregate MakeValidDocument(DocumentTypeId documentTypeId) + { + var document = UserDocumentAggregate.Upload( + UserId.Load(Guid.NewGuid()), + documentTypeId, + new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), + DocumentCriticity.High, + TextValueObject.Create("/storage/doc.pdf"), + "checksum-001", + ActorId.Create("user-001")).Value; + document.Validate(ActorId.Create("user-001")); + return document; + } + + private static UserDocumentAggregate MakePendingDocument(DocumentTypeId documentTypeId) => + UserDocumentAggregate.Upload( + UserId.Load(Guid.NewGuid()), + documentTypeId, + new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), + DocumentCriticity.High, + TextValueObject.Create("/storage/doc.pdf"), + "checksum-002", + ActorId.Create("user-001")).Value; + private RejectRequestCommandHandler CreateRejectHandler() => new(_repo.Object, _userAccountRepo.Object, _tenantRepo.Object, _notifications.Object, _ctx.Object); @@ -328,6 +377,30 @@ public async Task Approve_WithValidCommand_ReturnsSuccess() _profileUow.Verify(u => u.SaveEntitiesAsync(It.IsAny()), Times.Once); } + // G-119 (SoD): el creador de la solicitud NO puede aprobarla (self-approval prohibido). + [Fact] + public async Task Approve_WhenApproverIsCreator_ReturnsFailure_SelfApproval() + { + // createdBy == aprobador (_ctx.UserId="user-001") → debe rechazarse. + var req = MakeApprovalRequest(createdBy: "user-001"); + var user = MakeExternalUser(); + var tenant = MakeTenant(); + + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + _userAccountRepo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(user); + _tenantRepo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(tenant); + _profileRepo.Setup(r => r.GetByUserIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); + + var result = await CreateApproveHandler().Handle( + new ApproveRequestCommand(req.Props.Id.GetValue(), ValidRoleId.GetValue()), + CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Approvals.SelfApprovalNotAllowed, result.Error); + Assert.Equal(ApprovalStatus.Pending, req.Status); + } + [Fact] public async Task Approve_WithValidCommand_SendsApprovalNotificationToApplicant() { @@ -440,6 +513,130 @@ public async Task Approve_WithDelegatedBranchManagerScope_ReturnsSuccess() Assert.NotNull(req.TargetProfileId); } + // ---- G-051 F4: exigencia cross-agregado del checklist de documentos requeridos ---- + + [Fact] + public async Task Approve_WhenMandatoryDocumentMissing_ReturnsFailureAndKeepsPending() + { + var docType = DocumentTypeId.Load(Guid.NewGuid()); + var req = MakeApprovalRequest(); + var user = MakeExternalUser(); + + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + _userAccountRepo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(user); + _workflowRepo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(MakeWorkflowRequiring(docType)); + // El usuario no tiene ningun documento: checklist obligatorio incompleto. + _userDocumentRepo.Setup(r => r.GetByUserIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); + + var result = await CreateApproveHandler().Handle( + new ApproveRequestCommand(req.Props.Id.GetValue(), ValidRoleId.GetValue()), + CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Approvals.RequiredDocumentsIncomplete, result.Error); + Assert.Equal(ApprovalStatus.Pending, req.Status); + _repo.Verify(r => r.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Approve_WhenMandatoryDocumentValid_ReturnsSuccess() + { + var docType = DocumentTypeId.Load(Guid.NewGuid()); + var req = MakeApprovalRequest(); + var user = MakeExternalUser(); + var tenant = MakeTenant(); + + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + _userAccountRepo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(user); + _tenantRepo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(tenant); + _profileRepo.Setup(r => r.GetByUserIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); + _workflowRepo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(MakeWorkflowRequiring(docType)); + _userDocumentRepo.Setup(r => r.GetByUserIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new[] { MakeValidDocument(docType) }); + + var result = await CreateApproveHandler().Handle( + new ApproveRequestCommand(req.Props.Id.GetValue(), ValidRoleId.GetValue()), + CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(ApprovalStatus.Approved, req.Status); + } + + [Fact] + public async Task Approve_WhenMandatoryDocumentNotYetValid_ReturnsFailure() + { + var docType = DocumentTypeId.Load(Guid.NewGuid()); + var req = MakeApprovalRequest(); + var user = MakeExternalUser(); + + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + _userAccountRepo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(user); + _workflowRepo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(MakeWorkflowRequiring(docType)); + // Documento subido pero aun en PENDING_REVIEW: no cuenta como cumplimiento. + _userDocumentRepo.Setup(r => r.GetByUserIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new[] { MakePendingDocument(docType) }); + + var result = await CreateApproveHandler().Handle( + new ApproveRequestCommand(req.Props.Id.GetValue(), ValidRoleId.GetValue()), + CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Approvals.RequiredDocumentsIncomplete, result.Error); + Assert.Equal(ApprovalStatus.Pending, req.Status); + } + + [Fact] + public async Task Approve_WhenNonMandatoryDocumentMissing_ReturnsSuccess() + { + var docType = DocumentTypeId.Load(Guid.NewGuid()); + var req = MakeApprovalRequest(); + var user = MakeExternalUser(); + var tenant = MakeTenant(); + + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + _userAccountRepo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(user); + _tenantRepo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(tenant); + _profileRepo.Setup(r => r.GetByUserIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); + // Documento requerido pero NO obligatorio: su ausencia no bloquea la aprobacion. + _workflowRepo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(MakeWorkflowRequiring(docType, isMandatory: false)); + _userDocumentRepo.Setup(r => r.GetByUserIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); + + var result = await CreateApproveHandler().Handle( + new ApproveRequestCommand(req.Props.Id.GetValue(), ValidRoleId.GetValue()), + CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(ApprovalStatus.Approved, req.Status); + } + + [Fact] + public async Task Approve_WhenWorkflowNotResolvable_FailsClosed() + { + var req = MakeApprovalRequest(); + var user = MakeExternalUser(); + + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + _userAccountRepo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(user); + _workflowRepo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((ApprovalWorkflowAggregate?)null); + + var result = await CreateApproveHandler().Handle( + new ApproveRequestCommand(req.Props.Id.GetValue(), ValidRoleId.GetValue()), + CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Approvals.RequiredDocumentsIncomplete, result.Error); + Assert.Equal(ApprovalStatus.Pending, req.Status); + } + #endregion // ========================================================================= diff --git a/src/apps/ums.api/Ums.Application.Test/Approvals/ApprovalRequest/ApprovalRequestQueryHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Approvals/ApprovalRequest/ApprovalRequestQueryHandlerTests.cs index 42a3e3f7..2ee81d5b 100644 --- a/src/apps/ums.api/Ums.Application.Test/Approvals/ApprovalRequest/ApprovalRequestQueryHandlerTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Approvals/ApprovalRequest/ApprovalRequestQueryHandlerTests.cs @@ -38,6 +38,14 @@ private static ApprovalRequest MakeApprovalRequest(ApprovalStatus status) return req; } + private static ApprovalRequest MakeApprovalRequestForUser(Guid targetUserId) + => ApprovalRequest.Create( + ApprovalWorkflowId.Load(Guid.NewGuid()), + UserId.Load(targetUserId), + ProfileId.Load(Guid.NewGuid()), + ValidSystemId, null, ValidRoleId, null, + ActorId.Create("user-001")).Value; + // ========================================================================= #region GetApprovalRequestByIdQueryHandler // ========================================================================= @@ -155,6 +163,37 @@ public async Task GetAll_WithTenantFilter_ReturnsTenantItems() _repo.Verify(r => r.GetByTenantIdAsync(tenantId, It.IsAny()), Times.Once); } + [Fact] + public async Task GetAll_WithUserIdFilter_ReturnsOnlyTargetUserRequests() + { + // G-159: el filtro userId (usuario objetivo) debe aplicarse; antes se ignoraba. + var targetUser = Guid.NewGuid(); + var mine = MakeApprovalRequestForUser(targetUser); + var otherA = MakeApprovalRequestForUser(Guid.NewGuid()); + var otherB = MakeApprovalRequestForUser(Guid.NewGuid()); + var list = new List { otherA, mine, otherB }; + + _repo.Setup(r => r.GetAllAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(list); + + var query = new GetAllApprovalRequestsQuery( + TenantId: null, + UserId: targetUser, + Status: "all", + Search: null, + SortBy: null, + SortOrder: null, + Page: 1, + PageSize: 10); + + var handler = new GetAllApprovalRequestsQueryHandler(_repo.Object); + var result = await handler.Handle(query, CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(1, result.Value.TotalItems); + Assert.Equal(targetUser, result.Value.Items[0].TargetUserId); + } + [Fact] public async Task GetAll_WithStatusFilter_FiltersStatus() { diff --git a/src/apps/ums.api/Ums.Application.Test/Approvals/ApprovalWorkflow/ApprovalWorkflowCommandHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Approvals/ApprovalWorkflow/ApprovalWorkflowCommandHandlerTests.cs index 554971b5..a92e5d2f 100644 --- a/src/apps/ums.api/Ums.Application.Test/Approvals/ApprovalWorkflow/ApprovalWorkflowCommandHandlerTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Approvals/ApprovalWorkflow/ApprovalWorkflowCommandHandlerTests.cs @@ -153,7 +153,7 @@ public async Task RemoveRequiredDocument_WithValidCommand_ReturnsSuccess() var secondDocTypeId = Guid.NewGuid(); workflow.AddRequiredDocument(DocumentTypeId.Load(firstDocTypeId), true, ActorId.Create("user-001")); workflow.AddRequiredDocument(DocumentTypeId.Load(secondDocTypeId), true, ActorId.Create("user-001")); - var docId = workflow.RequiredDocuments.First().Id.GetValue(); + var docId = workflow.RequiredDocuments.First().GetId().GetValue(); _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(workflow); diff --git a/src/apps/ums.api/Ums.Application.Test/Approvals/UserDocument/UserDocumentCommandHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Approvals/UserDocument/UserDocumentCommandHandlerTests.cs index 19adbeab..573dbe95 100644 --- a/src/apps/ums.api/Ums.Application.Test/Approvals/UserDocument/UserDocumentCommandHandlerTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Approvals/UserDocument/UserDocumentCommandHandlerTests.cs @@ -45,7 +45,7 @@ private static UserDocument MakeUserDocument() => "MD5-12345", ActorId.Create("user-001")).Value; - private UserAccount MakeOwner() + private static UserAccount MakeOwner() { var user = UserAccount.Create( Domain.Kernel.ValueObjects.TenantId.Load(TenantId), @@ -292,7 +292,8 @@ public async Task Reject_WhenNotPendingReview_ReturnsFailure() public async Task ReUpload_WithValidCommand_ReturnsSuccess() { var doc = MakeUserDocument(); - doc.Expire(ActorId.Create("user-001")); + doc.Validate(ActorId.Create("user-001")); // PendingReview → Valid + doc.Expire(ActorId.Create("user-001")); // Valid → Expired (INV-UD3) _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(doc); var result = await CreateReUploadHandler().Handle( @@ -358,7 +359,8 @@ public async Task ReUpload_WhenUnauthenticated_ReturnsFailure() public async Task ReUpload_WhenExpirationBeforeIssueDate_ReturnsFailure() { var doc = MakeUserDocument(); - doc.Expire(ActorId.Create("user-001")); + doc.Validate(ActorId.Create("user-001")); // PendingReview → Valid + doc.Expire(ActorId.Create("user-001")); // Valid → Expired (INV-UD3) _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(doc); var result = await CreateReUploadHandler().Handle( @@ -380,6 +382,7 @@ public async Task ReUpload_WhenExpirationBeforeIssueDate_ReturnsFailure() public async Task Expire_WithValidCommand_ReturnsSuccess() { var doc = MakeUserDocument(); + doc.Validate(ActorId.Create("user-001")); // PendingReview → Valid (Expire requires Valid, INV-UD3) _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(doc); var result = await CreateExpireHandler().Handle( @@ -395,7 +398,8 @@ public async Task Expire_WithValidCommand_ReturnsSuccess() public async Task Expire_WhenAlreadyExpired_ReturnsFailure() { var doc = MakeUserDocument(); - doc.Expire(ActorId.Create("sys")); + doc.Validate(ActorId.Create("sys")); // PendingReview → Valid + doc.Expire(ActorId.Create("sys")); // Valid → Expired _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(doc); var result = await CreateExpireHandler().Handle( diff --git a/src/apps/ums.api/Ums.Application.Test/Audit/AuditRecord/AuditRecordCommandHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Audit/AuditRecord/AuditRecordCommandHandlerTests.cs index 49e8b34d..7f6f5ffe 100644 --- a/src/apps/ums.api/Ums.Application.Test/Audit/AuditRecord/AuditRecordCommandHandlerTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Audit/AuditRecord/AuditRecordCommandHandlerTests.cs @@ -17,11 +17,18 @@ public class AuditRecordCommandHandlerTests { private readonly Mock _repo = new(); private readonly Mock _uow = new(); + private readonly Mock _userContext = new(); public AuditRecordCommandHandlerTests() { _repo.Setup(r => r.UnitOfWork).Returns(_uow.Object); _uow.Setup(u => u.SaveEntitiesAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); + + // G-040 (SEGURIDAD): el actor y el inquilino se derivan del contexto + // autenticado, no del cuerpo de la petición. + _userContext.SetupGet(c => c.IsAuthenticated).Returns(true); + _userContext.SetupGet(c => c.UserId).Returns(Guid.NewGuid().ToString()); + _userContext.SetupGet(c => c.TenantId).Returns(Guid.NewGuid().ToString()); } [Fact] @@ -38,7 +45,7 @@ public async Task Record_WithValidCommand_ReturnsSuccess() RootTenantId: Guid.NewGuid(), Metadata: "{}"); - var handler = new RecordAuditCommandHandler(_repo.Object); + var handler = new RecordAuditCommandHandler(_repo.Object, _userContext.Object); var result = await handler.Handle(cmd, CancellationToken.None); Assert.True(result.IsSuccess); @@ -47,11 +54,49 @@ public async Task Record_WithValidCommand_ReturnsSuccess() _uow.Verify(u => u.SaveEntitiesAsync(It.IsAny(), It.IsAny()), Times.Once); } + // G-040 (FR-072): una traza registrada con metadata que contiene un secreto NO debe persistir el + // secreto en claro. La traza es append-only e inmutable (G-081): lo que se apende no se puede borrar. + [Fact] + public async Task Record_WithSecretInMetadata_DoesNotPersistSecretInClear() + { + AuditRecord? appended = null; + _repo.Setup(r => r.AppendAsync(It.IsAny(), It.IsAny())) + .Callback((record, _) => appended = record) + .Returns(Task.CompletedTask); + + var cmd = new RecordAuditCommand( + WhoActed: Guid.NewGuid(), + SubjectType: "User", + WhatChanged: "Registro con secretos", + EventType: "UserUpdated", + AuditResult: "Success", + AffectedEntityId: Guid.NewGuid(), + AffectedEntityType: "UserAccount", + RootTenantId: Guid.NewGuid(), + Metadata: "{\"password\":\"hunter2\",\"apiKey\":\"sk-live-secret\",\"handler\":\"legit\"}"); + + var handler = new RecordAuditCommandHandler(_repo.Object, _userContext.Object); + var result = await handler.Handle(cmd, CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.NotNull(appended); + Assert.NotNull(appended!.Metadata); + // El secreto en claro no se persiste; la clave se conserva redactada. + Assert.DoesNotContain("hunter2", appended.Metadata); + Assert.DoesNotContain("sk-live-secret", appended.Metadata); + Assert.Contains("[REDACTED]", appended.Metadata); + // La metadata legítima permanece intacta. + Assert.Contains("legit", appended.Metadata); + } + [Fact] - public async Task Record_WhenWhoActedIsEmpty_ReturnsFailure() + public async Task Record_WhenUserIsNotAuthenticated_ReturnsFailure() { + _userContext.SetupGet(c => c.IsAuthenticated).Returns(false); + _userContext.SetupGet(c => c.UserId).Returns((string?)null); + var cmd = new RecordAuditCommand( - WhoActed: Guid.Empty, + WhoActed: Guid.NewGuid(), SubjectType: "User", WhatChanged: "Updated user name", EventType: "UserUpdated", @@ -61,7 +106,7 @@ public async Task Record_WhenWhoActedIsEmpty_ReturnsFailure() RootTenantId: Guid.NewGuid(), Metadata: "{}"); - var handler = new RecordAuditCommandHandler(_repo.Object); + var handler = new RecordAuditCommandHandler(_repo.Object, _userContext.Object); var result = await handler.Handle(cmd, CancellationToken.None); Assert.True(result.IsFailure); @@ -81,7 +126,7 @@ public async Task Record_WhenWhatChangedIsEmpty_ReturnsFailure() RootTenantId: Guid.NewGuid(), Metadata: "{}"); - var handler = new RecordAuditCommandHandler(_repo.Object); + var handler = new RecordAuditCommandHandler(_repo.Object, _userContext.Object); var result = await handler.Handle(cmd, CancellationToken.None); Assert.True(result.IsFailure); diff --git a/src/apps/ums.api/Ums.Application.Test/Audit/AuditRecord/AuditRecordQueryHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Audit/AuditRecord/AuditRecordQueryHandlerTests.cs index 45075033..4242a6a0 100644 --- a/src/apps/ums.api/Ums.Application.Test/Audit/AuditRecord/AuditRecordQueryHandlerTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Audit/AuditRecord/AuditRecordQueryHandlerTests.cs @@ -42,8 +42,12 @@ public async Task GetById_WhenFound_ReturnsSuccess() _repo.Setup(r => r.GetByIdAsync(recordId, It.IsAny())) .ReturnsAsync(record); + // G-040 (SEGURIDAD): un admin interno puede leer registros de cualquier inquilino. + var adminCtx = new Mock(); + adminCtx.Setup(t => t.IsInternalAdmin).Returns(true); + var query = new GetAuditRecordByIdQuery(recordId); - var handler = new GetAuditRecordByIdQueryHandler(_repo.Object); + var handler = new GetAuditRecordByIdQueryHandler(_repo.Object, adminCtx.Object); var result = await handler.Handle(query, CancellationToken.None); Assert.True(result.IsSuccess); @@ -166,5 +170,31 @@ public async Task GetAll_WithEventTypeFilter_ReturnsEventTypeItems() _repo.Verify(r => r.QueryByEventTypeAsync(eventType, tenantId, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); } + // G-113 (regresión): internal-admin SIN request.TenantId debe consultar con su PROPIA + // OrganizationId, NO con Guid.Empty (que filtraba RootTenantId==empty y devolvía 0 pese a + // existir 129 registros en el despliegue). No debilitar: probar el efecto de mostrar el bug. + [Fact] + public async Task GetAll_InternalAdminWithoutTenantId_UsesOwnOrganizationId_NotEmpty() + { + var orgId = Guid.NewGuid(); + _repo.Setup(r => r.QueryByEventTypeAsync("*", orgId, It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { MakeAuditRecord() }); + + var query = new GetAllAuditRecordsQuery( + TenantId: null, ActorId: null, EntityId: null, EntityType: null, + EventType: null, From: null, To: null, Page: 1, PageSize: 10); + + var adminCtx = new Mock(); + adminCtx.Setup(t => t.IsInternalAdmin).Returns(true); + adminCtx.Setup(t => t.OrganizationId).Returns((Guid?)orgId); + var handler = new GetAllAuditRecordsQueryHandler(_repo.Object, adminCtx.Object); + var result = await handler.Handle(query, CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(1, result.Value.TotalItems); + _repo.Verify(r => r.QueryByEventTypeAsync("*", orgId, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + _repo.Verify(r => r.QueryByEventTypeAsync("*", Guid.Empty, It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + #endregion } diff --git a/src/apps/ums.api/Ums.Application.Test/Audit/AuditRecord/RecordAuditCommandValidatorTests.cs b/src/apps/ums.api/Ums.Application.Test/Audit/AuditRecord/RecordAuditCommandValidatorTests.cs new file mode 100644 index 00000000..911854c2 --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/Audit/AuditRecord/RecordAuditCommandValidatorTests.cs @@ -0,0 +1,38 @@ +namespace Ums.Application.Test.Audit.AuditRecord; + +using Ums.Application.Audit.AuditRecord.Commands; +using Xunit; + +// G-040: el Metadata de auditoría, si viene, debe estar acotado (≤4000) y ser JSON bien formado. +public sealed class RecordAuditCommandValidatorTests +{ + private readonly RecordAuditCommandValidator _validator = new(); + + private static RecordAuditCommand WithMetadata(string? metadata) => new( + WhoActed: Guid.NewGuid(), + SubjectType: "User", + WhatChanged: "cambio", + EventType: "User.Updated", + AuditResult: "Success", + AffectedEntityId: Guid.NewGuid(), + AffectedEntityType: "User", + RootTenantId: Guid.NewGuid(), + Metadata: metadata); + + private bool MetadataHasError(string? metadata) + => _validator.Validate(WithMetadata(metadata)) + .Errors.Exists(e => e.PropertyName == nameof(RecordAuditCommand.Metadata)); + + [Fact] + public void NullMetadata_IsAccepted() => Assert.False(MetadataHasError(null)); + + [Fact] + public void WellFormedJson_IsAccepted() => Assert.False(MetadataHasError("{\"key\":\"value\",\"n\":1}")); + + [Fact] + public void MalformedJson_IsRejected() => Assert.True(MetadataHasError("{not valid json")); + + [Fact] + public void OversizedMetadata_IsRejected() + => Assert.True(MetadataHasError("\"" + new string('a', 4001) + "\"")); // JSON válido pero >4000 +} diff --git a/src/apps/ums.api/Ums.Application.Test/Authorization/Graph/AppSettingVisibilityTests.cs b/src/apps/ums.api/Ums.Application.Test/Authorization/Graph/AppSettingVisibilityTests.cs new file mode 100644 index 00000000..7982931a --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/Authorization/Graph/AppSettingVisibilityTests.cs @@ -0,0 +1,69 @@ +namespace Ums.Application.Test.Authorization.Graph; + +using Xunit; +using FluentAssertions; +using Ums.Domain.Authorization.SystemSuite.AppSetting; +using Ums.Domain.Configuration; +using Ums.Domain.Kernel; +using SystemSuiteAggregate = Ums.Domain.Authorization.SystemSuite.SystemSuite; + +/// +/// La marca de exposición de los ajustes del sistema (G-178). +/// +/// `AppSetting` es una bolsa clave/valor sin tipo: junto al color de la marca puede haber una +/// cadena de conexión o el secreto de una integración. Estas pruebas fijan que la exposición se +/// decide ajuste a ajuste y que el default no publica — si alguien invierte ese default, aquí +/// se entera antes de que un secreto salga en el cable. +/// +public class AppSettingVisibilityTests +{ + private static SystemSuiteAggregate Suite() + => SystemSuiteAggregate.Create( + TenantId.Load(Guid.NewGuid()), + Code.Create("SDLC"), + Name.Create("Tablero"), + Description.Create("Tablero de gobierno"), + ActorId.Create("system")).Value; + + [Fact] + public void Un_ajuste_no_es_visible_salvo_que_se_diga() + { + var ajuste = AppSetting.Create( + ConfigurationKey.Create("OPS_CONNECTION_STRING"), + ConfigurationValue.Create("Host=db;Password=secreto"), + ConfigurationScope.Global).Value; + + ajuste.IsClientVisible.Should().BeFalse( + "el default debe ser no publicar: la bolsa contiene también ajustes operativos"); + } + + [Fact] + public void La_marca_viaja_al_agregado() + { + var suite = Suite(); + var actor = ActorId.Create("system"); + + suite.AddAppSetting(ConfigurationKey.Create("BRAND_LOGO_URL"), ConfigurationValue.Create("/logo.svg"), + ConfigurationScope.Global, actor, isClientVisible: true); + suite.AddAppSetting(ConfigurationKey.Create("OPS_INTERVAL"), ConfigurationValue.Create("30"), + ConfigurationScope.Global, actor); + + suite.AppSettings.Single(a => a.Key.GetValue() == "BRAND_LOGO_URL").IsClientVisible.Should().BeTrue(); + suite.AppSettings.Single(a => a.Key.GetValue() == "OPS_INTERVAL").IsClientVisible.Should().BeFalse(); + } + + [Fact] + public void Cambiar_el_valor_no_cambia_la_visibilidad() + { + var suite = Suite(); + var actor = ActorId.Create("system"); + + suite.AddAppSetting(ConfigurationKey.Create("OPS_INTERVAL"), ConfigurationValue.Create("30"), + ConfigurationScope.Global, actor); + suite.UpdateAppSetting(ConfigurationKey.Create("OPS_INTERVAL"), ConfigurationValue.Create("60"), actor); + + // Editar un valor no es decidir publicarlo: si esto se rompiera, cualquier edición + // rutinaria podría sacar al cable un ajuste operativo. + suite.AppSettings.Single(a => a.Key.GetValue() == "OPS_INTERVAL").IsClientVisible.Should().BeFalse(); + } +} diff --git a/src/apps/ums.api/Ums.Application.Test/Authorization/Graph/AuthGraphPayloadTests.cs b/src/apps/ums.api/Ums.Application.Test/Authorization/Graph/AuthGraphPayloadTests.cs new file mode 100644 index 00000000..7db11f7f --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/Authorization/Graph/AuthGraphPayloadTests.cs @@ -0,0 +1,202 @@ +namespace Ums.Application.Test.Authorization.Graph; + +using System.Text.Json; +using Xunit; +using FluentAssertions; +using Ums.Application.Authorization.Graph.Serializers; +using Ums.Domain.Authorization.Graph; + +/// +/// Ata la forma del grafo al contrato publicado en +/// src/libs/sdk/contracts/auth-graph.schema.json. +/// +/// Existe por G-167: el contrato declaraba `id` obligatorios, nombres bajo +/// `name`/`label` y envoltorios `module`/`resource` que NINGÚN endpoint emitía, +/// y nadie lo notó porque ninguna prueba comparaba ambas cosas. Estas pruebas +/// fallan si alguien vuelve a cambiar la proyección sin actualizar el contrato +/// —o al revés—, que es exactamente el fallo que dejó a los satélites leyendo +/// `undefined` en cada nombre. +/// +public class AuthGraphPayloadTests +{ + private static AuthorizationGraph Grafo() + { + var suiteId = Guid.NewGuid(); + var opcion = new GraphNavigationNode(Guid.NewGuid(), "STOCK_VIEW", "Ver Stock", "Option", 1, + null, "/inv/stock-view", + [new GraphNodeAction("VIEW", AccessEffect.Allow, PermissionSource.Template)], []); + var sub = new GraphNavigationNode(Guid.NewGuid(), "STOCK_OPS", "Operaciones", "SubMenu", 1, null, null, [], [opcion]); + var menu = new GraphNavigationNode(Guid.NewGuid(), "STOCK", "Stock", "Menu", 1, "package", null, [], [sub]); + var modulo = new GraphMenuModule(Guid.NewGuid(), "INV", "Inventario", 1, "Active", "package", [menu]); + + var recurso = new GraphDomainPermission( + Guid.NewGuid(), "Aggregate", "PURCHASE_ORDER", "Orden de Compra", + Guid.NewGuid(), null, + new[] { new GraphDomainAction(Guid.NewGuid(), "VIEW", "Ver", AccessEffect.Allow, PermissionSource.Template) }); + + var contexto = new GraphContext( + new GraphUser(Guid.NewGuid(), "ana@beyondnet.com.pe", "ana", "Ana Torres", "Active"), + new GraphTenant(Guid.NewGuid(), "BEYONDNET", "BeyondNet S.A.C.", "Active", false), + new GraphSystemSuite(suiteId, "WMS", "Almacén", "Active"), + new GraphRole(Guid.NewGuid(), "OPERARIO", "Operario de Almacén", 2, null), + new GraphProfile(Guid.NewGuid(), "OrgWide", true), + Branch: null); + + return AuthorizationGraph.Build( + contexto, + new GraphAuthentication("Local", null, false, DateTime.UtcNow, DateTime.UtcNow.AddHours(1)), + new[] { new GraphAction(Guid.NewGuid(), "VIEW", "Ver") }, + new[] { modulo }, + new[] { recurso }, + new[] { new GraphFeatureFlag("WMS_BULK_EXPORT", suiteId, false, null) }, + new GraphEffectiveConfig(60, 5, 12, true, new[] { "Totp" }, 3600000, false), + new[] { "stock_view.view" }, + DateTime.UtcNow, + settings: new Dictionary> + { + ["brand"] = new Dictionary { ["logo_url"] = "/logo.svg" }, + ["ui"] = new Dictionary { ["home_route"] = "/" }, + }); + } + + private static JsonElement Json(GraphSerializationOptions? opts = null) => + JsonSerializer.SerializeToElement(AuthGraphPayload.Build(Grafo(), opts)); + + [Fact] + public void Nombres_viajan_como_value_no_como_name_ni_label() + { + var g = Json(); + + g.GetProperty("context").GetProperty("user").GetProperty("value").GetString() + .Should().Be("Ana Torres"); + g.GetProperty("context").GetProperty("tenant").GetProperty("value").GetString() + .Should().Be("BeyondNet S.A.C."); + + var modulo = g.GetProperty("menuAccess")[0]; + modulo.GetProperty("value").GetString().Should().Be("Inventario"); + modulo.TryGetProperty("name", out _).Should().BeFalse(); + + var opcion = modulo.GetProperty("nodes")[0].GetProperty("children")[0].GetProperty("children")[0]; + opcion.GetProperty("value").GetString().Should().Be("Ver Stock"); + opcion.TryGetProperty("label", out _).Should().BeFalse(); + } + + [Fact] + public void Sin_metadatos_tecnicos_no_viaja_ningun_id() + { + var g = Json(); + + g.GetProperty("context").GetProperty("user").TryGetProperty("id", out _).Should().BeFalse(); + g.GetProperty("menuAccess")[0].TryGetProperty("id", out _).Should().BeFalse(); + g.GetProperty("domainPermissions")[0].TryGetProperty("resourceId", out _).Should().BeFalse(); + g.GetProperty("featureFlags")[0].TryGetProperty("systemSuiteId", out _).Should().BeFalse(); + } + + [Fact] + public void Con_metadatos_tecnicos_los_ids_aparecen() + { + var g = Json(new GraphSerializationOptions(IncludeTechnicalMetadata: true)); + + g.GetProperty("context").GetProperty("user").TryGetProperty("id", out _).Should().BeTrue(); + g.GetProperty("menuAccess")[0].TryGetProperty("id", out _).Should().BeTrue(); + g.GetProperty("domainPermissions")[0].TryGetProperty("resourceId", out _).Should().BeTrue(); + } + + [Fact] + public void MenuAccess_y_domainPermissions_son_listas_planas() + { + var g = Json(); + + var modulo = g.GetProperty("menuAccess")[0]; + modulo.TryGetProperty("module", out _).Should().BeFalse("el módulo no va envuelto"); + modulo.GetProperty("code").GetString().Should().Be("INV"); + + var recurso = g.GetProperty("domainPermissions")[0]; + recurso.TryGetProperty("resource", out _).Should().BeFalse("el recurso no va envuelto"); + recurso.GetProperty("resourceCode").GetString().Should().Be("PURCHASE_ORDER"); + recurso.GetProperty("resourceType").GetString().Should().Be("Aggregate"); + recurso.GetProperty("actions")[0].GetProperty("actionCode").GetString().Should().Be("VIEW"); + } + + [Fact] + public void SortOrder_viaja_en_los_tres_niveles_de_navegacion() + { + var modulo = Json().GetProperty("menuAccess")[0]; + + modulo.GetProperty("sortOrder").GetInt32().Should().Be(1); + var menu = modulo.GetProperty("nodes")[0]; + menu.GetProperty("sortOrder").GetInt32().Should().Be(1); + menu.GetProperty("children")[0].GetProperty("sortOrder").GetInt32().Should().Be(1); + } + + [Fact] + public void EffectiveConfig_publica_los_metodos_mfa_admitidos() + { + Json().GetProperty("effectiveConfig").GetProperty("mfaAllowedMethods") + .EnumerateArray().Select(x => x.GetString()).Should().Equal("Totp"); + } + + [Fact] + public void Efecto_y_origen_viajan_como_cadena_no_como_ordinal() + { + var accion = Json().GetProperty("menuAccess")[0] + .GetProperty("nodes")[0].GetProperty("children")[0] + .GetProperty("children")[0].GetProperty("actions")[0]; + + accion.GetProperty("effect").GetString().Should().Be("Allow"); + accion.GetProperty("source").GetString().Should().Be("Template"); + } + + [Fact] + public void Los_nulos_del_grafo_viajan_explicitos() + { + var g = Json(); + + g.GetProperty("context").GetProperty("branch").ValueKind.Should().Be(JsonValueKind.Null); + g.GetProperty("authentication").GetProperty("provider").ValueKind.Should().Be(JsonValueKind.Null); + g.GetProperty("featureFlags")[0].GetProperty("matchedCriteriaType").ValueKind + .Should().Be(JsonValueKind.Null); + } + + [Fact] + public void El_icono_y_la_ruta_viajan_en_el_nodo() + { + var menu = Json().GetProperty("menuAccess")[0].GetProperty("nodes")[0]; + + menu.GetProperty("icon").GetString().Should().Be("package"); + // Un menú agrupa: no navega a ninguna parte. + menu.GetProperty("route").ValueKind.Should().Be(JsonValueKind.Null); + + var opcion = menu.GetProperty("children")[0].GetProperty("children")[0]; + opcion.GetProperty("route").GetString().Should().Be("/inv/stock-view"); + // Las claves se emiten aunque estén vacías: el cliente no debe distinguir «sin icono» + // de «clave ausente». + opcion.GetProperty("icon").ValueKind.Should().Be(JsonValueKind.Null); + } + + [Fact] + public void El_icono_del_modulo_viaja_en_el_modulo() + { + // Sin esto el cliente sabe cómo se llama el módulo pero no con qué pintarlo, y acaba + // resolviendo el icono por código: la tabla estática que el grafo vino a eliminar (G-182). + Json().GetProperty("menuAccess")[0].GetProperty("icon").GetString().Should().Be("package"); + } + + [Fact] + public void Los_ajustes_visibles_viajan_agrupados_por_espacio_de_nombres() + { + var settings = Json().GetProperty("settings"); + + settings.GetProperty("brand").GetProperty("logo_url").GetString().Should().Be("/logo.svg"); + settings.GetProperty("ui").GetProperty("home_route").GetString().Should().Be("/"); + } + + [Fact] + public void Las_claves_de_primer_nivel_son_las_del_contrato() + { + Json().EnumerateObject().Select(p => p.Name).Should().Equal( + "schemaVersion", "onboardingPending", "accessState", "context", "authentication", + "actions", "profiles", "menuAccess", "domainPermissions", "featureFlags", + "effectiveConfig", "settings", "scopes", "generatedAt", "validUntil"); + } +} diff --git a/src/apps/ums.api/Ums.Application.Test/Authorization/Graph/AuthorizationGraphBuilderServiceTests.cs b/src/apps/ums.api/Ums.Application.Test/Authorization/Graph/AuthorizationGraphBuilderServiceTests.cs index 93baa8c4..3fb3cd9a 100644 --- a/src/apps/ums.api/Ums.Application.Test/Authorization/Graph/AuthorizationGraphBuilderServiceTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Authorization/Graph/AuthorizationGraphBuilderServiceTests.cs @@ -1,12 +1,18 @@ namespace Ums.Application.Test.Authorization.Graph; +#pragma warning disable S125 + +using Microsoft.Extensions.Logging.Abstractions; using Moq; using Xunit; using Ums.Application.Authorization.Graph; +using Ums.Application.Authorization.Template.Commands; +using Ums.Application.Common.Interfaces; using Ums.Application.Configuration.Services; using Ums.Domain.Authorization; using Ums.Domain.Authorization.Graph; using Ums.Domain.Authorization.Profile; +using Ums.Domain.Authorization.SystemSuite.DomainResource; using Ums.Domain.Authorization.Template; using Ums.Domain.Configuration; using Ums.Domain.Configuration.FeatureFlag; @@ -27,7 +33,6 @@ public class AuthorizationGraphBuilderServiceTests private readonly Mock _profileRepo = new(); private readonly Mock _roleRepo = new(); private readonly Mock _suiteRepo = new(); - private readonly Mock _templateRepo = new(); private readonly Mock _tenantRepo = new(); private readonly Mock _flagRepo = new(); private readonly Mock _flagEvaluator = new(); @@ -35,8 +40,9 @@ public class AuthorizationGraphBuilderServiceTests private AuthorizationGraphBuilderService CreateSut() => new( _profileRepo.Object, _roleRepo.Object, _suiteRepo.Object, - _templateRepo.Object, _tenantRepo.Object, _flagRepo.Object, - _flagEvaluator.Object, _configProvider.Object); + _tenantRepo.Object, _flagRepo.Object, + _flagEvaluator.Object, _configProvider.Object, + NullLogger.Instance); private static readonly Guid TenantGuid = Guid.NewGuid(); private static readonly Guid UserGuid = Guid.NewGuid(); @@ -50,11 +56,23 @@ public AuthorizationGraphBuilderServiceTests() _configProvider.Setup(c => c.GetValue(It.IsAny(), It.IsAny(), It.IsAny())) .Returns((string _, Guid? __, string? defaultValue) => defaultValue ?? string.Empty); - _flagRepo.Setup(r => r.GetBySystemSuiteIdAsync(It.IsAny(), It.IsAny())) + // El constructor carga SIEMPRE los perfiles del usuario para el bloque `profiles`, también + // en la vía que recibe un perfil explícito. Sin este stub, el mock devuelve null. + _profileRepo.Setup(r => r.GetActiveByUserAndTenantAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync([]); + + // Bloque `profiles` (G-177): sin estos dos stubs el constructor recibe null de los mocks. + // Devuelven vacío a propósito — estas pruebas verifican la resolución de permisos, no el + // selector de perfiles, que tiene las suyas. + _roleRepo.Setup(r => r.GetByIdsAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync([]); + _suiteRepo.Setup(r => r.GetSummariesByIdsAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync([]); + + _flagRepo.Setup(r => r.GetBySystemSuiteIdForEvaluationAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new List()); - _templateRepo.Setup(r => r.GetByTenantIdAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(new List()); } // ── Error paths ──────────────────────────────────────────────────────────── @@ -72,23 +90,33 @@ public async Task BuildAsync_TenantNotFound_ReturnsFailure() } [Fact] - public async Task BuildAsync_NoActiveProfileForUser_ReturnsFailure() + public async Task BuildAsync_NoActiveProfileForUser_ReturnsLobbyGraph() { + // G-043: un usuario autenticado y aprobado pero SIN perfil activo ya no falla el login; + // recibe un GRAFO LOBBY (OnboardingPending=true, sin suite/rol/perfil, sin menús) para que + // el cliente muestre el onboarding en vez de un error opaco. SetupValidTenant(); - _profileRepo.Setup(r => r.GetByUserIdAsync(It.IsAny(), It.IsAny())) + _profileRepo.Setup(r => r.GetActiveByUserAndTenantAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new List()); var result = await CreateSut().BuildAsync(MakeUser(), TenantGuid, AuthMethod.Local()); - Assert.True(result.IsFailure); - Assert.Contains("profile", result.Error, StringComparison.OrdinalIgnoreCase); + Assert.True(result.IsSuccess); + Assert.True(result.Value.OnboardingPending); + Assert.Null(result.Value.Context.SystemSuite); + Assert.Null(result.Value.Context.Role); + Assert.Null(result.Value.Context.Profile); + Assert.Empty(result.Value.MenuAccess); + // El contexto de usuario e inquilino sí es real. + Assert.NotNull(result.Value.Context.User); + Assert.Equal(TenantGuid, result.Value.Context.Tenant.Id); } [Fact] public async Task BuildAsync_RoleNotFound_ReturnsFailure() { SetupValidTenant(); - _profileRepo.Setup(r => r.GetByUserIdAsync(It.IsAny(), It.IsAny())) + _profileRepo.Setup(r => r.GetActiveByUserAndTenantAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new List { MakeProfile() }); _roleRepo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) .ReturnsAsync((RoleAggregate?)null); @@ -149,7 +177,7 @@ public async Task BuildAsync_FeatureFlags_EvaluatedWithUserContext() flag.Activate(ActorId.Create("test")); flag.DomainEvents.MarkChangesAsCommitted(); - _flagRepo.Setup(r => r.GetBySystemSuiteIdAsync(It.IsAny(), It.IsAny())) + _flagRepo.Setup(r => r.GetBySystemSuiteIdForEvaluationAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new List { flag }); _flagEvaluator.Setup(e => e.Evaluate(It.IsAny(), It.IsAny())) .Returns(new FlagEvaluationResult(true, null, "no criteria")); @@ -233,7 +261,7 @@ public async Task BuildForProfileAsync_UsesRequestedProfile() _roleRepo.Setup(r => r.GetByIdAsync(requestedRoleId, It.IsAny())) .ReturnsAsync(role); - var suite = BuildMinimalSuite(suiteId); + var suite = BuildMinimalSuite(); _suiteRepo.Setup(r => r.GetByIdAsync(suiteId, It.IsAny())) .ReturnsAsync(suite); @@ -259,6 +287,337 @@ public async Task BuildAsync_OrgWide_NoBranchInContext() Assert.Equal("OrgWide", result.Value.Context.Profile.Scope); } + // ── Adversariales: deny-wins multi-fuente / fail-closed / scaffolding G-016 / ciclo (G-085) ── + // + // El mapa efectivo (AuthorizationGraphBuilderService.BuildPermissionMap) compone varias fuentes + // — perfil + plantilla + override — para el mismo (TargetId, ActionId). Estas pruebas ejercen la + // resolución REAL: deny-wins gana siempre e independiente del orden; Override gana sobre Template + // sólo para Allow; la ausencia de entrada resuelve NotGranted (fail-closed, ADR-UMS-088/G-039); una + // plantilla Published que nunca se asignó NO contribuye al mapa (scaffolding reservado, G-016); y + // el constructor proyecta el ParentRoleId inmediato sin recorrer la ascendencia (ciclo-seguro). + // + // El efecto resuelto se observa en DomainPermissions (BuildDomainPermissions): se registra un + // DomainResource "USERS" (Aggregate) y una Action "VIEW", y los permisos de perfil se materializan + // vía AssignTemplate contra ese (TargetId=USERS.Id, ActionId=VIEW.Id). + + [Fact] + public async Task BuildForProfileAsync_DenyAndAllowFromTwoTemplates_ResolvesDeny() + { + // Dos plantillas Published para el mismo (target, action): una Allow y otra Deny. + // Ambas materializan ProfilePermission distintos ⇒ dos fuentes en conflicto ⇒ gana Deny. + var scenario = SetupProfileScenario(); + var allow = MakePublishedTemplate(scenario.ResourceId, scenario.ActionId, isAllowed: true, isDenied: false); + var deny = MakePublishedTemplate(scenario.ResourceId, scenario.ActionId, isAllowed: false, isDenied: true); + + Assert.True(scenario.Profile.AssignTemplate(allow, ActorId.Create("test")).IsSuccess); + Assert.True(scenario.Profile.AssignTemplate(deny, ActorId.Create("test")).IsSuccess); + + var result = await CreateSut().BuildForProfileAsync( + MakeUser(), TenantGuid, scenario.Profile.GetId().GetValue(), AuthMethod.Local()); + + Assert.True(result.IsSuccess); + var action = ResolveDomainAction(result.Value, "USERS", "VIEW"); + Assert.Equal(AccessEffect.Deny, action.Effect); + Assert.Equal(PermissionSource.Template, action.Source); + } + + [Fact] + public async Task BuildForProfileAsync_DenyWins_IsOrderIndependent() + { + // Mismo conflicto que arriba pero asignando primero la Deny y luego la Allow: + // deny-wins no depende del orden de iteración de profile.Permissions. + var scenario = SetupProfileScenario(); + var deny = MakePublishedTemplate(scenario.ResourceId, scenario.ActionId, isAllowed: false, isDenied: true); + var allow = MakePublishedTemplate(scenario.ResourceId, scenario.ActionId, isAllowed: true, isDenied: false); + + Assert.True(scenario.Profile.AssignTemplate(deny, ActorId.Create("test")).IsSuccess); + Assert.True(scenario.Profile.AssignTemplate(allow, ActorId.Create("test")).IsSuccess); + + var result = await CreateSut().BuildForProfileAsync( + MakeUser(), TenantGuid, scenario.Profile.GetId().GetValue(), AuthMethod.Local()); + + Assert.True(result.IsSuccess); + Assert.Equal(AccessEffect.Deny, ResolveDomainAction(result.Value, "USERS", "VIEW").Effect); + } + + [Fact] + public async Task BuildForProfileAsync_OverrideAllow_WinsOverTemplateAllow_SourceIsOverride() + { + // Dos fuentes Allow para el mismo (target, action): una Template y una Override. + // Override gana sobre Template para Allow ⇒ el efecto sigue Allow pero la fuente es Override. + var scenario = SetupProfileScenario(); + var t1 = MakePublishedTemplate(scenario.ResourceId, scenario.ActionId, isAllowed: true, isDenied: false); + var t2 = MakePublishedTemplate(scenario.ResourceId, scenario.ActionId, isAllowed: true, isDenied: false); + + Assert.True(scenario.Profile.AssignTemplate(t1, ActorId.Create("test")).IsSuccess); + Assert.True(scenario.Profile.AssignTemplate(t2, ActorId.Create("test")).IsSuccess); + + // Convierte la última entrada materializada (fuente Template) en un Override Allow. FindPermission + // resuelve por la identidad de entidad viva (ProfilePermission.Id), no por Props.Id: en un agregado + // en memoria ambas difieren (Props.Id sólo se alinea tras rehidratar desde BD, ver SystemSuite.FindModule). + var overridden = scenario.Profile.Permissions.Last(); + Assert.True(scenario.Profile.OverridePermissionAllow(overridden.GetId(), ActorId.Create("test")).IsSuccess); + + var result = await CreateSut().BuildForProfileAsync( + MakeUser(), TenantGuid, scenario.Profile.GetId().GetValue(), AuthMethod.Local()); + + Assert.True(result.IsSuccess); + var action = ResolveDomainAction(result.Value, "USERS", "VIEW"); + Assert.Equal(AccessEffect.Allow, action.Effect); + Assert.Equal(PermissionSource.Override, action.Source); + } + + [Fact] + public async Task BuildForProfileAsync_Deny_WinsOverTemplateAllowAndOverrideAllow() + { + // Tres fuentes para el mismo (target, action): Template Allow + Override Allow + Template Deny. + // deny-wins se evalúa ANTES que "Override gana sobre Template", así que Deny gana sobre ambos. + var scenario = SetupProfileScenario(); + var templateAllow = MakePublishedTemplate(scenario.ResourceId, scenario.ActionId, isAllowed: true, isDenied: false); + var willOverride = MakePublishedTemplate(scenario.ResourceId, scenario.ActionId, isAllowed: true, isDenied: false); + var templateDeny = MakePublishedTemplate(scenario.ResourceId, scenario.ActionId, isAllowed: false, isDenied: true); + + Assert.True(scenario.Profile.AssignTemplate(templateAllow, ActorId.Create("test")).IsSuccess); + Assert.True(scenario.Profile.AssignTemplate(willOverride, ActorId.Create("test")).IsSuccess); + var overridden = scenario.Profile.Permissions.Last(); + Assert.True(scenario.Profile.OverridePermissionAllow(overridden.GetId(), ActorId.Create("test")).IsSuccess); + Assert.True(scenario.Profile.AssignTemplate(templateDeny, ActorId.Create("test")).IsSuccess); + + var result = await CreateSut().BuildForProfileAsync( + MakeUser(), TenantGuid, scenario.Profile.GetId().GetValue(), AuthMethod.Local()); + + Assert.True(result.IsSuccess); + Assert.Equal(AccessEffect.Deny, ResolveDomainAction(result.Value, "USERS", "VIEW").Effect); + } + + [Fact] + public async Task BuildForProfileAsync_NoPermissionEntry_ResolvesNotGranted_FailClosed() + { + // Fail-closed (G-039, ADR-UMS-088): sin ProfilePermission para (target, action), el efecto es + // NotGranted, NO Allow. Nota adversarial: AccessEffect.Allow es el valor 0 del enum, así que + // un default de diccionario dejaría Allow (fail-open). Esta prueba fija la denegación implícita. + var scenario = SetupProfileScenario(); // sin AssignTemplate: perfil sin permisos materializados + + var result = await CreateSut().BuildForProfileAsync( + MakeUser(), TenantGuid, scenario.Profile.GetId().GetValue(), AuthMethod.Local()); + + Assert.True(result.IsSuccess); + var action = ResolveDomainAction(result.Value, "USERS", "VIEW"); + Assert.Equal(AccessEffect.NotGranted, action.Effect); + Assert.Empty(result.Value.Scopes); + } + + [Fact] + public async Task BuildForProfileAsync_PublishedTemplateNeverAssigned_DoesNotContributeToEffectiveMap() + { + // El mapa efectivo deriva SÓLO de los ProfilePermission materializados: un perfil sin + // AssignTemplate sale con permisos vacíos aunque exista una plantilla Published que le + // aplicaría. Desde G-174 el constructor ni siquiera consulta las plantillas —antes las + // cargaba todas y descartaba el resultado—, así que esta prueba fija que la plantilla + // no contribuye por ninguna vía, ni directa ni accidental. + var scenario = SetupProfileScenario(); + + // Plantilla Published para el MISMO rol+inquilino: el candidato más plausible a colarse. + MakePublishedTemplate( + scenario.ResourceId, scenario.ActionId, isAllowed: true, isDenied: false, + roleId: scenario.Role.GetId().GetValue()); + + var result = await CreateSut().BuildForProfileAsync( + MakeUser(), TenantGuid, scenario.Profile.GetId().GetValue(), AuthMethod.Local()); + + Assert.True(result.IsSuccess); + // …y aun así el permiso efectivo es NotGranted y no hay scopes: la plantilla no contribuye. + Assert.Equal(AccessEffect.NotGranted, ResolveDomainAction(result.Value, "USERS", "VIEW").Effect); + Assert.Empty(result.Value.Scopes); + } + + [Fact] + public async Task BuildForProfileAsync_ItemRetiradoDeLaPlantilla_NoConcede() + { + // ADR-0164 — la comprobación que decide si el borrado lógico es seguro o es una brecha. + // + // Al dejar de borrar físicamente el ítem, la fila permanece en la plantilla. Esta prueba + // recorre la cadena entera —ítem retirado → AssignTemplate → permMap → grafo resuelto— y + // fija que la retirada NO concede. Con `Profile.AssignTemplate` leyendo `Items` en vez de + // `ActiveItems`, aquí saldría Allow: sería el permiso vivo de una concesión que el operador + // ve apagada en la plantilla. + var scenario = SetupProfileScenario(); + var actor = ActorId.Create("test"); + + var template = PermissionTemplate.Create( + TenantId.Load(TenantGuid), RoleId.Load(Guid.NewGuid()), SystemSuiteId.Load(Guid.NewGuid()), actor).Value; + + // Dos concesiones: la que se retira sobre (USERS, VIEW) —la observable— y otra cualquiera que + // sostiene la publicación, porque una plantilla sin ítems vigentes ya no se puede publicar. + Assert.True(template.AddItem( + ExclusiveArcTarget.Aggregate, IdValueObject.Load(scenario.ResourceId), ActionId.Load(scenario.ActionId), + isAllowed: true, isDenied: false, actor).IsSuccess); + Assert.True(template.AddItem( + ExclusiveArcTarget.Aggregate, IdValueObject.Create(), ActionId.Load(Guid.NewGuid()), + isAllowed: true, isDenied: false, actor).IsSuccess); + + var retirado = template.Items.Single(i => i.TargetId.GetValue() == scenario.ResourceId); + Assert.True(template.DeactivateItem(retirado.GetId(), actor).IsSuccess); + Assert.True(template.Publish(actor).IsSuccess); + template.DomainEvents.MarkChangesAsCommitted(); + + Assert.True(scenario.Profile.AssignTemplate(template, actor).IsSuccess); + + var result = await CreateSut().BuildForProfileAsync( + MakeUser(), TenantGuid, scenario.Profile.GetId().GetValue(), AuthMethod.Local()); + + Assert.True(result.IsSuccess); + Assert.Equal(AccessEffect.NotGranted, ResolveDomainAction(result.Value, "USERS", "VIEW").Effect); + Assert.DoesNotContain("users.view", result.Value.Scopes); + + // Y la fila sigue en la plantilla: retirar no es borrar. + Assert.Equal(2, template.Items.Count); + } + + [Fact] + public async Task BuildForProfileAsync_ItemReactivadoTrasRetirarlo_VuelveAConceder() + { + // Contraprueba obligatoria de la anterior: si el ítem retirado no concediera por cualquier + // otra razón —una plantilla que nunca se asignó, un recurso que no resuelve— la prueba de + // arriba se satisfaría por accidente. Reactivar tiene que devolver el permiso. + var scenario = SetupProfileScenario(); + var actor = ActorId.Create("test"); + + var template = PermissionTemplate.Create( + TenantId.Load(TenantGuid), RoleId.Load(Guid.NewGuid()), SystemSuiteId.Load(Guid.NewGuid()), actor).Value; + + Assert.True(template.AddItem( + ExclusiveArcTarget.Aggregate, IdValueObject.Load(scenario.ResourceId), ActionId.Load(scenario.ActionId), + isAllowed: true, isDenied: false, actor).IsSuccess); + + var itemId = template.Items.Single().GetId(); + Assert.True(template.DeactivateItem(itemId, actor).IsSuccess); + Assert.True(template.ActivateItem(itemId, actor).IsSuccess); + Assert.True(template.Publish(actor).IsSuccess); + template.DomainEvents.MarkChangesAsCommitted(); + + Assert.True(scenario.Profile.AssignTemplate(template, actor).IsSuccess); + + var result = await CreateSut().BuildForProfileAsync( + MakeUser(), TenantGuid, scenario.Profile.GetId().GetValue(), AuthMethod.Local()); + + Assert.True(result.IsSuccess); + Assert.Equal(AccessEffect.Allow, ResolveDomainAction(result.Value, "USERS", "VIEW").Effect); + } + + /// + /// G-192 — recorrido completo de una concesión sobre un OBJETO DE DOMINIO por la vía de la API: + /// el validador la admite, el manejador la da de alta en la plantilla, AssignTemplate la + /// materializa en el perfil y el constructor la proyecta en domainPermissions. + /// + /// Es la prueba que faltaba: el tramo del grafo ya estaba cubierto, pero nadie fijaba que el + /// alta por comando llegase hasta él. Mientras el validador copió a mano los cuatro destinos de + /// navegación, este camino devolvía 400 y `domainPermissions` salía vacío en todo perfil + /// provisionado por API, aunque la suite hubiese declarado su catálogo de objetos de dominio. + /// + [Theory] + [InlineData("Aggregate")] + [InlineData("Entity")] + public async Task BuildForProfileAsync_ConcesionSobreObjetoDeDominioAltaPorComando_LlegaAlGrafo(string targetType) + { + var scenario = SetupProfileScenario(); + var actor = ActorId.Create("test"); + + var template = PermissionTemplate.Create( + TenantId.Load(TenantGuid), RoleId.Load(scenario.Role.GetId().GetValue()), + SystemSuiteId.Load(Guid.NewGuid()), actor).Value; + + var comando = new AddTemplateItemCommand( + TemplateId: template.GetId().GetValue(), + TargetType: targetType, + TargetId: scenario.ResourceId, + ActionId: scenario.ActionId, + IsAllowed: true, + IsDenied: false); + + // 1) La puerta de la API: el validador de FluentValidation es quien devolvía el 400. + Assert.True(new AddTemplateItemCommandValidator().Validate(comando).IsValid); + + // 2) El manejador: resuelve el destino contra el enumerado del dominio y lo añade. + var templateRepo = new Mock(); + var uow = new Mock(); + var userCtx = new Mock(); + templateRepo.Setup(r => r.GetByIdAsync(template.GetId().GetValue(), It.IsAny())) + .ReturnsAsync(template); + templateRepo.Setup(r => r.UnitOfWork).Returns(uow.Object); + uow.Setup(u => u.SaveEntitiesAsync(It.IsAny())).ReturnsAsync(true); + userCtx.Setup(u => u.UserId).Returns("test"); + + var alta = await new AddTemplateItemCommandHandler(templateRepo.Object, userCtx.Object) + .Handle(comando, CancellationToken.None); + + Assert.True(alta.IsSuccess, alta.IsFailure ? alta.Error : string.Empty); + Assert.Equal(targetType, template.Items.Single().TargetType.Name); + + // 3) Publicación y materialización en el perfil. + Assert.True(template.Publish(actor).IsSuccess); + template.DomainEvents.MarkChangesAsCommitted(); + Assert.True(scenario.Profile.AssignTemplate(template, actor).IsSuccess); + + // 4) Proyección: el objeto de dominio viaja en el grafo con su acción concedida. + var result = await CreateSut().BuildForProfileAsync( + MakeUser(), TenantGuid, scenario.Profile.GetId().GetValue(), AuthMethod.Local()); + + Assert.True(result.IsSuccess); + var recurso = Assert.Single(result.Value.DomainPermissions, p => p.ResourceCode == "USERS"); + Assert.Equal(AccessEffect.Allow, ResolveDomainAction(result.Value, "USERS", "VIEW").Effect); + Assert.Equal(scenario.ResourceId, recurso.ResourceId); + } + + [Fact] + public async Task BuildForProfileAsync_CyclicRoleHierarchy_ProjectsImmediateParentWithoutTraversing() + { + // Ciclo en el grafo efectivo, más allá del ciclo de roles ya cubierto en + // RoleCommandHandlerTests.Update_WhenParentIsDescendant_RejectsCycle (que lo RECHAZA al persistir). + // Aquí el dato ya contiene un ciclo R1 → R2 → R1; el constructor del grafo lo RESUELVE proyectando + // sólo el ParentRoleId inmediato como escalar (AuthorizationGraphBuilderService, nodo Context.Role) + // y NUNCA recorre la ascendencia — por eso una jerarquía cíclica no puede colgar la construcción. + SetupValidTenant(); + + var suiteId = Guid.NewGuid(); + var r1Key = Guid.NewGuid(); + var r2Key = Guid.NewGuid(); + + var suite = BuildMinimalSuite(); + _suiteRepo.Setup(r => r.GetByIdAsync(suiteId, It.IsAny())).ReturnsAsync(suite); + + var r1 = RoleAggregate.Create( + TenantId.Load(TenantGuid), SystemSuiteId.Load(suiteId), + Code.Create("R1"), Name.Create("Role 1"), Description.Create(""), + RoleId.Load(r2Key), 1, 1, ActorId.Create("test")).Value; + r1.DomainEvents.MarkChangesAsCommitted(); + + var r2 = RoleAggregate.Create( + TenantId.Load(TenantGuid), SystemSuiteId.Load(suiteId), + Code.Create("R2"), Name.Create("Role 2"), Description.Create(""), + RoleId.Load(r1Key), 1, 1, ActorId.Create("test")).Value; + r2.DomainEvents.MarkChangesAsCommitted(); + + _roleRepo.Setup(r => r.GetByIdAsync(r1Key, It.IsAny())).ReturnsAsync(r1); + _roleRepo.Setup(r => r.GetByIdAsync(r2Key, It.IsAny())).ReturnsAsync(r2); + + var profile = ProfileAggregate.Create( + TenantId.Load(TenantGuid), UserId.Load(UserGuid), + RoleId.Load(r1Key), null, ActorId.Create("test")).Value; + profile.DomainEvents.MarkChangesAsCommitted(); + _profileRepo.Setup(r => r.GetByIdAsync(profile.GetId().GetValue(), It.IsAny())) + .ReturnsAsync(profile); + + var result = await CreateSut().BuildForProfileAsync( + MakeUser(), TenantGuid, profile.GetId().GetValue(), AuthMethod.Local()); + + Assert.True(result.IsSuccess); + Assert.NotNull(result.Value.Context.Role); + Assert.Equal(r2Key, result.Value.Context.Role!.ParentRoleId); // padre inmediato proyectado como escalar + // El constructor no resolvió el padre ⇒ no existe recorrido de ascendencia que pueda ciclar. + _roleRepo.Verify(r => r.GetByIdAsync(r2Key, It.IsAny()), Times.Never); + } + // ── Helpers ──────────────────────────────────────────────────────────────── private static UserAccountAggregate MakeUser() @@ -314,7 +673,7 @@ private void SetupFullChain() TenantId.Load(TenantGuid), UserId.Load(UserGuid), RoleId.Load(roleId), null, ActorId.Create("test")).Value; profile.DomainEvents.MarkChangesAsCommitted(); - _profileRepo.Setup(r => r.GetByUserIdAsync(It.IsAny(), It.IsAny())) + _profileRepo.Setup(r => r.GetActiveByUserAndTenantAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new List { profile }); var role = RoleAggregate.Create( @@ -329,12 +688,12 @@ private void SetupFullChain() _roleRepo.Setup(r => r.GetByIdAsync(roleId, It.IsAny())) .ReturnsAsync(role); - var suite = BuildMinimalSuite(suiteId); + var suite = BuildMinimalSuite(); _suiteRepo.Setup(r => r.GetByIdAsync(suiteId, It.IsAny())) .ReturnsAsync(suite); } - private static SystemSuiteAggregate BuildMinimalSuite(Guid suiteId) + private static SystemSuiteAggregate BuildMinimalSuite() { var actor = ActorId.Create("test"); var suite = SystemSuiteAggregate.Create( @@ -349,4 +708,93 @@ private static SystemSuiteAggregate BuildMinimalSuite(Guid suiteId) suite.DomainEvents.MarkChangesAsCommitted(); return suite; } + + // ── Helpers adversariales (G-085) ──────────────────────────────────────────── + + /// + /// Prepara la cadena Perfil → Rol → Suite para BuildForProfileAsync con una suite que + /// contiene un DomainResource "USERS" (Aggregate) y una Action "VIEW", de modo que el + /// efecto resuelto para (USERS.Id, VIEW.Id) sea observable en DomainPermissions. + /// + private (ProfileAggregate Profile, RoleAggregate Role, Guid ResourceId, Guid ActionId) SetupProfileScenario() + { + SetupValidTenant(); + + var suiteId = Guid.NewGuid(); + var roleId = Guid.NewGuid(); + + var suite = BuildSuiteWithDomainResource(out var resourceId, out var actionId); + _suiteRepo.Setup(r => r.GetByIdAsync(suiteId, It.IsAny())).ReturnsAsync(suite); + + var role = RoleAggregate.Create( + TenantId.Load(TenantGuid), SystemSuiteId.Load(suiteId), + Code.Create("ADMIN"), Name.Create("Administrator"), Description.Create(""), + null, 0, 1, ActorId.Create("test")).Value; + role.DomainEvents.MarkChangesAsCommitted(); + _roleRepo.Setup(r => r.GetByIdAsync(roleId, It.IsAny())).ReturnsAsync(role); + + var profile = ProfileAggregate.Create( + TenantId.Load(TenantGuid), UserId.Load(UserGuid), + RoleId.Load(roleId), null, ActorId.Create("test")).Value; + profile.DomainEvents.MarkChangesAsCommitted(); + _profileRepo.Setup(r => r.GetByIdAsync(profile.GetId().GetValue(), It.IsAny())) + .ReturnsAsync(profile); + _profileRepo.Setup(r => r.GetActiveByUserAndTenantAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { profile }); + + return (profile, role, resourceId, actionId); + } + + private static SystemSuiteAggregate BuildSuiteWithDomainResource(out Guid resourceId, out Guid actionId) + { + var actor = ActorId.Create("test"); + var suite = SystemSuiteAggregate.Create( + TenantId.Load(Guid.NewGuid()), + Code.Create("CORE"), Name.Create("Core System"), Description.Create(""), + actor).Value; + + actionId = suite.RegisterAction(ActionCode.Create("VIEW"), Name.Create("View Records"), actor).Value; + resourceId = suite.AddDomainResource( + null, null, DomainResourceType.Aggregate, + Code.Create("USERS"), Name.Create("Users"), Description.Create(""), actor).Value; + suite.DomainEvents.MarkChangesAsCommitted(); + return suite; + } + + /// + /// Crea y publica una plantilla del inquilino de prueba con un único item para + /// (Aggregate, resourceId, actionId) con el efecto indicado. El RoleId es libre por + /// defecto (AssignTemplate no lo valida); se puede fijar para que el filtro del paso 5 + /// del constructor la seleccione (prueba de scaffolding G-016). + /// + private static PermissionTemplate MakePublishedTemplate( + Guid resourceId, Guid actionId, bool isAllowed, bool isDenied, Guid? roleId = null) + { + var actor = ActorId.Create("test"); + var template = PermissionTemplate.Create( + TenantId.Load(TenantGuid), + RoleId.Load(roleId ?? Guid.NewGuid()), + SystemSuiteId.Load(Guid.NewGuid()), + actor).Value; + + Assert.True(template.AddItem( + ExclusiveArcTarget.Aggregate, IdValueObject.Load(resourceId), ActionId.Load(actionId), + isAllowed, isDenied, actor).IsSuccess); + Assert.True(template.Publish(actor).IsSuccess); + template.DomainEvents.MarkChangesAsCommitted(); + return template; + } + + /// + /// Resolución efectiva de un par recurso-acción. Desde el contrato v2.0.0 las filas + /// `NotGranted` NO viajan —la ausencia es la denegación—, así que «no encontrado» y + /// «NotGranted» son lo mismo y este ayudante los unifica. + /// + private static GraphDomainAction ResolveDomainAction( + AuthorizationGraph graph, string resourceCode, string actionCode) + => graph.DomainPermissions + .SingleOrDefault(p => p.ResourceCode == resourceCode) + ?.Actions.SingleOrDefault(a => a.ActionCode == actionCode) + ?? new GraphDomainAction(Guid.Empty, actionCode, actionCode, + AccessEffect.NotGranted, PermissionSource.Template); } diff --git a/src/apps/ums.api/Ums.Application.Test/Authorization/Profile/ProfileCommandHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Authorization/Profile/ProfileCommandHandlerTests.cs index f4f6cde0..cb6f88c7 100644 --- a/src/apps/ums.api/Ums.Application.Test/Authorization/Profile/ProfileCommandHandlerTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Authorization/Profile/ProfileCommandHandlerTests.cs @@ -93,6 +93,43 @@ private void SetupValidProfileReferences(Guid tenantId, Guid userId, Guid roleId #region CreateProfileCommandHandler // ========================================================================= + /// + /// G-215 — un perfil por (usuario, rol, sucursal) activo. + /// + /// Sin esta guarda, cada llamada con los mismos datos creaba otro perfil. Un + /// aprovisionamiento reejecutado dejaba al usuario con dos perfiles del mismo rol —uno con + /// concesiones y otro vacío, porque la plantilla se asigna a uno solo— y el selector se los + /// ofrecía indistinguibles. Entrar por el vacío es entrar sin permisos, con todos los HTTP en + /// 2xx. + /// + [Fact] + public async Task Create_CuandoYaExisteUnPerfilDelMismoRol_Falla() + { + _ctx.Setup(u => u.UserId).Returns("user-001"); + SetupNoMatchingRules(); + var tenantId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var roleId = Guid.NewGuid(); + + _userAccountRepo.Setup(r => r.GetByIdAsync(userId, It.IsAny())) + .ReturnsAsync(MakeUser(tenantId, userId)); + _roleRepo.Setup(r => r.GetByIdAsync(roleId, It.IsAny())) + .ReturnsAsync(MakeRole(tenantId, roleId)); + + var yaExiste = Profile.Create( + TenantId.Load(tenantId), UserId.Load(userId), RoleId.Load(roleId), null, + ActorId.Create("user-001")).Value; + _repo.Setup(r => r.GetActiveByUserAndTenantAsync(userId, tenantId, It.IsAny())) + .ReturnsAsync([yaExiste]); + + var result = await MakeCreateHandler().Handle( + new CreateProfileCommand(tenantId, userId, roleId, BranchId: null), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Contains("profile_already_exists_for_role", result.Error); + _repo.Verify(r => r.AddAsync(It.IsAny(), It.IsAny()), Times.Never); + } + [Fact] public async Task Create_WithValidCommand_ReturnsSuccess() { @@ -401,13 +438,17 @@ public async Task Create_WhenMatchingRuleExists_AutoAssignsTemplate() [Fact] public async Task Create_WhenMatchingRuleAndTemplate_ProfileReceivesPermissions() { + // G-043: materialización de permisos. La plantilla publicada auto-asignada debe dejar + // permisos en el agregado Profile ANTES de persistir (un único Save). Antes esto se hacía + // en un segundo Save cuyo fallo se tragaba (permissionCount=0). Ahora se captura el perfil + // en AddAsync y se verifica que trae permisos. var tenantId = Guid.NewGuid(); var userId = Guid.NewGuid(); var roleId = Guid.NewGuid(); SetupValidProfileReferences(tenantId, userId, roleId); var capturedProfile = (Profile?)null; - _repo.Setup(r => r.UpdateAsync(It.IsAny(), It.IsAny())) + _repo.Setup(r => r.AddAsync(It.IsAny(), It.IsAny())) .Callback((p, _) => capturedProfile = p) .Returns(Task.CompletedTask); @@ -431,8 +472,70 @@ public async Task Create_WhenMatchingRuleAndTemplate_ProfileReceivesPermissions( var result = await MakeCreateHandler().Handle(cmd, CancellationToken.None); Assert.True(result.IsSuccess); - // capturedProfile may be null if TenantId != comparison fails (value equality issue) - // but result should still be success (auto-assign failure is swallowed gracefully) + Assert.NotNull(capturedProfile); + Assert.NotEmpty(capturedProfile!.Permissions); + } + + [Fact] + public async Task Create_WhenAutoAssignedTemplateIsUnpublished_ReturnsFailureWithCode() + { + // G-043 (desenmascarar): si la plantilla referida por la regla NO está publicada, + // AssignTemplate falla. Antes ese fallo se tragaba y el perfil quedaba sin permisos. + // Ahora el comando falla con un código estable y NO persiste el perfil. + var tenantId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var roleId = Guid.NewGuid(); + SetupValidProfileReferences(tenantId, userId, roleId); + + var draftTemplate = Ums.Domain.Authorization.Template.PermissionTemplate.Create( + TenantId.Load(tenantId), RoleId.Load(roleId), + SystemSuiteId.Load(Guid.NewGuid()), ActorId.Create("admin")).Value; + draftTemplate.AddItem(ExclusiveArcTarget.SystemSuite, IdValueObject.Create(), + ActionId.Load(Guid.NewGuid()), true, false, ActorId.Create("admin")); + // NOTA: NO se publica → AssignTemplate debe rechazarla. + + var rule = Ums.Domain.Authorization.AssignmentRule.TemplateAssignmentRule.Create( + TenantId.Load(tenantId), TemplateId.Load(draftTemplate.Props.Id.GetValue()), + RoleId.Load(roleId), 10, ActorId.Create("admin")).Value; + + _ruleRepo.Setup(r => r.GetActiveByTenantAndRoleAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { rule }); + _templateRepo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(draftTemplate); + + var cmd = new CreateProfileCommand(tenantId, userId, roleId, null); + var result = await MakeCreateHandler().Handle(cmd, CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains("PROFILE_TEMPLATE_ASSIGN_FAILED", result.Error); + _repo.Verify(r => r.AddAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Create_WhenRuleReferencesMissingTemplate_ReturnsFailureWithCode() + { + // G-043 (desenmascarar): una regla activa que apunta a una plantilla inexistente es una + // inconsistencia de configuración que ahora aflora en vez de tragarse (permissionCount=0). + var tenantId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var roleId = Guid.NewGuid(); + SetupValidProfileReferences(tenantId, userId, roleId); + + var rule = Ums.Domain.Authorization.AssignmentRule.TemplateAssignmentRule.Create( + TenantId.Load(tenantId), TemplateId.Load(Guid.NewGuid()), + RoleId.Load(roleId), 10, ActorId.Create("admin")).Value; + + _ruleRepo.Setup(r => r.GetActiveByTenantAndRoleAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { rule }); + _templateRepo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Ums.Domain.Authorization.Template.PermissionTemplate?)null); + + var cmd = new CreateProfileCommand(tenantId, userId, roleId, null); + var result = await MakeCreateHandler().Handle(cmd, CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains("PROFILE_TEMPLATE_MISSING", result.Error); + _repo.Verify(r => r.AddAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] @@ -443,13 +546,22 @@ public async Task Create_WhenMultipleRulesMatch_OnlyQueriesTemplateOnce() var roleId = Guid.NewGuid(); SetupValidProfileReferences(tenantId, userId, roleId); + var template = Ums.Domain.Authorization.Template.PermissionTemplate.Create( + TenantId.Load(tenantId), RoleId.Load(roleId), + SystemSuiteId.Load(Guid.NewGuid()), ActorId.Create("admin")).Value; + template.AddItem(ExclusiveArcTarget.SystemSuite, IdValueObject.Create(), + ActionId.Load(Guid.NewGuid()), true, false, ActorId.Create("admin")); + template.Publish(ActorId.Create("admin")); + var highPriorityRule = Ums.Domain.Authorization.AssignmentRule.TemplateAssignmentRule.Create( - TenantId.Load(tenantId), TemplateId.Load(Guid.NewGuid()), RoleId.Load(roleId), 100, ActorId.Create("admin")).Value; + TenantId.Load(tenantId), TemplateId.Load(template.Props.Id.GetValue()), RoleId.Load(roleId), 100, ActorId.Create("admin")).Value; var lowPriorityRule = Ums.Domain.Authorization.AssignmentRule.TemplateAssignmentRule.Create( TenantId.Load(tenantId), TemplateId.Load(Guid.NewGuid()), RoleId.Load(roleId), 10, ActorId.Create("admin")).Value; _ruleRepo.Setup(r => r.GetActiveByTenantAndRoleAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new List { highPriorityRule, lowPriorityRule }); + _templateRepo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(template); var cmd = new CreateProfileCommand(tenantId, userId, roleId, null); var result = await MakeCreateHandler().Handle(cmd, CancellationToken.None); diff --git a/src/apps/ums.api/Ums.Application.Test/Authorization/SystemSuite/SystemSuiteQueryHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Authorization/SystemSuite/SystemSuiteQueryHandlerTests.cs index 19c21322..d6e15fd1 100644 --- a/src/apps/ums.api/Ums.Application.Test/Authorization/SystemSuite/SystemSuiteQueryHandlerTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Authorization/SystemSuite/SystemSuiteQueryHandlerTests.cs @@ -1,3 +1,4 @@ +#pragma warning disable S1144 namespace Ums.Application.Test.Authorization.SystemSuite; using Ums.Application.Authorization.SystemSuite.Queries; @@ -10,6 +11,7 @@ namespace Ums.Application.Test.Authorization.SystemSuite; using Xunit; using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -80,122 +82,102 @@ public async Task GetById_WhenNotFound_ReturnsFailure() #region GetAllSystemSuitesQueryHandler // ========================================================================= - [Fact] - public async Task GetAll_WithoutTenantFilter_ReturnsAllItems() + // ── Paginación en la base (G-179) ──────────────────────────────────────── + // + // El filtrado, el orden y el recorte se resolvían en memoria sobre TODAS las suites del + // inquilino, con su árbol completo. Ahora los resuelve la base y el manejador solo traduce + // los criterios de la petición: eso es lo que estas pruebas verifican. La corrección del + // filtro SQL en sí es responsabilidad del repositorio, y se cubre contra una base real en + // las pruebas de integración. + + /// Prepara el repositorio para devolver una página con las suites indicadas. + private void ConPagina(int total, params SystemSuite[] suites) { - var s1 = MakeSystemSuite("SUITE-01", "Suite Alpha", "Active"); - var s2 = MakeSystemSuite("SUITE-02", "Suite Beta", "Maintenance"); - var list = new List { s1, s2 }; + var ids = suites.Select(x => x.GetId().GetValue()).ToList(); - _repo.Setup(r => r.GetAllAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(list); + _repo.Setup(r => r.GetPageAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new SystemSuitePage(ids, total)); + _repo.Setup(r => r.GetByIdsAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(suites.ToList()); + } - var query = new GetAllSystemSuitesQuery( - TenantId: null, - Criteria: "name", - Status: "all", - Search: null, - SortBy: null, - SortOrder: null, - Page: 1, - PageSize: 10); + private static GetAllSystemSuitesQuery Consulta( + Guid? tenantId = null, string criteria = "name", string status = "all", + string? search = null, string? sortBy = null, string? sortOrder = null, + int page = 1, int pageSize = 10) + => new(Page: page, PageSize: pageSize, Search: search, Criteria: criteria, + Status: status, SortBy: sortBy ?? "name", SortOrder: sortOrder ?? "asc", + TenantId: tenantId); + [Fact] + public async Task GetAll_DevuelveLaPaginaQueResolvioLaBase() + { + var s1 = MakeSystemSuite("SUITE-01", "Suite Alpha", "Active"); + var s2 = MakeSystemSuite("SUITE-02", "Suite Beta", "Maintenance"); + ConPagina(total: 37, s1, s2); // 37 en total, 2 en esta página _scopePolicy.Setup(p => p.ResolveQueryScope()).Returns((Guid?)null); var handler = new GetAllSystemSuitesQueryHandler(_repo.Object, _scopePolicy.Object); - var result = await handler.Handle(query, CancellationToken.None); + var result = await handler.Handle(Consulta(pageSize: 2), CancellationToken.None); Assert.True(result.IsSuccess); - Assert.Equal(2, result.Value.TotalItems); + // El total es el de la BASE, no el de los elementos traídos: eso es lo que se rompía al + // paginar en memoria sobre una lista ya recortada. + Assert.Equal(37, result.Value.TotalItems); + Assert.Equal(19, result.Value.TotalPages); Assert.Equal(2, result.Value.Items.Count); } [Fact] - public async Task GetAll_WithTenantFilter_ReturnsTenantItems() + public async Task GetAll_AcotaLaConsultaAlInquilinoResuelto() { var tenantId = Guid.NewGuid(); - var s1 = MakeSystemSuite("SUITE-01", "Suite Alpha", "Active"); - var list = new List { s1 }; - - _repo.Setup(r => r.GetByTenantIdAsync(tenantId, It.IsAny())) - .ReturnsAsync(list); - - var query = new GetAllSystemSuitesQuery( - TenantId: tenantId, - Criteria: "name", - Status: "all", - Search: null, - SortBy: null, - SortOrder: null, - Page: 1, - PageSize: 10); - + ConPagina(total: 1, MakeSystemSuite("SUITE-01", "Suite Alpha", "Active")); _scopePolicy.Setup(p => p.ResolveQueryScope()).Returns(tenantId); + var handler = new GetAllSystemSuitesQueryHandler(_repo.Object, _scopePolicy.Object); - var result = await handler.Handle(query, CancellationToken.None); + await handler.Handle(Consulta(tenantId: tenantId), CancellationToken.None); - Assert.True(result.IsSuccess); - Assert.Equal(1, result.Value.TotalItems); - _repo.Verify(r => r.GetByTenantIdAsync(tenantId, It.IsAny()), Times.Once); + _repo.Verify(r => r.GetPageAsync( + It.Is(q => q.TenantId == tenantId), It.IsAny()), Times.Once); } [Fact] - public async Task GetAll_WithStatusFilter_FiltersStatus() + public async Task GetAll_ConEstadoTodos_NoFiltraPorEstado() { - var s1 = MakeSystemSuite("SUITE-01", "Suite Alpha", "Active"); - var s2 = MakeSystemSuite("SUITE-02", "Suite Beta", "Maintenance"); - var list = new List { s1, s2 }; - - _repo.Setup(r => r.GetAllAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(list); - - var query = new GetAllSystemSuitesQuery( - TenantId: null, - Criteria: "name", - Status: "Maintenance", - Search: null, - SortBy: null, - SortOrder: null, - Page: 1, - PageSize: 10); - + ConPagina(total: 0); _scopePolicy.Setup(p => p.ResolveQueryScope()).Returns((Guid?)null); + var handler = new GetAllSystemSuitesQueryHandler(_repo.Object, _scopePolicy.Object); - var result = await handler.Handle(query, CancellationToken.None); + await handler.Handle(Consulta(status: "all"), CancellationToken.None); - Assert.True(result.IsSuccess); - Assert.Equal(1, result.Value.TotalItems); - Assert.Equal("Maintenance", result.Value.Items[0].Status); + // «all» no es un estado: viaja como ausencia de filtro, no como el literal "all". + _repo.Verify(r => r.GetPageAsync( + It.Is(q => q.Status == null), It.IsAny()), Times.Once); } [Fact] - public async Task GetAll_WithSearch_FiltersSearch() + public async Task GetAll_TrasladaEstadoBusquedaYOrdenALaBase() { - var s1 = MakeSystemSuite("SUITE-01", "Suite Alpha", "Active"); - var s2 = MakeSystemSuite("SUITE-02", "Suite Beta", "Active"); - var list = new List { s1, s2 }; - - _repo.Setup(r => r.GetAllAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(list); - - var query = new GetAllSystemSuitesQuery( - TenantId: null, - Criteria: "code", - Status: "all", - Search: "SUITE-02", - SortBy: null, - SortOrder: null, - Page: 1, - PageSize: 10); - + ConPagina(total: 0); _scopePolicy.Setup(p => p.ResolveQueryScope()).Returns((Guid?)null); - var handler = new GetAllSystemSuitesQueryHandler(_repo.Object, _scopePolicy.Object); - var result = await handler.Handle(query, CancellationToken.None); - Assert.True(result.IsSuccess); - Assert.Equal(1, result.Value.TotalItems); - Assert.Equal("SUITE-02", result.Value.Items[0].Code); + var handler = new GetAllSystemSuitesQueryHandler(_repo.Object, _scopePolicy.Object); + await handler.Handle( + Consulta(criteria: "code", status: "Maintenance", search: "SUITE-02", sortBy: "code", sortOrder: "desc"), + CancellationToken.None); + + _repo.Verify(r => r.GetPageAsync( + It.Is(q => + q.Status == "Maintenance" && + q.SearchField == "code" && + q.Search == "SUITE-02" && + q.SortBy == "code" && + q.Descending), + It.IsAny()), Times.Once); } #endregion } + +#pragma warning restore S1144 diff --git a/src/apps/ums.api/Ums.Application.Test/Authorization/Template/AddTemplateItemCommandValidatorTests.cs b/src/apps/ums.api/Ums.Application.Test/Authorization/Template/AddTemplateItemCommandValidatorTests.cs new file mode 100644 index 00000000..58b53de8 --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/Authorization/Template/AddTemplateItemCommandValidatorTests.cs @@ -0,0 +1,92 @@ +namespace Ums.Application.Test.Authorization.Template; + +using Ums.Application.Authorization.Template.Commands; +using Ums.Domain.Enums; +using BeyondNetCode.Shell.Ddd; +using Xunit; +using System; +using System.Linq; + +/// +/// G-192 — el validador del alta de ítems de plantilla NO puede tener su propia idea de qué +/// destinos existen. La verdad la fija : el agregado +/// PermissionTemplate.AddItem acepta cualquiera de sus valores, Profile.AssignTemplate +/// los copia al permiso del perfil y BuildDomainPermissions proyecta los que apuntan a un +/// recurso de dominio. La copia a mano que vivía aquí negaba Aggregate y Entity, y con +/// ello dejaba sin camino de alta a toda concesión sobre objetos de dominio. +/// +public class AddTemplateItemCommandValidatorTests +{ + private readonly AddTemplateItemCommandValidator _validator = new(); + + private static AddTemplateItemCommand Comando(string targetType) => new( + TemplateId: Guid.NewGuid(), + TargetType: targetType, + TargetId: Guid.NewGuid(), + ActionId: Guid.NewGuid(), + IsAllowed: true, + IsDenied: false); + + public static TheoryData DestinosDelDominio() + { + var data = new TheoryData(); + foreach (var nombre in DomainEnumeration.GetAll().Select(t => t.Name)) + { + data.Add(nombre); + } + + return data; + } + + [Theory] + [MemberData(nameof(DestinosDelDominio))] + public void Validate_AdmiteTodoDestinoDeclaradoPorElDominio(string targetType) + { + var resultado = _validator.Validate(Comando(targetType)); + + Assert.True(resultado.IsValid, $"El destino '{targetType}' lo declara el dominio y el validador lo rechazó: {resultado}"); + } + + [Theory] + [InlineData("Aggregate")] + [InlineData("Entity")] + public void Validate_AdmiteLosObjetosDeDominio(string targetType) + { + // Caso concreto de G-192: son los dos destinos que la lista copiada había dejado fuera. + Assert.True(_validator.Validate(Comando(targetType)).IsValid); + } + + [Theory] + [InlineData("aggregate")] + [InlineData(" Entity ")] + public void Validate_NoDistingueMayusculasNiEspacios_IgualQueElManejador(string targetType) + { + // El validador y el manejador deben resolver el destino con el MISMO criterio: si el + // validador fuese más laxo, el alta pasaría el 400 y moriría después con un error opaco. + Assert.True(_validator.Validate(Comando(targetType)).IsValid); + Assert.NotNull(Ums.Application.Common.DomainEnumerationParser.FromName(targetType)); + } + + [Theory] + [InlineData("DomainResource")] // no es un destino del arco: el recurso se apunta como Aggregate o Entity + [InlineData("Page")] + [InlineData("")] + public void Validate_RechazaDestinosQueElDominioNoDeclara(string targetType) + { + Assert.False(_validator.Validate(Comando(targetType)).IsValid); + } + + [Fact] + public void Mensaje_EnumeraLosDestinosQueElDominioDeclara() + { + // El mensaje se DERIVA del enumerado: si mañana el dominio incorpora un destino, el texto + // lo refleja solo. Antes recitaba cuatro nombres fijos que ya no eran la verdad. + var resultado = _validator.Validate(Comando("NoExiste")); + + var mensaje = string.Join(" ", resultado.Errors.Select(e => e.ErrorMessage)); + foreach (var nombre in DomainEnumeration.GetAll().Select(t => t.Name)) + { + Assert.Contains(nombre, mensaje, StringComparison.Ordinal); + } + } +} diff --git a/src/apps/ums.api/Ums.Application.Test/Authorization/Template/TemplateCommandHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Authorization/Template/TemplateCommandHandlerTests.cs index 47b6bd97..ff81018a 100644 --- a/src/apps/ums.api/Ums.Application.Test/Authorization/Template/TemplateCommandHandlerTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Authorization/Template/TemplateCommandHandlerTests.cs @@ -16,6 +16,7 @@ namespace Ums.Application.Test.Authorization.Template; public class TemplateCommandHandlerTests { private readonly Mock _repo = new(); + private readonly Mock _profiles = new(); private readonly Mock _uow = new(); private readonly Mock _ctx = new(); private readonly Mock _scopePolicy = new(); @@ -24,9 +25,14 @@ public TemplateCommandHandlerTests() { _repo.Setup(r => r.UnitOfWork).Returns(_uow.Object); _uow.Setup(u => u.SaveEntitiesAsync(It.IsAny())).ReturnsAsync(true); + _uow.Setup(u => u.SaveChangesAsync(It.IsAny())).ReturnsAsync(1); _ctx.Setup(u => u.UserId).Returns("user-001"); _scopePolicy.Setup(s => s.EnsureManagementOwnerScopeAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(Result.Success()); + // G-140: por defecto no hay plantillas previas para la terna → el alta usa la versión inicial. + _repo.Setup(r => r.GetByTenantRoleSuiteAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); } private static PermissionTemplate MakeTemplate() @@ -76,6 +82,60 @@ public async Task Create_WithValidCommand_ReturnsSuccess() _uow.Verify(u => u.SaveEntitiesAsync(It.IsAny()), Times.Once); } + [Fact] + public async Task Create_WhenNoExistingTemplate_AssignsInitialVersion() + { + _ctx.Setup(u => u.UserId).Returns("user-001"); + PermissionTemplate? captured = null; + _repo.Setup(r => r.AddAsync(It.IsAny(), It.IsAny())) + .Callback((t, _) => captured = t) + .Returns(Task.CompletedTask); + + var cmd = new CreatePermissionTemplateCommand( + TenantId: Guid.NewGuid(), + RoleId: Guid.NewGuid(), + SystemSuiteId: Guid.NewGuid()); + + var handler = new CreatePermissionTemplateCommandHandler(_repo.Object, _ctx.Object, _scopePolicy.Object); + var result = await handler.Handle(cmd, CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.NotNull(captured); + Assert.Equal("0.1.0", captured!.Version.GetValue()); + } + + [Fact] + public async Task Create_WhenRoleAlreadyTemplated_AssignsNextVersion() + { + // G-140: existe ya una plantilla v0.1.0 para la terna → el alta debe generar v0.2.0 + // en lugar de colisionar con el índice único. + _ctx.Setup(u => u.UserId).Returns("user-001"); + + var tenantId = Guid.NewGuid(); + var roleId = Guid.NewGuid(); + var suiteId = Guid.NewGuid(); + + var existing = PermissionTemplate.Create( + TenantId.Load(tenantId), RoleId.Load(roleId), SystemSuiteId.Load(suiteId), + ActorId.Create("user-001")).Value; + + _repo.Setup(r => r.GetByTenantRoleSuiteAsync(tenantId, roleId, suiteId, It.IsAny())) + .ReturnsAsync(new[] { existing }); + + PermissionTemplate? captured = null; + _repo.Setup(r => r.AddAsync(It.IsAny(), It.IsAny())) + .Callback((t, _) => captured = t) + .Returns(Task.CompletedTask); + + var cmd = new CreatePermissionTemplateCommand(tenantId, roleId, suiteId); + var handler = new CreatePermissionTemplateCommandHandler(_repo.Object, _ctx.Object, _scopePolicy.Object); + var result = await handler.Handle(cmd, CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.NotNull(captured); + Assert.Equal("0.2.0", captured!.Version.GetValue()); + } + [Fact] public async Task Create_WhenUnauthenticated_ReturnsFailure() { @@ -184,4 +244,103 @@ public async Task Publish_WhenAlreadyPublished_ReturnsFailure() } #endregion + + // ========================================================================= + #region DeletePermissionTemplateCommandHandler (borrado LÓGICO) + // ========================================================================= + + /// + /// Política del propietario: solo existe borrado lógico. El handler ya NO llama a un DELETE + /// físico —antes DeleteAsync hacía Remove y la fila desaparecía—; ahora transiciona + /// el agregado al estado terminal y lo persiste con UpdateAsync. + /// + [Fact] + public async Task Delete_ConPlantillaSinPerfilesVivos_MarcaEstadoTerminalYPersisteConUpdate() + { + _ctx.Setup(u => u.UserId).Returns("user-001"); + var template = MakeTemplate(); // queda en Draft + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(template); + _profiles.Setup(p => p.CountActiveByTemplateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(0); + + var cmd = new DeletePermissionTemplateCommand(template.Props.Id.GetValue()); + var handler = new DeletePermissionTemplateCommandHandler(_repo.Object, _profiles.Object, _ctx.Object); + var result = await handler.Handle(cmd, CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(TemplateStatus.Deleted, template.Status); + _repo.Verify(r => r.UpdateAsync(template, It.IsAny()), Times.Once); + _uow.Verify(u => u.SaveChangesAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Delete_ConPerfilesVivos_DevuelveErrorDeOperacionBloqueada() + { + // Guardia de cascada → el error viaja codificado como BlockedOperationError, que la capa de + // presentación traduce a 409 con las dependencias que bloquean. + _ctx.Setup(u => u.UserId).Returns("user-001"); + var template = MakeTemplate(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(template); + _profiles.Setup(p => p.CountActiveByTemplateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(3); + + var cmd = new DeletePermissionTemplateCommand(template.Props.Id.GetValue()); + var handler = new DeletePermissionTemplateCommandHandler(_repo.Object, _profiles.Object, _ctx.Object); + var result = await handler.Handle(cmd, CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.True(BlockedOperationError.TryDecode(result.Error, out var code, out var deps)); + Assert.Equal(DomainErrors.Authorization.TemplateHasActiveProfiles, code); + Assert.Equal(3, deps.Single().Count); + Assert.Equal("Profile", deps.Single().EntityType); + // Nada se persiste y el agregado sigue vivo: el bloqueo es previo a cualquier escritura. + Assert.NotEqual(TemplateStatus.Deleted, template.Status); + _repo.Verify(r => r.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Delete_CuandoLaReferenciaYaNoEstaViva_SiPermiteEliminar() + { + // Contrapartida de la prueba anterior: los perfiles ya desactivados no cuentan como referencia + // (CountActiveByTemplateAsync solo cuenta los activos), así que el borrado procede. + _ctx.Setup(u => u.UserId).Returns("user-001"); + var template = MakeTemplate(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(template); + _profiles.SetupSequence(p => p.CountActiveByTemplateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(1) // primer intento: perfil vivo + .ReturnsAsync(0); // tras desactivarlo: ya no bloquea + + var cmd = new DeletePermissionTemplateCommand(template.Props.Id.GetValue()); + var handler = new DeletePermissionTemplateCommandHandler(_repo.Object, _profiles.Object, _ctx.Object); + + var bloqueado = await handler.Handle(cmd, CancellationToken.None); + Assert.True(bloqueado.IsFailure); + + var permitido = await handler.Handle(cmd, CancellationToken.None); + Assert.True(permitido.IsSuccess); + Assert.Equal(TemplateStatus.Deleted, template.Status); + } + + [Fact] + public async Task Delete_CuandoYaEstaEliminada_DevuelveNoEncontrada() + { + // Las lecturas ocultan lo eliminado: el segundo DELETE no encuentra nada → 404, no un 500 ni + // un 204 mentiroso. Se modela con el repositorio devolviendo null, que es lo que hace el store + // real al filtrar por estado. + _ctx.Setup(u => u.UserId).Returns("user-001"); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((PermissionTemplate?)null); + + var cmd = new DeletePermissionTemplateCommand(Guid.NewGuid()); + var handler = new DeletePermissionTemplateCommandHandler(_repo.Object, _profiles.Object, _ctx.Object); + var result = await handler.Handle(cmd, CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains("not found", result.Error, StringComparison.OrdinalIgnoreCase); + } + + #endregion } diff --git a/src/apps/ums.api/Ums.Application.Test/Common/Aop/AuditMetadataSanitizerTests.cs b/src/apps/ums.api/Ums.Application.Test/Common/Aop/AuditMetadataSanitizerTests.cs new file mode 100644 index 00000000..29446e23 --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/Common/Aop/AuditMetadataSanitizerTests.cs @@ -0,0 +1,210 @@ +using System.Text.Json; +using FluentAssertions; +using Ums.Application.Common.Aop; +using Xunit; + +namespace Ums.Application.Test.Common.Aop; + +/// +/// G-040 (residual #5, FR-072): el saneador de metadata de auditoría redacta los valores de las +/// claves sensibles (hash/PIN/llave/token/secreto/credencial) antes de persistir en una traza +/// append-only e inmutable (G-081), conservando la clave y sin tocar los datos legítimos. +/// +public sealed class AuditMetadataSanitizerTests +{ + private const string Redacted = AuditMetadataSanitizer.RedactionPlaceholder; + + // ── Nula / vacía / en blanco → sin fallo, se devuelve tal cual ── + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void NullOrBlankMetadata_IsReturnedUnchanged(string? metadata) + { + AuditMetadataSanitizer.Sanitize(metadata).Should().Be(metadata); + } + + // ── Clave sensible → valor redactado (conservando la clave) ── + [Theory] + [InlineData("password")] + [InlineData("Password")] + [InlineData("PasswordHash")] + [InlineData("pwd")] + [InlineData("userPwd")] + [InlineData("hash")] + [InlineData("passwordHash")] + [InlineData("secret")] + [InlineData("clientSecret")] + [InlineData("token")] + [InlineData("accessToken")] + [InlineData("refreshToken")] + [InlineData("Authorization")] + [InlineData("apiKey")] + [InlineData("api_key")] + [InlineData("apikey")] + [InlineData("privateKey")] + [InlineData("signingKey")] + [InlineData("pin")] + [InlineData("userPin")] + [InlineData("credential")] + [InlineData("credentials")] + [InlineData("authHeader")] + public void SensitiveKey_ValueIsRedacted_KeyPreserved(string sensitiveKey) + { + var input = JsonSerializer.Serialize(new Dictionary + { + [sensitiveKey] = "el-secreto-en-claro", + }); + + var result = AuditMetadataSanitizer.Sanitize(input); + + using var doc = JsonDocument.Parse(result!); + doc.RootElement.TryGetProperty(sensitiveKey, out var value).Should().BeTrue("la clave se conserva para trazabilidad"); + value.GetString().Should().Be(Redacted); + result.Should().NotContain("el-secreto-en-claro"); + } + + // ── Clave normal → intacta (no se redacta ni se pierde) ── + [Theory] + [InlineData("handler")] + [InlineData("Method")] + [InlineData("requestType")] + [InlineData("sessionTrackingId")] + [InlineData("correlationId")] + [InlineData("traceId")] + [InlineData("spanId")] + [InlineData("userName")] + [InlineData("shippingCost")] // contiene «pin» como subcadena, pero no como token → no se redacta + [InlineData("monkey")] // contiene «key» como subcadena, pero no como token → no se redacta + [InlineData("keyboardLayout")] // «key» pegado a más letras → no es token «key» + public void NormalKey_ValueIsPreserved(string normalKey) + { + var input = JsonSerializer.Serialize(new Dictionary + { + [normalKey] = "valor-legitimo", + }); + + var result = AuditMetadataSanitizer.Sanitize(input); + + using var doc = JsonDocument.Parse(result!); + doc.RootElement.GetProperty(normalKey).GetString().Should().Be("valor-legitimo"); + result.Should().NotContain(Redacted); + } + + // ── Mezcla: solo lo sensible se redacta; lo legítimo permanece ── + [Fact] + public void MixedMetadata_RedactsOnlySensitiveValues() + { + var input = JsonSerializer.Serialize(new + { + Handler = "CreateUserHandler", + SessionTrackingId = "sess-123", + Password = "hunter2", + ApiKey = "sk-live-abcdef", + }); + + var result = AuditMetadataSanitizer.Sanitize(input); + + using var doc = JsonDocument.Parse(result!); + var root = doc.RootElement; + root.GetProperty("Handler").GetString().Should().Be("CreateUserHandler"); + root.GetProperty("SessionTrackingId").GetString().Should().Be("sess-123"); + root.GetProperty("Password").GetString().Should().Be(Redacted); + root.GetProperty("ApiKey").GetString().Should().Be(Redacted); + result.Should().NotContain("hunter2").And.NotContain("sk-live-abcdef"); + } + + // ── Clave sensible en objeto anidado → se redacta en profundidad ── + [Fact] + public void NestedSensitiveKey_IsRedacted() + { + var input = "{\"outer\":{\"innerToken\":\"leak-me\",\"safe\":\"keep-me\"}}"; + + var result = AuditMetadataSanitizer.Sanitize(input); + + using var doc = JsonDocument.Parse(result!); + var outer = doc.RootElement.GetProperty("outer"); + outer.GetProperty("innerToken").GetString().Should().Be(Redacted); + outer.GetProperty("safe").GetString().Should().Be("keep-me"); + result.Should().NotContain("leak-me"); + } + + // ── Subárbol bajo clave sensible → se redacta entero, sin descender ── + [Fact] + public void SensitiveKeyOverObject_RedactsWholeSubtree() + { + var input = "{\"credentials\":{\"user\":\"admin\",\"pwd\":\"1234\"}}"; + + var result = AuditMetadataSanitizer.Sanitize(input); + + using var doc = JsonDocument.Parse(result!); + doc.RootElement.GetProperty("credentials").ValueKind.Should().Be(JsonValueKind.String); + doc.RootElement.GetProperty("credentials").GetString().Should().Be(Redacted); + result.Should().NotContain("admin").And.NotContain("1234"); + } + + // ── Arreglo de objetos con claves sensibles → cada elemento se sanea ── + [Fact] + public void ArrayOfObjects_SanitizesEachElement() + { + var input = "{\"items\":[{\"name\":\"a\",\"secret\":\"s1\"},{\"name\":\"b\",\"secret\":\"s2\"}]}"; + + var result = AuditMetadataSanitizer.Sanitize(input); + + using var doc = JsonDocument.Parse(result!); + var items = doc.RootElement.GetProperty("items"); + items[0].GetProperty("name").GetString().Should().Be("a"); + items[0].GetProperty("secret").GetString().Should().Be(Redacted); + items[1].GetProperty("secret").GetString().Should().Be(Redacted); + result.Should().NotContain("s1").And.NotContain("s2"); + } + + // ── JSON vacío o sin claves sensibles → equivalente al de entrada ── + [Fact] + public void EmptyJsonObject_IsReturnedAsEmptyObject() + { + var result = AuditMetadataSanitizer.Sanitize("{}"); + using var doc = JsonDocument.Parse(result!); + doc.RootElement.ValueKind.Should().Be(JsonValueKind.Object); + doc.RootElement.EnumerateObject().Should().BeEmpty(); + } + + // ── No-JSON → sin fallo, devuelto tal cual (ambas vías reales garantizan JSON válido) ── + [Fact] + public void NonJsonMetadata_IsReturnedUnchanged() + { + const string notJson = "esto no es json"; + AuditMetadataSanitizer.Sanitize(notJson).Should().Be(notJson); + } + + // ── Literal JSON null → sin fallo ── + [Fact] + public void JsonNullLiteral_IsReturnedUnchanged() + { + AuditMetadataSanitizer.Sanitize("null").Should().Be("null"); + } + + // ── IsSensitiveKey: contrato explícito de la lista de patrones ── + [Theory] + [InlineData("password", true)] + [InlineData("PASSWORD", true)] + [InlineData("passwordHash", true)] + [InlineData("pwd", true)] + [InlineData("api_key", true)] + [InlineData("apiKey", true)] + [InlineData("bearerToken", true)] + [InlineData("pin", true)] + [InlineData("key", true)] + [InlineData("cred", true)] + [InlineData("authorization", true)] + [InlineData("handler", false)] + [InlineData("correlationId", false)] + [InlineData("shipping", false)] + [InlineData("monkey", false)] + [InlineData("", false)] + [InlineData(null, false)] + public void IsSensitiveKey_MatchesPatternList(string? key, bool expected) + { + AuditMetadataSanitizer.IsSensitiveKey(key).Should().Be(expected); + } +} diff --git a/src/apps/ums.api/Ums.Application.Test/Common/Aop/AuthorizationAspectTests.cs b/src/apps/ums.api/Ums.Application.Test/Common/Aop/AuthorizationAspectTests.cs index 22f74063..710ffa8d 100644 --- a/src/apps/ums.api/Ums.Application.Test/Common/Aop/AuthorizationAspectTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Common/Aop/AuthorizationAspectTests.cs @@ -1,3 +1,4 @@ +#pragma warning disable S1186 using System; using System.Reflection; using BeyondNetCode.Shell.Aop; @@ -26,6 +27,10 @@ public AuthorizationAspectTests() _joinPointMock.Setup(j => j.MethodInfo).Returns(targetType.GetMethod(nameof(CreateUserCommandHandler.Handle))!); } + // --------------------------------------------------------------------- + // Camino permitido (cobertura preexistente) + // --------------------------------------------------------------------- + [Fact] public void Apply_WhenNoAttribute_Proceeds() { @@ -50,20 +55,147 @@ public void Apply_WithAttributeAndPermission_Proceeds() // Act _sut.Apply(_joinPointMock.Object); - + // Assert _joinPointMock.Verify(j => j.Proceed(), Times.Once); } + + // --------------------------------------------------------------------- + // Ruta de DENEGACIÓN (G-080). El aspecto es el punto de control de + // acceso en runtime: si falta el permiso requerido debe DENEGAR + // lanzando UnauthorizedAccessException y NO invocar Proceed(). + // Evidencia del mecanismo real de negativa: + // AuthorizationAspect.cs:68-72 → throw new UnauthorizedAccessException(...) + // --------------------------------------------------------------------- + + [Fact] + public void Apply_WithAttributeAndMissingPermission_DeniesAndDoesNotProceed() + { + // Arrange + // CreateUserCommandHandler lleva [AuthorizationAspect]; el aspecto + // infiere el permiso "user:create" por convención de nombre. + // El contexto NO concede ese permiso (HasPermission → false por defecto). + _userContextMock.Setup(u => u.HasPermission("user:create")).Returns(false); + + // Act + Action act = () => _sut.Apply(_joinPointMock.Object); + + // Assert: deniega con UnauthorizedAccessException y no procede al handler. + act.Should().Throw() + .WithMessage("*user:create*"); + _joinPointMock.Verify(j => j.Proceed(), Times.Never); + } + + [Fact] + public void Apply_WithGrantForUnrelatedPermission_DeniesRequiredAction() + { + // Arrange + // El modelo de permisos (UserContext.HasPermission) es allow-list puro + // (grant-only, default-deny): no existe precedencia "deny-wins" porque no + // hay lista de denegación explícita. La propiedad de seguridad equivalente + // y verificable aquí es que poseer un permiso NO relacionado ("user:read") + // jamás autoriza una acción distinta ("user:create"): no hay fuga de + // privilegios entre permisos. + _userContextMock.Setup(u => u.HasPermission("user:read")).Returns(true); + _userContextMock.Setup(u => u.HasPermission("user:create")).Returns(false); + + // Act + Action act = () => _sut.Apply(_joinPointMock.Object); + + // Assert + act.Should().Throw() + .WithMessage("*user:create*"); + _joinPointMock.Verify(j => j.Proceed(), Times.Never); + } + + [Fact] + public void Apply_WithExplicitResourceActionAndMissingPermission_Denies() + { + // Arrange + // Handler con permiso EXPLÍCITO [AuthorizationAspect("branch", "delete")] + // (ejercita la rama de códigos explícitos de AuthorizationAspect.cs:41-42, + // distinta de la inferencia por convención). Simula además el aislamiento + // de scope: el contexto posee un permiso de OTRO recurso/rama ("user:create") + // pero eso no autoriza "branch:delete". + var targetType = typeof(CloseBranchCommandHandler); + _joinPointMock.Setup(j => j.TargetType).Returns(targetType); + _joinPointMock.Setup(j => j.MethodInfo).Returns(targetType.GetMethod(nameof(CloseBranchCommandHandler.Handle))!); + + _userContextMock.Setup(u => u.HasPermission("user:create")).Returns(true); // permiso de otro recurso/rama + _userContextMock.Setup(u => u.HasPermission("branch:delete")).Returns(false); // el requerido: no concedido + + // Act + Action act = () => _sut.Apply(_joinPointMock.Object); + + // Assert + act.Should().Throw() + .WithMessage("*branch:delete*"); + _joinPointMock.Verify(j => j.Proceed(), Times.Never); + } + + // --------------------------------------------------------------------- + // DEFECTO DE SEGURIDAD (fail-open) — GUARDA DE REGRESIÓN (G-098, CORREGIDO). + // + // Si un handler lleva [AuthorizationAspect] (intención explícita de + // protegerlo) pero el aspecto NO puede resolver el permiso —el nombre no + // casa la convención Create/Update/Delete/Get/List y no se pasaron + // ResourceCode/ActionCode— la implementación DEBE caer en el `else` de + // AuthorizationAspect y DENEGAR (fail-closed): un método marcado como + // protegido no puede ejecutarse sin control de acceso. + // + // Antes de G-098 el aspecto ejecutaba Proceed() SIN comprobar permiso + // (fail-open), una vía real de escalada de privilegios. Esta prueba fija el + // comportamiento SEGURO (debe DENEGAR y NO proceder) y actúa como testigo de + // no-regresión. NO debe "arreglarse" debilitando la aserción. + // --------------------------------------------------------------------- + + [Fact] + public void Apply_WithAttributeButUninferrablePermission_ShouldDenyButFailsOpen() + { + // Arrange + // ApproveBranchCommandHandler: lleva [AuthorizationAspect] sin códigos y + // el verbo "Approve" no casa la convención → el permiso queda indeterminado. + var targetType = typeof(ApproveBranchCommandHandler); + _joinPointMock.Setup(j => j.TargetType).Returns(targetType); + _joinPointMock.Setup(j => j.MethodInfo).Returns(targetType.GetMethod(nameof(ApproveBranchCommandHandler.Handle))!); + + // Act + Action act = () => _sut.Apply(_joinPointMock.Object); + + // Assert (comportamiento SEGURO exigido: DENIEGA fail-closed y NO procede) + act.Should().Throw( + "un handler explícitamente protegido cuyo permiso no puede resolverse DEBE denegarse (fail-closed)"); + _joinPointMock.Verify(j => j.Proceed(), Times.Never); + } } // Clases simuladas para el test [AuthorizationAspect] -public class CreateUserCommandHandler +public class CreateUserCommandHandler +{ + public void Handle() { } +} + +public class UnprotectedCommandHandler { public void Handle() { } } -public class UnprotectedCommandHandler +// Permiso explícito (recurso/acción declarados en el atributo). Es un DOBLE local del aspecto, no +// el manejador real; se renombra a «Close» para no dejar vivo el vocabulario del borrado físico que +// ADR-0164 retiró, aunque el permiso siga llamándose "branch:delete" (el verbo HTTP es DELETE). +[AuthorizationAspect("branch", "delete")] +public class CloseBranchCommandHandler { public void Handle() { } } + +// Protegido pero con permiso indeterminable (verbo fuera de convención, +// sin ResourceCode/ActionCode): dispara la rama fail-open del aspecto. +[AuthorizationAspect] +public class ApproveBranchCommandHandler +{ + public void Handle() { } +} + +#pragma warning restore S1186 diff --git a/src/apps/ums.api/Ums.Application.Test/Common/Aop/TenantValidationAspectTests.cs b/src/apps/ums.api/Ums.Application.Test/Common/Aop/TenantValidationAspectTests.cs new file mode 100644 index 00000000..ee54cee4 --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/Common/Aop/TenantValidationAspectTests.cs @@ -0,0 +1,197 @@ +#pragma warning disable S1186 +using System; +using BeyondNetCode.Shell.Aop; +using FluentAssertions; +using Moq; +using Ums.Application.Common.Aop; +using Ums.Application.Common.Interfaces; +using Xunit; + +namespace Ums.Application.Test.Common.Aop; + +public class TenantValidationAspectTests +{ + private readonly Mock _userContextMock; + private readonly TenantValidationAspect _sut; + private readonly Mock _joinPointMock; + + public TenantValidationAspectTests() + { + _userContextMock = new Mock(); + _sut = new TenantValidationAspect(_userContextMock.Object); + _joinPointMock = new Mock(); + } + + private void SetHandler(Type targetType, string methodName) + { + _joinPointMock.Setup(j => j.TargetType).Returns(targetType); + _joinPointMock.Setup(j => j.MethodInfo).Returns(targetType.GetMethod(methodName)!); + } + + // --------------------------------------------------------------------- + // Camino sin opt-in: el handler NO lleva [TenantValidationAspect] → el + // aspecto no valida y procede (el atributo se resuelve por MethodInfo). + // --------------------------------------------------------------------- + + [Fact] + public void Apply_WhenNoAttribute_Proceeds() + { + // Arrange + SetHandler(typeof(UnscopedHandler), nameof(UnscopedHandler.Handle)); + _joinPointMock.Setup(j => j.Arguments).Returns(new object?[] { new TenantScopedRequest { TenantId = "T1" } }); + _userContextMock.Setup(u => u.TenantId).Returns("T2"); // aunque difiera, sin atributo no se valida + + // Act + _sut.Apply(_joinPointMock.Object); + + // Assert + _joinPointMock.Verify(j => j.Proceed(), Times.Once); + } + + // --------------------------------------------------------------------- + // Flujo legítimo SIN inquilino (G-102): actor global / pre-inquilino + // (signup, creación de inquilino, admin de plataforma). El usuario carece + // de TenantId → no hay frontera que cruzar → PROCEDE a propósito. + // Esta prueba fija la excepción legítima: endurecer el aspecto NO debe + // romper los flujos sin inquilino (regla de dominio, no fail-open). + // --------------------------------------------------------------------- + + [Fact] + public void Apply_TenantlessCaller_Proceeds() + { + // Arrange: atributo presente, pero el usuario no está vinculado a un inquilino. + SetHandler(typeof(TenantScopedHandler), nameof(TenantScopedHandler.Handle)); + _joinPointMock.Setup(j => j.Arguments).Returns(new object?[] { new TenantScopedRequest { TenantId = "T1" } }); + _userContextMock.Setup(u => u.TenantId).Returns((string?)null); + + // Act + _sut.Apply(_joinPointMock.Object); + + // Assert + _joinPointMock.Verify(j => j.Proceed(), Times.Once); + } + + // --------------------------------------------------------------------- + // Camino permitido: usuario e inquilino de la petición coinciden. + // --------------------------------------------------------------------- + + [Fact] + public void Apply_MatchingTenant_Proceeds() + { + // Arrange + SetHandler(typeof(TenantScopedHandler), nameof(TenantScopedHandler.Handle)); + _joinPointMock.Setup(j => j.Arguments).Returns(new object?[] { new TenantScopedRequest { TenantId = "T1" } }); + _userContextMock.Setup(u => u.TenantId).Returns("T1"); + + // Act + _sut.Apply(_joinPointMock.Object); + + // Assert + _joinPointMock.Verify(j => j.Proceed(), Times.Once); + } + + // --------------------------------------------------------------------- + // Ruta de DENEGACIÓN por cruce de inquilino (comportamiento existente): + // usuario de un inquilino apunta a una petición de OTRO inquilino → DENIEGA. + // --------------------------------------------------------------------- + + [Fact] + public void Apply_CrossTenant_DeniesAndDoesNotProceed() + { + // Arrange + SetHandler(typeof(TenantScopedHandler), nameof(TenantScopedHandler.Handle)); + _joinPointMock.Setup(j => j.Arguments).Returns(new object?[] { new TenantScopedRequest { TenantId = "T2" } }); + _userContextMock.Setup(u => u.TenantId).Returns("T1"); + + // Act + Action act = () => _sut.Apply(_joinPointMock.Object); + + // Assert + act.Should().Throw(); + _joinPointMock.Verify(j => j.Proceed(), Times.Never); + } + + // --------------------------------------------------------------------- + // DEFECTO DE SEGURIDAD (soft fail-open) — GUARDA DE REGRESIÓN (G-102). + // + // Un handler VINCULADO a un inquilino (usuario con TenantId) marcado con + // [TenantValidationAspect] (intención explícita de validar inquilino) cuya + // petición NO permite determinar el inquilino objetivo (TenantId nulo/vacío, + // o el tipo de la petición carece de la propiedad TenantId) DEBE DENEGAR + // (fail-closed): el inquilino no puede confirmarse y dejar pasar sería un + // cruce indebido de inquilinos. + // + // Antes de G-102 el aspecto solo denegaba cuando AMBOS inquilinos estaban + // presentes y diferían; si el inquilino de la petición no podía determinarse, + // ejecutaba Proceed() SIN validar (soft fail-open). Esta prueba fija el + // comportamiento SEGURO (DEBE DENEGAR y NO proceder) y actúa como testigo de + // no-regresión. NO debe "arreglarse" debilitando la aserción (p. ej. esperando + // Proceed): eso reintroduciría el fail-open. + // --------------------------------------------------------------------- + + [Fact] + public void Apply_TenantBoundCallerButRequestTenantEmpty_DeniesFailClosed() + { + // Arrange: usuario con inquilino "T1"; la petición tiene TenantId vacío + // (inquilino objetivo indeterminado). + SetHandler(typeof(TenantScopedHandler), nameof(TenantScopedHandler.Handle)); + _joinPointMock.Setup(j => j.Arguments).Returns(new object?[] { new TenantScopedRequest { TenantId = "" } }); + _userContextMock.Setup(u => u.TenantId).Returns("T1"); + + // Act + Action act = () => _sut.Apply(_joinPointMock.Object); + + // Assert (comportamiento SEGURO exigido: DENIEGA fail-closed y NO procede) + act.Should().Throw( + "un handler tenant-scoped cuyo inquilino objetivo no puede confirmarse DEBE denegarse (fail-closed)"); + _joinPointMock.Verify(j => j.Proceed(), Times.Never); + } + + [Fact] + public void Apply_TenantBoundCallerButRequestHasNoTenantIdProperty_DeniesFailClosed() + { + // Arrange: usuario con inquilino "T1"; el tipo de la petición NO expone + // la propiedad TenantId → el inquilino objetivo no puede determinarse. + SetHandler(typeof(TenantScopedHandler), nameof(TenantScopedHandler.Handle)); + _joinPointMock.Setup(j => j.Arguments).Returns(new object?[] { new NoTenantRequest { Name = "x" } }); + _userContextMock.Setup(u => u.TenantId).Returns("T1"); + + // Act + Action act = () => _sut.Apply(_joinPointMock.Object); + + // Assert (fail-closed: no hay inquilino que confirmar → DENIEGA y NO procede) + act.Should().Throw( + "si la petición no expone TenantId, el inquilino no puede confirmarse y DEBE denegarse (fail-closed)"); + _joinPointMock.Verify(j => j.Proceed(), Times.Never); + } +} + +// --------------------------------------------------------------------------- +// Clases simuladas. GetAttribute (AbstractAspect) resuelve el atributo por +// MethodInfo → [TenantValidationAspect] se coloca en el MÉTODO Handle. +// --------------------------------------------------------------------------- + +public class TenantScopedRequest +{ + public string? TenantId { get; set; } +} + +public class NoTenantRequest +{ + public string Name { get; set; } = string.Empty; +} + +// Marcado como tenant-scoped: dispara la validación de inquilino del aspecto. +public class TenantScopedHandler +{ + [TenantValidationAspect] + public void Handle(TenantScopedRequest request) { } +} + +// Sin marcar: el aspecto no valida y procede. +public class UnscopedHandler +{ + public void Handle(TenantScopedRequest request) { } +} + +#pragma warning restore S1186 diff --git a/src/apps/ums.api/Ums.Application.Test/Common/Reliability/IntegrationEventOutboxDispatchTests.cs b/src/apps/ums.api/Ums.Application.Test/Common/Reliability/IntegrationEventOutboxDispatchTests.cs new file mode 100644 index 00000000..a5db323d --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/Common/Reliability/IntegrationEventOutboxDispatchTests.cs @@ -0,0 +1,236 @@ +namespace Ums.Application.Test.Common.Reliability; + +using Ums.Application.Common.Interfaces; +using Ums.Application.IGA.RolePromotion.Commands; +using Ums.Domain.IGA; +using Ums.Domain.Kernel; +using Ums.Domain.Kernel.ValueObjects; +using Xunit; + +using RolePromotionRequestAggregate = Ums.Domain.IGA.RolePromotionRequest.RolePromotionRequest; + +/// +/// Prueba EXPLÍCITA del despacho POST-commit de eventos de INTEGRACIÓN por el Transactional Outbox +/// (D-016 / ADR-0098 D4/D7). Complementa la cobertura indirecta de +/// (atomicidad del change set) y de +/// FunctionalTransactionTests (desenlace de la transacción funcional): aquí se fija, de forma +/// verificable, el invariante «no se publica un evento de integración antes del commit del agregado». +/// +/// Mecanismo real que se modela (ver UmsPlatformDbContext + MassTransitIntegrationEventPublisher +/// con UseBusOutbox()): ExecuteRolePromotionCommandHandler publica el evento por el puerto +/// ANTES de SaveEntitiesAsync. Bajo kind/prod ese publish +/// solo ESTACIONA el mensaje en las tablas del outbox dentro del mismo change set del agregado; el +/// servicio de entrega lo despacha al consumidor DESPUÉS del commit. Si la transacción del agregado hace +/// rollback, la fila del outbox se revierte con el resto del change set y el evento NUNCA se entrega. +/// +/// Limitación del harness (documentada, no oculta): las pruebas de aplicación usan dobles en memoria y +/// no ejercitan el bus-outbox EF real ni el bróker, así que el timing físico de la entrega no +/// es observable aquí. Se cubre el invariante al nivel donde SÍ es determinista: un doble del outbox +/// () que estaciona en el publish y solo entrega al confirmar la +/// transacción del agregado (o descarta al hacer rollback). La entrega física post-commit sobre el +/// bus-outbox real se valida en la suite de integración con Testcontainers. +/// +public sealed class IntegrationEventOutboxDispatchTests +{ + private readonly Mock _repo = new(); + private readonly Mock _scope = new(); + private readonly Mock _uow = new(); + private readonly Mock _ctx = new(); + + private readonly Guid _tenant = Guid.NewGuid(); + private readonly Guid _target = Guid.NewGuid(); + private readonly Guid _requester = Guid.NewGuid(); + private readonly Guid _approver = Guid.NewGuid(); + private readonly Guid _executor = Guid.NewGuid(); + private readonly Guid _currentRole = Guid.NewGuid(); + private readonly Guid _targetRole = Guid.NewGuid(); + + public IntegrationEventOutboxDispatchTests() + { + _repo.Setup(r => r.UnitOfWork).Returns(_uow.Object); + // Alcance de inquilino no restrictivo por defecto (administrador interno). + _scope.Setup(s => s.ResolveQueryScope()).Returns((Guid?)null); + } + + // ========================================================================= + // Doble del Transactional Outbox + // ========================================================================= + + /// + /// Doble de prueba que reproduce la semántica del Transactional Outbox de MassTransit + /// (UseBusOutbox()): solo ESTACIONA el evento dentro de la + /// transacción del agregado; la entrega al consumidor ocurre en + /// (post-commit). Un revierte lo estacionado — nunca se entrega. + /// El registra el orden para verificar que el publish precede al commit. + /// + private sealed class TransactionalOutboxSpy(IList timeline) : IIntegrationEventPublisher + { + private readonly List _staged = []; + private readonly List _delivered = []; + + /// Eventos ya ENTREGADOS al consumidor/bróker (post-commit). + public IReadOnlyList Delivered => _delivered; + + /// Eventos aún estacionados en el outbox (pendientes de commit). + public int StagedCount => _staged.Count; + + /// Nº de eventos ya entregados en el instante del último publish (debe ser 0: nada pre-commit). + public int DeliveredCountAtLastPublish { get; private set; } = -1; + + public Task PublishAsync(IIntegrationEvent integrationEvent, CancellationToken cancellationToken = default) + { + // El outbox NO entrega aquí: estaciona el mensaje en el change set del agregado. + DeliveredCountAtLastPublish = _delivered.Count; + timeline.Add("estacionar"); + _staged.Add(integrationEvent); + return Task.CompletedTask; + } + + /// La transacción del agregado CONFIRMÓ: el servicio de entrega despacha lo estacionado. + public void CommitTransaction() + { + timeline.Add("commit"); + _delivered.AddRange(_staged); + _staged.Clear(); + } + + /// La transacción del agregado hizo ROLLBACK: las filas del outbox se revierten. + public void RollbackTransaction() + { + timeline.Add("rollback"); + _staged.Clear(); + } + } + + // ========================================================================= + // O01 — Éxito: el evento se estaciona en el outbox y se entrega SOLO tras el commit + // ========================================================================= + + /// O01: en el camino feliz, ExecuteRolePromotionCommandHandler publica el evento de + /// integración (estacionado) ANTES de SaveEntitiesAsync, y la entrega al consumidor ocurre + /// únicamente al confirmar la transacción del agregado. En el instante del publish —dentro de la + /// tx— no se había entregado nada: no hay despacho a mitad de la transacción del agregado. + [Fact] + public async Task El_evento_de_integracion_se_estaciona_y_se_entrega_solo_tras_el_commit() + { + var timeline = new List(); + var outbox = new TransactionalOutboxSpy(timeline); + + ActingAs(_executor); + var req = InApproved(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + + // El commit de la transacción del agregado (SaveEntitiesAsync) confirma el change set —agregado + // + fila del outbox— y habilita la entrega POST-commit. + _uow.Setup(u => u.SaveEntitiesAsync(It.IsAny())) + .Callback(() => outbox.CommitTransaction()) + .ReturnsAsync(true); + + var handler = new ExecuteRolePromotionCommandHandler(_repo.Object, _scope.Object, _ctx.Object, outbox); + var result = await handler.Handle(new ExecuteRolePromotionCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal("Executed", req.Status.Name); + + // 1) El evento se ESTACIONA antes del commit y se ENTREGA en el commit (nunca a mitad de la tx). + Assert.Equal(["estacionar", "commit"], timeline); + // 2) En el instante del publish (dentro de la tx) NADA se había entregado aún. + Assert.Equal(0, outbox.DeliveredCountAtLastPublish); + // 3) Tras el commit, el evento se entregó exactamente una vez y el outbox quedó vacío. + Assert.Single(outbox.Delivered); + Assert.IsType(outbox.Delivered[0]); + Assert.Equal(0, outbox.StagedCount); + } + + // ========================================================================= + // O02 — Rollback: si el commit del agregado falla, el evento NO se entrega + // ========================================================================= + + /// O02: si SaveEntitiesAsync falla (la transacción del agregado hace rollback), el + /// evento —ya estacionado en el outbox dentro de la tx— se revierte con el resto del change set y + /// NUNCA llega al consumidor. Es el invariante clave: no hay evento de integración sin commit del + /// agregado. + [Fact] + public async Task Si_el_commit_del_agregado_falla_el_evento_de_integracion_no_se_entrega() + { + var timeline = new List(); + var outbox = new TransactionalOutboxSpy(timeline); + + ActingAs(_executor); + var req = InApproved(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + + // El commit falla → rollback: las filas del outbox se revierten con el change set del agregado + // (atomicidad del outbox transaccional, ADR-0098 D4/D7). + _uow.Setup(u => u.SaveEntitiesAsync(It.IsAny())) + .Callback(() => outbox.RollbackTransaction()) + .ThrowsAsync(new InvalidOperationException("Fallo de infraestructura al confirmar la transacción del agregado.")); + + var handler = new ExecuteRolePromotionCommandHandler(_repo.Object, _scope.Object, _ctx.Object, outbox); + + await Assert.ThrowsAsync(() => + handler.Handle(new ExecuteRolePromotionCommand(Guid.NewGuid()), CancellationToken.None)); + + // El evento se estacionó dentro de la tx, pero al fallar el commit NUNCA se entrega. + Assert.Equal(["estacionar", "rollback"], timeline); + Assert.Empty(outbox.Delivered); + Assert.Equal(0, outbox.StagedCount); + } + + // ========================================================================= + // O03 — Rechazo de dominio: nada se estaciona ni se entrega, ni se intenta commit + // ========================================================================= + + /// O03: cuando la operación del agregado se rechaza antes de publicar (guarda SoD de + /// ADR-UMS-096: el ejecutor coincide con el aprobador), el handler retorna temprano — no se estaciona + /// evento alguno en el outbox, no se entrega nada y no se intenta el commit. Confirma que el evento + /// de integración depende de una transición de estado válida del agregado. + [Fact] + public async Task Si_la_operacion_del_agregado_se_rechaza_no_se_estaciona_ni_se_entrega_ningun_evento() + { + var timeline = new List(); + var outbox = new TransactionalOutboxSpy(timeline); + + // El ejecutor coincide con el aprobador → viola SoD (INV-RPR3 endurecida, ADR-UMS-096). + ActingAs(_approver); + var req = InApproved(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + + var handler = new ExecuteRolePromotionCommandHandler(_repo.Object, _scope.Object, _ctx.Object, outbox); + var result = await handler.Handle(new ExecuteRolePromotionCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains("segregación de funciones", result.Error, StringComparison.OrdinalIgnoreCase); + Assert.Equal("Approved", req.Status.Name); // no transiciona + + // Ni se estaciona ni se entrega evento alguno, y no se intenta commit. + Assert.Empty(timeline); + Assert.Empty(outbox.Delivered); + Assert.Equal(0, outbox.StagedCount); + _uow.Verify(u => u.SaveEntitiesAsync(It.IsAny()), Times.Never); + } + + // ========================================================================= + // Helpers + // ========================================================================= + + private void ActingAs(Guid userId) => _ctx.Setup(c => c.UserId).Returns(userId.ToString()); + + private RolePromotionRequestAggregate NewDraft() => + RolePromotionRequestAggregate.Create( + TenantId.Load(_tenant), + UserId.Load(_target), + UserId.Load(_requester), + RoleId.Load(_currentRole), + RoleId.Load(_targetRole), + ActorId.Create(_requester.ToString())).Value; + + private RolePromotionRequestAggregate InApproved(int riskScore = 30) + { + var req = NewDraft(); + req.Submit(RiskScore.Create(riskScore).Value, ActorId.Create(_requester.ToString())); + req.ConfirmEligibility(true, ActorId.Create("sistema")); + req.ManagerApprove(UserId.Load(_approver), ActorId.Create(_approver.ToString())); + return req; + } +} diff --git a/src/apps/ums.api/Ums.Application.Test/Common/Reliability/TransactionalAtomicityTests.cs b/src/apps/ums.api/Ums.Application.Test/Common/Reliability/TransactionalAtomicityTests.cs index 989371c0..878a3044 100644 --- a/src/apps/ums.api/Ums.Application.Test/Common/Reliability/TransactionalAtomicityTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Common/Reliability/TransactionalAtomicityTests.cs @@ -1,5 +1,7 @@ namespace Ums.Application.Test.Common.Reliability; +#pragma warning disable S125 + using Ums.Application.Common.Interfaces; using Ums.Application.Configuration.AppConfiguration.Commands; using Ums.Application.Configuration.Services; @@ -394,8 +396,11 @@ public async Task T12_AuditFields_OnCreate_CarryActorFromUserContext() var audit = capturedAggregate!.Props.Audit.GetValue(); Assert.Equal("audited-actor-007", audit.CreatedBy); - // AuditValueObject.Create uses DateTime.Today.ToUniversalTime() (midnight UTC) - Assert.Equal(DateTime.Today.ToUniversalTime().Date, audit.CreatedAt.Date); + // El audit se estampa con DateTime.UtcNow: comparar contra la fecha UTC, no la + // local. (Antes se comparaba con DateTime.Today.ToUniversalTime().Date —derivado + // de la fecha local—, lo que fallaba en la ventana UTC 00:00–05:00, cuando la + // fecha local de Lima (UTC-5) aún es la del día anterior.) + Assert.Equal(DateTime.UtcNow.Date, audit.CreatedAt.Date); } // ========================================================================= diff --git a/src/apps/ums.api/Ums.Application.Test/Common/Services/TenantScopePolicyTests.cs b/src/apps/ums.api/Ums.Application.Test/Common/Services/TenantScopePolicyTests.cs index 85e0dde7..0b53a617 100644 --- a/src/apps/ums.api/Ums.Application.Test/Common/Services/TenantScopePolicyTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Common/Services/TenantScopePolicyTests.cs @@ -28,6 +28,19 @@ private static Tenant BuildTenant(bool isManagementOwner) tenantId: TenantId.Load(Guid.Parse("11111111-1111-1111-1111-111111111111")), isManagementOwner: isManagementOwner).Value; + [Fact] + public void ResolveQueryScope_ReturnsNull_WhenInternalAdmin() + { + // Internal admin has cross-tenant visibility: no scope filter, even when its own + // OrganizationId is populated (e.g. the INTERNAL_ADMIN dev tenant). + _tenantContext.SetupGet(x => x.IsInternalAdmin).Returns(true); + _tenantContext.SetupGet(x => x.OrganizationId).Returns(Guid.NewGuid()); + + var result = CreateSut().ResolveQueryScope(); + + Assert.Null(result); + } + [Fact] public void ResolveQueryScope_ReturnsOrganizationId_WhenContextHasOne() { diff --git a/src/apps/ums.api/Ums.Application.Test/Configuration/AppConfiguration/AppConfigurationCommandHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Configuration/AppConfiguration/AppConfigurationCommandHandlerTests.cs index 9df80fad..dab16159 100644 --- a/src/apps/ums.api/Ums.Application.Test/Configuration/AppConfiguration/AppConfigurationCommandHandlerTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Configuration/AppConfiguration/AppConfigurationCommandHandlerTests.cs @@ -327,6 +327,93 @@ public async Task Archive_WhenUnauthenticated_ReturnsFailure() #endregion + // ========================================================================= + #region DeleteAppConfigurationCommandHandler (borrado LÓGICO) + // ========================================================================= + + [Fact] + public async Task Delete_WhenExists_MarcaEliminadaYNoRetiraLaFila() + { + _ctx.Setup(u => u.UserId).Returns("user-001"); + var config = MakePublished(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(config); + + var handler = new DeleteAppConfigurationCommandHandler(_repo.Object, _ctx.Object, _configProvider.Object); + var result = await handler.Handle(new DeleteAppConfigurationCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsSuccess); + // La política del propietario, expresada como aserción: el agregado pasa al estado terminal + // y se persiste con UpdateAsync. El repositorio no expone ninguna baja física. + Assert.Equal(ConfigStatus.Deleted, config.Status); + _repo.Verify(r => r.UpdateAsync(config, It.IsAny()), Times.Once); + _uow.Verify(u => u.SaveEntitiesAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Delete_DesalojaLaCache() + { + _ctx.Setup(u => u.UserId).Returns("user-001"); + var config = MakePublished(); // configuración de inquilino → recarga acotada al inquilino + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(config); + + var handler = new DeleteAppConfigurationCommandHandler(_repo.Object, _ctx.Object, _configProvider.Object); + var result = await handler.Handle(new DeleteAppConfigurationCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsSuccess); + // Si la configuración deja de resolver, la caché tiene que enterarse igual: la fila sobrevive + // pero su valor ya no participa en la resolución. + _configProvider.Verify( + p => p.ReloadTenantAsync(config.Props.TenantId!.GetValue(), It.IsAny()), + Times.Once); + } + + [Fact] + public async Task Delete_WhenNotFound_ReturnsFailure() + { + _ctx.Setup(u => u.UserId).Returns("user-001"); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((AppConfigurationAggregate?)null); + + var handler = new DeleteAppConfigurationCommandHandler(_repo.Object, _ctx.Object, _configProvider.Object); + var result = await handler.Handle(new DeleteAppConfigurationCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains("not found", result.Error); + _repo.Verify(r => r.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Delete_SobreYaEliminada_ReturnsFailure() + { + _ctx.Setup(u => u.UserId).Returns("user-001"); + var config = MakePublished(); + config.Delete(ActorId.Create("user-000")); // ya eliminada por otro actor + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(config); + + var handler = new DeleteAppConfigurationCommandHandler(_repo.Object, _ctx.Object, _configProvider.Object); + var result = await handler.Handle(new DeleteAppConfigurationCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Configuration.AppConfigAlreadyDeleted, result.Error); + } + + [Fact] + public async Task Delete_WhenUnauthenticated_ReturnsFailure() + { + _ctx.Setup(u => u.UserId).Returns(""); + + var handler = new DeleteAppConfigurationCommandHandler(_repo.Object, _ctx.Object, _configProvider.Object); + var result = await handler.Handle(new DeleteAppConfigurationCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains("Authenticated user is required", result.Error); + } + + #endregion + // ========================================================================= #region UpdateAppConfigurationCommandHandler // ========================================================================= diff --git a/src/apps/ums.api/Ums.Application.Test/Configuration/AvisoDeConfiguracionEntreReplicasTests.cs b/src/apps/ums.api/Ums.Application.Test/Configuration/AvisoDeConfiguracionEntreReplicasTests.cs new file mode 100644 index 00000000..b0d50e11 --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/Configuration/AvisoDeConfiguracionEntreReplicasTests.cs @@ -0,0 +1,74 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using StackExchange.Redis; +using Ums.Infrastructure.Configuration; +using Xunit; + +namespace Ums.Application.Test.Configuration; + +/// +/// El aviso de cambio de configuración sale SIEMPRE, también cuando este pod no tenía nada +/// guardado de ese inquilino. +/// +/// POR QUÉ EXISTE. El aviso estaba condicionado a que la invalidación local hubiera quitado +/// algo, y esa condición no se cumple justo en el caso que importa: un inquilino dado de alta +/// DESPUÉS del arranque no está en la memoria del pod, así que no había nada que quitar y el aviso +/// nunca salía. Los demás pods no se enteraban de su configuración nunca, hasta reiniciar. +/// Medido en vivo con dos réplicas: al publicar una capacidad para un inquilino recién creado, el +/// pod que la publicó la aplicaba y el otro no. +/// +/// La prueba mira el CANAL, no el estado local: lo que se rompió no fue el borrado —ese +/// funcionaba— sino el aviso. Comprobar que el diccionario quedó vacío habría pasado en verde con +/// el defecto puesto. +/// +public sealed class AvisoDeConfiguracionEntreReplicasTests +{ + private static (RedisConfigurationCache Cache, List Avisos) CrearCache() + { + var avisos = new List(); + + // `PublishAsync` y no `Publish`: el aviso se emite sin esperar respuesta para no bloquear + // a quien escribe la configuración. Espiar el método equivocado deja la prueba en verde + // pase lo que pase, porque la lista nunca se llena. + var suscriptor = new Mock(); + suscriptor + .Setup(s => s.PublishAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((canal, _, _) => avisos.Add(canal.ToString())) + .ReturnsAsync(1L); + + var redis = new Mock(); + redis.Setup(r => r.GetSubscriber(It.IsAny())).Returns(suscriptor.Object); + + var cache = new RedisConfigurationCache( + redis.Object, + NullLogger.Instance, + new ServiceCollection().BuildServiceProvider().GetRequiredService()); + + return (cache, avisos); + } + + [Fact] + public void Invalidar_Un_Inquilino_Que_Este_Pod_No_Conoce_Avisa_Igual() + { + // Es el inquilino creado sobre la marcha: este pod nunca guardó nada suyo. + var (cache, avisos) = CrearCache(); + + cache.InvalidateTenant(Guid.NewGuid()); + + Assert.Contains(avisos, canal => canal.EndsWith(":tenant", StringComparison.Ordinal)); + } + + [Fact] + public void Invalidar_Un_Inquilino_Conocido_Sigue_Avisando() + { + // La mitad que ya funcionaba: no se arregla una rompiendo la otra. + var (cache, avisos) = CrearCache(); + var inquilino = Guid.NewGuid(); + cache.PopulateTenant(inquilino, []); + + cache.InvalidateTenant(inquilino); + + Assert.Contains(avisos, canal => canal.EndsWith(":tenant", StringComparison.Ordinal)); + } +} diff --git a/src/apps/ums.api/Ums.Application.Test/Configuration/Parameter/DeleteParameterDefinitionCommandHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Configuration/Parameter/DeleteParameterDefinitionCommandHandlerTests.cs new file mode 100644 index 00000000..6df57088 --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/Configuration/Parameter/DeleteParameterDefinitionCommandHandlerTests.cs @@ -0,0 +1,144 @@ +namespace Ums.Application.Test.Configuration.Parameter; + +using Ums.Application.Common.Interfaces; +using Ums.Application.Configuration.Parameter.Commands; +using Ums.Domain.Configuration; +using Ums.Domain.Configuration.Parameter; +using Ums.Domain.Configuration.Parameter.ValueObjects; +using Xunit; + +/// +/// Tests de capa de aplicación del borrado LÓGICO de ParameterDefinition. +/// +/// La política del propietario es que el borrado físico no existe: la fila permanece y lo que +/// cambia es su estado. Estas pruebas fijan esa política en el contrato del handler: +/// – Guarda de autenticación: sin usuario, no se toca el dominio. +/// – No encontrado: una definición inexistente —o ya eliminada, que las lecturas ocultan— da NotFound. +/// – Éxito: se marca `IsDeleted` y se persiste con UpdateAsync; NO existe DeleteAsync que llamar. +/// – Regla transaccional: con dependientes VIVOS → conflicto (409) codificado como +/// BlockedOperationError, sin tocar el agregado. +/// – Con esos mismos dependientes ya eliminados lógicamente (contadores de vivos a 0) → sí borra. +/// +public class DeleteParameterDefinitionCommandHandlerTests +{ + private readonly Mock _repo = new(); + private readonly Mock _ctx = new(); + + private static ParameterDefinition MakeDefinition() => + ParameterDefinition.Create( + Code.Create("PARAM-001"), + ParameterName.Create("Parameter 1"), + Description.Create("Test parameter"), + ParameterDataType.String, + DefaultValue.Create("default"), + ParameterScope.GlobalAndTenant, + isActive: true, + isMandatory: false, + displayOrder: 1, + ActorId.Create("user-001")).Value; + + [Fact] + public async Task Delete_SinDependientes_MarcaEliminadaYNoRetiraLaFila() + { + _ctx.Setup(u => u.UserId).Returns("user-001"); + var definition = MakeDefinition(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(definition); + _repo.Setup(r => r.CountLiveGlobalValuesAsync(It.IsAny(), It.IsAny())).ReturnsAsync(0); + _repo.Setup(r => r.CountLiveTenantValuesAsync(It.IsAny(), It.IsAny())).ReturnsAsync(0); + + var handler = new DeleteParameterDefinitionCommandHandler(_repo.Object, _ctx.Object); + var result = await handler.Handle(new DeleteParameterDefinitionCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.True(definition.IsDeleted); + Assert.False(definition.IsActive); // una definición eliminada tampoco resuelve + _repo.Verify(r => r.UpdateAsync(definition, It.IsAny()), Times.Once); + _repo.Verify(r => r.SaveChangesAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Delete_WhenNotFound_ReturnsFailure() + { + _ctx.Setup(u => u.UserId).Returns("user-001"); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((ParameterDefinition?)null); + + var handler = new DeleteParameterDefinitionCommandHandler(_repo.Object, _ctx.Object); + var result = await handler.Handle(new DeleteParameterDefinitionCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Common.NotFound, result.Error); + _repo.Verify(r => r.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Delete_ConValoresGlobalesVivos_ReturnsBlockedConflict() + { + _ctx.Setup(u => u.UserId).Returns("user-001"); + var definition = MakeDefinition(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(definition); + _repo.Setup(r => r.CountLiveGlobalValuesAsync(It.IsAny(), It.IsAny())).ReturnsAsync(2); + _repo.Setup(r => r.CountLiveTenantValuesAsync(It.IsAny(), It.IsAny())).ReturnsAsync(0); + + var handler = new DeleteParameterDefinitionCommandHandler(_repo.Object, _ctx.Object); + var result = await handler.Handle(new DeleteParameterDefinitionCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Configuration.ParameterHasActiveValues, result.Error); + Assert.False(definition.IsDeleted); + _repo.Verify(r => r.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Delete_ConValoresDeInquilinoVivos_ReturnsBlockedConflict() + { + _ctx.Setup(u => u.UserId).Returns("user-001"); + var definition = MakeDefinition(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(definition); + _repo.Setup(r => r.CountLiveGlobalValuesAsync(It.IsAny(), It.IsAny())).ReturnsAsync(0); + _repo.Setup(r => r.CountLiveTenantValuesAsync(It.IsAny(), It.IsAny())).ReturnsAsync(1); + + var handler = new DeleteParameterDefinitionCommandHandler(_repo.Object, _ctx.Object); + var result = await handler.Handle(new DeleteParameterDefinitionCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Configuration.ParameterHasActiveValues, result.Error); + Assert.False(definition.IsDeleted); + _repo.Verify(r => r.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Delete_ConDependientesYaEliminadosLogicamente_SiBorra() + { + _ctx.Setup(u => u.UserId).Returns("user-001"); + var definition = MakeDefinition(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(definition); + // Existen filas dependientes en la base, pero todas están eliminadas lógicamente: los + // contadores de VIVOS devuelven 0 y la referencia deja de ser real → no bloquea. + _repo.Setup(r => r.CountLiveGlobalValuesAsync(It.IsAny(), It.IsAny())).ReturnsAsync(0); + _repo.Setup(r => r.CountLiveTenantValuesAsync(It.IsAny(), It.IsAny())).ReturnsAsync(0); + + var handler = new DeleteParameterDefinitionCommandHandler(_repo.Object, _ctx.Object); + var result = await handler.Handle(new DeleteParameterDefinitionCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.True(definition.IsDeleted); + _repo.Verify(r => r.UpdateAsync(definition, It.IsAny()), Times.Once); + } + + [Fact] + public async Task Delete_WhenUnauthenticated_ReturnsFailure() + { + _ctx.Setup(u => u.UserId).Returns(""); + + var handler = new DeleteParameterDefinitionCommandHandler(_repo.Object, _ctx.Object); + var result = await handler.Handle(new DeleteParameterDefinitionCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains("Authenticated user is required", result.Error); + } +} diff --git a/src/apps/ums.api/Ums.Application.Test/Configuration/Services/ConfigurationAuditServiceTests.cs b/src/apps/ums.api/Ums.Application.Test/Configuration/Services/ConfigurationAuditServiceTests.cs new file mode 100644 index 00000000..45449547 --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/Configuration/Services/ConfigurationAuditServiceTests.cs @@ -0,0 +1,185 @@ +namespace Ums.Application.Test.Configuration.Services; + +using System.Text.Json; +using FluentAssertions; +using Ums.Application.Common.Aop; +using Ums.Application.Configuration.Services; +using Xunit; + +/// +/// G-105 (residual de G-040#5): el saneador por-clave redacta por +/// nombre de clave y NO cubre el valor de un parámetro secreto, que aterriza bajo las claves +/// PreviousValue/NewValue (no sensibles). debe +/// redactar ese valor por clasificación (parámetro cifrado/secreto) ANTES de escribirlo en la +/// metadata de la traza inmutable (G-081), y dejar intacto el valor de los parámetros no secretos +/// (no-regresión: la auditoría de cambios de configuración sigue siendo útil). +/// +public sealed class ConfigurationAuditServiceTests +{ + private const string Redacted = AuditMetadataSanitizer.RedactionPlaceholder; + private const string SecretPlaintext = "S3cr3t-Api-Key-DO-NOT-LEAK"; + + private static readonly Guid UserId = Guid.NewGuid(); + private static readonly Guid TenantId = Guid.Parse("3fa85f64-5717-4562-b3fc-2c963f66afa6"); + private static readonly Guid RootTenantId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + + // Captura la única entrada publicada al sumidero para inspeccionar su metadata. + private static (Mock sink, Func captured) MakeSink() + { + AuditTrailEntry? entry = null; + var sink = new Mock(); + sink.Setup(s => s.PublishAsync(It.IsAny(), It.IsAny())) + .Callback((e, _) => entry = e) + .Returns(Task.CompletedTask); + return (sink, () => entry ?? throw new InvalidOperationException("No se publicó ninguna entrada.")); + } + + private static JsonElement Metadata(AuditTrailEntry entry) + => JsonDocument.Parse(entry.Metadata!).RootElement; + + // ── Parámetro SECRETO → el valor se redacta, el claro no aparece en la traza ── + + [Fact] + public async Task SecretParameter_Modified_RedactsBothPreviousAndNewValue() + { + var (sink, captured) = MakeSink(); + var service = new ConfigurationAuditService(sink.Object); + + await service.RecordConfigurationChangeAsync( + UserId, + "SMTP-PASSWORD", + TenantId, + previousValue: "old-" + SecretPlaintext, + newValue: "new-" + SecretPlaintext, + operationType: "UPDATE", + RootTenantId, + isEncrypted: true); + + var entry = captured(); + var meta = Metadata(entry); + + meta.GetProperty("PreviousValue").GetString().Should().Be(Redacted); + meta.GetProperty("NewValue").GetString().Should().Be(Redacted); + entry.Metadata.Should().NotContain(SecretPlaintext, "el valor secreto en claro nunca debe llegar a la traza inmutable"); + } + + [Fact] + public async Task SecretParameter_Created_RedactsNewValue_PreviousNullStaysNull() + { + var (sink, captured) = MakeSink(); + var service = new ConfigurationAuditService(sink.Object); + + await service.RecordConfigurationChangeAsync( + UserId, + "SMTP-PASSWORD", + TenantId, + previousValue: null, + newValue: SecretPlaintext, + operationType: "CREATE", + RootTenantId, + isEncrypted: true); + + var meta = Metadata(captured()); + + // Un valor nulo no tiene nada que ocultar → se deja nulo (no se finge un valor redactado). + meta.GetProperty("PreviousValue").ValueKind.Should().Be(JsonValueKind.Null); + meta.GetProperty("NewValue").GetString().Should().Be(Redacted); + } + + [Fact] + public async Task SecretParameter_Deleted_RedactsPreviousValue_AndKeepsDeletedResult() + { + var (sink, captured) = MakeSink(); + var service = new ConfigurationAuditService(sink.Object); + + await service.RecordConfigurationChangeAsync( + UserId, + "SMTP-PASSWORD", + TenantId, + previousValue: SecretPlaintext, + newValue: null, + operationType: "DELETE", + RootTenantId, + isEncrypted: true); + + var entry = captured(); + var meta = Metadata(entry); + + meta.GetProperty("PreviousValue").GetString().Should().Be(Redacted); + meta.GetProperty("NewValue").ValueKind.Should().Be(JsonValueKind.Null); + // La redacción no altera la clasificación del evento: newValue nulo ⇒ DELETED se preserva. + entry.AuditResult.Should().Be("DELETED"); + entry.Metadata.Should().NotContain(SecretPlaintext); + } + + [Fact] + public async Task SecretParameter_Override_IsRedacted() + { + var (sink, captured) = MakeSink(); + var service = new ConfigurationAuditService(sink.Object); + + await service.RecordParameterOverrideAsync( + UserId, + "SMTP-PASSWORD", + TenantId, + previousValue: SecretPlaintext, + newValue: "otro-" + SecretPlaintext, + RootTenantId, + isEncrypted: true); + + var entry = captured(); + var meta = Metadata(entry); + + entry.EventType.Should().Be("OVERRIDE"); + meta.GetProperty("PreviousValue").GetString().Should().Be(Redacted); + meta.GetProperty("NewValue").GetString().Should().Be(Redacted); + entry.Metadata.Should().NotContain(SecretPlaintext); + } + + // ── Parámetro NORMAL → el valor se registra tal cual (no-regresión) ── + + [Fact] + public async Task NonSecretParameter_RecordsValuesVerbatim() + { + var (sink, captured) = MakeSink(); + var service = new ConfigurationAuditService(sink.Object); + + await service.RecordConfigurationChangeAsync( + UserId, + "SESSION-TIMEOUT", + TenantId, + previousValue: "3600", + newValue: "7200", + operationType: "UPDATE", + RootTenantId, + isEncrypted: false); + + var meta = Metadata(captured()); + + meta.GetProperty("PreviousValue").GetString().Should().Be("3600", "un parámetro no secreto conserva su valor auditable"); + meta.GetProperty("NewValue").GetString().Should().Be("7200"); + meta.GetProperty("PreviousValue").GetString().Should().NotBe(Redacted); + meta.GetProperty("NewValue").GetString().Should().NotBe(Redacted); + } + + [Fact] + public async Task NonSecretParameter_Override_RecordsValueVerbatim() + { + var (sink, captured) = MakeSink(); + var service = new ConfigurationAuditService(sink.Object); + + await service.RecordParameterOverrideAsync( + UserId, + "SESSION-TIMEOUT", + TenantId, + previousValue: "3600", + newValue: "1800", + RootTenantId, + isEncrypted: false); + + var meta = Metadata(captured()); + + meta.GetProperty("NewValue").GetString().Should().Be("1800"); + meta.GetProperty("PreviousValue").GetString().Should().Be("3600"); + } +} diff --git a/src/apps/ums.api/Ums.Application.Test/IGA/HeuristicRiskScoreCalculatorTests.cs b/src/apps/ums.api/Ums.Application.Test/IGA/HeuristicRiskScoreCalculatorTests.cs new file mode 100644 index 00000000..af040258 --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/IGA/HeuristicRiskScoreCalculatorTests.cs @@ -0,0 +1,90 @@ +namespace Ums.Application.Test.IGA; + +using System; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using Xunit; +using Ums.Application.IGA.Services; +using Ums.Domain.Authorization; +using Ums.Domain.Kernel.ValueObjects; +using RoleAggregate = Ums.Domain.Authorization.Role.Role; + +/// +/// Pruebas de la heurística versionada de RiskScore (IGA, ADR-UMS-093, FR-061). La fórmula es +/// determinista: verificamos el enrutamiento de riesgo alto (≥ umbral 70) frente a riesgo bajo, +/// la versión del modelo y el fallo cuando falta un rol. +/// +public class HeuristicRiskScoreCalculatorTests +{ + private readonly Mock _roleRepo = new(); + + private static RoleAggregate MakeRole(int hierarchyLevel, int promotionOrder) + { + RoleId? parent = hierarchyLevel == 0 ? null : RoleId.Load(Guid.NewGuid()); + return RoleAggregate.Create( + TenantId.Load(Guid.NewGuid()), + SystemSuiteId.Create(), + Code.Create("ROLE1"), + Name.Create("Rol"), + Description.Create("Rol de prueba"), + parent, + hierarchyLevel, + promotionOrder, + ActorId.Create("sistema")).Value; + } + + [Fact] + public async Task Calculate_ConGranEscalacion_ProduceRiesgoAlto() + { + var currentRole = Guid.NewGuid(); + var targetRole = Guid.NewGuid(); + _roleRepo.Setup(r => r.GetByIdAsync(currentRole, It.IsAny())).ReturnsAsync(MakeRole(1, 1)); + _roleRepo.Setup(r => r.GetByIdAsync(targetRole, It.IsAny())).ReturnsAsync(MakeRole(4, 4)); + + var calc = new HeuristicRiskScoreCalculator(_roleRepo.Object); + var result = await calc.CalculateAsync( + new RolePromotionRiskContext(Guid.NewGuid(), Guid.NewGuid(), currentRole, targetRole), + CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.True(result.Value.Score >= 70, $"Se esperaba riesgo alto, fue {result.Value.Score}."); + Assert.Equal(HeuristicRiskScoreCalculator.ModelVersion, result.Value.RiskModelVersion); + Assert.NotEmpty(result.Value.Factors); + } + + [Fact] + public async Task Calculate_ConEscalacionMinima_ProduceRiesgoBajo() + { + var currentRole = Guid.NewGuid(); + var targetRole = Guid.NewGuid(); + _roleRepo.Setup(r => r.GetByIdAsync(currentRole, It.IsAny())).ReturnsAsync(MakeRole(1, 1)); + _roleRepo.Setup(r => r.GetByIdAsync(targetRole, It.IsAny())).ReturnsAsync(MakeRole(2, 2)); + + var calc = new HeuristicRiskScoreCalculator(_roleRepo.Object); + var result = await calc.CalculateAsync( + new RolePromotionRiskContext(Guid.NewGuid(), Guid.NewGuid(), currentRole, targetRole), + CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.True(result.Value.Score < 70, $"Se esperaba riesgo bajo, fue {result.Value.Score}."); + Assert.InRange(result.Value.Score, RiskScore.Min, RiskScore.Max); + } + + [Fact] + public async Task Calculate_CuandoFaltaUnRol_DevuelveFallo() + { + var currentRole = Guid.NewGuid(); + var targetRole = Guid.NewGuid(); + _roleRepo.Setup(r => r.GetByIdAsync(currentRole, It.IsAny())).ReturnsAsync(MakeRole(1, 1)); + _roleRepo.Setup(r => r.GetByIdAsync(targetRole, It.IsAny())).ReturnsAsync((RoleAggregate?)null); + + var calc = new HeuristicRiskScoreCalculator(_roleRepo.Object); + var result = await calc.CalculateAsync( + new RolePromotionRiskContext(Guid.NewGuid(), Guid.NewGuid(), currentRole, targetRole), + CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains("rol objetivo", result.Error, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/apps/ums.api/Ums.Application.Test/IGA/RolePromotionCommandHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/IGA/RolePromotionCommandHandlerTests.cs new file mode 100644 index 00000000..b10d7e16 --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/IGA/RolePromotionCommandHandlerTests.cs @@ -0,0 +1,455 @@ +namespace Ums.Application.Test.IGA; + +using System; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using Xunit; +using Ums.Application.Common.Interfaces; +using Ums.Application.IGA.RolePromotion.Commands; +using Ums.Application.IGA.Services; +using Ums.Domain.Enums; +using Ums.Domain.Events; +using Ums.Domain.IGA; +using Ums.Domain.Kernel; +using Ums.Domain.Kernel.ValueObjects; +using RoleMaturityStatusAggregate = Ums.Domain.IGA.RoleMaturityStatus.RoleMaturityStatus; +using RolePromotionRequestAggregate = Ums.Domain.IGA.RolePromotionRequest.RolePromotionRequest; + +/// +/// Pruebas de los handlers de comandos de promoción de rol (IGA, ADR-UMS-093). Cubren, por handler, +/// el camino feliz y las guardas: estado inválido, no elegible → Rejected (fail-closed), +/// SoD violada → Failure y RiskScore alto → enruta a PendingSecurityReview. +/// +public class RolePromotionCommandHandlerTests +{ + private readonly Mock _repo = new(); + private readonly Mock _maturityRepo = new(); + private readonly Mock _calculator = new(); + private readonly Mock _scope = new(); + private readonly Mock _uow = new(); + private readonly Mock _ctx = new(); + // G-094: el ejecutor publica el efecto como evento de integración al outbox; se falsea el puerto. + private readonly Mock _integrationEvents = new(); + + private readonly Guid _tenant = Guid.NewGuid(); + private readonly Guid _target = Guid.NewGuid(); + private readonly Guid _requester = Guid.NewGuid(); + private readonly Guid _approver = Guid.NewGuid(); + private readonly Guid _reviewer = Guid.NewGuid(); + private readonly Guid _executor = Guid.NewGuid(); + private readonly Guid _verifier = Guid.NewGuid(); + private readonly Guid _currentRole = Guid.NewGuid(); + private readonly Guid _targetRole = Guid.NewGuid(); + + public RolePromotionCommandHandlerTests() + { + _repo.Setup(r => r.UnitOfWork).Returns(_uow.Object); + _maturityRepo.Setup(r => r.UnitOfWork).Returns(_uow.Object); + _uow.Setup(u => u.SaveEntitiesAsync(It.IsAny())).ReturnsAsync(true); + // Alcance de inquilino no restrictivo por defecto (administrador interno). + _scope.Setup(s => s.ResolveQueryScope()).Returns((Guid?)null); + } + + private void ActingAs(Guid userId) => _ctx.Setup(c => c.UserId).Returns(userId.ToString()); + + private RolePromotionRequestAggregate NewDraft() => + RolePromotionRequestAggregate.Create( + TenantId.Load(_tenant), + UserId.Load(_target), + UserId.Load(_requester), + RoleId.Load(_currentRole), + RoleId.Load(_targetRole), + ActorId.Create(_requester.ToString())).Value; + + private RolePromotionRequestAggregate InPendingManagerApproval(int riskScore) + { + var req = NewDraft(); + req.Submit(RiskScore.Create(riskScore).Value, ActorId.Create(_requester.ToString())); + req.ConfirmEligibility(true, ActorId.Create("sistema")); + return req; + } + + private RolePromotionRequestAggregate InApproved(int riskScore = 30) + { + var req = InPendingManagerApproval(riskScore); + req.ManagerApprove(UserId.Load(_approver), ActorId.Create(_approver.ToString())); + return req; + } + + private RolePromotionRequestAggregate InExecuted() + { + var req = InApproved(); + req.Execute(UserId.Load(_executor), ActorId.Create(_executor.ToString())); + return req; + } + + private RoleMaturityStatusAggregate EligibleMaturity() + { + var status = RoleMaturityStatusAggregate.Create( + TenantId.Load(_tenant), + UserId.Load(_target), + RoleId.Load(_currentRole), + RoleMaturityLevel.Junior, + DateTime.UtcNow.AddYears(-2), + ActorId.Create("sistema")).Value; + status.UpdatePerformanceScore(4.5m, ActorId.Create("sistema")); + return status; + } + + private RoleMaturityStatusAggregate NotEligibleMaturity() + { + // Desempeño 0 (por defecto) < 3.0 ⇒ no elegible. + return RoleMaturityStatusAggregate.Create( + TenantId.Load(_tenant), + UserId.Load(_target), + RoleId.Load(_currentRole), + RoleMaturityLevel.Junior, + DateTime.UtcNow.AddYears(-2), + ActorId.Create("sistema")).Value; + } + + // ── Create ──────────────────────────────────────────────────────────────── + + [Fact] + public async Task Create_ConSolicitanteDistintoDelObjetivo_DevuelveExito() + { + ActingAs(_requester); + var handler = new CreateRolePromotionRequestCommandHandler(_repo.Object, _scope.Object, _ctx.Object); + + var result = await handler.Handle( + new CreateRolePromotionRequestCommand(_tenant, _target, _currentRole, _targetRole), + CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.NotEqual(Guid.Empty, result.Value.RolePromotionRequestId); + _repo.Verify(r => r.AddAsync(It.IsAny(), It.IsAny()), Times.Once); + _uow.Verify(u => u.SaveEntitiesAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Create_CuandoSolicitanteEsElObjetivo_ViolaSoD() + { + ActingAs(_target); // el actor (solicitante) es el objetivo + var handler = new CreateRolePromotionRequestCommandHandler(_repo.Object, _scope.Object, _ctx.Object); + + var result = await handler.Handle( + new CreateRolePromotionRequestCommand(_tenant, _target, _currentRole, _targetRole), + CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains("segregación de funciones", result.Error, StringComparison.OrdinalIgnoreCase); + _repo.Verify(r => r.AddAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Create_SinUsuarioAutenticado_DevuelveFallo() + { + _ctx.Setup(c => c.UserId).Returns(string.Empty); + var handler = new CreateRolePromotionRequestCommandHandler(_repo.Object, _scope.Object, _ctx.Object); + + var result = await handler.Handle( + new CreateRolePromotionRequestCommand(_tenant, _target, _currentRole, _targetRole), + CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains("usuario autenticado", result.Error, StringComparison.OrdinalIgnoreCase); + } + + // ── Submit ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Submit_CongelaRiskScoreYAvanza() + { + ActingAs(_requester); + var req = NewDraft(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + _calculator + .Setup(c => c.CalculateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success(new RiskAssessment(42, "iga-risk-heuristic-v1", Array.Empty()))); + + var handler = new SubmitRolePromotionCommandHandler(_repo.Object, _calculator.Object, _scope.Object, _ctx.Object); + var result = await handler.Handle(new SubmitRolePromotionCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(42, req.RiskScore!.GetValue()); + Assert.Equal("PendingEligibilityCheck", req.Status.Name); + _repo.Verify(r => r.UpdateAsync(req, It.IsAny()), Times.Once); + } + + [Fact] + public async Task Submit_CuandoActorEsElObjetivo_ViolaSoD() + { + ActingAs(_target); + var req = NewDraft(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + + var handler = new SubmitRolePromotionCommandHandler(_repo.Object, _calculator.Object, _scope.Object, _ctx.Object); + var result = await handler.Handle(new SubmitRolePromotionCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains("segregación de funciones", result.Error, StringComparison.OrdinalIgnoreCase); + _calculator.Verify(c => c.CalculateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Submit_CuandoNoSeEncuentraLaSolicitud_DevuelveFallo() + { + ActingAs(_requester); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((RolePromotionRequestAggregate?)null); + + var handler = new SubmitRolePromotionCommandHandler(_repo.Object, _calculator.Object, _scope.Object, _ctx.Object); + var result = await handler.Handle(new SubmitRolePromotionCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsFailure); + // G-100: el handler ahora devuelve un código estable e idioma-agnóstico (no el texto en español) + // para que el mapeador HTTP lo clasifique como 404 con independencia del idioma del mensaje. + Assert.Equal(DomainErrors.IGA.RolePromotionRequestNotFound, result.Error); + } + + // ── ConfirmEligibility (fail-closed) ───────────────────────────────────────── + + [Fact] + public async Task ConfirmEligibility_CuandoElegible_AvanzaAAprobacionGerente() + { + ActingAs(_requester); + var req = NewDraft(); + req.Submit(RiskScore.Create(30).Value, ActorId.Create(_requester.ToString())); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + _maturityRepo + .Setup(r => r.GetByUserAndRoleAsync(_tenant, _target, _currentRole, It.IsAny())) + .ReturnsAsync(EligibleMaturity()); + + var handler = new ConfirmRolePromotionEligibilityCommandHandler(_repo.Object, _maturityRepo.Object, _scope.Object, _ctx.Object); + var result = await handler.Handle(new ConfirmRolePromotionEligibilityCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal("PendingManagerApproval", req.Status.Name); + } + + [Fact] + public async Task ConfirmEligibility_CuandoNoElegible_RechazaFailClosed() + { + ActingAs(_requester); + var req = NewDraft(); + req.Submit(RiskScore.Create(30).Value, ActorId.Create(_requester.ToString())); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + _maturityRepo + .Setup(r => r.GetByUserAndRoleAsync(_tenant, _target, _currentRole, It.IsAny())) + .ReturnsAsync(NotEligibleMaturity()); + + var handler = new ConfirmRolePromotionEligibilityCommandHandler(_repo.Object, _maturityRepo.Object, _scope.Object, _ctx.Object); + var result = await handler.Handle(new ConfirmRolePromotionEligibilityCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsSuccess); // la transición a Rejected es un resultado válido + Assert.Equal("Rejected", req.Status.Name); + _maturityRepo.Verify(r => r.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task ConfirmEligibility_CuandoNoHayEstadoDeMadurez_RechazaFailClosed() + { + ActingAs(_requester); + var req = NewDraft(); + req.Submit(RiskScore.Create(30).Value, ActorId.Create(_requester.ToString())); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + _maturityRepo + .Setup(r => r.GetByUserAndRoleAsync(_tenant, _target, _currentRole, It.IsAny())) + .ReturnsAsync((RoleMaturityStatusAggregate?)null); + + var handler = new ConfirmRolePromotionEligibilityCommandHandler(_repo.Object, _maturityRepo.Object, _scope.Object, _ctx.Object); + var result = await handler.Handle(new ConfirmRolePromotionEligibilityCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal("Rejected", req.Status.Name); + } + + // ── ManagerApprove (enrutamiento por RiskScore) ────────────────────────────── + + [Fact] + public async Task ManagerApprove_ConRiesgoBajo_Aprueba() + { + ActingAs(_approver); + var req = InPendingManagerApproval(riskScore: 30); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + + var handler = new ManagerApproveRolePromotionCommandHandler(_repo.Object, _scope.Object, _ctx.Object); + var result = await handler.Handle(new ManagerApproveRolePromotionCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal("Approved", req.Status.Name); + } + + [Fact] + public async Task ManagerApprove_ConRiesgoAlto_EnrutaARevisionSeguridad() + { + ActingAs(_approver); + var req = InPendingManagerApproval(riskScore: 80); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + + var handler = new ManagerApproveRolePromotionCommandHandler(_repo.Object, _scope.Object, _ctx.Object); + var result = await handler.Handle(new ManagerApproveRolePromotionCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal("PendingSecurityReview", req.Status.Name); + } + + [Fact] + public async Task ManagerApprove_CuandoAprobadorEsElSolicitante_ViolaSoD() + { + ActingAs(_requester); // el aprobador coincide con el solicitante + var req = InPendingManagerApproval(riskScore: 30); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + + var handler = new ManagerApproveRolePromotionCommandHandler(_repo.Object, _scope.Object, _ctx.Object); + var result = await handler.Handle(new ManagerApproveRolePromotionCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains("segregación de funciones", result.Error, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ManagerApprove_EnEstadoInvalido_DevuelveFallo() + { + ActingAs(_approver); + var req = NewDraft(); // sigue en Draft, no en PendingManagerApproval + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + + var handler = new ManagerApproveRolePromotionCommandHandler(_repo.Object, _scope.Object, _ctx.Object); + var result = await handler.Handle(new ManagerApproveRolePromotionCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Equal("Draft", req.Status.Name); + } + + // ── SecurityApprove ────────────────────────────────────────────────────────── + + [Fact] + public async Task SecurityApprove_DesdeRevisionSeguridad_Aprueba() + { + ActingAs(_reviewer); + var req = InPendingManagerApproval(riskScore: 80); + req.ManagerApprove(UserId.Load(_approver), ActorId.Create(_approver.ToString())); // → PendingSecurityReview + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + + var handler = new SecurityApproveRolePromotionCommandHandler(_repo.Object, _scope.Object, _ctx.Object); + var result = await handler.Handle(new SecurityApproveRolePromotionCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal("Approved", req.Status.Name); + } + + [Fact] + public async Task SecurityApprove_CuandoRevisorEsElAprobador_ViolaSoD() + { + ActingAs(_approver); // el revisor coincide con el aprobador + var req = InPendingManagerApproval(riskScore: 80); + req.ManagerApprove(UserId.Load(_approver), ActorId.Create(_approver.ToString())); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + + var handler = new SecurityApproveRolePromotionCommandHandler(_repo.Object, _scope.Object, _ctx.Object); + var result = await handler.Handle(new SecurityApproveRolePromotionCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains("segregación de funciones", result.Error, StringComparison.OrdinalIgnoreCase); + } + + // ── Execute / Verify ───────────────────────────────────────────────────────── + + [Fact] + public async Task Execute_DesdeAprobado_Ejecuta() + { + ActingAs(_executor); + var req = InApproved(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + + var handler = new ExecuteRolePromotionCommandHandler(_repo.Object, _scope.Object, _ctx.Object, _integrationEvents.Object); + var result = await handler.Handle(new ExecuteRolePromotionCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal("Executed", req.Status.Name); + // El efecto se delega al outbox: se publica exactamente un evento de integración de ejecución. + _integrationEvents.Verify( + p => p.PublishAsync(It.IsAny(), It.IsAny()), + Times.Once); + } + + [Fact] + public async Task Execute_CuandoEjecutorEsElAprobador_ViolaSoD() + { + // ADR-UMS-096 (INV-RPR3 endurecida): el ejecutor no puede ser el aprobador. + ActingAs(_approver); // el ejecutor coincide con el aprobador + var req = InApproved(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + + var handler = new ExecuteRolePromotionCommandHandler(_repo.Object, _scope.Object, _ctx.Object, _integrationEvents.Object); + var result = await handler.Handle(new ExecuteRolePromotionCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains("segregación de funciones", result.Error, StringComparison.OrdinalIgnoreCase); + Assert.Equal("Approved", req.Status.Name); // no transiciona + // No se ejecutó: no debe publicarse ningún efecto al outbox. + _integrationEvents.Verify( + p => p.PublishAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task Verify_DesdeEjecutado_Verifica() + { + ActingAs(_verifier); + var req = InExecuted(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + + var handler = new VerifyRolePromotionCommandHandler(_repo.Object, _scope.Object, _ctx.Object); + var result = await handler.Handle(new VerifyRolePromotionCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal("Verified", req.Status.Name); + } + + [Fact] + public async Task Verify_CuandoVerificadorEsElEjecutor_ViolaSoD() + { + ActingAs(_executor); // el verificador coincide con el ejecutor + var req = InExecuted(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + + var handler = new VerifyRolePromotionCommandHandler(_repo.Object, _scope.Object, _ctx.Object); + var result = await handler.Handle(new VerifyRolePromotionCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains("segregación de funciones", result.Error, StringComparison.OrdinalIgnoreCase); + } + + // ── Cancel ─────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Cancel_PorElSolicitante_Cancela() + { + ActingAs(_requester); + var req = NewDraft(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + + var handler = new CancelRolePromotionCommandHandler(_repo.Object, _scope.Object, _ctx.Object); + var result = await handler.Handle(new CancelRolePromotionCommand(Guid.NewGuid(), "Ya no aplica"), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal("Cancelled", req.Status.Name); + } + + [Fact] + public async Task Cancel_PorUnTercero_DevuelveFallo() + { + ActingAs(_approver); // no es el solicitante + var req = NewDraft(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + + var handler = new CancelRolePromotionCommandHandler(_repo.Object, _scope.Object, _ctx.Object); + var result = await handler.Handle(new CancelRolePromotionCommand(Guid.NewGuid(), "Motivo"), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains("solicitante", result.Error, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/apps/ums.api/Ums.Application.Test/IGA/RolePromotionQueryHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/IGA/RolePromotionQueryHandlerTests.cs new file mode 100644 index 00000000..31ae1722 --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/IGA/RolePromotionQueryHandlerTests.cs @@ -0,0 +1,140 @@ +namespace Ums.Application.Test.IGA; + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using Xunit; +using Ums.Application.Common.Interfaces; +using Ums.Application.IGA.RoleMaturity.Queries; +using Ums.Application.IGA.RolePromotion.Queries; +using Ums.Domain.Enums; +using Ums.Domain.IGA; +using Ums.Domain.Kernel.ValueObjects; +using RoleMaturityStatusAggregate = Ums.Domain.IGA.RoleMaturityStatus.RoleMaturityStatus; +using RolePromotionRequestAggregate = Ums.Domain.IGA.RolePromotionRequest.RolePromotionRequest; + +/// Pruebas de los handlers de consulta IGA (ADR-UMS-093), incluida la acotación por inquilino. +public class RolePromotionQueryHandlerTests +{ + private readonly Mock _repo = new(); + private readonly Mock _maturityRepo = new(); + private readonly Mock _scope = new(); + + private readonly Guid _tenant = Guid.NewGuid(); + private readonly Guid _target = Guid.NewGuid(); + private readonly Guid _requester = Guid.NewGuid(); + private readonly Guid _currentRole = Guid.NewGuid(); + private readonly Guid _targetRole = Guid.NewGuid(); + + private RolePromotionRequestAggregate NewRequest() => + RolePromotionRequestAggregate.Create( + TenantId.Load(_tenant), + UserId.Load(_target), + UserId.Load(_requester), + RoleId.Load(_currentRole), + RoleId.Load(_targetRole), + ActorId.Create(_requester.ToString())).Value; + + private RoleMaturityStatusAggregate NewMaturity() => + RoleMaturityStatusAggregate.Create( + TenantId.Load(_tenant), + UserId.Load(_target), + RoleId.Load(_currentRole), + RoleMaturityLevel.Junior, + DateTime.UtcNow.AddYears(-1), + ActorId.Create("sistema")).Value; + + [Fact] + public async Task GetById_DentroDelAlcance_DevuelveDto() + { + _scope.Setup(s => s.ResolveQueryScope()).Returns((Guid?)null); + var req = NewRequest(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + + var handler = new GetRolePromotionRequestByIdQueryHandler(_repo.Object, _scope.Object); + var result = await handler.Handle(new GetRolePromotionRequestByIdQuery(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(_tenant, result.Value.TenantId); + Assert.Equal("Draft", result.Value.Status); + } + + [Fact] + public async Task GetById_FueraDeAlcance_DevuelveFallo() + { + // El solicitante pertenece a otro inquilino distinto del de la solicitud. + _scope.Setup(s => s.ResolveQueryScope()).Returns(Guid.NewGuid()); + var req = NewRequest(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(req); + + var handler = new GetRolePromotionRequestByIdQueryHandler(_repo.Object, _scope.Object); + var result = await handler.Handle(new GetRolePromotionRequestByIdQuery(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains("alcance", result.Error, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task GetById_CuandoNoExiste_DevuelveCodigoDeNoEncontrado() + { + // G-100: sin la solicitud, el handler debe devolver el CÓDIGO estable de dominio + // (idioma-agnóstico), no el texto en español, para que el mapeador HTTP resuelva 404 y no 400. + _scope.Setup(s => s.ResolveQueryScope()).Returns((Guid?)null); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((RolePromotionRequestAggregate?)null); + + var handler = new GetRolePromotionRequestByIdQueryHandler(_repo.Object, _scope.Object); + var result = await handler.Handle(new GetRolePromotionRequestByIdQuery(Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Equal(DomainErrors.IGA.RolePromotionRequestNotFound, result.Error); + } + + [Fact] + public async Task List_PorInquilinoYEstado_UsaConsultaFiltrada() + { + _scope.Setup(s => s.ResolveQueryScope()).Returns(_tenant); + _repo.Setup(r => r.GetByTenantAndStatusAsync(_tenant, "Draft", It.IsAny())) + .ReturnsAsync(new List { NewRequest() }); + + var handler = new ListRolePromotionRequestsQueryHandler(_repo.Object, _scope.Object); + var result = await handler.Handle(new ListRolePromotionRequestsQuery(_tenant, "Draft"), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Single(result.Value); + _repo.Verify(r => r.GetByTenantAndStatusAsync(_tenant, "Draft", It.IsAny()), Times.Once); + } + + [Fact] + public async Task GetRoleMaturityStatus_ConRol_DevuelveEstadoUnico() + { + _scope.Setup(s => s.ResolveQueryScope()).Returns((Guid?)null); + _maturityRepo.Setup(r => r.GetByUserAndRoleAsync(_tenant, _target, _currentRole, It.IsAny())) + .ReturnsAsync(NewMaturity()); + + var handler = new GetRoleMaturityStatusByUserQueryHandler(_maturityRepo.Object, _scope.Object); + var result = await handler.Handle( + new GetRoleMaturityStatusByUserQuery(_tenant, _target, _currentRole), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Single(result.Value); + Assert.Equal(_target, result.Value[0].UserId); + } + + [Fact] + public async Task GetRoleMaturityStatus_SinRol_DevuelveLista() + { + _scope.Setup(s => s.ResolveQueryScope()).Returns((Guid?)null); + _maturityRepo.Setup(r => r.GetByUserAsync(_tenant, _target, It.IsAny())) + .ReturnsAsync(new List { NewMaturity() }); + + var handler = new GetRoleMaturityStatusByUserQueryHandler(_maturityRepo.Object, _scope.Object); + var result = await handler.Handle( + new GetRoleMaturityStatusByUserQuery(_tenant, _target, null), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Single(result.Value); + } +} diff --git a/src/apps/ums.api/Ums.Application.Test/Identity/Auth/AuthMethodResolverSuiteSourceTests.cs b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/AuthMethodResolverSuiteSourceTests.cs new file mode 100644 index 00000000..ac713adb --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/AuthMethodResolverSuiteSourceTests.cs @@ -0,0 +1,226 @@ +namespace Ums.Application.Test.Identity.Auth; + +using Moq; +using Xunit; +using Ums.Application.Configuration.Services; +using Ums.Application.Identity.Auth; +using Ums.Domain.Configuration; +using Ums.Domain.Configuration.AppConfiguration; +using Ums.Domain.Configuration.IdpConfiguration; +using Ums.Domain.Identity; +using Ums.Domain.Identity.Auth; +using AppConfigurationAggregate = Ums.Domain.Configuration.AppConfiguration.AppConfiguration; +using IdpConfigurationAggregate = Ums.Domain.Configuration.IdpConfiguration.IdpConfiguration; +using TenantAggregate = Ums.Domain.Identity.Tenant.Tenant; + +/// +/// G-110 (residual del slice 2a de FR-042, ADR-UMS-097 §2.2): la procedencia de la suite +/// pre-autenticación. El AuthMethodResolverService ya aceptaba systemSuiteId, pero por +/// la ruta REST real llegaba null porque ni el AuthAccessScope ni el Tenant lo +/// alimentaban. Estas pruebas ejercen la fuente nueva (suite por defecto del inquilino): sin +/// pasar suite explícita (como hace la ruta REST), el filtro por suite del IdpConfigurationSelector +/// se ejerce de verdad — una IdpConfiguration de otra suite NO se elige; la de la suite correcta sí. +/// Y la no-regresión: sin suite por defecto ⇒ filtro omitido (comportamiento previo); portal ⇒ local. +/// +public class AuthMethodResolverSuiteSourceTests +{ + private readonly Mock _config = new(); + private readonly Mock _tenantRepo = new(); + private readonly Mock _idpConfigRepo = new(); + private readonly Guid _tenantId = Guid.NewGuid(); + + private AuthMethodResolverService CreateSut() + => new(_config.Object, _tenantRepo.Object, _idpConfigRepo.Object); + + private void SetupAuthUseExternalIdp(bool value) + => _config.Setup(c => c.GetWithPrecedence(AppConfigurationCodes.AuthUseExternalIdp, _tenantId)) + .Returns(BuildConfiguration(_tenantId, value)); + + private void SetupIdpConfigurations(params IdpConfigurationAggregate[] configurations) + => _idpConfigRepo.Setup(r => r.GetByTenantIdAsync(_tenantId, It.IsAny())) + .ReturnsAsync(configurations.ToList()); + + private void SetupTenant(TenantAggregate tenant) + => _tenantRepo.Setup(r => r.GetByIdAsync(_tenantId, It.IsAny())) + .ReturnsAsync(tenant); + + // ── Fuente de suite: el default del inquilino alimenta el filtro por suite ────────── + + [Fact] + public async Task ResolveAsync_NoScopeSuite_UsesTenantDefaultSuite_SelectsMatchingSuiteConfig() + { + // Ruta REST realista: NO se pasa suite (systemSuiteId == null). El inquilino tiene suite por + // defecto = suiteB y proveedor Keycloak activo. Dos configs: AzureAd@suiteA con MEJOR prioridad + // (1) y Keycloak@suiteB con PEOR prioridad (10). Sin filtro de suite ganaría AzureAd@suiteA por + // prioridad; PERO al poblarse la suite desde el default del inquilino (suiteB), el filtro EXCLUYE + // la config de la otra suite y elige Keycloak@suiteB pese a su peor prioridad. Prueba que el + // SystemSuiteId llega no-null al selector desde una fuente real y que el filtro por suite se ejerce. + var suiteA = Guid.NewGuid(); + var suiteB = Guid.NewGuid(); + SetupAuthUseExternalIdp(true); + SetupTenant(BuildTenantWithActiveIdpAndDefaultSuite(IdpStrategy.Keycloak, defaultSuiteId: suiteB)); + SetupIdpConfigurations( + BuildActiveConfig(ProviderType.AzureAd, priority: 1, suiteId: suiteA), + BuildActiveConfig(ProviderType.Keycloak, priority: 10, suiteId: suiteB)); + + var result = await CreateSut().ResolveAsync( + _tenantId, AuthAccessScope.ExternalApi, systemSuiteId: null); + + Assert.True(result.IsSuccess); + Assert.Equal(AuthMethodType.IDP, result.Value.Type); + Assert.NotNull(result.Value.Provider); + Assert.Equal(IdpStrategy.Keycloak, result.Value.Provider!.Strategy); + } + + [Fact] + public async Task ResolveAsync_NoScopeSuite_TenantDefaultSelectsOtherSuiteConfig_NotForcedToActiveProvider() + { + // Mismo par de configs, pero ahora el default del inquilino = suiteA. El filtro por suite elige + // AzureAd@suiteA; como el proveedor activo es Keycloak (no AzureAd), el puente no casa y NO se + // sustituye por otro proveedor (autenticar contra un IdP no elegido sería incorrecto) → Local. + // Contrasta con la prueba anterior: cambiar SOLO la suite por defecto cambia la config elegida, + // lo que demuestra que la suite del inquilino gobierna la selección. + var suiteA = Guid.NewGuid(); + var suiteB = Guid.NewGuid(); + SetupAuthUseExternalIdp(true); + SetupTenant(BuildTenantWithActiveIdpAndDefaultSuite(IdpStrategy.Keycloak, defaultSuiteId: suiteA)); + SetupIdpConfigurations( + BuildActiveConfig(ProviderType.AzureAd, priority: 1, suiteId: suiteA), + BuildActiveConfig(ProviderType.Keycloak, priority: 10, suiteId: suiteB)); + + var result = await CreateSut().ResolveAsync( + _tenantId, AuthAccessScope.ExternalApi, systemSuiteId: null); + + Assert.True(result.IsSuccess); + Assert.Equal(AuthMethodType.Local, result.Value.Type); + } + + [Fact] + public async Task ResolveAsync_ScopeSuiteProvided_TakesPrecedenceOverTenantDefault() + { + // Si el AccessScope SÍ fija suite (systemSuiteId != null), esa suite manda sobre el default del + // inquilino (ADR-UMS-097 §2.2: "proviene del AccessScope; si el scope no la fija, el default"). El + // inquilino tiene default = suiteA, pero se pasa suiteB explícita → gana Keycloak@suiteB. + var suiteA = Guid.NewGuid(); + var suiteB = Guid.NewGuid(); + SetupAuthUseExternalIdp(true); + SetupTenant(BuildTenantWithActiveIdpAndDefaultSuite(IdpStrategy.Keycloak, defaultSuiteId: suiteA)); + SetupIdpConfigurations( + BuildActiveConfig(ProviderType.AzureAd, priority: 1, suiteId: suiteA), + BuildActiveConfig(ProviderType.Keycloak, priority: 10, suiteId: suiteB)); + + var result = await CreateSut().ResolveAsync( + _tenantId, AuthAccessScope.ExternalApi, systemSuiteId: suiteB); + + Assert.True(result.IsSuccess); + Assert.Equal(AuthMethodType.IDP, result.Value.Type); + Assert.Equal(IdpStrategy.Keycloak, result.Value.Provider!.Strategy); + } + + // ── No-regresión ──────────────────────────────────────────────────────────────────── + + [Fact] + public async Task ResolveAsync_NoScopeSuite_NoTenantDefault_OmitsSuiteFilter_PreviousBehavior() + { + // Inquilino SIN suite por defecto (suite única) y sin suite en el scope → efectivo == null → + // el selector OMITE el filtro por suite y gana por prioridad global (AzureAd@suiteA, prioridad 1), + // que casa con el proveedor activo AzureAd. Comportamiento previo intacto (retrocompatible). + var suiteA = Guid.NewGuid(); + var suiteB = Guid.NewGuid(); + SetupAuthUseExternalIdp(true); + SetupTenant(BuildTenantWithActiveIdpAndDefaultSuite(IdpStrategy.AzureAd, defaultSuiteId: null)); + SetupIdpConfigurations( + BuildActiveConfig(ProviderType.AzureAd, priority: 1, suiteId: suiteA), + BuildActiveConfig(ProviderType.Keycloak, priority: 10, suiteId: suiteB)); + + var result = await CreateSut().ResolveAsync( + _tenantId, AuthAccessScope.ExternalApi, systemSuiteId: null); + + Assert.True(result.IsSuccess); + Assert.Equal(AuthMethodType.IDP, result.Value.Type); + Assert.Equal(IdpStrategy.AzureAd, result.Value.Provider!.Strategy); + } + + [Fact] + public async Task ResolveAsync_PortalManagement_WithTenantDefaultSuite_StillForcesLocal() + { + // ADR-UMS-072: el portal de gestión interna SIEMPRE resuelve a local, incluso si el inquilino + // tiene suite por defecto. La fuente de suite no debe filtrarse al portal ni consultar el repo. + SetupAuthUseExternalIdp(true); + SetupTenant(BuildTenantWithActiveIdpAndDefaultSuite(IdpStrategy.Keycloak, defaultSuiteId: Guid.NewGuid())); + + var result = await CreateSut().ResolveAsync(_tenantId, AuthAccessScope.PortalManagement, systemSuiteId: null); + + Assert.True(result.IsSuccess); + Assert.Equal(AuthMethodType.Local, result.Value.Type); + Assert.Null(result.Value.Provider); + _tenantRepo.Verify(r => r.GetByIdAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + // ── Helpers ────────────────────────────────────────────────────────────────────────── + + private static TenantAggregate BuildTenantWithActiveIdpAndDefaultSuite(IdpStrategy strategy, Guid? defaultSuiteId) + { + var actor = ActorId.Create("test"); + var tenant = TenantAggregate.Create( + Code.Create("TEST"), + Name.Create("Test Tenant"), + Ums.Domain.Enums.OrganizationType.INTERNAL, + actor, + strategy).Value; + + if (defaultSuiteId.HasValue) + { + tenant.SetDefaultSystemSuite(SystemSuiteId.Load(defaultSuiteId.Value), actor); + } + + tenant.RegisterIdentityProvider( + Code.Create(strategy.Name.ToUpperInvariant()), + Name.Create(strategy.Name), + Description.Create(""), + strategy, + actor); + + var idp = tenant.IdentityProviders.First(); + tenant.ActivateIdentityProvider(idp.GetId(), actor); + tenant.DomainEvents.MarkChangesAsCommitted(); + return tenant; + } + + private IdpConfigurationAggregate BuildActiveConfig( + ProviderType providerType, + int priority, + Guid suiteId, + string[]? domainHints = null) + { + var actor = ActorId.Create("test"); + var config = IdpConfigurationAggregate.Create( + TenantId.Load(_tenantId), + SystemSuiteId.Load(suiteId), + providerType, + domainHints ?? Array.Empty(), + "{\"issuer\":\"https://idp.example\"}", + "vault/secret/idp", + priority, + null, + actor).Value; + config.Activate(actor); + return config; + } + + private static AppConfigurationAggregate BuildConfiguration(Guid tenantId, bool value) + { + var actor = ActorId.Create("test"); + return AppConfigurationAggregate.Create( + TenantId.Load(tenantId), + null, + null, + Code.Create(AppConfigurationCodes.AuthUseExternalIdp), + ConfigurationValue.Create(value.ToString().ToLowerInvariant()), + Description.Create("Use external IDP"), + true, + false, + actor) + .Value; + } +} diff --git a/src/apps/ums.api/Ums.Application.Test/Identity/Auth/AuthMethodResolverTests.cs b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/AuthMethodResolverTests.cs index 9afd40dd..3c17e498 100644 --- a/src/apps/ums.api/Ums.Application.Test/Identity/Auth/AuthMethodResolverTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/AuthMethodResolverTests.cs @@ -4,28 +4,46 @@ namespace Ums.Application.Test.Identity.Auth; using Xunit; using Ums.Application.Configuration.Services; using Ums.Application.Identity.Auth; +using Ums.Domain.Configuration; using Ums.Domain.Configuration.AppConfiguration; +using Ums.Domain.Configuration.IdpConfiguration; using Ums.Domain.Identity; using Ums.Domain.Identity.Auth; using AppConfigurationAggregate = Ums.Domain.Configuration.AppConfiguration.AppConfiguration; +using IdpConfigurationAggregate = Ums.Domain.Configuration.IdpConfiguration.IdpConfiguration; /// /// Tests for AuthMethodResolverService. -/// Verifies dynamic resolution from IConfigurationProvider without hitting the DB. +/// Verifies dynamic resolution from IConfigurationProvider without hitting the DB, +/// plus FR-042 (ADR-UMS-097 slice 2a): selección del proveedor por el motor de reglas +/// (prioridad/suite/dominio) y puente reglas ↔ IdentityProvider. /// public class AuthMethodResolverTests { - private readonly Mock _config = new(); - private readonly Mock _tenantRepo = new(); - private readonly Guid _tenantId = Guid.NewGuid(); + private readonly Mock _config = new(); + private readonly Mock _tenantRepo = new(); + private readonly Mock _idpConfigRepo = new(); + private readonly Guid _tenantId = Guid.NewGuid(); + + public AuthMethodResolverTests() + { + // Por defecto no hay IdpConfiguration → el resolver conserva el comportamiento previo + // (proveedor activo del inquilino). Los tests de reglas sobreescriben este setup. + _idpConfigRepo.Setup(r => r.GetByTenantIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + } private AuthMethodResolverService CreateSut() - => new(_config.Object, _tenantRepo.Object); + => new(_config.Object, _tenantRepo.Object, _idpConfigRepo.Object); private void SetupAuthUseExternalIdp(bool value) => _config.Setup(c => c.GetWithPrecedence(AppConfigurationCodes.AuthUseExternalIdp, _tenantId)) .Returns(BuildConfiguration(_tenantId, value)); + private void SetupIdpConfigurations(params IdpConfigurationAggregate[] configurations) + => _idpConfigRepo.Setup(r => r.GetByTenantIdAsync(_tenantId, It.IsAny())) + .ReturnsAsync(configurations.ToList()); + // ── Local mode ──────────────────────────────────────────────────────────── [Fact] @@ -70,7 +88,7 @@ public async Task ResolveAsync_WhenIdpConfigAndActiveIdp_ReturnsIdpMethod() { SetupAuthUseExternalIdp(true); _tenantRepo.Setup(r => r.GetByIdAsync(_tenantId, It.IsAny())) - .ReturnsAsync(BuildTenantWithActiveIdp()); + .ReturnsAsync(BuildTenantWithActiveIdp(IdpStrategy.AzureAd)); var result = await CreateSut().ResolveAsync(_tenantId, AuthAccessScope.ExternalApi); @@ -80,7 +98,7 @@ public async Task ResolveAsync_WhenIdpConfigAndActiveIdp_ReturnsIdpMethod() } [Fact] - public async Task ResolveAsync_WhenIdpConfigButNoActiveIdp_ReturnsError_AUTH011() + public async Task ResolveAsync_WhenIdpConfigButNoActiveIdp_ReturnsLocalAuth() { SetupAuthUseExternalIdp(true); _tenantRepo.Setup(r => r.GetByIdAsync(_tenantId, It.IsAny())) @@ -88,8 +106,8 @@ public async Task ResolveAsync_WhenIdpConfigButNoActiveIdp_ReturnsError_AUTH011( var result = await CreateSut().ResolveAsync(_tenantId, AuthAccessScope.ExternalApi); - Assert.True(result.IsFailure); - Assert.Contains("AUTH_011", result.Error); + Assert.True(result.IsSuccess); + Assert.Equal(AuthMethodType.Local, result.Value.Type); } [Fact] @@ -117,7 +135,7 @@ public async Task ResolveAsync_UsesConfigProvider_NotHardcoded() SetupAuthUseExternalIdp(true); _tenantRepo.Setup(r => r.GetByIdAsync(_tenantId, It.IsAny())) - .ReturnsAsync(BuildTenantWithActiveIdp()); + .ReturnsAsync(BuildTenantWithActiveIdp(IdpStrategy.AzureAd)); var idp = await CreateSut().ResolveAsync(_tenantId, AuthAccessScope.ExternalApi); Assert.Equal(AuthMethodType.IDP, idp.Value.Type); } @@ -135,9 +153,110 @@ public async Task ResolveAsync_PortalManagementAlwaysReturnsLocal() _tenantRepo.Verify(r => r.GetByIdAsync(It.IsAny(), It.IsAny()), Times.Never); } + // ── FR-042 (ADR-UMS-097 §2.1): selección por reglas + puente al IdentityProvider ── + + [Fact] + public async Task ResolveAsync_RuleBasedSelection_BridgesToProviderOfWinningConfig() + { + // Dos configuraciones activas de distinta prioridad; el proveedor activo del inquilino + // corresponde a la de menor orden (AzureAd, prioridad 5). El puente reglas↔proveedor casa. + SetupAuthUseExternalIdp(true); + _tenantRepo.Setup(r => r.GetByIdAsync(_tenantId, It.IsAny())) + .ReturnsAsync(BuildTenantWithActiveIdp(IdpStrategy.AzureAd)); + SetupIdpConfigurations( + BuildActiveConfig(ProviderType.AzureAd, priority: 5), + BuildActiveConfig(ProviderType.Keycloak, priority: 10)); + + var result = await CreateSut().ResolveAsync(_tenantId, AuthAccessScope.ExternalApi); + + Assert.True(result.IsSuccess); + Assert.Equal(AuthMethodType.IDP, result.Value.Type); + Assert.NotNull(result.Value.Provider); + Assert.Equal(IdpStrategy.AzureAd, result.Value.Provider!.Strategy); + } + + [Fact] + public async Task ResolveAsync_RuleSelectsProviderNotActive_DoesNotSubstituteActiveProvider() + { + // La regla elige KEYCLOAK (prioridad 5), pero el único proveedor activo del inquilino es AzureAd. + // No se sustituye por AzureAd (sería autenticar contra un IdP no elegido): se resuelve a Local. + // Prueba de que la selección la gobiernan las reglas, no FirstOrDefault(IsActive). + SetupAuthUseExternalIdp(true); + _tenantRepo.Setup(r => r.GetByIdAsync(_tenantId, It.IsAny())) + .ReturnsAsync(BuildTenantWithActiveIdp(IdpStrategy.AzureAd)); + SetupIdpConfigurations( + BuildActiveConfig(ProviderType.Keycloak, priority: 5), + BuildActiveConfig(ProviderType.AzureAd, priority: 10)); + + var result = await CreateSut().ResolveAsync(_tenantId, AuthAccessScope.ExternalApi); + + Assert.True(result.IsSuccess); + Assert.Equal(AuthMethodType.Local, result.Value.Type); + } + + [Fact] + public async Task ResolveAsync_SuiteFilter_SelectsConfigOfMatchingSuite() + { + // Inquilino con proveedor Keycloak activo. Dos suites: A→AzureAd, B→Keycloak (misma prioridad). + // Filtrando por la suite B se elige la config Keycloak y el puente casa con el proveedor activo. + var suiteA = Guid.NewGuid(); + var suiteB = Guid.NewGuid(); + SetupAuthUseExternalIdp(true); + _tenantRepo.Setup(r => r.GetByIdAsync(_tenantId, It.IsAny())) + .ReturnsAsync(BuildTenantWithActiveIdp(IdpStrategy.Keycloak)); + SetupIdpConfigurations( + BuildActiveConfig(ProviderType.AzureAd, priority: 1, suiteId: suiteA), + BuildActiveConfig(ProviderType.Keycloak, priority: 1, suiteId: suiteB)); + + var result = await CreateSut().ResolveAsync(_tenantId, AuthAccessScope.ExternalApi, systemSuiteId: suiteB); + + Assert.True(result.IsSuccess); + Assert.Equal(AuthMethodType.IDP, result.Value.Type); + Assert.Equal(IdpStrategy.Keycloak, result.Value.Provider!.Strategy); + } + + [Fact] + public async Task ResolveAsync_SuiteFilter_WhenMatchingConfigProviderNotActive_ReturnsLocal() + { + // Filtrando por la suite A la regla elige AzureAd, pero el proveedor activo es Keycloak → Local. + var suiteA = Guid.NewGuid(); + var suiteB = Guid.NewGuid(); + SetupAuthUseExternalIdp(true); + _tenantRepo.Setup(r => r.GetByIdAsync(_tenantId, It.IsAny())) + .ReturnsAsync(BuildTenantWithActiveIdp(IdpStrategy.Keycloak)); + SetupIdpConfigurations( + BuildActiveConfig(ProviderType.AzureAd, priority: 1, suiteId: suiteA), + BuildActiveConfig(ProviderType.Keycloak, priority: 1, suiteId: suiteB)); + + var result = await CreateSut().ResolveAsync(_tenantId, AuthAccessScope.ExternalApi, systemSuiteId: suiteA); + + Assert.True(result.IsSuccess); + Assert.Equal(AuthMethodType.Local, result.Value.Type); + } + + [Fact] + public async Task ResolveAsync_DomainRouting_PrefersDomainMatchedConfigOverPriority() + { + // Sin dominio gana Keycloak (prioridad 1). Con dominio acme.com gana AzureAd (casa por dominio, + // aunque su prioridad 10 sea peor): el routing por dominio prima sobre la prioridad. + SetupAuthUseExternalIdp(true); + _tenantRepo.Setup(r => r.GetByIdAsync(_tenantId, It.IsAny())) + .ReturnsAsync(BuildTenantWithActiveIdp(IdpStrategy.AzureAd)); + SetupIdpConfigurations( + BuildActiveConfig(ProviderType.AzureAd, priority: 10, domainHints: new[] { "acme.com" }), + BuildActiveConfig(ProviderType.Keycloak, priority: 1)); + + var withDomain = await CreateSut().ResolveAsync( + _tenantId, AuthAccessScope.ExternalApi, emailDomain: "user@acme.com"); + + Assert.True(withDomain.IsSuccess); + Assert.Equal(AuthMethodType.IDP, withDomain.Value.Type); + Assert.Equal(IdpStrategy.AzureAd, withDomain.Value.Provider!.Strategy); + } + // ── Helpers ──────────────────────────────────────────────────────────────── - private static Ums.Domain.Identity.Tenant.Tenant BuildTenantWithActiveIdp() + private static Ums.Domain.Identity.Tenant.Tenant BuildTenantWithActiveIdp(IdpStrategy strategy) { var actor = ActorId.Create("test"); var tenant = Ums.Domain.Identity.Tenant.Tenant.Create( @@ -145,13 +264,13 @@ private static Ums.Domain.Identity.Tenant.Tenant BuildTenantWithActiveIdp() Name.Create("Test Tenant"), Ums.Domain.Enums.OrganizationType.INTERNAL, actor, - Ums.Domain.Enums.IdpStrategy.AzureAd).Value; + strategy).Value; tenant.RegisterIdentityProvider( - Code.Create("AZURE"), - Name.Create("Azure AD"), + Code.Create(strategy.Name.ToUpperInvariant()), + Name.Create(strategy.Name), Description.Create(""), - Ums.Domain.Enums.IdpStrategy.AzureAd, + strategy, actor); var idp = tenant.IdentityProviders.First(); @@ -173,6 +292,27 @@ private static Ums.Domain.Identity.Tenant.Tenant BuildTenantWithNoActiveIdp() return tenant; } + private IdpConfigurationAggregate BuildActiveConfig( + ProviderType providerType, + int priority, + Guid? suiteId = null, + string[]? domainHints = null) + { + var actor = ActorId.Create("test"); + var config = IdpConfigurationAggregate.Create( + TenantId.Load(_tenantId), + suiteId.HasValue ? SystemSuiteId.Load(suiteId.Value) : SystemSuiteId.Create(), + providerType, + domainHints ?? Array.Empty(), + "{\"issuer\":\"https://idp.example\"}", + "vault/secret/idp", + priority, + null, + actor).Value; + config.Activate(actor); + return config; + } + private static AppConfigurationAggregate BuildConfiguration(Guid tenantId, bool value) { var actor = ActorId.Create("test"); diff --git a/src/apps/ums.api/Ums.Application.Test/Identity/Auth/AuthenticateUserCommandHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/AuthenticateUserCommandHandlerTests.cs new file mode 100644 index 00000000..bd199476 --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/AuthenticateUserCommandHandlerTests.cs @@ -0,0 +1,223 @@ +namespace Ums.Application.Test.Identity.Auth; + +using Ums.Application.Authorization.Graph; +using Ums.Application.Authorization.Graph.Serializers; +using Ums.Application.Common.Interfaces; +using Ums.Application.Configuration.Services; +using Ums.Application.Identity.Auth.Commands; +using Ums.Domain.Authorization.Graph; +using Ums.Domain.Configuration.AppConfiguration; +using Ums.Domain.Identity; +using Ums.Domain.Identity.Auth; +using TenantAggregate = Ums.Domain.Identity.Tenant.Tenant; +using UserAccountAggregate = Ums.Domain.Identity.UserAccount.UserAccount; + +/// +/// Pruebas unitarias de centradas en el bloqueo +/// temporal de cuenta por intentos fallidos (ADR-UMS-095). Todas las dependencias están mockeadas +/// —sin BD ni infraestructura— y el instante determinista se controla desde el arrange del dominio. +/// +/// Cumplimiento (ADR-UMS-095, sección test-first): +/// · Tras MaxLoginAttempts fallos, el intento N+1 con credencial CORRECTA devuelve «cuenta +/// bloqueada» (AUTH_017), no un login exitoso, y NO se llega a construir el grafo. +/// · Tras expirar LockedUntil, el login correcto vuelve a funcionar y resetea el contador. +/// +public sealed class AuthenticateUserCommandHandlerTests +{ + private readonly Mock _tenantRepo = new(); + private readonly Mock _userRepo = new(); + private readonly Mock _methodResolver = new(); + private readonly Mock _localStrategy = new(); + private readonly Mock _chainAuth = new(); + private readonly Mock _graphBuilder = new(); + private readonly Mock _serializer = new(); + private readonly Mock _auditService = new(); + private readonly Mock _configProvider = new(); + private readonly Mock _unitOfWork = new(); + + private const int MaxAttempts = 3; + private const int LockoutMinutes = 15; + private const string CorrectPassword = "correct-horse"; + private const string WrongPassword = "wrong-password"; + private const string TenantCode = "TEST"; + private const string UserEmail = "user@test.com"; + + private static readonly Guid TenantGuid = Guid.NewGuid(); + private static readonly Guid UserGuid = Guid.NewGuid(); + + private AuthenticateUserCommandHandler CreateSut() => new( + _tenantRepo.Object, _userRepo.Object, _methodResolver.Object, + _localStrategy.Object, _chainAuth.Object, _graphBuilder.Object, + _serializer.Object, _auditService.Object, + _configProvider.Object); + + private static AuthenticateUserCommand Command(string password) + => new(TenantCode, UserEmail, password, ClientIp: "10.0.0.1", AuthAccessScope.ExternalApi); + + // ── Test-first ADR-UMS-095: intento N+1 con credencial correcta ⇒ AUTH_017 ───────── + + [Fact] + public async Task AuthenticateLocal_AfterMaxFailedAttempts_ReturnsAccountLocked() + { + var user = BuildActiveUser(); + SetupLocalPipeline(user); + var sut = CreateSut(); + + // MaxLoginAttempts fallos consecutivos con credencial incorrecta. + for (var attempt = 0; attempt < MaxAttempts; attempt++) + { + var failed = await sut.Handle(Command(WrongPassword), CancellationToken.None); + Assert.True(failed.IsFailure); + Assert.Contains("AUTH_006", failed.Error); + } + + // Intento N+1 con credencial CORRECTA: debe rechazar por bloqueo, NO iniciar sesión. + var locked = await sut.Handle(Command(CorrectPassword), CancellationToken.None); + + Assert.True(locked.IsFailure); + Assert.Contains("AUTH_017", locked.Error); + Assert.True(user.IsLockedOut(DateTimeOffset.UtcNow)); + // No se llega a construir el grafo: el rechazo ocurre antes de validar credenciales. + _graphBuilder.Verify(g => g.BuildAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task AuthenticateLocal_BelowThreshold_ThenCorrect_Succeeds() + { + var user = BuildActiveUser(); + SetupLocalPipeline(user); + var sut = CreateSut(); + + // Un fallo (por debajo del umbral) no bloquea; el login correcto siguiente entra. + var failed = await sut.Handle(Command(WrongPassword), CancellationToken.None); + Assert.True(failed.IsFailure); + + var ok = await sut.Handle(Command(CorrectPassword), CancellationToken.None); + + Assert.True(ok.IsSuccess); + Assert.Equal(0, user.FailedLoginAttempts); + Assert.Null(user.LockedUntil); + } + + // ── Test-first ADR-UMS-095: tras expirar el bloqueo, el login correcto reabre y resetea ── + + [Fact] + public async Task AuthenticateLocal_AfterLockoutExpires_CorrectLoginSucceedsAndResets() + { + var user = BuildActiveUser(); + // Arrange determinista: bloqueo ya vencido. El instante se inyecta en el dominio (no reloj real): + // un fallo con umbral 1 y duración 1 min, situado 30 min en el pasado ⇒ LockedUntil ~29 min atrás. + var past = DateTimeOffset.UtcNow.AddMinutes(-30); + user.RecordAuthenticationAttempt(false, past, maxAttempts: 1, lockoutMinutes: 1, "Invalid password", "10.0.0.1", ActorId.Create("test")); + user.DomainEvents.MarkChangesAsCommitted(); + Assert.False(user.IsLockedOut(DateTimeOffset.UtcNow)); + + SetupLocalPipeline(user); + var sut = CreateSut(); + + var ok = await sut.Handle(Command(CorrectPassword), CancellationToken.None); + + Assert.True(ok.IsSuccess); + Assert.Equal(0, user.FailedLoginAttempts); + Assert.Null(user.LockedUntil); + Assert.False(user.IsLockedOut(DateTimeOffset.UtcNow)); + } + + // ── Setup helpers ──────────────────────────────────────────────────────────── + + private void SetupLocalPipeline(UserAccountAggregate user) + { + _tenantRepo.Setup(r => r.GetByCodeAsync(TenantCode, It.IsAny())) + .ReturnsAsync(BuildActiveTenant()); + + _methodResolver.Setup(m => m.ResolveAsync(TenantGuid, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success(AuthMethod.Local())); + + // G-168: el login busca acotado al inquilino. El stub global por email queda a + // propósito devolviendo null: si alguien reintroduce la búsqueda sin inquilino, estas + // pruebas fallan en vez de pasar por casualidad. + _userRepo.Setup(r => r.GetByTenantAndEmailAsync( + TenantGuid, It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(user); + _userRepo.Setup(r => r.UpdateAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + _unitOfWork.Setup(u => u.SaveEntitiesAsync(It.IsAny())).ReturnsAsync(true); + _userRepo.Setup(r => r.UnitOfWork).Returns(_unitOfWork.Object); + _serializer.Setup(x => x.FileExtension).Returns("json"); + + // Estrategia local: la contraseña correcta valida; cualquier otra falla (AUTH_006). + _localStrategy.Setup(s => s.Authenticate(It.IsAny(), CorrectPassword)) + .Returns(Result.Success()); + _localStrategy.Setup(s => s.Authenticate(It.IsAny(), It.Is(p => p != CorrectPassword))) + .Returns(Result.Failure("AUTH_006: Invalid username or password.")); + + // Config efectiva: umbral y duración del bloqueo (ADR-UMS-095). MfaRequiredForAdmin queda en false por defecto. + _configProvider.Setup(p => p.GetValueAs(AppConfigurationCodes.MaxLoginAttempts, It.IsAny(), It.IsAny())) + .Returns(MaxAttempts); + _configProvider.Setup(p => p.GetValueAs(AppConfigurationCodes.AccountLockoutDurationMinutes, It.IsAny(), It.IsAny())) + .Returns(LockoutMinutes); + + // Pipeline del grafo para el camino exitoso. + _graphBuilder.Setup(g => g.BuildAsync(It.IsAny(), TenantGuid, It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success(BuildGraph())); + _serializer.Setup(s => s.Serialize(It.IsAny(), It.IsAny())) + .Returns("{}"); + } + + private static TenantAggregate BuildActiveTenant() + { + var tenant = TenantAggregate.Create( + Code.Create(TenantCode), + Name.Create("Test Tenant"), + OrganizationType.INTERNAL, + ActorId.Create("test"), + IdpStrategy.InternalBcrypt, + tenantId: TenantId.Load(TenantGuid)).Value; // Create ⇒ Status Active + tenant.DomainEvents.MarkChangesAsCommitted(); + return tenant; + } + + private static UserAccountAggregate BuildActiveUser() + { + var user = UserAccountAggregate.Create( + TenantId.Load(TenantGuid), + Email.Create(UserEmail), + UserCategory.Internal, + null, null, + ActorId.Create("test"), + null, + UserAccountId.Load(UserGuid)).Value; + user.Activate(ActorId.Create("test")); + user.DomainEvents.MarkChangesAsCommitted(); + return user; + } + + private static AuthorizationGraph BuildGraph() + { + var context = new GraphContext( + new GraphUser(UserGuid, UserEmail, "user", "User", "Active"), + new GraphTenant(TenantGuid, TenantCode, "Test Tenant", "Active", false), + SystemSuite: null, Role: null, Profile: null, Branch: null); + + var authentication = new GraphAuthentication( + "Local", Provider: null, MfaRequired: false, + IssuedAt: DateTime.UtcNow, SessionExpiresAt: DateTime.UtcNow.AddMinutes(30)); + + var effectiveConfig = new GraphEffectiveConfig( + SessionTimeoutMinutes: 30, MaxLoginAttempts: MaxAttempts, MinPasswordLength: 8, + MfaRequiredForAdmin: false, MfaAllowedMethods: Array.Empty(), + AccessTokenDurationMs: 900_000, AuthUseExternalIdp: false); + + return AuthorizationGraph.Build( + context, authentication, + Array.Empty(), + Array.Empty(), + Array.Empty(), + Array.Empty(), + effectiveConfig, + Array.Empty(), + DateTime.UtcNow); + } +} diff --git a/src/apps/ums.api/Ums.Application.Test/Identity/Auth/ForgotPasswordCommandHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/ForgotPasswordCommandHandlerTests.cs new file mode 100644 index 00000000..210710f2 --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/ForgotPasswordCommandHandlerTests.cs @@ -0,0 +1,299 @@ +namespace Ums.Application.Test.Identity.Auth; + +using System.Text.Json; +using Ums.Application.Common.Interfaces; +using Ums.Application.Common.Notifications; +using Ums.Application.Identity.Auth; +using Ums.Application.Identity.Auth.Commands; +using Ums.Domain.Enums; +using Ums.Domain.Identity; +using Ums.Domain.Identity.UserAccount; +using Ums.Domain.Kernel; +using Moq; +using Xunit; + +/// +/// G-188: la solicitud anónima de restablecimiento no puede filtrar la contraseña, no puede +/// cambiarla y no puede delatar si la cuenta existe. Estas pruebas fijan las tres cosas. +/// +public class ForgotPasswordCommandHandlerTests +{ + private readonly Mock _tenantRepo = new(); + private readonly Mock _userRepo = new(); + private readonly Mock _resetTokens = new(); + private readonly Mock _notifications = new(); + private readonly Mock _timing = new(); + private readonly Mock _uow = new(); + + private static readonly Guid TenantIdValue = Guid.Parse("3fa85f64-5717-4562-b3fc-2c963f66afa6"); + private static readonly Guid OtherTenantIdValue = Guid.Parse("9c1c2b3a-0000-4000-8000-000000000099"); + private const string TenantCode = "ACME"; + private const string UserEmail = "admin@acme.com"; + private const string ExistingHash = "hash-de-la-clave-vigente"; + + public ForgotPasswordCommandHandlerTests() + { + _userRepo.Setup(r => r.UnitOfWork).Returns(_uow.Object); + _uow.Setup(u => u.SaveEntitiesAsync(It.IsAny())).ReturnsAsync(true); + } + + private static Domain.Identity.Tenant.Tenant MakeTenant() => + Domain.Identity.Tenant.Tenant.Create( + Code.Create(TenantCode), Name.Create("Acme Corp"), + OrganizationType.INTERNAL, ActorId.Create("sys"), + tenantId: TenantId.Load(TenantIdValue)).Value; + + private static UserAccount MakeUserWithPassword(Guid tenantId) + { + var user = UserAccount.Create( + TenantId.Load(tenantId), + Email.Create(UserEmail), + UserCategory.Internal, + null, null, + ActorId.Create("sys")).Value; + user.Activate(ActorId.Create("sys")); + user.AddPassword(PasswordHash.Create(ExistingHash), ActorId.Create("sys")); + return user; + } + + private ForgotPasswordCommandHandler CreateHandler() => + new(_tenantRepo.Object, _userRepo.Object, _resetTokens.Object, _notifications.Object, _timing.Object); + + private void GivenTenantExists() => + _tenantRepo.Setup(r => r.GetByCodeAsync(TenantCode, It.IsAny())) + .ReturnsAsync(MakeTenant()); + + private void GivenAccount(UserAccount? account) => + _userRepo.Setup(r => r.GetByEmailAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(account); + + private Task> Invoke(string email = UserEmail) => + CreateHandler().Handle(new ForgotPasswordCommand(TenantCode, email), CancellationToken.None); + + // ========================================================================= + #region No se filtra la contraseña + // ========================================================================= + + [Fact] + public void ForgotPassword_ResponseCarriesOnlyTheAmbiguousMessage() + { + // El contrato mismo es la defensa: si alguien reintroduce un campo con la credencial, + // esta prueba se pone en rojo antes de que llegue a un entorno. + var propiedades = typeof(ForgotPasswordResponse).GetProperties().Select(p => p.Name).ToArray(); + + Assert.Equal(new[] { "Message" }, propiedades); + } + + [Fact] + public async Task ForgotPassword_WhenAccountExists_ResponseNeverContainsTheIssuedToken() + { + GivenTenantExists(); + GivenAccount(MakeUserWithPassword(TenantIdValue)); + + string? hashEmitido = null; + _resetTokens + .Setup(s => s.IssueAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) + .Callback( + (_, _, hash, _, _, _) => hashEmitido = hash); + + string? tokenNotificado = null; + _notifications + .Setup(n => n.SendAsync(It.IsAny(), It.IsAny())) + .Callback((n, _) => tokenNotificado = n.Body); + + var result = await Invoke(); + var cuerpo = JsonSerializer.Serialize(result.Value); + + Assert.NotNull(hashEmitido); + Assert.NotNull(tokenNotificado); + Assert.DoesNotContain(hashEmitido!, cuerpo, StringComparison.Ordinal); + Assert.DoesNotContain(ExistingHash, cuerpo, StringComparison.Ordinal); + } + + #endregion + + // ========================================================================= + #region La respuesta es indistinguible + // ========================================================================= + + [Fact] + public async Task ForgotPassword_ResponseIsByteIdenticalForExistingAndUnknownAccount() + { + GivenTenantExists(); + GivenAccount(MakeUserWithPassword(TenantIdValue)); + var conCuenta = await Invoke(); + + GivenAccount(null); + var sinCuenta = await Invoke("nadie@acme.com"); + + Assert.True(conCuenta.IsSuccess); + Assert.True(sinCuenta.IsSuccess); + Assert.Equal( + JsonSerializer.Serialize(conCuenta.Value), + JsonSerializer.Serialize(sinCuenta.Value)); + } + + [Fact] + public async Task ForgotPassword_WhenTenantIsUnknown_ResponseIsTheSame() + { + _tenantRepo.Setup(r => r.GetByCodeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Domain.Identity.Tenant.Tenant?)null); + + var sinTenant = await Invoke(); + + GivenTenantExists(); + GivenAccount(MakeUserWithPassword(TenantIdValue)); + var conTodo = await Invoke(); + + Assert.Equal( + JsonSerializer.Serialize(conTodo.Value), + JsonSerializer.Serialize(sinTenant.Value)); + } + + [Fact] + public async Task ForgotPassword_AlwaysNormalizesResponseTiming() + { + // Sin nivelar el reloj, el camino «existe» (lectura + escritura + notificación) tarda + // sistemáticamente más que el camino «no existe»: el tiempo sería el oráculo. + _tenantRepo.Setup(r => r.GetByCodeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Domain.Identity.Tenant.Tenant?)null); + await Invoke(); + + GivenTenantExists(); + GivenAccount(MakeUserWithPassword(TenantIdValue)); + await Invoke(); + + _timing.Verify(t => t.NormalizeAsync(It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + + #endregion + + // ========================================================================= + #region La contraseña vigente sobrevive a la solicitud + // ========================================================================= + + [Fact] + public async Task ForgotPassword_WhenAccountExists_LeavesTheCurrentPasswordUntouched() + { + GivenTenantExists(); + var user = MakeUserWithPassword(TenantIdValue); + GivenAccount(user); + + await Invoke(); + + var vigente = Assert.Single(user.PasswordCredentials); + Assert.True(vigente.IsActive); + Assert.Equal(ExistingHash, vigente.PasswordHash.GetValue()); + } + + [Fact] + public async Task ForgotPassword_NeverPersistsTheUserAccount() + { + GivenTenantExists(); + GivenAccount(MakeUserWithPassword(TenantIdValue)); + + await Invoke(); + + _userRepo.Verify(r => r.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never); + _uow.Verify(u => u.SaveEntitiesAsync(It.IsAny()), Times.Never); + } + + #endregion + + // ========================================================================= + #region Emisión del token + // ========================================================================= + + [Fact] + public async Task ForgotPassword_WhenAccountExists_IssuesShortLivedTokenAndNotifiesTheMailbox() + { + GivenTenantExists(); + var user = MakeUserWithPassword(TenantIdValue); + GivenAccount(user); + + await Invoke(); + + _resetTokens.Verify(s => s.IssueAsync( + TenantIdValue, + user.Props.Id.GetValue(), + It.Is(hash => hash.Length == 64), // SHA-256 hex: nunca el plaintext + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Once); + + _notifications.Verify(n => n.SendAsync( + It.Is(msg => msg.Recipient == UserEmail), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ForgotPassword_WhenAccountBelongsToAnotherTenant_IssuesNothing() + { + GivenTenantExists(); + GivenAccount(MakeUserWithPassword(OtherTenantIdValue)); + + await Invoke(); + + VerifyNothingHappened(); + } + + [Fact] + public async Task ForgotPassword_WhenAccountIsFederated_IssuesNothing() + { + GivenTenantExists(); + var federado = UserAccount.Create( + TenantId.Load(TenantIdValue), + Email.Create(UserEmail), + UserCategory.Internal, + IdentityReference.Create("keycloak|abc"), + IdentityReferenceType.PartnerRef, + ActorId.Create("sys")).Value; + federado.Activate(ActorId.Create("sys")); + GivenAccount(federado); + + await Invoke(); + + VerifyNothingHappened(); + } + + [Fact] + public async Task ForgotPassword_WhenAccountIsBlocked_IssuesNothing() + { + GivenTenantExists(); + var bloqueado = MakeUserWithPassword(TenantIdValue); + bloqueado.Block(Reason.Create("prueba"), ActorId.Create("sys")); + GivenAccount(bloqueado); + + await Invoke(); + + VerifyNothingHappened(); + } + + [Fact] + public async Task ForgotPassword_WhenTenantIsUnknown_DoesNotEvenLookUpTheAccount() + { + _tenantRepo.Setup(r => r.GetByCodeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Domain.Identity.Tenant.Tenant?)null); + + await Invoke(); + + _userRepo.Verify(r => r.GetByEmailAsync(It.IsAny(), It.IsAny()), Times.Never); + VerifyNothingHappened(); + } + + private void VerifyNothingHappened() + { + _resetTokens.Verify(s => s.IssueAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + _notifications.Verify(n => n.SendAsync(It.IsAny(), It.IsAny()), Times.Never); + _userRepo.Verify(r => r.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + #endregion +} diff --git a/src/apps/ums.api/Ums.Application.Test/Identity/Auth/IdpChainAuthenticatorTests.cs b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/IdpChainAuthenticatorTests.cs new file mode 100644 index 00000000..39d76051 --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/IdpChainAuthenticatorTests.cs @@ -0,0 +1,338 @@ +namespace Ums.Application.Test.Identity.Auth; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using Xunit; +using Ums.Application.Common.Interfaces; +using Ums.Application.Configuration.Services; +using Ums.Application.Identity.Auth; +using Ums.Domain.Configuration; +using Ums.Domain.Identity.Auth; +using Ums.Domain.Identity.Tenant.IdentityProvider; +using IdpConfigurationAggregate = Ums.Domain.Configuration.IdpConfiguration.IdpConfiguration; +using TenantAggregate = Ums.Domain.Identity.Tenant.Tenant; + +/// +/// FR-042 (ADR-UMS-097 §2.3/§2.4, slice 2b): pruebas de seguridad del fallback encadenado. +/// +/// Las CLAVE son las de seguridad: +/// · Fallback SOLO por indisponibilidad: primario infra → intenta el siguiente; si el +/// siguiente tiene éxito → login OK. +/// · NO fallback por credenciales (anti credential-spraying): primario rechaza credenciales +/// → TERMINAL y el segundo IdP nunca se invoca. +/// · Ambiguo → terminal: un fallo no clasificable como infra no avanza. +/// · Ciclo A→B→A detectado, sin bucle; cadena agotada → 503 (AUTH_018), no 401. +/// · Auditoría: un evento por proveedor intentado. +/// +public class IdpChainAuthenticatorTests +{ + private static readonly Guid TenantGuid = Guid.NewGuid(); + private static readonly ActorId Actor = ActorId.Create("test"); + private const string Credential = "callback-o-password"; + private const string ClientIp = "10.0.0.1"; + + private readonly Mock _idpConfigRepo = new(); + private readonly Mock _idpStrategy = new(); + private readonly Mock _audit = new(); + private readonly Mock _config = new(); + + public IdpChainAuthenticatorTests() + { + // Tope de saltos: por defecto devuelve el default pasado por el orquestador (5), salvo que un + // test lo sobreescriba. Un método no configurado en Moq devolvería 0 → maxHops=1, así que ESTE + // setup es necesario para que la cadena pueda avanzar en los tests de fallback. + _config.Setup(c => c.GetValueAs(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((_, _, defaultValue) => defaultValue); + + _audit.Setup(a => a.RecordAuthEventAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + } + + private IdpChainAuthenticator CreateSut() + => new(_idpConfigRepo.Object, _idpStrategy.Object, _audit.Object, _config.Object); + + // ── Fallback por indisponibilidad ──────────────────────────────────────────── + + [Fact] + public async Task Fallback_PrimaryInfraUnavailable_AdvancesAndSecondSucceeds() + { + var tenant = BuildTenant(IdpStrategy.AzureAd, IdpStrategy.Keycloak); + // A (AzureAd, prio 1) → B (Keycloak, prio 2). El selector elige A por prioridad. + var b = BuildActiveConfig(ProviderType.Keycloak, priority: 2); + var a = BuildActiveConfig(ProviderType.AzureAd, priority: 1); + Link(a, b); + SetupConfigurations(a, b); + + SetupStrategy(IdpStrategy.AzureAd, Infra("AUTH_034")); // primario: JWKS caído + SetupStrategy(IdpStrategy.Keycloak, Ok("user@ransa.pe")); // respaldo: éxito + + var result = await CreateSut().AuthenticateAsync(tenant, Credential, null, null, ClientIp); + + Assert.True(result.IsSuccess, result.IsFailure ? result.Error : null); + Assert.Equal("user@ransa.pe", result.Value.Identity.Email); + Assert.Equal(IdpStrategy.Keycloak.Id, result.Value.Provider.Strategy.Id); + VerifyStrategyCalled(IdpStrategy.AzureAd, Times.Once()); + VerifyStrategyCalled(IdpStrategy.Keycloak, Times.Once()); + VerifyAuditEvents(Times.Exactly(2)); // un evento por proveedor intentado + } + + // ── NO fallback por credenciales (anti credential-spraying) — PRUEBA CLAVE ──── + + [Fact] + public async Task Credential_PrimaryRejects_Terminal_SecondNeverInvoked() + { + var tenant = BuildTenant(IdpStrategy.AzureAd, IdpStrategy.Keycloak); + var b = BuildActiveConfig(ProviderType.Keycloak, priority: 2); + var a = BuildActiveConfig(ProviderType.AzureAd, priority: 1); + Link(a, b); + SetupConfigurations(a, b); + + SetupStrategy(IdpStrategy.AzureAd, CredentialRejected("AUTH_021")); // primario: el IdP RECHAZÓ la credencial + SetupStrategy(IdpStrategy.Keycloak, Ok("attacker@ransa.pe")); // nunca debe llegar aquí + + var result = await CreateSut().AuthenticateAsync(tenant, Credential, null, null, ClientIp); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_021", result.Error); // terminal, se devuelve el error del IdP + VerifyStrategyCalled(IdpStrategy.AzureAd, Times.Once()); + VerifyStrategyCalled(IdpStrategy.Keycloak, Times.Never()); // el SEGUNDO IdP NO se intenta + VerifyAuditEvents(Times.Exactly(1)); // solo el intento primario + } + + // ── Ambiguo → terminal (fail-closed) ───────────────────────────────────────── + + [Fact] + public async Task Ambiguous_UnclassifiableFailure_Terminal_NoAdvance() + { + var tenant = BuildTenant(IdpStrategy.AzureAd, IdpStrategy.Keycloak); + var b = BuildActiveConfig(ProviderType.Keycloak, priority: 2); + var a = BuildActiveConfig(ProviderType.AzureAd, priority: 1); + Link(a, b); + SetupConfigurations(a, b); + + // Fallo no clasificable como infra (no está en la lista blanca) → fail-closed → terminal. + SetupStrategy(IdpStrategy.AzureAd, Result.Failure("AUTH_099: fallo raro no clasificable")); + SetupStrategy(IdpStrategy.Keycloak, Ok()); + + var result = await CreateSut().AuthenticateAsync(tenant, Credential, null, null, ClientIp); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_099", result.Error); + VerifyStrategyCalled(IdpStrategy.Keycloak, Times.Never()); + VerifyAuditEvents(Times.Exactly(1)); + } + + // ── Ciclo A→B→A detectado; cadena agotada → 503 ────────────────────────────── + + [Fact] + public async Task Cycle_AtoBtoA_Detected_NoInfiniteLoop_Returns503() + { + var tenant = BuildTenant(IdpStrategy.AzureAd, IdpStrategy.Keycloak); + var a = BuildActiveConfig(ProviderType.AzureAd, priority: 1); + var b = BuildActiveConfig(ProviderType.Keycloak, priority: 2); + Link(a, b); + Link(b, a); // ciclo + SetupConfigurations(a, b); + + SetupStrategy(IdpStrategy.AzureAd, Infra("AUTH_034")); + SetupStrategy(IdpStrategy.Keycloak, Infra("AUTH_012")); + + var result = await CreateSut().AuthenticateAsync(tenant, Credential, null, null, ClientIp); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_018", result.Error); // cadena agotada → 503 + VerifyStrategyCalled(IdpStrategy.AzureAd, Times.Once()); + VerifyStrategyCalled(IdpStrategy.Keycloak, Times.Once()); + VerifyAuditEvents(Times.Exactly(2)); // A y B, sin bucle infinito + } + + // ── Cadena lineal agotada (todos infra) → 503 ──────────────────────────────── + + [Fact] + public async Task ChainExhausted_AllInfra_ReturnsServiceUnavailable() + { + var tenant = BuildTenant(IdpStrategy.AzureAd, IdpStrategy.Keycloak); + var b = BuildActiveConfig(ProviderType.Keycloak, priority: 2); + var a = BuildActiveConfig(ProviderType.AzureAd, priority: 1); + Link(a, b); + SetupConfigurations(a, b); + + SetupStrategy(IdpStrategy.AzureAd, Infra("AUTH_034")); + SetupStrategy(IdpStrategy.Keycloak, Infra("AUTH_034")); + + var result = await CreateSut().AuthenticateAsync(tenant, Credential, null, null, ClientIp); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_018", result.Error); + VerifyAuditEvents(Times.Exactly(2)); + } + + // ── Tope de saltos configurable ────────────────────────────────────────────── + + [Fact] + public async Task HopCap_LimitsTraversal_StopsAtCap() + { + var tenant = BuildTenant(IdpStrategy.AzureAd, IdpStrategy.Keycloak, IdpStrategy.Okta); + var c = BuildActiveConfig(ProviderType.Okta, priority: 3); + var b = BuildActiveConfig(ProviderType.Keycloak, priority: 2); + var a = BuildActiveConfig(ProviderType.AzureAd, priority: 1); + Link(a, b); + Link(b, c); + SetupConfigurations(a, b, c); + + // Tope = 1 salto: solo se intenta el primario, la cadena se corta antes del segundo. + _config.Setup(cfg => cfg.GetValueAs(IdpChainAuthenticator.MaxHopsConfigCode, It.IsAny(), It.IsAny())) + .Returns(1); + + SetupStrategy(IdpStrategy.AzureAd, Infra("AUTH_034")); + SetupStrategy(IdpStrategy.Keycloak, Ok()); + SetupStrategy(IdpStrategy.Okta, Ok()); + + var result = await CreateSut().AuthenticateAsync(tenant, Credential, null, null, ClientIp); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_018", result.Error); + VerifyStrategyCalled(IdpStrategy.AzureAd, Times.Once()); + VerifyStrategyCalled(IdpStrategy.Keycloak, Times.Never()); + VerifyAuditEvents(Times.Exactly(1)); + } + + // ── Camino feliz sin fallback ──────────────────────────────────────────────── + + [Fact] + public async Task Primary_Succeeds_NoAdvance_SingleAttempt() + { + var tenant = BuildTenant(IdpStrategy.AzureAd, IdpStrategy.Keycloak); + var b = BuildActiveConfig(ProviderType.Keycloak, priority: 2); + var a = BuildActiveConfig(ProviderType.AzureAd, priority: 1); + Link(a, b); + SetupConfigurations(a, b); + + SetupStrategy(IdpStrategy.AzureAd, Ok("user@ransa.pe")); + + var result = await CreateSut().AuthenticateAsync(tenant, Credential, null, null, ClientIp); + + Assert.True(result.IsSuccess); + Assert.Equal(IdpStrategy.AzureAd.Id, result.Value.Provider.Strategy.Id); + VerifyStrategyCalled(IdpStrategy.Keycloak, Times.Never()); + VerifyAuditEvents(Times.Exactly(1)); + } + + // ── Legado: sin IdpConfiguration gobernante → intento único, sin 503 ────────── + + [Fact] + public async Task Legacy_NoGoverningConfig_SingleAttempt_Succeeds() + { + var tenant = BuildTenant(IdpStrategy.AzureAd); + SetupConfigurations(); // sin configuraciones → path legado + + SetupStrategy(IdpStrategy.AzureAd, Ok("user@ransa.pe")); + + var result = await CreateSut().AuthenticateAsync(tenant, Credential, null, null, ClientIp); + + Assert.True(result.IsSuccess); + Assert.Equal(IdpStrategy.AzureAd.Id, result.Value.Provider.Strategy.Id); + VerifyAuditEvents(Times.Exactly(1)); + } + + [Fact] + public async Task Legacy_NoGoverningConfig_InfraFailure_ReturnsRawError_NotChainExhausted() + { + // Sin cadena no hay 503: el intento único devuelve el error crudo (comportamiento previo a 2b). + var tenant = BuildTenant(IdpStrategy.AzureAd); + SetupConfigurations(); + + SetupStrategy(IdpStrategy.AzureAd, Infra("AUTH_034")); + + var result = await CreateSut().AuthenticateAsync(tenant, Credential, null, null, ClientIp); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_034", result.Error); + Assert.DoesNotContain("AUTH_018", result.Error); + VerifyAuditEvents(Times.Exactly(1)); + } + + // ── Helpers ────────────────────────────────────────────────────────────────── + + private void SetupConfigurations(params IdpConfigurationAggregate[] configurations) + => _idpConfigRepo.Setup(r => r.GetByTenantIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(configurations.ToList()); + + private void SetupStrategy(IdpStrategy strategy, Result result) + => _idpStrategy.Setup(s => s.AuthenticateAsync( + It.IsAny(), It.IsAny(), + It.Is(p => p.Strategy.Id == strategy.Id), It.IsAny())) + .ReturnsAsync(result); + + private void VerifyStrategyCalled(IdpStrategy strategy, Times times) + => _idpStrategy.Verify(s => s.AuthenticateAsync( + It.IsAny(), It.IsAny(), + It.Is(p => p.Strategy.Id == strategy.Id), It.IsAny()), + times); + + private void VerifyAuditEvents(Times times) + => _audit.Verify(a => a.RecordAuthEventAsync(It.IsAny(), It.IsAny()), times); + + private static Result Ok(string email = "user@ransa.pe") + => Result.Success(new ExternalIdentity(email, "sub", "User", new Dictionary())); + + private static Result Infra(string code) + => Result.Failure($"{code}: indisponibilidad de infraestructura del IdP."); + + private static Result CredentialRejected(string code) + => Result.Failure($"{code}: el IdP rechazó la credencial."); + + private static TenantAggregate BuildTenant(params IdpStrategy[] registeredStrategies) + { + var tenant = TenantAggregate.Create( + Code.Create("TEST"), + Name.Create("Test Tenant"), + Ums.Domain.Enums.OrganizationType.INTERNAL, + Actor, + registeredStrategies[0], + tenantId: TenantId.Load(TenantGuid)).Value; + + foreach (var strategy in registeredStrategies) + { + tenant.RegisterIdentityProvider( + Code.Create(strategy.Name.ToUpperInvariant()), + Name.Create(strategy.Name), + Description.Create(string.Empty), + strategy, + Actor); + } + + // Se activa el primer proveedor como «primario» (el inquilino solo admite uno activo). Los demás + // quedan registrados+inactivos: el puente 2b los usa igualmente porque la cadena la gobierna + // IdpConfiguration.Status, no el flag de proveedor activo (ADR-UMS-097 §2.5). + var first = tenant.IdentityProviders.First(); + tenant.ActivateIdentityProvider(first.GetId(), Actor); + tenant.DomainEvents.MarkChangesAsCommitted(); + return tenant; + } + + private static IdpConfigurationAggregate BuildActiveConfig(ProviderType providerType, int priority) + { + var config = IdpConfigurationAggregate.Create( + TenantId.Load(TenantGuid), + SystemSuiteId.Create(), + providerType, + Array.Empty(), + "{\"issuer\":\"https://idp.example\"}", + "vault/secret/idp", + priority, + fallbackToId: null, + Actor).Value; + config.Activate(Actor); + return config; + } + + // Enlaza from → to por FallbackToId (setter público de Props; el enlace es inmutable vía Create, + // así que en tests se establece directamente para poder construir cadenas y ciclos). + private static void Link(IdpConfigurationAggregate from, IdpConfigurationAggregate to) + => from.Props.FallbackToId = to.Props.Id.GetValue(); +} diff --git a/src/apps/ums.api/Ums.Application.Test/Identity/Auth/Oidc/HttpOidcTokenClientTests.cs b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/Oidc/HttpOidcTokenClientTests.cs new file mode 100644 index 00000000..e5f406fa --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/Oidc/HttpOidcTokenClientTests.cs @@ -0,0 +1,175 @@ +namespace Ums.Application.Test.Identity.Auth.Oidc; + +using System.Net; +using System.Net.Http; +using Xunit; +using Ums.Domain.Identity.Auth; +using Ums.Infrastructure.Identity.Auth.Oidc; + +/// +/// FR-042 · ADR-UMS-097 §2.3 · G-108 — pruebas de la SEPARACIÓN ESTRUCTURAL que hace +/// al intercambiar el código en el token endpoint: +/// +/// 5xx / timeout / transporte → indisponibilidad de INFRAESTRUCTURA → código +/// AUTH_035, que el reconoce como infra y que +/// HABILITA el fallback encadenado. +/// 4xx (invalid_grant/…) → fallo de CREDENCIAL → código AUTH_021, que sigue +/// siendo TERMINAL (NUNCA infra): es la invariante anti credential spraying de ADR-UMS-097 §2.3. +/// +/// La distinción se hace por la CLASE de status HTTP / tipo de excepción, no por el texto del mensaje. +/// Todos los casos usan un fake; ningún IdP real interviene. +/// +public sealed class HttpOidcTokenClientTests +{ + private static readonly OidcEndpoints Endpoints = OidcTestSupport.Endpoints(); + private static readonly OidcClientSettings Client = + new("ums-client", "secreto", "https://ums.test/callback"); + + private static HttpOidcTokenClient ClientWith(FakeTokenHttpHandler handler) => + new(new HttpClient(handler)); + + private static Task> Exchange(HttpOidcTokenClient sut, CancellationToken ct = default) => + sut.ExchangeAuthorizationCodeAsync( + Endpoints, Client, code: "auth-code-123", codeVerifier: "verifier-123", + redirectUri: "https://ums.test/callback", ct); + + // ── Rama de INFRAESTRUCTURA: 5xx → AUTH_035 → clasifica como infra (habilita fallback) ──────── + + [Theory] + [InlineData(HttpStatusCode.InternalServerError)] // 500 + [InlineData(HttpStatusCode.BadGateway)] // 502 + [InlineData(HttpStatusCode.ServiceUnavailable)] // 503 + [InlineData(HttpStatusCode.GatewayTimeout)] // 504 + public async Task Exchange_TokenEndpoint5xx_EmitsInfraCode_AUTH035(HttpStatusCode status) + { + var sut = ClientWith(FakeTokenHttpHandler.WithStatus(status, "{\"error\":\"server_error\"}")); + + var result = await Exchange(sut); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_035", result.Error); + Assert.DoesNotContain("AUTH_021", result.Error); + // Verificación de extremo a extremo: el código emitido lo trata el clasificador como infra. + Assert.True(IdpAuthOutcomeClassifier.IsInfraUnavailable(result.Error)); + } + + [Fact] + public async Task Exchange_TransportFailure_EmitsInfraCode_AUTH035() + { + // HttpRequestException = no se pudo completar el round-trip (conexión/red/DNS) → INFRA. + var sut = ClientWith(FakeTokenHttpHandler.Throwing(new HttpRequestException("connection refused"))); + + var result = await Exchange(sut); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_035", result.Error); + Assert.True(IdpAuthOutcomeClassifier.IsInfraUnavailable(result.Error)); + } + + [Fact] + public async Task Exchange_Timeout_EmitsInfraCode_AUTH035() + { + // Timeout del token endpoint: el HttpClient aborta con TaskCanceledException SIN que el token del + // llamador esté cancelado → INFRA (no es una cancelación del llamador). + var sut = ClientWith(FakeTokenHttpHandler.Throwing(new TaskCanceledException("timeout"))); + + var result = await Exchange(sut, ct: default); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_035", result.Error); + Assert.True(IdpAuthOutcomeClassifier.IsInfraUnavailable(result.Error)); + } + + // ── Rama de CREDENCIAL: 4xx → AUTH_021 → TERMINAL (NUNCA infra) — anti credential spraying ──── + + [Theory] + [InlineData(HttpStatusCode.BadRequest, "{\"error\":\"invalid_grant\"}")] // 400 invalid_grant (código inválido) + [InlineData(HttpStatusCode.Unauthorized, "{\"error\":\"invalid_client\"}")] // 401 invalid_client + [InlineData(HttpStatusCode.Forbidden, "{\"error\":\"access_denied\"}")] // 403 + public async Task Exchange_TokenEndpoint4xx_EmitsCredentialCode_AUTH021_NotInfra(HttpStatusCode status, string body) + { + var sut = ClientWith(FakeTokenHttpHandler.WithStatus(status, body)); + + var result = await Exchange(sut); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_021", result.Error); + Assert.DoesNotContain("AUTH_035", result.Error); + // GUARDA ANTI-REGRESIÓN DE SEGURIDAD: un 4xx (fallo de credencial) JAMÁS debe clasificarse como + // infra; hacerlo permitiría encadenar la misma credencial contra cada IdP (credential spraying + // cross-IdP, ADR-UMS-097 §2.3). NO "arreglar" esta aserción debilitándola: la resiliencia se ganó + // separando el 5xx en AUTH_035, no relajando el 4xx. + Assert.False(IdpAuthOutcomeClassifier.IsInfraUnavailable(result.Error)); + } + + // ── Cuerpo 2xx malformado: la credencial fue aceptada pero no parsea → TERMINAL (fail-closed) ─ + + [Fact] + public async Task Exchange_Success2xx_MalformedJsonBody_EmitsTerminalCode_AUTH021_NotInfra() + { + var sut = ClientWith(FakeTokenHttpHandler.WithStatus(HttpStatusCode.OK, "esto-no-es-json")); + + var result = await Exchange(sut); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_021", result.Error); + Assert.False(IdpAuthOutcomeClassifier.IsInfraUnavailable(result.Error)); + } + + // ── Cancelación del LLAMADOR: no es indisponibilidad del IdP → se propaga (no se enmascara) ─── + + [Fact] + public async Task Exchange_CallerCancels_PropagatesCancellation_NotInfra() + { + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + var sut = ClientWith(FakeTokenHttpHandler.HonoringCancellation()); + + await Assert.ThrowsAnyAsync(() => Exchange(sut, cts.Token)); + } + + // ── Camino feliz: 2xx con JSON válido → id_token parseado ───────────────────────────────────── + + [Fact] + public async Task Exchange_Success2xx_ValidJson_ReturnsToken() + { + const string body = + "{\"id_token\":\"abc.def.ghi\",\"access_token\":\"at\",\"token_type\":\"Bearer\",\"expires_in\":300}"; + var sut = ClientWith(FakeTokenHttpHandler.WithStatus(HttpStatusCode.OK, body)); + + var result = await Exchange(sut); + + Assert.True(result.IsSuccess, result.IsFailure ? result.Error : null); + Assert.Equal("abc.def.ghi", result.Value.IdToken); + } + + /// + /// fake que devuelve un status configurado, lanza una excepción + /// de transporte/timeout, u honra la cancelación del token. Permite ejercer las ramas 5xx/4xx/2xx + /// y de excepción de sin un IdP real. + /// + private sealed class FakeTokenHttpHandler : HttpMessageHandler + { + private readonly Func _responder; + + private FakeTokenHttpHandler(Func responder) + => _responder = responder; + + public static FakeTokenHttpHandler WithStatus(HttpStatusCode status, string body) => + new((_, _) => new HttpResponseMessage(status) { Content = new StringContent(body) }); + + public static FakeTokenHttpHandler Throwing(Exception ex) => + new((_, _) => throw ex); + + public static FakeTokenHttpHandler HonoringCancellation() => + new((_, ct) => + { + ct.ThrowIfCancellationRequested(); + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") }; + }); + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + => Task.FromResult(_responder(request, cancellationToken)); + } +} diff --git a/src/apps/ums.api/Ums.Application.Test/Identity/Auth/Oidc/OidcAuthorizationRequestFactoryTests.cs b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/Oidc/OidcAuthorizationRequestFactoryTests.cs new file mode 100644 index 00000000..8d4363d9 --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/Oidc/OidcAuthorizationRequestFactoryTests.cs @@ -0,0 +1,102 @@ +namespace Ums.Application.Test.Identity.Auth.Oidc; + +using System.Security.Cryptography; +using System.Text; +using Xunit; +using Ums.Infrastructure.Identity.Auth.Oidc; + +/// +/// Unit tests de la construcción de la URL de autorización (Authorization Code + PKCE +/// S256) y de las primitivas PKCE (ADR-UMS-094). +/// +public sealed class OidcAuthorizationRequestFactoryTests +{ + private static OidcProviderConfig Config() => + new( + OidcTestSupport.Endpoints(), + new OidcClientSettings( + ClientId: "ums-client", + ClientSecret: "secreto", + RedirectUri: "https://ums.test/callback", + Scopes: "openid email profile")); + + [Fact] + public void Build_ProducesAuthorizationCodePkceUrlWithAllRequiredParameters() + { + var config = Config(); + + var request = new OidcAuthorizationRequestFactory().Build(config); + + var uri = new Uri(request.AuthorizationUrl); + var query = ParseQuery(uri.Query); + + Assert.StartsWith(config.Endpoints.AuthorizationEndpoint, request.AuthorizationUrl); + Assert.Equal("code", query["response_type"]); + Assert.Equal("ums-client", query["client_id"]); + Assert.Equal("https://ums.test/callback", query["redirect_uri"]); + Assert.Equal("openid email profile", query["scope"]); + Assert.Equal("S256", query["code_challenge_method"]); + Assert.Equal(request.State, query["state"]); + Assert.Equal(request.Nonce, query["nonce"]); + Assert.Equal(request.CodeChallenge, query["code_challenge"]); + } + + [Fact] + public void Build_CodeChallengeIsS256OfCodeVerifier() + { + var request = new OidcAuthorizationRequestFactory().Build(Config()); + + var expected = OidcPkce.ComputeS256Challenge(request.CodeVerifier); + + Assert.Equal(expected, request.CodeChallenge); + // El code_verifier nunca viaja en la URL de autorización (sólo el challenge). + Assert.DoesNotContain(request.CodeVerifier, request.AuthorizationUrl); + } + + [Fact] + public void Build_GeneratesFreshStateNonceVerifierEachTime() + { + var factory = new OidcAuthorizationRequestFactory(); + + var a = factory.Build(Config()); + var b = factory.Build(Config()); + + Assert.NotEqual(a.State, b.State); + Assert.NotEqual(a.Nonce, b.Nonce); + Assert.NotEqual(a.CodeVerifier, b.CodeVerifier); + } + + [Fact] + public void ComputeS256Challenge_MatchesManualSha256Base64Url() + { + const string verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + + var challenge = OidcPkce.ComputeS256Challenge(verifier); + + var expected = OidcTestSupport.Base64Url(SHA256.HashData(Encoding.ASCII.GetBytes(verifier))); + Assert.Equal(expected, challenge); + } + + [Fact] + public void Build_WithLoginHint_IncludesLoginHint() + { + var request = new OidcAuthorizationRequestFactory().Build(Config(), loginHint: "user@ransa.pe"); + + var query = ParseQuery(new Uri(request.AuthorizationUrl).Query); + Assert.Equal("user@ransa.pe", query["login_hint"]); + } + + private static Dictionary ParseQuery(string query) + { + var result = new Dictionary(StringComparer.Ordinal); + foreach (var pair in query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + var idx = pair.IndexOf('='); + var key = idx >= 0 ? pair[..idx] : pair; + var val = idx >= 0 ? pair[(idx + 1)..] : string.Empty; + result[Uri.UnescapeDataString(key)] = Uri.UnescapeDataString(val); + } + + return result; + } +} diff --git a/src/apps/ums.api/Ums.Application.Test/Identity/Auth/Oidc/OidcIdTokenValidatorTests.cs b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/Oidc/OidcIdTokenValidatorTests.cs new file mode 100644 index 00000000..b72fc5f5 --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/Oidc/OidcIdTokenValidatorTests.cs @@ -0,0 +1,229 @@ +namespace Ums.Application.Test.Identity.Auth.Oidc; + +using System.Security.Cryptography; +using Xunit; +using Ums.Infrastructure.Identity.Auth.Oidc; + +/// +/// Unit tests de la validación estricta del id_token (ADR-UMS-094 · G-049). +/// Verifica éxito y, sobre todo, los negativos de seguridad (firma, exp, aud, nonce, +/// iss, alg) con llaves RSA generadas en el propio test y un JWKS fake — nunca un IdP +/// real (eso es el arnés Keycloak, slice 2). +/// +public sealed class OidcIdTokenValidatorTests +{ + private const string Kid = "test-key-1"; + private const string Audience = "ums-client"; + private const string Nonce = "nonce-123"; + + private readonly RSA _rsa = RSA.Create(2048); + private readonly OidcEndpoints _endpoints = OidcTestSupport.Endpoints(); + + private static OidcIdTokenValidator CreateSut(IJwksProvider jwks, TimeProvider? clock = null) + => new(jwks, clock ?? TimeProvider.System); + + private string SignValid(RSA signingKey, string alg = "RS256") + => OidcTestSupport.BuildIdToken( + signingKey, Kid, + OidcTestSupport.ValidPayload(_endpoints.Issuer, Audience, Nonce), + alg); + + // ── Éxito + mapeo de claims ───────────────────────────────────────────────── + + [Fact] + public async Task ValidateAsync_ValidToken_ReturnsSuccessWithMappedIdentity() + { + var jwks = FakeJwksProvider.WithKeys(OidcTestSupport.PublicJwk(_rsa, Kid)); + var token = SignValid(_rsa); + + var result = await CreateSut(jwks).ValidateAsync(token, _endpoints, Audience, Nonce); + + Assert.True(result.IsSuccess, result.IsFailure ? result.Error : null); + Assert.Equal("user@ransa.pe", result.Value.Email); + Assert.Equal("idp|abc-123", result.Value.ExternalId); + Assert.Equal("Ana Ransa", result.Value.DisplayName); + Assert.Equal("user@ransa.pe", result.Value.Claims["email"]); + Assert.Equal("aransa", result.Value.Claims["preferred_username"]); + } + + // ── Negativos: firma ──────────────────────────────────────────────────────── + + [Fact] + public async Task ValidateAsync_SignatureFromDifferentKey_ReturnsFailure_AUTH026() + { + using var attacker = RSA.Create(2048); + var jwks = FakeJwksProvider.WithKeys(OidcTestSupport.PublicJwk(_rsa, Kid)); // publica la buena + var token = SignValid(attacker); // firmada con otra llave + + var result = await CreateSut(jwks).ValidateAsync(token, _endpoints, Audience, Nonce); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_026", result.Error); + } + + [Fact] + public async Task ValidateAsync_AlgorithmNone_ReturnsFailure_AUTH024() + { + var jwks = FakeJwksProvider.WithKeys(OidcTestSupport.PublicJwk(_rsa, Kid)); + var token = SignValid(_rsa, alg: "none"); + + var result = await CreateSut(jwks).ValidateAsync(token, _endpoints, Audience, Nonce); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_024", result.Error); + } + + [Fact] + public async Task ValidateAsync_AlgorithmConfusionHs256_ReturnsFailure_AUTH024() + { + // Confusión de algoritmo: un atacante firma HS256 con el 'client secret'. + var jwks = FakeJwksProvider.WithKeys(OidcTestSupport.PublicJwk(_rsa, Kid)); + var token = SignValid(_rsa, alg: "HS256"); + + var result = await CreateSut(jwks).ValidateAsync(token, _endpoints, Audience, Nonce); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_024", result.Error); + } + + [Fact] + public async Task ValidateAsync_UnknownKid_ReturnsFailure_AUTH025() + { + var jwks = FakeJwksProvider.WithKeys(OidcTestSupport.PublicJwk(_rsa, "otro-kid")); + var token = SignValid(_rsa); + + var result = await CreateSut(jwks).ValidateAsync(token, _endpoints, Audience, Nonce); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_025", result.Error); + } + + [Fact] + public async Task ValidateAsync_JwksFetchFails_ReturnsFailure_AUTH034() + { + var jwks = FakeJwksProvider.Failing("boom"); + var token = SignValid(_rsa); + + var result = await CreateSut(jwks).ValidateAsync(token, _endpoints, Audience, Nonce); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_034", result.Error); + } + + // ── Negativos: claims ─────────────────────────────────────────────────────── + + [Fact] + public async Task ValidateAsync_Expired_ReturnsFailure_AUTH029() + { + var jwks = FakeJwksProvider.WithKeys(OidcTestSupport.PublicJwk(_rsa, Kid)); + var past = DateTimeOffset.UtcNow.ToUnixTimeSeconds() - 3600; + var token = OidcTestSupport.BuildIdToken( + _rsa, Kid, + OidcTestSupport.ValidPayload(_endpoints.Issuer, Audience, Nonce, exp: past, nbf: past - 60)); + + var result = await CreateSut(jwks).ValidateAsync(token, _endpoints, Audience, Nonce); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_029", result.Error); + } + + [Fact] + public async Task ValidateAsync_NotYetValid_ReturnsFailure_AUTH030() + { + var jwks = FakeJwksProvider.WithKeys(OidcTestSupport.PublicJwk(_rsa, Kid)); + var future = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + 3600; + var token = OidcTestSupport.BuildIdToken( + _rsa, Kid, + OidcTestSupport.ValidPayload(_endpoints.Issuer, Audience, Nonce, exp: future + 300, nbf: future)); + + var result = await CreateSut(jwks).ValidateAsync(token, _endpoints, Audience, Nonce); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_030", result.Error); + } + + [Fact] + public async Task ValidateAsync_WrongAudience_ReturnsFailure_AUTH028() + { + var jwks = FakeJwksProvider.WithKeys(OidcTestSupport.PublicJwk(_rsa, Kid)); + var token = OidcTestSupport.BuildIdToken( + _rsa, Kid, + OidcTestSupport.ValidPayload(_endpoints.Issuer, audience: "otro-cliente", nonce: Nonce)); + + var result = await CreateSut(jwks).ValidateAsync(token, _endpoints, Audience, Nonce); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_028", result.Error); + } + + [Fact] + public async Task ValidateAsync_WrongIssuer_ReturnsFailure_AUTH027() + { + var jwks = FakeJwksProvider.WithKeys(OidcTestSupport.PublicJwk(_rsa, Kid)); + var token = OidcTestSupport.BuildIdToken( + _rsa, Kid, + OidcTestSupport.ValidPayload(issuer: "https://malicioso.test", audience: Audience, nonce: Nonce)); + + var result = await CreateSut(jwks).ValidateAsync(token, _endpoints, Audience, Nonce); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_027", result.Error); + } + + [Fact] + public async Task ValidateAsync_NonceMismatch_ReturnsFailure_AUTH031() + { + var jwks = FakeJwksProvider.WithKeys(OidcTestSupport.PublicJwk(_rsa, Kid)); + var token = OidcTestSupport.BuildIdToken( + _rsa, Kid, + OidcTestSupport.ValidPayload(_endpoints.Issuer, Audience, nonce: "nonce-del-atacante")); + + var result = await CreateSut(jwks).ValidateAsync(token, _endpoints, Audience, expectedNonce: Nonce); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_031", result.Error); + } + + [Fact] + public async Task ValidateAsync_MissingEmail_ReturnsFailure_AUTH032() + { + var jwks = FakeJwksProvider.WithKeys(OidcTestSupport.PublicJwk(_rsa, Kid)); + var payload = OidcTestSupport.ValidPayload(_endpoints.Issuer, Audience, Nonce); + payload.Remove("email"); + var token = OidcTestSupport.BuildIdToken(_rsa, Kid, payload); + + var result = await CreateSut(jwks).ValidateAsync(token, _endpoints, Audience, Nonce); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_032", result.Error); + } + + [Fact] + public async Task ValidateAsync_MalformedToken_ReturnsFailure_AUTH023() + { + var jwks = FakeJwksProvider.WithKeys(OidcTestSupport.PublicJwk(_rsa, Kid)); + + var result = await CreateSut(jwks).ValidateAsync("no-es-un-jwt", _endpoints, Audience, Nonce); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_023", result.Error); + } + + [Fact] + public async Task ValidateAsync_ExpiredButWithinClockSkew_ReturnsSuccess() + { + // exp hace 30s, con skew por defecto de 2 min → aún válido. + var jwks = FakeJwksProvider.WithKeys(OidcTestSupport.PublicJwk(_rsa, Kid)); + var now = DateTimeOffset.UtcNow; + var token = OidcTestSupport.BuildIdToken( + _rsa, Kid, + OidcTestSupport.ValidPayload( + _endpoints.Issuer, Audience, Nonce, + exp: now.ToUnixTimeSeconds() - 30)); + + var clock = new FixedTimeProvider(now); + var result = await CreateSut(jwks, clock).ValidateAsync(token, _endpoints, Audience, Nonce); + + Assert.True(result.IsSuccess, result.IsFailure ? result.Error : null); + } +} diff --git a/src/apps/ums.api/Ums.Application.Test/Identity/Auth/Oidc/OidcIdpAuthAdapterTests.cs b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/Oidc/OidcIdpAuthAdapterTests.cs new file mode 100644 index 00000000..59afcc6b --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/Oidc/OidcIdpAuthAdapterTests.cs @@ -0,0 +1,156 @@ +namespace Ums.Application.Test.Identity.Auth.Oidc; + +using System.Security.Cryptography; +using System.Text.Json; +using Xunit; +using Ums.Domain.Identity.Tenant.IdentityProvider; +using Ums.Infrastructure.Identity.Auth.Oidc; + +/// +/// Unit tests de la orquestación del (Authorization +/// Code + PKCE): resolución de config → validación de state → intercambio de código → +/// validación del id_token. Todos los puertos (config/token/JWKS) son fakes; ningún +/// IdP real interviene (ADR-UMS-094 · slice 1). +/// +public sealed class OidcIdpAuthAdapterTests +{ + private const string Kid = "adapter-key"; + private const string ClientId = "ums-client"; + private const string Nonce = "nonce-abc"; + private const string State = "state-xyz"; + + private readonly RSA _rsa = RSA.Create(2048); + private readonly OidcEndpoints _endpoints = OidcTestSupport.Endpoints(); + + private OidcProviderConfig BuildConfig() => + new(_endpoints, new OidcClientSettings(ClientId, "secreto", "https://ums.test/callback")); + + private OidcIdTokenValidator BuildValidator() => + new(FakeJwksProvider.WithKeys(OidcTestSupport.PublicJwk(_rsa, Kid)), TimeProvider.System); + + private static IdentityProvider BuildProvider() + => IdentityProvider.Create( + TenantId.Load(Guid.NewGuid()), + Code.Create("KC"), + Name.Create("Keycloak"), + Description.Create("IdP corporativo"), + IdpStrategy.Keycloak, + ActorId.Create("test")).Value; + + private static string Callback(string state = State, string expectedState = State, string expectedNonce = Nonce) + => JsonSerializer.Serialize(new + { + code = "auth-code-123", + state, + expectedState, + expectedNonce, + codeVerifier = "verifier-123", + redirectUri = "https://ums.test/callback", + }); + + private string ValidIdToken() + => OidcTestSupport.BuildIdToken( + _rsa, Kid, + OidcTestSupport.ValidPayload(_endpoints.Issuer, ClientId, Nonce)); + + [Fact] + public async Task ValidateAsync_HappyPath_ReturnsExternalIdentity() + { + var adapter = new OidcIdpAuthAdapter( + FakeOidcProviderConfigStore.Returning(BuildConfig()), + FakeOidcTokenClient.Returning(ValidIdToken()), + BuildValidator()); + + var result = await adapter.ValidateAsync(BuildProvider(), Callback()); + + Assert.True(result.IsSuccess, result.IsFailure ? result.Error : null); + Assert.Equal("user@ransa.pe", result.Value.Email); + } + + [Fact] + public async Task ValidateAsync_StateMismatch_ReturnsFailure_AUTH033() + { + var adapter = new OidcIdpAuthAdapter( + FakeOidcProviderConfigStore.Returning(BuildConfig()), + FakeOidcTokenClient.Returning(ValidIdToken()), + BuildValidator()); + + var result = await adapter.ValidateAsync(BuildProvider(), Callback(state: "state-del-atacante")); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_033", result.Error); + } + + [Fact] + public async Task ValidateAsync_TokenExchangeFails_ReturnsFailure_AUTH021() + { + var adapter = new OidcIdpAuthAdapter( + FakeOidcProviderConfigStore.Returning(BuildConfig()), + FakeOidcTokenClient.Failing(OidcAuthErrors.TokenExchangeFailed("HTTP 400.")), + BuildValidator()); + + var result = await adapter.ValidateAsync(BuildProvider(), Callback()); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_021", result.Error); + } + + [Fact] + public async Task ValidateAsync_MissingIdToken_ReturnsFailure_AUTH022() + { + var adapter = new OidcIdpAuthAdapter( + FakeOidcProviderConfigStore.Returning(BuildConfig()), + FakeOidcTokenClient.Returning(idToken: null), + BuildValidator()); + + var result = await adapter.ValidateAsync(BuildProvider(), Callback()); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_022", result.Error); + } + + [Fact] + public async Task ValidateAsync_ConfigResolutionFails_ReturnsFailure_AUTH020() + { + var adapter = new OidcIdpAuthAdapter( + FakeOidcProviderConfigStore.Failing(OidcAuthErrors.ConfigNotFound("sin config")), + FakeOidcTokenClient.Returning(ValidIdToken()), + BuildValidator()); + + var result = await adapter.ValidateAsync(BuildProvider(), Callback()); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_020", result.Error); + } + + [Fact] + public async Task ValidateAsync_MalformedCallback_ReturnsFailure_AUTH033() + { + var adapter = new OidcIdpAuthAdapter( + FakeOidcProviderConfigStore.Returning(BuildConfig()), + FakeOidcTokenClient.Returning(ValidIdToken()), + BuildValidator()); + + var result = await adapter.ValidateAsync(BuildProvider(), "esto-no-es-json"); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_033", result.Error); + } + + [Fact] + public async Task ValidateAsync_NonceMismatchInIdToken_ReturnsFailure_AUTH031() + { + // El callback declara un nonce distinto al que trae el id_token → rechazo. + var adapter = new OidcIdpAuthAdapter( + FakeOidcProviderConfigStore.Returning(BuildConfig()), + FakeOidcTokenClient.Returning(ValidIdToken()), + BuildValidator()); + + var result = await adapter.ValidateAsync( + BuildProvider(), + Callback(expectedNonce: "otro-nonce")); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_031", result.Error); + } +} diff --git a/src/apps/ums.api/Ums.Application.Test/Identity/Auth/Oidc/OidcProviderConfigParserTests.cs b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/Oidc/OidcProviderConfigParserTests.cs new file mode 100644 index 00000000..9c8e43de --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/Oidc/OidcProviderConfigParserTests.cs @@ -0,0 +1,103 @@ +namespace Ums.Application.Test.Identity.Auth.Oidc; + +using Xunit; +using Ums.Infrastructure.Identity.Auth.Oidc; + +/// +/// Unit tests del parseo de la configuración OIDC desde el ConfigPayload del +/// inquilino: los endpoints se leen de configuración, nunca se hardcodean (ADR-UMS-094). +/// +public sealed class OidcProviderConfigParserTests +{ + [Fact] + public void Parse_FullDiscoveryPayload_ReadsAllEndpoints() + { + const string payload = """ + { + "issuer": "https://idp.test/realms/beyondnet", + "authorization_endpoint": "https://idp.test/auth", + "token_endpoint": "https://idp.test/token", + "jwks_uri": "https://idp.test/certs", + "client_id": "ums-client", + "client_secret": "s3cr3t", + "redirect_uri": "https://ums.test/callback", + "scope": "openid email" + } + """; + + var result = OidcProviderConfigParser.Parse(payload); + + Assert.True(result.IsSuccess, result.IsFailure ? result.Error : null); + Assert.Equal("https://idp.test/auth", result.Value.Endpoints.AuthorizationEndpoint); + Assert.Equal("https://idp.test/token", result.Value.Endpoints.TokenEndpoint); + Assert.Equal("https://idp.test/certs", result.Value.Endpoints.JwksUri); + Assert.Equal("https://idp.test/realms/beyondnet", result.Value.Endpoints.Issuer); + Assert.Equal("ums-client", result.Value.Client.ClientId); + Assert.Equal("s3cr3t", result.Value.Client.ClientSecret); + Assert.Equal("openid email", result.Value.Client.Scopes); + } + + [Fact] + public void Parse_IssuerOnly_DerivesKeycloakEndpoints() + { + const string payload = """ + { + "issuer": "https://idp.test/realms/beyondnet", + "clientId": "ums-client", + "redirectUri": "https://ums.test/callback" + } + """; + + var result = OidcProviderConfigParser.Parse(payload); + + Assert.True(result.IsSuccess, result.IsFailure ? result.Error : null); + Assert.Equal("https://idp.test/realms/beyondnet/protocol/openid-connect/auth", result.Value.Endpoints.AuthorizationEndpoint); + Assert.Equal("https://idp.test/realms/beyondnet/protocol/openid-connect/token", result.Value.Endpoints.TokenEndpoint); + Assert.Equal("https://idp.test/realms/beyondnet/protocol/openid-connect/certs", result.Value.Endpoints.JwksUri); + Assert.Equal("openid email profile", result.Value.Client.Scopes); // por defecto + } + + [Fact] + public void Parse_ExplicitClientSecretArgument_OverridesPayload() + { + const string payload = """ + { "issuer": "https://idp.test/realms/beyondnet", "clientId": "ums-client", "redirectUri": "https://ums.test/cb" } + """; + + var result = OidcProviderConfigParser.Parse(payload, clientSecret: "desde-vault"); + + Assert.True(result.IsSuccess, result.IsFailure ? result.Error : null); + Assert.Equal("desde-vault", result.Value.Client.ClientSecret); + } + + [Fact] + public void Parse_MissingRequiredKeys_ReturnsFailure_AUTH020() + { + const string payload = """{ "issuer": "https://idp.test/realms/beyondnet" }"""; + + var result = OidcProviderConfigParser.Parse(payload); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_020", result.Error); + Assert.Contains("client_id", result.Error); + Assert.Contains("redirect_uri", result.Error); + } + + [Fact] + public void Parse_InvalidJson_ReturnsFailure_AUTH020() + { + var result = OidcProviderConfigParser.Parse("no-es-json"); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_020", result.Error); + } + + [Fact] + public void Parse_EmptyPayload_ReturnsFailure_AUTH020() + { + var result = OidcProviderConfigParser.Parse(" "); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_020", result.Error); + } +} diff --git a/src/apps/ums.api/Ums.Application.Test/Identity/Auth/Oidc/OidcTestSupport.cs b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/Oidc/OidcTestSupport.cs new file mode 100644 index 00000000..9916df5a --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/Oidc/OidcTestSupport.cs @@ -0,0 +1,152 @@ +namespace Ums.Application.Test.Identity.Auth.Oidc; + +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Ums.Infrastructure.Identity.Auth.Oidc; + +/// +/// Utilidades de prueba para el flujo OIDC: generación de llaves RSA de prueba, +/// construcción de id_token firmados y fakes de los puertos HTTP/JWKS. +/// Ningún test toca un IdP real (ADR-UMS-094 · slice 1). +/// +internal static class OidcTestSupport +{ + public static string Base64Url(ReadOnlySpan bytes) => + Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + public static OidcEndpoints Endpoints(string issuer = "https://idp.test/realms/beyondnet") => + new( + AuthorizationEndpoint: $"{issuer}/protocol/openid-connect/auth", + TokenEndpoint: $"{issuer}/protocol/openid-connect/token", + JwksUri: $"{issuer}/protocol/openid-connect/certs", + Issuer: issuer); + + public static OidcJsonWebKey PublicJwk(RSA rsa, string kid) + { + var p = rsa.ExportParameters(includePrivateParameters: false); + return new OidcJsonWebKey( + Kid: kid, + Kty: "RSA", + Alg: "RS256", + Use: "sig", + N: Base64Url(p.Modulus!), + E: Base64Url(p.Exponent!)); + } + + /// Construye un id_token RS256 firmado con . + public static string BuildIdToken( + RSA signingKey, + string kid, + IReadOnlyDictionary payload, + string alg = "RS256") + { + var header = new Dictionary { ["alg"] = alg, ["typ"] = "JWT" }; + if (kid is not null) + { + header["kid"] = kid; + } + + var headerSegment = Base64Url(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(header))); + var payloadSegment = Base64Url(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(payload))); + var signingInput = Encoding.ASCII.GetBytes($"{headerSegment}.{payloadSegment}"); + + byte[] signature = alg switch + { + "none" => Array.Empty(), + "HS256" => new HMACSHA256(Encoding.UTF8.GetBytes("client-secret")).ComputeHash(signingInput), + _ => signingKey.SignData(signingInput, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1), + }; + + return $"{headerSegment}.{payloadSegment}.{Base64Url(signature)}"; + } + + public static Dictionary ValidPayload( + string issuer = "https://idp.test/realms/beyondnet", + string audience = "ums-client", + string nonce = "nonce-123", + string email = "user@ransa.pe", + long? exp = null, + long? nbf = null) + { + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + return new Dictionary + { + ["iss"] = issuer, + ["aud"] = audience, + ["exp"] = exp ?? now + 300, + ["nbf"] = nbf ?? now - 60, + ["iat"] = now, + ["nonce"] = nonce, + ["sub"] = "idp|abc-123", + ["email"] = email, + ["email_verified"] = true, + ["name"] = "Ana Ransa", + ["preferred_username"] = "aransa", + }; + } +} + +/// Fake de con llaves controladas. +internal sealed class FakeJwksProvider : IJwksProvider +{ + private readonly Result> _result; + + private FakeJwksProvider(Result> result) => _result = result; + + public static FakeJwksProvider WithKeys(params OidcJsonWebKey[] keys) => + new(Result>.Success(keys)); + + public static FakeJwksProvider Failing(string error) => + new(Result>.Failure(error)); + + public Task>> GetSigningKeysAsync( + OidcEndpoints endpoints, CancellationToken cancellationToken = default) + => Task.FromResult(_result); +} + +/// Fake de con respuesta controlada. +internal sealed class FakeOidcTokenClient : IOidcTokenClient +{ + private readonly Result _result; + + private FakeOidcTokenClient(Result result) => _result = result; + + public static FakeOidcTokenClient Returning(string? idToken) => + new(Result.Success(new OidcTokenResponse(idToken, "access", null, "Bearer", 300))); + + public static FakeOidcTokenClient Failing(string error) => + new(Result.Failure(error)); + + public Task> ExchangeAuthorizationCodeAsync( + OidcEndpoints endpoints, OidcClientSettings client, string code, string codeVerifier, + string redirectUri, CancellationToken cancellationToken = default) + => Task.FromResult(_result); +} + +/// Fake de . +internal sealed class FakeOidcProviderConfigStore : IOidcProviderConfigStore +{ + private readonly Result _result; + + private FakeOidcProviderConfigStore(Result result) => _result = result; + + public static FakeOidcProviderConfigStore Returning(OidcProviderConfig config) => + new(Result.Success(config)); + + public static FakeOidcProviderConfigStore Failing(string error) => + new(Result.Failure(error)); + + public Task> GetAsync( + Ums.Domain.Identity.Tenant.IdentityProvider.IdentityProvider provider, + CancellationToken cancellationToken = default) + => Task.FromResult(_result); +} + +/// fijo para probar exp/nbf de forma determinista. +internal sealed class FixedTimeProvider : TimeProvider +{ + private readonly DateTimeOffset _now; + public FixedTimeProvider(DateTimeOffset now) => _now = now; + public override DateTimeOffset GetUtcNow() => _now; +} diff --git a/src/apps/ums.api/Ums.Application.Test/Identity/Auth/RefreshAuthenticationCommandHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/RefreshAuthenticationCommandHandlerTests.cs new file mode 100644 index 00000000..d821f753 --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/RefreshAuthenticationCommandHandlerTests.cs @@ -0,0 +1,378 @@ +namespace Ums.Application.Test.Identity.Auth; + +using Moq; +using Xunit; +using Ums.Application.Authorization.Graph; +using Ums.Application.Authorization.Graph.Serializers; +using Ums.Application.Common.Interfaces; +using Ums.Application.Identity.Auth; +using Ums.Application.Identity.Auth.Commands; +using Ums.Domain.Authorization.Graph; +using Ums.Domain.Identity; +using Ums.Domain.Identity.Auth; +using TenantAggregate = Ums.Domain.Identity.Tenant.Tenant; +using UserAccountAggregate = Ums.Domain.Identity.UserAccount.UserAccount; + +/// +/// Tests unitarios de (ADR-UMS-091 / FR-015/016). +/// Dan cobertura verificable (SD-04/SD-05) al residual «falta E2E dedicado» de G-050 y G-034: +/// +/// G-050 — auditoría y revocación: +/// · cada fallo de renovación (incl. detección de reuso) se audita; +/// · los códigos de fallo se distinguen (el reuso NO colapsa con la expiración); +/// +/// G-034 — refresh configurable + revocación: +/// · fail-closed cuando la política del inquilino está apagada; +/// · detección de reuso ⇒ invalidación de la familia; +/// · revocación efectiva ante inquilino/usuario inactivos (bloqueo/suspensión); +/// · regeneración COMPLETA del grafo al renovar; +/// · rotación del token (y su ausencia cuando la política no rota). +/// +/// Todas las dependencias están mockeadas — sin BD ni infraestructura. +/// +public sealed class RefreshAuthenticationCommandHandlerTests +{ + private readonly Mock _store = new(); + private readonly Mock _policyProvider = new(); + private readonly Mock _tenantRepo = new(); + private readonly Mock _userRepo = new(); + private readonly Mock _methodResolver = new(); + private readonly Mock _graphBuilder = new(); + private readonly Mock _formatProvider = new(); + private readonly Mock _serializer = new(); + private readonly Mock _auditService = new(); + + private static readonly Guid TenantGuid = Guid.NewGuid(); + private static readonly Guid UserGuid = Guid.NewGuid(); + private static readonly Guid FamilyGuid = Guid.NewGuid(); + + private RefreshAuthenticationCommandHandler CreateSut() => new( + _store.Object, _policyProvider.Object, _tenantRepo.Object, _userRepo.Object, + _methodResolver.Object, _graphBuilder.Object, _formatProvider.Object, + _serializer.Object, _auditService.Object); + + private static RefreshAuthenticationCommand Command(string token = "opaque-refresh") + => new(token, ClientIp: "10.0.0.1"); + + // ── Validación de entrada ──────────────────────────────────────────────────── + + [Fact] + public async Task Handle_WhenRefreshTokenEmpty_ReturnsInvalid_WithoutTouchingStore() + { + var result = await CreateSut().Handle(Command(" "), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains(RefreshErrorCodes.Invalid, result.Error); + _store.Verify(s => s.FindByHashAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Handle_WhenTokenUnknown_ReturnsInvalid_AndAuditsFailure() + { + _store.Setup(s => s.FindByHashAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((RefreshTokenSnapshot?)null); + + var result = await CreateSut().Handle(Command(), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains(RefreshErrorCodes.Invalid, result.Error); + VerifyFailureAudited(RefreshErrorCodes.Invalid); + } + + // ── G-050: detección de reuso auditada + código distinto (no colapsa a expiración) ── + + [Fact] + public async Task Handle_WhenRotatedTokenPresented_DetectsReuse_RevokesFamily_AndAudits() + { + SetupSnapshot(RefreshTokenStatuses.Rotated); + _policyProvider.Setup(p => p.Resolve(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(EnabledPolicy(detectReuse: true)); + + var result = await CreateSut().Handle(Command(), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains(RefreshErrorCodes.ReuseDetected, result.Error); + _store.Verify(s => s.RevokeFamilyAsync(FamilyGuid, "reuse_detected", It.IsAny(), It.IsAny()), Times.Once); + VerifyFailureAudited(RefreshErrorCodes.ReuseDetected); + } + + [Fact] + public async Task Handle_WhenReuseDetectionDisabled_DoesNotRevokeFamily_ButStillReportsReuse() + { + SetupSnapshot(RefreshTokenStatuses.Used); + _policyProvider.Setup(p => p.Resolve(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(EnabledPolicy(detectReuse: false)); + + var result = await CreateSut().Handle(Command(), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains(RefreshErrorCodes.ReuseDetected, result.Error); + _store.Verify(s => s.RevokeFamilyAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + VerifyFailureAudited(RefreshErrorCodes.ReuseDetected); + } + + [Fact] + public async Task Handle_WhenRevoked_ReturnsRevoked_DistinctFromReuse_AndAudits() + { + SetupSnapshot(RefreshTokenStatuses.Revoked); + + var result = await CreateSut().Handle(Command(), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains(RefreshErrorCodes.Revoked, result.Error); + Assert.DoesNotContain(RefreshErrorCodes.ReuseDetected, result.Error); + VerifyFailureAudited(RefreshErrorCodes.Revoked); + } + + [Fact] + public async Task Handle_WhenExpired_ReturnsExpired_DistinctFromReuse_AndAudits() + { + // Un token Active pero vencido ⇒ AUTH_REFRESH_004, nunca el código de reuso: + // el bug histórico (G-050) era que expiración y reuso colapsaban a AUTH_007. + SetupSnapshot(RefreshTokenStatuses.Active, expiresAtUtc: DateTime.UtcNow.AddMinutes(-1)); + + var result = await CreateSut().Handle(Command(), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains(RefreshErrorCodes.Expired, result.Error); + Assert.DoesNotContain(RefreshErrorCodes.ReuseDetected, result.Error); + VerifyFailureAudited(RefreshErrorCodes.Expired); + } + + // ── G-034: fail-closed cuando el inquilino no tiene la capacidad activa ─────── + + [Fact] + public async Task Handle_WhenPolicyDisabled_ReturnsDisabled_FailClosed() + { + SetupSnapshot(RefreshTokenStatuses.Active); + _policyProvider.Setup(p => p.Resolve(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(RefreshTokenPolicy.Disabled); + + var result = await CreateSut().Handle(Command(), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains(RefreshErrorCodes.Disabled, result.Error); + VerifyFailureAudited(RefreshErrorCodes.Disabled); + // Fail-closed: no se regenera grafo ni se rota nada. + _graphBuilder.Verify(g => g.BuildAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Handle_WhenMaxRenewalsReached_RevokesFamily_AndReturnsMaxRenewals() + { + SetupSnapshot(RefreshTokenStatuses.Active, renewalCount: 5); + _policyProvider.Setup(p => p.Resolve(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(EnabledPolicy(maxRenewals: 5)); + + var result = await CreateSut().Handle(Command(), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains(RefreshErrorCodes.MaxRenewals, result.Error); + _store.Verify(s => s.RevokeFamilyAsync(FamilyGuid, "max_renewals", It.IsAny(), It.IsAny()), Times.Once); + VerifyFailureAudited(RefreshErrorCodes.MaxRenewals); + } + + // ── G-034: revocación efectiva ante principal inactivo (bloqueo/suspensión) ─── + + [Fact] + public async Task Handle_WhenTenantInactive_RevokesFamily_ReturnsPrincipalGone() + { + SetupSnapshot(RefreshTokenStatuses.Active); + SetupEnabledPolicy(); + _tenantRepo.Setup(r => r.GetByIdAsync(TenantGuid, It.IsAny())) + .ReturnsAsync((TenantAggregate?)null); + + var result = await CreateSut().Handle(Command(), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains(RefreshErrorCodes.PrincipalGone, result.Error); + _store.Verify(s => s.RevokeFamilyAsync(FamilyGuid, "tenant_inactive", It.IsAny(), It.IsAny()), Times.Once); + VerifyFailureAudited(RefreshErrorCodes.PrincipalGone); + } + + [Fact] + public async Task Handle_WhenUserBlocked_RevokesFamily_ReturnsPrincipalGone() + { + // Un usuario bloqueado no puede renovar ⇒ la familia se revoca (revocación efectiva + // por bloqueo/suspensión, G-034/D-012). + SetupSnapshot(RefreshTokenStatuses.Active); + SetupEnabledPolicy(); + SetupActiveTenant(); + _userRepo.Setup(r => r.GetByIdAsync(UserGuid, It.IsAny())) + .ReturnsAsync(BuildBlockedUser()); + + var result = await CreateSut().Handle(Command(), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains(RefreshErrorCodes.PrincipalGone, result.Error); + _store.Verify(s => s.RevokeFamilyAsync(FamilyGuid, "user_inactive", It.IsAny(), It.IsAny()), Times.Once); + VerifyFailureAudited(RefreshErrorCodes.PrincipalGone); + } + + // ── G-034: happy-path — regeneración COMPLETA del grafo + rotación + auditoría ─ + + [Fact] + public async Task Handle_HappyPath_RegeneratesGraph_RotatesToken_AndAuditsSuccess() + { + SetupSnapshot(RefreshTokenStatuses.Active); + SetupEnabledPolicy(rotate: true); + SetupActiveTenant(); + SetupActiveUser(); + SetupGraphPipeline(); + + var result = await CreateSut().Handle(Command(), CancellationToken.None); + + Assert.True(result.IsSuccess); + // Regeneración COMPLETA del grafo (decisión 1 de ADR-UMS-091): el builder se invoca. + _graphBuilder.Verify(g => g.BuildAsync(It.IsAny(), TenantGuid, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + // Rotación: el token vigente se canjea por uno nuevo de la misma familia. + _store.Verify(s => s.RotateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + Assert.False(string.IsNullOrWhiteSpace(result.Value.NewRefreshToken)); + // Auditoría de éxito persistida (G-050). + _auditService.Verify(a => a.RecordAuthEventAsync( + It.Is(e => e.EventType == "Auth.Refresh.Success" && e.Succeeded), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_HappyPath_WhenPolicyDoesNotRotate_KeepsClientToken() + { + SetupSnapshot(RefreshTokenStatuses.Active); + SetupEnabledPolicy(rotate: false); + SetupActiveTenant(); + SetupActiveUser(); + SetupGraphPipeline(); + + var result = await CreateSut().Handle(Command(), CancellationToken.None); + + Assert.True(result.IsSuccess); + _store.Verify(s => s.RotateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + Assert.Null(result.Value.NewRefreshToken); + // Aun sin rotación, el grafo se regenera por completo. + _graphBuilder.Verify(g => g.BuildAsync(It.IsAny(), TenantGuid, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + // ── Setup helpers ──────────────────────────────────────────────────────────── + + private void SetupSnapshot( + string status, + DateTime? expiresAtUtc = null, + int renewalCount = 0) + { + var snapshot = new RefreshTokenSnapshot( + Id: Guid.NewGuid(), + TenantId: TenantGuid, + UserId: UserGuid, + FamilyId: FamilyGuid, + Status: status, + IssuedAtUtc: DateTime.UtcNow.AddMinutes(-10), + ExpiresAtUtc: expiresAtUtc ?? DateTime.UtcNow.AddDays(1), + RenewalCount: renewalCount); + + _store.Setup(s => s.FindByHashAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(snapshot); + } + + private static RefreshTokenPolicy EnabledPolicy( + bool rotate = true, bool detectReuse = true, int maxRenewals = 0) + => new(Enabled: true, LifetimeMinutes: 60, Rotate: rotate, DetectReuse: detectReuse, MaxRenewals: maxRenewals); + + private void SetupEnabledPolicy(bool rotate = true) + => _policyProvider.Setup(p => p.Resolve(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(EnabledPolicy(rotate: rotate)); + + private void SetupActiveTenant() + => _tenantRepo.Setup(r => r.GetByIdAsync(TenantGuid, It.IsAny())) + .ReturnsAsync(BuildActiveTenant()); + + private void SetupActiveUser() + => _userRepo.Setup(r => r.GetByIdAsync(UserGuid, It.IsAny())) + .ReturnsAsync(BuildActiveUser()); + + private void SetupGraphPipeline() + { + _methodResolver.Setup(m => m.ResolveAsync(TenantGuid, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success(AuthMethod.Local())); + _graphBuilder.Setup(g => g.BuildAsync(It.IsAny(), TenantGuid, It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success(BuildGraph())); + _formatProvider.Setup(f => f.GetDefaultFormatAsync(TenantGuid, It.IsAny())) + .ReturnsAsync("JSON"); + _serializer.Setup(s => s.Serialize(It.IsAny(), It.IsAny())) + .Returns("{}"); + } + + private static TenantAggregate BuildActiveTenant() + { + var tenant = TenantAggregate.Create( + Code.Create("TEST"), + Name.Create("Test Tenant"), + Ums.Domain.Enums.OrganizationType.INTERNAL, + ActorId.Create("test"), + Ums.Domain.Enums.IdpStrategy.InternalBcrypt, + tenantId: TenantId.Load(TenantGuid)).Value; // Create ⇒ Status Active + tenant.DomainEvents.MarkChangesAsCommitted(); + return tenant; + } + + private static UserAccountAggregate BuildActiveUser() + { + var user = BuildUser(); + user.Activate(ActorId.Create("test")); + user.DomainEvents.MarkChangesAsCommitted(); + return user; + } + + private static UserAccountAggregate BuildBlockedUser() + { + var user = BuildUser(); + user.Activate(ActorId.Create("test")); + user.Block(Reason.Create("suspendido"), ActorId.Create("test")); + user.DomainEvents.MarkChangesAsCommitted(); + return user; + } + + private static UserAccountAggregate BuildUser() + => UserAccountAggregate.Create( + TenantId.Load(TenantGuid), + Email.Create("user@test.com"), + Ums.Domain.Enums.UserCategory.Internal, + null, null, + ActorId.Create("test"), + null, + UserAccountId.Load(UserGuid)).Value; + + private static AuthorizationGraph BuildGraph() + { + var context = new GraphContext( + new GraphUser(UserGuid, "user@test.com", "user", "User", "Active"), + new GraphTenant(TenantGuid, "TEST", "Test Tenant", "Active", false), + SystemSuite: null, Role: null, Profile: null, Branch: null); + + var authentication = new GraphAuthentication( + "Local", Provider: null, MfaRequired: false, + IssuedAt: DateTime.UtcNow, SessionExpiresAt: DateTime.UtcNow.AddMinutes(30)); + + var effectiveConfig = new GraphEffectiveConfig( + SessionTimeoutMinutes: 30, MaxLoginAttempts: 5, MinPasswordLength: 8, + MfaRequiredForAdmin: false, MfaAllowedMethods: Array.Empty(), + AccessTokenDurationMs: 900_000, AuthUseExternalIdp: false); + + return AuthorizationGraph.Build( + context, authentication, + Array.Empty(), + Array.Empty(), + Array.Empty(), + Array.Empty(), + effectiveConfig, + Array.Empty(), + DateTime.UtcNow); + } + + private void VerifyFailureAudited(string code) + => _auditService.Verify(a => a.RecordAuthEventAsync( + It.Is(e => + e.EventType == "Auth.Refresh.Failure" && + !e.Succeeded && + e.FailureReason != null && e.FailureReason.Contains(code)), + It.IsAny()), Times.Once); +} diff --git a/src/apps/ums.api/Ums.Application.Test/Identity/Auth/RefreshSessionCommandHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/RefreshSessionCommandHandlerTests.cs new file mode 100644 index 00000000..ab239299 --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/RefreshSessionCommandHandlerTests.cs @@ -0,0 +1,211 @@ +namespace Ums.Application.Test.Identity.Auth; + +using Moq; +using Xunit; +using Ums.Application.Authorization.Graph; +using Ums.Application.Common.Interfaces; +using Ums.Application.Identity.Auth; +using Ums.Application.Identity.Auth.Commands; +using Ums.Domain.Authorization.Graph; +using Ums.Domain.Identity; +using Ums.Domain.Identity.Auth; +using TenantAggregate = Ums.Domain.Identity.Tenant.Tenant; +using UserAccountAggregate = Ums.Domain.Identity.UserAccount.UserAccount; + +/// +/// Tests unitarios de (D-019 / ADR-UMS-091). +/// +/// El refresh deslizante por cookie debe ESPEJAR el login: regenera el grafo COMPLETO desde el +/// estado vigente (no re-firma claims estancados). Se verifica: +/// · happy-path ⇒ el grafo se regenera (IAuthorizationGraphBuilder) y el evento se audita; +/// · el resultado surfacea el grafo VIGENTE del builder (un cambio de permisos desde el login +/// se refleja, porque el grafo se reconstruye, no se cachea); +/// · corte en caliente ⇒ inquilino/usuario inactivo no renueva (y no se reconstruye el grafo). +/// +/// Todas las dependencias están mockeadas — sin BD ni infraestructura. +/// +public sealed class RefreshSessionCommandHandlerTests +{ + private readonly Mock _tenantRepo = new(); + private readonly Mock _userRepo = new(); + private readonly Mock _methodResolver = new(); + private readonly Mock _graphBuilder = new(); + private readonly Mock _formatProvider = new(); + private readonly Mock _auditService = new(); + + private static readonly Guid TenantGuid = Guid.NewGuid(); + private static readonly Guid UserGuid = Guid.NewGuid(); + + private RefreshSessionCommandHandler CreateSut() => new( + _tenantRepo.Object, _userRepo.Object, _methodResolver.Object, + _graphBuilder.Object, _formatProvider.Object, _auditService.Object); + + private static RefreshSessionCommand Command() + => new(UserGuid, TenantGuid, ClientIp: "10.0.0.1"); + + // ── Happy-path: regeneración COMPLETA del grafo + auditoría ─────────────────── + + [Fact] + public async Task Handle_HappyPath_RegeneratesGraph_AndAuditsSuccess() + { + SetupActiveTenant(); + SetupActiveUser(); + SetupGraphPipeline(BuildGraph(accessTokenDurationMs: 900_000)); + + var result = await CreateSut().Handle(Command(), CancellationToken.None); + + Assert.True(result.IsSuccess); + // Regeneración COMPLETA (decisión 1 de ADR-UMS-091): el builder se invoca. + _graphBuilder.Verify(g => g.BuildAsync( + It.IsAny(), TenantGuid, It.IsAny(), It.IsAny(), It.IsAny()), + Times.Once); + // ExpiresIn se deriva del grafo vigente, no de un valor hardcodeado. + Assert.Equal(900, result.Value.ExpiresIn); + _auditService.Verify(a => a.RecordAuthEventAsync( + It.Is(e => e.EventType == "Auth.Refresh.Success" && e.Succeeded), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_SurfacesCurrentGraph_NotALoginSnapshot() + { + // El handler NO tiene acceso a los permisos/claims del login; su única fuente es el + // builder. Un cambio de permisos aplicado tras el login se refleja porque el grafo se + // reconstruye desde cero: el resultado ES exactamente el grafo recién construido. + SetupActiveTenant(); + SetupActiveUser(); + var freshGraph = BuildGraph(accessTokenDurationMs: 1_800_000); + SetupGraphPipeline(freshGraph); + + var result = await CreateSut().Handle(Command(), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Same(freshGraph, result.Value.Graph); + Assert.Equal(1800, result.Value.ExpiresIn); + } + + // ── Corte en caliente: principal inactivo no renueva ───────────────────────── + + [Fact] + public async Task Handle_WhenTenantInactiveOrMissing_Fails_WithoutRebuildingGraph() + { + _tenantRepo.Setup(r => r.GetByIdAsync(TenantGuid, It.IsAny())) + .ReturnsAsync((TenantAggregate?)null); + + var result = await CreateSut().Handle(Command(), CancellationToken.None); + + Assert.True(result.IsFailure); + _graphBuilder.Verify(g => g.BuildAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + VerifyFailureAudited(); + } + + [Fact] + public async Task Handle_WhenUserBlocked_Fails_WithoutRebuildingGraph() + { + SetupActiveTenant(); + _userRepo.Setup(r => r.GetByIdAsync(UserGuid, It.IsAny())) + .ReturnsAsync(BuildBlockedUser()); + + var result = await CreateSut().Handle(Command(), CancellationToken.None); + + Assert.True(result.IsFailure); + _graphBuilder.Verify(g => g.BuildAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + VerifyFailureAudited(); + } + + // ── Setup helpers ──────────────────────────────────────────────────────────── + + private void SetupActiveTenant() + => _tenantRepo.Setup(r => r.GetByIdAsync(TenantGuid, It.IsAny())) + .ReturnsAsync(BuildActiveTenant()); + + private void SetupActiveUser() + => _userRepo.Setup(r => r.GetByIdAsync(UserGuid, It.IsAny())) + .ReturnsAsync(BuildActiveUser()); + + private void SetupGraphPipeline(AuthorizationGraph graph) + { + _methodResolver.Setup(m => m.ResolveAsync(TenantGuid, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success(AuthMethod.Local())); + _graphBuilder.Setup(g => g.BuildAsync(It.IsAny(), TenantGuid, It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success(graph)); + _formatProvider.Setup(f => f.GetDefaultFormatAsync(TenantGuid, It.IsAny())) + .ReturnsAsync("JSON"); + } + + private static TenantAggregate BuildActiveTenant() + { + var tenant = TenantAggregate.Create( + Code.Create("TEST"), + Name.Create("Test Tenant"), + Ums.Domain.Enums.OrganizationType.INTERNAL, + ActorId.Create("test"), + Ums.Domain.Enums.IdpStrategy.InternalBcrypt, + tenantId: TenantId.Load(TenantGuid)).Value; // Create ⇒ Status Active + tenant.DomainEvents.MarkChangesAsCommitted(); + return tenant; + } + + private static UserAccountAggregate BuildActiveUser() + { + var user = BuildUser(); + user.Activate(ActorId.Create("test")); + user.DomainEvents.MarkChangesAsCommitted(); + return user; + } + + private static UserAccountAggregate BuildBlockedUser() + { + var user = BuildUser(); + user.Activate(ActorId.Create("test")); + user.Block(Reason.Create("suspendido"), ActorId.Create("test")); + user.DomainEvents.MarkChangesAsCommitted(); + return user; + } + + private static UserAccountAggregate BuildUser() + => UserAccountAggregate.Create( + TenantId.Load(TenantGuid), + Email.Create("user@test.com"), + Ums.Domain.Enums.UserCategory.Internal, + null, null, + ActorId.Create("test"), + null, + UserAccountId.Load(UserGuid)).Value; + + private static AuthorizationGraph BuildGraph(int accessTokenDurationMs) + { + var context = new GraphContext( + new GraphUser(UserGuid, "user@test.com", "user", "User", "Active"), + new GraphTenant(TenantGuid, "TEST", "Test Tenant", "Active", false), + SystemSuite: null, Role: null, Profile: null, Branch: null); + + var authentication = new GraphAuthentication( + "Local", Provider: null, MfaRequired: false, + IssuedAt: DateTime.UtcNow, SessionExpiresAt: DateTime.UtcNow.AddMinutes(30)); + + var effectiveConfig = new GraphEffectiveConfig( + SessionTimeoutMinutes: 30, MaxLoginAttempts: 5, MinPasswordLength: 8, + MfaRequiredForAdmin: false, MfaAllowedMethods: Array.Empty(), + AccessTokenDurationMs: accessTokenDurationMs, AuthUseExternalIdp: false); + + return AuthorizationGraph.Build( + context, authentication, + Array.Empty(), + Array.Empty(), + Array.Empty(), + Array.Empty(), + effectiveConfig, + Array.Empty(), + DateTime.UtcNow); + } + + private void VerifyFailureAudited() + => _auditService.Verify(a => a.RecordAuthEventAsync( + It.Is(e => e.EventType == "Auth.Refresh.Failure" && !e.Succeeded), + It.IsAny()), Times.AtLeastOnce); +} diff --git a/src/apps/ums.api/Ums.Application.Test/Identity/Auth/ResetPasswordCommandHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/ResetPasswordCommandHandlerTests.cs new file mode 100644 index 00000000..4e447448 --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/ResetPasswordCommandHandlerTests.cs @@ -0,0 +1,234 @@ +namespace Ums.Application.Test.Identity.Auth; + +using Ums.Application.Common.Interfaces; +using Ums.Application.Common.Notifications; +using Ums.Application.Identity.Auth; +using Ums.Application.Identity.Auth.Commands; +using Ums.Domain.Enums; +using Ums.Domain.Identity; +using Ums.Domain.Identity.UserAccount; +using Ums.Domain.Kernel; +using Moq; +using Xunit; + +/// +/// G-188: el canje es el ÚNICO punto donde cambia la contraseña, y solo con un token vivo. +/// Todo lo demás —token inexistente, vencido, ya gastado, cuenta inhabilitada— colapsa en el +/// mismo error, sin tocar la credencial. +/// +public class ResetPasswordCommandHandlerTests +{ + private readonly Mock _resetTokens = new(); + private readonly Mock _userRepo = new(); + private readonly Mock _hasher = new(); + private readonly Mock _refreshTokens = new(); + private readonly Mock _notifications = new(); + private readonly Mock _uow = new(); + + private static readonly Guid TenantIdValue = Guid.Parse("3fa85f64-5717-4562-b3fc-2c963f66afa6"); + private static readonly Guid TokenId = Guid.Parse("11111111-1111-4111-8111-111111111111"); + private const string UserEmail = "admin@acme.com"; + private const string PreviousHash = "hash-de-la-clave-vigente"; + private const string NewHash = "hash-de-la-clave-nueva"; + private const string PlainToken = "token-en-claro"; + private const string NewPassword = "Nueva#Clave#2026"; + + public ResetPasswordCommandHandlerTests() + { + _userRepo.Setup(r => r.UnitOfWork).Returns(_uow.Object); + _uow.Setup(u => u.SaveEntitiesAsync(It.IsAny())).ReturnsAsync(true); + _hasher.Setup(h => h.Hash(It.IsAny())).Returns(NewHash); + } + + private static UserAccount MakeUserWithPassword(Guid tenantId = default) + { + var user = UserAccount.Create( + TenantId.Load(tenantId == default ? TenantIdValue : tenantId), + Email.Create(UserEmail), + UserCategory.Internal, + null, null, + ActorId.Create("sys")).Value; + user.Activate(ActorId.Create("sys")); + user.AddPassword(PasswordHash.Create(PreviousHash), ActorId.Create("sys")); + return user; + } + + private static PasswordResetTokenSnapshot MakeSnapshot( + Guid userId, + string status = PasswordResetTokenStatuses.Active, + int expiresInMinutes = 10) => + new(TokenId, TenantIdValue, userId, status, + DateTime.UtcNow.AddMinutes(-1), + DateTime.UtcNow.AddMinutes(expiresInMinutes)); + + private void GivenToken(PasswordResetTokenSnapshot? snapshot) => + _resetTokens.Setup(s => s.FindByHashAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(snapshot); + + private void GivenAccount(UserAccount? account) => + _userRepo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(account); + + private ResetPasswordCommandHandler CreateHandler() => + new(_resetTokens.Object, _userRepo.Object, _hasher.Object, _refreshTokens.Object, _notifications.Object); + + private Task> Invoke() => + CreateHandler().Handle(new ResetPasswordCommand(PlainToken, NewPassword), CancellationToken.None); + + // ========================================================================= + #region Canje válido + // ========================================================================= + + [Fact] + public async Task ResetPassword_WithLiveToken_ReplacesTheActiveCredential() + { + var user = MakeUserWithPassword(); + GivenToken(MakeSnapshot(user.Props.Id.GetValue())); + GivenAccount(user); + + var result = await Invoke(); + + Assert.True(result.IsSuccess); + var activa = Assert.Single(user.PasswordCredentials.Where(c => c.IsActive)); + Assert.Equal(NewHash, activa.PasswordHash.GetValue()); + // La anterior no desaparece: queda desactivada, que es como el agregado lleva su historia. + Assert.Contains(user.PasswordCredentials, c => !c.IsActive && c.PasswordHash.GetValue() == PreviousHash); + } + + [Fact] + public async Task ResetPassword_WithLiveToken_PersistsAndBurnsTheToken() + { + var user = MakeUserWithPassword(); + GivenToken(MakeSnapshot(user.Props.Id.GetValue())); + GivenAccount(user); + + await Invoke(); + + _userRepo.Verify(r => r.UpdateAsync(user, It.IsAny()), Times.Once); + _uow.Verify(u => u.SaveEntitiesAsync(It.IsAny()), Times.Once); + _resetTokens.Verify(s => s.ConsumeAsync(TokenId, It.IsAny(), It.IsAny()), Times.Once); + _resetTokens.Verify(s => s.InvalidateActiveForUserAsync( + TenantIdValue, user.Props.Id.GetValue(), It.IsAny(), + It.IsAny(), It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ResetPassword_WithLiveToken_ClosesLiveSessionsAndNotifiesTheOwner() + { + var user = MakeUserWithPassword(); + GivenToken(MakeSnapshot(user.Props.Id.GetValue())); + GivenAccount(user); + + await Invoke(); + + _refreshTokens.Verify(s => s.RevokeAllForUserAsync( + TenantIdValue, user.Props.Id.GetValue(), It.IsAny(), + It.IsAny(), It.IsAny()), + Times.Once); + _notifications.Verify(n => n.SendAsync( + It.Is(msg => msg.Recipient == UserEmail), + It.IsAny()), + Times.Once); + } + + #endregion + + // ========================================================================= + #region Canje rechazado — la credencial vigente sobrevive + // ========================================================================= + + [Theory] + [InlineData("inexistente")] + [InlineData("vencido")] + [InlineData("gastado")] + [InlineData("invalidado")] + public async Task ResetPassword_WithUnusableToken_FailsWithTheSameErrorAndChangesNothing(string caso) + { + var user = MakeUserWithPassword(); + var userId = user.Props.Id.GetValue(); + GivenAccount(user); + GivenToken(caso switch + { + "inexistente" => null, + "vencido" => MakeSnapshot(userId, expiresInMinutes: -1), + "gastado" => MakeSnapshot(userId, PasswordResetTokenStatuses.Used), + _ => MakeSnapshot(userId, PasswordResetTokenStatuses.Invalidated), + }); + + var result = await Invoke(); + + Assert.True(result.IsFailure); + Assert.Equal(ResetPasswordCommandHandler.InvalidTokenError, result.Error); + VerifyCredentialSurvived(user); + } + + [Fact] + public async Task ResetPassword_WithEmptyToken_FailsWithoutTouchingTheStore() + { + var result = await CreateHandler().Handle( + new ResetPasswordCommand(" ", NewPassword), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Equal(ResetPasswordCommandHandler.InvalidTokenError, result.Error); + _resetTokens.Verify(s => s.FindByHashAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task ResetPassword_WhenTokenPointsToAnotherTenantAccount_FailsAndBurnsTheToken() + { + var user = MakeUserWithPassword(Guid.Parse("9c1c2b3a-0000-4000-8000-000000000099")); + GivenToken(MakeSnapshot(user.Props.Id.GetValue())); + GivenAccount(user); + + var result = await Invoke(); + + Assert.True(result.IsFailure); + Assert.Equal(ResetPasswordCommandHandler.InvalidTokenError, result.Error); + VerifyCredentialSurvived(user); + // El token queda gastado: si la cuenta ya no es la que era, ese secreto no debe seguir vivo. + _resetTokens.Verify(s => s.ConsumeAsync(TokenId, It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task ResetPassword_WhenAccountWasBlockedAfterIssuing_Fails() + { + var user = MakeUserWithPassword(); + user.Block(Reason.Create("prueba"), ActorId.Create("sys")); + GivenToken(MakeSnapshot(user.Props.Id.GetValue())); + GivenAccount(user); + + var result = await Invoke(); + + Assert.True(result.IsFailure); + Assert.Equal(ResetPasswordCommandHandler.InvalidTokenError, result.Error); + VerifyCredentialSurvived(user); + } + + [Fact] + public async Task ResetPassword_WhenAccountVanished_Fails() + { + GivenToken(MakeSnapshot(Guid.NewGuid())); + GivenAccount(null); + + var result = await Invoke(); + + Assert.True(result.IsFailure); + Assert.Equal(ResetPasswordCommandHandler.InvalidTokenError, result.Error); + _userRepo.Verify(r => r.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + private void VerifyCredentialSurvived(UserAccount user) + { + var activa = Assert.Single(user.PasswordCredentials.Where(c => c.IsActive)); + Assert.Equal(PreviousHash, activa.PasswordHash.GetValue()); + _userRepo.Verify(r => r.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never); + _uow.Verify(u => u.SaveEntitiesAsync(It.IsAny()), Times.Never); + _refreshTokens.Verify(s => s.RevokeAllForUserAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), + Times.Never); + } + + #endregion +} diff --git a/src/apps/ums.api/Ums.Application.Test/Identity/Auth/SignupUserCommandHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/SignupUserCommandHandlerTests.cs index 58553f0d..7e6d81b3 100644 --- a/src/apps/ums.api/Ums.Application.Test/Identity/Auth/SignupUserCommandHandlerTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/SignupUserCommandHandlerTests.cs @@ -28,13 +28,13 @@ public SignupUserCommandHandlerTests() _hasher.Setup(h => h.Hash(It.IsAny())).Returns("hashed-password"); } - private Domain.Identity.Tenant.Tenant MakeTenant() => + private static Domain.Identity.Tenant.Tenant MakeTenant() => Domain.Identity.Tenant.Tenant.Create( Code.Create(TenantCode), Name.Create("Acme Corp"), OrganizationType.INTERNAL, ActorId.Create("sys"), tenantId: Domain.Kernel.ValueObjects.TenantId.Load(TenantId)).Value; - private UserAccount MakeActiveInternalAdmin() + private static UserAccount MakeActiveInternalAdmin() { var user = UserAccount.Create( Domain.Kernel.ValueObjects.TenantId.Load(TenantId), diff --git a/src/apps/ums.api/Ums.Application.Test/Identity/Auth/SwitchProfileCommandHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/SwitchProfileCommandHandlerTests.cs new file mode 100644 index 00000000..b90d2d52 --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/Identity/Auth/SwitchProfileCommandHandlerTests.cs @@ -0,0 +1,137 @@ +namespace Ums.Application.Test.Identity.Auth; + +using Moq; +using Xunit; +using FluentAssertions; +using Ums.Application.Common.Interfaces; +using Ums.Application.Identity.Auth.Commands; +using Ums.Domain.Authorization; +using Ums.Domain.Authorization.Graph; +using Ums.Domain.Identity; +using Ums.Domain.Identity.Auth; +using Ums.Domain.Kernel; +using ProfileAggregate = Ums.Domain.Authorization.Profile.Profile; +using UserAccountAggregate = Ums.Domain.Identity.UserAccount.UserAccount; + +/// +/// Reglas del cambio de perfil. Las dos primeras son el motivo por el que este endpoint no es +/// trivial: el identificador del perfil lo envía el cliente, así que si no se comprueba que +/// pertenece al usuario del token y a su inquilino, cambiar de perfil se convierte en una escalada +/// de privilegios de una línea. +/// +public class SwitchProfileCommandHandlerTests +{ + private readonly Mock _userRepo = new(); + private readonly Mock _profileRepo = new(); + private readonly Mock _graphBuilder = new(); + private readonly Mock _audit = new(); + + private static readonly Guid TenantGuid = Guid.NewGuid(); + private static readonly Guid UserGuid = Guid.NewGuid(); + private static readonly Guid ProfileGuid = Guid.NewGuid(); + + private SwitchProfileCommandHandler CreateSut() + => new(_userRepo.Object, _profileRepo.Object, _graphBuilder.Object, _audit.Object); + + private static SwitchProfileCommand Command() + => new(ProfileGuid, UserGuid, TenantGuid, "10.0.0.1"); + + private static UserAccountAggregate UsuarioActivo() + { + var user = UserAccountAggregate.Create( + TenantId.Load(TenantGuid), + Email.Create("ana@beyondnet.com.pe"), + UserCategory.Internal, + null, null, + ActorId.Create("system"), + null, + UserAccountId.Load(UserGuid)).Value; + + user.Activate(ActorId.Create("system")); + return user; + } + + private static ProfileAggregate Perfil(Guid userId, Guid tenantId, bool activo = true) + { + var profile = ProfileAggregate.Create( + TenantId.Load(tenantId), + UserId.Load(userId), + RoleId.Load(Guid.NewGuid()), + null, + ActorId.Create("system")).Value; + + if (!activo) profile.Deactivate(ActorId.Create("system")); + return profile; + } + + private void ConUsuarioYPerfil(ProfileAggregate perfil) + { + _userRepo.Setup(r => r.GetByIdAsync(UserGuid, It.IsAny())) + .ReturnsAsync(UsuarioActivo()); + _profileRepo.Setup(r => r.GetByIdAsync(ProfileGuid, It.IsAny())) + .ReturnsAsync(perfil); + } + + [Fact] + public async Task Un_perfil_de_otro_usuario_no_se_puede_asumir() + { + ConUsuarioYPerfil(Perfil(userId: Guid.NewGuid(), tenantId: TenantGuid)); + + var resultado = await CreateSut().Handle(Command(), CancellationToken.None); + + resultado.IsFailure.Should().BeTrue(); + // Mismo mensaje que «no existe»: distinguirlos permitiría enumerar perfiles ajenos. + resultado.Error.Should().Contain("AUTH_020"); + _graphBuilder.Verify(b => b.BuildForProfileAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Un_perfil_de_otro_inquilino_no_se_puede_asumir() + { + ConUsuarioYPerfil(Perfil(userId: UserGuid, tenantId: Guid.NewGuid())); + + var resultado = await CreateSut().Handle(Command(), CancellationToken.None); + + resultado.IsFailure.Should().BeTrue(); + resultado.Error.Should().Contain("AUTH_020"); + } + + [Fact] + public async Task El_intento_denegado_queda_auditado() + { + ConUsuarioYPerfil(Perfil(userId: Guid.NewGuid(), tenantId: TenantGuid)); + + await CreateSut().Handle(Command(), CancellationToken.None); + + _audit.Verify(a => a.RecordAuthEventAsync( + It.Is(e => e.EventType == "Auth.Profile.SwitchDenied" && !e.Succeeded), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task Un_perfil_inactivo_no_se_puede_asumir() + { + ConUsuarioYPerfil(Perfil(UserGuid, TenantGuid, activo: false)); + + var resultado = await CreateSut().Handle(Command(), CancellationToken.None); + + resultado.IsFailure.Should().BeTrue(); + resultado.Error.Should().Contain("AUTH_021"); + } + + [Fact] + public async Task Un_perfil_inexistente_devuelve_no_encontrado() + { + _userRepo.Setup(r => r.GetByIdAsync(UserGuid, It.IsAny())) + .ReturnsAsync(UsuarioActivo()); + _profileRepo.Setup(r => r.GetByIdAsync(ProfileGuid, It.IsAny())) + .ReturnsAsync((ProfileAggregate?)null); + + var resultado = await CreateSut().Handle(Command(), CancellationToken.None); + + resultado.IsFailure.Should().BeTrue(); + resultado.Error.Should().Contain("AUTH_020"); + } +} diff --git a/src/apps/ums.api/Ums.Application.Test/Identity/Tenant/Commands/SetManagementOwnerCommandHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Identity/Tenant/Commands/SetManagementOwnerCommandHandlerTests.cs index 17dc81d0..b46aea68 100644 --- a/src/apps/ums.api/Ums.Application.Test/Identity/Tenant/Commands/SetManagementOwnerCommandHandlerTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Identity/Tenant/Commands/SetManagementOwnerCommandHandlerTests.cs @@ -18,6 +18,10 @@ public SetManagementOwnerCommandHandlerTests() _unitOfWorkMock = new Mock(); _tenantRepositoryMock.Setup(r => r.UnitOfWork).Returns(_unitOfWorkMock.Object); _unitOfWorkMock.Setup(u => u.SaveEntitiesAsync(It.IsAny())).ReturnsAsync(true); + // G-045: el handler ahora verifica la unicidad del management owner leyendo todos los + // tenants antes de persistir. Por defecto no existe ningún otro owner. + _tenantRepositoryMock.Setup(r => r.GetAllAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); _userContextMock = new Mock(); _handler = new SetManagementOwnerCommandHandler(_tenantRepositoryMock.Object, _userContextMock.Object); @@ -91,6 +95,30 @@ public async Task Handle_WhenTenantNotFound_ReturnsFailure() _unitOfWorkMock.Verify(u => u.SaveEntitiesAsync(It.IsAny()), Times.Never); } + [Fact] + public async Task Handle_WhenAnotherManagementOwnerExists_ReturnsFailure() + { + // G-045: otorgar la propiedad de gestión a un segundo tenant viola el índice parcial + // único IX_Tenants_SingleManagementOwner. El handler lo detecta antes de persistir y + // devuelve un fallo (que presentación mapea a 409) en vez de un 500 por violación de índice. + var tenantId = Guid.NewGuid(); + _userContextMock.Setup(u => u.UserId).Returns("user-001"); + var tenant = CreateTenant(isManagementOwner: false); + _tenantRepositoryMock.Setup(r => r.GetByIdAsync(tenantId, It.IsAny())) + .ReturnsAsync(tenant); + var existingOwner = CreateTenant(isManagementOwner: true); + _tenantRepositoryMock.Setup(r => r.GetAllAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { existingOwner }); + + var result = await _handler.Handle(new SetManagementOwnerCommand(tenantId, true), CancellationToken.None); + + Assert.True(result.IsFailure); + // G-037/G-045: código estable de dominio (presentación lo mapea a 409). + Assert.Equal(DomainErrors.Tenant.ManagementOwnerAlreadyExists, result.Error); + _tenantRepositoryMock.Verify(r => r.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never); + _unitOfWorkMock.Verify(u => u.SaveEntitiesAsync(It.IsAny()), Times.Never); + } + private static Tenant CreateTenant(bool isManagementOwner) { return Tenant.Create( diff --git a/src/apps/ums.api/Ums.Application.Test/Identity/UserAccount/UserAccountPasswordMfaCommandHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Identity/UserAccount/UserAccountPasswordMfaCommandHandlerTests.cs index 775703cb..936ddb73 100644 --- a/src/apps/ums.api/Ums.Application.Test/Identity/UserAccount/UserAccountPasswordMfaCommandHandlerTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Identity/UserAccount/UserAccountPasswordMfaCommandHandlerTests.cs @@ -64,7 +64,7 @@ public async Task ActivatePassword_WithValidCommand_ReturnsSuccess() _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(user); - var cmd = new ActivatePasswordCommand(user.GetId().GetValue(), credential.Id.GetValue()); + var cmd = new ActivatePasswordCommand(user.GetId().GetValue(), credential.GetId().GetValue()); var handler = new ActivatePasswordCommandHandler(_repo.Object, _ctx.Object); var result = await handler.Handle(cmd, CancellationToken.None); @@ -124,7 +124,7 @@ public async Task ActivateUserAccountPassword_WithValidCommand_ReturnsSuccess() _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(user); - var cmd = new ActivateUserAccountPasswordCommand(user.GetId().GetValue(), credential.Id.GetValue()); + var cmd = new ActivateUserAccountPasswordCommand(user.GetId().GetValue(), credential.GetId().GetValue()); var handler = new ActivateUserAccountPasswordCommandHandler(_repo.Object, _ctx.Object); var result = await handler.Handle(cmd, CancellationToken.None); @@ -245,7 +245,7 @@ public async Task RemovePassword_WithValidCommand_ReturnsSuccess() _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(user); - var cmd = new RemovePasswordCommand(user.GetId().GetValue(), credential1.Id.GetValue()); + var cmd = new RemovePasswordCommand(user.GetId().GetValue(), credential1.GetId().GetValue()); var handler = new RemovePasswordCommandHandler(_repo.Object, _ctx.Object); var result = await handler.Handle(cmd, CancellationToken.None); @@ -305,7 +305,7 @@ public async Task RemovePassword_WhenLastPassword_ReturnsFailure() _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(user); - var cmd = new RemovePasswordCommand(user.GetId().GetValue(), credential.Id.GetValue()); + var cmd = new RemovePasswordCommand(user.GetId().GetValue(), credential.GetId().GetValue()); var handler = new RemovePasswordCommandHandler(_repo.Object, _ctx.Object); var result = await handler.Handle(cmd, CancellationToken.None); @@ -323,7 +323,7 @@ public async Task RemoveUserAccountPassword_WithValidCommand_ReturnsSuccess() _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(user); - var cmd = new RemoveUserAccountPasswordCommand(user.GetId().GetValue(), credential1.Id.GetValue()); + var cmd = new RemoveUserAccountPasswordCommand(user.GetId().GetValue(), credential1.GetId().GetValue()); var handler = new RemoveUserAccountPasswordCommandHandler(_repo.Object, _ctx.Object); var result = await handler.Handle(cmd, CancellationToken.None); @@ -383,7 +383,7 @@ public async Task RemoveUserAccountPassword_WhenLastPassword_ReturnsFailure() _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(user); - var cmd = new RemoveUserAccountPasswordCommand(user.GetId().GetValue(), credential.Id.GetValue()); + var cmd = new RemoveUserAccountPasswordCommand(user.GetId().GetValue(), credential.GetId().GetValue()); var handler = new RemoveUserAccountPasswordCommandHandler(_repo.Object, _ctx.Object); var result = await handler.Handle(cmd, CancellationToken.None); @@ -509,7 +509,7 @@ public async Task VerifyMfa_WithValidCommand_ReturnsSuccess() _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(user); - var cmd = new VerifyUserAccountMfaCommand(user.GetId().GetValue(), enrollment.Id.GetValue()); + var cmd = new VerifyUserAccountMfaCommand(user.GetId().GetValue(), enrollment.GetId().GetValue()); var handler = new VerifyUserAccountMfaCommandHandler(_repo.Object, _ctx.Object); var result = await handler.Handle(cmd, CancellationToken.None); @@ -571,7 +571,7 @@ public async Task RecordAuthenticationAttempt_WithValidCommand_ReturnsSuccess() true, "Password valid", "192.168.1.1"); - var handler = new RecordAuthenticationAttemptCommandHandler(_repo.Object, _ctx.Object); + var handler = new RecordAuthenticationAttemptCommandHandler(_repo.Object, _ctx.Object, _configurationProvider.Object); var result = await handler.Handle(cmd, CancellationToken.None); Assert.True(result.IsSuccess); @@ -586,7 +586,7 @@ public async Task RecordAuthenticationAttempt_WhenNotFound_ReturnsFailure() .ReturnsAsync((UserAccount?)null); var cmd = new RecordAuthenticationAttemptCommand(Guid.NewGuid(), true, "Reason", "127.0.0.1"); - var handler = new RecordAuthenticationAttemptCommandHandler(_repo.Object, _ctx.Object); + var handler = new RecordAuthenticationAttemptCommandHandler(_repo.Object, _ctx.Object, _configurationProvider.Object); var result = await handler.Handle(cmd, CancellationToken.None); Assert.True(result.IsFailure); @@ -599,7 +599,7 @@ public async Task RecordAuthenticationAttempt_WhenUnauthenticated_ReturnsFailure _ctx.Setup(u => u.UserId).Returns(""); var cmd = new RecordAuthenticationAttemptCommand(Guid.NewGuid(), true, "Reason", "127.0.0.1"); - var handler = new RecordAuthenticationAttemptCommandHandler(_repo.Object, _ctx.Object); + var handler = new RecordAuthenticationAttemptCommandHandler(_repo.Object, _ctx.Object, _configurationProvider.Object); var result = await handler.Handle(cmd, CancellationToken.None); Assert.True(result.IsFailure); @@ -618,7 +618,7 @@ public async Task RecordAuthenticationAttempt_WithFailedAttempt_ReturnsSuccess() false, "Invalid password", "10.0.0.1"); - var handler = new RecordAuthenticationAttemptCommandHandler(_repo.Object, _ctx.Object); + var handler = new RecordAuthenticationAttemptCommandHandler(_repo.Object, _ctx.Object, _configurationProvider.Object); var result = await handler.Handle(cmd, CancellationToken.None); Assert.True(result.IsSuccess); @@ -634,7 +634,7 @@ public async Task RevokeEnrollment_WithValidCommand_ReturnsSuccess() _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(user); - var cmd = new RevokeUserAccountMfaCommand(user.GetId().GetValue(), enrollment.Id.GetValue()); + var cmd = new RevokeUserAccountMfaCommand(user.GetId().GetValue(), enrollment.GetId().GetValue()); var handler = new RevokeUserAccountMfaCommandHandler(_repo.Object, _ctx.Object, _tenantScopePolicy.Object, _delegationAccess.Object); var result = await handler.Handle(cmd, CancellationToken.None); diff --git a/src/apps/ums.api/Ums.Application.Test/Identity/UserManagementDelegation/DelegationCommandHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Identity/UserManagementDelegation/DelegationCommandHandlerTests.cs index 648f5f34..7220adb3 100644 --- a/src/apps/ums.api/Ums.Application.Test/Identity/UserManagementDelegation/DelegationCommandHandlerTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Identity/UserManagementDelegation/DelegationCommandHandlerTests.cs @@ -19,6 +19,7 @@ public class DelegationCommandHandlerTests private readonly Mock _userRepo = new(); private readonly Mock _uow = new(); private readonly Mock _ctx = new(); + private readonly Mock _scope = new(); private readonly Guid _currentUserId = Guid.NewGuid(); private readonly Guid _tenantId = Guid.NewGuid(); private readonly Guid _delegatingAdminId; @@ -30,6 +31,9 @@ public DelegationCommandHandlerTests() _repo.Setup(r => r.UnitOfWork).Returns(_uow.Object); _uow.Setup(u => u.SaveEntitiesAsync(It.IsAny())).ReturnsAsync(true); _ctx.Setup(u => u.UserId).Returns(_currentUserId.ToString()); + // Por defecto el operador es management-owner del inquilino objetivo (gate satisfecho). + _scope.Setup(s => s.EnsureManagementOwnerScopeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success()); } private static UserManagementDelegation MakeDelegation() @@ -48,6 +52,25 @@ private static UserManagementDelegation MakeDelegation() ActorId.Create("user-001")).Value; } + // Delegación con delegatingAdmin conocido, en PendingApproval — para probar la SoD (auto-aprobación). + private static UserManagementDelegation MakePendingApprovalDelegationWith(Guid delegatingAdminId) + { + var delegation = UserManagementDelegation.Create( + TenantId.Load(Guid.NewGuid()), + UserAccountId.Load(delegatingAdminId), + UserAccountId.Load(Guid.NewGuid()), + DelegationScopeType.Tenant, + null, + new List { DelegatedAction.CreateUser }, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow.AddDays(5), + 10, + true, + ActorId.Create(delegatingAdminId.ToString())).Value; + delegation.SubmitForApproval(Guid.NewGuid(), ActorId.Create(delegatingAdminId.ToString())); + return delegation; + } + private UserAccount MakeActiveUser(Guid userId) { var user = UserAccount.Create( @@ -295,4 +318,274 @@ public async Task Expire_WhenNotFound_ReturnsFailure() } #endregion + + // ========================================================================= + #region SubmitDelegationForApprovalCommandHandler (G-132) + // ========================================================================= + + private static UserManagementDelegation MakePendingApprovalDelegation() + { + var delegation = MakeDelegation(); // Born Draft + delegation.SubmitForApproval(Guid.NewGuid(), ActorId.Create("user-001")); + return delegation; + } + + [Fact] + public async Task Submit_WithDraftDelegation_ReturnsSuccess() + { + _ctx.Setup(u => u.UserId).Returns("user-001"); + var delegation = MakeDelegation(); // Born Draft + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(delegation); + + var cmd = new SubmitDelegationForApprovalCommand(Guid.NewGuid()); + var handler = new SubmitDelegationForApprovalCommandHandler(_repo.Object, _ctx.Object, _scope.Object); + var result = await handler.Handle(cmd, CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(DelegationStatus.PendingApproval, delegation.Status); + Assert.NotNull(delegation.ApprovalRequestId); + Assert.NotEqual(Guid.Empty, delegation.ApprovalRequestId!.Value); + _repo.Verify(r => r.UpdateAsync(delegation, It.IsAny()), Times.Once); + _uow.Verify(u => u.SaveEntitiesAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Submit_WhenNotDraft_ReturnsFailure() + { + // Camino de RECHAZO: enviar a aprobación fuera de estado Draft (ya en PendingApproval) → falla. + _ctx.Setup(u => u.UserId).Returns("user-001"); + var delegation = MakePendingApprovalDelegation(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(delegation); + + var cmd = new SubmitDelegationForApprovalCommand(Guid.NewGuid()); + var handler = new SubmitDelegationForApprovalCommandHandler(_repo.Object, _ctx.Object, _scope.Object); + var result = await handler.Handle(cmd, CancellationToken.None); + + Assert.True(result.IsFailure); + _repo.Verify(r => r.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Submit_WhenNotFound_ReturnsFailure() + { + _ctx.Setup(u => u.UserId).Returns("user-001"); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((UserManagementDelegation?)null); + + var cmd = new SubmitDelegationForApprovalCommand(Guid.NewGuid()); + var handler = new SubmitDelegationForApprovalCommandHandler(_repo.Object, _ctx.Object, _scope.Object); + var result = await handler.Handle(cmd, CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains("not found", result.Error, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Submit_WhenUnauthenticated_ReturnsFailure() + { + _ctx.Setup(u => u.UserId).Returns(""); + + var cmd = new SubmitDelegationForApprovalCommand(Guid.NewGuid()); + var handler = new SubmitDelegationForApprovalCommandHandler(_repo.Object, _ctx.Object, _scope.Object); + var result = await handler.Handle(cmd, CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains("authenticated user is required", result.Error, StringComparison.OrdinalIgnoreCase); + } + + #endregion + + // ========================================================================= + #region ApproveDelegationCommandHandler (G-132) + // ========================================================================= + + [Fact] + public async Task Approve_WithPendingApproval_ReturnsSuccess() + { + _ctx.Setup(u => u.UserId).Returns("user-001"); + var delegation = MakePendingApprovalDelegation(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(delegation); + + var cmd = new ApproveDelegationCommand(Guid.NewGuid()); + var handler = new ApproveDelegationCommandHandler(_repo.Object, _ctx.Object, _scope.Object); + var result = await handler.Handle(cmd, CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(DelegationStatus.Active, delegation.Status); + _repo.Verify(r => r.UpdateAsync(delegation, It.IsAny()), Times.Once); + _uow.Verify(u => u.SaveEntitiesAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Approve_WhenNotPendingApproval_ReturnsFailure() + { + // Camino de RECHAZO: aprobar (activar) una delegación en Draft, fuera de PendingApproval → falla. + _ctx.Setup(u => u.UserId).Returns("user-001"); + var delegation = MakeDelegation(); // Born Draft + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(delegation); + + var cmd = new ApproveDelegationCommand(Guid.NewGuid()); + var handler = new ApproveDelegationCommandHandler(_repo.Object, _ctx.Object, _scope.Object); + var result = await handler.Handle(cmd, CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Equal(DelegationStatus.Draft, delegation.Status); + _repo.Verify(r => r.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Approve_WhenNotFound_ReturnsFailure() + { + _ctx.Setup(u => u.UserId).Returns("user-001"); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((UserManagementDelegation?)null); + + var cmd = new ApproveDelegationCommand(Guid.NewGuid()); + var handler = new ApproveDelegationCommandHandler(_repo.Object, _ctx.Object, _scope.Object); + var result = await handler.Handle(cmd, CancellationToken.None); + + Assert.True(result.IsFailure); + } + + // G-148 — AUTORIZACIÓN: sin autoridad de management-owner, aprobar falla y no persiste. + [Fact] + public async Task Approve_WhenNotManagementOwner_ReturnsFailure() + { + _ctx.Setup(u => u.UserId).Returns("user-001"); + var delegation = MakePendingApprovalDelegation(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(delegation); + // El operador NO es management-owner del inquilino → gate rechaza. + _scope.Setup(s => s.EnsureManagementOwnerScopeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Failure("AUTH_015: Tenant is not marked as management owner.")); + + var cmd = new ApproveDelegationCommand(Guid.NewGuid()); + var handler = new ApproveDelegationCommandHandler(_repo.Object, _ctx.Object, _scope.Object); + var result = await handler.Handle(cmd, CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Equal(DelegationStatus.PendingApproval, delegation.Status); + _repo.Verify(r => r.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + // G-149 — AISLAMIENTO POR INQUILINO: operar sobre una delegación de otro inquilino falla + // (el gate de management-owner devuelve AUTH_014 tenant mismatch) y no persiste. + [Fact] + public async Task Approve_WhenCrossTenant_ReturnsFailure() + { + _ctx.Setup(u => u.UserId).Returns("user-001"); + var delegation = MakePendingApprovalDelegation(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(delegation); + _scope.Setup(s => s.EnsureManagementOwnerScopeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Failure("AUTH_014: Tenant mismatch.")); + + var cmd = new ApproveDelegationCommand(Guid.NewGuid()); + var handler = new ApproveDelegationCommandHandler(_repo.Object, _ctx.Object, _scope.Object); + var result = await handler.Handle(cmd, CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains("AUTH_014", result.Error, StringComparison.OrdinalIgnoreCase); + _repo.Verify(r => r.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + // G-150 — SEPARACIÓN DE FUNCIONES: el administrador delegante no puede autoaprobar su propia + // delegación, aun siendo management-owner (invariante de dominio INV-DEL8). No persiste. + [Fact] + public async Task Approve_WhenSelfApproval_ReturnsFailure() + { + var delegatingAdminId = Guid.NewGuid(); + _ctx.Setup(u => u.UserId).Returns(delegatingAdminId.ToString()); + var delegation = MakePendingApprovalDelegationWith(delegatingAdminId); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(delegation); + // El gate de autorización se satisface (es management-owner); solo la SoD debe frenarlo. + _scope.Setup(s => s.EnsureManagementOwnerScopeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success()); + + var cmd = new ApproveDelegationCommand(Guid.NewGuid()); + var handler = new ApproveDelegationCommandHandler(_repo.Object, _ctx.Object, _scope.Object); + var result = await handler.Handle(cmd, CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Equal(DelegationStatus.PendingApproval, delegation.Status); + _repo.Verify(r => r.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + #endregion + + // ========================================================================= + #region RejectDelegationCommandHandler (G-132) + // ========================================================================= + + [Fact] + public async Task Reject_WithPendingApproval_ReturnsSuccess() + { + _ctx.Setup(u => u.UserId).Returns("user-001"); + var delegation = MakePendingApprovalDelegation(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(delegation); + + var cmd = new RejectDelegationCommand(Guid.NewGuid(), "Insufficient justification"); + var handler = new RejectDelegationCommandHandler(_repo.Object, _ctx.Object, _scope.Object); + var result = await handler.Handle(cmd, CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(DelegationStatus.Rejected, delegation.Status); + _repo.Verify(r => r.UpdateAsync(delegation, It.IsAny()), Times.Once); + } + + [Fact] + public async Task Reject_WhenNotPendingApproval_ReturnsFailure() + { + // Camino de RECHAZO: rechazar una delegación en Draft, fuera de PendingApproval → falla. + _ctx.Setup(u => u.UserId).Returns("user-001"); + var delegation = MakeDelegation(); // Born Draft + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(delegation); + + var cmd = new RejectDelegationCommand(Guid.NewGuid(), "reason"); + var handler = new RejectDelegationCommandHandler(_repo.Object, _ctx.Object, _scope.Object); + var result = await handler.Handle(cmd, CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Equal(DelegationStatus.Draft, delegation.Status); + _repo.Verify(r => r.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Reject_WhenEmptyReason_ReturnsFailure() + { + _ctx.Setup(u => u.UserId).Returns("user-001"); + var delegation = MakePendingApprovalDelegation(); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(delegation); + + var cmd = new RejectDelegationCommand(Guid.NewGuid(), " "); + var handler = new RejectDelegationCommandHandler(_repo.Object, _ctx.Object, _scope.Object); + var result = await handler.Handle(cmd, CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Equal(DelegationStatus.PendingApproval, delegation.Status); + } + + [Fact] + public async Task Reject_WhenNotFound_ReturnsFailure() + { + _ctx.Setup(u => u.UserId).Returns("user-001"); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((UserManagementDelegation?)null); + + var cmd = new RejectDelegationCommand(Guid.NewGuid(), "reason"); + var handler = new RejectDelegationCommandHandler(_repo.Object, _ctx.Object, _scope.Object); + var result = await handler.Handle(cmd, CancellationToken.None); + + Assert.True(result.IsFailure); + } + + #endregion } diff --git a/src/apps/ums.api/Ums.Application.Test/Identity/UserManagementDelegation/DelegationQueryHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Identity/UserManagementDelegation/DelegationQueryHandlerTests.cs index 0f2aa7f6..e70ac6e2 100644 --- a/src/apps/ums.api/Ums.Application.Test/Identity/UserManagementDelegation/DelegationQueryHandlerTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Identity/UserManagementDelegation/DelegationQueryHandlerTests.cs @@ -22,7 +22,7 @@ public DelegationQueryHandlerTests() _repo.Setup(r => r.UnitOfWork).Returns(_uow.Object); } - private static UserManagementDelegation MakeDelegation(Guid? id = null) + private static UserManagementDelegation MakeDelegation() { return UserManagementDelegation.Create( TenantId.Load(Guid.NewGuid()), diff --git a/src/apps/ums.api/Ums.Application.Test/Observability/FunctionalTransactionTests.cs b/src/apps/ums.api/Ums.Application.Test/Observability/FunctionalTransactionTests.cs new file mode 100644 index 00000000..d9a48cd9 --- /dev/null +++ b/src/apps/ums.api/Ums.Application.Test/Observability/FunctionalTransactionTests.cs @@ -0,0 +1,140 @@ +namespace Ums.Application.Test.Observability; + +using Microsoft.Extensions.Logging.Abstractions; +using Ums.Application.Common.Interfaces; +using Ums.Infrastructure.Observability; +using Xunit; + +/// +/// Pruebas del modelo de transacción funcional (ADR-0096 §2.3; ADR-UMS-085). Verifican el +/// invariante del desenlace, la acuñación perezosa e idempotente del localizador legible +/// (ADR-UMS-084) y la reconciliación del estado con las etapas. +/// +public sealed class FunctionalTransactionTests +{ + private sealed class StubLocatorFactory : ITransactionLocatorFactory + { + public int Calls { get; private set; } + + public Task NextAsync(CancellationToken cancellationToken = default) + { + Calls++; + return Task.FromResult($"TX-2026-{Calls:D6}"); + } + } + + private static FunctionalTransaction NewTransaction(out StubLocatorFactory factory) + { + factory = new StubLocatorFactory(); + return new FunctionalTransaction( + NullLogger.Instance, + factory); + } + + [Fact] + public void El_localizador_no_existe_hasta_acuñarlo() + { + var tx = NewTransaction(out _); + tx.Open("POST /x", actor: "ana"); + + Assert.Null(tx.Locator); + } + + [Fact] + public async Task GetOrMintLocator_es_idempotente_y_acuña_una_sola_vez() + { + var tx = NewTransaction(out var factory); + tx.Open("POST /x", actor: "ana"); + + var first = await tx.GetOrMintLocatorAsync(); + var second = await tx.GetOrMintLocatorAsync(); + + Assert.Equal(first, second); + Assert.Equal("TX-2026-000001", first); + Assert.Equal(1, factory.Calls); + Assert.Equal(first, tx.Locator); + } + + [Fact] + public async Task Un_fallo_acuña_el_localizador_para_mostrarlo_al_usuario() + { + var tx = NewTransaction(out var factory); + tx.Open("GET /x", actor: "ana"); + + await tx.CompleteAsync(TransactionState.Failed, statusCode: 500); + + Assert.Equal(TransactionState.Failed, tx.State); + Assert.NotNull(tx.Locator); + Assert.Equal(1, factory.Calls); + } + + [Fact] + public async Task Una_lectura_exitosa_no_acuña_localizador() + { + var tx = NewTransaction(out var factory); + tx.Open("GET /x", actor: "ana"); + + await tx.CompleteAsync(TransactionState.Completed, statusCode: 200); + + Assert.Equal(TransactionState.Completed, tx.State); + Assert.Null(tx.Locator); + Assert.Equal(0, factory.Calls); + } + + [Fact] + public async Task Exito_nominal_con_etapa_fallida_se_reconcilia_como_parcial() + { + var tx = NewTransaction(out _); + tx.Open("POST /x", actor: "ana"); + tx.RecordStage("validar", detail: "ok"); + tx.RecordStage("publicar-evento", detail: "timeout del bus", failed: true); + + await tx.CompleteAsync(TransactionState.Completed, statusCode: 200); + + Assert.Equal(TransactionState.PartiallyCompleted, tx.State); + Assert.NotNull(tx.Locator); // parcial es no-exitoso → se acuña + } + + [Fact] + public async Task Exito_nominal_con_todas_las_etapas_fallidas_se_reconcilia_como_fallo() + { + var tx = NewTransaction(out _); + tx.Open("POST /x", actor: "ana"); + tx.RecordStage("publicar-evento", failed: true); + + await tx.CompleteAsync(TransactionState.Completed, statusCode: 200); + + Assert.Equal(TransactionState.Failed, tx.State); + } + + [Fact] + public async Task CompleteAsync_es_idempotente() + { + var tx = NewTransaction(out var factory); + tx.Open("GET /x", actor: "ana"); + + await tx.CompleteAsync(TransactionState.Failed, statusCode: 500); + var locatorAfterFirst = tx.Locator; + await tx.CompleteAsync(TransactionState.Completed, statusCode: 200); + + // El segundo desenlace no altera el estado ni vuelve a acuñar. + Assert.Equal(TransactionState.Failed, tx.State); + Assert.Equal(locatorAfterFirst, tx.Locator); + Assert.Equal(1, factory.Calls); + } + + [Fact] + public void RecordEffect_y_RecordDecision_no_lanzan() + { + var tx = NewTransaction(out _); + tx.Open("POST /x", actor: "ana"); + + var effect = Record.Exception(() => + tx.RecordEffect("message.publish", "outbox", "evt-1", EffectReversibility.PendingCompensation)); + var decision = Record.Exception(() => + tx.RecordDecision("aprobar", "el monto está bajo el umbral")); + + Assert.Null(effect); + Assert.Null(decision); + } +} diff --git a/src/apps/ums.api/Ums.Application.Test/Tenants/AddBranch/AddBranchCommandHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Tenants/AddBranch/AddBranchCommandHandlerTests.cs index 4367188f..7a9756aa 100644 --- a/src/apps/ums.api/Ums.Application.Test/Tenants/AddBranch/AddBranchCommandHandlerTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Tenants/AddBranch/AddBranchCommandHandlerTests.cs @@ -58,17 +58,17 @@ public async Task Handle_WhenBranchCodeExistsCrossTenant_ReturnsFailure() { var tenantId = Guid.NewGuid(); _userContextMock.Setup(u => u.UserId).Returns("user-001"); - var tenant = CreateTenant(); + var tenant = CreateTenant(); // agregado sin sucursales (colección vaciada por el filtro global) _tenantRepositoryMock.Setup(r => r.GetByIdAsync(tenantId, It.IsAny())) .ReturnsAsync(tenant); - _tenantRepositoryMock.Setup(r => r.BranchCodeExistsAsync(tenantId, It.IsAny(), It.IsAny())) + _tenantRepositoryMock.Setup(r => r.BranchCodeExistsAsync(tenantId, "BR-001", It.IsAny())) .ReturnsAsync(true); var command = ValidCommand with { TenantId = tenantId }; var result = await _handler.Handle(command, CancellationToken.None); Assert.True(result.IsFailure); - Assert.Equal(DomainErrors.Tenant.BranchCodeNotUnique, result.Error); + Assert.Contains(DomainErrors.Tenant.BranchCodeNotUnique, result.Error); } [Fact] diff --git a/src/apps/ums.api/Ums.Application.Test/Tenants/Branch/BranchCommandHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Tenants/Branch/BranchCommandHandlerTests.cs index 33f1ae13..de6e6c86 100644 --- a/src/apps/ums.api/Ums.Application.Test/Tenants/Branch/BranchCommandHandlerTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Tenants/Branch/BranchCommandHandlerTests.cs @@ -1,6 +1,8 @@ namespace Ums.Application.Test.Tenants.Branch; using Ums.Application.Identity.Tenant.Branch.Commands; +using Ums.Application.Identity.Tenant.Branch.Queries; +using Ums.Domain.Authorization; using Ums.Domain.Identity; using Ums.Domain.Identity.Tenant; using Ums.Application.Common.Interfaces; @@ -9,6 +11,8 @@ namespace Ums.Application.Test.Tenants.Branch; public class BranchCommandHandlerTests { private readonly Mock _repo = new(); + private readonly Mock _userRepo = new(); + private readonly Mock _profileRepo = new(); private readonly Mock _uow = new(); private readonly Mock _ctx = new(); private readonly Mock _scopePolicy = new(); @@ -20,6 +24,10 @@ public BranchCommandHandlerTests() _ctx.Setup(u => u.UserId).Returns("user-001"); _scopePolicy.Setup(s => s.EnsureManagementOwnerScopeAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(Result.Success()); + _userRepo.Setup(r => r.CountActiveByBranchAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(0); + _profileRepo.Setup(r => r.CountActiveByBranchAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(0); } private static Tenant MakeTenant() @@ -42,14 +50,14 @@ private static Tenant MakeTenant() public async Task DeactivateBranch_WithValidCommand_ReturnsSuccess() { var tenant = MakeTenant(); - var branchResult = tenant.AddBranch(Code.Create("BR-001"), Name.Create("Branch One"), ActorId.Create("user-001"), null); + tenant.AddBranch(Code.Create("BR-001"), Name.Create("Branch One"), ActorId.Create("user-001"), null); var branchId = tenant.Branches.First().GetId().GetValue(); _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(tenant); var cmd = new DeactivateBranchCommand(tenant.Props.Id.GetValue(), branchId); - var handler = new DeactivateBranchCommandHandler(_repo.Object, _ctx.Object, _scopePolicy.Object); + var handler = new DeactivateBranchCommandHandler(_repo.Object, _userRepo.Object, _ctx.Object, _scopePolicy.Object); var result = await handler.Handle(cmd, CancellationToken.None); Assert.True(result.IsSuccess, result.Error); @@ -64,7 +72,7 @@ public async Task DeactivateBranch_WhenTenantNotFound_ReturnsFailure() .ReturnsAsync((Tenant?)null); var cmd = new DeactivateBranchCommand(Guid.NewGuid(), Guid.NewGuid()); - var handler = new DeactivateBranchCommandHandler(_repo.Object, _ctx.Object, _scopePolicy.Object); + var handler = new DeactivateBranchCommandHandler(_repo.Object, _userRepo.Object, _ctx.Object, _scopePolicy.Object); var result = await handler.Handle(cmd, CancellationToken.None); Assert.True(result.IsFailure); @@ -77,7 +85,7 @@ public async Task DeactivateBranch_WhenUnauthenticated_ReturnsFailure() _ctx.Setup(u => u.UserId).Returns(""); var cmd = new DeactivateBranchCommand(Guid.NewGuid(), Guid.NewGuid()); - var handler = new DeactivateBranchCommandHandler(_repo.Object, _ctx.Object, _scopePolicy.Object); + var handler = new DeactivateBranchCommandHandler(_repo.Object, _userRepo.Object, _ctx.Object, _scopePolicy.Object); var result = await handler.Handle(cmd, CancellationToken.None); Assert.True(result.IsFailure); @@ -92,7 +100,7 @@ public async Task DeactivateBranch_WhenBranchNotFound_ReturnsFailure() .ReturnsAsync(tenant); var cmd = new DeactivateBranchCommand(tenant.Props.Id.GetValue(), Guid.NewGuid()); - var handler = new DeactivateBranchCommandHandler(_repo.Object, _ctx.Object, _scopePolicy.Object); + var handler = new DeactivateBranchCommandHandler(_repo.Object, _userRepo.Object, _ctx.Object, _scopePolicy.Object); var result = await handler.Handle(cmd, CancellationToken.None); Assert.True(result.IsFailure); @@ -168,11 +176,11 @@ public async Task ReactivateBranch_WhenBranchNotFound_ReturnsFailure() #endregion // ========================================================================= - #region RemoveBranchCommandHandler + #region CloseBranchCommandHandler (ADR-0164) // ========================================================================= [Fact] - public async Task RemoveBranch_WithValidCommand_ReturnsSuccess() + public async Task CloseBranch_WithValidCommand_ReturnsSuccess() { var tenant = MakeTenant(); tenant.AddBranch(Code.Create("BR-001"), Name.Create("Branch One"), ActorId.Create("user-001"), null); @@ -182,55 +190,132 @@ public async Task RemoveBranch_WithValidCommand_ReturnsSuccess() _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(tenant); - var cmd = new RemoveBranchCommand(tenant.Props.Id.GetValue(), branch.GetId().GetValue()); - var handler = new RemoveBranchCommandHandler(_repo.Object, _ctx.Object, _scopePolicy.Object); + var cmd = new CloseBranchCommand(tenant.Props.Id.GetValue(), branch.GetId().GetValue()); + var handler = NuevoManejador(); var result = await handler.Handle(cmd, CancellationToken.None); Assert.True(result.IsSuccess, result.Error); - Assert.Empty(tenant.Branches); + // ADR-0164 §2.1: la sucursal NO desaparece de la colección. Antes esta misma prueba + // afirmaba `Assert.Empty(tenant.Branches)`. + Assert.Single(tenant.Branches); + Assert.True(tenant.Branches.First().IsClosed); _repo.Verify(r => r.UpdateAsync(tenant, It.IsAny()), Times.Once); } [Fact] - public async Task RemoveBranch_WhenTenantNotFound_ReturnsFailure() + public async Task CloseBranch_ConUsuariosActivos_Devuelve409ConElDesglose() + { + var tenant = MakeTenant(); + tenant.AddBranch(Code.Create("BR-001"), Name.Create("Branch One"), ActorId.Create("user-001"), null); + var branch = tenant.Branches.First(); + + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(tenant); + _userRepo.Setup(r => r.CountActiveByBranchAsync(It.IsAny(), It.IsAny())).ReturnsAsync(2); + _profileRepo.Setup(r => r.CountActiveByBranchAsync(It.IsAny(), It.IsAny())).ReturnsAsync(3); + + var cmd = new CloseBranchCommand(tenant.Props.Id.GetValue(), branch.GetId().GetValue()); + var result = await NuevoManejador().Handle(cmd, CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.True(BlockedOperationError.TryDecode(result.Error, out var codigo, out var deps)); + Assert.Equal(DomainErrors.Tenant.BranchHasLiveReferences, codigo); + // El desglose nombra LAS DOS clases que bloquean, no solo la primera encontrada. + Assert.Equal(2, deps.Count); + Assert.Equal(2, deps.Single(d => d.EntityType == "UserAccount").Count); + Assert.Equal(3, deps.Single(d => d.EntityType == "Profile").Count); + Assert.False(tenant.Branches.First().IsClosed); + } + + [Fact] + public async Task CloseBranch_ConPerfilesActivosPeroSinUsuarios_TambienBloquea() + { + // Sin este caso la guarda de perfiles podría quedar muerta detrás de la de usuarios. + var tenant = MakeTenant(); + tenant.AddBranch(Code.Create("BR-001"), Name.Create("Branch One"), ActorId.Create("user-001"), null); + var branch = tenant.Branches.First(); + + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(tenant); + _profileRepo.Setup(r => r.CountActiveByBranchAsync(It.IsAny(), It.IsAny())).ReturnsAsync(1); + + var cmd = new CloseBranchCommand(tenant.Props.Id.GetValue(), branch.GetId().GetValue()); + var result = await NuevoManejador().Handle(cmd, CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.True(BlockedOperationError.TryDecode(result.Error, out _, out var deps)); + Assert.Equal("Profile", Assert.Single(deps).EntityType); + } + + [Fact] + public async Task CloseBranch_WhenTenantNotFound_ReturnsFailure() { _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) .ReturnsAsync((Tenant?)null); - var cmd = new RemoveBranchCommand(Guid.NewGuid(), Guid.NewGuid()); - var handler = new RemoveBranchCommandHandler(_repo.Object, _ctx.Object, _scopePolicy.Object); - var result = await handler.Handle(cmd, CancellationToken.None); + var cmd = new CloseBranchCommand(Guid.NewGuid(), Guid.NewGuid()); + var result = await NuevoManejador().Handle(cmd, CancellationToken.None); Assert.True(result.IsFailure); Assert.Contains("Tenant was not found", result.Error); } [Fact] - public async Task RemoveBranch_WhenUnauthenticated_ReturnsFailure() + public async Task CloseBranch_WhenUnauthenticated_ReturnsFailure() { _ctx.Setup(u => u.UserId).Returns(""); - var cmd = new RemoveBranchCommand(Guid.NewGuid(), Guid.NewGuid()); - var handler = new RemoveBranchCommandHandler(_repo.Object, _ctx.Object, _scopePolicy.Object); - var result = await handler.Handle(cmd, CancellationToken.None); + var cmd = new CloseBranchCommand(Guid.NewGuid(), Guid.NewGuid()); + var result = await NuevoManejador().Handle(cmd, CancellationToken.None); Assert.True(result.IsFailure); Assert.Contains("Authenticated user is required", result.Error); } [Fact] - public async Task RemoveBranch_WhenBranchNotFound_ReturnsFailure() + public async Task CloseBranch_WhenBranchNotFound_ReturnsFailure() { var tenant = MakeTenant(); _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(tenant); - var cmd = new RemoveBranchCommand(tenant.Props.Id.GetValue(), Guid.NewGuid()); - var handler = new RemoveBranchCommandHandler(_repo.Object, _ctx.Object, _scopePolicy.Object); - var result = await handler.Handle(cmd, CancellationToken.None); + var cmd = new CloseBranchCommand(tenant.Props.Id.GetValue(), Guid.NewGuid()); + var result = await NuevoManejador().Handle(cmd, CancellationToken.None); Assert.True(result.IsFailure); } #endregion + + // ========================================================================= + #region GetBranchesByTenantIdQueryHandler (ADR-0164: listas sin las cerradas) + // ========================================================================= + + [Fact] + public async Task ListarSucursales_OcultaLasCerradasSalvoQueSePidanExpresamente() + { + var tenant = MakeTenant(); + tenant.AddBranch(Code.Create("BR-VIVA"), Name.Create("Sucursal viva"), ActorId.Create("user-001"), null); + tenant.AddBranch(Code.Create("BR-CERRADA"), Name.Create("Sucursal cerrada"), ActorId.Create("user-001"), null); + var cerrada = tenant.Branches.First(b => b.Code.GetValue() == "BR-CERRADA"); + tenant.CloseBranch(cerrada.GetId(), ActorId.Create("user-001")); + + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(tenant); + var handler = new GetBranchesByTenantIdQueryHandler(_repo.Object); + + var pordefecto = await handler.Handle(new GetBranchesByTenantIdQuery(tenant.Props.Id.GetValue()), CancellationToken.None); + Assert.True(pordefecto.IsSuccess); + Assert.Equal("BR-VIVA", Assert.Single(pordefecto.Value).Code); + + // Pedirlas explícitamente sí las trae, y marcadas: «oculto» no es «borrado». + var conCerradas = await handler.Handle( + new GetBranchesByTenantIdQuery(tenant.Props.Id.GetValue(), IncludeClosed: true), CancellationToken.None); + Assert.Equal(2, conCerradas.Value.Count); + var dtoCerrada = conCerradas.Value.Single(b => b.Code == "BR-CERRADA"); + Assert.True(dtoCerrada.IsClosed); + Assert.NotNull(dtoCerrada.ClosedAtUtc); + } + + #endregion + + private CloseBranchCommandHandler NuevoManejador() + => new(_repo.Object, _userRepo.Object, _profileRepo.Object, _ctx.Object, _scopePolicy.Object); } diff --git a/src/apps/ums.api/Ums.Application.Test/Tenants/Queries/TenantMultiTenantIsolationTests.cs b/src/apps/ums.api/Ums.Application.Test/Tenants/Queries/TenantMultiTenantIsolationTests.cs index 2d9b0e6f..bcb2bd69 100644 --- a/src/apps/ums.api/Ums.Application.Test/Tenants/Queries/TenantMultiTenantIsolationTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Tenants/Queries/TenantMultiTenantIsolationTests.cs @@ -35,7 +35,7 @@ private void SetupRepoForTenant(Guid? tenantId, IReadOnlyList result) _repo.Setup(r => r.GetPagedAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - tenantId, It.IsAny())) + tenantId, It.IsAny(), It.IsAny())) .ReturnsAsync((result, result.Count)); } @@ -60,7 +60,7 @@ public async Task RegularUser_AlwaysScopedToOwnTenant_EvenWithoutRequest() _repo.Verify(r => r.GetPagedAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - ransaId, It.IsAny()), Times.Once); + ransaId, It.IsAny(), It.IsAny()), Times.Once); } [Fact] @@ -85,12 +85,12 @@ public async Task RegularUser_CannotSeeOtherTenants_CrossTenantAttemptIsBlocked( _repo.Verify(r => r.GetPagedAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - acmeId, It.IsAny()), Times.Never); + acmeId, It.IsAny(), It.IsAny()), Times.Never); _repo.Verify(r => r.GetPagedAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - null, It.IsAny()), Times.Never); + null, It.IsAny(), It.IsAny()), Times.Never); } [Fact] @@ -98,7 +98,6 @@ public async Task RegularUser_OnlySeesOwnTenantData_NotOthers() { var ransaId = Guid.NewGuid(); var ransaTenant = MakeTenant("RANSA", "Ransa Corp"); - var acmeTenant = MakeTenant("ACME", "Acme Corp"); _scopePolicy.Setup(p => p.ResolveQueryScope()).Returns(ransaId); @@ -124,7 +123,6 @@ public async Task RegularUser_OnlySeesOwnTenantData_NotOthers() [Fact] public async Task InternalAdmin_WithNoFilter_SeesAllTenants() { - var adminTenantId = Guid.NewGuid(); var allTenants = new List { MakeTenant("RANSA", "Ransa Corp"), @@ -147,7 +145,7 @@ public async Task InternalAdmin_WithNoFilter_SeesAllTenants() _repo.Verify(r => r.GetPagedAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - null, It.IsAny()), Times.Once); + null, It.IsAny(), It.IsAny()), Times.Once); } [Fact] @@ -215,13 +213,13 @@ public async Task TwoDifferentTenantUsers_EachSeesOnlyOwnData() var repoRansa = new Mock(); repoRansa.Setup(r => r.GetPagedAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), ransaId, It.IsAny())) + It.IsAny(), It.IsAny(), ransaId, It.IsAny(), It.IsAny())) .ReturnsAsync(((IReadOnlyList)[ransaTenant], 1)); var repoAcme = new Mock(); repoAcme.Setup(r => r.GetPagedAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), acmeId, It.IsAny())) + It.IsAny(), It.IsAny(), acmeId, It.IsAny(), It.IsAny())) .ReturnsAsync(((IReadOnlyList)[acmeTenant], 1)); var query = new GetAllTenantsQuery(Page: 1, PageSize: 20); @@ -242,4 +240,58 @@ public async Task TwoDifferentTenantUsers_EachSeesOnlyOwnData() } #endregion + + // ========================================================================= + #region GetTenantById isolation (TS04/F3) + // ========================================================================= + // El agregado Tenant es su propia identidad, así que el global query filter (que aísla entidades + // con columna TenantId) NO lo cubre: sin chequeo, cualquier usuario autenticado leía la identidad + // de cualquier tenant por id (fuga cross-tenant). GetTenantByIdQueryHandler ahora exige propiedad + // vía ITenantScopePolicy.ResolveQueryScope() (null=internal-admin; si no, el propio OrganizationId) + // y devuelve "not found" (404, no filtra existencia) al pedir un tenant ajeno. + + [Fact] + public async Task GetById_RegularUser_OtherTenant_ReturnsNotFound() + { + var target = MakeTenant("AGRONORTE", "Agroexportadora del Norte"); + var otherOrgId = Guid.NewGuid(); // el scope del llamador (COMEX), distinto al tenant pedido + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(target); + _scopePolicy.Setup(p => p.ResolveQueryScope()).Returns(otherOrgId); + + var handler = new GetTenantByIdQueryHandler(_repo.Object, _scopePolicy.Object); + var result = await handler.Handle(new GetTenantByIdQuery(target.Props.Id.GetValue()), CancellationToken.None); + + Assert.True(result.IsFailure); + Assert.Contains("not found", result.Error, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task GetById_RegularUser_OwnTenant_ReturnsSuccess() + { + var own = MakeTenant("COMEX_ANDINA", "Comex Andina"); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(own); + _scopePolicy.Setup(p => p.ResolveQueryScope()).Returns(own.Props.Id.GetValue()); + + var handler = new GetTenantByIdQueryHandler(_repo.Object, _scopePolicy.Object); + var result = await handler.Handle(new GetTenantByIdQuery(own.Props.Id.GetValue()), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal("COMEX_ANDINA", result.Value.Code); + } + + [Fact] + public async Task GetById_InternalAdmin_AnyTenant_ReturnsSuccess() + { + var target = MakeTenant("AGRONORTE", "Agroexportadora del Norte"); + _repo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync(target); + _scopePolicy.Setup(p => p.ResolveQueryScope()).Returns((Guid?)null); // internal-admin cross-tenant + + var handler = new GetTenantByIdQueryHandler(_repo.Object, _scopePolicy.Object); + var result = await handler.Handle(new GetTenantByIdQuery(target.Props.Id.GetValue()), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal("AGRONORTE", result.Value.Code); + } + + #endregion } diff --git a/src/apps/ums.api/Ums.Application.Test/Tenants/Queries/TenantQueryHandlerTests.cs b/src/apps/ums.api/Ums.Application.Test/Tenants/Queries/TenantQueryHandlerTests.cs index 5e4c470c..d04300eb 100644 --- a/src/apps/ums.api/Ums.Application.Test/Tenants/Queries/TenantQueryHandlerTests.cs +++ b/src/apps/ums.api/Ums.Application.Test/Tenants/Queries/TenantQueryHandlerTests.cs @@ -36,7 +36,7 @@ public async Task GetById_WhenFound_ReturnsSuccess() .ReturnsAsync(tenant); var query = new GetTenantByIdQuery(tenant.Props.Id.GetValue()); - var handler = new GetTenantByIdQueryHandler(_repo.Object); + var handler = new GetTenantByIdQueryHandler(_repo.Object, _scopePolicy.Object); var result = await handler.Handle(query, CancellationToken.None); Assert.True(result.IsSuccess); @@ -51,7 +51,7 @@ public async Task GetById_WhenNotFound_ReturnsFailure() .ReturnsAsync((Tenant?)null); var query = new GetTenantByIdQuery(Guid.NewGuid()); - var handler = new GetTenantByIdQueryHandler(_repo.Object); + var handler = new GetTenantByIdQueryHandler(_repo.Object, _scopePolicy.Object); var result = await handler.Handle(query, CancellationToken.None); Assert.True(result.IsFailure); @@ -74,7 +74,7 @@ public async Task GetById_WithExternalTenant_ReturnsCorrectType() .ReturnsAsync(tenant); var query = new GetTenantByIdQuery(tenant.Props.Id.GetValue()); - var handler = new GetTenantByIdQueryHandler(_repo.Object); + var handler = new GetTenantByIdQueryHandler(_repo.Object, _scopePolicy.Object); var result = await handler.Handle(query, CancellationToken.None); Assert.True(result.IsSuccess); @@ -100,7 +100,7 @@ public async Task GetAll_WithoutFilters_ReturnsAll() _repo.Setup(r => r.GetPagedAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(((IReadOnlyList)tenants, tenants.Count)); var query = new GetAllTenantsQuery(Page: 1, PageSize: 10); @@ -121,7 +121,7 @@ public async Task GetAll_WithPagination_ReturnsCorrectPage() _repo.Setup(r => r.GetPagedAsync( 2, 5, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(((IReadOnlyList)tenants, 1)); var query = new GetAllTenantsQuery(Page: 2, PageSize: 5); @@ -142,7 +142,7 @@ public async Task GetAll_WithStatusFilter_PassesStatusToRepo() _repo.Setup(r => r.GetPagedAsync( It.IsAny(), It.IsAny(), It.IsAny(), "Active", It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(((IReadOnlyList)tenants, tenants.Count)); var query = new GetAllTenantsQuery(Status: "Active"); @@ -163,7 +163,7 @@ public async Task GetAll_WithSearch_PassesSearchToRepo() _repo.Setup(r => r.GetPagedAsync( It.IsAny(), It.IsAny(), "target", It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(((IReadOnlyList)tenants, tenants.Count)); var query = new GetAllTenantsQuery(Search: "target"); @@ -183,7 +183,7 @@ public async Task GetAll_WithEmptyResult_ReturnsZeroTotalPages() _repo.Setup(r => r.GetPagedAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(((IReadOnlyList)tenants, 0)); var query = new GetAllTenantsQuery(Page: 1, PageSize: 10); @@ -204,7 +204,7 @@ public async Task GetAll_WithSorting_PassesSortToRepo() _repo.Setup(r => r.GetPagedAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), "code", "desc", - It.IsAny(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(((IReadOnlyList)tenants, tenants.Count)); var query = new GetAllTenantsQuery(SortBy: "code", SortOrder: "desc"); diff --git a/src/apps/ums.api/Ums.Application/Approvals/AccessEnforcementPolicy/Commands/CreateAccessEnforcementPolicyCommand.cs b/src/apps/ums.api/Ums.Application/Approvals/AccessEnforcementPolicy/Commands/CreateAccessEnforcementPolicyCommand.cs index 374a7a57..55dbdbac 100644 --- a/src/apps/ums.api/Ums.Application/Approvals/AccessEnforcementPolicy/Commands/CreateAccessEnforcementPolicyCommand.cs +++ b/src/apps/ums.api/Ums.Application/Approvals/AccessEnforcementPolicy/Commands/CreateAccessEnforcementPolicyCommand.cs @@ -3,4 +3,6 @@ namespace Ums.Application.Approvals.AccessEnforcementPolicy.Commands; public sealed record CreateAccessEnforcementPolicyCommand( - Guid TenantId, Guid? ProfileId, Guid? RoleId, string EnforcementAction) : ICommand; + Guid TenantId, Guid? ProfileId, Guid? RoleId, string EnforcementAction, + // G-120 (FR-053): periodo de gracia en días (0 = inmediato). Opcional para compatibilidad. + int GracePeriodDays = 0) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/Approvals/AccessEnforcementPolicy/Commands/CreateAccessEnforcementPolicyCommandHandler.cs b/src/apps/ums.api/Ums.Application/Approvals/AccessEnforcementPolicy/Commands/CreateAccessEnforcementPolicyCommandHandler.cs index 9feb8c32..0fab68cd 100644 --- a/src/apps/ums.api/Ums.Application/Approvals/AccessEnforcementPolicy/Commands/CreateAccessEnforcementPolicyCommandHandler.cs +++ b/src/apps/ums.api/Ums.Application/Approvals/AccessEnforcementPolicy/Commands/CreateAccessEnforcementPolicyCommandHandler.cs @@ -32,7 +32,8 @@ public async Task> Handle(CreateAc request.ProfileId.HasValue ? ProfileId.Load(request.ProfileId.Value) : null, request.RoleId.HasValue ? RoleId.Load(request.RoleId.Value) : null, action, - ActorId.Create(_userContext.UserId)); + ActorId.Create(_userContext.UserId), + request.GracePeriodDays); if (result.IsFailure) return Result.Failure(result.Error); diff --git a/src/apps/ums.api/Ums.Application/Approvals/AccessEnforcementPolicy/Commands/CreateAccessEnforcementPolicyCommandValidator.cs b/src/apps/ums.api/Ums.Application/Approvals/AccessEnforcementPolicy/Commands/CreateAccessEnforcementPolicyCommandValidator.cs index 48116d7f..2d557c86 100644 --- a/src/apps/ums.api/Ums.Application/Approvals/AccessEnforcementPolicy/Commands/CreateAccessEnforcementPolicyCommandValidator.cs +++ b/src/apps/ums.api/Ums.Application/Approvals/AccessEnforcementPolicy/Commands/CreateAccessEnforcementPolicyCommandValidator.cs @@ -7,8 +7,16 @@ public sealed class CreateAccessEnforcementPolicyCommandValidator : AbstractVali public CreateAccessEnforcementPolicyCommandValidator() { RuleFor(c => c.TenantId).NotEmpty(); - RuleFor(c => c.EnforcementAction).NotEmpty(); + // G-045: valida el nombre del enum para evitar un NullReference → 500 cuando el + // handler parsea un valor no vacío pero inválido con `!`. + RuleFor(c => c.EnforcementAction) + .NotEmpty() + .Must(action => DomainEnumerationParser.FromName(action) is not null) + .WithMessage("Enforcement action is not supported."); RuleFor(c => c).Must(c => c.ProfileId.HasValue || c.RoleId.HasValue) .WithMessage("Either ProfileId or RoleId must be provided."); + // G-120 (FR-053): periodo de gracia no negativo (0 = enforcement inmediato). + RuleFor(c => c.GracePeriodDays).GreaterThanOrEqualTo(0) + .WithMessage("Grace period days must be zero or greater."); } } diff --git a/src/apps/ums.api/Ums.Application/Approvals/AccessEnforcementPolicy/DTOs/AccessEnforcementPolicyDto.cs b/src/apps/ums.api/Ums.Application/Approvals/AccessEnforcementPolicy/DTOs/AccessEnforcementPolicyDto.cs index 2d034158..e76c8c04 100644 --- a/src/apps/ums.api/Ums.Application/Approvals/AccessEnforcementPolicy/DTOs/AccessEnforcementPolicyDto.cs +++ b/src/apps/ums.api/Ums.Application/Approvals/AccessEnforcementPolicy/DTOs/AccessEnforcementPolicyDto.cs @@ -6,4 +6,6 @@ public sealed record AccessEnforcementPolicyDto( Guid? ProfileId, Guid? RoleId, string EnforcementAction, - bool IsActive); + bool IsActive, + // G-120 (FR-053): periodo de gracia en días antes de aplicar el enforcement (0 = inmediato). + int GracePeriodDays); diff --git a/src/apps/ums.api/Ums.Application/Approvals/AccessEnforcementPolicy/Queries/GetAccessEnforcementPolicyByIdQueryHandler.cs b/src/apps/ums.api/Ums.Application/Approvals/AccessEnforcementPolicy/Queries/GetAccessEnforcementPolicyByIdQueryHandler.cs index 2161f68d..eae8542f 100644 --- a/src/apps/ums.api/Ums.Application/Approvals/AccessEnforcementPolicy/Queries/GetAccessEnforcementPolicyByIdQueryHandler.cs +++ b/src/apps/ums.api/Ums.Application/Approvals/AccessEnforcementPolicy/Queries/GetAccessEnforcementPolicyByIdQueryHandler.cs @@ -19,6 +19,7 @@ public async Task> Handle(GetAccessEnforcemen return Result.Success(new AccessEnforcementPolicyDto( entity.Props.Id.GetValue(), entity.Props.TenantId.GetValue(), entity.Props.ProfileId?.GetValue(), - entity.Props.RoleId?.GetValue(), entity.Props.EnforcementAction.ToString(), entity.Props.IsActive)); + entity.Props.RoleId?.GetValue(), entity.Props.EnforcementAction.ToString(), entity.Props.IsActive, + entity.Props.GracePeriodDays)); } } diff --git a/src/apps/ums.api/Ums.Application/Approvals/AccessEnforcementPolicy/Queries/GetAllAccessEnforcementPoliciesQueryHandler.cs b/src/apps/ums.api/Ums.Application/Approvals/AccessEnforcementPolicy/Queries/GetAllAccessEnforcementPoliciesQueryHandler.cs index 5bc2882e..1db044a7 100644 --- a/src/apps/ums.api/Ums.Application/Approvals/AccessEnforcementPolicy/Queries/GetAllAccessEnforcementPoliciesQueryHandler.cs +++ b/src/apps/ums.api/Ums.Application/Approvals/AccessEnforcementPolicy/Queries/GetAllAccessEnforcementPoliciesQueryHandler.cs @@ -36,7 +36,8 @@ public async Task>> Handle(GetAll var query = items.Select(p => new AccessEnforcementPolicyDto( p.Props.Id.GetValue(), p.Props.TenantId.GetValue(), p.Props.ProfileId?.GetValue(), - p.Props.RoleId?.GetValue(), p.Props.EnforcementAction.ToString(), p.Props.IsActive)); + p.Props.RoleId?.GetValue(), p.Props.EnforcementAction.ToString(), p.Props.IsActive, + p.Props.GracePeriodDays)); if (!string.Equals(status, "all", StringComparison.OrdinalIgnoreCase)) { diff --git a/src/apps/ums.api/Ums.Application/Approvals/ApprovalRequest/Commands/ApproveRequestCommandHandler.cs b/src/apps/ums.api/Ums.Application/Approvals/ApprovalRequest/Commands/ApproveRequestCommandHandler.cs index 4669f61e..585898ed 100644 --- a/src/apps/ums.api/Ums.Application/Approvals/ApprovalRequest/Commands/ApproveRequestCommandHandler.cs +++ b/src/apps/ums.api/Ums.Application/Approvals/ApprovalRequest/Commands/ApproveRequestCommandHandler.cs @@ -18,6 +18,8 @@ public sealed class ApproveRequestCommandHandler : ICommandHandler Handle(ApproveRequestCommand request, CancellationToke var entity = await _repository.GetByIdAsync(request.ApprovalRequestId, cancellationToken); if (entity is null) return Result.Failure("Approval request not found."); + // G-119 (SoD): segregación de deberes en la aprobación EXPLÍCITA — quien creó la solicitud + // (Audit.CreatedBy) no puede aprobarla. Se guarda aquí (acción humana vía endpoint), no en el + // dominio, para NO bloquear el auto-approve de workflows con RequiresApproval=false (que aprueba + // con el actor creador como acción del sistema, sin intervención humana). Consistente con IGA. + if (string.Equals(_userContext.UserId, entity.Props.Audit.GetValue().CreatedBy, StringComparison.OrdinalIgnoreCase)) + return Result.Failure(DomainErrors.Approvals.SelfApprovalNotAllowed); + var targetUser = await _userAccountRepository.GetByIdAsync(entity.TargetUserId.GetValue(), cancellationToken); if (targetUser is null) return Result.Failure("Target user not found."); @@ -69,6 +82,15 @@ public async Task Handle(ApproveRequestCommand request, CancellationToke if (authorization.IsFailure && !await CanApproveAsDelegatedBranchManagerAsync(entity, targetUser.TenantId.GetValue(), cancellationToken)) return Result.Failure(authorization.Error); + // G-051 F4: exigencia cross-agregado del checklist de documentos requeridos. + // Precondicion de SOLO LECTURA y fail-closed: se cruza el checklist declarado por el + // ApprovalWorkflow contra los UserDocument del usuario objetivo ANTES de transicionar. + // No muta ApprovalWorkflow ni UserDocument, por lo que no amplia la excepcion D-016 de + // este handler (que sigue mutando solo ApprovalRequest + Profile). Ver RequiredDocumentChecklist. + var checklistResult = await EnsureRequiredDocumentsComplete(entity, targetUser, cancellationToken); + if (checklistResult.IsFailure) + return checklistResult; + var result = entity.Approve(ActorId.Create(_userContext.UserId), RoleId.Load(request.GrantedRoleId), request.DecisionReason); if (result.IsFailure) return result; @@ -89,15 +111,24 @@ public async Task Handle(ApproveRequestCommand request, CancellationToke _userContext.UserId, DateTime.UtcNow)); - await using var tx = await _unitOfWorkScope.BeginAsync(cancellationToken); - if (isNewProfile) - await _profileRepository.AddAsync(assignedProfile, cancellationToken); - else - await _profileRepository.UpdateAsync(assignedProfile, cancellationToken); - await _repository.UpdateAsync(entity, cancellationToken); - await _profileRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); - await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); - await tx.CommitAsync(cancellationToken); + // TODO(D-016): excepción PROVISIONAL a "un agregado por transacción" (ADR-0098 D2): + // muta ApprovalRequest + Profile en la misma tx. Candidata a separación por despacho + // post-commit / consistencia eventual (ADR-0098 D4). Ver DECISIONS.md D-016 (E2) y GAPS.md G-066. + // Patrón e interpretación: KB-TXN-001 (Base de Conocimiento de Arquitectura, evolith-core). + // Revisar al separar agregados en la progresión a microservicios. + // G-117: begin/commit vía ExecutionStrategy. El BeginAsync manual anterior era incompatible con + // EnableRetryOnFailure (NpgsqlRetryingExecutionStrategy) → toda aprobación fallaba con + // InvalidOperationException 'does not support user-initiated transactions' (mapeada a 400). + await _unitOfWorkScope.ExecuteInTransactionAsync(async ct => + { + if (isNewProfile) + await _profileRepository.AddAsync(assignedProfile, ct); + else + await _profileRepository.UpdateAsync(assignedProfile, ct); + await _repository.UpdateAsync(entity, ct); + await _profileRepository.UnitOfWork.SaveEntitiesAsync(ct); + await _repository.UnitOfWork.SaveEntitiesAsync(ct); + }, cancellationToken); var tenant = await _tenantRepository.GetByIdAsync(targetUser.TenantId.GetValue(), cancellationToken); await _notificationService.SendAsync( @@ -112,6 +143,24 @@ await _notificationService.SendAsync( return Result.Success(); } + // G-051 F4: valida (fail-closed) que el usuario objetivo satisfaga el checklist de documentos + // obligatorios del workflow de la solicitud antes de aprobar. Si el workflow no se puede + // resolver, se rechaza la aprobacion: no se aprueba sin poder verificar el cumplimiento. + private async Task EnsureRequiredDocumentsComplete( + ApprovalRequest entity, + UserAccount targetUser, + CancellationToken cancellationToken) + { + var workflow = await _workflowRepository.GetByIdAsync(entity.WorkflowId.GetValue(), cancellationToken); + if (workflow is null) + return Result.Failure(DomainErrors.Approvals.RequiredDocumentsIncomplete); + + var targetUserDocuments = await _userDocumentRepository.GetByUserIdAsync( + targetUser.GetId().GetValue(), cancellationToken); + + return RequiredDocumentChecklist.Evaluate(workflow, targetUserDocuments); + } + private async Task CanApproveAsDelegatedBranchManagerAsync( ApprovalRequest entity, Guid tenantId, diff --git a/src/apps/ums.api/Ums.Application/Approvals/ApprovalRequest/Queries/GetAllApprovalRequestsQueryHandler.cs b/src/apps/ums.api/Ums.Application/Approvals/ApprovalRequest/Queries/GetAllApprovalRequestsQueryHandler.cs index b1837179..168bf4be 100644 --- a/src/apps/ums.api/Ums.Application/Approvals/ApprovalRequest/Queries/GetAllApprovalRequestsQueryHandler.cs +++ b/src/apps/ums.api/Ums.Application/Approvals/ApprovalRequest/Queries/GetAllApprovalRequestsQueryHandler.cs @@ -54,7 +54,7 @@ public async Task>> Handle(GetAllApproval query = query.Where(r => r.WorkflowId.ToString().Contains(search, StringComparison.OrdinalIgnoreCase)); // G-159: el parámetro userId de la query se ignoraba (contrato expuesto, filtro no cableado). - // Se filtra por el usuario objetivo de la solicitud (TargetUserId), la semántica útil: + // Se filtra por el usuario objetivo de la solicitud (TargetUserId), que es la semántica útil: // «solicitudes de aprobación que conciernen a este usuario». if (request.UserId.HasValue) query = query.Where(r => r.TargetUserId == request.UserId.Value); diff --git a/src/apps/ums.api/Ums.Application/Approvals/ApprovalWorkflow/Commands/CreateApprovalWorkflowCommandValidator.cs b/src/apps/ums.api/Ums.Application/Approvals/ApprovalWorkflow/Commands/CreateApprovalWorkflowCommandValidator.cs index dff4b663..95f300b4 100644 --- a/src/apps/ums.api/Ums.Application/Approvals/ApprovalWorkflow/Commands/CreateApprovalWorkflowCommandValidator.cs +++ b/src/apps/ums.api/Ums.Application/Approvals/ApprovalWorkflow/Commands/CreateApprovalWorkflowCommandValidator.cs @@ -10,6 +10,11 @@ public CreateApprovalWorkflowCommandValidator() RuleFor(c => c.Code).NotEmpty().MaximumLength(50); RuleFor(c => c.Name).NotEmpty().MaximumLength(150); RuleFor(c => c.Description).NotEmpty().MaximumLength(500); - RuleFor(c => c.TargetUserCategory).NotEmpty(); + // G-045: valida el nombre del enum para evitar un NullReference → 500 cuando el + // handler parsea un valor no vacío pero inválido con `!`. + RuleFor(c => c.TargetUserCategory) + .NotEmpty() + .Must(category => DomainEnumerationParser.FromName(category) is not null) + .WithMessage("Target user category is not supported."); } } diff --git a/src/apps/ums.api/Ums.Application/Approvals/ApprovalWorkflow/DTOs/ApprovalWorkflowDto.cs b/src/apps/ums.api/Ums.Application/Approvals/ApprovalWorkflow/DTOs/ApprovalWorkflowDto.cs index 0f8c39b3..542beec7 100644 --- a/src/apps/ums.api/Ums.Application/Approvals/ApprovalWorkflow/DTOs/ApprovalWorkflowDto.cs +++ b/src/apps/ums.api/Ums.Application/Approvals/ApprovalWorkflow/DTOs/ApprovalWorkflowDto.cs @@ -8,4 +8,12 @@ public sealed record ApprovalWorkflowDto( string Name, string Description, string TargetUserCategory, - bool RequiresApproval); + bool RequiresApproval, + // WF2/WF3 (G-118): proyecta el checklist de documentos requeridos con su id, para que sea + // legible (antes write-only) y que un cliente pueda obtener el id que necesita el DELETE. + IReadOnlyList RequiredDocuments); + +public sealed record RequiredDocumentDto( + Guid RequiredDocumentId, + Guid DocumentTypeId, + bool IsMandatory); diff --git a/src/apps/ums.api/Ums.Application/Approvals/ApprovalWorkflow/Queries/GetAllApprovalWorkflowsQueryHandler.cs b/src/apps/ums.api/Ums.Application/Approvals/ApprovalWorkflow/Queries/GetAllApprovalWorkflowsQueryHandler.cs index 8ce651db..494fd789 100644 --- a/src/apps/ums.api/Ums.Application/Approvals/ApprovalWorkflow/Queries/GetAllApprovalWorkflowsQueryHandler.cs +++ b/src/apps/ums.api/Ums.Application/Approvals/ApprovalWorkflow/Queries/GetAllApprovalWorkflowsQueryHandler.cs @@ -39,7 +39,10 @@ public async Task>> Handle(GetAllApprova var query = items.Select(w => new ApprovalWorkflowDto( w.Props.Id.GetValue(), w.Props.TenantId.GetValue(), w.Props.SystemSuiteId?.GetValue(), w.Props.Code.GetValue(), w.Props.Name.GetValue(), w.Props.Description.GetValue(), - w.Props.TargetUserCategory.ToString(), w.Props.RequiresApproval)); + w.Props.TargetUserCategory.ToString(), w.Props.RequiresApproval, + // WF2/WF3 (G-118): checklist observable con id. + w.RequiredDocuments.Select(d => new RequiredDocumentDto( + d.GetId().GetValue(), d.DocumentTypeId.GetValue(), d.IsMandatory)).ToList())); if (!string.IsNullOrWhiteSpace(search)) query = query.Where(w => w.Name.Contains(search, StringComparison.OrdinalIgnoreCase)); diff --git a/src/apps/ums.api/Ums.Application/Approvals/ApprovalWorkflow/Queries/GetApprovalWorkflowByIdQueryHandler.cs b/src/apps/ums.api/Ums.Application/Approvals/ApprovalWorkflow/Queries/GetApprovalWorkflowByIdQueryHandler.cs index 182b15d1..f341ce0a 100644 --- a/src/apps/ums.api/Ums.Application/Approvals/ApprovalWorkflow/Queries/GetApprovalWorkflowByIdQueryHandler.cs +++ b/src/apps/ums.api/Ums.Application/Approvals/ApprovalWorkflow/Queries/GetApprovalWorkflowByIdQueryHandler.cs @@ -20,6 +20,9 @@ public async Task> Handle(GetApprovalWorkflowByIdQue return Result.Success(new ApprovalWorkflowDto( workflow.Props.Id.GetValue(), workflow.Props.TenantId.GetValue(), workflow.Props.SystemSuiteId?.GetValue(), workflow.Props.Code.GetValue(), workflow.Props.Name.GetValue(), workflow.Props.Description.GetValue(), - workflow.Props.TargetUserCategory.ToString(), workflow.Props.RequiresApproval)); + workflow.Props.TargetUserCategory.ToString(), workflow.Props.RequiresApproval, + // WF2/WF3 (G-118): checklist observable con id (para el DELETE por id). + workflow.RequiredDocuments.Select(d => new RequiredDocumentDto( + d.GetId().GetValue(), d.DocumentTypeId.GetValue(), d.IsMandatory)).ToList())); } } diff --git a/src/apps/ums.api/Ums.Application/Approvals/DocumentType/Commands/CreateDocumentTypeCommandValidator.cs b/src/apps/ums.api/Ums.Application/Approvals/DocumentType/Commands/CreateDocumentTypeCommandValidator.cs index 1a576301..d8eeabcc 100644 --- a/src/apps/ums.api/Ums.Application/Approvals/DocumentType/Commands/CreateDocumentTypeCommandValidator.cs +++ b/src/apps/ums.api/Ums.Application/Approvals/DocumentType/Commands/CreateDocumentTypeCommandValidator.cs @@ -10,6 +10,12 @@ public CreateDocumentTypeCommandValidator() RuleFor(c => c.Code).NotEmpty().MaximumLength(50); RuleFor(c => c.Name).NotEmpty().MaximumLength(150); RuleFor(c => c.Description).NotEmpty().MaximumLength(500); - RuleFor(c => c.Criticity).NotEmpty(); + // G-045: sin validar el nombre del enum, un valor no vacío pero inválido pasa la + // validación y el handler lo parsea con `!`, produciendo un NullReference → 500. + // Validamos aquí para devolver 400/422 con detalle en vez de un 500 inesperado. + RuleFor(c => c.Criticity) + .NotEmpty() + .Must(criticity => DomainEnumerationParser.FromName(criticity) is not null) + .WithMessage("Document criticity is not supported."); } } diff --git a/src/apps/ums.api/Ums.Application/Approvals/UserDocument/Commands/UploadUserDocumentCommandValidator.cs b/src/apps/ums.api/Ums.Application/Approvals/UserDocument/Commands/UploadUserDocumentCommandValidator.cs index baca5086..5f2c1906 100644 --- a/src/apps/ums.api/Ums.Application/Approvals/UserDocument/Commands/UploadUserDocumentCommandValidator.cs +++ b/src/apps/ums.api/Ums.Application/Approvals/UserDocument/Commands/UploadUserDocumentCommandValidator.cs @@ -10,7 +10,12 @@ public UploadUserDocumentCommandValidator() RuleFor(c => c.DocumentTypeId).NotEmpty(); RuleFor(c => c.IssueDate).NotEmpty(); RuleFor(c => c.ExpirationDate).NotEmpty(); - RuleFor(c => c.Criticity).NotEmpty(); + // G-045: valida el nombre del enum para evitar un NullReference → 500 cuando el + // handler parsea un valor no vacío pero inválido con `!`. + RuleFor(c => c.Criticity) + .NotEmpty() + .Must(criticity => DomainEnumerationParser.FromName(criticity) is not null) + .WithMessage("Document criticity is not supported."); RuleFor(c => c.FileStoragePath).NotEmpty().MaximumLength(500); RuleFor(c => c.FileChecksum).NotEmpty().MaximumLength(128); } diff --git a/src/apps/ums.api/Ums.Application/Audit/AuditRecord/Commands/RecordAuditCommandHandler.cs b/src/apps/ums.api/Ums.Application/Audit/AuditRecord/Commands/RecordAuditCommandHandler.cs index 64edf575..a0cb39fd 100644 --- a/src/apps/ums.api/Ums.Application/Audit/AuditRecord/Commands/RecordAuditCommandHandler.cs +++ b/src/apps/ums.api/Ums.Application/Audit/AuditRecord/Commands/RecordAuditCommandHandler.cs @@ -1,4 +1,5 @@ using Ums.Application.Audit.AuditRecord.DTOs; +using Ums.Application.Common.Interfaces; namespace Ums.Application.Audit.AuditRecord.Commands; @@ -8,10 +9,12 @@ namespace Ums.Application.Audit.AuditRecord.Commands; public sealed class RecordAuditCommandHandler : ICommandHandler { private readonly IAuditRecordRepository _auditRecordRepository; + private readonly IUserContext _userContext; - public RecordAuditCommandHandler(IAuditRecordRepository auditRecordRepository) + public RecordAuditCommandHandler(IAuditRecordRepository auditRecordRepository, IUserContext userContext) { _auditRecordRepository = auditRecordRepository; + _userContext = userContext; } [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] @@ -19,19 +22,40 @@ public async Task> Handle( RecordAuditCommand request, CancellationToken cancellationToken) { + // G-040 (SEGURIDAD): no repudio. El actor y el inquilino se derivan del + // contexto autenticado, NO del cuerpo de la petición (que es falsificable). + if (!_userContext.IsAuthenticated || string.IsNullOrWhiteSpace(_userContext.UserId)) + { + return Result.Failure("Authenticated user is required."); + } + + if (!Guid.TryParse(_userContext.UserId, out var actorId)) + { + return Result.Failure("Authenticated user identifier is invalid."); + } + + if (!Guid.TryParse(_userContext.TenantId, out var tenantId)) + { + return Result.Failure("Authenticated tenant is required."); + } + var subjectType = DomainEnumerationParser.FromName(request.SubjectType) ?? SubjectType.User; var auditResult = DomainEnumerationParser.FromName(request.AuditResult) ?? AuditResult.Success; + // G-040 (FR-072): desinfecta la metadata antes de persistirla — la traza es append-only e + // inmutable (G-081), así que un secreto (hash/PIN/llave/token) filtrado aquí no se puede borrar. + var sanitizedMetadata = AuditMetadataSanitizer.Sanitize(request.Metadata); + var auditRecordResult = AuditRecord.Record( - request.WhoActed, + actorId, subjectType, request.WhatChanged, request.EventType, auditResult, request.AffectedEntityId, request.AffectedEntityType, - request.RootTenantId, - request.Metadata); + tenantId, + sanitizedMetadata); if (auditRecordResult.IsFailure) { diff --git a/src/apps/ums.api/Ums.Application/Audit/AuditRecord/Commands/RecordAuditCommandValidator.cs b/src/apps/ums.api/Ums.Application/Audit/AuditRecord/Commands/RecordAuditCommandValidator.cs index e488e5c3..02899ab1 100644 --- a/src/apps/ums.api/Ums.Application/Audit/AuditRecord/Commands/RecordAuditCommandValidator.cs +++ b/src/apps/ums.api/Ums.Application/Audit/AuditRecord/Commands/RecordAuditCommandValidator.cs @@ -12,5 +12,30 @@ public RecordAuditCommandValidator() RuleFor(command => command.AffectedEntityId).NotEmpty(); RuleFor(command => command.AffectedEntityType).NotEmpty().MaximumLength(100); RuleFor(command => command.RootTenantId).NotEmpty(); + + // G-040: el Metadata de auditoría es opcional pero, si viene, debe estar acotado en tamaño y ser + // JSON válido — evita registros de auditoría con payloads gigantes o malformados/falsificables. + RuleFor(command => command.Metadata) + .MaximumLength(4000) + .Must(BeValidJsonWhenPresent) + .WithMessage("Metadata must be well-formed JSON when provided."); + } + + private static bool BeValidJsonWhenPresent(string? metadata) + { + if (string.IsNullOrWhiteSpace(metadata)) + { + return true; + } + + try + { + using var _ = System.Text.Json.JsonDocument.Parse(metadata); + return true; + } + catch (System.Text.Json.JsonException) + { + return false; + } } } diff --git a/src/apps/ums.api/Ums.Application/Audit/AuditRecord/Queries/GetAllAuditRecordsQueryHandler.cs b/src/apps/ums.api/Ums.Application/Audit/AuditRecord/Queries/GetAllAuditRecordsQueryHandler.cs index 6f57fbf7..7de02636 100644 --- a/src/apps/ums.api/Ums.Application/Audit/AuditRecord/Queries/GetAllAuditRecordsQueryHandler.cs +++ b/src/apps/ums.api/Ums.Application/Audit/AuditRecord/Queries/GetAllAuditRecordsQueryHandler.cs @@ -29,9 +29,12 @@ public async Task>> Handle( var from = request.From ?? DateTime.UtcNow.AddDays(-30); var to = request.To ?? DateTime.UtcNow; - // Tenant isolation: regular users can only query their own tenant's audit records + // Tenant isolation: regular users can only query their own tenant's audit records. + // G-113: el internal-admin puede consultar un tenant específico (request.TenantId); SIN él, + // cae a su PROPIA organización — no a Guid.Empty, que filtraba RootTenantId==empty y devolvía + // 0 pese a existir registros (la escritura persiste; el query los ocultaba por el filtro vacío). var effectiveTenantId = (_tenantContext?.IsInternalAdmin == true) - ? (request.TenantId ?? Guid.Empty) + ? (request.TenantId ?? _tenantContext?.OrganizationId ?? Guid.Empty) : (_tenantContext?.OrganizationId ?? Guid.Empty); IReadOnlyList records; diff --git a/src/apps/ums.api/Ums.Application/Audit/AuditRecord/Queries/GetAuditRecordByIdQueryHandler.cs b/src/apps/ums.api/Ums.Application/Audit/AuditRecord/Queries/GetAuditRecordByIdQueryHandler.cs index 3fa98bd7..d080784f 100644 --- a/src/apps/ums.api/Ums.Application/Audit/AuditRecord/Queries/GetAuditRecordByIdQueryHandler.cs +++ b/src/apps/ums.api/Ums.Application/Audit/AuditRecord/Queries/GetAuditRecordByIdQueryHandler.cs @@ -1,4 +1,5 @@ using Ums.Application.Audit.AuditRecord.DTOs; +using Ums.Application.Common.Interfaces; using Ums.Domain.Audit.AuditRecord; namespace Ums.Application.Audit.AuditRecord.Queries; @@ -6,10 +7,12 @@ namespace Ums.Application.Audit.AuditRecord.Queries; public sealed class GetAuditRecordByIdQueryHandler : IQueryHandler { private readonly IAuditRecordRepository _auditRecordRepository; + private readonly ITenantContext? _tenantContext; - public GetAuditRecordByIdQueryHandler(IAuditRecordRepository auditRecordRepository) + public GetAuditRecordByIdQueryHandler(IAuditRecordRepository auditRecordRepository, ITenantContext? tenantContext = null) { _auditRecordRepository = auditRecordRepository; + _tenantContext = tenantContext; } [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] @@ -25,6 +28,18 @@ public async Task> Handle( return Result.Failure("Audit record not found."); } + // G-040 (SEGURIDAD): aislamiento por inquilino. Un usuario regular solo puede + // leer registros de su propio inquilino; los admin internos ven todos. Se + // devuelve "not found" para no revelar la existencia de registros ajenos. + if (_tenantContext?.IsInternalAdmin != true) + { + var effectiveTenantId = _tenantContext?.OrganizationId; + if (effectiveTenantId is null || record.Props.RootTenantId != effectiveTenantId.Value) + { + return Result.Failure("Audit record not found."); + } + } + return Result.Success(new AuditRecordDto( record.Props.Id.GetValue(), record.Props.WhoActed, diff --git a/src/apps/ums.api/Ums.Application/Authorization/Graph/AuthorizationGraphBuilderService.cs b/src/apps/ums.api/Ums.Application/Authorization/Graph/AuthorizationGraphBuilderService.cs index 0b77f79e..60748d66 100644 --- a/src/apps/ums.api/Ums.Application/Authorization/Graph/AuthorizationGraphBuilderService.cs +++ b/src/apps/ums.api/Ums.Application/Authorization/Graph/AuthorizationGraphBuilderService.cs @@ -7,15 +7,22 @@ using Ums.Domain.Configuration.FeatureFlag; using Ums.Domain.Identity; using Ums.Domain.Identity.Auth; +using Ums.Domain.Authorization.SystemSuite.MenuNode; +using Microsoft.Extensions.Logging; +using SystemSuiteSummary = Ums.Domain.Authorization.SystemSuite.SystemSuiteSummary; using SystemSuiteAggregate = Ums.Domain.Authorization.SystemSuite.SystemSuite; using ProfileAggregate = Ums.Domain.Authorization.Profile.Profile; using RoleAggregate = Ums.Domain.Authorization.Role.Role; using BranchEntity = Ums.Domain.Identity.Tenant.Branch.Branch; +using MenuNodeEntity = Ums.Domain.Authorization.SystemSuite.MenuNode.MenuNode; using UserAccountAggregate = Ums.Domain.Identity.UserAccount.UserAccount; +using TenantAggregate = Ums.Domain.Identity.Tenant.Tenant; using FeatureFlagAggregate = Ums.Domain.Configuration.FeatureFlag.FeatureFlag; namespace Ums.Application.Authorization.Graph; +#pragma warning disable S125 + /// /// Builds the complete AuthorizationGraph for an authenticated user. /// @@ -36,56 +43,95 @@ public sealed class AuthorizationGraphBuilderService : IAuthorizationGraphBuilde private readonly IProfileRepository _profileRepo; private readonly IRoleRepository _roleRepo; private readonly ISystemSuiteRepository _suiteRepo; - private readonly IPermissionTemplateRepository _templateRepo; private readonly ITenantRepository _tenantRepo; private readonly IFeatureFlagRepository _featureFlagRepo; private readonly IFeatureFlagEvaluator _flagEvaluator; private readonly IConfigurationProvider _configProvider; + private readonly ILogger _logger; public AuthorizationGraphBuilderService( IProfileRepository profileRepo, IRoleRepository roleRepo, ISystemSuiteRepository suiteRepo, - IPermissionTemplateRepository templateRepo, ITenantRepository tenantRepo, IFeatureFlagRepository featureFlagRepo, IFeatureFlagEvaluator flagEvaluator, - IConfigurationProvider configProvider) + IConfigurationProvider configProvider, + ILogger logger) { _profileRepo = profileRepo; _roleRepo = roleRepo; _suiteRepo = suiteRepo; - _templateRepo = templateRepo; _tenantRepo = tenantRepo; _featureFlagRepo = featureFlagRepo; _flagEvaluator = flagEvaluator; _configProvider = configProvider; + _logger = logger; } public async Task> BuildAsync( UserAccountAggregate userAccount, Guid tenantId, AuthMethod authMethod, + string? systemCode = null, CancellationToken cancellationToken = default) - => await BuildInternalAsync(userAccount, tenantId, authMethod, null, cancellationToken); + => await BuildInternalAsync(userAccount, tenantId, authMethod, null, systemCode, cancellationToken); public async Task> BuildForProfileAsync( UserAccountAggregate userAccount, Guid tenantId, Guid profileId, AuthMethod authMethod, + string? systemCode = null, CancellationToken cancellationToken = default) - => await BuildInternalAsync(userAccount, tenantId, authMethod, profileId, cancellationToken); + // El perfil vigente NO lo elige el filtro —llega dado— pero el bloque `profiles` sí debe + // respetarlo: sin esto, un satélite acotado a un sistema recibiria al cambiar de perfil la + // lista de todos los sistemas en los que ese usuario trabaja (ADR-0156 §2.5). + => await BuildInternalAsync(userAccount, tenantId, authMethod, profileId, systemCode, cancellationToken); + /// + /// Mide la construcción y delega. La medida se toma aquí y no dentro del método para no + /// mezclar la instrumentación con los doce pasos de armado, y para que cubra también las + /// salidas por fallo: un grafo que tarda y falla es exactamente el que hay que ver. + /// private async Task> BuildInternalAsync( UserAccountAggregate userAccount, Guid tenantId, AuthMethod authMethod, Guid? profileIdOverride, + string? systemCode, + CancellationToken cancellationToken) + { + var cronometro = System.Diagnostics.Stopwatch.StartNew(); + var resultado = await ConstruirAsync( + userAccount, tenantId, authMethod, profileIdOverride, systemCode, cancellationToken); + cronometro.Stop(); + + // Cardinalidad acotada a propósito: sistema y rol, nunca usuario ni inquilino. + GraphMetrics.Duracion.Record( + cronometro.Elapsed.TotalMilliseconds, + new KeyValuePair("system", resultado.IsSuccess ? resultado.Value.Context.SystemSuite?.Code : "n/a"), + new KeyValuePair("role", resultado.IsSuccess ? resultado.Value.Context.Role?.Code : "n/a"), + new KeyValuePair("outcome", resultado.IsSuccess ? "ok" : "error")); + + return resultado; + } + + private async Task> ConstruirAsync( + UserAccountAggregate userAccount, + Guid tenantId, + AuthMethod authMethod, + Guid? profileIdOverride, + string? systemCode, CancellationToken cancellationToken) { var userId = userAccount.Props.Id.GetValue(); + // Normalización única del código pedido, para que el eco del contexto y la comparación del + // filtro no puedan divergir. Cadena vacía o en blanco == no se pidió sistema. + var sistemaPedido = string.IsNullOrWhiteSpace(systemCode) ? null : systemCode.Trim(); + var ecoSistema = sistemaPedido is null ? null : new GraphRequestedSystem(sistemaPedido); + // ── 1. Tenant ───────────────────────────────────────────────────────── var tenant = await _tenantRepo.GetByIdAsync(tenantId, cancellationToken); if (tenant is null) @@ -94,6 +140,50 @@ private async Task> BuildInternalAsync( // ── 2. Active profile for this user + tenant ─────────────────────────── ProfileAggregate? profile; + // Los perfiles activos del usuario en este inquilino se cargan SIEMPRE: alimentan el + // bloque `profiles` del grafo, que es lo que permite al cliente ofrecer el cambio de + // perfil sin otra llamada. La consulta ya se hacía antes; lo que no se hacía era + // aprovecharla (G-177). + var perfilesDelUsuario = await _profileRepo.GetActiveByUserAndTenantAsync( + userId, tenantId, cancellationToken); + + // Un rol pertenece a exactamente un sistema, así que los roles de esos perfiles traen + // consigo el nivel de jerarquía y el sistema. Una consulta por lote, no una por perfil. + var rolesDeLosPerfiles = (await _roleRepo.GetByIdsAsync( + perfilesDelUsuario.Select(p => p.Props.RoleId.GetValue()).Distinct().ToList(), + cancellationToken)) + .ToDictionary(r => r.GetId().GetValue()); + + // Los resúmenes de suite se resuelven AQUÍ y no dentro de `ConstruirPerfilesAsync`, que es + // donde vivían: el filtro por sistema los necesita antes de elegir perfil. No añade + // consultas — mueve una. + var suitesPorId = (await _suiteRepo.GetSummariesByIdsAsync( + rolesDeLosPerfiles.Values.Select(r => r.Props.SystemSuiteId.GetValue()).Distinct().ToList(), + cancellationToken)) + .ToDictionary(x => x.Id); + + // El código de suite de cada perfil se resuelve UNA vez: lo usan el filtro y el desempate, + // y su cálculo emite avisos cuando un perfil no resuelve. Recalcularlo dentro del + // comparador de la ordenación repetiría esos avisos tantas veces como comparaciones haga. + var suiteDelPerfil = perfilesDelUsuario.ToDictionary( + p => p.GetId().GetValue(), + p => CodigoDeSuiteDelPerfil(p, rolesDeLosPerfiles, suitesPorId)); + + // ── 2.b Filtro por sistema (ADR-0156 §4) ─────────────────────────────── + // + // El filtro se aplica SOBRE LOS PERFILES QUE EL USUARIO YA TIENE. Este camino NO consulta + // el catálogo de sistemas por código, nunca: es lo que hace que un código inexistente y un + // código existente sin perfil produzcan el mismo estado interno —lista vacía— y por tanto + // la misma respuesta, sin dos ramas que puedan divergir en un mensaje, un estado o un + // tiempo. Validar contra el catálogo y «devolver el mismo error» es la variante frágil: + // sobrevive hasta el primer refactor que añada un log distinto en cada rama. + var perfilesCandidatos = sistemaPedido is null + ? perfilesDelUsuario + : perfilesDelUsuario + .Where(p => suiteDelPerfil[p.GetId().GetValue()] is { } codigo + && string.Equals(codigo, sistemaPedido, StringComparison.OrdinalIgnoreCase)) + .ToList(); + if (profileIdOverride.HasValue) { profile = await _profileRepo.GetByIdAsync(profileIdOverride.Value, cancellationToken); @@ -114,13 +204,33 @@ private async Task> BuildInternalAsync( } else { - var profiles = await _profileRepo.GetByUserIdAsync(userId, cancellationToken); - profile = profiles.FirstOrDefault(p => - p.Props.TenantId.GetValue() == tenantId && p.IsActive); + profile = perfilesCandidatos + // Desempate explicable y estable: primero el rol de mayor jerarquía, luego el + // sistema y el rol por código. Antes se ordenaba por RoleId —un GUID—, así que + // ni el usuario ni el operador podían explicar por qué entró con ese perfil. + // + // G-211: el segundo criterio seguía ordenando por `SystemSuiteId`, otro GUID, pese + // a que el comentario decía «por código». Ahora ordena de verdad por el código de + // la suite, que el diccionario de resúmenes ya tiene resuelto: cero consultas más. + .OrderBy(p => rolesDeLosPerfiles.TryGetValue(p.Props.RoleId.GetValue(), out var r) ? r.Props.HierarchyLevel : int.MaxValue) + .ThenBy(p => suiteDelPerfil[p.GetId().GetValue()] ?? string.Empty, StringComparer.Ordinal) + .ThenBy(p => rolesDeLosPerfiles.TryGetValue(p.Props.RoleId.GetValue(), out var r) ? r.Props.Code.GetValue() : string.Empty, StringComparer.Ordinal) + .FirstOrDefault(); if (profile is null) - return Result.Failure( - "No active profile found for user in this tenant."); + { + // Dos ausencias distintas, y confundirlas le mostraría a un usuario dado de alta un + // flujo de alta (ADR-0156 §5.1): + // + // · SIN NINGÚN perfil en el inquilino → grafo LOBBY (G-043). El login no falla con + // un 401 opaco: el cliente recibe contexto real y muestra el onboarding. + // · CON perfiles pero ninguno en el sistema pedido → grafo SIN ACCESO. La cuenta + // está de alta; lo que no tiene es acceso a ESTE sistema. + return Result.Success( + perfilesDelUsuario.Count == 0 + ? BuildLobbyGraph(userAccount, tenant, authMethod, ecoSistema) + : BuildNoProfileInSystemGraph(userAccount, tenant, authMethod, ecoSistema)); + } } // ── 3. Role ─────────────────────────────────────────────────────────── @@ -133,13 +243,15 @@ private async Task> BuildInternalAsync( if (suite is null) return Result.Failure("SystemSuite not found for role."); - // ── 5. Published PermissionTemplate for this role+tenant ────────────── - var templates = await _templateRepo.GetByTenantIdAsync(tenantId, cancellationToken); - var template = templates - .Where(t => t.Props.RoleId.GetValue() == role.Props.Id.GetValue() - && t.Status == TemplateStatus.Published) - .OrderByDescending(t => t.Props.Audit.GetValue().CreatedAt) - .FirstOrDefault(); +// G-174: aquí se cargaban TODAS las plantillas del inquilino, con sus ítems, para + // quedarse con una que nunca se usaba: dos consultas y la rehidratación completa + // pagadas en cada login, cada refresco y cada preview, con un volumen que crece con + // el tamaño del inquilino y no con el del usuario. El permMap se construye desde los + // permisos del propio perfil (paso 8), no desde la plantilla. + // + // Cuando se cablee la plantilla como fuente de verdad (G-016), la consulta acotada ya + // existe: `IPermissionTemplateRepository.GetByTenantRoleSuiteAsync`. No reintroducir + // la carga por inquilino. // ── 6. Branch (if BranchScoped) ──────────────────────────────────────── BranchEntity? branch = profile.Props.BranchId is not null @@ -152,12 +264,21 @@ private async Task> BuildInternalAsync( a => a.GetId().GetValue(), a => (Code: a.Props.Code.GetValue(), Name: a.Props.Name.GetValue())); + // Segundo índice, por CÓDIGO. `BuildMenuAccess` resolvía la acción de cada opción con + // un FirstOrDefault sobre la lista dentro de un bucle de cuatro niveles: con 2.000 + // opciones y 200 acciones son cientos de miles de comparaciones de cadena por login, + // teniendo el diccionario ya construido cinco líneas más arriba (R-12). + var actionByCode = suite.Actions.ToDictionary( + a => a.Props.Code.GetValue(), + a => a.GetId().GetValue(), + StringComparer.Ordinal); + // ── 8. Resolve effective permission map ──────────────────────────────── - var permMap = BuildPermissionMap(profile, actionLookup); + var permMap = BuildPermissionMap(profile); // ── 9. Assemble graph sections ───────────────────────────────────────── var actions = BuildActions(suite); - var menuAccess = BuildMenuAccess(suite, permMap); + var menuAccess = BuildMenuAccess(suite, permMap, actionByCode); var domainPermissions = BuildDomainPermissions(suite, permMap, actionLookup); var featureFlags = await EvaluateFeatureFlagsAsync(suite, profile, role, branch, cancellationToken); var effectiveConfig = BuildEffectiveConfig(tenantId); @@ -196,7 +317,8 @@ private async Task> BuildInternalAsync( : new GraphBranch( branch.Props.Id.GetValue(), branch.Props.Code.GetValue(), - branch.Props.Name.GetValue())); + branch.Props.Name.GetValue()), + RequestedSystem: ecoSistema); // ── 11. Authentication node ──────────────────────────────────────────── GraphIdpProvider? idpProvider = authMethod.Provider is not null @@ -216,9 +338,18 @@ private async Task> BuildInternalAsync( IssuedAt: now, SessionExpiresAt: now.AddMinutes(sessionMinutes)); + // Se proyectan los CANDIDATOS, no todos los perfiles del usuario. Es una regla de + // seguridad, no de estética: con el grafo acotado a un sistema, mandar la lista completa + // le entregaría a ese satélite el inventario de los demás sistemas en los que el usuario + // trabaja — información del inquilino filtrándose a quien no la necesita (ADR-0156 §2.5). + var perfiles = ConstruirPerfiles( + perfilesCandidatos, rolesDeLosPerfiles, suitesPorId, tenant, profile.GetId().GetValue()); + var graph = AuthorizationGraph.Build( context, authentication, actions, menuAccess, - domainPermissions, featureFlags, effectiveConfig, scopes, now); + domainPermissions, featureFlags, effectiveConfig, scopes, now, + profiles: perfiles, + settings: ConstruirAjustes(suite)); return Result.Success(graph); } @@ -231,8 +362,7 @@ private async Task> BuildInternalAsync( /// private static Dictionary<(Guid TargetId, Guid ActionId), (AccessEffect Effect, PermissionSource Source)> BuildPermissionMap( - ProfileAggregate profile, - Dictionary actionLookup) + ProfileAggregate profile) { var map = new Dictionary<(Guid TargetId, Guid ActionId), (AccessEffect Effect, PermissionSource Source)>(); @@ -271,6 +401,130 @@ private static AccessEffect ResolveEffect(bool isAllowed, bool isDenied) // ── Section Builders ───────────────────────────────────────────────────── + /// + /// Código de la suite a la que pertenece el perfil, o null si su rol o su suite no + /// resuelven. El sistema NO sale del perfil —que no lo guarda— sino de su rol, que pertenece a + /// exactamente uno. + /// + private string? CodigoDeSuiteDelPerfil( + ProfileAggregate perfil, + Dictionary rolesPorId, + Dictionary suitesPorId) + { + if (!rolesPorId.TryGetValue(perfil.Props.RoleId.GetValue(), out var rol)) + { + // Con el filtro por sistema activo este descarte cambia de significado: deja de ser + // una fila que no se pinta y pasa a poder ser la diferencia entre «tienes acceso» y + // «no tienes acceso» (ADR-0156 §4.3). Callarlo dejaría al operador sin forma de + // explicar un `NoProfileInSystem` que no debería serlo. + _logger.LogWarning( + "Perfil {PerfilId} descartado del grafo: su rol {RolId} no resuelve.", + perfil.GetId().GetValue(), perfil.Props.RoleId.GetValue()); + return null; + } + + if (!suitesPorId.TryGetValue(rol.Props.SystemSuiteId.GetValue(), out var suite)) + { + _logger.LogWarning( + "Perfil {PerfilId} descartado del grafo: la suite {SuiteId} de su rol {RolId} no resuelve.", + perfil.GetId().GetValue(), rol.Props.SystemSuiteId.GetValue(), rol.GetId().GetValue()); + return null; + } + + return suite.Code; + } + + /// + /// Proyecta los perfiles candidatos del usuario para el selector del cliente. + /// + /// El sistema se resuelve por el rol —el perfil no lo guarda— desde los resúmenes de suite que + /// el llamante ya resolvió: cargar el agregado de cada suite costaría siete consultas por + /// suite para obtener dos cadenas. La sucursal sale del inquilino, que ya viene con sus + /// sucursales cargadas, así que no añade ninguna consulta. + /// + private static IReadOnlyList ConstruirPerfiles( + IReadOnlyList perfiles, + Dictionary rolesPorId, + Dictionary suitesPorId, + TenantAggregate tenant, + Guid perfilVigenteId) + { + if (perfiles.Count == 0) return []; + + var opciones = new List(perfiles.Count); + + foreach (var perfil in perfiles) + { + if (!rolesPorId.TryGetValue(perfil.Props.RoleId.GetValue(), out var rol)) continue; + if (!suitesPorId.TryGetValue(rol.Props.SystemSuiteId.GetValue(), out var suite)) continue; + + var sucursal = perfil.Props.BranchId is null + ? null + : tenant.Branches.FirstOrDefault(b => b.Props.Id.GetValue() == perfil.Props.BranchId.GetValue()); + + opciones.Add(new GraphProfileOption( + Id: perfil.GetId().GetValue(), + SystemCode: suite.Code, + SystemName: suite.Name, + RoleCode: rol.Props.Code.GetValue(), + RoleName: rol.Props.Value.GetValue(), + HierarchyLevel: rol.Props.HierarchyLevel, + BranchCode: sucursal?.Props.Code.GetValue(), + BranchName: sucursal?.Props.Name.GetValue(), + Scope: perfil.Scope.Name, + IsCurrent: perfil.GetId().GetValue() == perfilVigenteId)); + } + + // Mismo orden que el desempate del login: lo que el cliente pinta primero es lo que el + // servidor habría elegido. + return opciones + .OrderBy(o => o.HierarchyLevel) + .ThenBy(o => o.SystemCode, StringComparer.Ordinal) + .ThenBy(o => o.RoleCode, StringComparer.Ordinal) + .ToList(); + } + + /// + /// Ajustes del sistema visibles para el cliente, agrupados por espacio de nombres. + /// + /// Convención: la clave es `ESPACIO_RESTO` y se proyecta como `settings["espacio"]["resto"]`, + /// ambos en minúsculas. `BRAND_LOGO_URL` viaja como `settings.brand.logo_url`. Es una regla + /// mecánica y reversible a propósito: cualquier taxonomía cerrada en el código habría que + /// tocarla cada vez que el negocio quiera publicar algo nuevo, y el objetivo es justo lo + /// contrario — que añadir un ajuste no toque el contrato. + /// + /// Solo se proyecta lo marcado con `IsClientVisible` (G-178). Una clave sin espacio de nombres + /// —sin guion bajo— cae en `general`. + /// + private static IReadOnlyDictionary> ConstruirAjustes( + SystemSuiteAggregate suite) + { + var grupos = new Dictionary>(StringComparer.Ordinal); + + foreach (var ajuste in suite.AppSettings.Where(a => a.IsClientVisible)) + { + var clave = ajuste.Key.GetValue(); + var corte = clave.IndexOf('_'); + + var espacio = corte <= 0 ? "general" : clave[..corte].ToLowerInvariant(); + var resto = corte <= 0 ? clave.ToLowerInvariant() : clave[(corte + 1)..].ToLowerInvariant(); + + if (!grupos.TryGetValue(espacio, out var grupo)) + { + grupo = new Dictionary(StringComparer.Ordinal); + grupos[espacio] = grupo; + } + + grupo[resto] = ajuste.Value.GetValue(); + } + + return grupos.ToDictionary( + g => g.Key, + g => (IReadOnlyDictionary)g.Value, + StringComparer.Ordinal); + } + + private static IReadOnlyList BuildActions(SystemSuiteAggregate suite) => suite.Actions .Select(a => new GraphAction( @@ -280,64 +534,36 @@ private static IReadOnlyList BuildActions(SystemSuiteAggregate suit .OrderBy(a => a.Code) .ToList(); + /// + /// Proyecta la navegación ALCANZABLE de cada módulo, recorriendo el árbol de nodos a + /// cualquier profundidad (ADR-0090). + /// + /// Antes recorría exactamente tres niveles literales —Menu, luego SubMenu, luego Option— y + /// cualquier nodo fuera de ese patrón desaparecía sin dejar traza (G-171). Y emitía TODAS las + /// filas, incluidas las `NotGranted`: para un perfil restringido, más de la mitad del payload + /// era decir que no. + /// + /// Regla de poda, coherente con el fail-closed que ya regía: una hoja sin acciones concedidas + /// ni denegadas explícitamente no viaja; una rama que se queda sin hojas tampoco; un módulo + /// sin nodos, tampoco. Ausencia = no concedido. + /// private static IReadOnlyList BuildMenuAccess( SystemSuiteAggregate suite, - Dictionary<(Guid, Guid), (AccessEffect Effect, PermissionSource Source)> permMap) + Dictionary<(Guid, Guid), (AccessEffect Effect, PermissionSource Source)> permMap, + Dictionary actionByCode) { var modules = new List(); foreach (var module in suite.Modules.OrderBy(m => m.Props.SortOrder)) { - var menus = new List(); + var nodos = module.Nodes + .OrderBy(n => n.SortOrder) + .Select(n => ProyectarNodo(n, permMap, actionByCode)) + .Where(n => n is not null) + .Select(n => n!) + .ToList(); - foreach (var menu in module.Menus.OrderBy(m => m.Props.SortOrder)) - { - var subMenus = new List(); - - foreach (var sub in menu.SubMenus.OrderBy(s => s.Props.SortOrder)) - { - var options = new List(); - - foreach (var opt in sub.Options.OrderBy(o => o.Props.SortOrder)) - { - // Match action by ActionCode string (not by ActionId FK — Options reference by code) - var action = suite.Actions.FirstOrDefault(a => - a.Props.Code.GetValue() == opt.Props.ActionCode.GetValue()); - - var effect = AccessEffect.NotGranted; - var source = PermissionSource.Template; - - if (action is not null) - { - var key = (opt.Props.Id.GetValue(), action.GetId().GetValue()); - if (permMap.TryGetValue(key, out var perm)) - (effect, source) = perm; - } - - options.Add(new GraphMenuOption( - opt.Props.Id.GetValue(), - opt.Props.Code.GetValue(), - opt.Props.Label.GetValue(), - opt.Props.ActionCode.GetValue(), - effect, - source)); - } - - subMenus.Add(new GraphSubMenu( - sub.Props.Id.GetValue(), - sub.Props.Code.GetValue(), - sub.Props.Label.GetValue(), - sub.Props.SortOrder, - options)); - } - - menus.Add(new GraphMenu( - menu.Props.Id.GetValue(), - menu.Props.Code.GetValue(), - menu.Props.Label.GetValue(), - menu.Props.SortOrder, - subMenus)); - } + if (nodos.Count == 0) continue; modules.Add(new GraphMenuModule( module.Props.Id.GetValue(), @@ -345,36 +571,94 @@ private static IReadOnlyList BuildMenuAccess( module.Props.Name.GetValue(), module.Props.SortOrder, module.Props.Status?.Name ?? "Active", - menus)); + module.Props.Icon, + nodos)); } return modules; } + /// + /// Proyecta un nodo y su descendencia. Devuelve null si nada de ese subárbol es alcanzable. + /// + private static GraphNavigationNode? ProyectarNodo( + MenuNodeEntity nodo, + Dictionary<(Guid, Guid), (AccessEffect Effect, PermissionSource Source)> permMap, + Dictionary actionByCode) + { + var nodeId = nodo.GetId().GetValue(); + + // N:M (ADR-0090): una opción puede vincular varias acciones. Se conserva una entrada por + // acción con efecto explícito; las que no se conceden ni se deniegan, se omiten. + var acciones = nodo.ActionCodes + .Select(a => a.GetValue()) + .Select(actionCode => + { + if (!actionByCode.TryGetValue(actionCode, out var actionId)) return null; + if (!permMap.TryGetValue((nodeId, actionId), out var perm)) return null; + if (perm.Effect == AccessEffect.NotGranted) return null; + + return new GraphNodeAction(actionCode, perm.Effect, perm.Source); + }) + .Where(a => a is not null) + .Select(a => a!) + .ToList(); + + var hijos = nodo.Children + .OrderBy(n => n.SortOrder) + .Select(n => ProyectarNodo(n, permMap, actionByCode)) + .Where(n => n is not null) + .Select(n => n!) + .ToList(); + + // Ni acciones propias ni descendencia alcanzable: el nodo no existe para este perfil. + if (acciones.Count == 0 && hijos.Count == 0) return null; + + return new GraphNavigationNode( + nodeId, + nodo.Code.GetValue(), + nodo.Label.GetValue(), + nodo.Kind.ToString(), + nodo.SortOrder, + nodo.Props.Presentation.Icon, + nodo.Props.Presentation.Route, + acciones, + hijos); + } + + /// + /// Recursos de dominio con sus acciones RESUELTAS. + /// + /// Antes emitía el producto cartesiano completo —cada recurso por cada acción del sistema— + /// aunque la inmensa mayoría dijera `NotGranted`: sobre datos reales, 286 filas de las que un + /// perfil restringido usaba 22, y más de la mitad del payload del login. Ahora solo viajan las + /// acciones concedidas o denegadas explícitamente, y un recurso sin ninguna no viaja. La + /// ausencia sigue significando denegación (fail-closed, G-039): no se pierde información, se + /// deja de repetir en cada login lo que el contrato dice una vez. + /// private static IReadOnlyList BuildDomainPermissions( SystemSuiteAggregate suite, Dictionary<(Guid, Guid), (AccessEffect Effect, PermissionSource Source)> permMap, Dictionary actionLookup) { var result = new List(); + var accionesOrdenadas = actionLookup.OrderBy(kv => kv.Value.Code).ToList(); foreach (var resource in suite.DomainResources.OrderBy(r => r.Props.Code.GetValue())) { var domainActions = new List(); - foreach (var (actionId, (actionCode, actionName)) in actionLookup.OrderBy(kv => kv.Value.Code)) + foreach (var (actionId, (actionCode, actionName)) in accionesOrdenadas) { - var key = (resource.Props.Id.GetValue(), actionId); - permMap.TryGetValue(key, out var perm); + if (!permMap.TryGetValue((resource.Props.Id.GetValue(), actionId), out var perm)) continue; + if (perm.Effect == AccessEffect.NotGranted) continue; domainActions.Add(new GraphDomainAction( - actionId, - actionCode, - actionName, - perm.Effect, - perm.Source)); + actionId, actionCode, actionName, perm.Effect, perm.Source)); } + if (domainActions.Count == 0) continue; + result.Add(new GraphDomainPermission( resource.Props.Id.GetValue(), resource.Props.Type.Name, @@ -395,7 +679,7 @@ private async Task> EvaluateFeatureFlagsAsync( BranchEntity? branch, CancellationToken cancellationToken) { - var flags = await _featureFlagRepo.GetBySystemSuiteIdAsync( + var flags = await _featureFlagRepo.GetBySystemSuiteIdForEvaluationAsync( suite.Props.Id.GetValue(), cancellationToken); var ctx = new EvaluationContext( @@ -419,6 +703,131 @@ private async Task> EvaluateFeatureFlagsAsync( .ToList(); } + /// + /// G-043 — construye el GRAFO LOBBY para un usuario autenticado y aprobado que aún no tiene perfil + /// activo (onboarding pendiente). Trae Context de usuario+inquilino reales pero sin SystemSuite/Role/ + /// Profile, con Actions/MenuAccess/DomainPermissions/FeatureFlags/Scopes vacíos y OnboardingPending=true. + /// Así el login NO falla (evita el 401 AUTH_000 opaco) y el cliente puede mostrar el onboarding. + /// + private AuthorizationGraph BuildLobbyGraph( + UserAccountAggregate userAccount, TenantAggregate tenant, AuthMethod authMethod, + GraphRequestedSystem? ecoSistema = null) + { + var userId = userAccount.Props.Id.GetValue(); + var email = userAccount.Props.Email.GetValue(); + var effectiveConfig = BuildEffectiveConfig(tenant.Props.Id.GetValue()); + var now = DateTime.UtcNow; + + GraphIdpProvider? idpProvider = authMethod.Provider is not null + ? new GraphIdpProvider( + authMethod.Provider.GetId().GetValue(), + authMethod.Provider.Props.Name.GetValue(), + authMethod.Provider.Props.Code.GetValue(), + authMethod.Provider.Props.Strategy.Name) + : null; + + var context = new GraphContext( + User: new GraphUser( + userId, email, + userAccount.Props.IdentityReference?.GetValue() ?? email, + email, + userAccount.Props.Status.ToString()), + Tenant: new GraphTenant( + tenant.Props.Id.GetValue(), + tenant.Props.Code.GetValue(), + tenant.Props.Name.GetValue(), + tenant.Props.Status.ToString(), + tenant.IsManagementOwner), + SystemSuite: null, Role: null, Profile: null, Branch: null, + RequestedSystem: ecoSistema); + + var authentication = new GraphAuthentication( + Method: authMethod.Type.ToString(), + Provider: idpProvider, + MfaRequired: effectiveConfig.MfaRequiredForAdmin, + IssuedAt: now, + SessionExpiresAt: now.AddMinutes(effectiveConfig.SessionTimeoutMinutes)); + + return AuthorizationGraph.Build( + context, authentication, + Array.Empty(), + Array.Empty(), + Array.Empty(), + Array.Empty(), + effectiveConfig, + Array.Empty(), + now, + onboardingPending: true); + } + + /// + /// ADR-0156 §5.1 — grafo SIN ACCESO: el usuario tiene perfiles activos en el inquilino, pero + /// ninguno en el sistema que se pidió. O el sistema pedido no existe, que es el mismo estado + /// interno y por tanto la misma respuesta (§4.1). + /// + /// Se parece al lobby en la forma —contexto real, secciones vacías— y se diferencia en lo + /// único que importa: OnboardingPending = false. La cuenta está dada de alta; lo que no + /// tiene es acceso a ESTE sistema. Enseñarle un flujo de alta sería mentirle. + /// + /// El token se emite igualmente (§5.4): la identidad quedó probada y el satélite necesita un + /// portador para volver a consultar GET /client/graph más tarde — un administrador + /// puede asignarle el perfil sin que el usuario vuelva a teclear su contraseña. + /// + private AuthorizationGraph BuildNoProfileInSystemGraph( + UserAccountAggregate userAccount, TenantAggregate tenant, AuthMethod authMethod, + GraphRequestedSystem? ecoSistema) + { + var effectiveConfig = BuildEffectiveConfig(tenant.Props.Id.GetValue()); + var now = DateTime.UtcNow; + var email = userAccount.Props.Email.GetValue(); + + GraphIdpProvider? idpProvider = authMethod.Provider is not null + ? new GraphIdpProvider( + authMethod.Provider.GetId().GetValue(), + authMethod.Provider.Props.Name.GetValue(), + authMethod.Provider.Props.Code.GetValue(), + authMethod.Provider.Props.Strategy.Name) + : null; + + var context = new GraphContext( + User: new GraphUser( + userAccount.Props.Id.GetValue(), email, + userAccount.Props.IdentityReference?.GetValue() ?? email, + email, + userAccount.Props.Status.ToString()), + Tenant: new GraphTenant( + tenant.Props.Id.GetValue(), + tenant.Props.Code.GetValue(), + tenant.Props.Name.GetValue(), + tenant.Props.Status.ToString(), + tenant.IsManagementOwner), + SystemSuite: null, Role: null, Profile: null, Branch: null, + RequestedSystem: ecoSistema); + + var authentication = new GraphAuthentication( + Method: authMethod.Type.ToString(), + Provider: idpProvider, + MfaRequired: effectiveConfig.MfaRequiredForAdmin, + IssuedAt: now, + SessionExpiresAt: now.AddMinutes(effectiveConfig.SessionTimeoutMinutes)); + + return AuthorizationGraph.Build( + context, authentication, + Array.Empty(), + Array.Empty(), + Array.Empty(), + Array.Empty(), + effectiveConfig, + Array.Empty(), + now, + onboardingPending: false, + // `profiles` vacío a propósito: el usuario TIENE perfiles, pero ninguno de este + // sistema, y enumerárselos aquí sería entregarle a un satélite el inventario de los + // demás sistemas del inquilino — exactamente lo que el filtro evita (§2.5). + profiles: [], + accessState: GraphAccessState.NoProfileInSystem); + } + private GraphEffectiveConfig BuildEffectiveConfig(Guid tenantId) => new( SessionTimeoutMinutes: _configProvider.GetValueAs(AppConfigurationCodes.SessionTimeoutMinutes, tenantId, AppConfigurationDefaults.SessionTimeoutMinutes), @@ -435,16 +844,26 @@ private static IReadOnlyList DeriveScopes( { var scopes = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var module in menuAccess) - foreach (var menu in module.Menus) - foreach (var sub in menu.SubMenus) - foreach (var opt in sub.Options.Where(o => o.Effect == AccessEffect.Allow)) - scopes.Add($"{opt.Code.ToLowerInvariant()}.{opt.ActionCode.ToLowerInvariant()}"); + foreach (var (nodo, accion) in menuAccess + .SelectMany(module => RecorrerNodos(module.Nodes)) + .SelectMany(n => n.Actions.Where(a => a.Effect == AccessEffect.Allow), (n, a) => (n, a))) + scopes.Add($"{nodo.Code.ToLowerInvariant()}.{accion.ActionCode.ToLowerInvariant()}"); - foreach (var res in domainPerms) - foreach (var act in res.Actions.Where(a => a.Effect == AccessEffect.Allow)) + foreach (var (res, act) in domainPerms + .SelectMany(res => res.Actions.Where(a => a.Effect == AccessEffect.Allow), + (res, act) => (res, act))) scopes.Add($"{res.ResourceCode.ToLowerInvariant()}.{act.ActionCode.ToLowerInvariant()}"); return scopes.OrderBy(s => s).ToList(); } + + /// Aplana el árbol de navegación en profundidad, sin presuponer niveles. + private static IEnumerable RecorrerNodos(IEnumerable nodos) + { + foreach (var nodo in nodos) + { + yield return nodo; + foreach (var hijo in RecorrerNodos(nodo.Children)) yield return hijo; + } + } } diff --git a/src/apps/ums.api/Ums.Application/Authorization/Graph/GraphMetrics.cs b/src/apps/ums.api/Ums.Application/Authorization/Graph/GraphMetrics.cs new file mode 100644 index 00000000..e42947ba --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Authorization/Graph/GraphMetrics.cs @@ -0,0 +1,40 @@ +namespace Ums.Application.Authorization.Graph; + +using System.Diagnostics.Metrics; + +/// +/// Instrumentos del camino de construcción del grafo de autorización. +/// +/// El meter `UMS.Application` ya estaba registrado en el exportador OTel, pero sin un solo +/// instrumento: el tablero sabía cuánto tarda `POST /auth/login` y nada más. Sin desglose, decidir +/// dónde optimizar es adivinar — y la recomendación más cara del informe de arquitectura (cachear +/// el catálogo de la suite) depende justamente de saber si la suite domina el coste. +/// +/// Dos instrumentos, no más: +/// · — cuánto tarda construir el grafo, aislado del hash de la contraseña +/// y de la serialización. Es lo que responde «¿el desvío sobre el SLO es BCrypt o somos +/// nosotros?». +/// · — cuántos bytes se emiten. Es la métrica que hace visible el +/// crecimiento del catálogo, que es lo que realmente dimensiona el payload: un perfil sin +/// permisos recibe casi lo mismo que un administrador. +/// +/// El etiquetado es deliberadamente pobre en cardinalidad: código de sistema y de rol, nunca +/// identificadores de usuario o de inquilino. Una etiqueta por usuario multiplica las series +/// temporales por el número de usuarios y tumba al recolector de métricas. +/// +public static class GraphMetrics +{ + public const string MeterName = "UMS.Application"; + + private static readonly Meter Meter = new(MeterName); + + public static readonly Histogram Duracion = Meter.CreateHistogram( + "ums.auth_graph.build.duration", + unit: "ms", + description: "Tiempo de construcción del grafo de autorización, sin autenticación ni serialización."); + + public static readonly Histogram TamanoPayload = Meter.CreateHistogram( + "ums.auth_graph.payload.size", + unit: "By", + description: "Tamaño del grafo serializado que se entrega al cliente, antes de comprimir."); +} diff --git a/src/apps/ums.api/Ums.Application/Authorization/Graph/PreviewProfileAuthGraphCommandHandler.cs b/src/apps/ums.api/Ums.Application/Authorization/Graph/PreviewProfileAuthGraphCommandHandler.cs index dd8fab86..c2c314ed 100644 --- a/src/apps/ums.api/Ums.Application/Authorization/Graph/PreviewProfileAuthGraphCommandHandler.cs +++ b/src/apps/ums.api/Ums.Application/Authorization/Graph/PreviewProfileAuthGraphCommandHandler.cs @@ -66,7 +66,7 @@ public async Task> Handle( var methodResult = await methodResolver.ResolveAsync( tenantId, AuthAccessScope.InternalPreview, - cancellationToken); + cancellationToken: cancellationToken); if (methodResult.IsFailure) { @@ -76,8 +76,11 @@ public async Task> Handle( var authMethod = methodResult.Value; // ── 5. Build graph for the exact requested profile ──────────────────── + // Sin acotar por sistema: la vista previa es una herramienta de administración y su + // usuario quiere ver los perfiles del sujeto tal como son, no filtrados por el sistema + // desde el que mira. var graphResult = await graphBuilder.BuildForProfileAsync( - user, tenantId, command.ProfileId, authMethod, cancellationToken); + user, tenantId, command.ProfileId, authMethod, systemCode: null, cancellationToken); if (graphResult.IsFailure) { @@ -113,6 +116,10 @@ await RecordAuditAsync( AuthMethodUsed: authMethod.Type.ToString())); } + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Major Code Smell", "S1172:Unused method parameters should be removed", + Justification = "'profileUserId' y 'requestId' se conservan como contexto de auditoría reservado; " + + "aún no se proyectan en AuthAuditEvent (pendiente de enriquecer el evento, ver G-016).")] private Task RecordAuditAsync( PreviewProfileAuthGraphCommand command, Guid tenantId, diff --git a/src/apps/ums.api/Ums.Application/Authorization/Graph/Serializers/AuthGraphPayload.cs b/src/apps/ums.api/Ums.Application/Authorization/Graph/Serializers/AuthGraphPayload.cs new file mode 100644 index 00000000..9968ed4b --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Authorization/Graph/Serializers/AuthGraphPayload.cs @@ -0,0 +1,241 @@ +namespace Ums.Application.Authorization.Graph.Serializers; + +using Ums.Domain.Authorization.Graph; + +/// +/// Forma canónica del grafo de autorización tal como viaja al cliente, en un +/// único lugar. La consumen los dos emisores del grafo: el login web +/// (POST /api/v1/auth/login) y la autenticación de sistemas satélite +/// (POST /api/v1/client/authenticate). +/// +/// Existe porque hasta 2026-08-01 cada emisor tenía su propia forma y el +/// contrato publicado en src/libs/sdk/contracts/auth-graph.schema.json +/// no describía ninguna de las dos (G-167): el login serializaba el record de +/// dominio (con sortOrder, sin ids, resourceCode), el endpoint de +/// cliente proyectaba otro modelo (sin sortOrder, con ids opcionales, +/// code), y el SDK declaraba una tercera (ids obligatorios, +/// name/label, envoltorios module/resource). Un +/// satélite que integrara leyendo el contrato obtenía undefined en cada +/// nombre. La única forma de que esto no vuelva a divergir es que exista un +/// solo mapeador y que ambos endpoints pasen por él. +/// +/// Convenciones de la forma canónica: +/// +/// Todo nombre o etiqueta se expone como value. +/// Las jerarquías son listas planas: menuAccess es una lista de +/// módulos (sin envoltorio module) y domainPermissions una de +/// recursos (sin envoltorio resource). +/// Los identificadores técnicos (id, moduleId, +/// systemSuiteId…) son OPCIONALES: solo aparecen con +/// , que el +/// parámetro de inquilino AUTH_GRAPH_INCLUDE_TECHNICAL_METADATA deja +/// en false por defecto. Se OMITEN, no se emiten como null: +/// un cliente distingue «no viene» de «viene vacío». +/// +/// +/// Se construye sobre y no sobre tipos +/// anónimos precisamente por la última regla: un tipo anónimo no puede omitir +/// una propiedad según una condición. +/// +public static class AuthGraphPayload +{ + /// Proyecta el grafo a su forma canónica de transporte. + public static IReadOnlyDictionary Build( + AuthorizationGraph graph, + GraphSerializationOptions? options = null) + { + var opts = options ?? GraphSerializationOptions.Default; + var meta = opts.IncludeTechnicalMetadata; + + return new Dictionary + { + ["schemaVersion"] = graph.SchemaVersion, + ["onboardingPending"] = graph.OnboardingPending, + // Enumeración cerrada (ADR-0156 §5.1). Convive con `onboardingPending`, que se + // conserva para los consumidores de 2.0–2.3; el dominio garantiza que no divergen. + ["accessState"] = graph.AccessState.ToString(), + ["context"] = Context(graph.Context, meta), + ["authentication"] = Authentication(graph.Authentication, meta), + ["actions"] = graph.Actions + .Select(a => Map(("code", (object?)a.Code), ("value", a.Name))) + .ToList(), + ["profiles"] = graph.Profiles.Select(Profile).ToList(), + ["menuAccess"] = graph.MenuAccess.Select(m => Module(m, meta)).ToList(), + ["domainPermissions"] = graph.DomainPermissions.Select(r => Resource(r, meta)).ToList(), + ["featureFlags"] = graph.FeatureFlags.Select(f => FeatureFlag(f, meta)).ToList(), + ["effectiveConfig"] = EffectiveConfig(graph.EffectiveConfig), + // Diccionario abierto por diseño: es el único punto del contrato donde añadir una + // clave nueva NO es un cambio de contrato (G-178). + ["settings"] = graph.Settings, + ["scopes"] = graph.Scopes, + ["generatedAt"] = graph.GeneratedAt.ToString("O"), + ["validUntil"] = graph.ValidUntil.ToString("O"), + }; + } + + private static Dictionary Map(params (string Key, object? Value)[] pairs) + { + var d = new Dictionary(pairs.Length); + foreach (var (key, value) in pairs) d[key] = value; + return d; + } + + /// Antepone el id técnico solo cuando el inquilino lo habilitó. + private static Dictionary WithId(bool meta, string name, Guid? id, Dictionary rest) + { + if (!meta || id is null) return rest; + + var d = new Dictionary { [name] = id.Value.ToString() }; + foreach (var (k, v) in rest) d[k] = v; + return d; + } + + private static object Context(GraphContext ctx, bool meta) => Map( + ("user", WithId(meta, "id", ctx.User.Id, Map( + ("email", (object?)ctx.User.Email), + ("username", ctx.User.Username), + ("value", ctx.User.DisplayName), + ("status", ctx.User.Status)))), + ("tenant", WithId(meta, "id", ctx.Tenant.Id, Map( + ("code", (object?)ctx.Tenant.Code), + ("value", ctx.Tenant.Name), + ("status", ctx.Tenant.Status), + ("isManagementOwner", ctx.Tenant.IsManagementOwner)))), + // G-043: en el grafo lobby (onboarding pendiente) suite, rol y perfil son + // null y viajan como null — el cliente reacciona a `onboardingPending`. + ("systemSuite", ctx.SystemSuite is null ? null : WithId(meta, "id", ctx.SystemSuite.Id, Map( + ("code", (object?)ctx.SystemSuite.Code), + ("value", ctx.SystemSuite.Name), + ("status", ctx.SystemSuite.Status)))), + ("role", ctx.Role is null ? null : WithId(meta, "id", ctx.Role.Id, Map( + ("code", (object?)ctx.Role.Code), + ("value", ctx.Role.Name), + ("hierarchyLevel", ctx.Role.HierarchyLevel)))), + ("profile", ctx.Profile is null ? null : WithId(meta, "id", ctx.Profile.Id, Map( + ("scope", (object?)ctx.Profile.Scope), + ("isActive", ctx.Profile.IsActive)))), + ("branch", ctx.Branch is null ? null : WithId(meta, "id", ctx.Branch.Id, Map( + ("code", (object?)ctx.Branch.Code), + ("value", ctx.Branch.Name)))), + // Eco literal del sistema pedido, ya normalizado, o null si no se pidió ninguno. Se emite + // SIEMPRE la clave: un cliente no debería tener que distinguir «no se pidió» de «la clave + // no existe en esta versión». + ("requestedSystem", ctx.RequestedSystem is null ? null : Map( + ("code", (object?)ctx.RequestedSystem.Code)))); + + private static object Authentication(GraphAuthentication auth, bool meta) => Map( + ("method", (object?)auth.Method), + ("provider", auth.Provider is null ? null : WithId(meta, "id", auth.Provider.Id, Map( + ("code", (object?)auth.Provider.Code), + ("name", auth.Provider.Name), + ("value", auth.Provider.Strategy)))), + ("mfaRequired", auth.MfaRequired), + ("issuedAt", auth.IssuedAt.ToString("O")), + ("sessionExpiresAt", auth.SessionExpiresAt.ToString("O"))); + + /// + /// Perfil disponible para el selector del cliente. `system` y `role` se anidan aunque en el + /// dominio viajen planos: es la forma en que el cliente los consume, y aplanarlos aquí + /// obligaría a cada consumidor a recomponerlos. + /// + /// El `id` se emite SIEMPRE, fuera de (ADR-0156 §5.3). Aquí no es un + /// metadato técnico decorativo como en módulos, nodos, recursos y banderas —donde el `code` es + /// la clave de negocio y la regla se mantiene intacta—: es la clave de una operación que el + /// propio grafo invita a ejecutar, `POST /client/switch-profile`. Un contrato que ofrece una + /// operación y retiene su clave no es un contrato. + /// + /// No se usa un selector semántico `{ systemCode, roleCode, branchCode }`: el índice + /// (TenantId, UserId, RoleId, BranchId) no es único, así que dos perfiles pueden coincidir en + /// los tres campos y el selector obligaría a un conflicto que el cliente no puede resolver. + /// + private static object Profile(GraphProfileOption p) => Map( + ("id", (object?)p.Id.ToString()), + ("system", Map(("code", (object?)p.SystemCode), ("value", p.SystemName))), + ("role", Map( + ("code", (object?)p.RoleCode), + ("value", p.RoleName), + ("hierarchyLevel", p.HierarchyLevel))), + ("branch", p.BranchCode is null ? null : Map( + ("code", (object?)p.BranchCode), + ("value", p.BranchName))), + ("scope", p.Scope), + ("isCurrent", p.IsCurrent)); + + private static object Module(GraphMenuModule m, bool meta) => WithId(meta, "id", m.Id, Map( + ("code", (object?)m.Code), + ("value", m.Name), + // `sortOrder` es dato de presentación, no metadato técnico: sin él un satélite no puede + // pintar el menú en el orden que el tenant configuró. + ("sortOrder", m.SortOrder), + ("status", m.Status), + // Mismo criterio que en el nodo: se emite siempre, también como null. Sin él el cliente + // sabe cómo se llama el módulo pero no con qué pintarlo, y acaba resolviendo el icono por + // código —que es exactamente la tabla estática que el grafo vino a eliminar (G-181). + ("icon", m.Icon), + ("nodes", m.Nodes.Select(n => Node(n, meta)).ToList()))); + + /// + /// Nodo de navegación, recursivo. `children` se emite siempre, aunque venga vacío: un cliente + /// que recorra el árbol no debería tener que distinguir entre «sin hijos» y «clave ausente». + /// + private static object Node(GraphNavigationNode n, bool meta) => WithId(meta, "id", n.Id, Map( + ("code", (object?)n.Code), + ("value", n.Name), + ("kind", n.Kind), + ("sortOrder", n.SortOrder), + // Se emiten siempre, también como null: un cliente que recorra el árbol no debería tener + // que distinguir «sin icono» de «clave ausente». + ("icon", n.Icon), + ("route", n.Route), + ("actions", n.Actions.Select(a => Map( + ("actionCode", (object?)a.ActionCode), + ("effect", a.Effect.ToString()), + ("source", a.Source.ToString()))).ToList()), + ("children", n.Children.Select(h => Node(h, meta)).ToList()))); + + private static object Resource(GraphDomainPermission res, bool meta) + { + var body = Map( + ("resourceType", (object?)res.ResourceType), + // `resourceCode` y no `code`: el mismo dato se llamaba de dos formas + // según el endpoint que lo emitiera (G-167). + ("resourceCode", res.ResourceCode), + ("value", res.ResourceName), + ("actions", res.Actions.Select(a => Map( + ("actionCode", (object?)a.ActionCode), + ("value", a.ActionName), + ("effect", a.Effect.ToString()), + ("source", a.Source.ToString()))).ToList())); + + if (!meta) return body; + + var d = new Dictionary { ["resourceId"] = res.ResourceId.ToString() }; + foreach (var (k, v) in body) d[k] = v; + d["moduleId"] = res.ModuleId?.ToString(); + d["parentResourceId"] = res.ParentResourceId?.ToString(); + return d; + } + + private static object FeatureFlag(GraphFeatureFlag flag, bool meta) + { + var body = Map( + ("flagCode", (object?)flag.FlagCode), + ("isEnabled", flag.IsEnabled), + ("matchedCriteriaType", flag.MatchedCriteriaType)); + + return meta + ? WithId(true, "systemSuiteId", flag.SystemSuiteId, body) + : body; + } + + private static object EffectiveConfig(GraphEffectiveConfig cfg) => Map( + ("sessionTimeoutMinutes", (object?)cfg.SessionTimeoutMinutes), + ("maxLoginAttempts", cfg.MaxLoginAttempts), + ("minPasswordLength", cfg.MinPasswordLength), + ("mfaRequiredForAdmin", cfg.MfaRequiredForAdmin), + // Faltaba en la proyección del endpoint de cliente: el satélite no podía + // saber qué métodos MFA acepta el inquilino. + ("mfaAllowedMethods", cfg.MfaAllowedMethods), + ("accessTokenDurationMs", cfg.AccessTokenDurationMs), + ("authUseExternalIdp", cfg.AuthUseExternalIdp)); +} diff --git a/src/apps/ums.api/Ums.Application/Authorization/Profile/Commands/CreateProfileCommandHandler.cs b/src/apps/ums.api/Ums.Application/Authorization/Profile/Commands/CreateProfileCommandHandler.cs index 4e6a0816..8beddfc1 100644 --- a/src/apps/ums.api/Ums.Application/Authorization/Profile/Commands/CreateProfileCommandHandler.cs +++ b/src/apps/ums.api/Ums.Application/Authorization/Profile/Commands/CreateProfileCommandHandler.cs @@ -75,6 +75,27 @@ public async Task> Handle( "Role does not belong to the requested tenant."); } + // Un perfil por (usuario, rol, sucursal) activo. Sin esta guarda, reejecutar un + // aprovisionamiento —o pulsar dos veces— dejaba al usuario con perfiles duplicados: la + // plantilla se asigna a UNO, así que el otro queda con cero concesiones y el selector los + // ofrece indistinguibles. Entrar por el vacío es entrar sin permisos, y todo respondió 201. + // + // Se comprueba sobre los perfiles ACTIVOS: uno desactivado es historia, y volver a dar de + // alta el mismo rol tras retirarlo es legítimo. + var perfilesDelUsuario = await _profileRepository.GetActiveByUserAndTenantAsync( + request.UserId, request.TenantId, cancellationToken); + + // `is { Count: > 0 }` y no `.Any()` a secas: el contrato declara la lista no nula, pero un + // doble de prueba que no configure esta consulta devuelve null, y una guarda que revienta + // cuando su colaborador calla es peor que la ausencia de guarda. + if (perfilesDelUsuario is { Count: > 0 } && perfilesDelUsuario.Any(p => + p.RoleId.GetValue() == request.RoleId && + p.BranchId?.GetValue() == request.BranchId)) + { + return Result.Failure( + DomainErrors.Authorization.ProfileAlreadyExistsForRole); + } + var profileResult = Profile.Create( TenantId.Load(request.TenantId), UserId.Load(request.UserId), @@ -89,16 +110,27 @@ public async Task> Handle( var profile = profileResult.Value; + // G-043 (materialización de permisos): la plantilla auto-asignada por regla se materializa + // ANTES de persistir, en la MISMA unidad de trabajo del agregado Profile (un agregado por tx). + // Antes el perfil se guardaba primero y la asignación se intentaba en un segundo Save cuyo + // fallo se tragaba (permissionCount=0 sin señal). Ahora los permisos se incorporan al agregado + // y se persisten en un único Save; si la materialización falla, se aborta y se desenmascara el + // error (Result.Failure con código) en vez de crear un perfil sin permisos silenciosamente. + var materializeResult = await MaterializeAutoAssignedTemplateAsync( + profile, request.TenantId, request.RoleId, cancellationToken); + if (materializeResult.IsFailure) + { + return Result.Failure(materializeResult.Error); + } + await _profileRepository.AddAsync(profile, cancellationToken); await _profileRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); - await TryAutoAssignTemplateAsync(profile, request.TenantId, request.RoleId, cancellationToken); - return Result.Success( new CreateProfileResponse(profile.Props.Id.GetValue())); } - private async Task TryAutoAssignTemplateAsync( + private async Task MaterializeAutoAssignedTemplateAsync( Profile profile, Guid tenantId, Guid roleId, @@ -110,19 +142,26 @@ private async Task TryAutoAssignTemplateAsync( var topRule = matchingRules?.FirstOrDefault(); if (topRule is null) { - return; + // No hay regla de auto-asignación activa para (tenant, role): el perfil se crea sin + // plantilla. Es un resultado LEGÍTIMO —no todo rol tiene regla— y no debe fallar. + return Result.Success(); } var template = await _templateRepository.GetByIdAsync(topRule.TemplateId.GetValue(), cancellationToken); if (template is null) { - return; + // La regla referencia una plantilla inexistente: es una inconsistencia de configuración + // que DEBE aflorar (antes: return silencioso → permissionCount=0 sin diagnóstico). + return Result.Failure( + $"PROFILE_TEMPLATE_MISSING: la regla de asignación {topRule.Props.Id.GetValue()} referencia una plantilla inexistente ({topRule.TemplateId.GetValue()})."); } var assignResult = profile.AssignTemplate(template, ActorId.Create(_userContext.UserId)); if (assignResult.IsFailure) { - return; + // Desenmascarar: antes el fallo de AssignTemplate se tragaba y el perfil quedaba sin + // permisos. Ahora se propaga el error de dominio con un código estable. + return Result.Failure($"PROFILE_TEMPLATE_ASSIGN_FAILED: {assignResult.Error}"); } profile.DomainEvents.RaiseEvent(new TemplateAutoAssignedEvent( @@ -130,7 +169,6 @@ private async Task TryAutoAssignTemplateAsync( template.Props.Id.GetValue(), topRule.Props.Id.GetValue())); - await _profileRepository.UpdateAsync(profile, cancellationToken); - await _profileRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + return Result.Success(); } } diff --git a/src/apps/ums.api/Ums.Application/Authorization/Profile/Queries/GetAllProfilesQueryHandler.cs b/src/apps/ums.api/Ums.Application/Authorization/Profile/Queries/GetAllProfilesQueryHandler.cs index ad9f83ca..d8704197 100644 --- a/src/apps/ums.api/Ums.Application/Authorization/Profile/Queries/GetAllProfilesQueryHandler.cs +++ b/src/apps/ums.api/Ums.Application/Authorization/Profile/Queries/GetAllProfilesQueryHandler.cs @@ -7,6 +7,8 @@ namespace Ums.Application.Authorization.Profile.Queries; +#pragma warning disable S125 + public sealed class GetAllProfilesQueryHandler : IQueryHandler> { private readonly IProfileRepository _profileRepository; @@ -63,14 +65,21 @@ public async Task>> Handle( ? ctxTenantId : (Guid?)null); } - var profiles = request.UserId.HasValue - ? await _profileRepository.GetByUserIdAsync(request.UserId.Value, cancellationToken) - : effectiveTenantId.HasValue - ? await _profileRepository.GetByTenantIdAsync(effectiveTenantId.Value, cancellationToken) - : await _profileRepository.GetAllAsync(effectiveTenantId, cancellationToken); + IReadOnlyList profiles; + if (request.UserId.HasValue) + { + profiles = await _profileRepository.GetByUserIdAsync(request.UserId.Value, cancellationToken); + } + else if (effectiveTenantId.HasValue) + { + profiles = await _profileRepository.GetByTenantIdAsync(effectiveTenantId.Value, cancellationToken); + } + else + { + profiles = await _profileRepository.GetAllAsync(effectiveTenantId, cancellationToken); + } var allTenants = await _tenantRepository.GetAllAsync(null, cancellationToken); - var profileRoleIds = profiles.Select(p => p.Props.RoleId.GetValue()).Distinct().ToList(); var profileTenantIds = profiles.Select(p => p.Props.TenantId.GetValue()).Distinct().ToList(); var allRoles = new List(); diff --git a/src/apps/ums.api/Ums.Application/Authorization/Profile/Queries/GetProfileByIdQueryHandler.cs b/src/apps/ums.api/Ums.Application/Authorization/Profile/Queries/GetProfileByIdQueryHandler.cs index 02d23ed5..998acc95 100644 --- a/src/apps/ums.api/Ums.Application/Authorization/Profile/Queries/GetProfileByIdQueryHandler.cs +++ b/src/apps/ums.api/Ums.Application/Authorization/Profile/Queries/GetProfileByIdQueryHandler.cs @@ -6,6 +6,7 @@ using Ums.Domain.Authorization.Template.PermissionTemplateItem; using Ums.Domain.Identity; using Ums.Domain.Identity.UserAccount; +using MenuNodeEntity = Ums.Domain.Authorization.SystemSuite.MenuNode.MenuNode; using SystemSuiteAggregate = Ums.Domain.Authorization.SystemSuite.SystemSuite; namespace Ums.Application.Authorization.Profile.Queries; @@ -145,26 +146,21 @@ private static Dictionary BuildTargetNameLookup(SystemSuiteAggrega lookup[suite.GetId().GetValue()] = suite.Props.Name.GetValue(); - foreach (var module in suite.Modules) + void WalkNodes(IEnumerable nodes) { - lookup[module.Props.Id.GetValue()] = module.Name.GetValue(); - - foreach (var menu in module.Menus) + foreach (var node in nodes) { - lookup[menu.Props.Id.GetValue()] = menu.Label.GetValue(); - - foreach (var subMenu in menu.SubMenus) - { - lookup[subMenu.Props.Id.GetValue()] = subMenu.Label.GetValue(); - - foreach (var option in subMenu.Options) - { - lookup[option.Props.Id.GetValue()] = option.Label.GetValue(); - } - } + lookup[node.GetId().GetValue()] = node.Label.GetValue(); + WalkNodes(node.Children); } } + foreach (var module in suite.Modules) + { + lookup[module.Props.Id.GetValue()] = module.Name.GetValue(); + WalkNodes(module.Nodes); + } + foreach (var resource in suite.DomainResources) { lookup[resource.Id.GetValue()] = resource.Name.GetValue(); diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddDomainResourceCommand.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddDomainResourceCommand.cs index 73cc33c5..a06b311e 100644 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddDomainResourceCommand.cs +++ b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddDomainResourceCommand.cs @@ -2,6 +2,8 @@ namespace Ums.Application.Authorization.SystemSuite.Commands; +using Ums.Application.Authorization.SystemSuite.DTOs; + public sealed record AddDomainResourceCommand( Guid SystemSuiteId, Guid? ModuleId, @@ -9,4 +11,4 @@ public sealed record AddDomainResourceCommand( string Type, string Code, string Name, - string Description) : ICommand; + string Description) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddDomainResourceCommandHandler.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddDomainResourceCommandHandler.cs index 0e495cd0..a73217d2 100644 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddDomainResourceCommandHandler.cs +++ b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddDomainResourceCommandHandler.cs @@ -7,22 +7,23 @@ using Ums.Domain.Kernel; using Ums.Domain.Kernel.ValueObjects; using Ums.Application.Common; +using Ums.Application.Authorization.SystemSuite.DTOs; namespace Ums.Application.Authorization.SystemSuite.Commands; public sealed class AddDomainResourceCommandHandler(ISystemSuiteRepository repository, IUserContext userContext) - : ICommandHandler + : ICommandHandler { - public async Task Handle(AddDomainResourceCommand request, CancellationToken cancellationToken) + public async Task> Handle(AddDomainResourceCommand request, CancellationToken cancellationToken) { var suite = await repository.GetByIdAsync(request.SystemSuiteId, cancellationToken); if (suite is null) { - return Result.Failure(DomainErrors.Common.NotFound); + return Result.Failure(DomainErrors.Common.NotFound); } var type = DomainEnumerationParser.FromName(request.Type); - if (type is null) return Result.Failure($"Invalid DomainResourceType: {request.Type}"); + if (type is null) return Result.Failure($"Invalid DomainResourceType: {request.Type}"); var moduleId = request.ModuleId.HasValue ? ModuleId.Load(request.ModuleId.Value) : null; var parentId = request.ParentResourceId.HasValue ? IdValueObject.Load(request.ParentResourceId.Value) : null; @@ -38,12 +39,12 @@ public async Task Handle(AddDomainResourceCommand request, CancellationT if (result.IsFailure) { - return result; + return Result.Failure(result.Error); } await repository.UpdateAsync(suite, cancellationToken); await repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); - return Result.Success(); + return Result.Success(new AddDomainResourceResponse(result.Value)); } } diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddMenuCommand.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddMenuCommand.cs deleted file mode 100644 index af7c9875..00000000 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddMenuCommand.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Ums.Application.Authorization.SystemSuite.Commands; - -public sealed record AddMenuCommand( - Guid SystemSuiteId, - Guid ModuleId, - string Code, - string Label, - string Description, - int SortOrder) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddMenuCommandHandler.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddMenuCommandHandler.cs deleted file mode 100644 index 7114b2b7..00000000 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddMenuCommandHandler.cs +++ /dev/null @@ -1,40 +0,0 @@ -namespace Ums.Application.Authorization.SystemSuite.Commands; - -using Ums.Domain.Authorization; - -public sealed class AddMenuCommandHandler : ICommandHandler -{ - private readonly ISystemSuiteRepository _repository; - private readonly IUserContext _userContext; - - public AddMenuCommandHandler(ISystemSuiteRepository repository, IUserContext userContext) - { - _repository = repository; - _userContext = userContext; - } - - [AuditTrail] - [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] - public async Task Handle(AddMenuCommand request, CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(_userContext.UserId)) - return Result.Failure("Authenticated user is required."); - - var suite = await _repository.GetByIdAsync(request.SystemSuiteId, cancellationToken); - if (suite is null) return Result.Failure("System suite not found."); - - var result = suite.AddMenu( - IdValueObject.Load(request.ModuleId), - Code.Create(request.Code), - Name.Create(request.Label), - Description.Create(request.Description), - request.SortOrder, - ActorId.Create(_userContext.UserId)); - - if (result.IsFailure) return result; - - await _repository.UpdateAsync(suite, cancellationToken); - await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); - return Result.Success(); - } -} diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddMenuCommandValidator.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddMenuCommandValidator.cs deleted file mode 100644 index 0211fca1..00000000 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddMenuCommandValidator.cs +++ /dev/null @@ -1,29 +0,0 @@ -namespace Ums.Application.Authorization.SystemSuite.Commands; - -using FluentValidation; - -public sealed class AddMenuCommandValidator : AbstractValidator -{ - public AddMenuCommandValidator() - { - RuleLevelCascadeMode = CascadeMode.Stop; - - RuleFor(x => x.SystemSuiteId).NotEmpty(); - RuleFor(x => x.ModuleId).NotEmpty(); - - RuleFor(x => x.Code) - .NotEmpty().WithMessage("Menu code is required.") - .MaximumLength(50).WithMessage("Menu code must not exceed 50 characters.") - .Matches(@"^[A-Za-z0-9_]+$").WithMessage("Menu code may only contain letters, digits, and underscores."); - - RuleFor(x => x.Label) - .NotEmpty().WithMessage("Menu label is required.") - .MaximumLength(150).WithMessage("Menu label must not exceed 150 characters."); - - RuleFor(x => x.Description) - .MaximumLength(500).WithMessage("Description must not exceed 500 characters."); - - RuleFor(x => x.SortOrder) - .GreaterThan(0).WithMessage("Sort order must be greater than zero."); - } -} diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddModuleCommand.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddModuleCommand.cs index 0f7d8a8b..12419027 100644 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddModuleCommand.cs +++ b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddModuleCommand.cs @@ -1,8 +1,10 @@ namespace Ums.Application.Authorization.SystemSuite.Commands; +using Ums.Application.Authorization.SystemSuite.DTOs; + public sealed record AddModuleCommand( Guid SystemSuiteId, string Code, string Name, string Description, - int SortOrder) : ICommand; + int SortOrder) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddModuleCommandHandler.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddModuleCommandHandler.cs index c4d95a61..d6b540ee 100644 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddModuleCommandHandler.cs +++ b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddModuleCommandHandler.cs @@ -1,8 +1,9 @@ namespace Ums.Application.Authorization.SystemSuite.Commands; +using Ums.Application.Authorization.SystemSuite.DTOs; using Ums.Domain.Authorization; -public sealed class AddModuleCommandHandler : ICommandHandler +public sealed class AddModuleCommandHandler : ICommandHandler { private readonly ISystemSuiteRepository _repository; private readonly IUserContext _userContext; @@ -15,13 +16,13 @@ public AddModuleCommandHandler(ISystemSuiteRepository repository, IUserContext u [AuditTrail] [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] - public async Task Handle(AddModuleCommand request, CancellationToken cancellationToken) + public async Task> Handle(AddModuleCommand request, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(_userContext.UserId)) - return Result.Failure("Authenticated user is required."); + return Result.Failure("Authenticated user is required."); var suite = await _repository.GetByIdAsync(request.SystemSuiteId, cancellationToken); - if (suite is null) return Result.Failure("System suite not found."); + if (suite is null) return Result.Failure("System suite not found."); var result = suite.AddModule( Code.Create(request.Code), @@ -30,10 +31,10 @@ public async Task Handle(AddModuleCommand request, CancellationToken can request.SortOrder, ActorId.Create(_userContext.UserId)); - if (result.IsFailure) return result; + if (result.IsFailure) return Result.Failure(result.Error); await _repository.UpdateAsync(suite, cancellationToken); await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); - return Result.Success(); + return Result.Success(new AddModuleResponse(result.Value)); } } diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddNodeCommand.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddNodeCommand.cs new file mode 100644 index 00000000..f3018e79 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddNodeCommand.cs @@ -0,0 +1,89 @@ +namespace Ums.Application.Authorization.SystemSuite.Commands; + +using FluentValidation; +using Ums.Application.Authorization.SystemSuite.DTOs; +using Ums.Domain.Authorization; +using Ums.Domain.Authorization.SystemSuite.MenuNode; + +/// Alta de un nodo del árbol recursivo (ADR-0090). ParentNodeId nulo ⇒ nodo raíz del módulo. +public sealed record AddNodeCommand( + Guid SystemSuiteId, + Guid ModuleId, + Guid? ParentNodeId, + string Kind, + string Code, + string Label, + string Description, + int SortOrder, + string? Icon = null, + string? Route = null) : ICommand; + +public sealed class AddNodeCommandHandler : ICommandHandler +{ + private readonly ISystemSuiteRepository _repository; + private readonly IUserContext _userContext; + + public AddNodeCommandHandler(ISystemSuiteRepository repository, IUserContext userContext) + { + _repository = repository; + _userContext = userContext; + } + + [AuditTrail] + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task> Handle(AddNodeCommand request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(_userContext.UserId)) + return Result.Failure("Authenticated user is required."); + + if (!Enum.TryParse(request.Kind, ignoreCase: true, out var kind)) + return Result.Failure($"Invalid node kind '{request.Kind}'."); + + var suite = await _repository.GetByIdAsync(request.SystemSuiteId, cancellationToken); + if (suite is null) return Result.Failure("System suite not found."); + + var actor = ActorId.Create(_userContext.UserId); + var moduleId = IdValueObject.Load(request.ModuleId); + var code = Code.Create(request.Code); + var label = Name.Create(request.Label); + var description = Description.Create(request.Description); + + var presentation = MenuNodePresentation.Create(request.Icon, request.Route); + + var result = request.ParentNodeId is { } parentId + ? suite.AddModuleChildNode(moduleId, IdValueObject.Load(parentId), kind, code, label, description, request.SortOrder, actor, presentation: presentation) + : suite.AddModuleRootNode(moduleId, kind, code, label, description, request.SortOrder, actor, presentation: presentation); + + if (result.IsFailure) return Result.Failure(result.Error); + + await _repository.UpdateAsync(suite, cancellationToken); + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + return Result.Success(new AddNodeResponse(result.Value)); + } + + +public sealed class AddNodeCommandValidator : AbstractValidator +{ + public AddNodeCommandValidator() + { + RuleLevelCascadeMode = CascadeMode.Stop; + + RuleFor(x => x.SystemSuiteId).NotEmpty(); + RuleFor(x => x.ModuleId).NotEmpty(); + RuleFor(x => x.Kind).NotEmpty().WithMessage("Node kind is required."); + RuleFor(x => x.Code) + .NotEmpty().WithMessage("Node code is required.") + .MaximumLength(100) + .Matches(@"^[A-Za-z0-9_]+$").WithMessage("Node code may only contain letters, digits, and underscores."); + RuleFor(x => x.Label).NotEmpty().MaximumLength(200); + RuleFor(x => x.Description).MaximumLength(1000); + RuleFor(x => x.SortOrder).GreaterThan(0); + RuleFor(x => x.Icon).MaximumLength(64); + // La ruta es relativa: una absoluta permitiría que un menú llevara a otro dominio. + RuleFor(x => x.Route) + .MaximumLength(400) + .Must(r => string.IsNullOrWhiteSpace(r) || r.StartsWith('/')) + .WithMessage("La ruta debe ser relativa y empezar por '/'."); + } +} +} diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddOptionCommand.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddOptionCommand.cs deleted file mode 100644 index bfcfdef5..00000000 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddOptionCommand.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace Ums.Application.Authorization.SystemSuite.Commands; - -public sealed record AddOptionCommand( - Guid SystemSuiteId, - Guid ModuleId, - Guid MenuId, - Guid SubMenuId, - string Code, - string Label, - string Description, - string ActionCode, - int SortOrder) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddOptionCommandHandler.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddOptionCommandHandler.cs deleted file mode 100644 index 9ed994d2..00000000 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddOptionCommandHandler.cs +++ /dev/null @@ -1,43 +0,0 @@ -namespace Ums.Application.Authorization.SystemSuite.Commands; - -using Ums.Domain.Authorization; - -public sealed class AddOptionCommandHandler : ICommandHandler -{ - private readonly ISystemSuiteRepository _repository; - private readonly IUserContext _userContext; - - public AddOptionCommandHandler(ISystemSuiteRepository repository, IUserContext userContext) - { - _repository = repository; - _userContext = userContext; - } - - [AuditTrail] - [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] - public async Task Handle(AddOptionCommand request, CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(_userContext.UserId)) - return Result.Failure("Authenticated user is required."); - - var suite = await _repository.GetByIdAsync(request.SystemSuiteId, cancellationToken); - if (suite is null) return Result.Failure("System suite not found."); - - var result = suite.AddOption( - IdValueObject.Load(request.ModuleId), - IdValueObject.Load(request.MenuId), - IdValueObject.Load(request.SubMenuId), - Code.Create(request.Code), - Name.Create(request.Label), - Description.Create(request.Description), - ActionCode.Create(request.ActionCode), - request.SortOrder, - ActorId.Create(_userContext.UserId)); - - if (result.IsFailure) return result; - - await _repository.UpdateAsync(suite, cancellationToken); - await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); - return Result.Success(); - } -} diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddOptionCommandValidator.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddOptionCommandValidator.cs deleted file mode 100644 index 1b9106d4..00000000 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddOptionCommandValidator.cs +++ /dev/null @@ -1,35 +0,0 @@ -namespace Ums.Application.Authorization.SystemSuite.Commands; - -using FluentValidation; - -public sealed class AddOptionCommandValidator : AbstractValidator -{ - public AddOptionCommandValidator() - { - RuleLevelCascadeMode = CascadeMode.Stop; - - RuleFor(x => x.SystemSuiteId).NotEmpty(); - RuleFor(x => x.ModuleId).NotEmpty(); - RuleFor(x => x.MenuId).NotEmpty(); - RuleFor(x => x.SubMenuId).NotEmpty(); - - RuleFor(x => x.Code) - .NotEmpty().WithMessage("Option code is required.") - .MaximumLength(50).WithMessage("Option code must not exceed 50 characters.") - .Matches(@"^[A-Za-z0-9_]+$").WithMessage("Option code may only contain letters, digits, and underscores."); - - RuleFor(x => x.Label) - .NotEmpty().WithMessage("Option label is required.") - .MaximumLength(150).WithMessage("Option label must not exceed 150 characters."); - - RuleFor(x => x.Description) - .MaximumLength(500).WithMessage("Description must not exceed 500 characters."); - - RuleFor(x => x.ActionCode) - .NotEmpty().WithMessage("Action code is required.") - .MaximumLength(50).WithMessage("Action code must not exceed 50 characters."); - - RuleFor(x => x.SortOrder) - .GreaterThan(0).WithMessage("Sort order must be greater than zero."); - } -} diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddSubMenuCommand.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddSubMenuCommand.cs deleted file mode 100644 index c6fe65ae..00000000 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddSubMenuCommand.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Ums.Application.Authorization.SystemSuite.Commands; - -public sealed record AddSubMenuCommand( - Guid SystemSuiteId, - Guid ModuleId, - Guid MenuId, - string Code, - string Label, - string Description, - int SortOrder) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddSubMenuCommandValidator.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddSubMenuCommandValidator.cs deleted file mode 100644 index 1ef73b64..00000000 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddSubMenuCommandValidator.cs +++ /dev/null @@ -1,30 +0,0 @@ -namespace Ums.Application.Authorization.SystemSuite.Commands; - -using FluentValidation; - -public sealed class AddSubMenuCommandValidator : AbstractValidator -{ - public AddSubMenuCommandValidator() - { - RuleLevelCascadeMode = CascadeMode.Stop; - - RuleFor(x => x.SystemSuiteId).NotEmpty(); - RuleFor(x => x.ModuleId).NotEmpty(); - RuleFor(x => x.MenuId).NotEmpty(); - - RuleFor(x => x.Code) - .NotEmpty().WithMessage("SubMenu code is required.") - .MaximumLength(50).WithMessage("SubMenu code must not exceed 50 characters.") - .Matches(@"^[A-Za-z0-9_]+$").WithMessage("SubMenu code may only contain letters, digits, and underscores."); - - RuleFor(x => x.Label) - .NotEmpty().WithMessage("SubMenu label is required.") - .MaximumLength(150).WithMessage("SubMenu label must not exceed 150 characters."); - - RuleFor(x => x.Description) - .MaximumLength(500).WithMessage("Description must not exceed 500 characters."); - - RuleFor(x => x.SortOrder) - .GreaterThan(0).WithMessage("Sort order must be greater than zero."); - } -} diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/DeleteSystemSuiteCommand.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/DeleteSystemSuiteCommand.cs new file mode 100644 index 00000000..977572f7 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/DeleteSystemSuiteCommand.cs @@ -0,0 +1,10 @@ +namespace Ums.Application.Authorization.SystemSuite.Commands; + +/// +/// Elimina LÓGICAMENTE un sistema ya archivado y sin referencias vivas (G-246). +/// +/// El archivado previo se hace con Deprecated. +/// La fila permanece en la base —el catálogo se consulta sobre datos antiguos—; lo que cambia es que +/// deja de aparecer en cualquier lectura. +/// +public sealed record DeleteSystemSuiteCommand(Guid SystemSuiteId) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/DeleteSystemSuiteCommandHandler.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/DeleteSystemSuiteCommandHandler.cs new file mode 100644 index 00000000..84c970bf --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/DeleteSystemSuiteCommandHandler.cs @@ -0,0 +1,76 @@ +namespace Ums.Application.Authorization.SystemSuite.Commands; + +using Ums.Domain.Authorization; +using Ums.Domain.Kernel; + +/// +/// Elimina lógicamente un sistema del catálogo (G-246). +/// +/// Orden deliberado: primero se comprueba el ámbito de gestión —dar de baja un sistema es al menos +/// tan sensible como darlo de alta, así que exige el mismo permiso que CreateSystemSuite—, +/// luego se cuentan las referencias vivas y solo entonces se pregunta al dominio. El dominio dicta +/// la regla; esta capa se limita a traducir su negativa al 409 estructurado que ya usan el resto de +/// guardas de dependencias. +/// +/// La escritura sale por UpdateAsync, igual que un cambio de estado cualquiera, porque eso es +/// exactamente lo que es: la fila se queda, con su estado en Deleted. No hay —ni debe haber— +/// una llamada de borrado en el repositorio. +/// +public sealed class DeleteSystemSuiteCommandHandler : ICommandHandler +{ + private readonly ISystemSuiteRepository _systemSuiteRepository; + private readonly IUserContext _userContext; + private readonly ITenantScopePolicy _tenantScopePolicy; + + public DeleteSystemSuiteCommandHandler( + ISystemSuiteRepository systemSuiteRepository, + IUserContext userContext, + ITenantScopePolicy tenantScopePolicy) + { + _systemSuiteRepository = systemSuiteRepository; + _userContext = userContext; + _tenantScopePolicy = tenantScopePolicy; + } + + [AuditTrail] + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task Handle(DeleteSystemSuiteCommand request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(_userContext.UserId)) + { + return Result.Failure("Authenticated user is required to delete a system suite."); + } + + // GetByIdAsync ya no ve los sistemas eliminados (filtro global): repetir el DELETE sobre uno + // ya eliminado devuelve 404, que es lo coherente con que haya desaparecido de las lecturas. + var systemSuite = await _systemSuiteRepository.GetByIdAsync(request.SystemSuiteId, cancellationToken); + if (systemSuite is null) + { + return Result.Failure(DomainErrors.Common.NotFound); + } + + var scopeResult = await _tenantScopePolicy.EnsureManagementOwnerScopeAsync( + systemSuite.TenantId.GetValue(), cancellationToken); + if (scopeResult.IsFailure) + { + return scopeResult; + } + + var dependents = await _systemSuiteRepository.GetDependentsAsync(request.SystemSuiteId, cancellationToken); + + var result = systemSuite.Delete(dependents, ActorId.Create(_userContext.UserId)); + if (result.IsFailure) + { + // El detalle de QUÉ bloquea es formato de respuesta, no regla de negocio: el dominio + // devuelve el código con nombre y aquí se le adjunta el desglose que verá el llamador. + return result.Error == DomainErrors.Authorization.SystemSuiteHasDependents + ? Result.Failure(BlockedOperationError.Encode(result.Error, dependents.ToBlockingDependencies())) + : result; + } + + await _systemSuiteRepository.UpdateAsync(systemSuite, cancellationToken); + await _systemSuiteRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/NodeActionCommands.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/NodeActionCommands.cs new file mode 100644 index 00000000..5c87305b --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/NodeActionCommands.cs @@ -0,0 +1,106 @@ +namespace Ums.Application.Authorization.SystemSuite.Commands; + +using FluentValidation; +using Ums.Domain.Authorization; + +// ── Vincular funcionalidad (N:M) a un nodo hoja (Opción) ── + +public sealed record LinkNodeActionCommand(Guid SystemSuiteId, Guid ModuleId, Guid NodeId, string ActionCode) : ICommand; + +public sealed class LinkNodeActionCommandHandler : ICommandHandler +{ + private readonly ISystemSuiteRepository _repository; + private readonly IUserContext _userContext; + + public LinkNodeActionCommandHandler(ISystemSuiteRepository repository, IUserContext userContext) + { + _repository = repository; + _userContext = userContext; + } + + [AuditTrail] + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task Handle(LinkNodeActionCommand request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(_userContext.UserId)) + return Result.Failure("Authenticated user is required."); + + var suite = await _repository.GetByIdAsync(request.SystemSuiteId, cancellationToken); + if (suite is null) return Result.Failure("System suite not found."); + + var result = suite.LinkModuleNodeAction( + IdValueObject.Load(request.ModuleId), + IdValueObject.Load(request.NodeId), + Domain.Kernel.ValueObjects.ActionCode.Create(request.ActionCode), + ActorId.Create(_userContext.UserId)); + + if (result.IsFailure) return result; + + await _repository.UpdateAsync(suite, cancellationToken); + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + return Result.Success(); + } +} + +public sealed class LinkNodeActionCommandValidator : AbstractValidator +{ + public LinkNodeActionCommandValidator() + { + RuleLevelCascadeMode = CascadeMode.Stop; + RuleFor(x => x.SystemSuiteId).NotEmpty(); + RuleFor(x => x.ModuleId).NotEmpty(); + RuleFor(x => x.NodeId).NotEmpty(); + RuleFor(x => x.ActionCode).NotEmpty().MaximumLength(100); + } +} + +// ── Desvincular funcionalidad ── + +public sealed record UnlinkNodeActionCommand(Guid SystemSuiteId, Guid ModuleId, Guid NodeId, string ActionCode) : ICommand; + +public sealed class UnlinkNodeActionCommandHandler : ICommandHandler +{ + private readonly ISystemSuiteRepository _repository; + private readonly IUserContext _userContext; + + public UnlinkNodeActionCommandHandler(ISystemSuiteRepository repository, IUserContext userContext) + { + _repository = repository; + _userContext = userContext; + } + + [AuditTrail] + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task Handle(UnlinkNodeActionCommand request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(_userContext.UserId)) + return Result.Failure("Authenticated user is required."); + + var suite = await _repository.GetByIdAsync(request.SystemSuiteId, cancellationToken); + if (suite is null) return Result.Failure("System suite not found."); + + var result = suite.UnlinkModuleNodeAction( + IdValueObject.Load(request.ModuleId), + IdValueObject.Load(request.NodeId), + Domain.Kernel.ValueObjects.ActionCode.Create(request.ActionCode), + ActorId.Create(_userContext.UserId)); + + if (result.IsFailure) return result; + + await _repository.UpdateAsync(suite, cancellationToken); + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + return Result.Success(); + } +} + +public sealed class UnlinkNodeActionCommandValidator : AbstractValidator +{ + public UnlinkNodeActionCommandValidator() + { + RuleLevelCascadeMode = CascadeMode.Stop; + RuleFor(x => x.SystemSuiteId).NotEmpty(); + RuleFor(x => x.ModuleId).NotEmpty(); + RuleFor(x => x.NodeId).NotEmpty(); + RuleFor(x => x.ActionCode).NotEmpty(); + } +} diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RegisterActionCommand.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RegisterActionCommand.cs index 960c1945..361eb6a8 100644 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RegisterActionCommand.cs +++ b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RegisterActionCommand.cs @@ -1,3 +1,5 @@ namespace Ums.Application.Authorization.SystemSuite.Commands; -public sealed record RegisterActionCommand(Guid SystemSuiteId, string Code, string Name) : ICommand; +using Ums.Application.Authorization.SystemSuite.DTOs; + +public sealed record RegisterActionCommand(Guid SystemSuiteId, string Code, string Name) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RegisterActionCommandHandler.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RegisterActionCommandHandler.cs index 772cb62b..543a5adf 100644 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RegisterActionCommandHandler.cs +++ b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RegisterActionCommandHandler.cs @@ -1,8 +1,9 @@ namespace Ums.Application.Authorization.SystemSuite.Commands; +using Ums.Application.Authorization.SystemSuite.DTOs; using Ums.Domain.Authorization; -public sealed class RegisterActionCommandHandler : ICommandHandler +public sealed class RegisterActionCommandHandler : ICommandHandler { private readonly ISystemSuiteRepository _repository; private readonly IUserContext _userContext; @@ -15,23 +16,23 @@ public RegisterActionCommandHandler(ISystemSuiteRepository repository, IUserCont [AuditTrail] [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] - public async Task Handle(RegisterActionCommand request, CancellationToken cancellationToken) + public async Task> Handle(RegisterActionCommand request, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(_userContext.UserId)) - return Result.Failure("Authenticated user is required."); + return Result.Failure("Authenticated user is required."); var suite = await _repository.GetByIdAsync(request.SystemSuiteId, cancellationToken); - if (suite is null) return Result.Failure("System suite not found."); + if (suite is null) return Result.Failure("System suite not found."); var result = suite.RegisterAction( ActionCode.Create(request.Code), Name.Create(request.Name), ActorId.Create(_userContext.UserId)); - if (result.IsFailure) return result; + if (result.IsFailure) return Result.Failure(result.Error); await _repository.UpdateAsync(suite, cancellationToken); await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); - return Result.Success(); + return Result.Success(new RegisterActionResponse(result.Value, request.Code)); } } diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RemoveMenuCommand.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RemoveMenuCommand.cs deleted file mode 100644 index 3bee5b5e..00000000 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RemoveMenuCommand.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Ums.Application.Authorization.SystemSuite.Commands; - -public sealed record RemoveMenuCommand(Guid SystemSuiteId, Guid ModuleId, Guid MenuId) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RemoveMenuCommandHandler.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RemoveMenuCommandHandler.cs deleted file mode 100644 index f51a68f7..00000000 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RemoveMenuCommandHandler.cs +++ /dev/null @@ -1,37 +0,0 @@ -namespace Ums.Application.Authorization.SystemSuite.Commands; - -using Ums.Domain.Authorization; - -public sealed class RemoveMenuCommandHandler : ICommandHandler -{ - private readonly ISystemSuiteRepository _repository; - private readonly IUserContext _userContext; - - public RemoveMenuCommandHandler(ISystemSuiteRepository repository, IUserContext userContext) - { - _repository = repository; - _userContext = userContext; - } - - [AuditTrail] - [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] - public async Task Handle(RemoveMenuCommand request, CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(_userContext.UserId)) - return Result.Failure("Authenticated user is required."); - - var suite = await _repository.GetByIdAsync(request.SystemSuiteId, cancellationToken); - if (suite is null) return Result.Failure("System suite not found."); - - var result = suite.RemoveMenu( - IdValueObject.Load(request.ModuleId), - IdValueObject.Load(request.MenuId), - ActorId.Create(_userContext.UserId)); - - if (result.IsFailure) return result; - - await _repository.UpdateAsync(suite, cancellationToken); - await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); - return Result.Success(); - } -} diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateMenuCommandHandler.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RemoveNodeCommand.cs similarity index 59% rename from src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateMenuCommandHandler.cs rename to src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RemoveNodeCommand.cs index c3edc2fc..eb062e6b 100644 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateMenuCommandHandler.cs +++ b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RemoveNodeCommand.cs @@ -1,13 +1,16 @@ namespace Ums.Application.Authorization.SystemSuite.Commands; +using FluentValidation; using Ums.Domain.Authorization; -public sealed class UpdateMenuCommandHandler : ICommandHandler +public sealed record RemoveNodeCommand(Guid SystemSuiteId, Guid ModuleId, Guid NodeId) : ICommand; + +public sealed class RemoveNodeCommandHandler : ICommandHandler { private readonly ISystemSuiteRepository _repository; private readonly IUserContext _userContext; - public UpdateMenuCommandHandler(ISystemSuiteRepository repository, IUserContext userContext) + public RemoveNodeCommandHandler(ISystemSuiteRepository repository, IUserContext userContext) { _repository = repository; _userContext = userContext; @@ -15,7 +18,7 @@ public UpdateMenuCommandHandler(ISystemSuiteRepository repository, IUserContext [AuditTrail] [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] - public async Task Handle(UpdateMenuCommand request, CancellationToken cancellationToken) + public async Task Handle(RemoveNodeCommand request, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(_userContext.UserId)) return Result.Failure("Authenticated user is required."); @@ -23,12 +26,9 @@ public async Task Handle(UpdateMenuCommand request, CancellationToken ca var suite = await _repository.GetByIdAsync(request.SystemSuiteId, cancellationToken); if (suite is null) return Result.Failure("System suite not found."); - var result = suite.UpdateMenu( + var result = suite.RemoveModuleNode( IdValueObject.Load(request.ModuleId), - IdValueObject.Load(request.MenuId), - Name.Create(request.Label), - Description.Create(request.Description), - request.SortOrder, + IdValueObject.Load(request.NodeId), ActorId.Create(_userContext.UserId)); if (result.IsFailure) return result; @@ -38,3 +38,14 @@ public async Task Handle(UpdateMenuCommand request, CancellationToken ca return Result.Success(); } } + +public sealed class RemoveNodeCommandValidator : AbstractValidator +{ + public RemoveNodeCommandValidator() + { + RuleLevelCascadeMode = CascadeMode.Stop; + RuleFor(x => x.SystemSuiteId).NotEmpty(); + RuleFor(x => x.ModuleId).NotEmpty(); + RuleFor(x => x.NodeId).NotEmpty(); + } +} diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RemoveOptionCommand.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RemoveOptionCommand.cs deleted file mode 100644 index 006275b1..00000000 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RemoveOptionCommand.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Ums.Application.Authorization.SystemSuite.Commands; - -public sealed record RemoveOptionCommand(Guid SystemSuiteId, Guid ModuleId, Guid MenuId, Guid SubMenuId, Guid OptionId) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RemoveOptionCommandHandler.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RemoveOptionCommandHandler.cs deleted file mode 100644 index b1622ebf..00000000 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RemoveOptionCommandHandler.cs +++ /dev/null @@ -1,39 +0,0 @@ -namespace Ums.Application.Authorization.SystemSuite.Commands; - -using Ums.Domain.Authorization; - -public sealed class RemoveOptionCommandHandler : ICommandHandler -{ - private readonly ISystemSuiteRepository _repository; - private readonly IUserContext _userContext; - - public RemoveOptionCommandHandler(ISystemSuiteRepository repository, IUserContext userContext) - { - _repository = repository; - _userContext = userContext; - } - - [AuditTrail] - [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] - public async Task Handle(RemoveOptionCommand request, CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(_userContext.UserId)) - return Result.Failure("Authenticated user is required."); - - var suite = await _repository.GetByIdAsync(request.SystemSuiteId, cancellationToken); - if (suite is null) return Result.Failure("System suite not found."); - - var result = suite.RemoveOption( - IdValueObject.Load(request.ModuleId), - IdValueObject.Load(request.MenuId), - IdValueObject.Load(request.SubMenuId), - IdValueObject.Load(request.OptionId), - ActorId.Create(_userContext.UserId)); - - if (result.IsFailure) return result; - - await _repository.UpdateAsync(suite, cancellationToken); - await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); - return Result.Success(); - } -} diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RemoveSubMenuCommand.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RemoveSubMenuCommand.cs deleted file mode 100644 index e8f97bc4..00000000 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RemoveSubMenuCommand.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Ums.Application.Authorization.SystemSuite.Commands; - -public sealed record RemoveSubMenuCommand(Guid SystemSuiteId, Guid ModuleId, Guid MenuId, Guid SubMenuId) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RemoveSubMenuCommandHandler.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RemoveSubMenuCommandHandler.cs deleted file mode 100644 index 81417cb7..00000000 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/RemoveSubMenuCommandHandler.cs +++ /dev/null @@ -1,38 +0,0 @@ -namespace Ums.Application.Authorization.SystemSuite.Commands; - -using Ums.Domain.Authorization; - -public sealed class RemoveSubMenuCommandHandler : ICommandHandler -{ - private readonly ISystemSuiteRepository _repository; - private readonly IUserContext _userContext; - - public RemoveSubMenuCommandHandler(ISystemSuiteRepository repository, IUserContext userContext) - { - _repository = repository; - _userContext = userContext; - } - - [AuditTrail] - [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] - public async Task Handle(RemoveSubMenuCommand request, CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(_userContext.UserId)) - return Result.Failure("Authenticated user is required."); - - var suite = await _repository.GetByIdAsync(request.SystemSuiteId, cancellationToken); - if (suite is null) return Result.Failure("System suite not found."); - - var result = suite.RemoveSubMenu( - IdValueObject.Load(request.ModuleId), - IdValueObject.Load(request.MenuId), - IdValueObject.Load(request.SubMenuId), - ActorId.Create(_userContext.UserId)); - - if (result.IsFailure) return result; - - await _repository.UpdateAsync(suite, cancellationToken); - await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); - return Result.Success(); - } -} diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/SetNodeMetadataCommand.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/SetNodeMetadataCommand.cs new file mode 100644 index 00000000..df2d49bc --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/SetNodeMetadataCommand.cs @@ -0,0 +1,77 @@ +namespace Ums.Application.Authorization.SystemSuite.Commands; + +using FluentValidation; +using Ums.Domain.Authorization; +using Ums.Domain.Authorization.SystemSuite.MenuNode; + +/// Fija (reemplaza) los metadatos de gobernanza SDLC de un nodo (ADR-0090). Todos opcionales. +public sealed record SetNodeMetadataCommand( + Guid SystemSuiteId, + Guid ModuleId, + Guid NodeId, + string? Responsable, + string? Criticidad, + string? ProductoImpactado, + string? ComponenteTecnico, + string? Dependencias, + string? Evidencias, + string? TrazabilidadSdlc) : ICommand; + +public sealed class SetNodeMetadataCommandHandler : ICommandHandler +{ + private readonly ISystemSuiteRepository _repository; + private readonly IUserContext _userContext; + + public SetNodeMetadataCommandHandler(ISystemSuiteRepository repository, IUserContext userContext) + { + _repository = repository; + _userContext = userContext; + } + + [AuditTrail] + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task Handle(SetNodeMetadataCommand request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(_userContext.UserId)) + return Result.Failure("Authenticated user is required."); + + var suite = await _repository.GetByIdAsync(request.SystemSuiteId, cancellationToken); + if (suite is null) return Result.Failure("System suite not found."); + + var metadata = MenuNodeMetadata.Create( + request.Responsable, + request.Criticidad, + request.ProductoImpactado, + request.ComponenteTecnico, + request.Dependencias, + request.Evidencias, + request.TrazabilidadSdlc); + + var result = suite.SetModuleNodeMetadata( + IdValueObject.Load(request.ModuleId), + IdValueObject.Load(request.NodeId), + metadata, + ActorId.Create(_userContext.UserId)); + + if (result.IsFailure) return result; + + await _repository.UpdateAsync(suite, cancellationToken); + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + return Result.Success(); + } +} + +public sealed class SetNodeMetadataCommandValidator : AbstractValidator +{ + public SetNodeMetadataCommandValidator() + { + RuleLevelCascadeMode = CascadeMode.Stop; + RuleFor(x => x.SystemSuiteId).NotEmpty(); + RuleFor(x => x.ModuleId).NotEmpty(); + RuleFor(x => x.NodeId).NotEmpty(); + RuleFor(x => x.Criticidad).MaximumLength(50); + RuleFor(x => x.Responsable).MaximumLength(200); + RuleFor(x => x.ProductoImpactado).MaximumLength(200); + RuleFor(x => x.ComponenteTecnico).MaximumLength(200); + } +} diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/SetNodeStatusCommand.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/SetNodeStatusCommand.cs new file mode 100644 index 00000000..f30db36b --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/SetNodeStatusCommand.cs @@ -0,0 +1,54 @@ +namespace Ums.Application.Authorization.SystemSuite.Commands; + +using FluentValidation; +using Ums.Domain.Authorization; + +public sealed record SetNodeStatusCommand(Guid SystemSuiteId, Guid ModuleId, Guid NodeId, bool Active) : ICommand; + +public sealed class SetNodeStatusCommandHandler : ICommandHandler +{ + private readonly ISystemSuiteRepository _repository; + private readonly IUserContext _userContext; + + public SetNodeStatusCommandHandler(ISystemSuiteRepository repository, IUserContext userContext) + { + _repository = repository; + _userContext = userContext; + } + + [AuditTrail] + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task Handle(SetNodeStatusCommand request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(_userContext.UserId)) + return Result.Failure("Authenticated user is required."); + + var suite = await _repository.GetByIdAsync(request.SystemSuiteId, cancellationToken); + if (suite is null) return Result.Failure("System suite not found."); + + var moduleId = IdValueObject.Load(request.ModuleId); + var nodeId = IdValueObject.Load(request.NodeId); + var actor = ActorId.Create(_userContext.UserId); + + var result = request.Active + ? suite.ActivateModuleNode(moduleId, nodeId, actor) + : suite.DeactivateModuleNode(moduleId, nodeId, actor); + + if (result.IsFailure) return result; + + await _repository.UpdateAsync(suite, cancellationToken); + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + return Result.Success(); + } +} + +public sealed class SetNodeStatusCommandValidator : AbstractValidator +{ + public SetNodeStatusCommandValidator() + { + RuleLevelCascadeMode = CascadeMode.Stop; + RuleFor(x => x.SystemSuiteId).NotEmpty(); + RuleFor(x => x.ModuleId).NotEmpty(); + RuleFor(x => x.NodeId).NotEmpty(); + } +} diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateMenuCommand.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateMenuCommand.cs deleted file mode 100644 index a2ff1f9d..00000000 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateMenuCommand.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Ums.Application.Authorization.SystemSuite.Commands; - -public sealed record UpdateMenuCommand( - Guid SystemSuiteId, - Guid ModuleId, - Guid MenuId, - string Label, - string Description, - int SortOrder) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateMenuCommandValidator.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateMenuCommandValidator.cs deleted file mode 100644 index 59ed0ffe..00000000 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateMenuCommandValidator.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace Ums.Application.Authorization.SystemSuite.Commands; - -using FluentValidation; - -public sealed class UpdateMenuCommandValidator : AbstractValidator -{ - public UpdateMenuCommandValidator() - { - RuleFor(x => x.SystemSuiteId).NotEmpty(); - RuleFor(x => x.ModuleId).NotEmpty(); - RuleFor(x => x.MenuId).NotEmpty(); - RuleFor(x => x.Label).NotEmpty().MaximumLength(150); - RuleFor(x => x.Description).MaximumLength(500); - RuleFor(x => x.SortOrder).GreaterThan(0).WithMessage("SortOrder must be a positive integer."); - } -} diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddSubMenuCommandHandler.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateNodeCommand.cs similarity index 55% rename from src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddSubMenuCommandHandler.cs rename to src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateNodeCommand.cs index 08bdf49c..b1b26adf 100644 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddSubMenuCommandHandler.cs +++ b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateNodeCommand.cs @@ -1,13 +1,22 @@ namespace Ums.Application.Authorization.SystemSuite.Commands; +using FluentValidation; using Ums.Domain.Authorization; -public sealed class AddSubMenuCommandHandler : ICommandHandler +public sealed record UpdateNodeCommand( + Guid SystemSuiteId, + Guid ModuleId, + Guid NodeId, + string Label, + string Description, + int SortOrder) : ICommand; + +public sealed class UpdateNodeCommandHandler : ICommandHandler { private readonly ISystemSuiteRepository _repository; private readonly IUserContext _userContext; - public AddSubMenuCommandHandler(ISystemSuiteRepository repository, IUserContext userContext) + public UpdateNodeCommandHandler(ISystemSuiteRepository repository, IUserContext userContext) { _repository = repository; _userContext = userContext; @@ -15,7 +24,7 @@ public AddSubMenuCommandHandler(ISystemSuiteRepository repository, IUserContext [AuditTrail] [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] - public async Task Handle(AddSubMenuCommand request, CancellationToken cancellationToken) + public async Task Handle(UpdateNodeCommand request, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(_userContext.UserId)) return Result.Failure("Authenticated user is required."); @@ -23,10 +32,9 @@ public async Task Handle(AddSubMenuCommand request, CancellationToken ca var suite = await _repository.GetByIdAsync(request.SystemSuiteId, cancellationToken); if (suite is null) return Result.Failure("System suite not found."); - var result = suite.AddSubMenu( + var result = suite.UpdateModuleNode( IdValueObject.Load(request.ModuleId), - IdValueObject.Load(request.MenuId), - Code.Create(request.Code), + IdValueObject.Load(request.NodeId), Name.Create(request.Label), Description.Create(request.Description), request.SortOrder, @@ -39,3 +47,17 @@ public async Task Handle(AddSubMenuCommand request, CancellationToken ca return Result.Success(); } } + +public sealed class UpdateNodeCommandValidator : AbstractValidator +{ + public UpdateNodeCommandValidator() + { + RuleLevelCascadeMode = CascadeMode.Stop; + RuleFor(x => x.SystemSuiteId).NotEmpty(); + RuleFor(x => x.ModuleId).NotEmpty(); + RuleFor(x => x.NodeId).NotEmpty(); + RuleFor(x => x.Label).NotEmpty().MaximumLength(200); + RuleFor(x => x.Description).MaximumLength(1000); + RuleFor(x => x.SortOrder).GreaterThan(0); + } +} diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateOptionCommand.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateOptionCommand.cs deleted file mode 100644 index 119612b5..00000000 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateOptionCommand.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace Ums.Application.Authorization.SystemSuite.Commands; - -public sealed record UpdateOptionCommand( - Guid SystemSuiteId, - Guid ModuleId, - Guid MenuId, - Guid SubMenuId, - Guid OptionId, - string Label, - string Description, - string ActionCode, - int SortOrder) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateOptionCommandHandler.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateOptionCommandHandler.cs deleted file mode 100644 index a54d0915..00000000 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateOptionCommandHandler.cs +++ /dev/null @@ -1,43 +0,0 @@ -namespace Ums.Application.Authorization.SystemSuite.Commands; - -using Ums.Domain.Authorization; - -public sealed class UpdateOptionCommandHandler : ICommandHandler -{ - private readonly ISystemSuiteRepository _repository; - private readonly IUserContext _userContext; - - public UpdateOptionCommandHandler(ISystemSuiteRepository repository, IUserContext userContext) - { - _repository = repository; - _userContext = userContext; - } - - [AuditTrail] - [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] - public async Task Handle(UpdateOptionCommand request, CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(_userContext.UserId)) - return Result.Failure("Authenticated user is required."); - - var suite = await _repository.GetByIdAsync(request.SystemSuiteId, cancellationToken); - if (suite is null) return Result.Failure("System suite not found."); - - var result = suite.UpdateOption( - IdValueObject.Load(request.ModuleId), - IdValueObject.Load(request.MenuId), - IdValueObject.Load(request.SubMenuId), - IdValueObject.Load(request.OptionId), - Name.Create(request.Label), - Description.Create(request.Description), - ActionCode.Create(request.ActionCode), - request.SortOrder, - ActorId.Create(_userContext.UserId)); - - if (result.IsFailure) return result; - - await _repository.UpdateAsync(suite, cancellationToken); - await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); - return Result.Success(); - } -} diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateOptionCommandValidator.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateOptionCommandValidator.cs deleted file mode 100644 index e9542883..00000000 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateOptionCommandValidator.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace Ums.Application.Authorization.SystemSuite.Commands; - -using FluentValidation; - -public sealed class UpdateOptionCommandValidator : AbstractValidator -{ - public UpdateOptionCommandValidator() - { - RuleFor(x => x.SystemSuiteId).NotEmpty(); - RuleFor(x => x.ModuleId).NotEmpty(); - RuleFor(x => x.MenuId).NotEmpty(); - RuleFor(x => x.SubMenuId).NotEmpty(); - RuleFor(x => x.OptionId).NotEmpty(); - RuleFor(x => x.Label).NotEmpty().MaximumLength(150); - RuleFor(x => x.Description).MaximumLength(500); - RuleFor(x => x.ActionCode).NotEmpty().MaximumLength(100); - RuleFor(x => x.SortOrder).GreaterThan(0).WithMessage("SortOrder must be a positive integer."); - } -} diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateSubMenuCommand.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateSubMenuCommand.cs deleted file mode 100644 index d83ffec5..00000000 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateSubMenuCommand.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Ums.Application.Authorization.SystemSuite.Commands; - -public sealed record UpdateSubMenuCommand( - Guid SystemSuiteId, - Guid ModuleId, - Guid MenuId, - Guid SubMenuId, - string Label, - string Description, - int SortOrder) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateSubMenuCommandHandler.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateSubMenuCommandHandler.cs deleted file mode 100644 index 59a0d902..00000000 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateSubMenuCommandHandler.cs +++ /dev/null @@ -1,41 +0,0 @@ -namespace Ums.Application.Authorization.SystemSuite.Commands; - -using Ums.Domain.Authorization; - -public sealed class UpdateSubMenuCommandHandler : ICommandHandler -{ - private readonly ISystemSuiteRepository _repository; - private readonly IUserContext _userContext; - - public UpdateSubMenuCommandHandler(ISystemSuiteRepository repository, IUserContext userContext) - { - _repository = repository; - _userContext = userContext; - } - - [AuditTrail] - [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] - public async Task Handle(UpdateSubMenuCommand request, CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(_userContext.UserId)) - return Result.Failure("Authenticated user is required."); - - var suite = await _repository.GetByIdAsync(request.SystemSuiteId, cancellationToken); - if (suite is null) return Result.Failure("System suite not found."); - - var result = suite.UpdateSubMenu( - IdValueObject.Load(request.ModuleId), - IdValueObject.Load(request.MenuId), - IdValueObject.Load(request.SubMenuId), - Name.Create(request.Label), - Description.Create(request.Description), - request.SortOrder, - ActorId.Create(_userContext.UserId)); - - if (result.IsFailure) return result; - - await _repository.UpdateAsync(suite, cancellationToken); - await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); - return Result.Success(); - } -} diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateSubMenuCommandValidator.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateSubMenuCommandValidator.cs deleted file mode 100644 index 7a9f4454..00000000 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/UpdateSubMenuCommandValidator.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace Ums.Application.Authorization.SystemSuite.Commands; - -using FluentValidation; - -public sealed class UpdateSubMenuCommandValidator : AbstractValidator -{ - public UpdateSubMenuCommandValidator() - { - RuleFor(x => x.SystemSuiteId).NotEmpty(); - RuleFor(x => x.ModuleId).NotEmpty(); - RuleFor(x => x.MenuId).NotEmpty(); - RuleFor(x => x.SubMenuId).NotEmpty(); - RuleFor(x => x.Label).NotEmpty().MaximumLength(150); - RuleFor(x => x.Description).MaximumLength(500); - RuleFor(x => x.SortOrder).GreaterThan(0).WithMessage("SortOrder must be a positive integer."); - } -} diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/DTOs/AddDomainResourceResponse.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/DTOs/AddDomainResourceResponse.cs new file mode 100644 index 00000000..89046c90 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/DTOs/AddDomainResourceResponse.cs @@ -0,0 +1,4 @@ +namespace Ums.Application.Authorization.SystemSuite.DTOs; + +/// Id del recurso de dominio recién creado, para evitar un GET posterior (G-053). +public sealed record AddDomainResourceResponse(Guid DomainResourceId); diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/DTOs/AddModuleResponse.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/DTOs/AddModuleResponse.cs new file mode 100644 index 00000000..6087199c --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/DTOs/AddModuleResponse.cs @@ -0,0 +1,4 @@ +namespace Ums.Application.Authorization.SystemSuite.DTOs; + +/// Id del módulo recién creado, para evitar un GET posterior (G-053). +public sealed record AddModuleResponse(Guid ModuleId); diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/DTOs/AddNodeResponse.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/DTOs/AddNodeResponse.cs new file mode 100644 index 00000000..ef62dd9c --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/DTOs/AddNodeResponse.cs @@ -0,0 +1,4 @@ +namespace Ums.Application.Authorization.SystemSuite.DTOs; + +/// Id del nodo recién añadido al árbol del módulo, para evitar un GET posterior (G-053). +public sealed record AddNodeResponse(Guid NodeId); diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/DTOs/RegisterActionResponse.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/DTOs/RegisterActionResponse.cs new file mode 100644 index 00000000..b98b5fbe --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/DTOs/RegisterActionResponse.cs @@ -0,0 +1,4 @@ +namespace Ums.Application.Authorization.SystemSuite.DTOs; + +/// Identificador de la acción recién registrada (G-053). Code es la clave de negocio. +public sealed record RegisterActionResponse(Guid ActionId, string Code); diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/DTOs/SystemSuiteDto.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/DTOs/SystemSuiteDto.cs index 40a164ae..796eafca 100644 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/DTOs/SystemSuiteDto.cs +++ b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/DTOs/SystemSuiteDto.cs @@ -2,6 +2,8 @@ using System.Collections.Generic; using System.Linq; +using MenuNodeEntity = Ums.Domain.Authorization.SystemSuite.MenuNode.MenuNode; + namespace Ums.Application.Authorization.SystemSuite.DTOs; public sealed record SystemSuiteDto( @@ -31,28 +33,7 @@ public static SystemSuiteDto Map(Ums.Domain.Authorization.SystemSuite.SystemSuit m.Description.GetValue(), m.Status.ToString(), m.SortOrder, - m.Menus.Select(menu => new SystemSuiteMenuDto( - menu.Props.Id.GetValue(), // Props.Id = stable DB GUID - menu.Code.GetValue(), - menu.Label.GetValue(), - menu.Description.GetValue(), - menu.SortOrder, - menu.SubMenus.Select(sm => new SystemSuiteSubMenuDto( - sm.Props.Id.GetValue(), // Props.Id = stable DB GUID - sm.Code.GetValue(), - sm.Label.GetValue(), - sm.Description.GetValue(), - sm.SortOrder, - sm.Options.Select(opt => new SystemSuiteOptionDto( - opt.Props.Id.GetValue(), // Props.Id = stable DB GUID - opt.Code.GetValue(), - opt.Label.GetValue(), - opt.Description.GetValue(), - opt.ActionCode.GetValue(), - opt.SortOrder - )).ToList() - )).ToList() - )).ToList() + m.Nodes.Select(MapNode).ToList() )).ToList(), suite.Actions.Select(a => new SystemSuiteActionDto( a.Props.Id.GetValue(), // Props.Id = stable DB GUID @@ -70,6 +51,27 @@ public static SystemSuiteDto Map(Ums.Domain.Authorization.SystemSuite.SystemSuit )).ToList() ); } + + private static SystemSuiteNodeDto MapNode(MenuNodeEntity node) + { + var meta = node.Metadata; + return new SystemSuiteNodeDto( + node.GetId().GetValue(), + node.ParentNodeId?.GetValue(), + node.Kind.ToString(), + node.Code.GetValue(), + node.Label.GetValue(), + node.Description.GetValue(), + node.Status.ToString(), + node.SortOrder, + node.ActionCodes.Select(a => a.GetValue()).ToList(), + meta.IsEmpty + ? null + : new SystemSuiteNodeMetadataDto( + meta.Responsable, meta.Criticidad, meta.ProductoImpactado, + meta.ComponenteTecnico, meta.Dependencias, meta.Evidencias, meta.TrazabilidadSdlc), + node.Children.Select(MapNode).ToList()); + } } public sealed record SystemSuiteModuleDto( @@ -79,31 +81,29 @@ public sealed record SystemSuiteModuleDto( string Description, string Status, int SortOrder, - IReadOnlyList Menus); + IReadOnlyList Nodes); -public sealed record SystemSuiteMenuDto( - Guid Id, - string Code, - string Label, - string Description, - int SortOrder, - IReadOnlyList SubMenus); - -public sealed record SystemSuiteSubMenuDto( +public sealed record SystemSuiteNodeDto( Guid Id, + Guid? ParentNodeId, + string Kind, string Code, string Label, string Description, + string Status, int SortOrder, - IReadOnlyList Options); + IReadOnlyList ActionCodes, + SystemSuiteNodeMetadataDto? Metadata, + IReadOnlyList Children); -public sealed record SystemSuiteOptionDto( - Guid Id, - string Code, - string Label, - string Description, - string ActionCode, - int SortOrder); +public sealed record SystemSuiteNodeMetadataDto( + string? Responsable, + string? Criticidad, + string? ProductoImpactado, + string? ComponenteTecnico, + string? Dependencias, + string? Evidencias, + string? TrazabilidadSdlc); public sealed record SystemSuiteActionDto( Guid Id, diff --git a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Queries/GetAllSystemSuitesQueryHandler.cs b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Queries/GetAllSystemSuitesQueryHandler.cs index b23a42e7..b4f46756 100644 --- a/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Queries/GetAllSystemSuitesQueryHandler.cs +++ b/src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Queries/GetAllSystemSuitesQueryHandler.cs @@ -1,6 +1,7 @@ using Ums.Application.Authorization.SystemSuite.DTOs; using Ums.Application.Common.Interfaces; using Ums.Domain.Authorization; +using Ums.Domain.Authorization.SystemSuite; using static Ums.Application.Common.QueryRequestNormalizer; namespace Ums.Application.Authorization.SystemSuite.Queries; @@ -34,43 +35,28 @@ public async Task>> Handle( var effectiveTenantId = _tenantScopePolicy.ResolveQueryScope(); - var systemSuites = effectiveTenantId.HasValue - ? await _systemSuiteRepository.GetByTenantIdAsync(effectiveTenantId.Value, cancellationToken) - : await _systemSuiteRepository.GetAllAsync(null, cancellationToken); + // G-179: filtro, orden y recorte se resuelven EN LA BASE. Antes se traían todas las suites + // del inquilino con su árbol completo —módulos, nodos, acciones y recursos de dominio— para + // devolver una página de veinte: con cientos de sistemas, traer el catálogo entero a la + // aplicación y tirar el 95 %. + var pagina = await _systemSuiteRepository.GetPageAsync( + new SystemSuitePageQuery( + TenantId: effectiveTenantId, + Status: string.Equals(status, "all", StringComparison.OrdinalIgnoreCase) ? null : status, + SearchField: criteria, + Search: string.IsNullOrWhiteSpace(search) ? null : search, + SortBy: sortBy, + Descending: string.Equals(sortOrder, "desc", StringComparison.OrdinalIgnoreCase), + Page: page, + PageSize: pageSize), + cancellationToken); - var query = systemSuites.Select(SystemSuiteDto.Map); + // El árbol se carga SOLO para las suites de la página, y en el orden que la página fijó. + var suites = await _systemSuiteRepository.GetByIdsAsync(pagina.Ids, cancellationToken); + var items = suites.Select(SystemSuiteDto.Map).ToList(); - if (!string.Equals(status, "all", StringComparison.OrdinalIgnoreCase)) - { - query = query.Where(s => string.Equals(s.Status, status, StringComparison.OrdinalIgnoreCase)); - } - - if (!string.IsNullOrWhiteSpace(search)) - { - query = criteria switch - { - "code" => query.Where(s => s.Code.Contains(search, StringComparison.OrdinalIgnoreCase)), - "id" => query.Where(s => s.SystemSuiteId.ToString().Contains(search, StringComparison.OrdinalIgnoreCase)), - _ => query.Where(s => s.Name.Contains(search, StringComparison.OrdinalIgnoreCase)), - }; - } - - query = (sortBy, sortOrder) switch - { - ("code", "desc") => query.OrderByDescending(s => s.Code), - ("code", _) => query.OrderBy(s => s.Code), - ("status", "desc") => query.OrderByDescending(s => s.Status), - ("status", _) => query.OrderBy(s => s.Status), - ("name", "desc") => query.OrderByDescending(s => s.Name), - _ => query.OrderBy(s => s.Name), - }; - - var totalItems = query.Count(); + var totalItems = pagina.TotalItems; var totalPages = totalItems == 0 ? 0 : (int)Math.Ceiling(totalItems / (double)pageSize); - var items = query - .Skip((page - 1) * pageSize) - .Take(pageSize) - .ToList(); return Result>.Success(new PagedResult( items, diff --git a/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/AddTemplateItemCommand.cs b/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/AddTemplateItemCommand.cs index ed2f2cea..b66406ed 100644 --- a/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/AddTemplateItemCommand.cs +++ b/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/AddTemplateItemCommand.cs @@ -1,9 +1,11 @@ namespace Ums.Application.Authorization.Template.Commands; +using Ums.Application.Authorization.Template.DTOs; + public sealed record AddTemplateItemCommand( Guid TemplateId, string TargetType, Guid TargetId, Guid ActionId, bool IsAllowed, - bool IsDenied) : ICommand; + bool IsDenied) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/AddTemplateItemCommandHandler.cs b/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/AddTemplateItemCommandHandler.cs index 548ded50..2e4ca257 100644 --- a/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/AddTemplateItemCommandHandler.cs +++ b/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/AddTemplateItemCommandHandler.cs @@ -1,10 +1,11 @@ namespace Ums.Application.Authorization.Template.Commands; +using Ums.Application.Authorization.Template.DTOs; using Ums.Domain.Authorization; using Ums.Domain.Enums; using BeyondNetCode.Shell.Ddd; -public sealed class AddTemplateItemCommandHandler : ICommandHandler +public sealed class AddTemplateItemCommandHandler : ICommandHandler { private readonly IPermissionTemplateRepository _repository; private readonly IUserContext _userContext; @@ -19,19 +20,24 @@ public AddTemplateItemCommandHandler( [AuditTrail] [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] - public async Task Handle( + public async Task> Handle( AddTemplateItemCommand request, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(_userContext.UserId)) - return Result.Failure("Authenticated user is required."); + return Result.Failure("Authenticated user is required."); var template = await _repository.GetByIdAsync(request.TemplateId, cancellationToken); - if (template is null) return Result.Failure("Template not found."); + if (template is null) return Result.Failure("Template not found."); - var targetType = DomainEnumeration.FromDisplayName(request.TargetType); + // G-192 — se resuelve con el parseador común (recorta y no distingue mayúsculas) y el + // mensaje enumera los destinos que el DOMINIO declara. Antes usaba FromDisplayName, que + // exige coincidencia exacta, y recitaba una lista de cuatro que el enumerado ya había + // dejado atrás: «Aggregate» y «Entity» son destinos legítimos del arco exclusivo. + var targetType = DomainEnumerationParser.FromName(request.TargetType); if (targetType is null) - return Result.Failure($"Invalid target type '{request.TargetType}'. Valid: SystemSuite, Module, Submodule, Option."); + return Result.Failure( + $"Invalid target type '{request.TargetType}'. Valid: {string.Join(", ", AddTemplateItemCommandValidator.ValidTargetTypes)}."); var result = template.AddItem( targetType, @@ -41,10 +47,10 @@ public async Task Handle( request.IsDenied, ActorId.Create(_userContext.UserId)); - if (result.IsFailure) return result; + if (result.IsFailure) return Result.Failure(result.Error); await _repository.UpdateAsync(template, cancellationToken); await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); - return Result.Success(); + return Result.Success(new AddTemplateItemResponse(result.Value)); } } diff --git a/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/AddTemplateItemCommandValidator.cs b/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/AddTemplateItemCommandValidator.cs index 6a25314a..0c31c4c3 100644 --- a/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/AddTemplateItemCommandValidator.cs +++ b/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/AddTemplateItemCommandValidator.cs @@ -2,15 +2,34 @@ namespace Ums.Application.Authorization.Template.Commands; public sealed class AddTemplateItemCommandValidator : AbstractValidator { - private static readonly string[] ValidTargetTypes = ["SystemSuite", "Module", "Submodule", "Option"]; + /// + /// Destinos admitidos, DERIVADOS de . + /// + /// G-192: aquí había una copia a mano —SystemSuite, Module, Submodule, Option— que dejó fuera + /// a Aggregate y Entity. El dominio sí los admite (PermissionTemplate.AddItem + /// no restringe el arco), Profile.AssignTemplate los copia tal cual al permiso del perfil + /// y AuthorizationGraphBuilderService.BuildDomainPermissions los proyecta casando + /// (TargetId, ActionId) contra los recursos de dominio de la suite; el seeder canónico los usa. + /// O sea: el único que los negaba era este validador, y con él ninguna concesión sobre un objeto + /// de dominio podía darse de alta por API, dejando `domainPermissions` vacío en todo perfil + /// provisionado así. Derivar la lista del enumerado hace que la divergencia no pueda repetirse. + /// + internal static readonly IReadOnlyList ValidTargetTypes = + DomainEnumerationParser.NamesOf(); + + internal static readonly string ValidTargetTypesMessage = + $"TargetType must be one of: {string.Join(", ", ValidTargetTypes)}."; public AddTemplateItemCommandValidator() { RuleFor(x => x.TemplateId).NotEmpty(); RuleFor(x => x.TargetType) .NotEmpty() - .Must(v => ValidTargetTypes.Contains(v, StringComparer.OrdinalIgnoreCase)) - .WithMessage("TargetType must be one of: SystemSuite, Module, Submodule, Option."); + // Se resuelve con el MISMO criterio que usará el manejador (recorte y sin distinguir + // mayúsculas): si el validador aceptara una forma que el manejador no sabe resolver, el + // alta fallaría después con un error opaco en vez de con un 400 explícito. + .Must(v => DomainEnumerationParser.FromName(v) is not null) + .WithMessage(ValidTargetTypesMessage); RuleFor(x => x.TargetId).NotEmpty(); RuleFor(x => x.ActionId).NotEmpty(); RuleFor(x => x).Must(x => x.IsAllowed || x.IsDenied || (!x.IsAllowed && !x.IsDenied)) diff --git a/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/CreatePermissionTemplateCommandHandler.cs b/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/CreatePermissionTemplateCommandHandler.cs index 490ebb9c..579476e4 100644 --- a/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/CreatePermissionTemplateCommandHandler.cs +++ b/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/CreatePermissionTemplateCommandHandler.cs @@ -38,11 +38,18 @@ public async Task> Handle( return Result.Failure(scopeResult.Error); } - var templateResult = PermissionTemplate.Create( + // G-140: resuelve la versión SIGUIENTE a partir de las plantillas existentes para la terna + // (tenant, rol, suite). Antes se asignaba siempre 0.1.0 y el alta sobre un rol ya plantillado + // colisionaba con el índice único → DbUpdateException no controlada → 500 opaco. + var existing = await _templateRepository.GetByTenantRoleSuiteAsync( + request.TenantId, request.RoleId, request.SystemSuiteId, cancellationToken); + + var templateResult = PermissionTemplate.CreateNextVersion( TenantId.Load(request.TenantId), RoleId.Load(request.RoleId), SystemSuiteId.Load(request.SystemSuiteId), - ActorId.Create(_userContext.UserId)); + ActorId.Create(_userContext.UserId), + (existing ?? []).Select(t => t.Version)); if (templateResult.IsFailure) { diff --git a/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/DeletePermissionTemplateCommandHandler.cs b/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/DeletePermissionTemplateCommandHandler.cs index 8bf4815b..b2872996 100644 --- a/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/DeletePermissionTemplateCommandHandler.cs +++ b/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/DeletePermissionTemplateCommandHandler.cs @@ -2,17 +2,35 @@ namespace Ums.Application.Authorization.Template.Commands; using Ums.Domain.Authorization; using Ums.Domain.Authorization.Template; +using Ums.Domain.Kernel; +/// +/// Borrado LÓGICO de una plantilla de permisos. El contrato HTTP no cambia —DELETE sigue devolviendo +/// 204, 404 o 409 igual que antes—; lo que cambia es que la fila NO se elimina de la base. +/// +/// Orden de ejecución (importa): +/// 1. Carga. El repositorio ya oculta lo eliminado, así que un segundo DELETE da 404. +/// 2. Guardia de cascada: perfiles VIVOS que referencian la plantilla → 409 con dependencias +/// estructuradas. Los perfiles ya desactivados no cuentan: su referencia también está +/// lógicamente eliminada, que es exactamente la regla que pide la política. +/// 3. PermissionTemplate.Delete valida el estado (solo Draft/Deprecated) y transiciona al +/// estado terminal Deleted. +/// 4. UpdateAsync persiste el nuevo StatusId y la auditoría (quién y cuándo lo eliminó): sin +/// esta llamada el rastro de la eliminación se perdería, que es justo lo que la política protege. +/// public sealed class DeletePermissionTemplateCommandHandler : ICommandHandler { private readonly IPermissionTemplateRepository _templateRepository; + private readonly IProfileRepository _profileRepository; private readonly IUserContext _userContext; public DeletePermissionTemplateCommandHandler( IPermissionTemplateRepository templateRepository, + IProfileRepository profileRepository, IUserContext userContext) { _templateRepository = templateRepository; + _profileRepository = profileRepository; _userContext = userContext; } @@ -31,18 +49,30 @@ public async Task Handle(DeletePermissionTemplateCommand request, Cancel return Result.Failure("Authenticated user is required."); } - var deleteResult = template.Delete(ActorId.Create(_userContext.UserId)); - if (deleteResult.IsFailure) + // ── Guardia de cascada: perfiles vivos que usan la plantilla ────────── + // Análoga a un ON DELETE RESTRICT: no se elimina lógicamente algo con referencias reales + // que no hayan sido eliminadas lógicamente primero. Mismo conteo y mismo código de error + // que la depreciación (DeprecatePermissionTemplateCommandHandler), que ya lo aplica. + var activeProfileCount = await _profileRepository.CountActiveByTemplateAsync( + request.TemplateId, cancellationToken); + + if (activeProfileCount > 0) { - return deleteResult; + var deps = new List + { + new("Profile", "Active", activeProfileCount), + }; + return Result.Failure(BlockedOperationError.Encode( + DomainErrors.Authorization.TemplateHasActiveProfiles, deps)); } - var deleted = await _templateRepository.DeleteAsync(request.TemplateId, cancellationToken); - if (!deleted) + var deleteResult = template.Delete(ActorId.Create(_userContext.UserId), activeProfileCount); + if (deleteResult.IsFailure) { - return Result.Failure("Template could not be deleted."); + return deleteResult; } + await _templateRepository.UpdateAsync(template, cancellationToken); await _templateRepository.UnitOfWork.SaveChangesAsync(cancellationToken); return Result.Success(); } diff --git a/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/RemoveTemplateItemCommand.cs b/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/RemoveTemplateItemCommand.cs deleted file mode 100644 index 2a2bd3bf..00000000 --- a/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/RemoveTemplateItemCommand.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Ums.Application.Authorization.Template.Commands; - -public sealed record RemoveTemplateItemCommand(Guid TemplateId, Guid ItemId) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/RemoveTemplateItemCommandHandler.cs b/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/RemoveTemplateItemCommandHandler.cs deleted file mode 100644 index e6a36eab..00000000 --- a/src/apps/ums.api/Ums.Application/Authorization/Template/Commands/RemoveTemplateItemCommandHandler.cs +++ /dev/null @@ -1,40 +0,0 @@ -namespace Ums.Application.Authorization.Template.Commands; - -using Ums.Domain.Authorization; - -public sealed class RemoveTemplateItemCommandHandler : ICommandHandler -{ - private readonly IPermissionTemplateRepository _repository; - private readonly IUserContext _userContext; - - public RemoveTemplateItemCommandHandler( - IPermissionTemplateRepository repository, - IUserContext userContext) - { - _repository = repository; - _userContext = userContext; - } - - [AuditTrail] - [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] - public async Task Handle( - RemoveTemplateItemCommand request, - CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(_userContext.UserId)) - return Result.Failure("Authenticated user is required."); - - var template = await _repository.GetByIdAsync(request.TemplateId, cancellationToken); - if (template is null) return Result.Failure("Template not found."); - - var result = template.RemoveItem( - IdValueObject.Load(request.ItemId), - ActorId.Create(_userContext.UserId)); - - if (result.IsFailure) return result; - - await _repository.UpdateAsync(template, cancellationToken); - await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); - return Result.Success(); - } -} diff --git a/src/apps/ums.api/Ums.Application/Authorization/Template/DTOs/AddTemplateItemResponse.cs b/src/apps/ums.api/Ums.Application/Authorization/Template/DTOs/AddTemplateItemResponse.cs new file mode 100644 index 00000000..b516a42b --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Authorization/Template/DTOs/AddTemplateItemResponse.cs @@ -0,0 +1,4 @@ +namespace Ums.Application.Authorization.Template.DTOs; + +/// Id del ítem de plantilla recién creado, para evitar un GET posterior (G-053). +public sealed record AddTemplateItemResponse(Guid ItemId); diff --git a/src/apps/ums.api/Ums.Application/Authorization/Template/Queries/GetPermissionTemplateByIdQueryHandler.cs b/src/apps/ums.api/Ums.Application/Authorization/Template/Queries/GetPermissionTemplateByIdQueryHandler.cs index 0cfedbc2..dd5a754b 100644 --- a/src/apps/ums.api/Ums.Application/Authorization/Template/Queries/GetPermissionTemplateByIdQueryHandler.cs +++ b/src/apps/ums.api/Ums.Application/Authorization/Template/Queries/GetPermissionTemplateByIdQueryHandler.cs @@ -1,6 +1,7 @@ using Ums.Application.Authorization.Template.DTOs; using Ums.Domain.Authorization; using Ums.Domain.Authorization.Template; +using MenuNodeEntity = Ums.Domain.Authorization.SystemSuite.MenuNode.MenuNode; using SystemSuiteAggregate = Ums.Domain.Authorization.SystemSuite.SystemSuite; namespace Ums.Application.Authorization.Template.Queries; @@ -85,26 +86,21 @@ private static Dictionary BuildTargetNameLookup(SystemSuiteAggrega var lookup = new Dictionary(); if (suite is null) return lookup; - foreach (var module in suite.Modules) + void WalkNodes(IEnumerable nodes) { - lookup[module.Props.Id.GetValue()] = module.Name.GetValue(); - - foreach (var menu in module.Menus) + foreach (var node in nodes) { - lookup[menu.Props.Id.GetValue()] = menu.Label.GetValue(); - - foreach (var subMenu in menu.SubMenus) - { - lookup[subMenu.Props.Id.GetValue()] = subMenu.Label.GetValue(); - - foreach (var option in subMenu.Options) - { - lookup[option.Props.Id.GetValue()] = option.Label.GetValue(); - } - } + lookup[node.GetId().GetValue()] = node.Label.GetValue(); + WalkNodes(node.Children); } } + foreach (var module in suite.Modules) + { + lookup[module.Props.Id.GetValue()] = module.Name.GetValue(); + WalkNodes(module.Nodes); + } + return lookup; } } diff --git a/src/apps/ums.api/Ums.Application/Common/Aop/AuditMetadataSanitizer.cs b/src/apps/ums.api/Ums.Application/Common/Aop/AuditMetadataSanitizer.cs new file mode 100644 index 00000000..3aaacc6e --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Common/Aop/AuditMetadataSanitizer.cs @@ -0,0 +1,173 @@ +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; + +namespace Ums.Application.Common.Aop; + +/// +/// G-040 (residual #5, FR-072): desinfecta la metadata serializada de la traza de auditoría +/// antes de persistirla, redactando los valores cuyas claves casen patrones sensibles (contraseñas, +/// hashes, PIN, llaves, tokens, secretos, credenciales…). Se aplica en AMBAS vías —la manual +/// (RecordAuditCommandHandler) y la automática (AuditTrailAspect)— antes de emitir/persistir. +/// +/// +/// La traza es append-only e inmutable (G-081): un secreto filtrado a ella no se puede borrar +/// después. Por eso la redacción es conservadora (ante la duda, redacta) y por clave, no +/// por valor: se conserva la clave —para dejar constancia de que el dato existía— y se sustituye su +/// valor completo por . Si la clave marca un subárbol (objeto o +/// arreglo) sensible, se redacta el subárbol entero sin descender en él. +/// +/// +/// +/// La metadata de auditoría es un string JSON en ambas vías (la vía manual lo valida como JSON +/// bien formado en RecordAuditCommandValidator; la automática lo serializa con +/// JsonSerializer.Serialize). El saneador parsea ese JSON, recorre el árbol y reconstruye una +/// copia redactada. No muta la entrada. +/// +/// +/// +/// Lista de patrones (fácil de extender — añade a o +/// ). Las comparaciones son insensibles a mayúsculas. Se tokeniza la +/// clave por límites camelCase, snake_case, kebab-case y separadores, de modo que +/// apiKey, api_key y API-KEY se tratan igual. +/// +/// — patrones largos e inequívocos; casan en cualquier parte +/// de la clave (p. ej. passwordHash, bearerToken, clientSecret). +/// — patrones cortos y ambiguos; casan solo como token +/// delimitado para no redactar por accidente claves legítimas (p. ej. shipping contiene +/// «pin», monkey contiene «key»: no deben redactarse). +/// +/// +/// +/// +/// Limitación honesta: la redacción es por nombre de clave. Un secreto embebido en el +/// valor de una clave no sensible (p. ej. un texto libre "note":"la clave es 1234") no +/// se detecta. La lista es deliberadamente amplia por seguridad: puede sobre-redactar claves legítimas +/// que contengan «key» (p. ej. publicKey, idempotencyKey) — la sobre-redacción es el lado +/// seguro para una traza inmutable. +/// +/// +public static class AuditMetadataSanitizer +{ + /// Valor de reemplazo para los datos redactados (alineado con PiiMaskingPolicy). + public const string RedactionPlaceholder = "[REDACTED]"; + + /// + /// Patrones largos e inequívocos: casan como subcadena en cualquier parte de la clave (en minúsculas). + /// Extiende aquí para nuevos secretos «largos». + /// + private static readonly string[] SubstringPatterns = + [ + "password", + "passwd", + "secret", + "token", + "apikey", + "authorization", + "credential", + "hash", + ]; + + /// + /// Patrones cortos y ambiguos: casan solo como token delimitado (p. ej. «key» en apiKey, + /// pero no en monkey). Extiende aquí para nuevos secretos «cortos». + /// + private static readonly HashSet TokenPatterns = new(StringComparer.OrdinalIgnoreCase) + { + "pwd", + "pin", + "key", + "auth", + "cred", + }; + + // Tokeniza una clave por límites camelCase, respetando acrónimos: "APIKey" → [API, Key], + // "apiKey" → [api, Key], "api_key" → [api, key], "password" → [password]. + private static readonly Regex TokenBoundary = new( + "[A-Z]+(?![a-z])|[A-Z][a-z0-9]*|[a-z0-9]+", + RegexOptions.Compiled | RegexOptions.CultureInvariant, + TimeSpan.FromMilliseconds(100)); + + /// + /// Devuelve una copia de la metadata JSON con los valores de las claves sensibles redactados. + /// Entrada nula, en blanco o no-JSON se devuelve sin cambios (no falla): ambas vías reales + /// garantizan JSON válido, y una entrada sin estructura clave-valor no tiene claves que redactar. + /// + public static string? Sanitize(string? metadata) + { + if (string.IsNullOrWhiteSpace(metadata)) + { + return metadata; + } + + JsonNode? root; + try + { + root = JsonNode.Parse(metadata); + } + catch (JsonException) + { + // No es JSON: sin estructura clave-valor sobre la que redactar por clave. Se devuelve tal cual. + return metadata; + } + + if (root is null) + { + // Literal JSON "null": nada que redactar. + return metadata; + } + + var sanitized = SanitizeNode(root); + return sanitized?.ToJsonString(); + } + + /// + /// Indica si una clave debe considerarse sensible (y por tanto redactarse su valor). Público para + /// que la lista de patrones sea verificable de forma unitaria y fácil de razonar. + /// + public static bool IsSensitiveKey(string? key) + { + if (string.IsNullOrEmpty(key)) + { + return false; + } + + var lower = key.ToLowerInvariant(); + if (SubstringPatterns.Any(pattern => lower.Contains(pattern, StringComparison.Ordinal))) + { + return true; + } + + return TokenBoundary.Matches(key).Any(token => TokenPatterns.Contains(token.Value)); + } + + // Reconstruye el nodo redactado. No muta la entrada: los objetos/arreglos se rehacen desde cero y + // los escalares se clonan, de modo que ningún nodo cambia de padre. + private static JsonNode? SanitizeNode(JsonNode? node) + { + switch (node) + { + case JsonObject obj: + var sanitizedObject = new JsonObject(); + foreach (var property in obj) + { + sanitizedObject[property.Key] = IsSensitiveKey(property.Key) + ? RedactionPlaceholder + : SanitizeNode(property.Value); + } + + return sanitizedObject; + + case JsonArray array: + var sanitizedArray = new JsonArray(); + foreach (var item in array) + { + sanitizedArray.Add(SanitizeNode(item)); + } + + return sanitizedArray; + + default: + return node?.DeepClone(); + } + } +} diff --git a/src/apps/ums.api/Ums.Application/Common/Aop/AuditTrailAspect.cs b/src/apps/ums.api/Ums.Application/Common/Aop/AuditTrailAspect.cs index e103de01..d67ceda9 100644 --- a/src/apps/ums.api/Ums.Application/Common/Aop/AuditTrailAspect.cs +++ b/src/apps/ums.api/Ums.Application/Common/Aop/AuditTrailAspect.cs @@ -3,6 +3,11 @@ namespace Ums.Application.Common.Aop; public sealed class AuditTrailAspect : AbstractAspect { private static readonly Guid SystemActorId = Guid.Parse("00000000-0000-0000-0000-000000000001"); + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Security Hotspot", "S3011:Reflection should not be used to increase accessibility of classes, methods, or fields", + Justification = "ADR-UMS-099: reflexión sancionada para AOP transversal. DispatchProxy (ADR-UMS-060) es " + + "síncrono; resolver el wrapper genérico privado WrapAsyncOfT por reflexión es la única vía " + + "para preservar la async-correctness de Task.")] private static readonly MethodInfo WrapAsyncOfTMethod = typeof(AuditTrailAspect).GetMethod(nameof(WrapAsyncOfT), BindingFlags.Instance | BindingFlags.NonPublic)!; @@ -59,11 +64,11 @@ public override void Apply(IJoinPoint joinPoint) return; } - Capture(joinPoint, attribute, joinPoint.Return, null); + CaptureAsync(joinPoint, attribute, joinPoint.Return, null).GetAwaiter().GetResult(); } catch (Exception ex) { - Capture(joinPoint, attribute, null, ex); + CaptureAsync(joinPoint, attribute, null, ex).GetAwaiter().GetResult(); throw; } } @@ -81,11 +86,11 @@ private async Task WrapAsync(IJoinPoint joinPoint, Task task, AuditTrailAttribut try { await task.ConfigureAwait(false); - Capture(joinPoint, attribute, null, null); + await CaptureAsync(joinPoint, attribute, null, null).ConfigureAwait(false); } catch (Exception ex) { - Capture(joinPoint, attribute, null, ex); + await CaptureAsync(joinPoint, attribute, null, ex).ConfigureAwait(false); throw; } } @@ -95,17 +100,17 @@ private async Task WrapAsyncOfT(IJoinPoint joinPoint, Task tas try { var result = await ((Task)task).ConfigureAwait(false); - Capture(joinPoint, attribute, result, null); + await CaptureAsync(joinPoint, attribute, result, null).ConfigureAwait(false); return result; } catch (Exception ex) { - Capture(joinPoint, attribute, null, ex); + await CaptureAsync(joinPoint, attribute, null, ex).ConfigureAwait(false); throw; } } - private void Capture(IJoinPoint joinPoint, AuditTrailAttribute attribute, object? resultObject, Exception? exception) + private async Task CaptureAsync(IJoinPoint joinPoint, AuditTrailAttribute attribute, object? resultObject, Exception? exception) { var actorId = ResolveActorId(); var entityId = ResolveAffectedEntityId(joinPoint, resultObject); @@ -134,7 +139,10 @@ private void Capture(IJoinPoint joinPoint, AuditTrailAttribute attribute, object Exception = exception?.Message, }); - _auditTrailSink.TryWrite(new AuditTrailEntry( + // G-040 (FR-072): la metadata se desinfecta de forma centralizada en el sink + // (AuditTrailOutboxSink), único punto por el que pasan TODAS las emisiones automáticas de la + // traza (este aspecto y ConfigurationAuditService) antes de encolarse por el outbox. + await _auditTrailSink.PublishAsync(new AuditTrailEntry( actorId, subjectType, whatChanged, @@ -143,7 +151,7 @@ private void Capture(IJoinPoint joinPoint, AuditTrailAttribute attribute, object entityId, entityType, rootTenantId, - metadata)); + metadata)).ConfigureAwait(false); } private Guid ResolveActorId() diff --git a/src/apps/ums.api/Ums.Application/Common/Aop/AuthorizationAspect.cs b/src/apps/ums.api/Ums.Application/Common/Aop/AuthorizationAspect.cs index f7e59559..4be213e8 100644 --- a/src/apps/ums.api/Ums.Application/Common/Aop/AuthorizationAspect.cs +++ b/src/apps/ums.api/Ums.Application/Common/Aop/AuthorizationAspect.cs @@ -74,7 +74,16 @@ public override void Apply(IJoinPoint joinPoint) } else { - Console.WriteLine($"[AOP] Resource/Action code could not be inferred for {joinPoint.TargetType?.Name}"); + // Fail-closed (G-098): el atributo [AuthorizationAspect] está presente + // —intención explícita de proteger el handler— pero el permiso no pudo + // determinarse (el nombre no casa la convención Create/Update/Delete/ + // Get/List y no se declararon ResourceCode/ActionCode). Denegar por + // defecto en lugar de dejar pasar: un método marcado como protegido + // nunca debe ejecutarse sin control de acceso (evita escalada de + // privilegios). Espejo de la rama de denegación anterior. + Console.WriteLine($"[AOP] Access DENIED for {joinPoint.TargetType?.Name}: authorization required but permission could not be determined (fail-closed)"); + throw new UnauthorizedAccessException( + $"Access denied. Authorization is required for '{joinPoint.TargetType?.Name}' but the permission could not be determined."); } Proceed(joinPoint); diff --git a/src/apps/ums.api/Ums.Application/Common/Aop/IAuditTrailSink.cs b/src/apps/ums.api/Ums.Application/Common/Aop/IAuditTrailSink.cs index 5e35cce3..febc3c81 100644 --- a/src/apps/ums.api/Ums.Application/Common/Aop/IAuditTrailSink.cs +++ b/src/apps/ums.api/Ums.Application/Common/Aop/IAuditTrailSink.cs @@ -1,6 +1,16 @@ namespace Ums.Application.Common.Aop; +/// +/// Sumidero de la pista de auditoría automática (AUDIT-01..06, ADR-0016). +/// +/// G-040 (residual arquitectural): la vía automática ya no escribe a un canal en memoria con +/// descarte silencioso (el antiguo Channel.TryWrite devolvía false al saturarse y +/// se ignoraba). El registro se encola por el Transactional Outbox de MassTransit +/// cableado en UmsPlatformDbContext (UseBusOutbox()), con entrega POST-commit al +/// consumidor que lo persiste de forma append-only. Un fallo al encolar se registra como error +/// alertable; nunca se descarta en silencio. +/// public interface IAuditTrailSink { - bool TryWrite(AuditTrailEntry entry); + Task PublishAsync(AuditTrailEntry entry, CancellationToken cancellationToken = default); } diff --git a/src/apps/ums.api/Ums.Application/Common/Aop/TenantValidationAspect.cs b/src/apps/ums.api/Ums.Application/Common/Aop/TenantValidationAspect.cs index 1d30e3ec..0303c8a5 100644 --- a/src/apps/ums.api/Ums.Application/Common/Aop/TenantValidationAspect.cs +++ b/src/apps/ums.api/Ums.Application/Common/Aop/TenantValidationAspect.cs @@ -22,31 +22,53 @@ public override void Apply(IJoinPoint joinPoint) var attribute = GetAttribute(joinPoint); if (attribute is null) { + // El handler no está marcado con [TenantValidationAspect]: no hay + // intención de validar inquilino → nada que hacer. Proceed(joinPoint); return; } - var request = joinPoint.Arguments.FirstOrDefault(a => a is not System.Threading.CancellationToken); + var userTenantId = _userContext.TenantId; + + // Actor global / pre-inquilino (sin TenantId propio): excepción legítima y + // DOCUMENTADA (G-102). El signup/auto-registro previo al inquilino, la + // creación de inquilino (el operador global carece de TenantId) y la + // administración de plataforma dirigida a un inquilino operan legítimamente + // sin un inquilino propio: no existe frontera de inquilino que cruzar, así + // que se procede a propósito. NO es un fail-open: es la regla de dominio + // para el actor sin inquilino (distinguir admin-interno/pre-tenant del + // cruce indebido, tal como prescribe G-102). + if (string.IsNullOrWhiteSpace(userTenantId)) + { + Proceed(joinPoint); + return; + } + + // Llamante VINCULADO a un inquilino sobre un handler marcado explícitamente + // como tenant-scoped: la petición DEBE dirigirse a su propio inquilino. + // Si el inquilino objetivo no puede confirmarse igual al del usuario, + // DENEGAR (fail-closed, espejo de G-098): un handler marcado para validación + // de inquilino nunca debe ejecutarse sin que el inquilino quede confirmado. + // Esto cierra el soft fail-open previo (si no se podía determinar el + // inquilino de la petición, se dejaba pasar), vía real de cruce indebido. + var request = joinPoint.Arguments?.FirstOrDefault(a => a is not System.Threading.CancellationToken); + + string? requestTenantId = null; if (request is not null) { var requestType = request.GetType(); var tenantIdProperty = _tenantIdPropertyCache.GetOrAdd( requestType, type => type.GetProperty("TenantId", BindingFlags.Instance | BindingFlags.Public)); + requestTenantId = tenantIdProperty?.GetValue(request)?.ToString(); + } - if (tenantIdProperty is not null) - { - var requestTenantId = tenantIdProperty.GetValue(request)?.ToString(); - var userTenantId = _userContext.TenantId; - - // Validate if both are present and not equal - if (!string.IsNullOrWhiteSpace(requestTenantId) && - !string.IsNullOrWhiteSpace(userTenantId) && - !string.Equals(requestTenantId, userTenantId, StringComparison.OrdinalIgnoreCase)) - { - throw new UnauthorizedAccessException($"Tenant mismatch. User belongs to {userTenantId}, but request targets {requestTenantId}."); - } - } + if (string.IsNullOrWhiteSpace(requestTenantId) || + !string.Equals(requestTenantId, userTenantId, StringComparison.OrdinalIgnoreCase)) + { + throw new UnauthorizedAccessException( + $"Tenant validation failed. User belongs to tenant '{userTenantId}', " + + $"but the request tenant could not be confirmed to match (target: '{requestTenantId ?? ""}')."); } Proceed(joinPoint); diff --git a/src/apps/ums.api/Ums.Application/Common/Aop/TransactionAspect.cs b/src/apps/ums.api/Ums.Application/Common/Aop/TransactionAspect.cs index 3cc98f43..57294941 100644 --- a/src/apps/ums.api/Ums.Application/Common/Aop/TransactionAspect.cs +++ b/src/apps/ums.api/Ums.Application/Common/Aop/TransactionAspect.cs @@ -6,6 +6,11 @@ namespace Ums.Application.Common.Aop; public sealed class TransactionAspect : AbstractAspect { + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Security Hotspot", "S3011:Reflection should not be used to increase accessibility of classes, methods, or fields", + Justification = "ADR-UMS-099: reflexión sancionada para AOP transversal. DispatchProxy (ADR-UMS-060) es " + + "síncrono; resolver el wrapper genérico privado WrapAsyncOfT por reflexión es la única vía " + + "para preservar la async-correctness de Task.")] private static readonly MethodInfo WrapAsyncOfTMethod = typeof(TransactionAspect).GetMethod(nameof(WrapAsyncOfT), BindingFlags.Instance | BindingFlags.NonPublic)!; @@ -73,6 +78,14 @@ private void Proceed(IJoinPoint joinPoint) GetNext()!.Apply(joinPoint); } + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Minor Code Smell", "S2325:Methods and properties that don't access instance data should be static", + Justification = "Firma uniforme con WrapAsyncOfT, que se invoca por reflexión como método de " + + "instancia (BindingFlags.Instance). Se mantienen simétricos e instancia por diseño AOP.")] + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Major Code Smell", "S1172:Unused method parameters should be removed", + Justification = "'joinPoint' forma parte de la firma uniforme del wrapper AOP invocado por reflexión; " + + "se conserva para simetría con WrapAsyncOfT.")] private async Task WrapAsync(IJoinPoint joinPoint, Task task, ITransactionScope tx) { try @@ -91,6 +104,14 @@ private async Task WrapAsync(IJoinPoint joinPoint, Task task, ITransactionScope } } + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Minor Code Smell", "S2325:Methods and properties that don't access instance data should be static", + Justification = "Se invoca por reflexión como método de instancia (BindingFlags.Instance, Invoke(this,...)); " + + "volverla estática rompería la resolución del MethodInfo en el ctor.")] + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Major Code Smell", "S1172:Unused method parameters should be removed", + Justification = "'joinPoint' se pasa en el arreglo de argumentos de la invocación por reflexión; " + + "forma parte de la firma uniforme del wrapper AOP.")] private async Task WrapAsyncOfT(IJoinPoint joinPoint, Task task, ITransactionScope tx) { try diff --git a/src/apps/ums.api/Ums.Application/Common/DomainEnumerationParser.cs b/src/apps/ums.api/Ums.Application/Common/DomainEnumerationParser.cs index b7bd9d1e..e0193e25 100644 --- a/src/apps/ums.api/Ums.Application/Common/DomainEnumerationParser.cs +++ b/src/apps/ums.api/Ums.Application/Common/DomainEnumerationParser.cs @@ -16,4 +16,20 @@ internal static class DomainEnumerationParser .GetAll() .FirstOrDefault(value => string.Equals(value.Name, name.Trim(), StringComparison.OrdinalIgnoreCase)); } + + /// + /// Nombres que declara el enumerado de dominio, ordenados por su identificador. + /// + /// Existe para que los validadores y los mensajes de error DERIVEN del dominio en vez de + /// copiarlo a mano: una lista copiada envejece en silencio y acaba negando lo que el dominio + /// sí admite —G-192, donde ExclusiveArcTarget incorporó Aggregate y Entity + /// y la copia de la capa de aplicación se quedó en los cuatro destinos de navegación—. + /// + public static IReadOnlyList NamesOf() + where T : DomainEnumeration + => DomainEnumeration + .GetAll() + .OrderBy(value => value.Id) + .Select(value => value.Name) + .ToArray(); } diff --git a/src/apps/ums.api/Ums.Application/Common/Interfaces/IFunctionalTransaction.cs b/src/apps/ums.api/Ums.Application/Common/Interfaces/IFunctionalTransaction.cs new file mode 100644 index 00000000..babe8f0c --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Common/Interfaces/IFunctionalTransaction.cs @@ -0,0 +1,110 @@ +namespace Ums.Application.Common.Interfaces; + +/// +/// Estados del ciclo de vida de una transacción funcional (ADR-0095, adoptado por +/// ADR-0096 §2.3). El desenlace siempre cae en uno de estos estados; una +/// transacción sin desenlace es un defecto alertable. +/// +public enum TransactionState +{ + /// La transacción se abrió (apertura de la narrativa). + Started, + + /// Hay etapas en curso. + InProgress, + + /// A la espera de un recurso o intervención externa. + Waiting, + + /// Reintentando una etapa fallida de forma controlada. + Retrying, + + /// Desenlace exitoso: todas las etapas se completaron. + Completed, + + /// Desenlace parcial: unas etapas se completaron y otras no. + PartiallyCompleted, + + /// Desenlace fallido. + Failed, + + /// La transacción se canceló (p. ej. el cliente abortó la petición). + Cancelled, + + /// La transacción excedió su tiempo límite. + TimedOut, +} + +/// +/// Reversibilidad de un efecto sobre el mundo externo (ADR-0096 §2.4). +/// +public enum EffectReversibility +{ + /// El efecto ya fue compensado. + Compensated, + + /// El efecto está pendiente de compensación. + PendingCompensation, + + /// El efecto es irreversible. + Irreversible, +} + +/// +/// Puerto de la transacción funcional (ADR-0096, contrato funcional de trazabilidad; +/// decidido para UMS en ADR-UMS-085). Modela el proceso como un relato: +/// apertura → etapas → decisiones (el porqué) → efectos → desenlace siempre. +/// +/// El modelo vive en un middleware que abre y cierra la transacción (garantizando el +/// desenlace por construcción) y emite la narrativa como logs estructurados enriquecidos +/// con atributos beyondnet.transaction.* / beyondnet.stage / beyondnet.effect.* +/// sobre Serilog + OpenTelemetry. La capa de aplicación usa este puerto para registrar +/// las etapas, decisiones y efectos con significado de negocio. +/// +public interface IFunctionalTransaction +{ + /// + /// Localizador legible TX-AAAA-NNNNNN (ADR-UMS-084) si ya se acuñó; null + /// si la transacción aún se identifica solo por su traceId W3C. Se acuña de forma + /// perezosa: siempre en operaciones que mutan estado y ante cualquier fallo. + /// + string? Locator { get; } + + /// Nombre funcional de la transacción (acción del actor). + string Name { get; } + + /// Actor que inició la transacción (usuario autenticado o iniciador anónimo). + string? Actor { get; } + + /// Estado actual de la transacción. + TransactionState State { get; } + + /// + /// Devuelve el localizador legible, acuñándolo si aún no existe. Es idempotente dentro + /// de la misma transacción: llamadas sucesivas devuelven el mismo valor. + /// + Task GetOrMintLocatorAsync(CancellationToken cancellationToken = default); + + /// + /// Registra una etapa con significado de negocio (ADR-0096 §2.3). Si + /// es true, la etapa se contabiliza como fallida y el + /// desenlace podrá resolverse como parcial o fallido. + /// + void RecordStage(string stage, string? detail = null, bool failed = false); + + /// Registra una decisión y su porqué (ADR-0096 §2.3). + void RecordDecision(string decision, string reason); + + /// + /// Registra un efecto sobre el mundo externo con su reversibilidad + /// (ADR-0096 §2.4). Emite los atributos beyondnet.effect.type/target/ref. + /// + void RecordEffect( + string effectType, + string target, + string? reference, + EffectReversibility reversibility); + + /// Marca explícitamente el estado (p. ej. WAITING, RETRYING). + void MarkState(TransactionState state); +} diff --git a/src/apps/ums.api/Ums.Application/Common/Interfaces/IIntegrationEventPublisher.cs b/src/apps/ums.api/Ums.Application/Common/Interfaces/IIntegrationEventPublisher.cs new file mode 100644 index 00000000..fe2929ad --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Common/Interfaces/IIntegrationEventPublisher.cs @@ -0,0 +1,15 @@ +using Ums.Domain.Events; + +namespace Ums.Application.Common.Interfaces; + +/// +/// ADR-0098 D7: puerto para publicar eventos de INTEGRACIÓN explícitos hacia el bróker +/// inter-sistema, a través del Transactional Outbox. Es la ÚNICA vía por la que un mensaje sale del +/// proceso hacia el transporte. Los eventos de dominio se manejan en proceso (MediatR, post-commit) +/// y nunca usan este puerto: publicarlos crudos convertiría el modelo interno en contrato público +/// y congelaría el dominio (D7/D9.6). +/// +public interface IIntegrationEventPublisher +{ + Task PublishAsync(IIntegrationEvent integrationEvent, CancellationToken cancellationToken = default); +} diff --git a/src/apps/ums.api/Ums.Application/Common/Interfaces/ILimitadorDePeticiones.cs b/src/apps/ums.api/Ums.Application/Common/Interfaces/ILimitadorDePeticiones.cs new file mode 100644 index 00000000..195d72dd --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Common/Interfaces/ILimitadorDePeticiones.cs @@ -0,0 +1,30 @@ +namespace Ums.Application.Common.Interfaces; + +/// +/// Resultado de contar una petición contra su cupo. +/// +/// Falso cuando la petición excede el cupo de la ventana en curso. +/// Peticiones contadas en la ventana, incluida esta. +/// Lo que falta para que la ventana se renueve. Viaja en `Retry-After`. +public readonly record struct ResultadoDelLimite(bool Permitida, long Consumidas, TimeSpan EsperaSugerida); + +/// +/// Cuenta peticiones por clave dentro de una ventana fija (G-248). +/// +/// POR QUÉ EXISTE. El limitador de ASP.NET es un PartitionedRateLimiter en proceso: +/// cada réplica cuenta las suyas sin saber de las demás, así que con N réplicas el cupo efectivo +/// es N veces el declarado. Con una réplica no se notaba; al escalar, la protección se diluye justo +/// cuando más hace falta, y en silencio. +/// +/// La ventana es FIJA y no deslizante, igual que la que sustituye. Una deslizante es más +/// justa en el borde —con la fija, un cliente puede gastar el cupo al final de una ventana y otro +/// tanto al principio de la siguiente—, pero exige guardar cada marca de tiempo por clave en vez de +/// un contador. El comportamiento anterior era de ventana fija: cambiarlo aquí mezclaría dos +/// cambios en uno y haría imposible saber a cuál atribuir una diferencia de medición. +/// +public interface ILimitadorDePeticiones +{ + /// Cuenta una petición de y dice si cabe en el cupo. + Task RegistrarAsync( + string clave, int cupo, TimeSpan ventana, CancellationToken ct = default); +} diff --git a/src/apps/ums.api/Ums.Application/Common/Interfaces/ISessionRevocationStore.cs b/src/apps/ums.api/Ums.Application/Common/Interfaces/ISessionRevocationStore.cs new file mode 100644 index 00000000..464e67ba --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Common/Interfaces/ISessionRevocationStore.cs @@ -0,0 +1,35 @@ +namespace Ums.Application.Common.Interfaces; + +/// +/// Lista de sesiones cerradas, por SESIÓN y no por usuario (G-247). +/// +/// POR QUÉ EXISTE. Cerrar sesión no cerraba nada: SignOutAsync solo le pide al +/// navegador que borre la cookie, y el portador seguía siendo criptográficamente válido hasta +/// caducar. Medido en vivo: tras el logout, la misma cookie seguía devolviendo 200 — también contra +/// el mismo pod, así que no era un problema de réplicas. Quien tuviera una copia —una máquina +/// compartida, una captura de red— conservaba el acceso. +/// +/// POR QUÉ NO SE REUSÓ . Ese almacén revoca por USUARIO y +/// ventana de tiempo: sirve para «esta cuenta queda fuera» (bloqueo, borrado, cambio de +/// contraseña), y usarlo en el logout cerraría también la sesión del móvil y la del portátil del +/// mismo usuario. La decisión de producto fue la contraria: cerrar sesión cierra solo este +/// dispositivo. Son dos preguntas distintas —«¿está vetada la cuenta?» y «¿sigue viva esta +/// sesión?»— y mezclarlas en una clave obligaría a elegir una de las dos semánticas. +/// +/// La implementación va sobre IDistributedCache, que es Redis cuando está configurado: +/// una sesión cerrada en un pod queda cerrada en todos, que es lo que exige más de una réplica. +/// +public interface ISessionRevocationStore +{ + /// + /// Cierra la sesión indicada hasta . + /// + /// El plazo no es decorativo: una entrada eterna haría crecer la lista sin límite, y una + /// demasiado corta reabriría la sesión antes de que el portador caduque. Se pasa el instante en + /// que el portador deja de ser válido por sí mismo — a partir de ahí, recordarlo no aporta. + /// + Task RevocarAsync(string sessionId, DateTime cerrarHastaUtc, CancellationToken ct = default); + + /// ¿Está cerrada esta sesión? Se consulta en cada petición autenticada. + Task EstaRevocadaAsync(string sessionId, CancellationToken ct = default); +} diff --git a/src/apps/ums.api/Ums.Application/Common/Interfaces/ITransactionLocatorFactory.cs b/src/apps/ums.api/Ums.Application/Common/Interfaces/ITransactionLocatorFactory.cs new file mode 100644 index 00000000..52532c73 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Common/Interfaces/ITransactionLocatorFactory.cs @@ -0,0 +1,16 @@ +namespace Ums.Application.Common.Interfaces; + +/// +/// Acuña el localizador legible de una transacción funcional en el formato +/// TX-AAAA-NNNNNN (ADR-0096 §2.2; decisión de implementación en ADR-UMS-084: +/// secuencia PostgreSQL por año). El localizador es comunicable por voz o chat y es lo +/// que el usuario pega en el ticket de soporte cuando algo falla. +/// +public interface ITransactionLocatorFactory +{ + /// + /// Devuelve el siguiente localizador del año en curso. Cada llamada consume un valor + /// de la secuencia (escritura barata, monotónica y no transaccional). + /// + Task NextAsync(CancellationToken cancellationToken = default); +} diff --git a/src/apps/ums.api/Ums.Application/Common/Interfaces/IUnitOfWorkScope.cs b/src/apps/ums.api/Ums.Application/Common/Interfaces/IUnitOfWorkScope.cs index 10a6bf1e..a41f0a58 100644 --- a/src/apps/ums.api/Ums.Application/Common/Interfaces/IUnitOfWorkScope.cs +++ b/src/apps/ums.api/Ums.Application/Common/Interfaces/IUnitOfWorkScope.cs @@ -21,6 +21,15 @@ public interface IUnitOfWorkScope { /// Abre una nueva transacción de base de datos. Task BeginAsync(CancellationToken cancellationToken = default); + + /// + /// G-117: ejecuta dentro de una transacción gestionada por la + /// ExecutionStrategy del proveedor. Necesario cuando EnableRetryOnFailure está + /// activo (NpgsqlRetryingExecutionStrategy): una transacción iniciada por el usuario vía + /// lanza «does not support user-initiated transactions». La estrategia + /// hace begin+commit y reintenta el bloque completo ante fallos transitorios (con rollback previo). + /// + Task ExecuteInTransactionAsync(Func operation, CancellationToken cancellationToken = default); } /// Representa una transacción abierta; dispone (rollback) si no se hace Commit. diff --git a/src/apps/ums.api/Ums.Application/Common/Notifications/NotificationTemplates.cs b/src/apps/ums.api/Ums.Application/Common/Notifications/NotificationTemplates.cs index 7eb59ca3..eb493744 100644 --- a/src/apps/ums.api/Ums.Application/Common/Notifications/NotificationTemplates.cs +++ b/src/apps/ums.api/Ums.Application/Common/Notifications/NotificationTemplates.cs @@ -2,6 +2,61 @@ namespace Ums.Application.Common.Notifications; public static class NotificationTemplates { + /// + /// Solicitud anónima de restablecimiento. El secreto viaja SOLO por aquí —el buzón— porque + /// es justo lo que prueba la posesión de la cuenta; la respuesta HTTP no lo lleva. La cuenta + /// conserva su contraseña actual hasta que este token se canjea, de modo que una solicitud + /// ajena molesta pero no deja a nadie fuera. + /// + public static UmsNotification PasswordResetRequested( + string recipient, + string recipientName, + string resetToken, + int expiresInMinutes) => + new( + Recipient: recipient, + Subject: "Restablecimiento de Contraseña — UMS", + Body: $""" + Hola {recipientName}, + + Recibimos una solicitud para restablecer la contraseña de su cuenta. + + Código de restablecimiento: + + {resetToken} + + Ingréselo en la pantalla de recuperación del portal para definir su nueva contraseña. + El código vence en {expiresInMinutes} minutos y solo puede usarse una vez. + + Su contraseña actual sigue vigente: si no solicitó este cambio, ignore este mensaje + y no será necesario hacer nada. Si recibe estos avisos con frecuencia, avise a su + administrador. + + — Equipo UMS + """, + RecipientName: recipientName + ); + + /// + /// Aviso posterior al canje. Es la única señal que recibe el titular legítimo si alguien + /// llegó a canjear un token con su buzón comprometido. + /// + public static UmsNotification PasswordChanged(string recipient, string recipientName) => + new( + Recipient: recipient, + Subject: "Su contraseña fue actualizada — UMS", + Body: $""" + Hola {recipientName}, + + La contraseña de su cuenta acaba de ser actualizada y sus sesiones activas se cerraron. + + Si no fue usted, contacte a su administrador de inmediato. + + — Equipo UMS + """, + RecipientName: recipientName + ); + public static UmsNotification PasswordReset(string recipient, string recipientName, string temporaryPassword) => new( Recipient: recipient, @@ -93,7 +148,7 @@ La solicitud de registro de {companyName} en la plataforma UMS fue recibida. Nuestro equipo la revisará y se pondrá en contacto a la brevedad para completar el proceso de onboarding. - — Equipo BeyondNet Code + — Equipo BeyondNet """ ); @@ -198,7 +253,7 @@ Su organización {companyName} fue incorporada exitosamente a la plataforma UMS. Por seguridad, cambie la contraseña en su primer inicio de sesión. - — Equipo BeyondNet Code + — Equipo BeyondNet """ ); } diff --git a/src/apps/ums.api/Ums.Application/Common/Services/TenantScopePolicy.cs b/src/apps/ums.api/Ums.Application/Common/Services/TenantScopePolicy.cs index 8a6a408f..3cef07a1 100644 --- a/src/apps/ums.api/Ums.Application/Common/Services/TenantScopePolicy.cs +++ b/src/apps/ums.api/Ums.Application/Common/Services/TenantScopePolicy.cs @@ -25,9 +25,12 @@ public TenantScopePolicy( public Guid? ResolveQueryScope() { + // Internal admins have cross-tenant visibility: no scope filter (null = all tenants). + // When an internal admin narrows to a specific tenant, the request no longer carries + // the internal-admin flag, so it falls through to the scoped branches below. if (_tenantContext.IsInternalAdmin) { - return _tenantContext.OrganizationId; + return null; } if (_tenantContext.OrganizationId.HasValue) @@ -46,6 +49,25 @@ public async Task EnsureManagementOwnerScopeAsync(Guid targetTenantId, C return Result.Failure("AUTH_013: Tenant context is required for management access."); } + // ADR-0077 aclarado (evolith-core#18): el flag IsManagementOwner autoriza al OPERADOR + // que actúa, no al inquilino OBJETIVO de la acción. Un internal-admin (operador de + // gestión) puede ejecutar comandos mutantes sobre cualquier inquilino gestionado + // (on-behalf). Se re-apunta el scope del request al inquilino objetivo para que las + // lecturas de repositorio dentro de este comando resuelvan los datos del objetivo + // (los usuarios/roles cliente están filtrados por OrganizationId y no hacen bypass + // para el internal-admin). La escritura queda auditada por el aspecto [AuditTrail]. + if (_tenantContext.IsInternalAdmin) + { + var targetTenant = await _tenantRepository.GetByIdAsync(targetTenantId, cancellationToken); + if (targetTenant is null) + { + return Result.Failure("AUTH_002: Tenant not found."); + } + + _tenantContext.SetOrganizationId(targetTenantId); + return Result.Success(); + } + if (currentTenantId.Value != targetTenantId) { return Result.Failure( diff --git a/src/apps/ums.api/Ums.Application/Configuration/AppConfiguration/Commands/CreateAppConfigurationCommandHandler.cs b/src/apps/ums.api/Ums.Application/Configuration/AppConfiguration/Commands/CreateAppConfigurationCommandHandler.cs index a40d2f09..fc19bcd3 100644 --- a/src/apps/ums.api/Ums.Application/Configuration/AppConfiguration/Commands/CreateAppConfigurationCommandHandler.cs +++ b/src/apps/ums.api/Ums.Application/Configuration/AppConfiguration/Commands/CreateAppConfigurationCommandHandler.cs @@ -38,7 +38,8 @@ public async Task> Handle(CreateAppConfig code.GetValue(), cancellationToken); - if (existing is not null) + // CFG-06: an Archived (scope, code) must not block creating a new configuration. + if (existing is not null && existing.Props.Status.Id != ConfigStatus.Archived.Id) { return Result.Failure("App configuration code already exists for the selected scope."); } diff --git a/src/apps/ums.api/Ums.Application/Configuration/AppConfiguration/Commands/DeleteAppConfigurationCommand.cs b/src/apps/ums.api/Ums.Application/Configuration/AppConfiguration/Commands/DeleteAppConfigurationCommand.cs new file mode 100644 index 00000000..141f4ea0 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Configuration/AppConfiguration/Commands/DeleteAppConfigurationCommand.cs @@ -0,0 +1,5 @@ +namespace Ums.Application.Configuration.AppConfiguration.Commands; + +// G-143: borrado duro de una configuración de aplicación (DELETE /app-configurations/{id}). +// A diferencia de Archive (que sólo cambia el estado), este comando ELIMINA la fila. +public sealed record DeleteAppConfigurationCommand(Guid AppConfigurationId) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/Configuration/AppConfiguration/Commands/DeleteAppConfigurationCommandHandler.cs b/src/apps/ums.api/Ums.Application/Configuration/AppConfiguration/Commands/DeleteAppConfigurationCommandHandler.cs new file mode 100644 index 00000000..69b15a9b --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Configuration/AppConfiguration/Commands/DeleteAppConfigurationCommandHandler.cs @@ -0,0 +1,60 @@ + +namespace Ums.Application.Configuration.AppConfiguration.Commands; + +using Ums.Application.Configuration.Services; +using Ums.Domain.Configuration; + +public sealed class DeleteAppConfigurationCommandHandler : ICommandHandler +{ + private readonly IAppConfigurationRepository _repository; + private readonly IUserContext _userContext; + private readonly IConfigurationProvider _configProvider; + + public DeleteAppConfigurationCommandHandler( + IAppConfigurationRepository repository, + IUserContext userContext, + IConfigurationProvider configProvider) + { + _repository = repository; + _userContext = userContext; + _configProvider = configProvider; + } + + [AuditTrail] + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task Handle(DeleteAppConfigurationCommand request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(_userContext.UserId)) + { + return Result.Failure("Authenticated user is required."); + } + + // Las lecturas del repositorio ya ocultan lo eliminado: si la configuración ya se borró, + // esto devuelve null y el DELETE responde «no encontrada» (mismo 404 que un id inexistente). + var appConfiguration = await _repository.GetByIdAsync(request.AppConfigurationId, cancellationToken); + if (appConfiguration is null) + { + return Result.Failure("App configuration was not found."); + } + + // Borrado LÓGICO: la fila NO se elimina, pasa al estado terminal Deleted. AppConfiguration es + // un agregado hoja (sin valores hijos), así que no hay dependientes que exigir eliminados antes. + var result = appConfiguration.Delete(ActorId.Create(_userContext.UserId)); + if (result.IsFailure) + { + return result; + } + + await _repository.UpdateAsync(appConfiguration, cancellationToken); + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + // La configuración deja de resolver aunque su fila siga ahí: la caché tiene que enterarse igual. + var tenantId = appConfiguration.Props.TenantId?.GetValue(); + if (tenantId.HasValue) + await _configProvider.ReloadTenantAsync(tenantId.Value, cancellationToken); + else + await _configProvider.ReloadAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/src/apps/ums.api/Ums.Application/Configuration/IdpConfiguration/Commands/CreateIdpConfigurationCommandHandler.cs b/src/apps/ums.api/Ums.Application/Configuration/IdpConfiguration/Commands/CreateIdpConfigurationCommandHandler.cs index cec152da..32bb5b1a 100644 --- a/src/apps/ums.api/Ums.Application/Configuration/IdpConfiguration/Commands/CreateIdpConfigurationCommandHandler.cs +++ b/src/apps/ums.api/Ums.Application/Configuration/IdpConfiguration/Commands/CreateIdpConfigurationCommandHandler.cs @@ -5,6 +5,9 @@ namespace Ums.Application.Configuration.IdpConfiguration.Commands; using Ums.Domain.Configuration; using Ums.Domain.Enums; +using Ums.Application.Common.Aop; + +[AuthorizationAspect(ResourceCode = "idpconfiguration", ActionCode = "create")] public sealed class CreateIdpConfigurationCommandHandler : ICommandHandler { private readonly IIdpConfigurationRepository _repository; @@ -31,6 +34,19 @@ public async Task> Handle(CreateIdpConfig return Result.Failure("Invalid identity provider type."); } + // Integridad referencial: un FallbackToId debe apuntar a una IdpConfiguration existente + // del mismo TenantId/SystemSuiteId; de lo contrario se rechaza la referencia colgante. + if (request.FallbackToId is Guid fallbackToId) + { + var fallback = await _repository.GetByIdAsync(fallbackToId, cancellationToken); + if (fallback is null + || fallback.Props.TenantId.GetValue() != request.TenantId + || fallback.Props.SystemSuiteId.GetValue() != request.SystemSuiteId) + { + return Result.Failure(DomainErrors.Configuration.IdpFallbackNotFound); + } + } + var result = Ums.Domain.Configuration.IdpConfiguration.IdpConfiguration.Create( TenantId.Load(request.TenantId), SystemSuiteId.Load(request.SystemSuiteId), diff --git a/src/apps/ums.api/Ums.Application/Configuration/Parameter/Commands/ParameterDefinitionCommands.cs b/src/apps/ums.api/Ums.Application/Configuration/Parameter/Commands/ParameterDefinitionCommands.cs index 2a6ab59d..4c97b552 100644 --- a/src/apps/ums.api/Ums.Application/Configuration/Parameter/Commands/ParameterDefinitionCommands.cs +++ b/src/apps/ums.api/Ums.Application/Configuration/Parameter/Commands/ParameterDefinitionCommands.cs @@ -23,12 +23,12 @@ public sealed class CreateParameterDefinitionCommandHandler( { [AuditTrail] [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] - public async Task> Handle(CreateParameterDefinitionCommand cmd, CancellationToken ct) + public async Task> Handle(CreateParameterDefinitionCommand cmd, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(userContext.UserId)) return Result.Failure("Authenticated user is required."); - var count = await repo.CountByCodeAsync(cmd.Code.ToUpperInvariant(), ct); + var count = await repo.CountByCodeAsync(cmd.Code.ToUpperInvariant(), cancellationToken); if (count > 0) return Result.Failure(DomainErrors.Configuration.ParameterCodeNotUnique); @@ -46,8 +46,8 @@ public async Task> Handle(CreateParameterDefinitionCommand cmd, Can if (result.IsFailure) return Result.Failure(result.Error); - await repo.AddAsync(result.Value, ct); - await repo.SaveChangesAsync(ct); + await repo.AddAsync(result.Value, cancellationToken); + await repo.SaveChangesAsync(cancellationToken); return Result.Success(result.Value.Props.Id.GetValue()); } } @@ -71,12 +71,12 @@ public sealed class UpdateParameterDefinitionCommandHandler( { [AuditTrail] [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] - public async Task Handle(UpdateParameterDefinitionCommand cmd, CancellationToken ct) + public async Task Handle(UpdateParameterDefinitionCommand cmd, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(userContext.UserId)) return Result.Failure("Authenticated user is required."); - var definition = await repo.GetByIdAsync(cmd.Id, ct); + var definition = await repo.GetByIdAsync(cmd.Id, cancellationToken); if (definition is null) return Result.Failure(DomainErrors.Common.NotFound); var result = definition.Update( @@ -89,8 +89,8 @@ public async Task Handle(UpdateParameterDefinitionCommand cmd, Cancellat if (result.IsFailure) return result; - await repo.UpdateAsync(definition, ct); - await repo.SaveChangesAsync(ct); + await repo.UpdateAsync(definition, cancellationToken); + await repo.SaveChangesAsync(cancellationToken); return Result.Success(); } } @@ -106,22 +106,74 @@ public sealed class ArchiveParameterDefinitionCommandHandler( { [AuditTrail] [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] - public async Task Handle(ArchiveParameterDefinitionCommand cmd, CancellationToken ct) + public async Task Handle(ArchiveParameterDefinitionCommand cmd, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(userContext.UserId)) return Result.Failure("Authenticated user is required."); - var definition = await repo.GetByIdAsync(cmd.Id, ct); + var definition = await repo.GetByIdAsync(cmd.Id, cancellationToken); if (definition is null) return Result.Failure(DomainErrors.Common.NotFound); - var globalCount = await repo.CountGlobalValuesAsync(cmd.Id, ct); - var tenantCount = await repo.CountTenantValuesAsync(cmd.Id, ct); + // Solo bloquean los dependientes VIVOS; uno ya eliminado lógicamente dejó de ser referencia. + var globalCount = await repo.CountLiveGlobalValuesAsync(cmd.Id, cancellationToken); + var tenantCount = await repo.CountLiveTenantValuesAsync(cmd.Id, cancellationToken); var result = definition.Archive(ActorId.Create(userContext.UserId), globalCount, tenantCount); if (result.IsFailure) return result; - await repo.UpdateAsync(definition, ct); - await repo.SaveChangesAsync(ct); + await repo.UpdateAsync(definition, cancellationToken); + await repo.SaveChangesAsync(cancellationToken); + return Result.Success(); + } +} + +// ── Delete (borrado LÓGICO) ─────────────────────────────────────────────────── +// El frontend invoca DELETE /parameter-definitions/{id} y el contrato HTTP no cambia (204 en +// éxito, 409 con dependientes, 404 si no existe). Lo que cambia es que la fila NO se borra: se +// marca como eliminada y desaparece de las lecturas. Motivo: sobre esta configuración se hacen +// consultas históricas, y una fila borrada de verdad se pierde para siempre — una definición +// retirada hace un año tiene que seguir explicando por qué el sistema se comportó como se comportó. +// +// Regla transaccional (análoga al RESTRICT de una FK): no se elimina lógicamente algo con +// referencias REALES vivas. Los valores globales/de inquilino ya eliminados lógicamente NO cuentan. + +public sealed record DeleteParameterDefinitionCommand(Guid Id) : ICommand; + +public sealed class DeleteParameterDefinitionCommandHandler( + IParameterDefinitionRepository repo, + IUserContext userContext) + : ICommandHandler +{ + [AuditTrail] + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task Handle(DeleteParameterDefinitionCommand cmd, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(userContext.UserId)) + return Result.Failure("Authenticated user is required."); + + // GetByIdAsync ya no devuelve definiciones eliminadas → un segundo DELETE responde 404. + var definition = await repo.GetByIdAsync(cmd.Id, cancellationToken); + if (definition is null) return Result.Failure(DomainErrors.Common.NotFound); + + var globalCount = await repo.CountLiveGlobalValuesAsync(cmd.Id, cancellationToken); + var tenantCount = await repo.CountLiveTenantValuesAsync(cmd.Id, cancellationToken); + + if (globalCount > 0 || tenantCount > 0) + { + // 409 con el detalle de qué bloquea: el actor debe eliminar antes esos valores. + var deps = new List(); + if (globalCount > 0) deps.Add(new BlockingDependency("ParameterGlobalValue", "Active", globalCount)); + if (tenantCount > 0) deps.Add(new BlockingDependency("ParameterTenantValue", "Active", tenantCount)); + return Result.Failure( + BlockedOperationError.Encode(DomainErrors.Configuration.ParameterHasActiveValues, deps)); + } + + // La guarda se repite dentro del agregado: la invariante vive en el dominio, no en el handler. + var result = definition.Delete(ActorId.Create(userContext.UserId), globalCount, tenantCount); + if (result.IsFailure) return result; + + await repo.UpdateAsync(definition, cancellationToken); + await repo.SaveChangesAsync(cancellationToken); return Result.Success(); } } diff --git a/src/apps/ums.api/Ums.Application/Configuration/Parameter/Commands/ParameterValueCommands.cs b/src/apps/ums.api/Ums.Application/Configuration/Parameter/Commands/ParameterValueCommands.cs index 87408d9c..46a5aaa5 100644 --- a/src/apps/ums.api/Ums.Application/Configuration/Parameter/Commands/ParameterValueCommands.cs +++ b/src/apps/ums.api/Ums.Application/Configuration/Parameter/Commands/ParameterValueCommands.cs @@ -18,15 +18,15 @@ public sealed class CreateParameterGlobalValueCommandHandler( { [AuditTrail] [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] - public async Task> Handle(CreateParameterGlobalValueCommand cmd, CancellationToken ct) + public async Task> Handle(CreateParameterGlobalValueCommand cmd, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(userContext.UserId)) return Result.Failure("Authenticated user is required."); - var definition = await definitionRepo.GetByIdAsync(cmd.DefinitionId, ct); + var definition = await definitionRepo.GetByIdAsync(cmd.DefinitionId, cancellationToken); if (definition is null) return Result.Failure(DomainErrors.Common.NotFound); - var existing = await valueRepo.GetByDefinitionIdAsync(cmd.DefinitionId, ct); + var existing = await valueRepo.GetByDefinitionIdAsync(cmd.DefinitionId, cancellationToken); if (existing is not null) return Result.Failure("A global value already exists for this parameter. Use Update instead."); @@ -38,8 +38,8 @@ public async Task> Handle(CreateParameterGlobalValueCommand cmd, Ca if (result.IsFailure) return Result.Failure(result.Error); - await valueRepo.AddAsync(result.Value, ct); - await valueRepo.SaveChangesAsync(ct); + await valueRepo.AddAsync(result.Value, cancellationToken); + await valueRepo.SaveChangesAsync(cancellationToken); return Result.Success(result.Value.Props.Id.GetValue()); } } @@ -56,22 +56,22 @@ public sealed class UpdateParameterGlobalValueCommandHandler( { [AuditTrail] [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] - public async Task Handle(UpdateParameterGlobalValueCommand cmd, CancellationToken ct) + public async Task Handle(UpdateParameterGlobalValueCommand cmd, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(userContext.UserId)) return Result.Failure("Authenticated user is required."); - var value = await valueRepo.GetByIdAsync(cmd.Id, ct); + var value = await valueRepo.GetByIdAsync(cmd.Id, cancellationToken); if (value is null) return Result.Failure(DomainErrors.Common.NotFound); - var definition = await definitionRepo.GetByIdAsync(value.ParameterDefinitionId.GetValue(), ct); + var definition = await definitionRepo.GetByIdAsync(value.ParameterDefinitionId.GetValue(), cancellationToken); var dataType = definition?.DataType ?? ParameterDataType.String; var result = value.UpdateValue(EffectiveValue.Create(cmd.Value), dataType, ActorId.Create(userContext.UserId)); if (result.IsFailure) return result; - await valueRepo.UpdateAsync(value, ct); - await valueRepo.SaveChangesAsync(ct); + await valueRepo.UpdateAsync(value, cancellationToken); + await valueRepo.SaveChangesAsync(cancellationToken); return Result.Success(); } } @@ -87,19 +87,19 @@ public sealed class PublishParameterGlobalValueCommandHandler( { [AuditTrail] [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] - public async Task Handle(PublishParameterGlobalValueCommand cmd, CancellationToken ct) + public async Task Handle(PublishParameterGlobalValueCommand cmd, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(userContext.UserId)) return Result.Failure("Authenticated user is required."); - var value = await valueRepo.GetByIdAsync(cmd.Id, ct); + var value = await valueRepo.GetByIdAsync(cmd.Id, cancellationToken); if (value is null) return Result.Failure(DomainErrors.Common.NotFound); var result = value.Publish(ActorId.Create(userContext.UserId)); if (result.IsFailure) return result; - await valueRepo.UpdateAsync(value, ct); - await valueRepo.SaveChangesAsync(ct); + await valueRepo.UpdateAsync(value, cancellationToken); + await valueRepo.SaveChangesAsync(cancellationToken); return Result.Success(); } } @@ -115,19 +115,50 @@ public sealed class ArchiveParameterGlobalValueCommandHandler( { [AuditTrail] [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] - public async Task Handle(ArchiveParameterGlobalValueCommand cmd, CancellationToken ct) + public async Task Handle(ArchiveParameterGlobalValueCommand cmd, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(userContext.UserId)) return Result.Failure("Authenticated user is required."); - var value = await valueRepo.GetByIdAsync(cmd.Id, ct); + var value = await valueRepo.GetByIdAsync(cmd.Id, cancellationToken); if (value is null) return Result.Failure(DomainErrors.Common.NotFound); var result = value.Archive(ActorId.Create(userContext.UserId)); if (result.IsFailure) return result; - await valueRepo.UpdateAsync(value, ct); - await valueRepo.SaveChangesAsync(ct); + await valueRepo.UpdateAsync(value, cancellationToken); + await valueRepo.SaveChangesAsync(cancellationToken); + return Result.Success(); + } +} + +// ── ParameterGlobalValue: Delete (borrado LÓGICO) ──────────────────────────── +// Sin esta ruta la regla transaccional del borrado de definiciones sería un callejón sin salida: +// exige eliminar antes los dependientes, y no habría forma de eliminarlos. Archivar no basta —una +// fila archivada sigue siendo una referencia real—, así que hace falta el estado terminal Deleted. + +public sealed record DeleteParameterGlobalValueCommand(Guid Id) : ICommand; + +public sealed class DeleteParameterGlobalValueCommandHandler( + IParameterGlobalValueRepository valueRepo, + IUserContext userContext) + : ICommandHandler +{ + [AuditTrail] + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task Handle(DeleteParameterGlobalValueCommand cmd, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(userContext.UserId)) + return Result.Failure("Authenticated user is required."); + + var value = await valueRepo.GetByIdAsync(cmd.Id, cancellationToken); + if (value is null) return Result.Failure(DomainErrors.Common.NotFound); + + var result = value.Delete(ActorId.Create(userContext.UserId)); + if (result.IsFailure) return result; + + await valueRepo.UpdateAsync(value, cancellationToken); + await valueRepo.SaveChangesAsync(cancellationToken); return Result.Success(); } } @@ -147,15 +178,15 @@ public sealed class CreateParameterTenantValueCommandHandler( { [AuditTrail] [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] - public async Task> Handle(CreateParameterTenantValueCommand cmd, CancellationToken ct) + public async Task> Handle(CreateParameterTenantValueCommand cmd, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(userContext.UserId)) return Result.Failure("Authenticated user is required."); - var definition = await definitionRepo.GetByIdAsync(cmd.DefinitionId, ct); + var definition = await definitionRepo.GetByIdAsync(cmd.DefinitionId, cancellationToken); if (definition is null) return Result.Failure(DomainErrors.Common.NotFound); - var existing = await valueRepo.GetByTenantAndDefinitionAsync(cmd.TenantId, cmd.DefinitionId, ct); + var existing = await valueRepo.GetByTenantAndDefinitionAsync(cmd.TenantId, cmd.DefinitionId, cancellationToken); if (existing is not null) return Result.Failure("A tenant value already exists for this parameter and tenant."); @@ -169,8 +200,8 @@ public async Task> Handle(CreateParameterTenantValueCommand cmd, Ca if (result.IsFailure) return Result.Failure(result.Error); - await valueRepo.AddAsync(result.Value, ct); - await valueRepo.SaveChangesAsync(ct); + await valueRepo.AddAsync(result.Value, cancellationToken); + await valueRepo.SaveChangesAsync(cancellationToken); return Result.Success(result.Value.Props.Id.GetValue()); } } @@ -187,23 +218,51 @@ public sealed class UpdateParameterTenantValueCommandHandler( { [AuditTrail] [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] - public async Task Handle(UpdateParameterTenantValueCommand cmd, CancellationToken ct) + public async Task Handle(UpdateParameterTenantValueCommand cmd, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(userContext.UserId)) return Result.Failure("Authenticated user is required."); - var value = await valueRepo.GetByIdAsync(cmd.Id, ct); + var value = await valueRepo.GetByIdAsync(cmd.Id, cancellationToken); if (value is null) return Result.Failure(DomainErrors.Common.NotFound); - var definition = await definitionRepo.GetByIdAsync(value.ParameterDefinitionId.GetValue(), ct); + var definition = await definitionRepo.GetByIdAsync(value.ParameterDefinitionId.GetValue(), cancellationToken); var dataType = definition?.DataType ?? ParameterDataType.String; var scope = definition?.Scope ?? ParameterScope.GlobalAndTenant; var result = value.UpdateValue(OverrideValue.Create(cmd.Value), dataType, scope, ActorId.Create(userContext.UserId)); if (result.IsFailure) return result; - await valueRepo.UpdateAsync(value, ct); - await valueRepo.SaveChangesAsync(ct); + await valueRepo.UpdateAsync(value, cancellationToken); + await valueRepo.SaveChangesAsync(cancellationToken); + return Result.Success(); + } +} + +// ── ParameterTenantValue: Delete (borrado LÓGICO) ──────────────────────────── + +public sealed record DeleteParameterTenantValueCommand(Guid Id) : ICommand; + +public sealed class DeleteParameterTenantValueCommandHandler( + IParameterTenantValueRepository valueRepo, + IUserContext userContext) + : ICommandHandler +{ + [AuditTrail] + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task Handle(DeleteParameterTenantValueCommand cmd, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(userContext.UserId)) + return Result.Failure("Authenticated user is required."); + + var value = await valueRepo.GetByIdAsync(cmd.Id, cancellationToken); + if (value is null) return Result.Failure(DomainErrors.Common.NotFound); + + var result = value.Delete(ActorId.Create(userContext.UserId)); + if (result.IsFailure) return result; + + await valueRepo.UpdateAsync(value, cancellationToken); + await valueRepo.SaveChangesAsync(cancellationToken); return Result.Success(); } } diff --git a/src/apps/ums.api/Ums.Application/Configuration/Services/ConfigurationAuditService.cs b/src/apps/ums.api/Ums.Application/Configuration/Services/ConfigurationAuditService.cs index f4309346..a216e4cb 100644 --- a/src/apps/ums.api/Ums.Application/Configuration/Services/ConfigurationAuditService.cs +++ b/src/apps/ums.api/Ums.Application/Configuration/Services/ConfigurationAuditService.cs @@ -2,6 +2,30 @@ namespace Ums.Application.Configuration.Services; using Ums.Application.Common.Aop; +/// +/// Registra en la pista de auditoría los cambios de parámetros de configuración +/// (crear/modificar/eliminar/override), emitiéndolos por . +/// +/// +/// G-105 (residual de G-040#5) — redacción de nivel-valor por clasificación. El saneador +/// redacta por nombre de clave y NO cubre este caso: aquí +/// el valor de un parámetro aterriza bajo las claves PreviousValue/NewValue, que no son +/// nombres sensibles. Si el parámetro está clasificado como cifrado/secreto +/// (, +/// la misma bandera que cifra el valor en reposo y lo redacta por REST), su valor en claro se filtraría +/// a la traza. Es una fuga de nivel-valor gobernada por la clasificación del parámetro, +/// no por el nombre de la clave. Por eso, cuando el parámetro es secreto, su valor anterior y nuevo se +/// sustituyen por ANTES de escribirlos en la +/// metadata. La traza es append-only e inmutable (G-081): un secreto filtrado a ella no se puede borrar +/// después. +/// +/// +/// +/// Los parámetros no secretos siguen registrando su valor en claro: la auditoría de cambios de +/// configuración sigue siendo útil. Esto complementa —no duplica— al saneador por-clave: aquel +/// es por nombre de clave; éste es por clasificación de nivel-valor. +/// +/// public sealed class ConfigurationAuditService { private readonly IAuditTrailSink _auditTrailSink; @@ -11,21 +35,28 @@ public ConfigurationAuditService(IAuditTrailSink auditTrailSink) _auditTrailSink = auditTrailSink; } - public void RecordConfigurationChange( + public async Task RecordConfigurationChangeAsync( Guid userId, string parameterCode, Guid? tenantId, string? previousValue, string? newValue, string operationType, - Guid rootTenantId) + Guid rootTenantId, + bool isEncrypted, + CancellationToken cancellationToken = default) { + // G-105: el resultado (DELETED/MODIFIED) se deriva del valor ORIGINAL para preservar la + // semántica; la redacción sólo afecta a lo que se persiste como metadata, no a la clasificación + // del evento. + var auditResult = string.IsNullOrEmpty(newValue) ? "DELETED" : "MODIFIED"; + var entry = new AuditTrailEntry( WhoActed: userId, SubjectType: "AppConfiguration", WhatChanged: $"Parameter '{parameterCode}' {operationType}", EventType: operationType, - AuditResult: string.IsNullOrEmpty(newValue) ? "DELETED" : "MODIFIED", + AuditResult: auditResult, AffectedEntityId: Guid.Empty, AffectedEntityType: "AppConfiguration", RootTenantId: rootTenantId, @@ -33,29 +64,39 @@ public void RecordConfigurationChange( { ParameterCode = parameterCode, TenantId = tenantId, - PreviousValue = previousValue, - NewValue = newValue, + PreviousValue = RedactIfSecret(previousValue, isEncrypted), + NewValue = RedactIfSecret(newValue, isEncrypted), Operation = operationType })); - _auditTrailSink.TryWrite(entry); + await _auditTrailSink.PublishAsync(entry, cancellationToken); } - public void RecordParameterOverride( + public Task RecordParameterOverrideAsync( Guid userId, string parameterCode, Guid tenantId, string? previousValue, string? newValue, - Guid rootTenantId) - { - RecordConfigurationChange( + Guid rootTenantId, + bool isEncrypted, + CancellationToken cancellationToken = default) + => RecordConfigurationChangeAsync( userId, parameterCode, tenantId, previousValue, newValue, "OVERRIDE", - rootTenantId); - } -} \ No newline at end of file + rootTenantId, + isEncrypted, + cancellationToken); + + // G-105: si el parámetro está clasificado como cifrado/secreto, su valor no debe aterrizar en claro + // en la traza inmutable → se redacta. Un valor nulo/vacío no tiene nada que ocultar (y redactarlo + // fingiría que existía un valor), así que se deja tal cual; sólo se redacta lo que sí es contenido. + private static string? RedactIfSecret(string? value, bool isEncrypted) + => isEncrypted && !string.IsNullOrEmpty(value) + ? AuditMetadataSanitizer.RedactionPlaceholder + : value; +} diff --git a/src/apps/ums.api/Ums.Application/Configuration/Services/ConfigurationValues.cs b/src/apps/ums.api/Ums.Application/Configuration/Services/ConfigurationValues.cs index b44e670e..1b686518 100644 --- a/src/apps/ums.api/Ums.Application/Configuration/Services/ConfigurationValues.cs +++ b/src/apps/ums.api/Ums.Application/Configuration/Services/ConfigurationValues.cs @@ -20,6 +20,9 @@ public ConfigurationValues(IConfigurationProvider provider, Guid? tenantId = nul public int MaxLoginAttempts => _provider.GetValueAs(AppConfigurationCodes.MaxLoginAttempts, _tenantId, AppConfigurationDefaults.MaxLoginAttempts); + // ADR-UMS-095: duración del bloqueo temporal por intentos fallidos (jerarquía Global>Suite>Tenant>Module). + public int AccountLockoutDurationMinutes => _provider.GetValueAs(AppConfigurationCodes.AccountLockoutDurationMinutes, _tenantId, AppConfigurationDefaults.AccountLockoutDurationMinutes); + public int AccessTokenDurationMs => _provider.GetValueAs(AppConfigurationCodes.AccessTokenDurationMs, _tenantId, AppConfigurationDefaults.AccessTokenDurationMs); public int RefreshTokenDurationMs => _provider.GetValueAs(AppConfigurationCodes.RefreshTokenDurationMs, _tenantId, AppConfigurationDefaults.RefreshTokenDurationMs); diff --git a/src/apps/ums.api/Ums.Application/DependencyInjection.cs b/src/apps/ums.api/Ums.Application/DependencyInjection.cs index b51927ec..2d64a411 100644 --- a/src/apps/ums.api/Ums.Application/DependencyInjection.cs +++ b/src/apps/ums.api/Ums.Application/DependencyInjection.cs @@ -5,6 +5,7 @@ namespace Ums.Application; using Microsoft.Extensions.DependencyInjection; using Ums.Application.Common.Behaviors; using Ums.Application.Common.Interfaces; +using Ums.Application.IGA.Services; public static class DependencyInjection { @@ -18,6 +19,10 @@ public static IServiceCollection AddApplication(this IServiceCollection services services.AddScoped(); services.AddScoped(); + // IGA (ADR-UMS-093): heurística versionada de RiskScore. La infraestructura puede sustituirla + // por una implementación respaldada por el grafo de autorización (ADR-UMS-088) sin tocar handlers. + services.AddScoped(); + return services; } } diff --git a/src/apps/ums.api/Ums.Application/IGA/Common/IgaHandlerGuards.cs b/src/apps/ums.api/Ums.Application/IGA/Common/IgaHandlerGuards.cs new file mode 100644 index 00000000..c538ea0f --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/Common/IgaHandlerGuards.cs @@ -0,0 +1,57 @@ +namespace Ums.Application.IGA.Common; + +/// +/// Guardas compartidas por los handlers del contexto acotado IGA (ADR-UMS-093): +/// autenticación, acotación por inquilino y segregación de funciones (SoD) a nivel de aplicación. +/// La SoD también es invariante de dominio (INV-RPR3); aquí se hace cumplir antes de tocar +/// el agregado para devolver un fallo temprano y legible, cerrando el hueco que la auditoría halló +/// en Aprobaciones (el actor no puede aprobar/ejecutar/verificar su propia promoción). +/// +internal static class IgaHandlerGuards +{ + /// Exige un usuario autenticado y devuelve su identificador como . + public static Result RequireAuthenticatedUser(IUserContext userContext) + { + if (string.IsNullOrWhiteSpace(userContext.UserId)) + { + return Result.Failure("Se requiere un usuario autenticado para operar sobre una promoción de rol."); + } + + if (!Guid.TryParse(userContext.UserId, out var userId)) + { + return Result.Failure("El identificador del usuario autenticado no es válido."); + } + + return Result.Success(userId); + } + + /// + /// Verifica que el inquilino de la solicitud esté dentro del alcance visible del solicitante. + /// Un administrador interno (alcance nulo = multi-inquilino) puede operar sobre cualquier inquilino; + /// un usuario regular sólo sobre el suyo. + /// + public static Result EnsureTenantInScope(ITenantScopePolicy tenantScopePolicy, Guid tenantId) + { + var scope = tenantScopePolicy.ResolveQueryScope(); + if (scope is null || scope.Value == tenantId) + { + return Result.Success(); + } + + return Result.Failure("La solicitud de promoción pertenece a otro inquilino y está fuera de su alcance."); + } + + /// SoD: el actor no puede coincidir con ninguno de los sujetos indicados (objetivo, aprobador, etc.). + public static Result EnsureSegregation(Guid actorId, params (Guid? Subject, string Message)[] conflicts) + { + foreach (var (subject, message) in conflicts) + { + if (subject is not null && subject.Value == actorId) + { + return Result.Failure(message); + } + } + + return Result.Success(); + } +} diff --git a/src/apps/ums.api/Ums.Application/IGA/DTOs/CreateRolePromotionRequestResponse.cs b/src/apps/ums.api/Ums.Application/IGA/DTOs/CreateRolePromotionRequestResponse.cs new file mode 100644 index 00000000..e97a4cda --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/DTOs/CreateRolePromotionRequestResponse.cs @@ -0,0 +1,4 @@ +namespace Ums.Application.IGA.DTOs; + +/// Respuesta a la creación de una solicitud de promoción de rol: el identificador generado. +public sealed record CreateRolePromotionRequestResponse(Guid RolePromotionRequestId); diff --git a/src/apps/ums.api/Ums.Application/IGA/DTOs/RoleMaturityStatusDto.cs b/src/apps/ums.api/Ums.Application/IGA/DTOs/RoleMaturityStatusDto.cs new file mode 100644 index 00000000..eaa46eaa --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/DTOs/RoleMaturityStatusDto.cs @@ -0,0 +1,19 @@ +namespace Ums.Application.IGA.DTOs; + +/// Proyección de lectura del estado de madurez de un usuario en un rol (IGA, ADR-UMS-093). +public sealed record RoleMaturityStatusDto( + Guid Id, + Guid TenantId, + Guid UserId, + Guid RoleId, + string CurrentMaturityLevel, + string? NextEligibleMaturityLevel, + DateTime AssignedAt, + DateTime CurrentLevelSince, + DateTime? EligibleForPromotionAt, + int CompletedCertificationsCount, + int CompletedTrainingsCount, + decimal PerformanceScore, + bool HasNoComplianceIssues, + string? BlockingFactor, + DateTime? LastReviewedAt); diff --git a/src/apps/ums.api/Ums.Application/IGA/DTOs/RolePromotionMapper.cs b/src/apps/ums.api/Ums.Application/IGA/DTOs/RolePromotionMapper.cs new file mode 100644 index 00000000..2bc5fe85 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/DTOs/RolePromotionMapper.cs @@ -0,0 +1,42 @@ +namespace Ums.Application.IGA.DTOs; + +using Ums.Domain.IGA.RoleMaturityStatus; +using Ums.Domain.IGA.RolePromotionRequest; + +/// Proyecciones de lectura de los agregados IGA a sus DTO (ADR-UMS-093). +internal static class RolePromotionMapper +{ + public static RolePromotionRequestDto ToDto(RolePromotionRequest request) => + new( + request.GetId().GetValue(), + request.TenantId.GetValue(), + request.TargetUserId.GetValue(), + request.RequesterId.GetValue(), + request.CurrentRoleId.GetValue(), + request.TargetRoleId.GetValue(), + request.Status.Name, + request.RiskScore?.GetValue(), + request.ApproverId?.GetValue(), + request.SecurityReviewerId?.GetValue(), + request.ExecutorId?.GetValue(), + request.VerifierId?.GetValue(), + request.DecisionReason); + + public static RoleMaturityStatusDto ToDto(RoleMaturityStatus status) => + new( + status.GetId().GetValue(), + status.TenantId.GetValue(), + status.UserId.GetValue(), + status.RoleId.GetValue(), + status.CurrentMaturityLevel.ToString(), + status.NextEligibleMaturityLevel?.ToString(), + status.AssignedAt, + status.CurrentLevelSince, + status.EligibleForPromotionAt, + status.CompletedCertificationsCount, + status.CompletedTrainingsCount, + status.PerformanceScore, + status.HasNoComplianceIssues, + status.BlockingFactor?.GetValue(), + status.LastReviewedAt); +} diff --git a/src/apps/ums.api/Ums.Application/IGA/DTOs/RolePromotionRequestDto.cs b/src/apps/ums.api/Ums.Application/IGA/DTOs/RolePromotionRequestDto.cs new file mode 100644 index 00000000..1f18056b --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/DTOs/RolePromotionRequestDto.cs @@ -0,0 +1,17 @@ +namespace Ums.Application.IGA.DTOs; + +/// Proyección de lectura de una solicitud de promoción de rol (IGA, ADR-UMS-093). +public sealed record RolePromotionRequestDto( + Guid Id, + Guid TenantId, + Guid TargetUserId, + Guid RequesterId, + Guid CurrentRoleId, + Guid TargetRoleId, + string Status, + int? RiskScore, + Guid? ApproverId, + Guid? SecurityReviewerId, + Guid? ExecutorId, + Guid? VerifierId, + string? DecisionReason); diff --git a/src/apps/ums.api/Ums.Application/IGA/RoleMaturity/Queries/GetRoleMaturityStatusByUserQuery.cs b/src/apps/ums.api/Ums.Application/IGA/RoleMaturity/Queries/GetRoleMaturityStatusByUserQuery.cs new file mode 100644 index 00000000..db791bcf --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RoleMaturity/Queries/GetRoleMaturityStatusByUserQuery.cs @@ -0,0 +1,11 @@ +namespace Ums.Application.IGA.RoleMaturity.Queries; + +using Ums.Application.IGA.DTOs; + +/// +/// Obtiene el/los estado(s) de madurez de un usuario, acotado por inquilino. Si se indica +/// devuelve el estado de ese rol; en caso contrario, todos los del usuario. +/// IGA, ADR-UMS-093, FR-062. +/// +public sealed record GetRoleMaturityStatusByUserQuery(Guid TenantId, Guid UserId, Guid? RoleId) + : IQuery>; diff --git a/src/apps/ums.api/Ums.Application/IGA/RoleMaturity/Queries/GetRoleMaturityStatusByUserQueryHandler.cs b/src/apps/ums.api/Ums.Application/IGA/RoleMaturity/Queries/GetRoleMaturityStatusByUserQueryHandler.cs new file mode 100644 index 00000000..e5bc52ec --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RoleMaturity/Queries/GetRoleMaturityStatusByUserQueryHandler.cs @@ -0,0 +1,49 @@ +namespace Ums.Application.IGA.RoleMaturity.Queries; + +using Ums.Application.IGA.Common; +using Ums.Application.IGA.DTOs; +using Ums.Domain.IGA; +using RoleMaturityStatusAggregate = Ums.Domain.IGA.RoleMaturityStatus.RoleMaturityStatus; + +public sealed class GetRoleMaturityStatusByUserQueryHandler + : IQueryHandler> +{ + private readonly IRoleMaturityStatusRepository _repository; + private readonly ITenantScopePolicy _tenantScopePolicy; + + public GetRoleMaturityStatusByUserQueryHandler( + IRoleMaturityStatusRepository repository, + ITenantScopePolicy tenantScopePolicy) + { + _repository = repository; + _tenantScopePolicy = tenantScopePolicy; + } + + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task>> Handle( + GetRoleMaturityStatusByUserQuery request, + CancellationToken cancellationToken) + { + var scope = IgaHandlerGuards.EnsureTenantInScope(_tenantScopePolicy, request.TenantId); + if (scope.IsFailure) + { + return Result>.Failure(scope.Error); + } + + if (request.RoleId is not null) + { + var single = await _repository.GetByUserAndRoleAsync( + request.TenantId, request.UserId, request.RoleId.Value, cancellationToken); + var singleList = single is null + ? new List() + : new List { RolePromotionMapper.ToDto(single) }; + return Result>.Success(singleList); + } + + IReadOnlyList items = + await _repository.GetByUserAsync(request.TenantId, request.UserId, cancellationToken); + + var dtos = items.Select(RolePromotionMapper.ToDto).ToList(); + return Result>.Success(dtos); + } +} diff --git a/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/CancelRolePromotionCommand.cs b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/CancelRolePromotionCommand.cs new file mode 100644 index 00000000..15e8aeca --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/CancelRolePromotionCommand.cs @@ -0,0 +1,4 @@ +namespace Ums.Application.IGA.RolePromotion.Commands; + +/// Draft → Cancelled (el solicitante cancela antes de enviar). IGA, ADR-UMS-093, FR-060. +public sealed record CancelRolePromotionCommand(Guid RolePromotionRequestId, string Reason) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/CancelRolePromotionCommandHandler.cs b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/CancelRolePromotionCommandHandler.cs new file mode 100644 index 00000000..10a39ba4 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/CancelRolePromotionCommandHandler.cs @@ -0,0 +1,61 @@ +namespace Ums.Application.IGA.RolePromotion.Commands; + +using Ums.Application.IGA.Common; +using Ums.Domain.IGA; + +public sealed class CancelRolePromotionCommandHandler : ICommandHandler +{ + private readonly IRolePromotionRequestRepository _repository; + private readonly ITenantScopePolicy _tenantScopePolicy; + private readonly IUserContext _userContext; + + public CancelRolePromotionCommandHandler( + IRolePromotionRequestRepository repository, + ITenantScopePolicy tenantScopePolicy, + IUserContext userContext) + { + _repository = repository; + _tenantScopePolicy = tenantScopePolicy; + _userContext = userContext; + } + + [AuditTrail] + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task Handle(CancelRolePromotionCommand request, CancellationToken cancellationToken) + { + var actor = IgaHandlerGuards.RequireAuthenticatedUser(_userContext); + if (actor.IsFailure) + { + return Result.Failure(actor.Error); + } + + var entity = await _repository.GetByIdAsync(request.RolePromotionRequestId, cancellationToken); + if (entity is null) + { + return Result.Failure(DomainErrors.IGA.RolePromotionRequestNotFound); + } + + var scope = IgaHandlerGuards.EnsureTenantInScope(_tenantScopePolicy, entity.TenantId.GetValue()); + if (scope.IsFailure) + { + return Result.Failure(scope.Error); + } + + // Sólo el solicitante cancela su propia solicitud (ADR-UMS-093, §Máquina de estados). + if (actor.Value != entity.RequesterId.GetValue()) + { + return Result.Failure("Sólo el solicitante puede cancelar la solicitud de promoción."); + } + + var result = entity.Cancel(request.Reason, ActorId.Create(_userContext.UserId)); + if (result.IsFailure) + { + return result; + } + + await _repository.UpdateAsync(entity, cancellationToken); + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ConfirmRolePromotionEligibilityCommand.cs b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ConfirmRolePromotionEligibilityCommand.cs new file mode 100644 index 00000000..b1edd6c1 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ConfirmRolePromotionEligibilityCommand.cs @@ -0,0 +1,8 @@ +namespace Ums.Application.IGA.RolePromotion.Commands; + +/// +/// PendingEligibilityCheck → PendingManagerApproval (elegible) o → Rejected (no elegible, fail-closed). +/// Orquesta RoleMaturityStatus.EvaluateEligibility y aplica el resultado en +/// RolePromotionRequest.ConfirmEligibility (IGA, ADR-UMS-093, FR-062, INV-RPR4). +/// +public sealed record ConfirmRolePromotionEligibilityCommand(Guid RolePromotionRequestId) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ConfirmRolePromotionEligibilityCommandHandler.cs b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ConfirmRolePromotionEligibilityCommandHandler.cs new file mode 100644 index 00000000..bfc8832b --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ConfirmRolePromotionEligibilityCommandHandler.cs @@ -0,0 +1,89 @@ +namespace Ums.Application.IGA.RolePromotion.Commands; + +using Ums.Application.IGA.Common; +using Ums.Domain.IGA; + +/// +/// Handler de confirmación de elegibilidad (fail-closed). Consulta el RoleMaturityStatus +/// del usuario objetivo en su rol actual y ejecuta EvaluateEligibility. Cualquier condición +/// no cumplida —incluida la ausencia del estado de madurez— se traduce en no elegible, con +/// lo que la promoción pasa a Rejected y nunca avanza (INV-RPR4). +/// El instante de evaluación se inyecta desde la aplicación (DateTime.UtcNow). +/// +public sealed class ConfirmRolePromotionEligibilityCommandHandler + : ICommandHandler +{ + private readonly IRolePromotionRequestRepository _repository; + private readonly IRoleMaturityStatusRepository _maturityRepository; + private readonly ITenantScopePolicy _tenantScopePolicy; + private readonly IUserContext _userContext; + + public ConfirmRolePromotionEligibilityCommandHandler( + IRolePromotionRequestRepository repository, + IRoleMaturityStatusRepository maturityRepository, + ITenantScopePolicy tenantScopePolicy, + IUserContext userContext) + { + _repository = repository; + _maturityRepository = maturityRepository; + _tenantScopePolicy = tenantScopePolicy; + _userContext = userContext; + } + + [AuditTrail] + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task Handle( + ConfirmRolePromotionEligibilityCommand request, + CancellationToken cancellationToken) + { + var actor = IgaHandlerGuards.RequireAuthenticatedUser(_userContext); + if (actor.IsFailure) + { + return Result.Failure(actor.Error); + } + + var entity = await _repository.GetByIdAsync(request.RolePromotionRequestId, cancellationToken); + if (entity is null) + { + return Result.Failure(DomainErrors.IGA.RolePromotionRequestNotFound); + } + + var scope = IgaHandlerGuards.EnsureTenantInScope(_tenantScopePolicy, entity.TenantId.GetValue()); + if (scope.IsFailure) + { + return Result.Failure(scope.Error); + } + + var actorId = ActorId.Create(_userContext.UserId); + + // Fuente de verdad de elegibilidad: el RoleMaturityStatus del objetivo en su rol actual. + var maturity = await _maturityRepository.GetByUserAndRoleAsync( + entity.TenantId.GetValue(), + entity.TargetUserId.GetValue(), + entity.CurrentRoleId.GetValue(), + cancellationToken); + + var isEligible = false; + if (maturity is not null) + { + // EvaluateEligibility muta el agregado sólo en éxito; sólo entonces se persiste. + var eligibility = maturity.EvaluateEligibility(DateTime.UtcNow, actorId); + isEligible = eligibility.IsSuccess; + if (isEligible) + { + await _maturityRepository.UpdateAsync(maturity, cancellationToken); + } + } + + var confirmResult = entity.ConfirmEligibility(isEligible, actorId); + if (confirmResult.IsFailure) + { + return confirmResult; + } + + await _repository.UpdateAsync(entity, cancellationToken); + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/CreateRolePromotionRequestCommand.cs b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/CreateRolePromotionRequestCommand.cs new file mode 100644 index 00000000..31c7eb1c --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/CreateRolePromotionRequestCommand.cs @@ -0,0 +1,13 @@ +namespace Ums.Application.IGA.RolePromotion.Commands; + +using Ums.Application.IGA.DTOs; + +/// +/// Crea una solicitud de promoción de rol en estado Draft (IGA, ADR-UMS-093, FR-060). +/// El solicitante es el usuario autenticado; no puede coincidir con el usuario objetivo (SoD). +/// +public sealed record CreateRolePromotionRequestCommand( + Guid TenantId, + Guid TargetUserId, + Guid CurrentRoleId, + Guid TargetRoleId) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/CreateRolePromotionRequestCommandHandler.cs b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/CreateRolePromotionRequestCommandHandler.cs new file mode 100644 index 00000000..077c8dc3 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/CreateRolePromotionRequestCommandHandler.cs @@ -0,0 +1,69 @@ +namespace Ums.Application.IGA.RolePromotion.Commands; + +using Ums.Application.IGA.Common; +using Ums.Application.IGA.DTOs; +using Ums.Domain.IGA; +using Ums.Domain.IGA.RolePromotionRequest; + +public sealed class CreateRolePromotionRequestCommandHandler + : ICommandHandler +{ + private readonly IRolePromotionRequestRepository _repository; + private readonly ITenantScopePolicy _tenantScopePolicy; + private readonly IUserContext _userContext; + + public CreateRolePromotionRequestCommandHandler( + IRolePromotionRequestRepository repository, + ITenantScopePolicy tenantScopePolicy, + IUserContext userContext) + { + _repository = repository; + _tenantScopePolicy = tenantScopePolicy; + _userContext = userContext; + } + + [AuditTrail] + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task> Handle( + CreateRolePromotionRequestCommand request, + CancellationToken cancellationToken) + { + var actor = IgaHandlerGuards.RequireAuthenticatedUser(_userContext); + if (actor.IsFailure) + { + return Result.Failure(actor.Error); + } + + var scope = IgaHandlerGuards.EnsureTenantInScope(_tenantScopePolicy, request.TenantId); + if (scope.IsFailure) + { + return Result.Failure(scope.Error); + } + + // SoD (INV-RPR3): el solicitante no puede promover(se) a sí mismo. + if (actor.Value == request.TargetUserId) + { + return Result.Failure( + "El solicitante no puede ser el usuario objetivo de la promoción (segregación de funciones)."); + } + + var createResult = RolePromotionRequest.Create( + TenantId.Load(request.TenantId), + UserId.Load(request.TargetUserId), + UserId.Load(actor.Value), + RoleId.Load(request.CurrentRoleId), + RoleId.Load(request.TargetRoleId), + ActorId.Create(_userContext.UserId)); + + if (createResult.IsFailure) + { + return Result.Failure(createResult.Error); + } + + await _repository.AddAsync(createResult.Value, cancellationToken); + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + return Result.Success( + new CreateRolePromotionRequestResponse(createResult.Value.GetId().GetValue())); + } +} diff --git a/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ExecuteRolePromotionCommand.cs b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ExecuteRolePromotionCommand.cs new file mode 100644 index 00000000..15d45a2c --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ExecuteRolePromotionCommand.cs @@ -0,0 +1,4 @@ +namespace Ums.Application.IGA.RolePromotion.Commands; + +/// Approved → Executed (aplica el cambio de rol). El ejecutor es el usuario autenticado. IGA, ADR-UMS-093, INV-RPR5. +public sealed record ExecuteRolePromotionCommand(Guid RolePromotionRequestId) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ExecuteRolePromotionCommandHandler.cs b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ExecuteRolePromotionCommandHandler.cs new file mode 100644 index 00000000..c3369c67 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ExecuteRolePromotionCommandHandler.cs @@ -0,0 +1,90 @@ +namespace Ums.Application.IGA.RolePromotion.Commands; + +using Ums.Application.IGA.Common; +using Ums.Domain.Events; +using Ums.Domain.IGA; + +public sealed class ExecuteRolePromotionCommandHandler : ICommandHandler +{ + private readonly IRolePromotionRequestRepository _repository; + private readonly ITenantScopePolicy _tenantScopePolicy; + private readonly IUserContext _userContext; + private readonly IIntegrationEventPublisher _integrationEventPublisher; + + public ExecuteRolePromotionCommandHandler( + IRolePromotionRequestRepository repository, + ITenantScopePolicy tenantScopePolicy, + IUserContext userContext, + IIntegrationEventPublisher integrationEventPublisher) + { + _repository = repository; + _tenantScopePolicy = tenantScopePolicy; + _userContext = userContext; + _integrationEventPublisher = integrationEventPublisher; + } + + [AuditTrail] + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task Handle(ExecuteRolePromotionCommand request, CancellationToken cancellationToken) + { + var actor = IgaHandlerGuards.RequireAuthenticatedUser(_userContext); + if (actor.IsFailure) + { + return Result.Failure(actor.Error); + } + + var entity = await _repository.GetByIdAsync(request.RolePromotionRequestId, cancellationToken); + if (entity is null) + { + return Result.Failure(DomainErrors.IGA.RolePromotionRequestNotFound); + } + + var scope = IgaHandlerGuards.EnsureTenantInScope(_tenantScopePolicy, entity.TenantId.GetValue()); + if (scope.IsFailure) + { + return Result.Failure(scope.Error); + } + + // SoD (INV-RPR3 endurecida, ADR-UMS-096): el ejecutor no puede ser el objetivo, el aprobador + // ni el revisor de seguridad (espejo de VerifyRolePromotionCommandHandler). + var sod = IgaHandlerGuards.EnsureSegregation( + actor.Value, + (entity.TargetUserId.GetValue(), "El ejecutor no puede ser el usuario objetivo (segregación de funciones)."), + (entity.ApproverId?.GetValue(), "El ejecutor no puede ser el aprobador (segregación de funciones)."), + (entity.SecurityReviewerId?.GetValue(), "El ejecutor no puede ser el revisor de seguridad (segregación de funciones).")); + if (sod.IsFailure) + { + return Result.Failure(sod.Error); + } + + var result = entity.Execute(UserId.Load(actor.Value), ActorId.Create(_userContext.UserId)); + if (result.IsFailure) + { + return result; + } + + await _repository.UpdateAsync(entity, cancellationToken); + + // G-094: el EFECTO de la promoción (reasignar el rol del perfil objetivo) se entrega con + // GARANTÍA por el Transactional Outbox de MassTransit, no por un handoff in-process + // best-effort (G-093). Se publica el evento de integración ANTES de SaveEntitiesAsync: bajo + // kind/prod el bus-outbox EF estaciona el mensaje en el MISMO change set del agregado y lo + // confirma atómicamente con el cambio a Executed; el servicio de entrega lo despacha + // POST-commit al RolePromotionRoleAssignmentConsumer (reintentos + dead-letter). En dev/tests + // (bus en memoria, sin outbox EF) el publish entrega directo al consumidor en proceso. Así el + // efecto nunca se pierde en silencio si el consumidor falla. + await _integrationEventPublisher.PublishAsync( + new RolePromotionExecutedIntegrationEvent( + entity.TenantId.GetValue(), + entity.GetId().GetValue(), + entity.TargetUserId.GetValue(), + entity.CurrentRoleId.GetValue(), + entity.TargetRoleId.GetValue(), + actor.Value), + cancellationToken); + + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ManagerApproveRolePromotionCommand.cs b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ManagerApproveRolePromotionCommand.cs new file mode 100644 index 00000000..14c6436b --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ManagerApproveRolePromotionCommand.cs @@ -0,0 +1,7 @@ +namespace Ums.Application.IGA.RolePromotion.Commands; + +/// +/// PendingManagerApproval → PendingSecurityReview (RiskScore ≥ umbral) o → Approved (RiskScore < umbral). +/// El aprobador es el usuario autenticado (IGA, ADR-UMS-093, FR-060). +/// +public sealed record ManagerApproveRolePromotionCommand(Guid RolePromotionRequestId) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ManagerApproveRolePromotionCommandHandler.cs b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ManagerApproveRolePromotionCommandHandler.cs new file mode 100644 index 00000000..653e8cd7 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ManagerApproveRolePromotionCommandHandler.cs @@ -0,0 +1,72 @@ +namespace Ums.Application.IGA.RolePromotion.Commands; + +using Ums.Application.IGA.Common; +using Ums.Domain.IGA; +using Ums.Domain.IGA.RolePromotionRequest; + +public sealed class ManagerApproveRolePromotionCommandHandler + : ICommandHandler +{ + private readonly IRolePromotionRequestRepository _repository; + private readonly ITenantScopePolicy _tenantScopePolicy; + private readonly IUserContext _userContext; + + public ManagerApproveRolePromotionCommandHandler( + IRolePromotionRequestRepository repository, + ITenantScopePolicy tenantScopePolicy, + IUserContext userContext) + { + _repository = repository; + _tenantScopePolicy = tenantScopePolicy; + _userContext = userContext; + } + + [AuditTrail] + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task Handle( + ManagerApproveRolePromotionCommand request, + CancellationToken cancellationToken) + { + var actor = IgaHandlerGuards.RequireAuthenticatedUser(_userContext); + if (actor.IsFailure) + { + return Result.Failure(actor.Error); + } + + var entity = await _repository.GetByIdAsync(request.RolePromotionRequestId, cancellationToken); + if (entity is null) + { + return Result.Failure(DomainErrors.IGA.RolePromotionRequestNotFound); + } + + var scope = IgaHandlerGuards.EnsureTenantInScope(_tenantScopePolicy, entity.TenantId.GetValue()); + if (scope.IsFailure) + { + return Result.Failure(scope.Error); + } + + // SoD (INV-RPR3): el aprobador no puede ser el objetivo ni el solicitante. + var sod = IgaHandlerGuards.EnsureSegregation( + actor.Value, + (entity.TargetUserId.GetValue(), "El aprobador no puede ser el usuario objetivo (segregación de funciones)."), + (entity.RequesterId.GetValue(), "El aprobador no puede ser el solicitante (segregación de funciones).")); + if (sod.IsFailure) + { + return Result.Failure(sod.Error); + } + + var result = entity.ManagerApprove( + UserId.Load(actor.Value), + ActorId.Create(_userContext.UserId), + RolePromotionRequest.DefaultHighRiskThreshold); + if (result.IsFailure) + { + return result; + } + + await _repository.UpdateAsync(entity, cancellationToken); + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ManagerRejectRolePromotionCommand.cs b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ManagerRejectRolePromotionCommand.cs new file mode 100644 index 00000000..a6acb65c --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ManagerRejectRolePromotionCommand.cs @@ -0,0 +1,4 @@ +namespace Ums.Application.IGA.RolePromotion.Commands; + +/// PendingManagerApproval → Rejected (el gerente rechaza con motivo). IGA, ADR-UMS-093, FR-060. +public sealed record ManagerRejectRolePromotionCommand(Guid RolePromotionRequestId, string Reason) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ManagerRejectRolePromotionCommandHandler.cs b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ManagerRejectRolePromotionCommandHandler.cs new file mode 100644 index 00000000..a6307aea --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/ManagerRejectRolePromotionCommandHandler.cs @@ -0,0 +1,68 @@ +namespace Ums.Application.IGA.RolePromotion.Commands; + +using Ums.Application.IGA.Common; +using Ums.Domain.IGA; + +public sealed class ManagerRejectRolePromotionCommandHandler + : ICommandHandler +{ + private readonly IRolePromotionRequestRepository _repository; + private readonly ITenantScopePolicy _tenantScopePolicy; + private readonly IUserContext _userContext; + + public ManagerRejectRolePromotionCommandHandler( + IRolePromotionRequestRepository repository, + ITenantScopePolicy tenantScopePolicy, + IUserContext userContext) + { + _repository = repository; + _tenantScopePolicy = tenantScopePolicy; + _userContext = userContext; + } + + [AuditTrail] + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task Handle( + ManagerRejectRolePromotionCommand request, + CancellationToken cancellationToken) + { + var actor = IgaHandlerGuards.RequireAuthenticatedUser(_userContext); + if (actor.IsFailure) + { + return Result.Failure(actor.Error); + } + + var entity = await _repository.GetByIdAsync(request.RolePromotionRequestId, cancellationToken); + if (entity is null) + { + return Result.Failure(DomainErrors.IGA.RolePromotionRequestNotFound); + } + + var scope = IgaHandlerGuards.EnsureTenantInScope(_tenantScopePolicy, entity.TenantId.GetValue()); + if (scope.IsFailure) + { + return Result.Failure(scope.Error); + } + + // SoD (INV-RPR3): quien decide el rechazo no puede ser el objetivo ni el solicitante. + var sod = IgaHandlerGuards.EnsureSegregation( + actor.Value, + (entity.TargetUserId.GetValue(), "El aprobador no puede ser el usuario objetivo (segregación de funciones)."), + (entity.RequesterId.GetValue(), "El aprobador no puede ser el solicitante (segregación de funciones).")); + if (sod.IsFailure) + { + return Result.Failure(sod.Error); + } + + var result = entity.ManagerReject(UserId.Load(actor.Value), request.Reason, ActorId.Create(_userContext.UserId)); + if (result.IsFailure) + { + return result; + } + + await _repository.UpdateAsync(entity, cancellationToken); + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/SecurityApproveRolePromotionCommand.cs b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/SecurityApproveRolePromotionCommand.cs new file mode 100644 index 00000000..120f0452 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/SecurityApproveRolePromotionCommand.cs @@ -0,0 +1,4 @@ +namespace Ums.Application.IGA.RolePromotion.Commands; + +/// PendingSecurityReview → Approved. El revisor es el usuario autenticado. IGA, ADR-UMS-093, FR-060. +public sealed record SecurityApproveRolePromotionCommand(Guid RolePromotionRequestId) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/SecurityApproveRolePromotionCommandHandler.cs b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/SecurityApproveRolePromotionCommandHandler.cs new file mode 100644 index 00000000..f8bda8bb --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/SecurityApproveRolePromotionCommandHandler.cs @@ -0,0 +1,69 @@ +namespace Ums.Application.IGA.RolePromotion.Commands; + +using Ums.Application.IGA.Common; +using Ums.Domain.IGA; + +public sealed class SecurityApproveRolePromotionCommandHandler + : ICommandHandler +{ + private readonly IRolePromotionRequestRepository _repository; + private readonly ITenantScopePolicy _tenantScopePolicy; + private readonly IUserContext _userContext; + + public SecurityApproveRolePromotionCommandHandler( + IRolePromotionRequestRepository repository, + ITenantScopePolicy tenantScopePolicy, + IUserContext userContext) + { + _repository = repository; + _tenantScopePolicy = tenantScopePolicy; + _userContext = userContext; + } + + [AuditTrail] + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task Handle( + SecurityApproveRolePromotionCommand request, + CancellationToken cancellationToken) + { + var actor = IgaHandlerGuards.RequireAuthenticatedUser(_userContext); + if (actor.IsFailure) + { + return Result.Failure(actor.Error); + } + + var entity = await _repository.GetByIdAsync(request.RolePromotionRequestId, cancellationToken); + if (entity is null) + { + return Result.Failure(DomainErrors.IGA.RolePromotionRequestNotFound); + } + + var scope = IgaHandlerGuards.EnsureTenantInScope(_tenantScopePolicy, entity.TenantId.GetValue()); + if (scope.IsFailure) + { + return Result.Failure(scope.Error); + } + + // SoD (INV-RPR3): el revisor de seguridad no puede ser el objetivo, el solicitante ni el aprobador. + var sod = IgaHandlerGuards.EnsureSegregation( + actor.Value, + (entity.TargetUserId.GetValue(), "El revisor de seguridad no puede ser el usuario objetivo (segregación de funciones)."), + (entity.RequesterId.GetValue(), "El revisor de seguridad no puede ser el solicitante (segregación de funciones)."), + (entity.ApproverId?.GetValue(), "El revisor de seguridad no puede ser el aprobador (segregación de funciones).")); + if (sod.IsFailure) + { + return Result.Failure(sod.Error); + } + + var result = entity.SecurityApprove(UserId.Load(actor.Value), ActorId.Create(_userContext.UserId)); + if (result.IsFailure) + { + return result; + } + + await _repository.UpdateAsync(entity, cancellationToken); + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/SecurityRejectRolePromotionCommand.cs b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/SecurityRejectRolePromotionCommand.cs new file mode 100644 index 00000000..fb855f1b --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/SecurityRejectRolePromotionCommand.cs @@ -0,0 +1,4 @@ +namespace Ums.Application.IGA.RolePromotion.Commands; + +/// PendingSecurityReview → Rejected (seguridad rechaza con motivo). IGA, ADR-UMS-093, FR-060. +public sealed record SecurityRejectRolePromotionCommand(Guid RolePromotionRequestId, string Reason) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/SecurityRejectRolePromotionCommandHandler.cs b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/SecurityRejectRolePromotionCommandHandler.cs new file mode 100644 index 00000000..8f8992cb --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/SecurityRejectRolePromotionCommandHandler.cs @@ -0,0 +1,69 @@ +namespace Ums.Application.IGA.RolePromotion.Commands; + +using Ums.Application.IGA.Common; +using Ums.Domain.IGA; + +public sealed class SecurityRejectRolePromotionCommandHandler + : ICommandHandler +{ + private readonly IRolePromotionRequestRepository _repository; + private readonly ITenantScopePolicy _tenantScopePolicy; + private readonly IUserContext _userContext; + + public SecurityRejectRolePromotionCommandHandler( + IRolePromotionRequestRepository repository, + ITenantScopePolicy tenantScopePolicy, + IUserContext userContext) + { + _repository = repository; + _tenantScopePolicy = tenantScopePolicy; + _userContext = userContext; + } + + [AuditTrail] + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task Handle( + SecurityRejectRolePromotionCommand request, + CancellationToken cancellationToken) + { + var actor = IgaHandlerGuards.RequireAuthenticatedUser(_userContext); + if (actor.IsFailure) + { + return Result.Failure(actor.Error); + } + + var entity = await _repository.GetByIdAsync(request.RolePromotionRequestId, cancellationToken); + if (entity is null) + { + return Result.Failure(DomainErrors.IGA.RolePromotionRequestNotFound); + } + + var scope = IgaHandlerGuards.EnsureTenantInScope(_tenantScopePolicy, entity.TenantId.GetValue()); + if (scope.IsFailure) + { + return Result.Failure(scope.Error); + } + + // SoD (INV-RPR3): el revisor de seguridad no puede ser el objetivo, el solicitante ni el aprobador. + var sod = IgaHandlerGuards.EnsureSegregation( + actor.Value, + (entity.TargetUserId.GetValue(), "El revisor de seguridad no puede ser el usuario objetivo (segregación de funciones)."), + (entity.RequesterId.GetValue(), "El revisor de seguridad no puede ser el solicitante (segregación de funciones)."), + (entity.ApproverId?.GetValue(), "El revisor de seguridad no puede ser el aprobador (segregación de funciones).")); + if (sod.IsFailure) + { + return Result.Failure(sod.Error); + } + + var result = entity.SecurityReject(UserId.Load(actor.Value), request.Reason, ActorId.Create(_userContext.UserId)); + if (result.IsFailure) + { + return result; + } + + await _repository.UpdateAsync(entity, cancellationToken); + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/SubmitRolePromotionCommand.cs b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/SubmitRolePromotionCommand.cs new file mode 100644 index 00000000..2e54e09f --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/SubmitRolePromotionCommand.cs @@ -0,0 +1,7 @@ +namespace Ums.Application.IGA.RolePromotion.Commands; + +/// +/// Draft → PendingEligibilityCheck (IGA, ADR-UMS-093, FR-061). Calcula el RiskScore vía +/// IRiskScoreCalculator y lo congela en el agregado (INV-RPR2). +/// +public sealed record SubmitRolePromotionCommand(Guid RolePromotionRequestId) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/SubmitRolePromotionCommandHandler.cs b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/SubmitRolePromotionCommandHandler.cs new file mode 100644 index 00000000..10a68ca1 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/SubmitRolePromotionCommandHandler.cs @@ -0,0 +1,86 @@ +namespace Ums.Application.IGA.RolePromotion.Commands; + +using Ums.Application.IGA.Common; +using Ums.Application.IGA.Services; +using Ums.Domain.IGA; + +public sealed class SubmitRolePromotionCommandHandler : ICommandHandler +{ + private readonly IRolePromotionRequestRepository _repository; + private readonly IRiskScoreCalculator _riskScoreCalculator; + private readonly ITenantScopePolicy _tenantScopePolicy; + private readonly IUserContext _userContext; + + public SubmitRolePromotionCommandHandler( + IRolePromotionRequestRepository repository, + IRiskScoreCalculator riskScoreCalculator, + ITenantScopePolicy tenantScopePolicy, + IUserContext userContext) + { + _repository = repository; + _riskScoreCalculator = riskScoreCalculator; + _tenantScopePolicy = tenantScopePolicy; + _userContext = userContext; + } + + [AuditTrail] + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task Handle(SubmitRolePromotionCommand request, CancellationToken cancellationToken) + { + var actor = IgaHandlerGuards.RequireAuthenticatedUser(_userContext); + if (actor.IsFailure) + { + return Result.Failure(actor.Error); + } + + var entity = await _repository.GetByIdAsync(request.RolePromotionRequestId, cancellationToken); + if (entity is null) + { + return Result.Failure(DomainErrors.IGA.RolePromotionRequestNotFound); + } + + var scope = IgaHandlerGuards.EnsureTenantInScope(_tenantScopePolicy, entity.TenantId.GetValue()); + if (scope.IsFailure) + { + return Result.Failure(scope.Error); + } + + // SoD: quien envía (calcula y congela el riesgo) no puede ser el usuario objetivo. + var sod = IgaHandlerGuards.EnsureSegregation( + actor.Value, + (entity.TargetUserId.GetValue(), "El usuario objetivo no puede enviar su propia promoción (segregación de funciones).")); + if (sod.IsFailure) + { + return Result.Failure(sod.Error); + } + + var assessment = await _riskScoreCalculator.CalculateAsync( + new RolePromotionRiskContext( + entity.TenantId.GetValue(), + entity.TargetUserId.GetValue(), + entity.CurrentRoleId.GetValue(), + entity.TargetRoleId.GetValue()), + cancellationToken); + if (assessment.IsFailure) + { + return Result.Failure(assessment.Error); + } + + var riskScoreResult = RiskScore.Create(assessment.Value.Score); + if (riskScoreResult.IsFailure) + { + return Result.Failure(riskScoreResult.Error); + } + + var submitResult = entity.Submit(riskScoreResult.Value, ActorId.Create(_userContext.UserId)); + if (submitResult.IsFailure) + { + return submitResult; + } + + await _repository.UpdateAsync(entity, cancellationToken); + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/VerifyRolePromotionCommand.cs b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/VerifyRolePromotionCommand.cs new file mode 100644 index 00000000..43f455bf --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/VerifyRolePromotionCommand.cs @@ -0,0 +1,4 @@ +namespace Ums.Application.IGA.RolePromotion.Commands; + +/// Executed → Verified (verificación post-ejecución por un auditor). IGA, ADR-UMS-093, INV-RPR5. +public sealed record VerifyRolePromotionCommand(Guid RolePromotionRequestId) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/VerifyRolePromotionCommandHandler.cs b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/VerifyRolePromotionCommandHandler.cs new file mode 100644 index 00000000..3866f5e0 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Commands/VerifyRolePromotionCommandHandler.cs @@ -0,0 +1,66 @@ +namespace Ums.Application.IGA.RolePromotion.Commands; + +using Ums.Application.IGA.Common; +using Ums.Domain.IGA; + +public sealed class VerifyRolePromotionCommandHandler : ICommandHandler +{ + private readonly IRolePromotionRequestRepository _repository; + private readonly ITenantScopePolicy _tenantScopePolicy; + private readonly IUserContext _userContext; + + public VerifyRolePromotionCommandHandler( + IRolePromotionRequestRepository repository, + ITenantScopePolicy tenantScopePolicy, + IUserContext userContext) + { + _repository = repository; + _tenantScopePolicy = tenantScopePolicy; + _userContext = userContext; + } + + [AuditTrail] + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task Handle(VerifyRolePromotionCommand request, CancellationToken cancellationToken) + { + var actor = IgaHandlerGuards.RequireAuthenticatedUser(_userContext); + if (actor.IsFailure) + { + return Result.Failure(actor.Error); + } + + var entity = await _repository.GetByIdAsync(request.RolePromotionRequestId, cancellationToken); + if (entity is null) + { + return Result.Failure(DomainErrors.IGA.RolePromotionRequestNotFound); + } + + var scope = IgaHandlerGuards.EnsureTenantInScope(_tenantScopePolicy, entity.TenantId.GetValue()); + if (scope.IsFailure) + { + return Result.Failure(scope.Error); + } + + // SoD (INV-RPR3): el verificador no puede ser el objetivo, el ejecutor ni el aprobador. + var sod = IgaHandlerGuards.EnsureSegregation( + actor.Value, + (entity.TargetUserId.GetValue(), "El verificador no puede ser el usuario objetivo (segregación de funciones)."), + (entity.ExecutorId?.GetValue(), "El verificador no puede ser el ejecutor (segregación de funciones)."), + (entity.ApproverId?.GetValue(), "El verificador no puede ser el aprobador (segregación de funciones).")); + if (sod.IsFailure) + { + return Result.Failure(sod.Error); + } + + var result = entity.Verify(UserId.Load(actor.Value), ActorId.Create(_userContext.UserId)); + if (result.IsFailure) + { + return result; + } + + await _repository.UpdateAsync(entity, cancellationToken); + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Queries/GetRolePromotionRequestByIdQuery.cs b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Queries/GetRolePromotionRequestByIdQuery.cs new file mode 100644 index 00000000..5ac20d42 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Queries/GetRolePromotionRequestByIdQuery.cs @@ -0,0 +1,6 @@ +namespace Ums.Application.IGA.RolePromotion.Queries; + +using Ums.Application.IGA.DTOs; + +/// Obtiene una solicitud de promoción de rol por su identificador, acotada por inquilino. IGA, ADR-UMS-093. +public sealed record GetRolePromotionRequestByIdQuery(Guid RolePromotionRequestId) : IQuery; diff --git a/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Queries/GetRolePromotionRequestByIdQueryHandler.cs b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Queries/GetRolePromotionRequestByIdQueryHandler.cs new file mode 100644 index 00000000..0d9ed50a --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Queries/GetRolePromotionRequestByIdQueryHandler.cs @@ -0,0 +1,40 @@ +namespace Ums.Application.IGA.RolePromotion.Queries; + +using Ums.Application.IGA.Common; +using Ums.Application.IGA.DTOs; +using Ums.Domain.IGA; + +public sealed class GetRolePromotionRequestByIdQueryHandler + : IQueryHandler +{ + private readonly IRolePromotionRequestRepository _repository; + private readonly ITenantScopePolicy _tenantScopePolicy; + + public GetRolePromotionRequestByIdQueryHandler( + IRolePromotionRequestRepository repository, + ITenantScopePolicy tenantScopePolicy) + { + _repository = repository; + _tenantScopePolicy = tenantScopePolicy; + } + + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task> Handle( + GetRolePromotionRequestByIdQuery request, + CancellationToken cancellationToken) + { + var entity = await _repository.GetByIdAsync(request.RolePromotionRequestId, cancellationToken); + if (entity is null) + { + return Result.Failure(DomainErrors.IGA.RolePromotionRequestNotFound); + } + + var scope = IgaHandlerGuards.EnsureTenantInScope(_tenantScopePolicy, entity.TenantId.GetValue()); + if (scope.IsFailure) + { + return Result.Failure(scope.Error); + } + + return Result.Success(RolePromotionMapper.ToDto(entity)); + } +} diff --git a/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Queries/ListRolePromotionRequestsQuery.cs b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Queries/ListRolePromotionRequestsQuery.cs new file mode 100644 index 00000000..902038d1 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Queries/ListRolePromotionRequestsQuery.cs @@ -0,0 +1,10 @@ +namespace Ums.Application.IGA.RolePromotion.Queries; + +using Ums.Application.IGA.DTOs; + +/// +/// Lista solicitudes de promoción de rol acotadas por inquilino y, opcionalmente, por estado +/// (nombre de RolePromotionStatus). IGA, ADR-UMS-093. +/// +public sealed record ListRolePromotionRequestsQuery(Guid? TenantId, string? Status) + : IQuery>; diff --git a/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Queries/ListRolePromotionRequestsQueryHandler.cs b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Queries/ListRolePromotionRequestsQueryHandler.cs new file mode 100644 index 00000000..dab0c990 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/RolePromotion/Queries/ListRolePromotionRequestsQueryHandler.cs @@ -0,0 +1,49 @@ +namespace Ums.Application.IGA.RolePromotion.Queries; + +using Ums.Application.IGA.DTOs; +using Ums.Domain.IGA; +using Ums.Domain.IGA.RolePromotionRequest; +using RolePromotionRequestAggregate = Ums.Domain.IGA.RolePromotionRequest.RolePromotionRequest; + +public sealed class ListRolePromotionRequestsQueryHandler + : IQueryHandler> +{ + private readonly IRolePromotionRequestRepository _repository; + private readonly ITenantScopePolicy _tenantScopePolicy; + + public ListRolePromotionRequestsQueryHandler( + IRolePromotionRequestRepository repository, + ITenantScopePolicy tenantScopePolicy) + { + _repository = repository; + _tenantScopePolicy = tenantScopePolicy; + } + + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task>> Handle( + ListRolePromotionRequestsQuery request, + CancellationToken cancellationToken) + { + // Acotación por inquilino: un usuario regular queda ceñido a su inquilino; un administrador + // interno (alcance nulo) puede consultar el inquilino solicitado o, en su defecto, todos. + var scope = _tenantScopePolicy.ResolveQueryScope(); + var effectiveTenantId = scope ?? request.TenantId; + + IReadOnlyList items; + if (effectiveTenantId is null) + { + items = await _repository.GetAllAsync(null, cancellationToken); + } + else if (!string.IsNullOrWhiteSpace(request.Status)) + { + items = await _repository.GetByTenantAndStatusAsync(effectiveTenantId.Value, request.Status!, cancellationToken); + } + else + { + items = await _repository.GetByTenantIdAsync(effectiveTenantId.Value, cancellationToken); + } + + var dtos = items.Select(RolePromotionMapper.ToDto).ToList(); + return Result>.Success(dtos); + } +} diff --git a/src/apps/ums.api/Ums.Application/IGA/Services/HeuristicRiskScoreCalculator.cs b/src/apps/ums.api/Ums.Application/IGA/Services/HeuristicRiskScoreCalculator.cs new file mode 100644 index 00000000..b5319009 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/Services/HeuristicRiskScoreCalculator.cs @@ -0,0 +1,90 @@ +namespace Ums.Application.IGA.Services; + +using Ums.Domain.Authorization; + +/// +/// Implementación heurística versionada (iga-risk-heuristic-v1) del cálculo del RiskScore +/// de una promoción de rol (FR-061, ADR-UMS-093). +/// +/// Fórmula v1 (determinista, acotada a [0, 100]): +/// base (10) +/// + escalación jerárquica = max(0, nivelObjetivo − nivelActual) · 15 (proxy de permisos nuevos) +/// + salto de orden de promoción = min(3, ordenObjetivo − ordenActual) · 10 +/// + sensibilidad del rol objetivo por banda de nivel jerárquico (≥4:30, 3:20, 2:10, resto:5) +/// +/// Limitación conocida (registrada como seguimiento de ADR-UMS-093): esta v1 usa metadatos del rol +/// (nivel jerárquico y orden de promoción) como proxy. Aún NO calcula el diferencial real de +/// permisos nuevos/removidos ni las combinaciones tóxicas (conflictos SoD con los perfiles/roles +/// vigentes del usuario), que requieren el grafo de autorización (ADR-UMS-088). La infraestructura puede +/// sustituir por una implementación respaldada por el grafo. +/// +public sealed class HeuristicRiskScoreCalculator : IRiskScoreCalculator +{ + public const string ModelVersion = "iga-risk-heuristic-v1"; + + private const int BaseWeight = 10; + private const int EscalationWeightPerLevel = 15; + private const int PromotionOrderWeightPerStep = 10; + private const int MaxPromotionOrderSteps = 3; + + private readonly IRoleRepository _roleRepository; + + public HeuristicRiskScoreCalculator(IRoleRepository roleRepository) + { + _roleRepository = roleRepository; + } + + public async Task> CalculateAsync( + RolePromotionRiskContext context, + CancellationToken cancellationToken = default) + { + var currentRole = await _roleRepository.GetByIdAsync(context.CurrentRoleId, cancellationToken); + if (currentRole is null) + { + return Result.Failure("No se encontró el rol actual para calcular el RiskScore."); + } + + var targetRole = await _roleRepository.GetByIdAsync(context.TargetRoleId, cancellationToken); + if (targetRole is null) + { + return Result.Failure("No se encontró el rol objetivo para calcular el RiskScore."); + } + + var factors = new List(); + + factors.Add(new RiskFactor("base", BaseWeight, "Riesgo base de toda promoción de rol.")); + + var escalation = Math.Max(0, targetRole.HierarchyLevel - currentRole.HierarchyLevel); + var escalationWeight = escalation * EscalationWeightPerLevel; + factors.Add(new RiskFactor( + "escalacion_jerarquica", + escalationWeight, + $"Escalación de {escalation} nivel(es) jerárquico(s) (proxy de permisos nuevos).")); + + var promotionSteps = Math.Min(MaxPromotionOrderSteps, Math.Max(0, targetRole.PromotionOrder - currentRole.PromotionOrder)); + var promotionWeight = promotionSteps * PromotionOrderWeightPerStep; + factors.Add(new RiskFactor( + "salto_orden_promocion", + promotionWeight, + $"Salto de {promotionSteps} paso(s) en el orden de promoción.")); + + var sensitivityWeight = SensitivityWeightFor(targetRole.HierarchyLevel); + factors.Add(new RiskFactor( + "sensibilidad_rol_objetivo", + sensitivityWeight, + $"Sensibilidad del rol objetivo por banda de nivel {targetRole.HierarchyLevel}.")); + + var raw = factors.Sum(f => f.Weight); + var score = Math.Clamp(raw, RiskScore.Min, RiskScore.Max); + + return Result.Success(new RiskAssessment(score, ModelVersion, factors)); + } + + private static int SensitivityWeightFor(int hierarchyLevel) => hierarchyLevel switch + { + >= 4 => 30, + 3 => 20, + 2 => 10, + _ => 5, + }; +} diff --git a/src/apps/ums.api/Ums.Application/IGA/Services/IRiskScoreCalculator.cs b/src/apps/ums.api/Ums.Application/IGA/Services/IRiskScoreCalculator.cs new file mode 100644 index 00000000..8307f520 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/IGA/Services/IRiskScoreCalculator.cs @@ -0,0 +1,33 @@ +namespace Ums.Application.IGA.Services; + +/// +/// Contexto de cálculo del RiskScore de una promoción de rol (FR-061, ADR-UMS-093). +/// Identifica el inquilino, el usuario objetivo y el par de roles (actual → objetivo) +/// cuyo salto de privilegio se evalúa. +/// +public sealed record RolePromotionRiskContext( + Guid TenantId, + Guid TargetUserId, + Guid CurrentRoleId, + Guid TargetRoleId); + +/// Factor individual que contribuye al RiskScore, con su peso y una descripción trazable. +public sealed record RiskFactor(string Code, int Weight, string Description); + +/// +/// Evaluación de riesgo congelable de una promoción: la puntuación [0, 100], la versión del modelo +/// (riskModelVersion) para trazar cambios de fórmula y el desglose de factores. +/// +public sealed record RiskAssessment(int Score, string RiskModelVersion, IReadOnlyList Factors); + +/// +/// Puerto de cálculo del RiskScore de impacto tóxico de una promoción de rol (FR-061, ADR-UMS-093). +/// Se invoca una única vez, al hacer Submit (Draft → PendingEligibilityCheck), y su resultado +/// se congela en el agregado (INV-RPR2). La implementación por defecto es una heurística versionada +/// (); la infraestructura puede sustituirla por una +/// implementación respaldada por el grafo de autorización (ADR-UMS-088) sin tocar los handlers. +/// +public interface IRiskScoreCalculator +{ + Task> CalculateAsync(RolePromotionRiskContext context, CancellationToken cancellationToken = default); +} diff --git a/src/apps/ums.api/Ums.Application/Identity/Auth/AuthMethodResolverService.cs b/src/apps/ums.api/Ums.Application/Identity/Auth/AuthMethodResolverService.cs index 976bbfcf..46f840b6 100644 --- a/src/apps/ums.api/Ums.Application/Identity/Auth/AuthMethodResolverService.cs +++ b/src/apps/ums.api/Ums.Application/Identity/Auth/AuthMethodResolverService.cs @@ -1,7 +1,10 @@ using Ums.Application.Configuration.Services; +using Ums.Domain.Configuration; using Ums.Domain.Configuration.AppConfiguration; +using Ums.Domain.Configuration.IdpConfiguration; using Ums.Domain.Identity; using Ums.Domain.Identity.Auth; +using Ums.Domain.Identity.Tenant.IdentityProvider; namespace Ums.Application.Identity.Auth; @@ -14,23 +17,38 @@ namespace Ums.Application.Identity.Auth; /// - false → AuthMethod.Local() /// - true + active IDP → AuthMethod.Idp(provider) /// - true + no active IDP: -/// - ExternalApi → Result.Failure("AUTH_011") +/// - ExternalApi → AuthMethod.Local() (G-049) /// - InternalPreview → AuthMethod(Type = IDP, Provider = null) +/// +/// FR-042 (ADR-UMS-097, slice 2a): en modo IDP el proveedor se elige por el motor de reglas +/// ( sobre las IdpConfiguration del inquilino), por +/// prioridad/suite/dominio (desempate por versión), en lugar del antiguo FirstOrDefault(IsActive). +/// La configuración ganadora se reconcilia con el IdentityProvider que consume el adaptador +/// (puente por estrategia ↔ tipo de proveedor). Si el inquilino no tiene ninguna IdpConfiguration +/// que gobierne la selección, se conserva el comportamiento previo (único proveedor activo). +/// No hay fallback encadenado: esto es slice 2a; un fallo de credenciales es terminal (§2.3). /// public sealed class AuthMethodResolverService : IAuthMethodResolver { - private readonly IConfigurationProvider _config; - private readonly ITenantRepository _tenantRepo; + private readonly IConfigurationProvider _config; + private readonly ITenantRepository _tenantRepo; + private readonly IIdpConfigurationRepository _idpConfigRepo; - public AuthMethodResolverService(IConfigurationProvider config, ITenantRepository tenantRepo) + public AuthMethodResolverService( + IConfigurationProvider config, + ITenantRepository tenantRepo, + IIdpConfigurationRepository idpConfigRepo) { - _config = config; - _tenantRepo = tenantRepo; + _config = config; + _tenantRepo = tenantRepo; + _idpConfigRepo = idpConfigRepo; } public async Task> ResolveAsync( Guid tenantId, AuthAccessScope scope, + Guid? systemSuiteId = null, + string? emailDomain = null, CancellationToken cancellationToken = default) { if (scope == AuthAccessScope.PortalManagement) @@ -54,24 +72,63 @@ public async Task> ResolveAsync( if (!useExternalIdp) return Result.Success(AuthMethod.Local()); - // IDP mode — find the active identity provider for this tenant + // IDP mode — select the identity provider for this tenant by the FR-042 rules. var tenant = await _tenantRepo.GetByIdAsync(tenantId, cancellationToken); if (tenant is null) return Result.Failure($"AUTH_002: Tenant {tenantId} not found."); - var activeIdp = tenant.IdentityProviders.FirstOrDefault(p => p.IsActive); - if (activeIdp is null) + // FR-042 (ADR-UMS-097 §2.2): procedencia de la suite pre-autenticación. La suite viene del + // AccessScope que el login transporta (systemSuiteId); si el scope no la fija, se usa el + // default del inquilino. Null ⇒ inquilino de suite única ⇒ el selector omite el filtro por suite. + var effectiveSuiteId = systemSuiteId ?? tenant.DefaultSystemSuiteId?.GetValue(); + + var provider = await SelectProviderAsync(tenant, tenantId, effectiveSuiteId, emailDomain, cancellationToken); + + if (provider is null) { if (scope == AuthAccessScope.InternalPreview) { return Result.Success(new AuthMethod(AuthMethodType.IDP)); } - return Result.Failure( - "AUTH_011: Tenant is configured for external IDP authentication " + - "but has no active Identity Provider. Configure and activate an IDP first."); + // Fallback a Local según G-049 + return Result.Success(AuthMethod.Local()); + } + + return Result.Success(AuthMethod.Idp(provider)); + } + + /// + /// Elige el IdentityProvider aplicando la regla de selección FR-042 sobre las + /// IdpConfiguration del inquilino y reconciliándola con el proveedor que consume el adaptador. + /// + private async Task SelectProviderAsync( + Ums.Domain.Identity.Tenant.Tenant tenant, + Guid tenantId, + Guid? systemSuiteId, + string? emailDomain, + CancellationToken cancellationToken) + { + var configurations = await _idpConfigRepo.GetByTenantIdAsync(tenantId, cancellationToken); + var selection = IdpConfigurationSelector.Select(configurations, systemSuiteId, emailDomain, providerType: null); + + if (selection is null) + { + // Ninguna IdpConfiguration gobierna la selección → se conserva el comportamiento previo + // (único proveedor activo). No se rompe a los inquilinos aún no migrados a reglas. + return tenant.GetActiveIdentityProvider(); } - return Result.Success(AuthMethod.Idp(activeIdp)); + // Puente reglas ↔ dominio (ADR-UMS-097 §2.5): la configuración ganadora determina el + // IdentityProvider activo cuyo IdpStrategy corresponde al ProviderType de la regla. + // ProviderType e IdpStrategy son enumeraciones paralelas por Id (mismo par 1..10, verificado + // por la persistencia que las guarda por Id), lo que da un puente 1-a-1 sin Code/Name en el DTO. + var winnerProviderTypeId = selection.Value.Configuration.ProviderType.Id; + var bridged = tenant.IdentityProviders + .FirstOrDefault(ip => ip.IsActive && ip.Strategy.Id == winnerProviderTypeId); + + // Si la regla eligió un proveedor que el inquilino no tiene activo, NO se sustituye por otro + // (autenticar contra un IdP no elegido sería incorrecto): se trata como «sin proveedor usable». + return bridged; } } diff --git a/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/AuthenticateUserCommand.cs b/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/AuthenticateUserCommand.cs index 18b61dc4..35489db5 100644 --- a/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/AuthenticateUserCommand.cs +++ b/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/AuthenticateUserCommand.cs @@ -6,6 +6,12 @@ namespace Ums.Application.Identity.Auth.Commands; /// /// Authenticate a user by tenant code + credentials. /// Returns a complete AuthorizationGraph on success. +/// +/// FR-042 (ADR-UMS-097 §2.2): transporta la suite del contexto de +/// resolución del IdP. Hoy el no fija suite y el inquilino no tiene +/// suite por defecto, así que queda null (sin filtro de suite); se enhebra ahora para que +/// una fuente de suite (slice posterior) la pueble sin volver a tocar el contrato. El dominio de +/// email se deriva del en el handler. No cambia el contrato REST de /auth/login. /// public sealed record AuthenticateUserCommand( string TenantCode, @@ -13,7 +19,15 @@ public sealed record AuthenticateUserCommand( string Password, string ClientIp, AuthAccessScope AccessScope, - bool RememberMe = false) : ICommand; + bool RememberMe = false, + Guid? SystemSuiteId = null, + // ADR-0156 §3.2 — código del sistema que pide el grafo, OPCIONAL. Presente, el grafo se acota + // a los perfiles del usuario en ese sistema; ausente, viajan todos (portal multiproducto). + // + // NO se reutiliza `SystemSuiteId` para esto: es el contexto de resolución de IdP de FR-042, + // viaja como identificador y hoy siempre llega null. Mezclar ambas semánticas en un campo + // ataría el enrutado de IdP al filtro de perfiles, que son problemas distintos (§3.3). + string? SystemCode = null) : ICommand; /// Result of a successful authentication — graph + raw JWT. public sealed record AuthenticateUserResult( diff --git a/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/AuthenticateUserCommandHandler.cs b/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/AuthenticateUserCommandHandler.cs index b7989101..767b5784 100644 --- a/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/AuthenticateUserCommandHandler.cs +++ b/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/AuthenticateUserCommandHandler.cs @@ -25,9 +25,8 @@ public sealed class AuthenticateUserCommandHandler private readonly IUserAccountRepository _userRepo; private readonly IAuthMethodResolver _methodResolver; private readonly ILocalAuthStrategy _localStrategy; - private readonly IIdpAuthStrategy _idpStrategy; + private readonly IIdpChainAuthenticator _chainAuthenticator; private readonly IAuthorizationGraphBuilder _graphBuilder; - private readonly IAuthGraphFormatProvider _formatProvider; private readonly IAuthorizationGraphSerializer _defaultSerializer; private readonly IAuthAuditService _auditService; private readonly IConfigurationProvider _configProvider; @@ -37,23 +36,21 @@ public AuthenticateUserCommandHandler( IUserAccountRepository userRepo, IAuthMethodResolver methodResolver, ILocalAuthStrategy localStrategy, - IIdpAuthStrategy idpStrategy, + IIdpChainAuthenticator chainAuthenticator, IAuthorizationGraphBuilder graphBuilder, - IAuthGraphFormatProvider formatProvider, IAuthorizationGraphSerializer defaultSerializer, IAuthAuditService auditService, IConfigurationProvider configProvider) { - _tenantRepo = tenantRepo; - _userRepo = userRepo; - _methodResolver = methodResolver; - _localStrategy = localStrategy; - _idpStrategy = idpStrategy; - _graphBuilder = graphBuilder; - _formatProvider = formatProvider; - _defaultSerializer = defaultSerializer; - _auditService = auditService; - _configProvider = configProvider; + _tenantRepo = tenantRepo; + _userRepo = userRepo; + _methodResolver = methodResolver; + _localStrategy = localStrategy; + _chainAuthenticator = chainAuthenticator; + _graphBuilder = graphBuilder; + _defaultSerializer = defaultSerializer; + _auditService = auditService; + _configProvider = configProvider; } public async Task> Handle( @@ -86,9 +83,13 @@ await RecordFailureAsync(tenantId, userId, command, "AUTH_003: Tenant inactive", tenantId = tenant.Props.Id.GetValue(); // ── 2. Auth method resolution ────────────────────────────────────── + // FR-042 (ADR-UMS-097 §2.2): se enhebra el contexto de resolución (suite del comando + + // dominio de email derivado del Username) hacia el motor de reglas del resolver. var methodResult = await _methodResolver.ResolveAsync( tenantId, command.AccessScope, + command.SystemSuiteId, + ExtractEmailDomain(command.Username), cancellationToken); if (methodResult.IsFailure) { @@ -109,7 +110,7 @@ await RecordFailureAsync(tenantId, userId, command, methodResult.Error, else { return await AuthenticateIdpAsync( - command, tenant, tenantId, authMethod, methodName, cancellationToken); + command, tenant, tenantId, methodName, cancellationToken); } } catch (Exception ex) @@ -131,10 +132,15 @@ private async Task> AuthenticateLocalAsync( string methodName, CancellationToken cancellationToken) { - var user = await _userRepo.GetByEmailAsync( - Email.Create(command.Username), cancellationToken); + // G-168: la búsqueda se acota al inquilino. `GetByEmailAsync` consulta sin filtro de + // inquilino y sin `ORDER BY`: con el mismo correo dado de alta en dos inquilinos —la + // unicidad es (TenantId, Email), así que es un estado válido— devolvía una fila + // arbitraria, y qué fila ganaba dependía del plan de ejecución. El usuario legítimo + // podía quedarse fuera de su propio inquilino sin explicación posible en el log. + var user = await _userRepo.GetByTenantAndEmailAsync( + tenantId, Email.Create(command.Username), cancellationToken: cancellationToken); - if (user is null || user.Props.TenantId.GetValue() != tenantId) + if (user is null) { await RecordFailureAsync(tenantId, Guid.Empty, command, "AUTH_006: Invalid credentials", methodName, cancellationToken); @@ -149,11 +155,30 @@ await RecordFailureAsync(tenantId, user.Props.Id.GetValue(), command, "AUTH_005: User account is not active. Contact your administrator."); } + // ADR-UMS-095: política de bloqueo temporal. El instante se resuelve aquí (aplicación) y se + // inyecta en el dominio, que es determinista. La misma fuente de «ahora» rige la comprobación + // y el registro del intento. maxAttempts/lockoutMinutes salen de la config efectiva (cascada + // Global>Suite>Tenant>Module). El bloqueo administrativo permanente ya se rechazó arriba (AUTH_005). + var cfg = _configProvider.ForTenant(tenantId); + var now = DateTimeOffset.UtcNow; + var maxLoginAttempts = cfg.MaxLoginAttempts; + var lockoutMinutes = cfg.AccountLockoutDurationMinutes; + + // ADR-UMS-095: enforcement ANTES de validar credenciales y sin revelar su validez. + // AUTH_017 = bloqueo temporal de cuenta (AUTH_012 ya está tomado: «no IDP adapter registered»). + if (user.IsLockedOut(now)) + { + await RecordFailureAsync(tenantId, user.Props.Id.GetValue(), command, + "AUTH_017: Account temporarily locked", methodName, cancellationToken); + return Result.Failure( + "AUTH_017: Account temporarily locked due to failed login attempts."); + } + var authResult = _localStrategy.Authenticate(user, command.Password); if (authResult.IsFailure) { - user.RecordAuthenticationAttempt(false, authResult.Error, - command.ClientIp, ActorId.Create("auth:system")); + user.RecordAuthenticationAttempt(false, now, maxLoginAttempts, lockoutMinutes, + authResult.Error, command.ClientIp, ActorId.Create("auth:system")); await _userRepo.UpdateAsync(user, cancellationToken); await _userRepo.UnitOfWork.SaveEntitiesAsync(cancellationToken); @@ -170,8 +195,8 @@ await RecordFailureAsync(tenantId, user.Props.Id.GetValue(), command, return Result.Failure(mfaCheck.Error); } - user.RecordAuthenticationAttempt(true, "Login successful", - command.ClientIp, ActorId.Create("auth:system")); + user.RecordAuthenticationAttempt(true, now, maxLoginAttempts, lockoutMinutes, + "Login successful", command.ClientIp, ActorId.Create("auth:system")); await _userRepo.UpdateAsync(user, cancellationToken); await _userRepo.UnitOfWork.SaveEntitiesAsync(cancellationToken); @@ -186,23 +211,36 @@ private async Task> AuthenticateIdpAsync( AuthenticateUserCommand command, Ums.Domain.Identity.Tenant.Tenant tenant, Guid tenantId, - AuthMethod authMethod, string methodName, CancellationToken cancellationToken) { - var idpResult = await _idpStrategy.AuthenticateAsync( - tenantId, command.Password, authMethod.Provider!, cancellationToken); - - if (idpResult.IsFailure) + // FR-042 (ADR-UMS-097 §2.3/§2.4, slice 2b): la autenticación federada recorre la cadena de + // fallback FallbackToId partiendo de la config ganadora del selector (2a). El avance de la + // cadena SOLO ocurre por indisponibilidad de infraestructura; un fallo de credenciales es + // TERMINAL (anti credential-spraying). El orquestador clasifica fail-closed y audita por intento. + var chainResult = await _chainAuthenticator.AuthenticateAsync( + tenant, command.Password, command.SystemSuiteId, + ExtractEmailDomain(command.Username), command.ClientIp, cancellationToken); + + if (chainResult.IsFailure) { await RecordFailureAsync(tenantId, Guid.Empty, command, - idpResult.Error, methodName, cancellationToken); - return Result.Failure(idpResult.Error); + chainResult.Error, methodName, cancellationToken); + return Result.Failure(chainResult.Error); } - var externalId = idpResult.Value; - var user = await _userRepo.GetByEmailAsync( - Email.Create(externalId.Email), cancellationToken); + var externalId = chainResult.Value.Identity; + // El proveedor que EFECTIVAMENTE autenticó puede ser uno de respaldo (distinto al primario): + // el grafo y la auditoría de éxito se construyen con ese proveedor, no con el ganador inicial. + var resolvedMethod = AuthMethod.Idp(chainResult.Value.Provider); + + // G-168: acotado al inquilino, igual que la rama local. Aquí el defecto era peor: no + // existía NINGUNA comprobación posterior de `TenantId`, de modo que una cuenta de otro + // inquilino que compartiera el correo devuelto por el IdP pasaba a construir el grafo + // con el inquilino solicitado. Eso no es una denegación mal puesta: es un cruce de + // frontera de inquilino en la emisión de permisos. + var user = await _userRepo.GetByTenantAndEmailAsync( + tenantId, Email.Create(externalId.Email), cancellationToken: cancellationToken); if (user is null) { @@ -228,14 +266,17 @@ await RecordFailureAsync(tenantId, user.Props.Id.GetValue(), command, return Result.Failure(mfaCheck.Error); } - user.RecordAuthenticationAttempt(true, "IDP login successful", - command.ClientIp, ActorId.Create("auth:system")); + // ADR-UMS-095: en el flujo IDP el bloqueo por contraseña no aplica; el registro exitoso + // resetea cualquier contador previo. maxAttempts/lockoutMinutes salen de la config efectiva. + var idpCfg = _configProvider.ForTenant(tenantId); + user.RecordAuthenticationAttempt(true, DateTimeOffset.UtcNow, idpCfg.MaxLoginAttempts, idpCfg.AccountLockoutDurationMinutes, + "IDP login successful", command.ClientIp, ActorId.Create("auth:system")); await _userRepo.UpdateAsync(user, cancellationToken); await _userRepo.UnitOfWork.SaveEntitiesAsync(cancellationToken); return await BuildResultAsync( user, tenantId, tenant.Props.Code.GetValue(), - authMethod, command, methodName, cancellationToken); + resolvedMethod, command, methodName, cancellationToken); } // ── Shared Result Builder ───────────────────────────────────────────────── @@ -249,7 +290,10 @@ private async Task> BuildResultAsync( string methodName, CancellationToken cancellationToken) { - var graphResult = await _graphBuilder.BuildAsync(user, tenantId, authMethod, cancellationToken); + // ADR-0156 §4: el código de sistema del comando acota el grafo a los perfiles del usuario + // en ese sistema. Ausente —el caso del portal multiproducto— no acota nada. + var graphResult = await _graphBuilder.BuildAsync( + user, tenantId, authMethod, command.SystemCode, cancellationToken); if (graphResult.IsFailure) { await RecordFailureAsync(tenantId, user.Props.Id.GetValue(), command, @@ -257,9 +301,20 @@ await RecordFailureAsync(tenantId, user.Props.Id.GetValue(), command, return Result.Failure(graphResult.Error); } - var graph = graphResult.Value; - var format = await _formatProvider.GetDefaultFormatAsync(tenantId, cancellationToken); + var graph = graphResult.Value; + + // G-176: se declara el formato que REALMENTE se produjo, no el preferido del inquilino. + // Antes se anunciaba el formato por defecto del inquilino mientras se serializaba + // siempre con el serializador inyectado (JSON): un inquilino con XML por defecto recibía + // la cabecera `X-Graph-Format: XML` sobre un cuerpo JSON, que es peor que no soportar XML. + // El endpoint de cliente sigue re-serializando cuando el llamante pide otro formato por + // `?format` o `Accept`. Honrar automáticamente la preferencia del inquilino exige + // resolver el serializador por fábrica en este punto: queda anotado en G-176. var serialized = _defaultSerializer.Serialize(graph); + // Defensivo: un serializador sin extensión declarada no debe tumbar un login. + var format = string.IsNullOrWhiteSpace(_defaultSerializer.FileExtension) + ? "JSON" + : _defaultSerializer.FileExtension.ToUpperInvariant(); await _auditService.RecordAuthEventAsync(new AuthAuditEvent( UserId: user.Props.Id.GetValue(), @@ -301,6 +356,21 @@ private Result CheckMfaPolicy( return Result.Success(); } + // FR-042 (ADR-UMS-097 §2.2): el dominio de email para el routing por dominio sale del identificador + // de login cuando es un email; si no lo es, se devuelve null y el motor omite el filtro por dominio. + private static string? ExtractEmailDomain(string username) + { + if (string.IsNullOrWhiteSpace(username)) + { + return null; + } + + var atIndex = username.IndexOf('@'); + return atIndex >= 0 && atIndex < username.Length - 1 + ? username[(atIndex + 1)..] + : null; + } + private async Task RecordFailureAsync( Guid tenantId, Guid userId, diff --git a/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/ForgotPasswordCommand.cs b/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/ForgotPasswordCommand.cs index 1d71dbad..bfa00dff 100644 --- a/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/ForgotPasswordCommand.cs +++ b/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/ForgotPasswordCommand.cs @@ -3,7 +3,9 @@ namespace Ums.Application.Identity.Auth.Commands; public sealed record ForgotPasswordCommand(string TenantCode, string Email) : ICommand; -public sealed record ForgotPasswordResponse( - string Message, - string? SimulatedTemporaryPassword // visible in dev/simulated mode only -); +/// +/// Respuesta del flujo anónimo. Lleva un ÚNICO campo, y siempre con el mismo valor: cualquier +/// dato que dependa de si la cuenta existe convierte el endpoint en un oráculo de enumeración. +/// El secreto de restablecimiento no viaja aquí en ningún entorno; sale solo por el buzón. +/// +public sealed record ForgotPasswordResponse(string Message); diff --git a/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/ForgotPasswordCommandHandler.cs b/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/ForgotPasswordCommandHandler.cs index 22d5c08d..03653538 100644 --- a/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/ForgotPasswordCommandHandler.cs +++ b/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/ForgotPasswordCommandHandler.cs @@ -1,95 +1,107 @@ -using System.Security.Cryptography; +using System.Diagnostics; using Ums.Application.Common.Interfaces; using Ums.Application.Common.Notifications; namespace Ums.Application.Identity.Auth.Commands; +/// +/// Solicitud anónima de restablecimiento (G-188). +/// +/// Lo que este flujo NO hace, y por qué: no cambia la contraseña ni devuelve +/// credencial alguna. Conocer un correo no prueba poseer el buzón, así que actuar sobre la +/// credencial ante una petición anónima permitía a cualquiera dejar fuera al titular —o entrar +/// en su lugar si la contraseña nueva viajaba en la respuesta—. Lo único que hace es emitir un +/// secreto de un solo uso y vida corta y mandarlo por el buzón; el cambio ocurre en el canje +/// (), que es donde ya hay prueba de posesión. +/// +/// Indistinguibilidad: la respuesta es literalmente la misma instancia lógica para +/// cuenta existente e inexistente —un solo campo con texto fijo— y el tiempo se nivela con +/// , porque el camino «existe» escribe y notifica mientras +/// el otro retorna en seco. +/// public sealed class ForgotPasswordCommandHandler : ICommandHandler { + /// + /// Texto único de la respuesta. Es constante y deliberadamente vago: no confirma ni desmiente + /// que el correo esté registrado. + /// + private const string AmbiguousMessage = + "Si el correo está registrado, recibirá instrucciones para restablecer su contraseña."; + private readonly ITenantRepository _tenantRepository; private readonly IUserAccountRepository _userAccountRepository; - private readonly IPasswordHashingService _passwordHashingService; + private readonly IPasswordResetTokenStore _resetTokenStore; private readonly INotificationService _notificationService; + private readonly IResponseTimingNormalizer _timingNormalizer; public ForgotPasswordCommandHandler( ITenantRepository tenantRepository, IUserAccountRepository userAccountRepository, - IPasswordHashingService passwordHashingService, - INotificationService notificationService) + IPasswordResetTokenStore resetTokenStore, + INotificationService notificationService, + IResponseTimingNormalizer timingNormalizer) { _tenantRepository = tenantRepository; _userAccountRepository = userAccountRepository; - _passwordHashingService = passwordHashingService; + _resetTokenStore = resetTokenStore; _notificationService = notificationService; + _timingNormalizer = timingNormalizer; } public async Task> Handle(ForgotPasswordCommand request, CancellationToken cancellationToken) { - var tenant = await _tenantRepository.GetByCodeAsync(request.TenantCode, cancellationToken); - if (tenant is null) - return Ambiguous(); - - var email = Email.Create(request.Email); - var userAccount = await _userAccountRepository.GetByEmailAsync(email, cancellationToken); + var startedAt = Stopwatch.GetTimestamp(); - // Always return success to avoid user enumeration - var tenantId = tenant.Props.Id.GetValue(); - if (userAccount is null - || userAccount.Props.TenantId.GetValue() != tenantId - || userAccount.IdentityReference is not null) - return Ambiguous(); + // El resultado del intento se descarta a propósito: nada de lo que ocurra aquí dentro + // puede alterar lo que ve el llamante. + await TryIssueResetTokenAsync(request, cancellationToken).ConfigureAwait(false); + await _timingNormalizer.NormalizeAsync(startedAt, cancellationToken).ConfigureAwait(false); - var tempPassword = GenerateTemporaryPassword(); - var hash = _passwordHashingService.Hash(tempPassword); - var actor = ActorId.Create("00000000-0000-0000-0000-000000000001"); // system actor + return Result.Success(new ForgotPasswordResponse(AmbiguousMessage)); + } - var result = userAccount.AddPassword(PasswordHash.Create(hash), actor); - if (result.IsFailure) - return Ambiguous(); + private async Task TryIssueResetTokenAsync(ForgotPasswordCommand request, CancellationToken cancellationToken) + { + var tenant = await _tenantRepository.GetByCodeAsync(request.TenantCode, cancellationToken).ConfigureAwait(false); + if (tenant is null) + return; - await _userAccountRepository.UpdateAsync(userAccount, cancellationToken); - await _userAccountRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + var userAccount = await _userAccountRepository + .GetByEmailAsync(Email.Create(request.Email), cancellationToken) + .ConfigureAwait(false); - await _notificationService.SendAsync( - NotificationTemplates.PasswordReset( - recipient: userAccount.Email.GetValue(), - recipientName: userAccount.Email.GetValue().Split('@')[0], - temporaryPassword: tempPassword), - cancellationToken); + if (userAccount is null || userAccount.Props.TenantId.GetValue() != tenant.Props.Id.GetValue()) + return; - return Result.Success(new ForgotPasswordResponse( - Message: "Si el correo está registrado, recibirá instrucciones para restablecer su contraseña.", - SimulatedTemporaryPassword: tempPassword - )); - } + // Las cuentas federadas no tienen contraseña local que restablecer, y las bloqueadas o + // borradas no deben recuperar acceso por esta vía: en ambos casos el desenlace de cara + // al llamante es idéntico al de un correo desconocido. + if (userAccount.IdentityReference is not null) + return; - private static Result Ambiguous() => - Result.Success(new ForgotPasswordResponse( - Message: "Si el correo está registrado, recibirá instrucciones para restablecer su contraseña.", - SimulatedTemporaryPassword: null - )); + if (userAccount.Status == UserStatus.Blocked + || userAccount.Status == UserStatus.Deleted + || userAccount.Status == UserStatus.Denied) + return; - private static string GenerateTemporaryPassword() - { - const string upper = "ABCDEFGHJKLMNPQRSTUVWXYZ"; - const string lower = "abcdefghjkmnpqrstuvwxyz"; - const string digits = "23456789"; - const string special = "!@#$%&"; - const string all = upper + lower + digits + special; + var token = PasswordResetToken.Generate(); + var issuedAt = DateTime.UtcNow; - var chars = new char[16]; - chars[0] = upper[RandomNumberGenerator.GetInt32(upper.Length)]; - chars[1] = lower[RandomNumberGenerator.GetInt32(lower.Length)]; - chars[2] = digits[RandomNumberGenerator.GetInt32(digits.Length)]; - chars[3] = special[RandomNumberGenerator.GetInt32(special.Length)]; - for (var i = 4; i < chars.Length; i++) - chars[i] = all[RandomNumberGenerator.GetInt32(all.Length)]; + await _resetTokenStore.IssueAsync( + tenant.Props.Id.GetValue(), + userAccount.Props.Id.GetValue(), + PasswordResetToken.Hash(token), + issuedAt, + issuedAt.Add(PasswordResetToken.Lifetime), + cancellationToken).ConfigureAwait(false); - for (var i = chars.Length - 1; i > 0; i--) - { - var j = RandomNumberGenerator.GetInt32(i + 1); - (chars[i], chars[j]) = (chars[j], chars[i]); - } - return new string(chars); + var email = userAccount.Email.GetValue(); + await _notificationService.SendAsync( + NotificationTemplates.PasswordResetRequested( + recipient: email, + recipientName: email.Split('@')[0], + resetToken: token, + expiresInMinutes: (int)PasswordResetToken.Lifetime.TotalMinutes), + cancellationToken).ConfigureAwait(false); } } diff --git a/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/RefreshAuthenticationCommand.cs b/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/RefreshAuthenticationCommand.cs new file mode 100644 index 00000000..f96024c8 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/RefreshAuthenticationCommand.cs @@ -0,0 +1,25 @@ +using Ums.Domain.Authorization.Graph; + +namespace Ums.Application.Identity.Auth.Commands; + +/// +/// Renueva una sesión a partir de un refresh token (ADR-UMS-091 / FR-015). +/// +/// Regenera el grafo completo desde el estado actual (cierra la brecha de +/// latencia de permisos), rota el token (rotación + detección de reuso) y aplica la +/// postura fail-closed: si el inquilino no tiene la capacidad activa, la renovación +/// se rechaza. +/// +public sealed record RefreshAuthenticationCommand( + string RefreshToken, + string ClientIp) : ICommand; + +/// Resultado de una renovación exitosa — grafo recién regenerado + nuevo refresh. +public sealed record RefreshAuthenticationResult( + AuthorizationGraph Graph, + string SerializedGraph, + string GraphFormat, + int ExpiresIn, // vida del access token, en segundos + DateTime IssuedAt, + string? NewRefreshToken, // null si la política no rota (el cliente conserva el suyo) + int RefreshExpiresIn); // vida del refresh, en segundos diff --git a/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/RefreshAuthenticationCommandHandler.cs b/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/RefreshAuthenticationCommandHandler.cs new file mode 100644 index 00000000..278f3570 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/RefreshAuthenticationCommandHandler.cs @@ -0,0 +1,245 @@ +using Ums.Application.Authorization.Graph; +using Ums.Application.Authorization.Graph.Serializers; +using Ums.Application.Identity.Auth; +using Ums.Domain.Authorization.Graph; +using Ums.Domain.Identity.Auth; + +namespace Ums.Application.Identity.Auth.Commands; + +/// Códigos de error de la renovación (ADR-UMS-091). +public static class RefreshErrorCodes +{ + public const string Invalid = "AUTH_REFRESH_001"; // no existe / hash desconocido + public const string ReuseDetected = "AUTH_REFRESH_002"; // token ya rotado presentado ⇒ familia invalidada + public const string Revoked = "AUTH_REFRESH_003"; // token/familia ya revocados + public const string Expired = "AUTH_REFRESH_004"; // vencido + public const string Disabled = "AUTH_REFRESH_005"; // capacidad no activa (fail-closed) + public const string MaxRenewals = "AUTH_REFRESH_006"; // tope de renovaciones alcanzado + public const string PrincipalGone = "AUTH_REFRESH_007"; // inquilino o usuario inactivo/inexistente +} + +/// +/// Renovación de sesión por refresh token (ADR-UMS-091 / FR-015/016). Orquesta: +/// 1. Buscar el token por hash (SHA-256); nunca se compara el plaintext. +/// 2. Detección de reuso: presentar un token ya rotado/usado invalida la familia entera. +/// 3. Fail-closed: si la política del inquilino está apagada, no se renueva. +/// 4. Validar vigencia, tope de renovaciones, y que inquilino y usuario sigan activos +/// (un usuario bloqueado o suspendido no puede renovar → revocación efectiva). +/// 5. Regenerar el grafo COMPLETO desde el estado actual (IAuthorizationGraphBuilder). +/// 6. Rotar el token (nuevo Active en la familia; el anterior queda Rotated). +/// 7. Auditar el evento. +/// +public sealed class RefreshAuthenticationCommandHandler + : ICommandHandler +{ + private readonly IRefreshTokenStore _store; + private readonly IRefreshTokenPolicyProvider _policyProvider; + private readonly ITenantRepository _tenantRepo; + private readonly IUserAccountRepository _userRepo; + private readonly IAuthMethodResolver _methodResolver; + private readonly IAuthorizationGraphBuilder _graphBuilder; + private readonly IAuthGraphFormatProvider _formatProvider; + private readonly IAuthorizationGraphSerializer _serializer; + private readonly IAuthAuditService _auditService; + + public RefreshAuthenticationCommandHandler( + IRefreshTokenStore store, + IRefreshTokenPolicyProvider policyProvider, + ITenantRepository tenantRepo, + IUserAccountRepository userRepo, + IAuthMethodResolver methodResolver, + IAuthorizationGraphBuilder graphBuilder, + IAuthGraphFormatProvider formatProvider, + IAuthorizationGraphSerializer serializer, + IAuthAuditService auditService) + { + _store = store; + _policyProvider = policyProvider; + _tenantRepo = tenantRepo; + _userRepo = userRepo; + _methodResolver = methodResolver; + _graphBuilder = graphBuilder; + _formatProvider = formatProvider; + _serializer = serializer; + _auditService = auditService; + } + + public async Task> Handle( + RefreshAuthenticationCommand command, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(command.RefreshToken)) + { + return Result.Failure($"{RefreshErrorCodes.Invalid}: Refresh token is required."); + } + + var hash = RefreshTokenHasher.Hash(command.RefreshToken); + var snapshot = await _store.FindByHashAsync(hash, cancellationToken); + + if (snapshot is null) + { + await AuditFailureAsync(Guid.Empty, Guid.Empty, command.ClientIp, + RefreshErrorCodes.Invalid, "Unknown refresh token", cancellationToken); + return Result.Failure($"{RefreshErrorCodes.Invalid}: Invalid refresh token."); + } + + var now = DateTime.UtcNow; + + // ── Detección de reuso / estado no renovable ────────────────────────────── + if (snapshot.Status == RefreshTokenStatuses.Revoked) + { + await AuditFailureAsync(snapshot.UserId, snapshot.TenantId, command.ClientIp, + RefreshErrorCodes.Revoked, "Revoked refresh token presented", cancellationToken); + return Result.Failure($"{RefreshErrorCodes.Revoked}: Refresh token has been revoked."); + } + + if (snapshot.Status is not (RefreshTokenStatuses.Active)) + { + // Rotated | Used ⇒ el token ya fue canjeado; presentarlo de nuevo es reuso. + var policyForReuse = _policyProvider.Resolve(snapshot.TenantId); + if (policyForReuse.DetectReuse) + { + await _store.RevokeFamilyAsync(snapshot.FamilyId, "reuse_detected", now, cancellationToken); + } + await AuditFailureAsync(snapshot.UserId, snapshot.TenantId, command.ClientIp, + RefreshErrorCodes.ReuseDetected, "Reuse of a rotated refresh token", cancellationToken); + return Result.Failure($"{RefreshErrorCodes.ReuseDetected}: Refresh token reuse detected; the session family was revoked."); + } + + if (snapshot.ExpiresAtUtc <= now) + { + await AuditFailureAsync(snapshot.UserId, snapshot.TenantId, command.ClientIp, + RefreshErrorCodes.Expired, "Expired refresh token", cancellationToken); + return Result.Failure($"{RefreshErrorCodes.Expired}: Refresh token has expired."); + } + + // ── Fail-closed: la capacidad debe seguir activa para el inquilino ──────── + var policy = _policyProvider.Resolve(snapshot.TenantId); + if (!policy.Enabled) + { + await AuditFailureAsync(snapshot.UserId, snapshot.TenantId, command.ClientIp, + RefreshErrorCodes.Disabled, "Refresh capability disabled for tenant", cancellationToken); + return Result.Failure($"{RefreshErrorCodes.Disabled}: Refresh capability is not enabled for this tenant."); + } + + // ── Tope de renovaciones (0 = sin tope) ─────────────────────────────────── + if (policy.MaxRenewals > 0 && snapshot.RenewalCount >= policy.MaxRenewals) + { + await _store.RevokeFamilyAsync(snapshot.FamilyId, "max_renewals", now, cancellationToken); + await AuditFailureAsync(snapshot.UserId, snapshot.TenantId, command.ClientIp, + RefreshErrorCodes.MaxRenewals, "Max renewals reached", cancellationToken); + return Result.Failure($"{RefreshErrorCodes.MaxRenewals}: Maximum number of renewals reached; please sign in again."); + } + + // ── El inquilino y el usuario deben seguir vigentes ─────────────────────── + var tenant = await _tenantRepo.GetByIdAsync(snapshot.TenantId, cancellationToken); + if (tenant is null || tenant.Props.Status != Domain.Enums.TenantStatus.Active) + { + await _store.RevokeFamilyAsync(snapshot.FamilyId, "tenant_inactive", now, cancellationToken); + await AuditFailureAsync(snapshot.UserId, snapshot.TenantId, command.ClientIp, + RefreshErrorCodes.PrincipalGone, "Tenant inactive or missing", cancellationToken); + return Result.Failure($"{RefreshErrorCodes.PrincipalGone}: The tenant is no longer active."); + } + + var user = await _userRepo.GetByIdAsync(snapshot.UserId, cancellationToken); + if (user is null + || user.Props.TenantId.GetValue() != snapshot.TenantId + || user.Props.Status != Domain.Enums.UserStatus.Active) + { + // Usuario bloqueado/suspendido/eliminado ⇒ revocación efectiva de la sesión. + await _store.RevokeFamilyAsync(snapshot.FamilyId, "user_inactive", now, cancellationToken); + await AuditFailureAsync(snapshot.UserId, snapshot.TenantId, command.ClientIp, + RefreshErrorCodes.PrincipalGone, "User inactive or missing", cancellationToken); + return Result.Failure($"{RefreshErrorCodes.PrincipalGone}: The user account is no longer active."); + } + + // ── Regeneración COMPLETA del grafo (ADR-UMS-091, decisión 1) ──────────────── + var methodResult = await _methodResolver.ResolveAsync( + snapshot.TenantId, AuthAccessScope.PortalManagement, cancellationToken: cancellationToken); + if (methodResult.IsFailure) + { + await AuditFailureAsync(snapshot.UserId, snapshot.TenantId, command.ClientIp, + RefreshErrorCodes.PrincipalGone, methodResult.Error, cancellationToken); + return Result.Failure(methodResult.Error); + } + + var authMethod = methodResult.Value; + // Sin acotar por sistema, por el mismo motivo que `RefreshSessionCommandHandler`: es el + // refresco de la sesión del portal, que es multiproducto (ADR-0156 §4). + var graphResult = await _graphBuilder.BuildAsync( + user, snapshot.TenantId, authMethod, systemCode: null, cancellationToken); + if (graphResult.IsFailure) + { + await AuditFailureAsync(snapshot.UserId, snapshot.TenantId, command.ClientIp, + RefreshErrorCodes.PrincipalGone, graphResult.Error, cancellationToken); + return Result.Failure(graphResult.Error); + } + + var graph = graphResult.Value; + var format = await _formatProvider.GetDefaultFormatAsync(snapshot.TenantId, cancellationToken); + var serialized = _serializer.Serialize(graph); + + // ── Rotación del refresh token ──────────────────────────────────────────── + string? newRefreshPlaintext = null; + int refreshExpiresIn; + if (policy.Rotate) + { + newRefreshPlaintext = RefreshTokenGenerator.Generate(); + var newTokenId = Guid.NewGuid(); + var expiresAt = now.AddMinutes(policy.LifetimeMinutes); + await _store.RotateAsync( + snapshot, + newTokenId, + RefreshTokenHasher.Hash(newRefreshPlaintext), + now, + expiresAt, + cancellationToken); + refreshExpiresIn = policy.LifetimeMinutes * 60; + } + else + { + // Sin rotación: el cliente conserva su token; solo se renueva el acceso. + refreshExpiresIn = (int)Math.Max(0, (snapshot.ExpiresAtUtc - now).TotalSeconds); + } + + await _auditService.RecordAuthEventAsync(new AuthAuditEvent( + UserId: snapshot.UserId, + TenantId: snapshot.TenantId, + TenantCode: tenant.Props.Code.GetValue(), + AuthMethod: authMethod.Type.ToString(), + EventType: "Auth.Refresh.Success", + Succeeded: true, + ClientIp: command.ClientIp), cancellationToken); + + return Result.Success(new RefreshAuthenticationResult( + Graph: graph, + SerializedGraph: serialized, + GraphFormat: format, + ExpiresIn: graph.EffectiveConfig.AccessTokenDurationMs / 1000, + IssuedAt: graph.GeneratedAt, + NewRefreshToken: newRefreshPlaintext, + RefreshExpiresIn: refreshExpiresIn)); + } + + private async Task AuditFailureAsync( + Guid userId, Guid tenantId, string clientIp, + string code, string reason, CancellationToken cancellationToken) + { + try + { + await _auditService.RecordAuthEventAsync(new AuthAuditEvent( + UserId: userId, + TenantId: tenantId, + TenantCode: string.Empty, + AuthMethod: "Refresh", + EventType: "Auth.Refresh.Failure", + Succeeded: false, + ClientIp: clientIp, + FailureReason: $"{code}: {reason}"), cancellationToken); + } + catch + { + // La auditoría no debe enmascarar el resultado de la renovación. + } + } +} diff --git a/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/RefreshSessionCommand.cs b/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/RefreshSessionCommand.cs new file mode 100644 index 00000000..6a4720e5 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/RefreshSessionCommand.cs @@ -0,0 +1,25 @@ +using Ums.Domain.Authorization.Graph; + +namespace Ums.Application.Identity.Auth.Commands; + +/// +/// Renueva una sesión de PORTAL a partir del principal ya autenticado por cookie +/// (D-019 / ADR-UMS-091). A diferencia de —que +/// canjea un refresh token OPACO (ADR-UMS-091/FR-015)—, este comando parte de la cookie de +/// sesión viva: recibe el userId y el tenantId resueltos de los claims y +/// regenera el grafo de autorización COMPLETO desde el estado actual, en espejo del +/// login (). +/// +/// Cierra la brecha de latencia de permisos del refresh deslizante: el JWT emitido tras la +/// renovación porta el modelo de permisos vigente, no un grafo vacío. +/// +public sealed record RefreshSessionCommand( + Guid UserId, + Guid TenantId, + string ClientIp) : ICommand; + +/// Resultado de una renovación de sesión por cookie — grafo recién regenerado. +public sealed record RefreshSessionResult( + AuthorizationGraph Graph, + string GraphFormat, + int ExpiresIn); // vida del access token, en segundos (derivada del grafo) diff --git a/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/RefreshSessionCommandHandler.cs b/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/RefreshSessionCommandHandler.cs new file mode 100644 index 00000000..252e995f --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/RefreshSessionCommandHandler.cs @@ -0,0 +1,128 @@ +using Ums.Application.Authorization.Graph; +using Ums.Application.Identity.Auth; +using Ums.Domain.Authorization.Graph; +using Ums.Domain.Identity.Auth; + +namespace Ums.Application.Identity.Auth.Commands; + +/// +/// Renovación de la sesión de portal por cookie (D-019 / ADR-UMS-091). Espeja el login: +/// 1. Verifica que el inquilino y el usuario del principal sigan vigentes (activos). +/// 2. Resuelve el método de autenticación de portal (local, ADR-0077). +/// 3. Regenera el grafo COMPLETO desde el estado actual (IAuthorizationGraphBuilder), +/// cerrando la brecha de latencia de permisos del refresh deslizante. +/// 4. Audita el evento de renovación (ADR-UMS-091, invariante de auditoría append-only). +/// +/// No toca el flujo de refresh token OPACO (): +/// aquí la credencial es la cookie de sesión viva, no un token opaco, por lo que no hay store, +/// rotación ni detección de reuso — solo la regeneración del grafo que faltaba. +/// +public sealed class RefreshSessionCommandHandler + : ICommandHandler +{ + private readonly ITenantRepository _tenantRepo; + private readonly IUserAccountRepository _userRepo; + private readonly IAuthMethodResolver _methodResolver; + private readonly IAuthorizationGraphBuilder _graphBuilder; + private readonly IAuthGraphFormatProvider _formatProvider; + private readonly IAuthAuditService _auditService; + + public RefreshSessionCommandHandler( + ITenantRepository tenantRepo, + IUserAccountRepository userRepo, + IAuthMethodResolver methodResolver, + IAuthorizationGraphBuilder graphBuilder, + IAuthGraphFormatProvider formatProvider, + IAuthAuditService auditService) + { + _tenantRepo = tenantRepo; + _userRepo = userRepo; + _methodResolver = methodResolver; + _graphBuilder = graphBuilder; + _formatProvider = formatProvider; + _auditService = auditService; + } + + public async Task> Handle( + RefreshSessionCommand command, + CancellationToken cancellationToken) + { + // ── El inquilino debe seguir activo ─────────────────────────────────────── + var tenant = await _tenantRepo.GetByIdAsync(command.TenantId, cancellationToken); + if (tenant is null || tenant.Props.Status != Domain.Enums.TenantStatus.Active) + { + await AuditFailureAsync(command, "Tenant inactive or missing", cancellationToken); + return Result.Failure("AUTH_007: The tenant is no longer active."); + } + + // ── El usuario debe seguir activo y pertenecer al inquilino ─────────────── + var user = await _userRepo.GetByIdAsync(command.UserId, cancellationToken); + if (user is null + || user.Props.TenantId.GetValue() != command.TenantId + || user.Props.Status != Domain.Enums.UserStatus.Active) + { + // Usuario bloqueado/suspendido/eliminado ⇒ no se renueva (corte en caliente). + await AuditFailureAsync(command, "User inactive or missing", cancellationToken); + return Result.Failure("AUTH_007: The user account is no longer active."); + } + + // ── Método de autenticación de portal (ADR-0077: siempre local) ─────────── + var methodResult = await _methodResolver.ResolveAsync( + command.TenantId, AuthAccessScope.PortalManagement, cancellationToken: cancellationToken); + if (methodResult.IsFailure) + { + await AuditFailureAsync(command, methodResult.Error, cancellationToken); + return Result.Failure(methodResult.Error); + } + + // ── Regeneración COMPLETA del grafo (ADR-UMS-091, decisión 1) ──────────────── + // Sin acotar por sistema: este refresco sirve a la sesión de COOKIE del portal, que es + // multiproducto por definición. El satélite no pasa por aquí — revalida con su portador + // contra `GET /client/graph`, que sí conserva el sistema (ADR-0156 §4). + var graphResult = await _graphBuilder.BuildAsync( + user, command.TenantId, methodResult.Value, systemCode: null, cancellationToken); + if (graphResult.IsFailure) + { + await AuditFailureAsync(command, graphResult.Error, cancellationToken); + return Result.Failure(graphResult.Error); + } + + var graph = graphResult.Value; + var format = await _formatProvider.GetDefaultFormatAsync(command.TenantId, cancellationToken); + + await _auditService.RecordAuthEventAsync(new AuthAuditEvent( + UserId: command.UserId, + TenantId: command.TenantId, + TenantCode: tenant.Props.Code.GetValue(), + AuthMethod: methodResult.Value.Type.ToString(), + EventType: "Auth.Refresh.Success", + Succeeded: true, + ClientIp: command.ClientIp), cancellationToken); + + return Result.Success(new RefreshSessionResult( + Graph: graph, + GraphFormat: format, + ExpiresIn: graph.EffectiveConfig.AccessTokenDurationMs / 1000)); + } + + private async Task AuditFailureAsync( + RefreshSessionCommand command, string reason, CancellationToken cancellationToken) + { + try + { + await _auditService.RecordAuthEventAsync(new AuthAuditEvent( + UserId: command.UserId, + TenantId: command.TenantId, + TenantCode: string.Empty, + AuthMethod: "Refresh", + EventType: "Auth.Refresh.Failure", + Succeeded: false, + ClientIp: command.ClientIp, + FailureReason: reason), cancellationToken); + } + catch + { + // La auditoría no debe enmascarar el resultado de la renovación. + } + } +} diff --git a/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/ResetPasswordCommand.cs b/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/ResetPasswordCommand.cs new file mode 100644 index 00000000..c64e739e --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/ResetPasswordCommand.cs @@ -0,0 +1,13 @@ +namespace Ums.Application.Identity.Auth.Commands; + +/// +/// Canje del secreto emitido por . +/// +/// No lleva código de inquilino ni correo a propósito: el token ya identifica al usuario y +/// a su inquilino. Pedir además el correo no añadiría seguridad —el token es el secreto— y sí +/// abriría un segundo punto donde una respuesta distinta podría delatar qué cuentas existen. +/// +public sealed record ResetPasswordCommand(string Token, string NewPassword) + : ICommand; + +public sealed record ResetPasswordResponse(string Message); diff --git a/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/ResetPasswordCommandHandler.cs b/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/ResetPasswordCommandHandler.cs new file mode 100644 index 00000000..8e3ba945 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/ResetPasswordCommandHandler.cs @@ -0,0 +1,105 @@ +using Ums.Application.Common.Interfaces; +using Ums.Application.Common.Notifications; + +namespace Ums.Application.Identity.Auth.Commands; + +/// +/// Canje del token de restablecimiento: aquí —y solo aquí— cambia la contraseña (G-188). +/// +/// El token es la prueba de posesión del buzón, así que este es el primer punto del flujo +/// donde tocar la credencial es legítimo. Todo fallo colapsa en un único error: distinguir +/// «token inexistente» de «ya usado», «vencido» o «cuenta federada» reabriría por la puerta del +/// canje la enumeración que el flujo anónimo cierra. +/// +public sealed class ResetPasswordCommandHandler : ICommandHandler +{ + /// Único error del canje. Ver la nota de clase: la ambigüedad es intencional. + public const string InvalidTokenError = + "AUTH_019: El enlace de restablecimiento no es válido o ha expirado."; + + private readonly IPasswordResetTokenStore _resetTokenStore; + private readonly IUserAccountRepository _userAccountRepository; + private readonly IPasswordHashingService _passwordHashingService; + private readonly IRefreshTokenStore _refreshTokenStore; + private readonly INotificationService _notificationService; + + public ResetPasswordCommandHandler( + IPasswordResetTokenStore resetTokenStore, + IUserAccountRepository userAccountRepository, + IPasswordHashingService passwordHashingService, + IRefreshTokenStore refreshTokenStore, + INotificationService notificationService) + { + _resetTokenStore = resetTokenStore; + _userAccountRepository = userAccountRepository; + _passwordHashingService = passwordHashingService; + _refreshTokenStore = refreshTokenStore; + _notificationService = notificationService; + } + + public async Task> Handle(ResetPasswordCommand request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.Token)) + return Invalid(); + + var now = DateTime.UtcNow; + var snapshot = await _resetTokenStore + .FindByHashAsync(PasswordResetToken.Hash(request.Token), cancellationToken) + .ConfigureAwait(false); + + if (snapshot is null + || snapshot.Status != PasswordResetTokenStatuses.Active + || snapshot.ExpiresAtUtc <= now) + return Invalid(); + + var userAccount = await _userAccountRepository.GetByIdAsync(snapshot.UserId, cancellationToken).ConfigureAwait(false); + if (userAccount is null + || userAccount.Props.TenantId.GetValue() != snapshot.TenantId + || userAccount.IdentityReference is not null + || userAccount.Status == UserStatus.Blocked + || userAccount.Status == UserStatus.Deleted + || userAccount.Status == UserStatus.Denied) + { + // La cuenta cambió de estado entre la emisión y el canje: el token deja de servir y + // se cierra, para que no quede vivo esperando a que la cuenta se reactive. + await _resetTokenStore.ConsumeAsync(snapshot.Id, now, cancellationToken).ConfigureAwait(false); + return Invalid(); + } + + // El actor del cambio es el propio titular: el restablecimiento lo ejecuta quien probó + // poseer el buzón, no un administrador ni el sistema. + var actor = ActorId.Create(snapshot.UserId.ToString()); + var hash = _passwordHashingService.Hash(request.NewPassword); + var applied = userAccount.AddPassword(PasswordHash.Create(hash), actor); + if (applied.IsFailure) + return Result.Failure(applied.Error); + + await _userAccountRepository.UpdateAsync(userAccount, cancellationToken).ConfigureAwait(false); + await _userAccountRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken).ConfigureAwait(false); + + // Un solo uso: el token se gasta y cualquier otro que siguiera vivo para el mismo usuario + // muere con él. El orden importa —primero la contraseña— para que un fallo al persistir + // no deje al titular sin credencial nueva Y sin token para reintentar. + await _resetTokenStore.ConsumeAsync(snapshot.Id, now, cancellationToken).ConfigureAwait(false); + await _resetTokenStore.InvalidateActiveForUserAsync( + snapshot.TenantId, snapshot.UserId, "password-reset", now, cancellationToken).ConfigureAwait(false); + + // Cambiar la contraseña sin cerrar las sesiones vivas dejaría dentro a quien la robó. + // Solo se revocan los refresh tokens: la revocación de access tokens es por usuario y + // ventana temporal (ITokenRevocationStore), así que aplicarla aquí impediría al titular + // volver a entrar justo después de restablecer. + await _refreshTokenStore.RevokeAllForUserAsync( + snapshot.TenantId, snapshot.UserId, "password-reset", now, cancellationToken).ConfigureAwait(false); + + var email = userAccount.Email.GetValue(); + await _notificationService.SendAsync( + NotificationTemplates.PasswordChanged(email, email.Split('@')[0]), + cancellationToken).ConfigureAwait(false); + + return Result.Success(new ResetPasswordResponse( + "Su contraseña fue actualizada. Ya puede iniciar sesión.")); + } + + private static Result Invalid() => + Result.Failure(InvalidTokenError); +} diff --git a/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/ResetPasswordCommandValidator.cs b/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/ResetPasswordCommandValidator.cs new file mode 100644 index 00000000..c477d58d --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/ResetPasswordCommandValidator.cs @@ -0,0 +1,24 @@ +namespace Ums.Application.Identity.Auth.Commands; + +using Ums.Application.Configuration.Services; + +/// +/// La longitud mínima se toma de la configuración global —la misma que ya rige el alta de +/// contraseñas— para que el restablecimiento no sea la puerta trasera por la que entra una +/// credencial más débil de lo que la política permite. +/// +public sealed class ResetPasswordCommandValidator : AbstractValidator +{ + public ResetPasswordCommandValidator(IConfigurationProvider configProvider) + { + var minLength = configProvider.Global().MinPasswordLength; + + RuleFor(x => x.Token) + .NotEmpty().WithMessage("El código de restablecimiento es obligatorio."); + + RuleFor(x => x.NewPassword) + .NotEmpty().WithMessage("La nueva contraseña es obligatoria.") + .MinimumLength(minLength).WithMessage($"La contraseña debe tener al menos {minLength} caracteres.") + .MaximumLength(128).WithMessage("La contraseña no puede superar 128 caracteres."); + } +} diff --git a/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/SwitchProfileCommand.cs b/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/SwitchProfileCommand.cs new file mode 100644 index 00000000..d309fe27 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Auth/Commands/SwitchProfileCommand.cs @@ -0,0 +1,121 @@ +namespace Ums.Application.Identity.Auth.Commands; + +using Ums.Application.Common.Interfaces; +using Ums.Domain.Authorization; +using Ums.Domain.Authorization.Graph; +using Ums.Domain.Identity; +using Ums.Domain.Identity.Auth; + +/// +/// Cambia el perfil vigente de una sesión ya autenticada. +/// +/// Un perfil ata un usuario, un rol y —por el rol— un sistema. Un usuario con varios perfiles +/// tenía que cerrar sesión y volver a entrar para cambiarse de sombrero, y ni siquiera sabía que +/// tenía otros: el grafo elegía uno y descartaba el resto sin dejar rastro (G-177). +/// +/// La identidad NO cambia: no se piden credenciales otra vez. Lo que cambia es el conjunto de +/// permisos con el que se opera, y por eso el resultado es un grafo nuevo completo, no un parche +/// sobre el anterior. +/// +public sealed record SwitchProfileCommand( + Guid ProfileId, + Guid UserId, + Guid TenantId, + string ClientIp, + // ADR-0156 §2.5 — acota el bloque `profiles` del grafo resultante al sistema del llamante. + // No elige el perfil vigente, que llega en `ProfileId`. Lo envía el carril de satélite, + // tomándolo del claim `sys_suite` de su propio portador; el portal lo deja en null, porque + // es multiproducto por definición. + string? SystemCode = null) : ICommand; + +public sealed class SwitchProfileCommandHandler : ICommandHandler +{ + private readonly IUserAccountRepository _userRepo; + private readonly IProfileRepository _profileRepo; + private readonly IAuthorizationGraphBuilder _graphBuilder; + private readonly IAuthAuditService _auditService; + + public SwitchProfileCommandHandler( + IUserAccountRepository userRepo, + IProfileRepository profileRepo, + IAuthorizationGraphBuilder graphBuilder, + IAuthAuditService auditService) + { + _userRepo = userRepo; + _profileRepo = profileRepo; + _graphBuilder = graphBuilder; + _auditService = auditService; + } + + public async Task> Handle( + SwitchProfileCommand request, + CancellationToken cancellationToken) + { + var user = await _userRepo.GetByIdAsync(request.UserId, cancellationToken); + if (user is null || user.Props.Status != Domain.Enums.UserStatus.Active) + { + return Result.Failure( + "AUTH_005: The account is not active."); + } + + var profile = await _profileRepo.GetByIdAsync(request.ProfileId, cancellationToken); + if (profile is null) + { + return Result.Failure("AUTH_020: Profile not found."); + } + + // El identificador del perfil lo envía el CLIENTE: nunca se confía en él. Sin estas dos + // comprobaciones, este endpoint sería una escalada de privilegios de una línea — bastaría + // enviar el id del perfil de un administrador. + if (profile.Props.UserId.GetValue() != request.UserId || + profile.Props.TenantId.GetValue() != request.TenantId) + { + await _auditService.RecordAuthEventAsync(new AuthAuditEvent( + UserId: request.UserId, + TenantId: request.TenantId, + TenantCode: string.Empty, + AuthMethod: "Local", + EventType: "Auth.Profile.SwitchDenied", + Succeeded: false, + ClientIp: request.ClientIp, + IdpProvider: null), cancellationToken); + + // Mismo mensaje que «no existe»: distinguirlos permitiría a un atacante enumerar + // perfiles ajenos preguntando por identificadores. + return Result.Failure("AUTH_020: Profile not found."); + } + + if (!profile.IsActive) + { + return Result.Failure("AUTH_021: Profile is inactive."); + } + + var graphResult = await _graphBuilder.BuildForProfileAsync( + user, request.TenantId, request.ProfileId, AuthMethod.Local(), + request.SystemCode, cancellationToken); + + if (graphResult.IsFailure) + { + return Result.Failure(graphResult.Error); + } + + await _auditService.RecordAuthEventAsync(new AuthAuditEvent( + UserId: request.UserId, + TenantId: request.TenantId, + TenantCode: graphResult.Value.Context.Tenant.Code, + AuthMethod: "Local", + EventType: "Auth.Profile.Switch", + Succeeded: true, + ClientIp: request.ClientIp, + IdpProvider: null), cancellationToken); + + return Result.Success(new AuthenticateUserResult( + Graph: graphResult.Value, + Token: string.Empty, // lo emite la capa de presentación, como en el login + TokenType: "Bearer", + ExpiresIn: graphResult.Value.EffectiveConfig.AccessTokenDurationMs / 1000, + IssuedAt: graphResult.Value.GeneratedAt, + SerializedGraph: string.Empty, + GraphFormat: "JSON")); + } +} diff --git a/src/apps/ums.api/Ums.Application/Identity/Auth/IPasswordResetTokenStore.cs b/src/apps/ums.api/Ums.Application/Identity/Auth/IPasswordResetTokenStore.cs new file mode 100644 index 00000000..f54f2045 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Auth/IPasswordResetTokenStore.cs @@ -0,0 +1,81 @@ +namespace Ums.Application.Identity.Auth; + +/// +/// Estados de un token de restablecimiento. Active es el único canjeable; el resto +/// existen para que el canje distinga «nunca emitido» de «ya gastado» en la auditoría, sin +/// que esa distinción llegue jamás al llamante. +/// +public static class PasswordResetTokenStatuses +{ + public const string Active = "Active"; + public const string Used = "Used"; + public const string Invalidated = "Invalidated"; +} + +/// +/// Instantánea de un token de restablecimiento persistido. Nunca contiene el plaintext: +/// la búsqueda es por hash, igual que con los refresh tokens. +/// +public sealed record PasswordResetTokenSnapshot( + Guid Id, + Guid TenantId, + Guid UserId, + string Status, + DateTime IssuedAtUtc, + DateTime ExpiresAtUtc); + +/// +/// Puerto de persistencia de tokens de restablecimiento de contraseña. +/// +/// Existe porque la petición anónima de «olvidé mi contraseña» no debe cambiar la +/// credencial: quien conoce un correo no prueba con ello poseer el buzón. El único efecto +/// legítimo de esa petición es emitir un secreto de un solo uso y vida corta hacia el canal +/// de notificación; la contraseña anterior sigue siendo válida hasta que ese secreto se canjea. +/// +/// Se guarda ÚNICAMENTE el hash SHA-256 del token: un volcado de la tabla no permite +/// tomar ninguna cuenta. +/// +public interface IPasswordResetTokenStore +{ + /// + /// Emite un token activo. La implementación invalida primero los tokens vivos del mismo + /// usuario: solo un secreto de restablecimiento puede estar pendiente a la vez, de modo que + /// pedir el restablecimiento otra vez anula el enlace anterior. + /// + Task IssueAsync( + Guid tenantId, + Guid userId, + string tokenHash, + DateTime issuedAtUtc, + DateTime expiresAtUtc, + CancellationToken cancellationToken = default); + + /// + /// Busca un token por su hash, sea cual sea su estado. Devolver también los no activos es + /// intencional: permite que el canje trate «reutilizado» y «revocado» exactamente igual que + /// «inexistente» de cara al llamante, sin perder la traza interna. + /// + Task FindByHashAsync( + string tokenHash, + CancellationToken cancellationToken = default); + + /// + /// Marca el token como gastado. Debe invocarse en el canje y solo una vez: es lo que + /// convierte el enlace en «de un solo uso». + /// + Task ConsumeAsync( + Guid tokenId, + DateTime consumedAtUtc, + CancellationToken cancellationToken = default); + + /// + /// Invalida todos los tokens vivos del usuario. Se usa tras un canje exitoso y ante un + /// cambio de credencial por otra vía. Idempotente. + /// + Task InvalidateActiveForUserAsync( + Guid tenantId, + Guid userId, + string reason, + DateTime invalidatedAtUtc, + CancellationToken cancellationToken = default); +} diff --git a/src/apps/ums.api/Ums.Application/Identity/Auth/IRefreshTokenPolicyProvider.cs b/src/apps/ums.api/Ums.Application/Identity/Auth/IRefreshTokenPolicyProvider.cs new file mode 100644 index 00000000..acb03a7b --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Auth/IRefreshTokenPolicyProvider.cs @@ -0,0 +1,12 @@ +namespace Ums.Application.Identity.Auth; + +/// +/// Resuelve la efectiva de un inquilino desde la +/// configuración jerárquica (Global > Suite > Tenant > Module), aplicando +/// una postura fail-closed: ante ausencia, error o valor inválido, la política +/// resultante está deshabilitada (ADR-UMS-091 / FR-015). +/// +public interface IRefreshTokenPolicyProvider +{ + RefreshTokenPolicy Resolve(Guid? tenantId, Guid? suiteId = null, Guid? moduleId = null); +} diff --git a/src/apps/ums.api/Ums.Application/Identity/Auth/IRefreshTokenStore.cs b/src/apps/ums.api/Ums.Application/Identity/Auth/IRefreshTokenStore.cs new file mode 100644 index 00000000..c314e9f0 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Auth/IRefreshTokenStore.cs @@ -0,0 +1,79 @@ +namespace Ums.Application.Identity.Auth; + +/// +/// Instantánea de un refresh token persistido, expuesta a la capa de aplicación sin +/// filtrar la entidad de EF. Nunca contiene el plaintext — la búsqueda es por hash. +/// +public sealed record RefreshTokenSnapshot( + Guid Id, + Guid TenantId, + Guid UserId, + Guid FamilyId, + string Status, + DateTime IssuedAtUtc, + DateTime ExpiresAtUtc, + int RenewalCount); + +/// +/// Puerto de persistencia de refresh tokens (ADR-UMS-091 / FR-015/016). Guarda solo el +/// hash del token; el plaintext nunca cruza esta frontera hacia el almacén. +/// Aislado por inquilino. Cubre emisión, renovación (rotación) y revocación. +/// +public interface IRefreshTokenStore +{ + /// Emite (persiste) un refresh token activo. es el SHA-256 del plaintext. + Task IssueAsync( + Guid tenantId, + Guid userId, + Guid familyId, + string tokenHash, + DateTime issuedAtUtc, + DateTime expiresAtUtc, + CancellationToken cancellationToken = default); + + /// + /// Busca un token por su hash, sea cual sea su estado. Devuelve null si no existe. + /// Devolver tokens no-activos es intencional: permite detectar el reuso de un + /// token ya rotado/revocado y responder invalidando la familia. + /// + Task FindByHashAsync( + string tokenHash, + CancellationToken cancellationToken = default); + + /// + /// Rotación atómica: marca el token como Rotated + /// (apuntando a ) y persiste el nuevo token Active + /// en la misma familia, con el contador de renovaciones incrementado en uno. + /// + Task RotateAsync( + RefreshTokenSnapshot current, + Guid newTokenId, + string newTokenHash, + DateTime issuedAtUtc, + DateTime expiresAtUtc, + CancellationToken cancellationToken = default); + + /// + /// Revoca toda la familia (todos los tokens no revocados con ese + /// ). Se usa en la detección de reuso y en la revocación + /// explícita (logout, bloqueo, cambio crítico de permisos). + /// + Task RevokeFamilyAsync( + Guid familyId, + string reason, + DateTime revokedAtUtc, + CancellationToken cancellationToken = default); + + /// + /// Revoca todas las familias vivas de un usuario dentro de un inquilino + /// (todos sus tokens no revocados). Es la revocación explícita del logout: cierra + /// la sesión de refresh sin necesitar el familyId, que el logout no conoce. + /// Idempotente: si no hay tokens activos, no hace nada. + /// + Task RevokeAllForUserAsync( + Guid tenantId, + Guid userId, + string reason, + DateTime revokedAtUtc, + CancellationToken cancellationToken = default); +} diff --git a/src/apps/ums.api/Ums.Application/Identity/Auth/IResponseTimingNormalizer.cs b/src/apps/ums.api/Ums.Application/Identity/Auth/IResponseTimingNormalizer.cs new file mode 100644 index 00000000..f25d087b --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Auth/IResponseTimingNormalizer.cs @@ -0,0 +1,43 @@ +namespace Ums.Application.Identity.Auth; + +using System.Diagnostics; + +/// +/// Nivelador del tiempo de respuesta de los flujos anónimos que deben ser indistinguibles. +/// +/// Un cuerpo idéntico no basta para no filtrar la existencia de una cuenta: si el camino +/// «existe» escribe en la base y notifica, y el camino «no existe» retorna de inmediato, el reloj +/// dice lo que el mensaje calla. Este puerto acolcha la respuesta hasta un presupuesto fijo, +/// de modo que ambos caminos tarden lo mismo desde fuera. +/// +public interface IResponseTimingNormalizer +{ + /// + /// Espera lo que falte para completar el presupuesto desde + /// (obtenido con ). Si el trabajo real ya lo excedió, retorna + /// sin esperar. + /// + Task NormalizeAsync(long startingTimestamp, CancellationToken cancellationToken = default); +} + +/// +/// Implementación por reloj: acolcha hasta . +/// +public sealed class ResponseTimingNormalizer : IResponseTimingNormalizer +{ + /// + /// Presupuesto fijo. Debe superar con holgura el camino «cuenta existe» (lectura + escritura + + /// notificación simulada) sin degradar la experiencia ni volverse una palanca de saturación: + /// el endpoint es anónimo y cada petición retiene una conexión durante este tiempo. + /// + public static readonly TimeSpan Budget = TimeSpan.FromMilliseconds(400); + + public async Task NormalizeAsync(long startingTimestamp, CancellationToken cancellationToken = default) + { + var remaining = Budget - Stopwatch.GetElapsedTime(startingTimestamp); + if (remaining > TimeSpan.Zero) + { + await Task.Delay(remaining, cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/src/apps/ums.api/Ums.Application/Identity/Auth/IdpChainAuthenticator.cs b/src/apps/ums.api/Ums.Application/Identity/Auth/IdpChainAuthenticator.cs new file mode 100644 index 00000000..58ba9402 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Auth/IdpChainAuthenticator.cs @@ -0,0 +1,267 @@ +using Ums.Application.Common.Interfaces; +using Ums.Application.Configuration.Services; +using Ums.Domain.Configuration; +using Ums.Domain.Configuration.IdpConfiguration; +using Ums.Domain.Identity.Auth; +using Ums.Domain.Identity.Tenant.IdentityProvider; +using IdpConfigurationAggregate = Ums.Domain.Configuration.IdpConfiguration.IdpConfiguration; +using TenantAggregate = Ums.Domain.Identity.Tenant.Tenant; + +namespace Ums.Application.Identity.Auth; + +/// +/// Implementación del fallback encadenado de FR-042 (ADR-UMS-097 §2.3/§2.4, slice 2b). +/// +/// Disparador (irrenunciable): el avance por FallbackToId ocurre EXCLUSIVAMENTE +/// ante (indisponibilidad de infraestructura). Un +/// DETIENE el recorrido y devuelve el error (nunca se +/// prueba la credencial contra el siguiente IdP: anti credential spraying). La clasificación es +/// fail-closed (). +/// +/// Recorrido: comienza en la configuración ganadora del selector 2a +/// (, por prioridad/suite/dominio) y sigue FallbackToId +/// con detección de ciclos (conjunto de visitados) y tope de saltos configurable. Si la cadena se +/// agota por indisponibilidad devuelve (503, no 401). +/// +/// Puente reglas ↔ dominio (ADR-UMS-097 §2.5): cada configuración se resuelve al +/// del inquilino cuya estrategia corresponde a su ProviderType +/// (ProviderType.IdIdpStrategy.Id, enumeraciones paralelas). A diferencia de la +/// selección de método (2a), aquí no se exige IsActive: el inquilino solo puede tener un +/// proveedor activo, pero la cadena la gobierna IdpConfiguration.Status; los proveedores de los +/// eslabones de respaldo están registrados aunque no sean el activo primario. +/// +/// Auditoría (ADR-UMS-097 §2.4): un evento append-only por proveedor intentado, acotado por +/// inquilino, con {config intentada, resultado, motivo}. Nunca se registra la credencial ni secretos +/// ([G-040]#5): solo se audita el código/motivo del resultado, que no contiene material sensible. +/// +public sealed class IdpChainAuthenticator : IIdpChainAuthenticator +{ + /// + /// Cadena de fallback agotada por indisponibilidad ⇒ 503 (ADR-UMS-097 §2.3), no 401. Código local + /// al motor de auth (mismo patrón que OidcAuthErrors AUTH_020..034; no vive en el catálogo SDK). + /// + public const string ChainExhaustedError = + "AUTH_018: El servicio de autenticación federada no está disponible temporalmente (cadena de fallback agotada)."; + + /// + /// Guarda defensiva: modo IdP sin ningún proveedor utilizable. No debería alcanzarse tras la + /// resolución 2a en el flujo externo; se conserva por robustez. Se clasifica como infra a nivel HTTP + /// (AUTH_012 ⇒ 503) porque significa que el adaptador/proveedor no está disponible, no que la credencial sea inválida. + /// + private const string NoUsableProviderError = + "AUTH_012: No hay un proveedor de identidad utilizable para el inquilino."; + + /// + /// Tope de saltos configurable (cascada Global>Suite>Tenant>Module); default seguro = 5. + /// No se siembra como parámetro obligatorio: ausente ⇒ default. La detección de ciclos ya acota el + /// recorrido con independencia de este tope (es una segunda red de seguridad). + /// + public const string MaxHopsConfigCode = "AUTH_IDP_FALLBACK_MAX_HOPS"; + private const int DefaultMaxHops = 5; + + private const string IdpAttemptEventType = "Auth.Login.IdpAttempt"; + + private readonly IIdpConfigurationRepository _idpConfigRepo; + private readonly IIdpAuthStrategy _idpStrategy; + private readonly IAuthAuditService _auditService; + private readonly IConfigurationProvider _config; + + public IdpChainAuthenticator( + IIdpConfigurationRepository idpConfigRepo, + IIdpAuthStrategy idpStrategy, + IAuthAuditService auditService, + IConfigurationProvider config) + { + _idpConfigRepo = idpConfigRepo; + _idpStrategy = idpStrategy; + _auditService = auditService; + _config = config; + } + + public async Task> AuthenticateAsync( + TenantAggregate tenant, + string credential, + Guid? systemSuiteId, + string? emailDomain, + string clientIp, + CancellationToken cancellationToken = default) + { + var tenantId = tenant.Props.Id.GetValue(); + var tenantCode = tenant.Props.Code.GetValue(); + + // FR-042 (ADR-UMS-097 §2.2): misma procedencia de suite que el resolver 2a — si el AccessScope no + // fija suite, se usa el default del inquilino. Mantiene coherente el eslabón inicial de la cadena + // 2b con el proveedor que eligió la resolución del método (2a). + var effectiveSuiteId = systemSuiteId ?? tenant.DefaultSystemSuiteId?.GetValue(); + + var configurations = await _idpConfigRepo.GetByTenantIdAsync(tenantId, cancellationToken); + var selection = IdpConfigurationSelector.Select(configurations, effectiveSuiteId, emailDomain, providerType: null); + + // ── Legacy: ninguna IdpConfiguration gobierna la selección → intento único contra el proveedor + // activo del inquilino, conservando el comportamiento previo a 2b (sin cadena ni 503). + if (selection is null) + { + var legacyProvider = tenant.GetActiveIdentityProvider(); + if (legacyProvider is null) + { + return Result.Failure(NoUsableProviderError); + } + + return await AttemptLegacyAsync(tenantId, tenantCode, credential, clientIp, legacyProvider, cancellationToken); + } + + // ── Cadena gobernada por reglas: recorrido de FallbackToId con ciclos + tope de saltos. + var byId = BuildIndex(configurations); + var maxHops = Math.Max(1, _config.GetValueAs(MaxHopsConfigCode, tenantId, DefaultMaxHops)); + var visited = new HashSet(); + + var current = selection.Value.Configuration; + var hops = 0; + + while (current is not null && current.Status == IdpConfigStatus.Active) + { + var configId = current.Props.Id.GetValue(); + + // Detección de ciclos: un Id ya visitado corta el recorrido (sin bucle infinito). + if (!visited.Add(configId)) + { + break; + } + + // Tope de saltos: red de seguridad secundaria a la detección de ciclos. + if (hops >= maxHops) + { + break; + } + + hops++; + + var provider = BridgeToProvider(tenant, current); + if (provider is null) + { + // El adaptador/proveedor de esta configuración no está registrado → indisponibilidad de + // infraestructura de ESTE eslabón (§2.3 «adaptador no disponible/no registrado»). No se + // prueba ninguna credencial, así que avanzar no abre spraying. + await AuditAttemptAsync(tenantId, tenantCode, clientIp, DescribeConfig(current), + IdpAuthOutcome.InfraUnavailable, "Proveedor no registrado para la estrategia de la configuración.", + cancellationToken); + current = NextInChain(current, byId); + continue; + } + + var attempt = await _idpStrategy.AuthenticateAsync(tenantId, credential, provider, cancellationToken); + var outcome = IdpAuthOutcomeClassifier.Classify(attempt); + + await AuditAttemptAsync(tenantId, tenantCode, clientIp, DescribeConfig(current), outcome, + outcome == IdpAuthOutcome.Success ? "Autenticación exitosa." : attempt.Error, cancellationToken); + + switch (outcome) + { + case IdpAuthOutcome.Success: + return Result.Success(new IdpChainOutcome(attempt.Value, provider)); + + case IdpAuthOutcome.CredentialTerminal: + // TERMINAL: el IdP rechazó (o el fallo es no clasificable como infra). NO se avanza al + // siguiente proveedor — encadenar aquí permitiría credential spraying cross-IdP (§2.3). + return Result.Failure(attempt.Error); + + default: + // Indisponibilidad de infraestructura (IdpAuthOutcome.InfraUnavailable): avanzar por FallbackToId. + current = NextInChain(current, byId); + break; + } + } + + // Cadena agotada (todos los eslabones indisponibles, o ciclo, o tope) → 503, NUNCA 401. + return Result.Failure(ChainExhaustedError); + } + + /// Intento único (sin cadena) para inquilinos legados sin IdpConfiguration gobernante. + private async Task> AttemptLegacyAsync( + Guid tenantId, + string tenantCode, + string credential, + string clientIp, + IdentityProvider provider, + CancellationToken cancellationToken) + { + var attempt = await _idpStrategy.AuthenticateAsync(tenantId, credential, provider, cancellationToken); + var outcome = IdpAuthOutcomeClassifier.Classify(attempt); + + await AuditAttemptAsync(tenantId, tenantCode, clientIp, + $"legacy:{provider.Strategy.Name}", outcome, + outcome == IdpAuthOutcome.Success ? "Autenticación exitosa." : attempt.Error, cancellationToken); + + // Sin cadena no hay fallback: cualquier fallo es terminal y devuelve el error tal cual (comportamiento + // previo a 2b; no se convierte en 503, que es una semántica propia del recorrido de cadena). + return outcome == IdpAuthOutcome.Success + ? Result.Success(new IdpChainOutcome(attempt.Value, provider)) + : Result.Failure(attempt.Error); + } + + /// + /// Puente configuración → proveedor por estrategia (ADR-UMS-097 §2.5). No exige IsActive: ver + /// nota de clase. Devuelve null si el inquilino no registró un proveedor de esa estrategia. + /// + private static IdentityProvider? BridgeToProvider(TenantAggregate tenant, IdpConfigurationAggregate config) + => tenant.IdentityProviders.FirstOrDefault(ip => ip.Strategy.Id == config.ProviderType.Id); + + private static IdpConfigurationAggregate? NextInChain( + IdpConfigurationAggregate current, + IReadOnlyDictionary byId) + { + var fallbackToId = current.Props.FallbackToId; + if (!fallbackToId.HasValue) + { + return null; + } + + return byId.TryGetValue(fallbackToId.Value, out var next) ? next : null; + } + + private static IReadOnlyDictionary BuildIndex( + IReadOnlyList configurations) + { + var index = new Dictionary(configurations.Count); + foreach (var configuration in configurations) + { + index[configuration.Props.Id.GetValue()] = configuration; + } + + return index; + } + + /// Etiqueta de traza del intento: tipo de proveedor + Id de la configuración (no secreto). + private static string DescribeConfig(IdpConfigurationAggregate config) + => $"{config.ProviderType.Name}#{config.Props.Id.GetValue()}"; + + private async Task AuditAttemptAsync( + Guid tenantId, + string tenantCode, + string clientIp, + string idpLabel, + IdpAuthOutcome outcome, + string reason, + CancellationToken cancellationToken) + { + // Motivo auditado sin material sensible: es el código/mensaje del resultado, nunca la credencial + // ni el token ([G-040]#5). La credencial jamás entra en estos textos. + var resultLabel = outcome switch + { + IdpAuthOutcome.Success => "success", + IdpAuthOutcome.InfraUnavailable => "infra->advance", + _ => "credential->terminal", + }; + + await _auditService.RecordAuthEventAsync(new AuthAuditEvent( + UserId: Guid.Empty, + TenantId: tenantId, + TenantCode: tenantCode, + AuthMethod: "IDP", + EventType: IdpAttemptEventType, + Succeeded: outcome == IdpAuthOutcome.Success, + ClientIp: clientIp, + FailureReason: $"{resultLabel}: {reason}", + IdpProvider: idpLabel), cancellationToken); + } +} diff --git a/src/apps/ums.api/Ums.Application/Identity/Auth/PasswordResetToken.cs b/src/apps/ums.api/Ums.Application/Identity/Auth/PasswordResetToken.cs new file mode 100644 index 00000000..5eac7ce7 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Auth/PasswordResetToken.cs @@ -0,0 +1,43 @@ +namespace Ums.Application.Identity.Auth; + +using System.Buffers.Text; +using System.Security.Cryptography; +using System.Text; + +/// +/// Secreto de restablecimiento de contraseña: generación del plaintext y hash de persistencia. +/// +/// El plaintext se entrega UNA sola vez y por el canal del buzón (correo), nunca en la +/// respuesta HTTP de la petición anónima: si viajara en el cuerpo, quien conoce el correo ya +/// tendría el secreto y la prueba de posesión del buzón —única razón de ser del flujo— no +/// probaría nada. +/// +/// Codificado en Base64Url para poder viajar tal cual dentro de un enlace sin escapes. +/// +public static class PasswordResetToken +{ + /// + /// Vida del secreto. Corta a propósito: el token es equivalente a la contraseña durante su + /// vigencia, así que la ventana de exposición de un buzón comprometido debe ser mínima. + /// + public static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(15); + + /// 256 bits de entropía criptográfica: inadivinable por fuerza bruta en línea o fuera de ella. + public static string Generate() + { + var bytes = new byte[32]; + RandomNumberGenerator.Fill(bytes); + return Base64Url.EncodeToString(bytes); + } + + /// + /// SHA-256 hex en minúsculas del plaintext. Se persiste el hash, nunca el token: quien lea + /// la tabla no puede canjear nada. No lleva sal ni coste porque el secreto ya tiene 256 bits + /// de entropía — el ataque de diccionario que justifica BCrypt en contraseñas no aplica. + /// + public static string Hash(string plaintext) + { + ArgumentException.ThrowIfNullOrEmpty(plaintext); + return Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(plaintext))); + } +} diff --git a/src/apps/ums.api/Ums.Application/Identity/Auth/RefreshTokenGenerator.cs b/src/apps/ums.api/Ums.Application/Identity/Auth/RefreshTokenGenerator.cs new file mode 100644 index 00000000..1d0d028d --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Auth/RefreshTokenGenerator.cs @@ -0,0 +1,20 @@ +namespace Ums.Application.Identity.Auth; + +using System.Security.Cryptography; + +/// +/// Genera el plaintext de un refresh token opaco (ADR-UMS-091): 64 bytes aleatorios +/// criptográficos en base64. Espeja el formato de JwtTokenService.GenerateRefreshToken, +/// pero vive en la capa de aplicación para que la rotación (que persiste el hash) no +/// dependa de la capa de presentación. El plaintext se devuelve una sola vez al cliente; +/// solo su hash SHA-256 se persiste (). +/// +public static class RefreshTokenGenerator +{ + public static string Generate() + { + var bytes = new byte[64]; + RandomNumberGenerator.Fill(bytes); + return Convert.ToBase64String(bytes); + } +} diff --git a/src/apps/ums.api/Ums.Application/Identity/Auth/RefreshTokenHasher.cs b/src/apps/ums.api/Ums.Application/Identity/Auth/RefreshTokenHasher.cs new file mode 100644 index 00000000..5bbff7d9 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Auth/RefreshTokenHasher.cs @@ -0,0 +1,19 @@ +namespace Ums.Application.Identity.Auth; + +using System.Security.Cryptography; +using System.Text; + +/// +/// Hash de refresh tokens para persistencia (ADR-UMS-091). Se guarda el hash, nunca el +/// plaintext — como una contraseña. La búsqueda en renovación/revocación se hace +/// hasheando el token presentado y comparando. SHA-256 hex en minúsculas. +/// +public static class RefreshTokenHasher +{ + public static string Hash(string plaintext) + { + ArgumentException.ThrowIfNullOrEmpty(plaintext); + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(plaintext)); + return Convert.ToHexStringLower(bytes); + } +} diff --git a/src/apps/ums.api/Ums.Application/Identity/Auth/RefreshTokenPolicy.cs b/src/apps/ums.api/Ums.Application/Identity/Auth/RefreshTokenPolicy.cs new file mode 100644 index 00000000..553c4f3e --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Auth/RefreshTokenPolicy.cs @@ -0,0 +1,19 @@ +namespace Ums.Application.Identity.Auth; + +/// +/// Política efectiva de refresh token de un inquilino, resuelta desde la +/// configuración jerárquica (ADR-UMS-091 / FR-015). Fail-closed: si la capacidad no +/// está habilitada, es false y el resto es irrelevante +/// (se conserva el modelo de ADR-UMS-088: solo validUntil). +/// +public sealed record RefreshTokenPolicy( + bool Enabled, + int LifetimeMinutes, + bool Rotate, + bool DetectReuse, + int MaxRenewals) +{ + /// Política deshabilitada — comportamiento por defecto y fail-closed. + public static readonly RefreshTokenPolicy Disabled = + new(Enabled: false, LifetimeMinutes: 0, Rotate: false, DetectReuse: false, MaxRenewals: 0); +} diff --git a/src/apps/ums.api/Ums.Application/Identity/Auth/RefreshTokenPolicyProvider.cs b/src/apps/ums.api/Ums.Application/Identity/Auth/RefreshTokenPolicyProvider.cs new file mode 100644 index 00000000..ab46180d --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Auth/RefreshTokenPolicyProvider.cs @@ -0,0 +1,61 @@ +namespace Ums.Application.Identity.Auth; + +using Ums.Application.Configuration.Services; +using Ums.Domain.Configuration.AppConfiguration; + +/// +/// Implementación fail-closed de : lee los +/// códigos de por la cascada jerárquica del +/// . Cualquier lectura ausente cae al default +/// seguro (deshabilitado). No emite ni persiste nada — solo resuelve la política. +/// +public sealed class RefreshTokenPolicyProvider : IRefreshTokenPolicyProvider +{ + private readonly IConfigurationProvider _config; + + public RefreshTokenPolicyProvider(IConfigurationProvider config) + { + _config = config; + } + + public RefreshTokenPolicy Resolve(Guid? tenantId, Guid? suiteId = null, Guid? moduleId = null) + { + var enabled = _config.GetValueAs( + AppConfigurationCodes.AuthRefreshTokenEnabled, tenantId, suiteId, moduleId, + AppConfigurationDefaults.AuthRefreshTokenEnabled); + + if (!enabled) + { + return RefreshTokenPolicy.Disabled; + } + + var lifetimeMinutes = _config.GetValueAs( + AppConfigurationCodes.AuthRefreshTokenLifetimeMinutes, tenantId, suiteId, moduleId, + AppConfigurationDefaults.AuthRefreshTokenLifetimeMinutes); + + // Vida no positiva con la capacidad activada: configuración inválida ⇒ fail-closed. + if (lifetimeMinutes <= 0) + { + return RefreshTokenPolicy.Disabled; + } + + var rotate = _config.GetValueAs( + AppConfigurationCodes.AuthRefreshTokenRotate, tenantId, suiteId, moduleId, + AppConfigurationDefaults.AuthRefreshTokenRotate); + + var detectReuse = _config.GetValueAs( + AppConfigurationCodes.AuthRefreshTokenDetectReuse, tenantId, suiteId, moduleId, + AppConfigurationDefaults.AuthRefreshTokenDetectReuse); + + var maxRenewals = _config.GetValueAs( + AppConfigurationCodes.AuthRefreshTokenMaxRenewals, tenantId, suiteId, moduleId, + AppConfigurationDefaults.AuthRefreshTokenMaxRenewals); + + return new RefreshTokenPolicy( + Enabled: true, + LifetimeMinutes: lifetimeMinutes, + Rotate: rotate, + DetectReuse: detectReuse, + MaxRenewals: maxRenewals < 0 ? 0 : maxRenewals); + } +} diff --git a/src/apps/ums.api/Ums.Application/Identity/Auth/RefreshTokenStatuses.cs b/src/apps/ums.api/Ums.Application/Identity/Auth/RefreshTokenStatuses.cs new file mode 100644 index 00000000..02c65f32 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Auth/RefreshTokenStatuses.cs @@ -0,0 +1,22 @@ +namespace Ums.Application.Identity.Auth; + +/// +/// Estados canónicos del ciclo de vida de un refresh token (ADR-UMS-091). +/// +/// +/// Active: vigente; el único que renueva. +/// Rotated: fue canjeado y reemplazado por otro de su familia (rotación normal). +/// Used: consumido sin rotación (política sin rotación). +/// Revoked: invalidado (logout, bloqueo, cambio de permisos o detección de reuso). +/// +/// +/// Definidos en la capa de aplicación para que el resolutor y el almacén (infraestructura) +/// compartan la misma verdad sin que aplicación dependa de infraestructura. +/// +public static class RefreshTokenStatuses +{ + public const string Active = "Active"; + public const string Rotated = "Rotated"; + public const string Revoked = "Revoked"; + public const string Used = "Used"; +} diff --git a/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/CloseBranchCommand.cs b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/CloseBranchCommand.cs new file mode 100644 index 00000000..f2b1cea5 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/CloseBranchCommand.cs @@ -0,0 +1,17 @@ +using Ums.Application.Identity.Tenant.Branch.DTOs; + +namespace Ums.Application.Identity.Tenant.Branch.Commands; + +/// +/// Cierre DEFINITIVO de una sucursal (ADR-0164 §2.1). Sustituye a RemoveBranchCommand, que +/// borraba la fila: el nombre cambia con la semántica porque dejar vivo el vocabulario del borrado +/// físico es la vía más rápida a que alguien lo reintroduzca. +/// +/// +/// Motivo declarado del cierre. Va a la bitácora, no al estado: dentro de dos años la pregunta no +/// será si la sucursal está cerrada —eso se ve— sino por qué se cerró y quién lo decidió. +/// +public sealed record CloseBranchCommand( + Guid TenantId, + Guid BranchId, + string? Reason = null) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/CloseBranchCommandHandler.cs b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/CloseBranchCommandHandler.cs new file mode 100644 index 00000000..d6b0014b --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/CloseBranchCommandHandler.cs @@ -0,0 +1,100 @@ +using Ums.Application.Identity.Tenant.Branch.DTOs; +using Ums.Domain.Authorization; + +namespace Ums.Application.Identity.Tenant.Branch.Commands; + +/// +/// Cierra definitivamente una sucursal (ADR-0164). Antes esto la BORRABA de la base. +/// +/// Guarda de cascada. Se cuentan las referencias VIVAS a la sucursal en los dos +/// agregados que la apuntan —cuentas y perfiles— y, si hay alguna, se rechaza con 409 nombrando +/// cuántas de cada clase. Es imprescindible que la haga la aplicación: ni Profiles.BranchId +/// ni UserAccounts.BranchId tienen clave ajena contra TenantBranches, así que la base +/// nunca habría dicho nada — de hecho el borrado físico anterior huerfanizaba esas filas en +/// silencio. +/// +/// Lo ya eliminado no bloquea: los dos recuentos miran solo lo activo (§2.2). +/// +public sealed class CloseBranchCommandHandler : ICommandHandler +{ + private readonly ITenantRepository _tenantRepository; + private readonly IUserAccountRepository _userAccountRepository; + private readonly IProfileRepository _profileRepository; + private readonly IUserContext _userContext; + private readonly ITenantScopePolicy _tenantScopePolicy; + + public CloseBranchCommandHandler( + ITenantRepository tenantRepository, + IUserAccountRepository userAccountRepository, + IProfileRepository profileRepository, + IUserContext userContext, + ITenantScopePolicy tenantScopePolicy) + { + _tenantRepository = tenantRepository; + _userAccountRepository = userAccountRepository; + _profileRepository = profileRepository; + _userContext = userContext; + _tenantScopePolicy = tenantScopePolicy; + } + + [AuditTrail] + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task> Handle( + CloseBranchCommand request, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(_userContext.UserId)) + { + return Result.Failure("Authenticated user is required to close a branch."); + } + + var tenant = await _tenantRepository.GetByIdAsync(request.TenantId, cancellationToken); + if (tenant is null) + { + return Result.Failure("Tenant was not found."); + } + + var scopeResult = await _tenantScopePolicy.EnsureManagementOwnerScopeAsync(request.TenantId, cancellationToken); + if (scopeResult.IsFailure) + { + return Result.Failure(scopeResult.Error); + } + + // ── Guarda de cascada: referencias vivas a la sucursal (ADR-0164 §2.2) ── + var activeUserCount = await _userAccountRepository.CountActiveByBranchAsync( + request.BranchId, cancellationToken); + var activeProfileCount = await _profileRepository.CountActiveByBranchAsync( + request.BranchId, cancellationToken); + + if (activeUserCount > 0 || activeProfileCount > 0) + { + // Se enumeran las DOS clases en la misma respuesta, no la primera que aparece: quien + // opera necesita saber todo lo que tiene que resolver antes de reintentar. + var deps = new List(); + if (activeUserCount > 0) deps.Add(new BlockingDependency("UserAccount", "Active", activeUserCount)); + if (activeProfileCount > 0) deps.Add(new BlockingDependency("Profile", "Active", activeProfileCount)); + + return Result.Failure( + BlockedOperationError.Encode(DomainErrors.Tenant.BranchHasLiveReferences, deps)); + } + + // El dominio revalida los recuentos: la guarda de arriba existe para poder adjuntar el + // desglose, no para sustituir a la invariante. + var result = tenant.CloseBranch( + IdValueObject.Load(request.BranchId), + ActorId.Create(_userContext.UserId), + activeUserCount, + activeProfileCount, + request.Reason); + + if (result.IsFailure) + { + return Result.Failure(result.Error); + } + + await _tenantRepository.UpdateAsync(tenant, cancellationToken); + await _tenantRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + return Result.Success(new CloseBranchResponse(request.TenantId)); + } +} diff --git a/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/CloseBranchCommandValidator.cs b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/CloseBranchCommandValidator.cs new file mode 100644 index 00000000..f1e803c6 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/CloseBranchCommandValidator.cs @@ -0,0 +1,21 @@ +namespace Ums.Application.Identity.Tenant.Branch.Commands; + +using FluentValidation; + +public sealed class CloseBranchCommandValidator : AbstractValidator +{ + public CloseBranchCommandValidator() + { + RuleFor(command => command.TenantId) + .NotEmpty(); + + RuleFor(command => command.BranchId) + .NotEmpty(); + + // El motivo es opcional, pero si viene debe caber en la columna de la bitácora: mejor un 400 + // explicable que un truncamiento silencioso del único texto que explica el cierre. + RuleFor(command => command.Reason) + .MaximumLength(500) + .When(command => command.Reason is not null); + } +} diff --git a/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/DeactivateBranchCommandHandler.cs b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/DeactivateBranchCommandHandler.cs index c59ec08a..f15ef3c7 100644 --- a/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/DeactivateBranchCommandHandler.cs +++ b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/DeactivateBranchCommandHandler.cs @@ -5,15 +5,18 @@ namespace Ums.Application.Identity.Tenant.Branch.Commands; public sealed class DeactivateBranchCommandHandler : ICommandHandler { private readonly ITenantRepository _tenantRepository; + private readonly IUserAccountRepository _userAccountRepository; private readonly IUserContext _userContext; private readonly ITenantScopePolicy _tenantScopePolicy; public DeactivateBranchCommandHandler( ITenantRepository tenantRepository, + IUserAccountRepository userAccountRepository, IUserContext userContext, ITenantScopePolicy tenantScopePolicy) { _tenantRepository = tenantRepository; + _userAccountRepository = userAccountRepository; _userContext = userContext; _tenantScopePolicy = tenantScopePolicy; } @@ -41,6 +44,20 @@ public async Task> Handle( return Result.Failure(scopeResult.Error); } + // ── Dependency guard: active users bound to the branch (G-046) ──────── + var activeUserCount = await _userAccountRepository.CountActiveByBranchAsync( + request.BranchId, cancellationToken); + + if (activeUserCount > 0) + { + var deps = new List + { + new("UserAccount", "Active", activeUserCount), + }; + return Result.Failure( + BlockedOperationError.Encode(DomainErrors.Tenant.BranchHasActiveUsers, deps)); + } + var result = tenant.DeactivateBranch( IdValueObject.Load(request.BranchId), ActorId.Create(_userContext.UserId)); diff --git a/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/RemoveBranchCommand.cs b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/RemoveBranchCommand.cs deleted file mode 100644 index 766488a2..00000000 --- a/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/RemoveBranchCommand.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Ums.Application.Identity.Tenant.Branch.DTOs; - - - -namespace Ums.Application.Identity.Tenant.Branch.Commands; - - -public sealed record RemoveBranchCommand( - Guid TenantId, - Guid BranchId) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/RemoveBranchCommandValidator.cs b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/RemoveBranchCommandValidator.cs deleted file mode 100644 index 3db94b04..00000000 --- a/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/RemoveBranchCommandValidator.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace Ums.Application.Identity.Tenant.Branch.Commands; - -using FluentValidation; - -public sealed class RemoveBranchCommandValidator : AbstractValidator -{ - public RemoveBranchCommandValidator() - { - RuleFor(command => command.TenantId) - .NotEmpty(); - - RuleFor(command => command.BranchId) - .NotEmpty(); - } -} diff --git a/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/UpdateBranchCommand.cs b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/UpdateBranchCommand.cs new file mode 100644 index 00000000..411f2ce3 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/UpdateBranchCommand.cs @@ -0,0 +1,8 @@ +namespace Ums.Application.Identity.Tenant.Branch.Commands; + +// FS-26 (G-024): actualiza los datos editables de una sucursal. El Code es inmutable. +public sealed record UpdateBranchCommand( + Guid TenantId, + Guid BranchId, + string Name, + string? GeofencingMetadata) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/RemoveBranchCommandHandler.cs b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/UpdateBranchCommandHandler.cs similarity index 62% rename from src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/RemoveBranchCommandHandler.cs rename to src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/UpdateBranchCommandHandler.cs index 62655fdc..2130d6f3 100644 --- a/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/RemoveBranchCommandHandler.cs +++ b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Commands/UpdateBranchCommandHandler.cs @@ -1,14 +1,12 @@ -using Ums.Application.Identity.Tenant.Branch.DTOs; - namespace Ums.Application.Identity.Tenant.Branch.Commands; -public sealed class RemoveBranchCommandHandler : ICommandHandler +public sealed class UpdateBranchCommandHandler : ICommandHandler { private readonly ITenantRepository _tenantRepository; private readonly IUserContext _userContext; private readonly ITenantScopePolicy _tenantScopePolicy; - public RemoveBranchCommandHandler( + public UpdateBranchCommandHandler( ITenantRepository tenantRepository, IUserContext userContext, ITenantScopePolicy tenantScopePolicy) @@ -20,39 +18,39 @@ public RemoveBranchCommandHandler( [AuditTrail] [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] - public async Task> Handle( - RemoveBranchCommand request, - CancellationToken cancellationToken) + public async Task Handle(UpdateBranchCommand request, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(_userContext.UserId)) { - return Result.Failure("Authenticated user is required to remove a branch."); + return Result.Failure("Authenticated user is required to update a branch."); } var tenant = await _tenantRepository.GetByIdAsync(request.TenantId, cancellationToken); if (tenant is null) { - return Result.Failure("Tenant was not found."); + return Result.Failure("Tenant was not found."); } var scopeResult = await _tenantScopePolicy.EnsureManagementOwnerScopeAsync(request.TenantId, cancellationToken); if (scopeResult.IsFailure) { - return Result.Failure(scopeResult.Error); + return Result.Failure(scopeResult.Error); } - var result = tenant.RemoveBranch( + var result = tenant.UpdateBranch( IdValueObject.Load(request.BranchId), + Name.Create(request.Name), + string.IsNullOrWhiteSpace(request.GeofencingMetadata) ? null : Value.Create(request.GeofencingMetadata), ActorId.Create(_userContext.UserId)); if (result.IsFailure) { - return Result.Failure(result.Error); + return result; } await _tenantRepository.UpdateAsync(tenant, cancellationToken); await _tenantRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); - return Result.Success(new RemoveBranchResponse(request.TenantId)); + return Result.Success(); } } diff --git a/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/DTOs/BranchDto.cs b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/DTOs/BranchDto.cs index 3cd9fa2e..5d6a1978 100644 --- a/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/DTOs/BranchDto.cs +++ b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/DTOs/BranchDto.cs @@ -1,8 +1,14 @@ namespace Ums.Application.Identity.Tenant.Branch.DTOs; +/// +/// Cierre definitivo (ADR-0164). Se expone —y no solo se filtra— porque quien pida explícitamente +/// las cerradas debe poder distinguirlas de las meramente desactivadas: son dos cosas distintas. +/// public sealed record BranchDto( Guid BranchId, string Code, string Name, bool IsActive, - string? GeofencingMetadata); + string? GeofencingMetadata, + bool IsClosed = false, + DateTime? ClosedAtUtc = null); diff --git a/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/DTOs/BranchLifecycleEntryDto.cs b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/DTOs/BranchLifecycleEntryDto.cs new file mode 100644 index 00000000..b931a641 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/DTOs/BranchLifecycleEntryDto.cs @@ -0,0 +1,15 @@ +namespace Ums.Application.Identity.Tenant.Branch.DTOs; + +/// +/// Un asiento de la bitácora de una sucursal, tal y como sale por el API (ADR-0164). +/// El episodio viaja como NOMBRE ("Opened", "Deactivated", "Reactivated", "Closed") y no como +/// número: quien lee una auditoría no debería tener que consultar una tabla de equivalencias. +/// +public sealed record BranchLifecycleEntryDto( + Guid EntryId, + string Episode, + DateTime OccurredAtUtc, + string ActorId, + string NameSnapshot, + string? GeofencingSnapshot, + string? Reason); diff --git a/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/DTOs/CloseBranchResponse.cs b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/DTOs/CloseBranchResponse.cs new file mode 100644 index 00000000..2aa495ca --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/DTOs/CloseBranchResponse.cs @@ -0,0 +1,3 @@ +namespace Ums.Application.Identity.Tenant.Branch.DTOs; + +public sealed record CloseBranchResponse(Guid TenantId); diff --git a/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/DTOs/RemoveBranchResponse.cs b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/DTOs/RemoveBranchResponse.cs deleted file mode 100644 index 244a2dcb..00000000 --- a/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/DTOs/RemoveBranchResponse.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Ums.Application.Identity.Tenant.Branch.DTOs; - -public sealed record RemoveBranchResponse(Guid TenantId); diff --git a/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Queries/GetBranchLifecycleQuery.cs b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Queries/GetBranchLifecycleQuery.cs new file mode 100644 index 00000000..8fdb51f6 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Queries/GetBranchLifecycleQuery.cs @@ -0,0 +1,12 @@ +using Ums.Application.Identity.Tenant.Branch.DTOs; + +namespace Ums.Application.Identity.Tenant.Branch.Queries; + +/// +/// Bitácora de una sucursal: sus episodios en orden cronológico (ADR-0164). +/// Responde a la pregunta que el estado no puede responder: «¿cómo estaba esta sucursal cuando salió +/// aquel despacho, y quién decidió cada cambio?». +/// +public sealed record GetBranchLifecycleQuery( + Guid TenantId, + Guid BranchId) : IQuery>; diff --git a/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Queries/GetBranchLifecycleQueryHandler.cs b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Queries/GetBranchLifecycleQueryHandler.cs new file mode 100644 index 00000000..4d760034 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Queries/GetBranchLifecycleQueryHandler.cs @@ -0,0 +1,52 @@ +using Ums.Application.Identity.Tenant.Branch.DTOs; +using Ums.Domain.Identity.Tenant; + +namespace Ums.Application.Identity.Tenant.Branch.Queries; + +public sealed class GetBranchLifecycleQueryHandler : IQueryHandler> +{ + private readonly ITenantRepository _tenantRepository; + + public GetBranchLifecycleQueryHandler(ITenantRepository tenantRepository) + { + _tenantRepository = tenantRepository; + } + + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task>> Handle( + GetBranchLifecycleQuery request, + CancellationToken cancellationToken) + { + var tenant = await _tenantRepository.GetByIdAsync(request.TenantId, cancellationToken); + if (tenant is null) + { + return Result>.Failure("Tenant not found."); + } + + // La resolución por id SÍ devuelve las cerradas (ADR-0164): preguntar por el pasado de una + // sucursal cerrada es exactamente para lo que existe la bitácora, así que aquí no se filtra + // por `IsClosed`. Lo que se comprueba es que la sucursal PERTENEZCA a este inquilino, para + // que el identificador de otro no sirva de sonda. + var branch = tenant.Branches.FirstOrDefault(b => b.GetId().GetValue() == request.BranchId); + if (branch is null) + { + return Result>.Failure(DomainErrors.Tenant.BranchNotFound); + } + + var entries = await _tenantRepository.GetBranchLifecycleAsync( + request.TenantId, request.BranchId, cancellationToken); + + var dtos = entries + .Select(e => new BranchLifecycleEntryDto( + e.Id, + e.Episode.Name, + e.OccurredAtUtc, + e.ActorId, + e.NameSnapshot, + e.GeofencingSnapshot, + e.Reason)) + .ToList(); + + return Result>.Success(dtos.AsReadOnly()); + } +} diff --git a/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Queries/GetBranchesByTenantIdQuery.cs b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Queries/GetBranchesByTenantIdQuery.cs index 490f41aa..0aaee353 100644 --- a/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Queries/GetBranchesByTenantIdQuery.cs +++ b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Queries/GetBranchesByTenantIdQuery.cs @@ -2,4 +2,11 @@ namespace Ums.Application.Identity.Tenant.Branch.Queries; -public sealed record GetBranchesByTenantIdQuery(Guid TenantId) : IQuery>; +/// +/// ADR-0164: por defecto el listado NO trae las sucursales cerradas definitivamente — para quien +/// opera hoy ya no existen. Se puede pedir que las traiga para revisar el histórico, que es la +/// diferencia entre «oculto» y «borrado»: lo borrado no se puede pedir. +/// +public sealed record GetBranchesByTenantIdQuery( + Guid TenantId, + bool IncludeClosed = false) : IQuery>; diff --git a/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Queries/GetBranchesByTenantIdQueryHandler.cs b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Queries/GetBranchesByTenantIdQueryHandler.cs index e8e255a8..30ca32dd 100644 --- a/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Queries/GetBranchesByTenantIdQueryHandler.cs +++ b/src/apps/ums.api/Ums.Application/Identity/Tenant/Branch/Queries/GetBranchesByTenantIdQueryHandler.cs @@ -25,13 +25,20 @@ public async Task>> Handle( return Result>.Failure("Tenant not found."); } + // ADR-0164: el filtro de las cerradas vive AQUÍ, en la lectura, y no en un filtro global de + // EF. Con un filtro global la sucursal cerrada desaparecería también de la vía de escritura y + // de la resolución por id —el grafo de autorización resuelve la sucursal de un perfil por su + // id—, y el borrado lógico habría acabado siendo tan opaco como el físico que sustituye. var branches = tenant.Branches + .Where(b => request.IncludeClosed || !b.IsClosed) .Select(b => new BranchDto( b.GetId().GetValue(), b.Code.GetValue(), b.Name.GetValue(), b.IsActive, - b.GeofencingMetadata?.GetValue())) + b.GeofencingMetadata?.GetValue(), + b.IsClosed, + b.ClosedAtUtc)) .ToList(); return Result>.Success(branches.AsReadOnly()); diff --git a/src/apps/ums.api/Ums.Application/Identity/Tenant/Commands/CreateTenantCommandHandler.cs b/src/apps/ums.api/Ums.Application/Identity/Tenant/Commands/CreateTenantCommandHandler.cs index 93dbad96..d8a0b4e3 100644 --- a/src/apps/ums.api/Ums.Application/Identity/Tenant/Commands/CreateTenantCommandHandler.cs +++ b/src/apps/ums.api/Ums.Application/Identity/Tenant/Commands/CreateTenantCommandHandler.cs @@ -35,12 +35,39 @@ public async Task> Handle( return Result.Failure("Tenant code already exists."); } - var type = DomainEnumerationParser.FromName(request.Type)!; + // G-045/G-037: la propiedad de gestión (management owner) es única en todo el sistema. + // Existe un índice parcial único en BD (IX_Tenants_SingleManagementOwner). Sin este + // chequeo explícito, crear un segundo tenant con IsManagementOwner=true provoca una + // violación de índice en SaveEntitiesAsync que termina en 500. Verificamos la unicidad + // ANTES de persistir y devolvemos un Result.Failure que el mapeo de presentación + // resuelve a 409 (análogo a SetManagementOwnerCommandHandler). + if (request.IsManagementOwner) + { + var allTenants = await _tenantRepository.GetAllAsync(null, cancellationToken); + var existingOwner = allTenants.FirstOrDefault(candidate => candidate.IsManagementOwner); + if (existingOwner is not null) + { + return Result.Failure(DomainErrors.Tenant.ManagementOwnerAlreadyExists); + } + } + + // G-045: red de defensa en profundidad. El validador ya rechaza un OrganizationType + // desconocido, pero no debemos depender de que el pipeline de validación esté cableado: + // un enum inválido debe resolver Result.Failure (→ 400) y nunca propagar null hasta una + // NullReferenceException que colapsaría a 500 (sin excepciones para control de flujo). + var type = DomainEnumerationParser.FromName(request.Type); + if (type is null) + { + return Result.Failure(DomainErrors.Common.Invalid); + } + var idpStrategy = DomainEnumerationParser.FromName(request.IdpStrategy) ?? IdpStrategy.InternalBcrypt; var companyReference = string.IsNullOrWhiteSpace(request.CompanyReference) ? null : CompanyReference.Create(request.CompanyReference); - var parentTenantId = TenantId.Load(Guid.NewGuid()); + // G-046: a tenant created through this endpoint is a top-level tenant with no parent. + // Never fabricate a random parent id — that produced a dangling, non-existent reference. + TenantId? parentTenantId = null; var tenantResult = Tenant.Create( code, diff --git a/src/apps/ums.api/Ums.Application/Identity/Tenant/Commands/SetManagementOwnerCommandHandler.cs b/src/apps/ums.api/Ums.Application/Identity/Tenant/Commands/SetManagementOwnerCommandHandler.cs index a4dd2e65..a8f818aa 100644 --- a/src/apps/ums.api/Ums.Application/Identity/Tenant/Commands/SetManagementOwnerCommandHandler.cs +++ b/src/apps/ums.api/Ums.Application/Identity/Tenant/Commands/SetManagementOwnerCommandHandler.cs @@ -28,6 +28,24 @@ public async Task Handle(SetManagementOwnerCommand request, Cancellation return Result.Failure("Tenant was not found."); } + // G-045: la propiedad de gestión (management owner) es única en todo el sistema. + // Existe un índice parcial único en BD (IX_Tenants_SingleManagementOwner). Sin este + // chequeo explícito, otorgarla a un segundo tenant provoca una violación de índice + // en SaveEntitiesAsync que se traduce en 500. Verificamos la unicidad ANTES de + // persistir y devolvemos un Result.Failure que el mapeo de presentación resuelve a 409. + if (request.Value) + { + var allTenants = await _tenantRepository.GetAllAsync(null, cancellationToken); + var existingOwner = allTenants.FirstOrDefault(candidate => + candidate.IsManagementOwner && + candidate.Props.Id.GetValue() != request.TenantId); + + if (existingOwner is not null) + { + return Result.Failure(DomainErrors.Tenant.ManagementOwnerAlreadyExists); + } + } + var result = tenant.SetManagementOwner(request.Value, ActorId.Create(_userContext.UserId)); if (result.IsFailure) { diff --git a/src/apps/ums.api/Ums.Application/Identity/Tenant/Commands/UpdateTenantCommand.cs b/src/apps/ums.api/Ums.Application/Identity/Tenant/Commands/UpdateTenantCommand.cs new file mode 100644 index 00000000..1abbc894 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Tenant/Commands/UpdateTenantCommand.cs @@ -0,0 +1,8 @@ +namespace Ums.Application.Identity.Tenant.Commands; + +// FS-26 (G-024): actualiza los datos generales editables del tenant. El Code es inmutable. +public sealed record UpdateTenantCommand( + Guid TenantId, + string Name, + string Type, + string? CompanyReference) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/Identity/Tenant/Commands/UpdateTenantCommandHandler.cs b/src/apps/ums.api/Ums.Application/Identity/Tenant/Commands/UpdateTenantCommandHandler.cs new file mode 100644 index 00000000..911aff93 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/Tenant/Commands/UpdateTenantCommandHandler.cs @@ -0,0 +1,68 @@ +namespace Ums.Application.Identity.Tenant.Commands; + +using Ums.Domain.Identity.Tenant; + +public sealed class UpdateTenantCommandHandler : ICommandHandler +{ + private readonly ITenantRepository _tenantRepository; + private readonly IUserContext _userContext; + private readonly ITenantScopePolicy _tenantScopePolicy; + + public UpdateTenantCommandHandler( + ITenantRepository tenantRepository, + IUserContext userContext, + ITenantScopePolicy tenantScopePolicy) + { + _tenantRepository = tenantRepository; + _userContext = userContext; + _tenantScopePolicy = tenantScopePolicy; + } + + [AuditTrail] + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task Handle(UpdateTenantCommand request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(_userContext.UserId)) + { + return Result.Failure("Authenticated user is required to update a tenant."); + } + + var tenant = await _tenantRepository.GetByIdAsync(request.TenantId, cancellationToken); + if (tenant is null) + { + return Result.Failure("Tenant was not found."); + } + + var scopeResult = await _tenantScopePolicy.EnsureManagementOwnerScopeAsync(request.TenantId, cancellationToken); + if (scopeResult.IsFailure) + { + return Result.Failure(scopeResult.Error); + } + + var type = DomainEnumerationParser.FromName(request.Type); + if (type is null) + { + return Result.Failure("Invalid organization type."); + } + + var companyReference = string.IsNullOrWhiteSpace(request.CompanyReference) + ? null + : CompanyReference.Create(request.CompanyReference); + + var result = tenant.UpdateGeneralData( + Name.Create(request.Name), + type, + companyReference, + ActorId.Create(_userContext.UserId)); + + if (result.IsFailure) + { + return result; + } + + await _tenantRepository.UpdateAsync(tenant, cancellationToken); + await _tenantRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/src/apps/ums.api/Ums.Application/Identity/Tenant/Queries/GetAllTenantsQuery.cs b/src/apps/ums.api/Ums.Application/Identity/Tenant/Queries/GetAllTenantsQuery.cs index 5cc4334f..249f7d82 100644 --- a/src/apps/ums.api/Ums.Application/Identity/Tenant/Queries/GetAllTenantsQuery.cs +++ b/src/apps/ums.api/Ums.Application/Identity/Tenant/Queries/GetAllTenantsQuery.cs @@ -6,7 +6,9 @@ public sealed record GetAllTenantsQuery( int Page = 1, int PageSize = 20, string? Search = null, - string Criteria = "name", + // Campo de búsqueda (`criteria`). Null = no especificado: el repositorio cae a SortBy por + // compatibilidad (antes el campo de búsqueda se derivaba de SortBy). Ver GetAllTenantsQueryHandler. + string? Criteria = null, string Status = "all", string SortBy = "name", string SortOrder = "asc") : IQuery>; diff --git a/src/apps/ums.api/Ums.Application/Identity/Tenant/Queries/GetAllTenantsQueryHandler.cs b/src/apps/ums.api/Ums.Application/Identity/Tenant/Queries/GetAllTenantsQueryHandler.cs index 4a49f31b..12a4d060 100644 --- a/src/apps/ums.api/Ums.Application/Identity/Tenant/Queries/GetAllTenantsQueryHandler.cs +++ b/src/apps/ums.api/Ums.Application/Identity/Tenant/Queries/GetAllTenantsQueryHandler.cs @@ -27,7 +27,6 @@ public async Task>> Handle( { var page = NormalizePage(request.Page); var pageSize = NormalizePageSize(request.PageSize); - var criteria = NormalizeText(request.Criteria, "name").ToLowerInvariant(); var status = NormalizeText(request.Status, "all"); var sortBy = NormalizeText(request.SortBy, "name").ToLowerInvariant(); var sortOrder = NormalizeText(request.SortOrder, "asc").ToLowerInvariant(); @@ -37,8 +36,12 @@ public async Task>> Handle( // REC-12: Push filtering/sorting/pagination to the repository so SQL // implementations use DB-level Skip/Take instead of loading all rows. + // searchField (parámetro `criteria` del API) determina el campo de búsqueda. Se pasa el valor + // CRUDO (no el normalizado con default "name"): si el cliente no envía `criteria`, va null y el + // repositorio cae a `sortBy` por compatibilidad hacia atrás (antes el campo de búsqueda se + // derivaba de sortBy). Si el cliente sí envía `criteria` (p. ej. "code"), este manda. var (tenants, totalItems) = await _tenantRepository.GetPagedAsync( - page, pageSize, search, status, sortBy, sortOrder, effectiveTenantId, cancellationToken); + page, pageSize, search, status, sortBy, sortOrder, effectiveTenantId, cancellationToken, searchField: request.Criteria); var items = tenants.Select(t => new TenantDto( t.Props.Id.GetValue(), diff --git a/src/apps/ums.api/Ums.Application/Identity/Tenant/Queries/GetTenantByIdQueryHandler.cs b/src/apps/ums.api/Ums.Application/Identity/Tenant/Queries/GetTenantByIdQueryHandler.cs index a424a9d2..72967187 100644 --- a/src/apps/ums.api/Ums.Application/Identity/Tenant/Queries/GetTenantByIdQueryHandler.cs +++ b/src/apps/ums.api/Ums.Application/Identity/Tenant/Queries/GetTenantByIdQueryHandler.cs @@ -1,3 +1,4 @@ +using Ums.Application.Common.Interfaces; using Ums.Application.Identity.Tenant.DTOs; using Ums.Domain.Identity.Tenant; @@ -6,10 +7,12 @@ namespace Ums.Application.Identity.Tenant.Queries; public sealed class GetTenantByIdQueryHandler : IQueryHandler { private readonly ITenantRepository _tenantRepository; + private readonly ITenantScopePolicy _tenantScopePolicy; - public GetTenantByIdQueryHandler(ITenantRepository tenantRepository) + public GetTenantByIdQueryHandler(ITenantRepository tenantRepository, ITenantScopePolicy tenantScopePolicy) { _tenantRepository = tenantRepository; + _tenantScopePolicy = tenantScopePolicy; } [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] @@ -25,6 +28,17 @@ public async Task> Handle( return Result.Failure("Tenant not found."); } + // TS04/F3 (aislamiento cross-tenant): el agregado Tenant es su propia identidad, así que el + // global query filter (que aísla entidades con columna TenantId) NO lo cubre. Sin este chequeo, + // cualquier usuario autenticado leería la identidad de cualquier tenant por id (fuga cross-tenant). + // ResolveQueryScope() devuelve null para internal-admin (cross-tenant) y el OrganizationId propio + // para un usuario regular. Se devuelve "not found" (404, no 403) para no filtrar la existencia. + var scope = _tenantScopePolicy.ResolveQueryScope(); + if (scope.HasValue && tenant.Props.Id.GetValue() != scope.Value) + { + return Result.Failure("Tenant not found."); + } + return Result.Success(new TenantDto( tenant.Props.Id.GetValue(), tenant.Props.Code.GetValue(), diff --git a/src/apps/ums.api/Ums.Application/Identity/Tenant/SignupRequests/Commands/ApproveTenantSignupCommandHandler.cs b/src/apps/ums.api/Ums.Application/Identity/Tenant/SignupRequests/Commands/ApproveTenantSignupCommandHandler.cs index 94473af4..4c3d43e8 100644 --- a/src/apps/ums.api/Ums.Application/Identity/Tenant/SignupRequests/Commands/ApproveTenantSignupCommandHandler.cs +++ b/src/apps/ums.api/Ums.Application/Identity/Tenant/SignupRequests/Commands/ApproveTenantSignupCommandHandler.cs @@ -112,6 +112,12 @@ public async Task> Handle(ApproveTenantSignu return Result.Failure(approveResult.Error); } + // TODO(D-016): excepción JUSTIFICADA a "un agregado por transacción" (ADR-0098 D2): + // crea Tenant + UserAccount + actualiza SignupRequest en la misma tx. El arranque de un + // inquilino con su primer admin es indivisible (crearlo a medias deja un inquilino inusable), + // por lo que se permite consistencia inmediata bajo ADR-0098 D5. Ver DECISIONS.md D-016 (E1) y GAPS.md G-066. + // Patrón e interpretación: KB-TXN-001 (Base de Conocimiento de Arquitectura, evolith-core). + // Al progresar a microservicios, separar vía saga de aprovisionamiento. await _tenantRepository.AddAsync(tenant, cancellationToken); await _userAccountRepository.AddAsync(adminUser, cancellationToken); await _requestRepository.UpdateAsync(signupRequest, cancellationToken); diff --git a/src/apps/ums.api/Ums.Application/Identity/UserAccount/Commands/AddPasswordCommandHandler.cs b/src/apps/ums.api/Ums.Application/Identity/UserAccount/Commands/AddPasswordCommandHandler.cs index 1eeea8a7..5120bf0b 100644 --- a/src/apps/ums.api/Ums.Application/Identity/UserAccount/Commands/AddPasswordCommandHandler.cs +++ b/src/apps/ums.api/Ums.Application/Identity/UserAccount/Commands/AddPasswordCommandHandler.cs @@ -40,6 +40,9 @@ public async Task> Handle(AddPasswordCommand request await _userAccountRepository.UpdateAsync(userAccount, cancellationToken); await _userAccountRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); - return Result.Success(new AddPasswordResponse(credential.Id.GetValue())); + // AT06/F1: devolver la identidad canónica (Props.Id, la que se persiste y con la que después + // se activa/remueve la credencial), NO el Id base de Entity<> —regenerado aleatorio en cada + // construcción— que jamás casaría en ActivatePassword/RemovePassword tras recargar. + return Result.Success(new AddPasswordResponse(credential.GetId().GetValue())); } } diff --git a/src/apps/ums.api/Ums.Application/Identity/UserAccount/Commands/RecordAuthenticationAttemptCommandHandler.cs b/src/apps/ums.api/Ums.Application/Identity/UserAccount/Commands/RecordAuthenticationAttemptCommandHandler.cs index a4bb1294..4027313d 100644 --- a/src/apps/ums.api/Ums.Application/Identity/UserAccount/Commands/RecordAuthenticationAttemptCommandHandler.cs +++ b/src/apps/ums.api/Ums.Application/Identity/UserAccount/Commands/RecordAuthenticationAttemptCommandHandler.cs @@ -1,5 +1,6 @@ namespace Ums.Application.Identity.UserAccount.Commands; +using Ums.Application.Configuration.Services; using Ums.Domain.Identity; public sealed class RecordAuthenticationAttemptCommandHandler @@ -7,13 +8,16 @@ public sealed class RecordAuthenticationAttemptCommandHandler { private readonly IUserAccountRepository _repository; private readonly IUserContext _userContext; + private readonly IConfigurationProvider _configurationProvider; public RecordAuthenticationAttemptCommandHandler( IUserAccountRepository repository, - IUserContext userContext) + IUserContext userContext, + IConfigurationProvider configurationProvider) { _repository = repository; _userContext = userContext; + _configurationProvider = configurationProvider; } [AuditTrail] @@ -29,8 +33,14 @@ public async Task Handle( if (entity is null) return Result.Failure("User account not found."); var actor = ActorId.Create(_userContext.UserId); + // ADR-UMS-095: el instante y los parámetros de política se resuelven en la aplicación y se + // inyectan en el dominio determinista (cascada de config Global>Suite>Tenant>Module). + var cfg = _configurationProvider.ForTenant(entity.Props.TenantId.GetValue()); var result = entity.RecordAuthenticationAttempt( request.Success, + DateTimeOffset.UtcNow, + cfg.MaxLoginAttempts, + cfg.AccountLockoutDurationMinutes, request.Reason, request.IpAddress, actor); diff --git a/src/apps/ums.api/Ums.Application/Identity/UserAccount/Queries/GetAllUserAccountsQueryHandler.cs b/src/apps/ums.api/Ums.Application/Identity/UserAccount/Queries/GetAllUserAccountsQueryHandler.cs index fca4cbf4..8e47476b 100644 --- a/src/apps/ums.api/Ums.Application/Identity/UserAccount/Queries/GetAllUserAccountsQueryHandler.cs +++ b/src/apps/ums.api/Ums.Application/Identity/UserAccount/Queries/GetAllUserAccountsQueryHandler.cs @@ -29,7 +29,6 @@ public async Task>> Handle( { var page = NormalizePage(request.Page); var pageSize = NormalizePageSize(request.PageSize); - var criteria = NormalizeText(request.Criteria, "email").ToLowerInvariant(); var status = NormalizeText(request.Status, "all"); var sortBy = NormalizeText(request.SortBy, "email").ToLowerInvariant(); var sortOrder = NormalizeText(request.SortOrder, "asc").ToLowerInvariant(); diff --git a/src/apps/ums.api/Ums.Application/Identity/UserAccount/Queries/GetUserAccountMfaEnrollmentsQueryHandler.cs b/src/apps/ums.api/Ums.Application/Identity/UserAccount/Queries/GetUserAccountMfaEnrollmentsQueryHandler.cs index 228258c5..2b767b7f 100644 --- a/src/apps/ums.api/Ums.Application/Identity/UserAccount/Queries/GetUserAccountMfaEnrollmentsQueryHandler.cs +++ b/src/apps/ums.api/Ums.Application/Identity/UserAccount/Queries/GetUserAccountMfaEnrollmentsQueryHandler.cs @@ -27,7 +27,7 @@ public async Task>> Handle( var dtos = userAccount.MfaEnrollments .Select(e => new MfaEnrollmentDto( - e.Id.GetValue(), + e.GetId().GetValue(), e.Method.Name, e.Status.Name, e.Props.Audit.GetValue().CreatedAt)) diff --git a/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/ApproveDelegationCommand.cs b/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/ApproveDelegationCommand.cs new file mode 100644 index 00000000..d53a5815 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/ApproveDelegationCommand.cs @@ -0,0 +1,8 @@ +namespace Ums.Application.Identity.UserManagementDelegation.Commands; + +/// +/// PendingApproval → Active (delegación, ADR-UMS-086, espejo de ADR-UMS-093 FR-060). +/// Resuelve la solicitud de aprobación aprobándola y activa la delegación. +/// El aprobador es el usuario autenticado. +/// +public sealed record ApproveDelegationCommand(Guid DelegationId) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/ApproveDelegationCommandHandler.cs b/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/ApproveDelegationCommandHandler.cs new file mode 100644 index 00000000..534b9192 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/ApproveDelegationCommandHandler.cs @@ -0,0 +1,58 @@ + +namespace Ums.Application.Identity.UserManagementDelegation.Commands; + +public sealed class ApproveDelegationCommandHandler : ICommandHandler +{ + private readonly IUserManagementDelegationRepository _repository; + private readonly IUserContext _userContext; + private readonly ITenantScopePolicy _tenantScopePolicy; + + public ApproveDelegationCommandHandler( + IUserManagementDelegationRepository repository, + IUserContext userContext, + ITenantScopePolicy tenantScopePolicy) + { + _repository = repository; + _userContext = userContext; + _tenantScopePolicy = tenantScopePolicy; + } + + [AuditTrail] + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task Handle(ApproveDelegationCommand request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(_userContext.UserId)) + { + return Result.Failure("Authenticated user is required to approve a delegation."); + } + + var delegation = await _repository.GetByIdAsync(request.DelegationId, cancellationToken); + if (delegation is null) + { + return Result.Failure("Delegation not found."); + } + + // Autorización + aislamiento por inquilino (G-148/G-149): aprobar una delegación es una + // operación administrativa (concede autoridad sobre cuentas del inquilino), así que se + // exige el mismo gate de management-owner que Block/Create/ForcePasswordReset de UserAccount + // (ADR-UMS-086 endurecido). Cierra: solo management-owner/internal-admin aprueba, y nunca + // de forma cruzada entre inquilinos. Sin fallback de acceso delegado a propósito: un + // administrador delegado no puede aprobar delegaciones (evita escalada). + var ownerScope = await _tenantScopePolicy.EnsureManagementOwnerScopeAsync(delegation.TenantId.GetValue(), cancellationToken); + if (ownerScope.IsFailure) + { + return ownerScope; + } + + var result = delegation.Approve(ActorId.Create(_userContext.UserId)); + if (result.IsFailure) + { + return result; + } + + await _repository.UpdateAsync(delegation, cancellationToken); + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/ApproveDelegationCommandValidator.cs b/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/ApproveDelegationCommandValidator.cs new file mode 100644 index 00000000..6c48b8bf --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/ApproveDelegationCommandValidator.cs @@ -0,0 +1,12 @@ +namespace Ums.Application.Identity.UserManagementDelegation.Commands; + +using FluentValidation; + +public sealed class ApproveDelegationCommandValidator : AbstractValidator +{ + public ApproveDelegationCommandValidator() + { + RuleFor(command => command.DelegationId) + .NotEmpty(); + } +} diff --git a/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/RejectDelegationCommand.cs b/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/RejectDelegationCommand.cs new file mode 100644 index 00000000..7be79e30 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/RejectDelegationCommand.cs @@ -0,0 +1,8 @@ +namespace Ums.Application.Identity.UserManagementDelegation.Commands; + +/// +/// PendingApproval → Rejected (delegación, ADR-UMS-086, espejo de ADR-UMS-093 FR-060). +/// Resuelve la solicitud de aprobación rechazándola con un motivo obligatorio. +/// El revisor es el usuario autenticado. +/// +public sealed record RejectDelegationCommand(Guid DelegationId, string Reason) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/RejectDelegationCommandHandler.cs b/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/RejectDelegationCommandHandler.cs new file mode 100644 index 00000000..bda991ea --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/RejectDelegationCommandHandler.cs @@ -0,0 +1,55 @@ + +namespace Ums.Application.Identity.UserManagementDelegation.Commands; + +public sealed class RejectDelegationCommandHandler : ICommandHandler +{ + private readonly IUserManagementDelegationRepository _repository; + private readonly IUserContext _userContext; + private readonly ITenantScopePolicy _tenantScopePolicy; + + public RejectDelegationCommandHandler( + IUserManagementDelegationRepository repository, + IUserContext userContext, + ITenantScopePolicy tenantScopePolicy) + { + _repository = repository; + _userContext = userContext; + _tenantScopePolicy = tenantScopePolicy; + } + + [AuditTrail] + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task Handle(RejectDelegationCommand request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(_userContext.UserId)) + { + return Result.Failure("Authenticated user is required to reject a delegation."); + } + + var delegation = await _repository.GetByIdAsync(request.DelegationId, cancellationToken); + if (delegation is null) + { + return Result.Failure("Delegation not found."); + } + + // Autorización + aislamiento por inquilino (G-148/G-149): mismo gate de management-owner + // que Approve. Rechazar una solicitud pendiente es una decisión de gobernanza reservada al + // management-owner/internal-admin; no se admite de forma cruzada entre inquilinos. + var ownerScope = await _tenantScopePolicy.EnsureManagementOwnerScopeAsync(delegation.TenantId.GetValue(), cancellationToken); + if (ownerScope.IsFailure) + { + return ownerScope; + } + + var result = delegation.Reject(request.Reason, ActorId.Create(_userContext.UserId)); + if (result.IsFailure) + { + return result; + } + + await _repository.UpdateAsync(delegation, cancellationToken); + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/RejectDelegationCommandValidator.cs b/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/RejectDelegationCommandValidator.cs new file mode 100644 index 00000000..740c4035 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/RejectDelegationCommandValidator.cs @@ -0,0 +1,16 @@ +namespace Ums.Application.Identity.UserManagementDelegation.Commands; + +using FluentValidation; + +public sealed class RejectDelegationCommandValidator : AbstractValidator +{ + public RejectDelegationCommandValidator() + { + RuleFor(command => command.DelegationId) + .NotEmpty(); + + RuleFor(command => command.Reason) + .NotEmpty() + .MaximumLength(500); + } +} diff --git a/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/SubmitDelegationForApprovalCommand.cs b/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/SubmitDelegationForApprovalCommand.cs new file mode 100644 index 00000000..2a24be57 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/SubmitDelegationForApprovalCommand.cs @@ -0,0 +1,9 @@ +namespace Ums.Application.Identity.UserManagementDelegation.Commands; + +/// +/// Draft → PendingApproval (delegación, ADR-UMS-086, espejo de ADR-UMS-093 FR-060). +/// Genera y congela el identificador de la solicitud de aprobación en el agregado +/// (SubmitForApproval). La delegación orquesta su propia máquina de aprobación +/// dentro de su agregado — un-agregado-por-transacción, sin ApprovalRequest genérico. +/// +public sealed record SubmitDelegationForApprovalCommand(Guid DelegationId) : ICommand; diff --git a/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/SubmitDelegationForApprovalCommandHandler.cs b/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/SubmitDelegationForApprovalCommandHandler.cs new file mode 100644 index 00000000..610cc239 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/SubmitDelegationForApprovalCommandHandler.cs @@ -0,0 +1,62 @@ + +namespace Ums.Application.Identity.UserManagementDelegation.Commands; + +public sealed class SubmitDelegationForApprovalCommandHandler : ICommandHandler +{ + private readonly IUserManagementDelegationRepository _repository; + private readonly IUserContext _userContext; + private readonly ITenantScopePolicy _tenantScopePolicy; + + public SubmitDelegationForApprovalCommandHandler( + IUserManagementDelegationRepository repository, + IUserContext userContext, + ITenantScopePolicy tenantScopePolicy) + { + _repository = repository; + _userContext = userContext; + _tenantScopePolicy = tenantScopePolicy; + } + + [AuditTrail] + [LoggerAspect(Type = typeof(IUmsLogger), LogDuration = true, LogException = true, LogArguments = [])] + public async Task Handle(SubmitDelegationForApprovalCommand request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(_userContext.UserId)) + { + return Result.Failure("Authenticated user is required to submit a delegation for approval."); + } + + var delegation = await _repository.GetByIdAsync(request.DelegationId, cancellationToken); + if (delegation is null) + { + return Result.Failure("Delegation not found."); + } + + // Autorización + aislamiento por inquilino (G-148/G-149): enviar a aprobación es una + // operación administrativa sobre la delegación; se exige el mismo gate de management-owner + // que el resto de comandos de gestión y se veda la operación cruzada entre inquilinos. + var ownerScope = await _tenantScopePolicy.EnsureManagementOwnerScopeAsync(delegation.TenantId.GetValue(), cancellationToken); + if (ownerScope.IsFailure) + { + return ownerScope; + } + + // Espejo de IGA RolePromotion (ADR-UMS-093): la delegación orquesta su propia máquina de + // aprobación dentro de su agregado, sin materializar un ApprovalRequest genérico (cuyo + // contrato Create exige workflow/sistema/rol ajenos a una delegación). El identificador de + // la solicitud es una correlación generada aquí y congelada en el agregado (INV: inmutable + // tras Submit). Esto preserva un-agregado-por-transacción (ADR-UMS-086). + var approvalRequestId = Guid.NewGuid(); + + var result = delegation.SubmitForApproval(approvalRequestId, ActorId.Create(_userContext.UserId)); + if (result.IsFailure) + { + return result; + } + + await _repository.UpdateAsync(delegation, cancellationToken); + await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/SubmitDelegationForApprovalCommandValidator.cs b/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/SubmitDelegationForApprovalCommandValidator.cs new file mode 100644 index 00000000..547c44f3 --- /dev/null +++ b/src/apps/ums.api/Ums.Application/Identity/UserManagementDelegation/Commands/SubmitDelegationForApprovalCommandValidator.cs @@ -0,0 +1,12 @@ +namespace Ums.Application.Identity.UserManagementDelegation.Commands; + +using FluentValidation; + +public sealed class SubmitDelegationForApprovalCommandValidator : AbstractValidator +{ + public SubmitDelegationForApprovalCommandValidator() + { + RuleFor(command => command.DelegationId) + .NotEmpty(); + } +} diff --git a/src/apps/ums.api/Ums.Application/Ums.Application.csproj b/src/apps/ums.api/Ums.Application/Ums.Application.csproj index 6a1730e3..cefb6482 100644 --- a/src/apps/ums.api/Ums.Application/Ums.Application.csproj +++ b/src/apps/ums.api/Ums.Application/Ums.Application.csproj @@ -18,6 +18,15 @@ + + diff --git a/src/apps/ums.api/Ums.ContractTest/Consumers/ApprovalsConsumerTests.cs b/src/apps/ums.api/Ums.ContractTest/Consumers/ApprovalsConsumerTests.cs new file mode 100644 index 00000000..c26f91b7 --- /dev/null +++ b/src/apps/ums.api/Ums.ContractTest/Consumers/ApprovalsConsumerTests.cs @@ -0,0 +1,207 @@ +using PactNet.Matchers; +using System.Net.Http.Json; + +namespace Ums.ContractTest.Consumers; + +/// +/// G-082: Consumer contract tests for the Approvals API (ApprovalRequest). +/// +/// Perspectiva de ums-web-app (consumer) → ums-api (provider). Sólo importa la FORMA +/// HTTP: lista paginada, obtención por id, no-encontrado (404) y el comando de creación con datos +/// inválidos (400 de validación). Rutas reales: /api/v1/approval-requests. +/// +public sealed class ApprovalsConsumerTests : IDisposable +{ + private readonly IPactBuilderV4 _pactBuilder; + + private static readonly string PactsDir = + Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "pacts"); + + private const string SampleGuid = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; + + public ApprovalsConsumerTests(ITestOutputHelper output) + { + var config = new PactConfig + { + PactDir = PactsDir, + Outputters = [new XunitOutput(output)], + LogLevel = PactLogLevel.Warn, + }; + + _pactBuilder = Pact.V4("ums-web-app", "ums-api", config).WithHttpInteractions(); + } + + // ───────────────────────────────────────────────────────────── + // GET /api/v1/approval-requests + // ───────────────────────────────────────────────────────────── + + [Fact] + [Trait("pact", "consumer")] + public async Task GetApprovalRequests_ReturnsPagedList() + { + _pactBuilder + .UponReceiving("a paginated list request for approval requests") + .Given("at least one approval request exists") + .WithRequest(HttpMethod.Get, "/api/v1/approval-requests") + .WithQuery("page", Match.Equality("1")) + .WithQuery("pageSize", Match.Equality("10")) + .WithHeader("X-User-Id", Match.Type("dev-user")) + .WillRespond() + .WithStatus(HttpStatusCode.OK) + .WithHeader("Content-Type", Match.Type("application/json; charset=utf-8")) + .WithJsonBody(new + { + items = Match.MinType(new + { + approvalRequestId = Match.Type(SampleGuid), + workflowId = Match.Type(SampleGuid), + targetUserId = Match.Type(SampleGuid), + status = Match.Type("Pending"), + requestedSystemId = Match.Type(SampleGuid), + requestedRoleId = Match.Type(SampleGuid), + }, 1), + totalItems = Match.Type(1), + page = Match.Type(1), + pageSize = Match.Type(10), + }); + + await _pactBuilder.VerifyAsync(async ctx => + { + using var client = new HttpClient { BaseAddress = ctx.MockServerUri }; + client.DefaultRequestHeaders.Add("X-User-Id", "dev-user"); + + var response = await client.GetAsync("/api/v1/approval-requests?page=1&pageSize=10"); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadFromJsonAsync(); + Assert.True(json.TryGetProperty("items", out _), "response should have 'items'"); + Assert.True(json.TryGetProperty("totalItems", out _), "response should have 'totalItems'"); + }); + } + + // ───────────────────────────────────────────────────────────── + // GET /api/v1/approval-requests/{id} — found + // ───────────────────────────────────────────────────────────── + + [Fact] + [Trait("pact", "consumer")] + public async Task GetApprovalRequestById_WhenFound_Returns200() + { + const string id = "a1a1a1a1-0000-0000-0000-000000000001"; + + _pactBuilder + .UponReceiving("a request for a specific approval request that exists") + .Given($"an approval request with id {id} exists") + .WithRequest(HttpMethod.Get, $"/api/v1/approval-requests/{id}") + .WithHeader("X-User-Id", Match.Type("dev-user")) + .WillRespond() + .WithStatus(HttpStatusCode.OK) + .WithHeader("Content-Type", Match.Type("application/json; charset=utf-8")) + .WithJsonBody(new + { + approvalRequestId = Match.Type(id), + workflowId = Match.Type(SampleGuid), + targetUserId = Match.Type(SampleGuid), + status = Match.Type("Pending"), + requestedSystemId = Match.Type(SampleGuid), + requestedRoleId = Match.Type(SampleGuid), + }); + + await _pactBuilder.VerifyAsync(async ctx => + { + using var client = new HttpClient { BaseAddress = ctx.MockServerUri }; + client.DefaultRequestHeaders.Add("X-User-Id", "dev-user"); + + var response = await client.GetAsync($"/api/v1/approval-requests/{id}"); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadFromJsonAsync(); + Assert.True(json.TryGetProperty("approvalRequestId", out _), "response should have 'approvalRequestId'"); + }); + } + + // ───────────────────────────────────────────────────────────── + // GET /api/v1/approval-requests/{id} — not found + // ───────────────────────────────────────────────────────────── + + [Fact] + [Trait("pact", "consumer")] + public async Task GetApprovalRequestById_WhenNotFound_Returns404() + { + const string missingId = "00000000-0000-0000-0000-0000000000a1"; + + _pactBuilder + .UponReceiving("a request for an approval request that does not exist") + .Given($"no approval request with id {missingId} exists") + .WithRequest(HttpMethod.Get, $"/api/v1/approval-requests/{missingId}") + .WithHeader("X-User-Id", Match.Type("dev-user")) + .WillRespond() + .WithStatus(HttpStatusCode.NotFound) + .WithHeader("Content-Type", Match.Type("application/problem+json")) + .WithJsonBody(new + { + status = Match.Type(404), + title = Match.Type("Not Found"), + }); + + await _pactBuilder.VerifyAsync(async ctx => + { + using var client = new HttpClient { BaseAddress = ctx.MockServerUri }; + client.DefaultRequestHeaders.Add("X-User-Id", "dev-user"); + + var response = await client.GetAsync($"/api/v1/approval-requests/{missingId}"); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + }); + } + + // ───────────────────────────────────────────────────────────── + // POST /api/v1/approval-requests — invalid body → 400 + // ───────────────────────────────────────────────────────────── + + [Fact] + [Trait("pact", "consumer")] + public async Task CreateApprovalRequest_WithInvalidData_Returns400() + { + const string emptyGuid = "00000000-0000-0000-0000-000000000000"; + + _pactBuilder + .UponReceiving("a create approval request with invalid data") + .WithRequest(HttpMethod.Post, "/api/v1/approval-requests") + .WithHeader("X-User-Id", Match.Type("dev-user")) + .WithHeader("Content-Type", Match.Regex("application/json; charset=utf-8", "application/json.*")) + .WithJsonBody(new + { + workflowId = emptyGuid, + targetUserId = emptyGuid, + requestedSystemId = emptyGuid, + requestedRoleId = emptyGuid, + }) + .WillRespond() + .WithStatus(HttpStatusCode.BadRequest) + .WithHeader("Content-Type", Match.Type("application/problem+json")) + .WithJsonBody(new + { + status = Match.Type(400), + title = Match.Type("Validation Error"), + }); + + await _pactBuilder.VerifyAsync(async ctx => + { + using var client = new HttpClient { BaseAddress = ctx.MockServerUri }; + client.DefaultRequestHeaders.Add("X-User-Id", "dev-user"); + + using var content = JsonContent.Create(new + { + workflowId = emptyGuid, + targetUserId = emptyGuid, + requestedSystemId = emptyGuid, + requestedRoleId = emptyGuid, + }); + + var response = await client.PostAsync("/api/v1/approval-requests", content); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + }); + } + + public void Dispose() { } +} diff --git a/src/apps/ums.api/Ums.ContractTest/Consumers/AuditConsumerTests.cs b/src/apps/ums.api/Ums.ContractTest/Consumers/AuditConsumerTests.cs new file mode 100644 index 00000000..2dac7b5b --- /dev/null +++ b/src/apps/ums.api/Ums.ContractTest/Consumers/AuditConsumerTests.cs @@ -0,0 +1,160 @@ +using PactNet.Matchers; +using System.Net.Http.Json; + +namespace Ums.ContractTest.Consumers; + +/// +/// G-082: Consumer contract tests for the Audit API (AuditRecord). +/// +/// La auditoría es sobre todo lectura y exige autenticación (cabecera X-User-Id) y +/// acotación por inquilino (parámetro tenantId). Rutas reales: /api/v1/audit-records. +/// +public sealed class AuditConsumerTests : IDisposable +{ + private readonly IPactBuilderV4 _pactBuilder; + + private static readonly string PactsDir = + Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "pacts"); + + private const string SampleGuid = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; + // Inquilino por defecto sembrado por el manejador de provider-states (RootTenantId del registro). + private const string TenantId = "11111111-1111-1111-1111-111111111111"; + + public AuditConsumerTests(ITestOutputHelper output) + { + var config = new PactConfig + { + PactDir = PactsDir, + Outputters = [new XunitOutput(output)], + LogLevel = PactLogLevel.Warn, + }; + + _pactBuilder = Pact.V4("ums-web-app", "ums-api", config).WithHttpInteractions(); + } + + // ───────────────────────────────────────────────────────────── + // GET /api/v1/audit-records?tenantId=… + // ───────────────────────────────────────────────────────────── + + [Fact] + [Trait("pact", "consumer")] + public async Task GetAuditRecords_ReturnsPagedList() + { + _pactBuilder + .UponReceiving("a paginated list request for audit records scoped by tenant") + .Given("at least one audit record exists") + .WithRequest(HttpMethod.Get, "/api/v1/audit-records") + .WithQuery("tenantId", Match.Equality(TenantId)) + .WithQuery("page", Match.Equality("1")) + .WithQuery("pageSize", Match.Equality("10")) + .WithHeader("X-User-Id", Match.Type("dev-user")) + .WillRespond() + .WithStatus(HttpStatusCode.OK) + .WithHeader("Content-Type", Match.Type("application/json; charset=utf-8")) + .WithJsonBody(new + { + items = Match.MinType(new + { + auditRecordId = Match.Type(SampleGuid), + whoActed = Match.Type(SampleGuid), + eventType = Match.Type("ContractTestEvent"), + affectedEntityId = Match.Type(SampleGuid), + affectedEntityType = Match.Type("ContractTest"), + rootTenantId = Match.Type(SampleGuid), + }, 1), + totalItems = Match.Type(1), + page = Match.Type(1), + pageSize = Match.Type(10), + }); + + await _pactBuilder.VerifyAsync(async ctx => + { + using var client = new HttpClient { BaseAddress = ctx.MockServerUri }; + client.DefaultRequestHeaders.Add("X-User-Id", "dev-user"); + + var response = await client.GetAsync($"/api/v1/audit-records?tenantId={TenantId}&page=1&pageSize=10"); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadFromJsonAsync(); + Assert.True(json.TryGetProperty("items", out _), "response should have 'items'"); + Assert.True(json.TryGetProperty("totalItems", out _), "response should have 'totalItems'"); + }); + } + + // ───────────────────────────────────────────────────────────── + // GET /api/v1/audit-records/{id} — found + // ───────────────────────────────────────────────────────────── + + [Fact] + [Trait("pact", "consumer")] + public async Task GetAuditRecordById_WhenFound_Returns200() + { + const string id = "a2a2a2a2-0000-0000-0000-000000000001"; + + _pactBuilder + .UponReceiving("a request for a specific audit record that exists") + .Given($"an audit record with id {id} exists") + .WithRequest(HttpMethod.Get, $"/api/v1/audit-records/{id}") + .WithHeader("X-User-Id", Match.Type("dev-user")) + .WillRespond() + .WithStatus(HttpStatusCode.OK) + .WithHeader("Content-Type", Match.Type("application/json; charset=utf-8")) + .WithJsonBody(new + { + auditRecordId = Match.Type(id), + whoActed = Match.Type(SampleGuid), + eventType = Match.Type("ContractTestEvent"), + affectedEntityId = Match.Type(SampleGuid), + affectedEntityType = Match.Type("ContractTest"), + rootTenantId = Match.Type(SampleGuid), + }); + + await _pactBuilder.VerifyAsync(async ctx => + { + using var client = new HttpClient { BaseAddress = ctx.MockServerUri }; + client.DefaultRequestHeaders.Add("X-User-Id", "dev-user"); + + var response = await client.GetAsync($"/api/v1/audit-records/{id}"); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadFromJsonAsync(); + Assert.True(json.TryGetProperty("auditRecordId", out _), "response should have 'auditRecordId'"); + }); + } + + // ───────────────────────────────────────────────────────────── + // GET /api/v1/audit-records/{id} — not found + // ───────────────────────────────────────────────────────────── + + [Fact] + [Trait("pact", "consumer")] + public async Task GetAuditRecordById_WhenNotFound_Returns404() + { + const string missingId = "00000000-0000-0000-0000-0000000000a2"; + + _pactBuilder + .UponReceiving("a request for an audit record that does not exist") + .Given($"no audit record with id {missingId} exists") + .WithRequest(HttpMethod.Get, $"/api/v1/audit-records/{missingId}") + .WithHeader("X-User-Id", Match.Type("dev-user")) + .WillRespond() + .WithStatus(HttpStatusCode.NotFound) + .WithHeader("Content-Type", Match.Type("application/problem+json")) + .WithJsonBody(new + { + status = Match.Type(404), + title = Match.Type("Not Found"), + }); + + await _pactBuilder.VerifyAsync(async ctx => + { + using var client = new HttpClient { BaseAddress = ctx.MockServerUri }; + client.DefaultRequestHeaders.Add("X-User-Id", "dev-user"); + + var response = await client.GetAsync($"/api/v1/audit-records/{missingId}"); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + }); + } + + public void Dispose() { } +} diff --git a/src/apps/ums.api/Ums.ContractTest/Consumers/AuthConsumerTests.cs b/src/apps/ums.api/Ums.ContractTest/Consumers/AuthConsumerTests.cs index f1927652..cd0faa8e 100644 --- a/src/apps/ums.api/Ums.ContractTest/Consumers/AuthConsumerTests.cs +++ b/src/apps/ums.api/Ums.ContractTest/Consumers/AuthConsumerTests.cs @@ -9,6 +9,13 @@ namespace Ums.ContractTest.Consumers; /// /// OPS-02: Consumer contract tests for the Auth API. +/// +/// El endpoint real de inicio de sesión es POST /api/v1/auth/login y recibe +/// { tenantCode, username, password } (ADR-0096 / AuthenticateUserCommand). Aquí sólo se +/// contrasta la FORMA HTTP de las rutas de rechazo, que no requieren sembrar un grafo de +/// autorización completo: la API responde 400 application/problem+json ante credenciales +/// inválidas o campos ausentes. El camino feliz (200 con el grafo + cookie de sesión) depende de +/// un grafo sembrado que excede el alcance de un contrato de forma y no se modela aquí. /// public sealed class AuthConsumerTests : IDisposable { @@ -29,31 +36,31 @@ public AuthConsumerTests(ITestOutputHelper output) _pactBuilder = Pact.V4("ums-web-app", "ums-api", config).WithHttpInteractions(); } + // ───────────────────────────────────────────────────────────── + // POST /api/v1/auth/login — campos obligatorios ausentes → 400 + // ───────────────────────────────────────────────────────────── + [Fact] [Trait("pact", "consumer")] - public async Task PostToken_WithValidCredentials_Returns200() + public async Task PostLogin_WithMissingFields_Returns400() { _pactBuilder - .UponReceiving("a valid token request") - .Given("a user account exists with valid credentials") - .WithRequest(HttpMethod.Post, "/api/v1/auth/token") - .WithHeader("Content-Type", Match.Regex("application/json.*", "application/json; charset=utf-8")) + .UponReceiving("a login request that is missing required fields") + .WithRequest(HttpMethod.Post, "/api/v1/auth/login") + .WithHeader("Content-Type", Match.Regex("application/json; charset=utf-8", "application/json.*")) .WithJsonBody(new { - email = Match.Type("user@example.com"), - password = Match.Type("ValidPassword123!"), + tenantCode = "", + username = "", + password = "", }) .WillRespond() - .WithStatus(HttpStatusCode.OK) - .WithHeader("Content-Type", Match.Regex("application/json.*", "application/json; charset=utf-8")) + .WithStatus(HttpStatusCode.BadRequest) + .WithHeader("Content-Type", Match.Type("application/problem+json")) .WithJsonBody(new { - token = Match.Type("eyJhbGciOiJIUzI1NiIsInR5cCI..."), - user = new - { - userId = Match.Type("3fa85f64-5717-4562-b3fc-2c963f66afa6"), - email = Match.Type("user@example.com"), - } + status = Match.Type(400), + title = Match.Type("Bad Request"), }); await _pactBuilder.VerifyAsync(async ctx => @@ -62,39 +69,42 @@ await _pactBuilder.VerifyAsync(async ctx => using var content = JsonContent.Create(new { - email = "user@example.com", - password = "ValidPassword123!", + tenantCode = "", + username = "", + password = "", }); - var response = await client.PostAsync("/api/v1/auth/token", content); - response.EnsureSuccessStatusCode(); - - var json = await response.Content.ReadFromJsonAsync(); - Assert.True(json.TryGetProperty("token", out _), "response should have 'token'"); + var response = await client.PostAsync("/api/v1/auth/login", content); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); }); } + // ───────────────────────────────────────────────────────────── + // POST /api/v1/auth/login — credenciales no autenticables → 400 + // ───────────────────────────────────────────────────────────── + [Fact] [Trait("pact", "consumer")] - public async Task PostToken_WithInvalidCredentials_Returns401() + public async Task PostLogin_WithUnauthenticableCredentials_Returns400() { _pactBuilder - .UponReceiving("an invalid token request") - .Given("a user account does not exist or credentials do not match") - .WithRequest(HttpMethod.Post, "/api/v1/auth/token") - .WithHeader("Content-Type", Match.Regex("application/json.*", "application/json; charset=utf-8")) + .UponReceiving("a login request with credentials that cannot be authenticated") + .Given("no tenant with code UNKNOWN_TENANT exists") + .WithRequest(HttpMethod.Post, "/api/v1/auth/login") + .WithHeader("Content-Type", Match.Regex("application/json; charset=utf-8", "application/json.*")) .WithJsonBody(new { - email = Match.Type("wrong@example.com"), - password = Match.Type("WrongPassword123!"), + tenantCode = "UNKNOWN_TENANT", + username = Match.Type("user@example.com"), + password = Match.Type("ValidPassword123!"), }) .WillRespond() - .WithStatus(HttpStatusCode.Unauthorized) + .WithStatus(HttpStatusCode.BadRequest) .WithHeader("Content-Type", Match.Type("application/problem+json")) .WithJsonBody(new { - status = Match.Type(401), - title = Match.Type("Unauthorized"), + status = Match.Type(400), + title = Match.Type("Bad Request"), }); await _pactBuilder.VerifyAsync(async ctx => @@ -103,12 +113,13 @@ await _pactBuilder.VerifyAsync(async ctx => using var content = JsonContent.Create(new { - email = "wrong@example.com", - password = "WrongPassword123!", + tenantCode = "UNKNOWN_TENANT", + username = "user@example.com", + password = "ValidPassword123!", }); - var response = await client.PostAsync("/api/v1/auth/token", content); - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + var response = await client.PostAsync("/api/v1/auth/login", content); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); }); } diff --git a/src/apps/ums.api/Ums.ContractTest/Consumers/ConfigurationConsumerTests.cs b/src/apps/ums.api/Ums.ContractTest/Consumers/ConfigurationConsumerTests.cs new file mode 100644 index 00000000..dd1499a4 --- /dev/null +++ b/src/apps/ums.api/Ums.ContractTest/Consumers/ConfigurationConsumerTests.cs @@ -0,0 +1,349 @@ +using PactNet.Matchers; +using System.Net.Http.Json; + +namespace Ums.ContractTest.Consumers; + +/// +/// G-082: Consumer contract tests for the Configuration API (FeatureFlag + AppConfiguration). +/// +/// Cubre consulta (lista + por id), un error clave (404 / 400) y comandos de creación. +/// Rutas reales: /api/v1/feature-flags y /api/v1/app-configurations. +/// +public sealed class ConfigurationConsumerTests : IDisposable +{ + private readonly IPactBuilderV4 _pactBuilder; + + private static readonly string PactsDir = + Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "pacts"); + + private const string SampleGuid = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; + + public ConfigurationConsumerTests(ITestOutputHelper output) + { + var config = new PactConfig + { + PactDir = PactsDir, + Outputters = [new XunitOutput(output)], + LogLevel = PactLogLevel.Warn, + }; + + _pactBuilder = Pact.V4("ums-web-app", "ums-api", config).WithHttpInteractions(); + } + + // ───────────────────────────────────────────────────────────── + // GET /api/v1/feature-flags + // ───────────────────────────────────────────────────────────── + + [Fact] + [Trait("pact", "consumer")] + public async Task GetFeatureFlags_ReturnsPagedList() + { + _pactBuilder + .UponReceiving("a paginated list request for feature flags") + .Given("at least one feature flag exists") + .WithRequest(HttpMethod.Get, "/api/v1/feature-flags") + .WithQuery("page", Match.Equality("1")) + .WithQuery("pageSize", Match.Equality("10")) + .WithHeader("X-User-Id", Match.Type("dev-user")) + .WillRespond() + .WithStatus(HttpStatusCode.OK) + .WithHeader("Content-Type", Match.Type("application/json; charset=utf-8")) + .WithJsonBody(new + { + items = Match.MinType(new + { + featureFlagId = Match.Type(SampleGuid), + systemSuiteId = Match.Type(SampleGuid), + flagCode = Match.Type("CONTRACT_TEST_FLAG"), + flagType = Match.Type("Boolean"), + flagTargets = Match.Type("all"), + status = Match.Type("Inactive"), + }, 1), + totalItems = Match.Type(1), + page = Match.Type(1), + pageSize = Match.Type(10), + }); + + await _pactBuilder.VerifyAsync(async ctx => + { + using var client = new HttpClient { BaseAddress = ctx.MockServerUri }; + client.DefaultRequestHeaders.Add("X-User-Id", "dev-user"); + + var response = await client.GetAsync("/api/v1/feature-flags?page=1&pageSize=10"); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadFromJsonAsync(); + Assert.True(json.TryGetProperty("items", out _), "response should have 'items'"); + }); + } + + // ───────────────────────────────────────────────────────────── + // GET /api/v1/feature-flags/{id} — found + // ───────────────────────────────────────────────────────────── + + [Fact] + [Trait("pact", "consumer")] + public async Task GetFeatureFlagById_WhenFound_Returns200() + { + const string id = "a3a3a3a3-0000-0000-0000-000000000001"; + + _pactBuilder + .UponReceiving("a request for a specific feature flag that exists") + .Given($"a feature flag with id {id} exists") + .WithRequest(HttpMethod.Get, $"/api/v1/feature-flags/{id}") + .WithHeader("X-User-Id", Match.Type("dev-user")) + .WillRespond() + .WithStatus(HttpStatusCode.OK) + .WithHeader("Content-Type", Match.Type("application/json; charset=utf-8")) + .WithJsonBody(new + { + featureFlagId = Match.Type(id), + systemSuiteId = Match.Type(SampleGuid), + flagCode = Match.Type("CONTRACT_TEST_FLAG"), + flagType = Match.Type("Boolean"), + flagTargets = Match.Type("all"), + status = Match.Type("Inactive"), + }); + + await _pactBuilder.VerifyAsync(async ctx => + { + using var client = new HttpClient { BaseAddress = ctx.MockServerUri }; + client.DefaultRequestHeaders.Add("X-User-Id", "dev-user"); + + var response = await client.GetAsync($"/api/v1/feature-flags/{id}"); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadFromJsonAsync(); + Assert.True(json.TryGetProperty("featureFlagId", out _), "response should have 'featureFlagId'"); + }); + } + + // ───────────────────────────────────────────────────────────── + // GET /api/v1/feature-flags/{id} — not found + // ───────────────────────────────────────────────────────────── + + [Fact] + [Trait("pact", "consumer")] + public async Task GetFeatureFlagById_WhenNotFound_Returns404() + { + const string missingId = "00000000-0000-0000-0000-0000000000a3"; + + _pactBuilder + .UponReceiving("a request for a feature flag that does not exist") + .Given($"no feature flag with id {missingId} exists") + .WithRequest(HttpMethod.Get, $"/api/v1/feature-flags/{missingId}") + .WithHeader("X-User-Id", Match.Type("dev-user")) + .WillRespond() + .WithStatus(HttpStatusCode.NotFound) + .WithHeader("Content-Type", Match.Type("application/problem+json")) + .WithJsonBody(new + { + status = Match.Type(404), + title = Match.Type("Not Found"), + }); + + await _pactBuilder.VerifyAsync(async ctx => + { + using var client = new HttpClient { BaseAddress = ctx.MockServerUri }; + client.DefaultRequestHeaders.Add("X-User-Id", "dev-user"); + + var response = await client.GetAsync($"/api/v1/feature-flags/{missingId}"); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + }); + } + + // ───────────────────────────────────────────────────────────── + // POST /api/v1/feature-flags — valid → 201 + // ───────────────────────────────────────────────────────────── + + [Fact] + [Trait("pact", "consumer")] + public async Task CreateFeatureFlag_WithValidData_Returns201() + { + const string suiteId = "cccc3333-0000-0000-0000-000000000003"; + + _pactBuilder + .UponReceiving("a create feature flag request with valid data") + .WithRequest(HttpMethod.Post, "/api/v1/feature-flags") + .WithHeader("X-User-Id", Match.Type("dev-user")) + .WithHeader("Content-Type", Match.Regex("application/json; charset=utf-8", "application/json.*")) + .WithJsonBody(new + { + systemSuiteId = Match.Type(suiteId), + flagCode = Match.Type("PACT_CREATE_FLAG"), + flagType = Match.Type("Boolean"), + flagTargets = Match.Type("all"), + }) + .WillRespond() + .WithStatus(HttpStatusCode.Created) + .WithHeader("Content-Type", Match.Type("application/json; charset=utf-8")) + .WithJsonBody(new + { + featureFlagId = Match.Regex(SampleGuid, "[0-9a-fA-F-]{36}"), + }); + + await _pactBuilder.VerifyAsync(async ctx => + { + using var client = new HttpClient { BaseAddress = ctx.MockServerUri }; + client.DefaultRequestHeaders.Add("X-User-Id", "dev-user"); + + using var content = JsonContent.Create(new + { + systemSuiteId = suiteId, + flagCode = "PACT_CREATE_FLAG", + flagType = "Boolean", + flagTargets = "all", + }); + + var response = await client.PostAsync("/api/v1/feature-flags", content); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + }); + } + + // ───────────────────────────────────────────────────────────── + // POST /api/v1/feature-flags — unsupported flag type → 400 + // ───────────────────────────────────────────────────────────── + + [Fact] + [Trait("pact", "consumer")] + public async Task CreateFeatureFlag_WithUnsupportedType_Returns400() + { + const string suiteId = "cccc3333-0000-0000-0000-000000000003"; + + _pactBuilder + .UponReceiving("a create feature flag request with an unsupported flag type") + .WithRequest(HttpMethod.Post, "/api/v1/feature-flags") + .WithHeader("X-User-Id", Match.Type("dev-user")) + .WithHeader("Content-Type", Match.Regex("application/json; charset=utf-8", "application/json.*")) + .WithJsonBody(new + { + systemSuiteId = Match.Type(suiteId), + flagCode = Match.Type("PACT_BAD_FLAG"), + flagType = "NotAFlagType", + flagTargets = Match.Type("all"), + }) + .WillRespond() + .WithStatus(HttpStatusCode.BadRequest) + .WithHeader("Content-Type", Match.Type("application/problem+json")) + .WithJsonBody(new + { + status = Match.Type(400), + title = Match.Type("Validation Error"), + }); + + await _pactBuilder.VerifyAsync(async ctx => + { + using var client = new HttpClient { BaseAddress = ctx.MockServerUri }; + client.DefaultRequestHeaders.Add("X-User-Id", "dev-user"); + + using var content = JsonContent.Create(new + { + systemSuiteId = suiteId, + flagCode = "PACT_BAD_FLAG", + flagType = "NotAFlagType", + flagTargets = "all", + }); + + var response = await client.PostAsync("/api/v1/feature-flags", content); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + }); + } + + // ───────────────────────────────────────────────────────────── + // GET /api/v1/app-configurations + // ───────────────────────────────────────────────────────────── + + [Fact] + [Trait("pact", "consumer")] + public async Task GetAppConfigurations_ReturnsPagedList() + { + _pactBuilder + .UponReceiving("a paginated list request for app configurations") + .Given("at least one app configuration exists") + .WithRequest(HttpMethod.Get, "/api/v1/app-configurations") + .WithQuery("page", Match.Equality("1")) + .WithQuery("pageSize", Match.Equality("10")) + .WithHeader("X-User-Id", Match.Type("dev-user")) + .WillRespond() + .WithStatus(HttpStatusCode.OK) + .WithHeader("Content-Type", Match.Type("application/json; charset=utf-8")) + .WithJsonBody(new + { + items = Match.MinType(new + { + appConfigurationId = Match.Type(SampleGuid), + code = Match.Type("CONTRACT_TEST_CFG"), + value = Match.Type("contract-test-value"), + description = Match.Type("Contract test configuration."), + scope = Match.Type("Global"), + version = Match.Type("1.0.0"), + status = Match.Type("Draft"), + }, 1), + totalItems = Match.Type(1), + page = Match.Type(1), + pageSize = Match.Type(10), + }); + + await _pactBuilder.VerifyAsync(async ctx => + { + using var client = new HttpClient { BaseAddress = ctx.MockServerUri }; + client.DefaultRequestHeaders.Add("X-User-Id", "dev-user"); + + var response = await client.GetAsync("/api/v1/app-configurations?page=1&pageSize=10"); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadFromJsonAsync(); + Assert.True(json.TryGetProperty("items", out _), "response should have 'items'"); + }); + } + + // ───────────────────────────────────────────────────────────── + // POST /api/v1/app-configurations — valid global config → 201 + // ───────────────────────────────────────────────────────────── + + [Fact] + [Trait("pact", "consumer")] + public async Task CreateAppConfiguration_WithValidData_Returns201() + { + _pactBuilder + .UponReceiving("a create app configuration request with valid global data") + .WithRequest(HttpMethod.Post, "/api/v1/app-configurations") + .WithHeader("X-User-Id", Match.Type("dev-user")) + .WithHeader("Content-Type", Match.Regex("application/json; charset=utf-8", "application/json.*")) + .WithJsonBody(new + { + code = Match.Type("PACT_CREATE_CFG"), + value = Match.Type("pact-value"), + description = Match.Type("Pact created configuration."), + isInheritable = Match.Type(true), + isEncrypted = Match.Type(false), + }) + .WillRespond() + .WithStatus(HttpStatusCode.Created) + .WithHeader("Content-Type", Match.Type("application/json; charset=utf-8")) + .WithJsonBody(new + { + appConfigurationId = Match.Regex(SampleGuid, "[0-9a-fA-F-]{36}"), + }); + + await _pactBuilder.VerifyAsync(async ctx => + { + using var client = new HttpClient { BaseAddress = ctx.MockServerUri }; + client.DefaultRequestHeaders.Add("X-User-Id", "dev-user"); + + using var content = JsonContent.Create(new + { + code = "PACT_CREATE_CFG", + value = "pact-value", + description = "Pact created configuration.", + isInheritable = true, + isEncrypted = false, + }); + + var response = await client.PostAsync("/api/v1/app-configurations", content); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + }); + } + + public void Dispose() { } +} diff --git a/src/apps/ums.api/Ums.ContractTest/Consumers/IgaConsumerTests.cs b/src/apps/ums.api/Ums.ContractTest/Consumers/IgaConsumerTests.cs new file mode 100644 index 00000000..0dfc11c6 --- /dev/null +++ b/src/apps/ums.api/Ums.ContractTest/Consumers/IgaConsumerTests.cs @@ -0,0 +1,251 @@ +using PactNet.Matchers; +using System.Net.Http.Json; + +namespace Ums.ContractTest.Consumers; + +/// +/// G-082: Consumer contract tests for the IGA API (RolePromotionRequest, ADR-UMS-093). +/// +/// El grupo exige autenticación; el actor (cabecera X-User-Id) debe ser un GUID válido y no +/// puede coincidir con el usuario objetivo (segregación de funciones, INV-RPR3). Rutas reales: +/// /api/v1/role-promotion-requests. Se contrasta creación (201), violación de SoD (400), +/// lista (200), obtención por id (200) y no-encontrado por id (404). G-100: el manejador devuelve el +/// error de dominio de «no encontrado» con un código estable e idioma-agnóstico, así que el mapeador +/// lo clasifica como 404 (antes lo colapsaba a 400 por buscar sólo el substring en inglés «not found»). +/// +public sealed class IgaConsumerTests : IDisposable +{ + private readonly IPactBuilderV4 _pactBuilder; + + private static readonly string PactsDir = + Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "pacts"); + + private const string SampleGuid = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; + private const string RequesterGuid = "0000000a-0000-0000-0000-00000000000a"; + private const string TargetGuid = "0000000b-0000-0000-0000-00000000000b"; + private const string CurrentRole = "0000000c-0000-0000-0000-00000000000c"; + private const string TargetRole = "0000000d-0000-0000-0000-00000000000d"; + + public IgaConsumerTests(ITestOutputHelper output) + { + var config = new PactConfig + { + PactDir = PactsDir, + Outputters = [new XunitOutput(output)], + LogLevel = PactLogLevel.Warn, + }; + + _pactBuilder = Pact.V4("ums-web-app", "ums-api", config).WithHttpInteractions(); + } + + // ───────────────────────────────────────────────────────────── + // POST /api/v1/role-promotion-requests — valid → 201 + // ───────────────────────────────────────────────────────────── + + [Fact] + [Trait("pact", "consumer")] + public async Task CreateRolePromotionRequest_WithValidData_Returns201() + { + _pactBuilder + .UponReceiving("a create role promotion request with valid data") + .WithRequest(HttpMethod.Post, "/api/v1/role-promotion-requests") + .WithHeader("X-User-Id", Match.Type(RequesterGuid)) + .WithHeader("Content-Type", Match.Regex("application/json; charset=utf-8", "application/json.*")) + .WithJsonBody(new + { + tenantId = Match.Type(SampleGuid), + targetUserId = Match.Type(TargetGuid), + currentRoleId = Match.Type(CurrentRole), + targetRoleId = Match.Type(TargetRole), + }) + .WillRespond() + .WithStatus(HttpStatusCode.Created) + .WithHeader("Content-Type", Match.Type("application/json; charset=utf-8")) + .WithJsonBody(new + { + rolePromotionRequestId = Match.Regex(SampleGuid, "[0-9a-fA-F-]{36}"), + }); + + await _pactBuilder.VerifyAsync(async ctx => + { + using var client = new HttpClient { BaseAddress = ctx.MockServerUri }; + client.DefaultRequestHeaders.Add("X-User-Id", RequesterGuid); + + using var content = JsonContent.Create(new + { + tenantId = SampleGuid, + targetUserId = TargetGuid, + currentRoleId = CurrentRole, + targetRoleId = TargetRole, + }); + + var response = await client.PostAsync("/api/v1/role-promotion-requests", content); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + }); + } + + // ───────────────────────────────────────────────────────────── + // POST /api/v1/role-promotion-requests — SoD violation → 400 + // ───────────────────────────────────────────────────────────── + + [Fact] + [Trait("pact", "consumer")] + public async Task CreateRolePromotionRequest_ViolatingSegregation_Returns400() + { + _pactBuilder + .UponReceiving("a create role promotion request that violates segregation of duties") + .WithRequest(HttpMethod.Post, "/api/v1/role-promotion-requests") + .WithHeader("X-User-Id", Match.Type(RequesterGuid)) + .WithHeader("Content-Type", Match.Regex("application/json; charset=utf-8", "application/json.*")) + .WithJsonBody(new + { + tenantId = Match.Type(SampleGuid), + targetUserId = RequesterGuid, // el objetivo coincide con el solicitante → SoD + currentRoleId = Match.Type(CurrentRole), + targetRoleId = Match.Type(TargetRole), + }) + .WillRespond() + .WithStatus(HttpStatusCode.BadRequest) + .WithHeader("Content-Type", Match.Type("application/problem+json")) + .WithJsonBody(new + { + status = Match.Type(400), + title = Match.Type("Bad Request"), + }); + + await _pactBuilder.VerifyAsync(async ctx => + { + using var client = new HttpClient { BaseAddress = ctx.MockServerUri }; + client.DefaultRequestHeaders.Add("X-User-Id", RequesterGuid); + + using var content = JsonContent.Create(new + { + tenantId = SampleGuid, + targetUserId = RequesterGuid, + currentRoleId = CurrentRole, + targetRoleId = TargetRole, + }); + + var response = await client.PostAsync("/api/v1/role-promotion-requests", content); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + }); + } + + // ───────────────────────────────────────────────────────────── + // GET /api/v1/role-promotion-requests + // ───────────────────────────────────────────────────────────── + + [Fact] + [Trait("pact", "consumer")] + public async Task ListRolePromotionRequests_Returns200() + { + _pactBuilder + .UponReceiving("a list request for role promotion requests") + .Given("at least one role promotion request exists") + .WithRequest(HttpMethod.Get, "/api/v1/role-promotion-requests") + .WithHeader("X-User-Id", Match.Type(RequesterGuid)) + .WillRespond() + .WithStatus(HttpStatusCode.OK) + .WithHeader("Content-Type", Match.Type("application/json; charset=utf-8")) + .WithJsonBody(Match.MinType(new + { + id = Match.Type(SampleGuid), + tenantId = Match.Type(SampleGuid), + targetUserId = Match.Type(SampleGuid), + requesterId = Match.Type(SampleGuid), + currentRoleId = Match.Type(SampleGuid), + targetRoleId = Match.Type(SampleGuid), + status = Match.Type("Draft"), + }, 1)); + + await _pactBuilder.VerifyAsync(async ctx => + { + using var client = new HttpClient { BaseAddress = ctx.MockServerUri }; + client.DefaultRequestHeaders.Add("X-User-Id", RequesterGuid); + + var response = await client.GetAsync("/api/v1/role-promotion-requests"); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadFromJsonAsync(); + Assert.Equal(JsonValueKind.Array, json.ValueKind); + }); + } + + // ───────────────────────────────────────────────────────────── + // GET /api/v1/role-promotion-requests/{id} — found + // ───────────────────────────────────────────────────────────── + + [Fact] + [Trait("pact", "consumer")] + public async Task GetRolePromotionRequestById_WhenFound_Returns200() + { + const string id = "a4a4a4a4-0000-0000-0000-000000000001"; + + _pactBuilder + .UponReceiving("a request for a specific role promotion request that exists") + .Given($"a role promotion request with id {id} exists") + .WithRequest(HttpMethod.Get, $"/api/v1/role-promotion-requests/{id}") + .WithHeader("X-User-Id", Match.Type(RequesterGuid)) + .WillRespond() + .WithStatus(HttpStatusCode.OK) + .WithHeader("Content-Type", Match.Type("application/json; charset=utf-8")) + .WithJsonBody(new + { + id = Match.Type(id), + tenantId = Match.Type(SampleGuid), + targetUserId = Match.Type(SampleGuid), + requesterId = Match.Type(SampleGuid), + currentRoleId = Match.Type(SampleGuid), + targetRoleId = Match.Type(SampleGuid), + status = Match.Type("Draft"), + }); + + await _pactBuilder.VerifyAsync(async ctx => + { + using var client = new HttpClient { BaseAddress = ctx.MockServerUri }; + client.DefaultRequestHeaders.Add("X-User-Id", RequesterGuid); + + var response = await client.GetAsync($"/api/v1/role-promotion-requests/{id}"); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadFromJsonAsync(); + Assert.True(json.TryGetProperty("id", out _), "response should have 'id'"); + }); + } + + // ───────────────────────────────────────────────────────────── + // GET /api/v1/role-promotion-requests/{id} — not found → 404 (G-100) + // ───────────────────────────────────────────────────────────── + + [Fact] + [Trait("pact", "consumer")] + public async Task GetRolePromotionRequestById_WhenNotFound_Returns404() + { + const string id = "a4a4a4a4-0000-0000-0000-0000000000ff"; + + _pactBuilder + .UponReceiving("a request for a role promotion request that does not exist") + .Given($"no role promotion request with id {id} exists") + .WithRequest(HttpMethod.Get, $"/api/v1/role-promotion-requests/{id}") + .WithHeader("X-User-Id", Match.Type(RequesterGuid)) + .WillRespond() + .WithStatus(HttpStatusCode.NotFound) + .WithHeader("Content-Type", Match.Type("application/problem+json")) + .WithJsonBody(new + { + status = Match.Type(404), + title = Match.Type("Not Found"), + }); + + await _pactBuilder.VerifyAsync(async ctx => + { + using var client = new HttpClient { BaseAddress = ctx.MockServerUri }; + client.DefaultRequestHeaders.Add("X-User-Id", RequesterGuid); + + var response = await client.GetAsync($"/api/v1/role-promotion-requests/{id}"); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + }); + } + + public void Dispose() { } +} diff --git a/src/apps/ums.api/Ums.ContractTest/Consumers/TenantsConsumerTests.cs b/src/apps/ums.api/Ums.ContractTest/Consumers/TenantsConsumerTests.cs index 0a37cdd5..0fcc4f35 100644 --- a/src/apps/ums.api/Ums.ContractTest/Consumers/TenantsConsumerTests.cs +++ b/src/apps/ums.api/Ums.ContractTest/Consumers/TenantsConsumerTests.cs @@ -163,14 +163,13 @@ public async Task CreateTenant_WithValidBody_Returns201() .UponReceiving("a create tenant request with valid data") .WithRequest(HttpMethod.Post, "/api/v1/tenants") .WithHeader("X-User-Id", Match.Type("dev-user")) - .WithHeader("Content-Type", Match.Regex("application/json.*", "application/json; charset=utf-8")) - .WithHeader("Idempotency-Key", Match.Regex("[0-9a-fA-F-]{36}", "a1b2c3d4-e5f6-7890-abcd-ef1234567890")) + .WithHeader("Content-Type", Match.Regex("application/json; charset=utf-8", "application/json.*")) + .WithHeader("Idempotency-Key", Match.Regex("a1b2c3d4-e5f6-7890-abcd-ef1234567890", "[0-9a-fA-F-]{36}")) .WithJsonBody(new { - code = Match.Type("NEWCO"), - name = Match.Type("New Company"), - organizationTypeId = Match.Type(1), - idpStrategyId = Match.Type(1), + code = Match.Type("NEWCO"), + name = Match.Type("New Company"), + type = Match.Regex("INTERNAL", "INTERNAL|SUPPLIER|CLIENT"), }) .WillRespond() .WithStatus(HttpStatusCode.Created) @@ -178,8 +177,8 @@ public async Task CreateTenant_WithValidBody_Returns201() .WithJsonBody(new { tenantId = Match.Regex( - "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", - "3fa85f64-5717-4562-b3fc-2c963f66afa6"), + "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"), }); await _pactBuilder.VerifyAsync(async ctx => @@ -191,10 +190,9 @@ await _pactBuilder.VerifyAsync(async ctx => using var content = JsonContent.Create(new { - code = "NEWCO", - name = "New Company", - organizationTypeId = 1, - idpStrategyId = 1, + code = "NEWCO", + name = "New Company", + type = "INTERNAL", }); var response = await client.PostAsync("/api/v1/tenants", content); diff --git a/src/apps/ums.api/Ums.ContractTest/Infrastructure/ContractTestWebApplicationFactory.cs b/src/apps/ums.api/Ums.ContractTest/Infrastructure/ContractTestWebApplicationFactory.cs index 68d83cff..533fbc2e 100644 --- a/src/apps/ums.api/Ums.ContractTest/Infrastructure/ContractTestWebApplicationFactory.cs +++ b/src/apps/ums.api/Ums.ContractTest/Infrastructure/ContractTestWebApplicationFactory.cs @@ -25,7 +25,7 @@ namespace Ums.ContractTest.Infrastructure; /// Starts the UMS API with InMemory stores so PactNet consumer tests can verify /// contracts against a live server. /// -public sealed class ContractTestWebApplicationFactory : WebApplicationFactory +public class ContractTestWebApplicationFactory : WebApplicationFactory { static ContractTestWebApplicationFactory() { @@ -34,11 +34,32 @@ static ContractTestWebApplicationFactory() Environment.SetEnvironmentVariable("Persistence__SeedDevData", "true"); Environment.SetEnvironmentVariable("Persistence__EnableOutbox", "false"); Environment.SetEnvironmentVariable("Persistence__InitializePlatformStoreOnStartup","false"); + // Un host levantado en «Production» exige un secreto de firma REAL: desde G-203 el arranque + // rechaza los marcadores de posición del repositorio, y sin esto la factoría productiva + // heredaba el de `appsettings.json` y se negaba a arrancar. + // + // Va como VARIABLE DE ENTORNO y no en el diccionario de `ConfigureAppConfiguration`: ese + // diccionario lo pisa `appsettings.json`, que la construcción del host añade después. + Environment.SetEnvironmentVariable("Jwt__Secret", SecretoDeFirmaDePrueba); } + /// + /// Entorno del host bajo prueba. Por defecto «Development» —el entorno que el + /// proveedor de Pact necesita para que se registre `/_pact/provider-states` (G-101)—. + /// Las pruebas que verifican el blindaje productivo lo sobrescriben con «Production». + /// + protected virtual string EnvironmentName => "Development"; + + /// + /// Secreto de firma para la corrida. Se genera al azar en cada arranque: fijarlo aquí lo + /// convertiría en una cadena versionada, que es el defecto que G-203 corrige. + /// + private static readonly string SecretoDeFirmaDePrueba = + Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(48)); + protected override void ConfigureWebHost(IWebHostBuilder builder) { - builder.UseEnvironment("Development"); + builder.UseEnvironment(EnvironmentName); builder.ConfigureAppConfiguration((_, cfg) => { diff --git a/src/apps/ums.api/Ums.ContractTest/Infrastructure/ProductionContractTestWebApplicationFactory.cs b/src/apps/ums.api/Ums.ContractTest/Infrastructure/ProductionContractTestWebApplicationFactory.cs new file mode 100644 index 00000000..1ded1c1d --- /dev/null +++ b/src/apps/ums.api/Ums.ContractTest/Infrastructure/ProductionContractTestWebApplicationFactory.cs @@ -0,0 +1,15 @@ +namespace Ums.ContractTest.Infrastructure; + +/// +/// G-101: variante de la factoría que arranca el host en entorno «Production», +/// reutilizando la misma sustitución de repositorios InMemory de la base. +/// +/// Sirve para verificar el blindaje del endpoint de estados de proveedor de Pact: +/// en producción, `POST /_pact/provider-states` NO debe estar registrado (404), +/// mientras que en desarrollo (factoría base) sí lo está para que el proveedor +/// de Pact pueda sembrar estados. +/// +public sealed class ProductionContractTestWebApplicationFactory : ContractTestWebApplicationFactory +{ + protected override string EnvironmentName => "Production"; +} diff --git a/src/apps/ums.api/Ums.ContractTest/Provider/ProviderStateEndpointGuardTests.cs b/src/apps/ums.api/Ums.ContractTest/Provider/ProviderStateEndpointGuardTests.cs new file mode 100644 index 00000000..106271d0 --- /dev/null +++ b/src/apps/ums.api/Ums.ContractTest/Provider/ProviderStateEndpointGuardTests.cs @@ -0,0 +1,63 @@ +using System.Net.Http.Json; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace Ums.ContractTest.Provider; + +/// +/// G-101: verifica la guarda de entorno de `POST /_pact/provider-states`. +/// +/// El endpoint siembra datos de dominio arbitrarios de forma anónima, por lo que +/// sólo debe registrarse en entornos de desarrollo/contract-test. Estas pruebas +/// confirman el contraste: +/// - En «Development» (factoría base) el endpoint responde 200 OK. +/// - En «Production» el endpoint NO está registrado → 404 Not Found. +/// +/// Comparte la colección «Provider» con para +/// serializar el arranque de hosts: con hosting mínimo (WebApplication) la +/// resolución de entorno de WebApplicationFactory se contamina entre hosts que se +/// construyen en paralelo, de modo que un host «Production» concurrente haría que +/// el proveedor viese el endpoint como no registrado (404). En secuencia, cada host +/// resuelve su entorno correctamente. +/// +[Collection("Provider")] +public sealed class ProviderStateEndpointGuardTests +{ + private static readonly object ProviderStateBody = new { state = "at least one tenant exists" }; + + [Fact] + [Trait("pact", "guard")] + public async Task ProviderStates_InProduction_IsNotRegistered() + { + using var factory = new ProductionContractTestWebApplicationFactory(); + + // Base https para no toparse con UseHttpsRedirection (activo fuera de desarrollo): + // así la petición llega al enrutado, que devuelve 404 al no existir el endpoint. + using var client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + BaseAddress = new Uri("https://localhost"), + }); + + using var response = await client.PostAsJsonAsync( + "/_pact/provider-states", ProviderStateBody, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + [Trait("pact", "guard")] + public async Task ProviderStates_InDevelopment_IsRegistered() + { + using var factory = new ContractTestWebApplicationFactory(); + + using var client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + }); + + using var response = await client.PostAsJsonAsync( + "/_pact/provider-states", ProviderStateBody, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } +} diff --git a/src/apps/ums.api/Ums.ContractTest/pacts/ums-web-app-ums-api.json b/src/apps/ums.api/Ums.ContractTest/pacts/ums-web-app-ums-api.json index 3976fd51..181bc59c 100644 --- a/src/apps/ums.api/Ums.ContractTest/pacts/ums-web-app-ums-api.json +++ b/src/apps/ums.api/Ums.ContractTest/pacts/ums-web-app-ums-api.json @@ -4,25 +4,23 @@ }, "interactions": [ { - "description": "a create tenant request with valid data", + "description": "a create app configuration request with valid global data", "pending": false, "request": { "body": { "content": { - "code": "NEWCO", - "idpStrategyId": 1, - "name": "New Company", - "organizationTypeId": 1 + "code": "PACT_CREATE_CFG", + "description": "Pact created configuration.", + "isEncrypted": false, + "isInheritable": true, + "value": "pact-value" }, "contentType": "application/json", "encoded": false }, "headers": { "Content-Type": [ - "application/json.*" - ], - "Idempotency-Key": [ - "[0-9a-fA-F-]{36}" + "application/json; charset=utf-8" ], "X-User-Id": [ "dev-user" @@ -38,7 +36,7 @@ } ] }, - "$.idpStrategyId": { + "$.description": { "combine": "AND", "matchers": [ { @@ -46,7 +44,7 @@ } ] }, - "$.name": { + "$.isEncrypted": { "combine": "AND", "matchers": [ { @@ -54,31 +52,30 @@ } ] }, - "$.organizationTypeId": { + "$.isInheritable": { "combine": "AND", "matchers": [ { "match": "type" } ] - } - }, - "header": { - "Content-Type": { + }, + "$.value": { "combine": "AND", "matchers": [ { - "match": "regex", - "regex": "application/json; charset=utf-8" + "match": "type" } ] - }, - "Idempotency-Key": { + } + }, + "header": { + "Content-Type": { "combine": "AND", "matchers": [ { "match": "regex", - "regex": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + "regex": "application/json.*" } ] }, @@ -93,29 +90,29 @@ } }, "method": "POST", - "path": "/api/v1/tenants" + "path": "/api/v1/app-configurations" }, "response": { "body": { "content": { - "tenantId": "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}" + "appConfigurationId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" }, "contentType": "application/json", "encoded": false }, "headers": { "Content-Type": [ - "application/json.*" + "application/json; charset=utf-8" ] }, "matchingRules": { "body": { - "$.tenantId": { + "$.appConfigurationId": { "combine": "AND", "matchers": [ { "match": "regex", - "regex": "3fa85f64-5717-4562-b3fc-2c963f66afa6" + "regex": "[0-9a-fA-F-]{36}" } ] } @@ -125,8 +122,7 @@ "combine": "AND", "matchers": [ { - "match": "regex", - "regex": "application/json; charset=utf-8" + "match": "type" } ] } @@ -137,21 +133,38 @@ "type": "Synchronous/HTTP" }, { - "description": "a deactivate request for a profile that does not exist", + "description": "a create approval request with invalid data", "pending": false, - "providerStates": [ - { - "name": "no profile with id 00000000-0000-0000-0000-000000000001 exists" - } - ], "request": { + "body": { + "content": { + "requestedRoleId": "00000000-0000-0000-0000-000000000000", + "requestedSystemId": "00000000-0000-0000-0000-000000000000", + "targetUserId": "00000000-0000-0000-0000-000000000000", + "workflowId": "00000000-0000-0000-0000-000000000000" + }, + "contentType": "application/json", + "encoded": false + }, "headers": { + "Content-Type": [ + "application/json; charset=utf-8" + ], "X-User-Id": [ "dev-user" ] }, "matchingRules": { "header": { + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "application/json.*" + } + ] + }, "X-User-Id": { "combine": "AND", "matchers": [ @@ -163,13 +176,13 @@ } }, "method": "POST", - "path": "/api/v1/profiles/00000000-0000-0000-0000-000000000001/deactivate" + "path": "/api/v1/approval-requests" }, "response": { "body": { "content": { - "status": 404, - "title": "Not Found" + "status": 400, + "title": "Validation Error" }, "contentType": "application/json", "encoded": false @@ -209,27 +222,51 @@ } } }, - "status": 404 + "status": 400 }, "type": "Synchronous/HTTP" }, { - "description": "a deactivate request for an active profile", + "description": "a create feature flag request with an unsupported flag type", "pending": false, - "providerStates": [ - { - "name": "a profile with id 3fa85f64-5717-4562-b3fc-2c963f66afa6 exists" - } - ], "request": { + "body": { + "content": { + "flagCode": "PACT_BAD_FLAG", + "flagTargets": "all", + "flagType": "NotAFlagType", + "systemSuiteId": "cccc3333-0000-0000-0000-000000000003" + }, + "contentType": "application/json", + "encoded": false + }, "headers": { + "Content-Type": [ + "application/json; charset=utf-8" + ], "X-User-Id": [ "dev-user" ] }, "matchingRules": { - "header": { - "X-User-Id": { + "body": { + "$.flagCode": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.flagTargets": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.systemSuiteId": { "combine": "AND", "matchers": [ { @@ -237,32 +274,17 @@ } ] } - } - }, - "method": "POST", - "path": "/api/v1/profiles/3fa85f64-5717-4562-b3fc-2c963f66afa6/deactivate" - }, - "response": { - "status": 204 - }, - "type": "Synchronous/HTTP" - }, - { - "description": "a delete request for a user account that does not exist", - "pending": false, - "providerStates": [ - { - "name": "no user account with id 00000000-0000-0000-0000-000000000001 exists" - } - ], - "request": { - "headers": { - "X-User-Id": [ - "dev-user" - ] - }, - "matchingRules": { + }, "header": { + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "application/json.*" + } + ] + }, "X-User-Id": { "combine": "AND", "matchers": [ @@ -273,14 +295,14 @@ } } }, - "method": "DELETE", - "path": "/api/v1/user-accounts/00000000-0000-0000-0000-000000000001" + "method": "POST", + "path": "/api/v1/feature-flags" }, "response": { "body": { "content": { - "status": 404, - "title": "Not Found" + "status": 400, + "title": "Validation Error" }, "contentType": "application/json", "encoded": false @@ -320,61 +342,59 @@ } } }, - "status": 404 + "status": 400 }, "type": "Synchronous/HTTP" }, { - "description": "a delete request for an inactive user account", + "description": "a create feature flag request with valid data", "pending": false, - "providerStates": [ - { - "name": "a user account with id 3fa85f64-5717-4562-b3fc-2c963f66afa6 is inactive" - } - ], "request": { + "body": { + "content": { + "flagCode": "PACT_CREATE_FLAG", + "flagTargets": "all", + "flagType": "Boolean", + "systemSuiteId": "cccc3333-0000-0000-0000-000000000003" + }, + "contentType": "application/json", + "encoded": false + }, "headers": { + "Content-Type": [ + "application/json; charset=utf-8" + ], "X-User-Id": [ "dev-user" ] }, "matchingRules": { - "header": { - "X-User-Id": { + "body": { + "$.flagCode": { "combine": "AND", "matchers": [ { "match": "type" } ] - } - } - }, - "method": "DELETE", - "path": "/api/v1/user-accounts/3fa85f64-5717-4562-b3fc-2c963f66afa6" - }, - "response": { - "status": 204 - }, - "type": "Synchronous/HTTP" - }, - { - "description": "a paginated list request for permission templates", - "pending": false, - "providerStates": [ - { - "name": "at least one permission template exists" - } - ], - "request": { - "headers": { - "X-User-Id": [ - "dev-user" - ] - }, - "matchingRules": { - "header": { - "X-User-Id": { + }, + "$.flagTargets": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.flagType": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.systemSuiteId": { "combine": "AND", "matchers": [ { @@ -383,107 +403,2140 @@ ] } }, - "query": { - "page": { + "header": { + "Content-Type": { "combine": "AND", "matchers": [ { - "match": "equality" + "match": "regex", + "regex": "application/json.*" } ] }, - "pageSize": { + "X-User-Id": { "combine": "AND", "matchers": [ { - "match": "equality" + "match": "type" } ] } } }, - "method": "GET", - "path": "/api/v1/permission-templates", - "query": { - "page": [ - "1" - ], - "pageSize": [ - "10" - ] - } + "method": "POST", + "path": "/api/v1/feature-flags" }, "response": { "body": { "content": { - "items": [ - { - "roleId": "a5367133-fe90-46c0-ab7e-fb2a961022be", - "roleName": "Administrator", - "status": "Published", - "systemSuiteId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "systemSuiteName": "User Management System", - "templateId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "tenantId": "11111111-1111-1111-1111-111111111111", - "version": "1.0.0" - } - ], - "page": 1, - "pageSize": 10, - "totalItems": 1 + "featureFlagId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" }, "contentType": "application/json", "encoded": false }, "headers": { "Content-Type": [ - "application/json.*" + "application/json; charset=utf-8" ] }, "matchingRules": { "body": { - "$.items": { + "$.featureFlagId": { "combine": "AND", "matchers": [ { - "match": "type", - "min": 1 - } - ] - }, - "$.items[*].roleId": { - "combine": "AND", - "matchers": [ - { - "match": "type" - } - ] - }, - "$.items[*].roleName": { - "combine": "AND", - "matchers": [ - { - "match": "type" + "match": "regex", + "regex": "[0-9a-fA-F-]{36}" } ] - }, - "$.items[*].status": { + } + }, + "header": { + "Content-Type": { "combine": "AND", "matchers": [ { "match": "type" } ] - }, - "$.items[*].systemSuiteId": { - "combine": "AND", - "matchers": [ - { - "match": "type" - } + } + } + }, + "status": 201 + }, + "type": "Synchronous/HTTP" + }, + { + "description": "a create role promotion request that violates segregation of duties", + "pending": false, + "request": { + "body": { + "content": { + "currentRoleId": "0000000c-0000-0000-0000-00000000000c", + "targetRoleId": "0000000d-0000-0000-0000-00000000000d", + "targetUserId": "0000000a-0000-0000-0000-00000000000a", + "tenantId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/json; charset=utf-8" + ], + "X-User-Id": [ + "0000000a-0000-0000-0000-00000000000a" + ] + }, + "matchingRules": { + "body": { + "$.currentRoleId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.targetRoleId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.tenantId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "header": { + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "application/json.*" + } + ] + }, + "X-User-Id": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + }, + "method": "POST", + "path": "/api/v1/role-promotion-requests" + }, + "response": { + "body": { + "content": { + "status": 400, + "title": "Bad Request" + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/problem+json" + ] + }, + "matchingRules": { + "body": { + "$.status": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.title": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "header": { + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + }, + "status": 400 + }, + "type": "Synchronous/HTTP" + }, + { + "description": "a create role promotion request with valid data", + "pending": false, + "request": { + "body": { + "content": { + "currentRoleId": "0000000c-0000-0000-0000-00000000000c", + "targetRoleId": "0000000d-0000-0000-0000-00000000000d", + "targetUserId": "0000000b-0000-0000-0000-00000000000b", + "tenantId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/json; charset=utf-8" + ], + "X-User-Id": [ + "0000000a-0000-0000-0000-00000000000a" + ] + }, + "matchingRules": { + "body": { + "$.currentRoleId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.targetRoleId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.targetUserId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.tenantId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "header": { + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "application/json.*" + } + ] + }, + "X-User-Id": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + }, + "method": "POST", + "path": "/api/v1/role-promotion-requests" + }, + "response": { + "body": { + "content": { + "rolePromotionRequestId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/json; charset=utf-8" + ] + }, + "matchingRules": { + "body": { + "$.rolePromotionRequestId": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "[0-9a-fA-F-]{36}" + } + ] + } + }, + "header": { + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + }, + "status": 201 + }, + "type": "Synchronous/HTTP" + }, + { + "description": "a create tenant request with valid data", + "pending": false, + "request": { + "body": { + "content": { + "code": "NEWCO", + "name": "New Company", + "type": "INTERNAL" + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/json; charset=utf-8" + ], + "Idempotency-Key": [ + "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + ], + "X-User-Id": [ + "dev-user" + ] + }, + "matchingRules": { + "body": { + "$.code": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.name": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.type": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "INTERNAL|SUPPLIER|CLIENT" + } + ] + } + }, + "header": { + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "application/json.*" + } + ] + }, + "Idempotency-Key": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "[0-9a-fA-F-]{36}" + } + ] + }, + "X-User-Id": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + }, + "method": "POST", + "path": "/api/v1/tenants" + }, + "response": { + "body": { + "content": { + "tenantId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/json.*" + ] + }, + "matchingRules": { + "body": { + "$.tenantId": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}" + } + ] + } + }, + "header": { + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "application/json; charset=utf-8" + } + ] + } + } + }, + "status": 201 + }, + "type": "Synchronous/HTTP" + }, + { + "description": "a deactivate request for a profile that does not exist", + "pending": false, + "providerStates": [ + { + "name": "no profile with id 00000000-0000-0000-0000-000000000001 exists" + } + ], + "request": { + "headers": { + "X-User-Id": [ + "dev-user" + ] + }, + "matchingRules": { + "header": { + "X-User-Id": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + }, + "method": "POST", + "path": "/api/v1/profiles/00000000-0000-0000-0000-000000000001/deactivate" + }, + "response": { + "body": { + "content": { + "status": 404, + "title": "Not Found" + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/problem+json" + ] + }, + "matchingRules": { + "body": { + "$.status": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.title": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "header": { + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + }, + "status": 404 + }, + "type": "Synchronous/HTTP" + }, + { + "description": "a deactivate request for an active profile", + "pending": false, + "providerStates": [ + { + "name": "a profile with id 3fa85f64-5717-4562-b3fc-2c963f66afa6 exists" + } + ], + "request": { + "headers": { + "X-User-Id": [ + "dev-user" + ] + }, + "matchingRules": { + "header": { + "X-User-Id": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + }, + "method": "POST", + "path": "/api/v1/profiles/3fa85f64-5717-4562-b3fc-2c963f66afa6/deactivate" + }, + "response": { + "status": 204 + }, + "type": "Synchronous/HTTP" + }, + { + "description": "a delete request for a user account that does not exist", + "pending": false, + "providerStates": [ + { + "name": "no user account with id 00000000-0000-0000-0000-000000000001 exists" + } + ], + "request": { + "headers": { + "X-User-Id": [ + "dev-user" + ] + }, + "matchingRules": { + "header": { + "X-User-Id": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + }, + "method": "DELETE", + "path": "/api/v1/user-accounts/00000000-0000-0000-0000-000000000001" + }, + "response": { + "body": { + "content": { + "status": 404, + "title": "Not Found" + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/problem+json" + ] + }, + "matchingRules": { + "body": { + "$.status": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.title": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "header": { + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + }, + "status": 404 + }, + "type": "Synchronous/HTTP" + }, + { + "description": "a delete request for an inactive user account", + "pending": false, + "providerStates": [ + { + "name": "a user account with id 3fa85f64-5717-4562-b3fc-2c963f66afa6 is inactive" + } + ], + "request": { + "headers": { + "X-User-Id": [ + "dev-user" + ] + }, + "matchingRules": { + "header": { + "X-User-Id": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + }, + "method": "DELETE", + "path": "/api/v1/user-accounts/3fa85f64-5717-4562-b3fc-2c963f66afa6" + }, + "response": { + "status": 204 + }, + "type": "Synchronous/HTTP" + }, + { + "description": "a list request for role promotion requests", + "pending": false, + "providerStates": [ + { + "name": "at least one role promotion request exists" + } + ], + "request": { + "headers": { + "X-User-Id": [ + "0000000a-0000-0000-0000-00000000000a" + ] + }, + "matchingRules": { + "header": { + "X-User-Id": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + }, + "method": "GET", + "path": "/api/v1/role-promotion-requests" + }, + "response": { + "body": { + "content": [ + { + "currentRoleId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "requesterId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "status": "Draft", + "targetRoleId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "targetUserId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "tenantId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" + } + ], + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/json; charset=utf-8" + ] + }, + "matchingRules": { + "body": { + "$": { + "combine": "AND", + "matchers": [ + { + "match": "type", + "min": 1 + } + ] + }, + "$[*].currentRoleId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$[*].id": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$[*].requesterId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$[*].status": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$[*].targetRoleId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$[*].targetUserId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$[*].tenantId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "header": { + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + }, + "status": 200 + }, + "type": "Synchronous/HTTP" + }, + { + "description": "a login request that is missing required fields", + "pending": false, + "request": { + "body": { + "content": { + "password": "", + "tenantCode": "", + "username": "" + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/json; charset=utf-8" + ] + }, + "matchingRules": { + "header": { + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "application/json.*" + } + ] + } + } + }, + "method": "POST", + "path": "/api/v1/auth/login" + }, + "response": { + "body": { + "content": { + "status": 400, + "title": "Bad Request" + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/problem+json" + ] + }, + "matchingRules": { + "body": { + "$.status": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.title": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "header": { + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + }, + "status": 400 + }, + "type": "Synchronous/HTTP" + }, + { + "description": "a login request with credentials that cannot be authenticated", + "pending": false, + "providerStates": [ + { + "name": "no tenant with code UNKNOWN_TENANT exists" + } + ], + "request": { + "body": { + "content": { + "password": "ValidPassword123!", + "tenantCode": "UNKNOWN_TENANT", + "username": "user@example.com" + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/json; charset=utf-8" + ] + }, + "matchingRules": { + "body": { + "$.password": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.username": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "header": { + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "application/json.*" + } + ] + } + } + }, + "method": "POST", + "path": "/api/v1/auth/login" + }, + "response": { + "body": { + "content": { + "status": 400, + "title": "Bad Request" + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/problem+json" + ] + }, + "matchingRules": { + "body": { + "$.status": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.title": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "header": { + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + }, + "status": 400 + }, + "type": "Synchronous/HTTP" + }, + { + "description": "a paginated list request for app configurations", + "pending": false, + "providerStates": [ + { + "name": "at least one app configuration exists" + } + ], + "request": { + "headers": { + "X-User-Id": [ + "dev-user" + ] + }, + "matchingRules": { + "header": { + "X-User-Id": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "query": { + "page": { + "combine": "AND", + "matchers": [ + { + "match": "equality" + } + ] + }, + "pageSize": { + "combine": "AND", + "matchers": [ + { + "match": "equality" + } + ] + } + } + }, + "method": "GET", + "path": "/api/v1/app-configurations", + "query": { + "page": [ + "1" + ], + "pageSize": [ + "10" + ] + } + }, + "response": { + "body": { + "content": { + "items": [ + { + "appConfigurationId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "code": "CONTRACT_TEST_CFG", + "description": "Contract test configuration.", + "scope": "Global", + "status": "Draft", + "value": "contract-test-value", + "version": "1.0.0" + } + ], + "page": 1, + "pageSize": 10, + "totalItems": 1 + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/json; charset=utf-8" + ] + }, + "matchingRules": { + "body": { + "$.items": { + "combine": "AND", + "matchers": [ + { + "match": "type", + "min": 1 + } + ] + }, + "$.items[*].appConfigurationId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].code": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].description": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].scope": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].status": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].value": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].version": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.page": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.pageSize": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.totalItems": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "header": { + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + }, + "status": 200 + }, + "type": "Synchronous/HTTP" + }, + { + "description": "a paginated list request for approval requests", + "pending": false, + "providerStates": [ + { + "name": "at least one approval request exists" + } + ], + "request": { + "headers": { + "X-User-Id": [ + "dev-user" + ] + }, + "matchingRules": { + "header": { + "X-User-Id": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "query": { + "page": { + "combine": "AND", + "matchers": [ + { + "match": "equality" + } + ] + }, + "pageSize": { + "combine": "AND", + "matchers": [ + { + "match": "equality" + } + ] + } + } + }, + "method": "GET", + "path": "/api/v1/approval-requests", + "query": { + "page": [ + "1" + ], + "pageSize": [ + "10" + ] + } + }, + "response": { + "body": { + "content": { + "items": [ + { + "approvalRequestId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "requestedRoleId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "requestedSystemId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "status": "Pending", + "targetUserId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "workflowId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" + } + ], + "page": 1, + "pageSize": 10, + "totalItems": 1 + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/json; charset=utf-8" + ] + }, + "matchingRules": { + "body": { + "$.items": { + "combine": "AND", + "matchers": [ + { + "match": "type", + "min": 1 + } + ] + }, + "$.items[*].approvalRequestId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].requestedRoleId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].requestedSystemId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].status": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].targetUserId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].workflowId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.page": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.pageSize": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.totalItems": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "header": { + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + }, + "status": 200 + }, + "type": "Synchronous/HTTP" + }, + { + "description": "a paginated list request for audit records scoped by tenant", + "pending": false, + "providerStates": [ + { + "name": "at least one audit record exists" + } + ], + "request": { + "headers": { + "X-User-Id": [ + "dev-user" + ] + }, + "matchingRules": { + "header": { + "X-User-Id": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "query": { + "page": { + "combine": "AND", + "matchers": [ + { + "match": "equality" + } + ] + }, + "pageSize": { + "combine": "AND", + "matchers": [ + { + "match": "equality" + } + ] + }, + "tenantId": { + "combine": "AND", + "matchers": [ + { + "match": "equality" + } + ] + } + } + }, + "method": "GET", + "path": "/api/v1/audit-records", + "query": { + "page": [ + "1" + ], + "pageSize": [ + "10" + ], + "tenantId": [ + "11111111-1111-1111-1111-111111111111" + ] + } + }, + "response": { + "body": { + "content": { + "items": [ + { + "affectedEntityId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "affectedEntityType": "ContractTest", + "auditRecordId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "eventType": "ContractTestEvent", + "rootTenantId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "whoActed": "3fa85f64-5717-4562-b3fc-2c963f66afa6" + } + ], + "page": 1, + "pageSize": 10, + "totalItems": 1 + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/json; charset=utf-8" + ] + }, + "matchingRules": { + "body": { + "$.items": { + "combine": "AND", + "matchers": [ + { + "match": "type", + "min": 1 + } + ] + }, + "$.items[*].affectedEntityId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].affectedEntityType": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].auditRecordId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].eventType": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].rootTenantId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].whoActed": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.page": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.pageSize": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.totalItems": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "header": { + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + }, + "status": 200 + }, + "type": "Synchronous/HTTP" + }, + { + "description": "a paginated list request for feature flags", + "pending": false, + "providerStates": [ + { + "name": "at least one feature flag exists" + } + ], + "request": { + "headers": { + "X-User-Id": [ + "dev-user" + ] + }, + "matchingRules": { + "header": { + "X-User-Id": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "query": { + "page": { + "combine": "AND", + "matchers": [ + { + "match": "equality" + } + ] + }, + "pageSize": { + "combine": "AND", + "matchers": [ + { + "match": "equality" + } + ] + } + } + }, + "method": "GET", + "path": "/api/v1/feature-flags", + "query": { + "page": [ + "1" + ], + "pageSize": [ + "10" + ] + } + }, + "response": { + "body": { + "content": { + "items": [ + { + "featureFlagId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "flagCode": "CONTRACT_TEST_FLAG", + "flagTargets": "all", + "flagType": "Boolean", + "status": "Inactive", + "systemSuiteId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" + } + ], + "page": 1, + "pageSize": 10, + "totalItems": 1 + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/json; charset=utf-8" + ] + }, + "matchingRules": { + "body": { + "$.items": { + "combine": "AND", + "matchers": [ + { + "match": "type", + "min": 1 + } + ] + }, + "$.items[*].featureFlagId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].flagCode": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].flagTargets": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].flagType": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].status": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].systemSuiteId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.page": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.pageSize": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.totalItems": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "header": { + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + }, + "status": 200 + }, + "type": "Synchronous/HTTP" + }, + { + "description": "a paginated list request for permission templates", + "pending": false, + "providerStates": [ + { + "name": "at least one permission template exists" + } + ], + "request": { + "headers": { + "X-User-Id": [ + "dev-user" + ] + }, + "matchingRules": { + "header": { + "X-User-Id": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "query": { + "page": { + "combine": "AND", + "matchers": [ + { + "match": "equality" + } + ] + }, + "pageSize": { + "combine": "AND", + "matchers": [ + { + "match": "equality" + } + ] + } + } + }, + "method": "GET", + "path": "/api/v1/permission-templates", + "query": { + "page": [ + "1" + ], + "pageSize": [ + "10" + ] + } + }, + "response": { + "body": { + "content": { + "items": [ + { + "roleId": "a5367133-fe90-46c0-ab7e-fb2a961022be", + "roleName": "Administrator", + "status": "Published", + "systemSuiteId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "systemSuiteName": "User Management System", + "templateId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "tenantId": "11111111-1111-1111-1111-111111111111", + "version": "1.0.0" + } + ], + "page": 1, + "pageSize": 10, + "totalItems": 1 + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/json.*" + ] + }, + "matchingRules": { + "body": { + "$.items": { + "combine": "AND", + "matchers": [ + { + "match": "type", + "min": 1 + } + ] + }, + "$.items[*].roleId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].roleName": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].status": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].systemSuiteId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].systemSuiteName": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].templateId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].tenantId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].version": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.page": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.pageSize": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.totalItems": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "header": { + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "application/json; charset=utf-8" + } + ] + } + } + }, + "status": 200 + }, + "type": "Synchronous/HTTP" + }, + { + "description": "a paginated list request for profiles", + "pending": false, + "providerStates": [ + { + "name": "at least one profile exists" + } + ], + "request": { + "headers": { + "X-User-Id": [ + "dev-user" + ] + }, + "matchingRules": { + "header": { + "X-User-Id": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "query": { + "page": { + "combine": "AND", + "matchers": [ + { + "match": "equality" + } + ] + }, + "pageSize": { + "combine": "AND", + "matchers": [ + { + "match": "equality" + } + ] + } + } + }, + "method": "GET", + "path": "/api/v1/profiles", + "query": { + "page": [ + "1" + ], + "pageSize": [ + "10" + ] + } + }, + "response": { + "body": { + "content": { + "items": [ + { + "isActive": true, + "permissionCount": 0, + "permissions": [], + "profileId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "roleCode": "ADMIN", + "roleId": "a5367133-fe90-46c0-ab7e-fb2a961022be", + "roleName": "Administrator", + "scope": "OrgWide", + "systemSuiteCode": "UMS-CORE", + "systemSuiteId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "systemSuiteName": "User Management System", + "tenantCode": "ACME", + "tenantId": "11111111-1111-1111-1111-111111111111", + "tenantName": "Acme Corp", + "userEmail": "user@example.com", + "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" + } + ], + "page": 1, + "pageSize": 10, + "totalItems": 1 + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/json.*" + ] + }, + "matchingRules": { + "body": { + "$.items": { + "combine": "AND", + "matchers": [ + { + "match": "type", + "min": 1 + } + ] + }, + "$.items[*].isActive": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].permissionCount": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].profileId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].roleCode": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].roleId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].roleName": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].scope": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].systemSuiteCode": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].systemSuiteId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].systemSuiteName": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].tenantCode": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].tenantId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].tenantName": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].userEmail": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].userId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.page": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } ] }, - "$.items[*].systemSuiteName": { + "$.pageSize": { "combine": "AND", "matchers": [ { @@ -491,15 +2544,121 @@ } ] }, - "$.items[*].templateId": { + "$.totalItems": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "header": { + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "application/json; charset=utf-8" + } + ] + } + } + }, + "status": 200 + }, + "type": "Synchronous/HTTP" + }, + { + "description": "a paginated list request for system suites", + "pending": false, + "providerStates": [ + { + "name": "at least one system suite exists" + } + ], + "request": { + "headers": { + "X-User-Id": [ + "dev-user" + ] + }, + "matchingRules": { + "header": { + "X-User-Id": { "combine": "AND", "matchers": [ { "match": "type" } ] + } + }, + "query": { + "page": { + "combine": "AND", + "matchers": [ + { + "match": "equality" + } + ] }, - "$.items[*].tenantId": { + "pageSize": { + "combine": "AND", + "matchers": [ + { + "match": "equality" + } + ] + } + } + }, + "method": "GET", + "path": "/api/v1/system-suites", + "query": { + "page": [ + "1" + ], + "pageSize": [ + "10" + ] + } + }, + "response": { + "body": { + "content": { + "items": [ + { + "code": "UMS-CORE", + "name": "User Management System", + "status": "Active", + "systemSuiteId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" + } + ], + "page": 1, + "pageSize": 10, + "totalItems": 1 + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/json.*" + ] + }, + "matchingRules": { + "body": { + "$.items": { + "combine": "AND", + "matchers": [ + { + "match": "type", + "min": 1 + } + ] + }, + "$.items[*].code": { "combine": "AND", "matchers": [ { @@ -507,7 +2666,23 @@ } ] }, - "$.items[*].version": { + "$.items[*].name": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].status": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.items[*].systemSuiteId": { "combine": "AND", "matchers": [ { @@ -557,11 +2732,11 @@ "type": "Synchronous/HTTP" }, { - "description": "a paginated list request for profiles", + "description": "a paginated list request for tenants", "pending": false, "providerStates": [ { - "name": "at least one profile exists" + "name": "at least one tenant exists" } ], "request": { @@ -601,7 +2776,7 @@ } }, "method": "GET", - "path": "/api/v1/profiles", + "path": "/api/v1/tenants", "query": { "page": [ "1" @@ -616,22 +2791,9 @@ "content": { "items": [ { - "isActive": true, - "permissionCount": 0, - "permissions": [], - "profileId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "roleCode": "ADMIN", - "roleId": "a5367133-fe90-46c0-ab7e-fb2a961022be", - "roleName": "Administrator", - "scope": "OrgWide", - "systemSuiteCode": "UMS-CORE", - "systemSuiteId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "systemSuiteName": "User Management System", - "tenantCode": "ACME", - "tenantId": "11111111-1111-1111-1111-111111111111", - "tenantName": "Acme Corp", - "userEmail": "user@example.com", - "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" + "code": "ACME", + "name": "Acme Corp", + "tenantId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" } ], "page": 1, @@ -657,15 +2819,7 @@ } ] }, - "$.items[*].isActive": { - "combine": "AND", - "matchers": [ - { - "match": "type" - } - ] - }, - "$.items[*].permissionCount": { + "$.items[*].code": { "combine": "AND", "matchers": [ { @@ -673,7 +2827,7 @@ } ] }, - "$.items[*].profileId": { + "$.items[*].name": { "combine": "AND", "matchers": [ { @@ -681,7 +2835,7 @@ } ] }, - "$.items[*].roleCode": { + "$.items[*].tenantId": { "combine": "AND", "matchers": [ { @@ -689,7 +2843,7 @@ } ] }, - "$.items[*].roleId": { + "$.page": { "combine": "AND", "matchers": [ { @@ -697,7 +2851,7 @@ } ] }, - "$.items[*].roleName": { + "$.pageSize": { "combine": "AND", "matchers": [ { @@ -705,55 +2859,120 @@ } ] }, - "$.items[*].scope": { + "$.totalItems": { "combine": "AND", "matchers": [ { "match": "type" } ] - }, - "$.items[*].systemSuiteCode": { + } + }, + "header": { + "Content-Type": { "combine": "AND", "matchers": [ { - "match": "type" + "match": "regex", + "regex": "application/json; charset=utf-8" } ] - }, - "$.items[*].systemSuiteId": { + } + } + }, + "status": 200 + }, + "type": "Synchronous/HTTP" + }, + { + "description": "a paginated list request for user accounts", + "pending": false, + "providerStates": [ + { + "name": "at least one user account exists" + } + ], + "request": { + "headers": { + "X-User-Id": [ + "dev-user" + ] + }, + "matchingRules": { + "header": { + "X-User-Id": { "combine": "AND", "matchers": [ { "match": "type" } ] - }, - "$.items[*].systemSuiteName": { + } + }, + "query": { + "page": { "combine": "AND", "matchers": [ { - "match": "type" + "match": "equality" } ] }, - "$.items[*].tenantCode": { + "pageSize": { "combine": "AND", "matchers": [ { - "match": "type" + "match": "equality" } ] - }, - "$.items[*].tenantId": { + } + } + }, + "method": "GET", + "path": "/api/v1/user-accounts", + "query": { + "page": [ + "1" + ], + "pageSize": [ + "10" + ] + } + }, + "response": { + "body": { + "content": { + "items": [ + { + "email": "user@example.com", + "status": "Active", + "userAccountId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" + } + ], + "page": 1, + "pageSize": 10, + "totalItems": 1 + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/json.*" + ] + }, + "matchingRules": { + "body": { + "$.items": { "combine": "AND", "matchers": [ { - "match": "type" + "match": "type", + "min": 1 } ] }, - "$.items[*].tenantName": { + "$.items[*].email": { "combine": "AND", "matchers": [ { @@ -761,7 +2980,7 @@ } ] }, - "$.items[*].userEmail": { + "$.items[*].status": { "combine": "AND", "matchers": [ { @@ -769,7 +2988,7 @@ } ] }, - "$.items[*].userId": { + "$.items[*].userAccountId": { "combine": "AND", "matchers": [ { @@ -819,11 +3038,11 @@ "type": "Synchronous/HTTP" }, { - "description": "a paginated list request for system suites", + "description": "a request for a feature flag that does not exist", "pending": false, "providerStates": [ { - "name": "at least one system suite exists" + "name": "no feature flag with id 00000000-0000-0000-0000-0000000000a3 exists" } ], "request": { @@ -842,80 +3061,186 @@ } ] } + } + }, + "method": "GET", + "path": "/api/v1/feature-flags/00000000-0000-0000-0000-0000000000a3" + }, + "response": { + "body": { + "content": { + "status": 404, + "title": "Not Found" }, - "query": { - "page": { + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/problem+json" + ] + }, + "matchingRules": { + "body": { + "$.status": { "combine": "AND", "matchers": [ { - "match": "equality" + "match": "type" } ] }, - "pageSize": { + "$.title": { "combine": "AND", "matchers": [ { - "match": "equality" + "match": "type" + } + ] + } + }, + "header": { + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "type" } ] } } }, - "method": "GET", - "path": "/api/v1/system-suites", - "query": { - "page": [ - "1" - ], - "pageSize": [ - "10" - ] + "status": 404 + }, + "type": "Synchronous/HTTP" + }, + { + "description": "a request for a role promotion request that does not exist", + "pending": false, + "providerStates": [ + { + "name": "no role promotion request with id a4a4a4a4-0000-0000-0000-0000000000ff exists" } + ], + "request": { + "headers": { + "X-User-Id": [ + "0000000a-0000-0000-0000-00000000000a" + ] + }, + "matchingRules": { + "header": { + "X-User-Id": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + }, + "method": "GET", + "path": "/api/v1/role-promotion-requests/a4a4a4a4-0000-0000-0000-0000000000ff" }, "response": { "body": { "content": { - "items": [ - { - "code": "UMS-CORE", - "name": "User Management System", - "status": "Active", - "systemSuiteId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" - } - ], - "page": 1, - "pageSize": 10, - "totalItems": 1 + "status": 404, + "title": "Not Found" }, "contentType": "application/json", "encoded": false }, "headers": { "Content-Type": [ - "application/json.*" + "application/problem+json" ] }, "matchingRules": { "body": { - "$.items": { + "$.status": { "combine": "AND", "matchers": [ { - "match": "type", - "min": 1 + "match": "type" } ] }, - "$.items[*].code": { + "$.title": { "combine": "AND", "matchers": [ { "match": "type" } ] - }, - "$.items[*].name": { + } + }, + "header": { + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + }, + "status": 404 + }, + "type": "Synchronous/HTTP" + }, + { + "description": "a request for a specific approval request that exists", + "pending": false, + "providerStates": [ + { + "name": "an approval request with id a1a1a1a1-0000-0000-0000-000000000001 exists" + } + ], + "request": { + "headers": { + "X-User-Id": [ + "dev-user" + ] + }, + "matchingRules": { + "header": { + "X-User-Id": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + }, + "method": "GET", + "path": "/api/v1/approval-requests/a1a1a1a1-0000-0000-0000-000000000001" + }, + "response": { + "body": { + "content": { + "approvalRequestId": "a1a1a1a1-0000-0000-0000-000000000001", + "requestedRoleId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "requestedSystemId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "status": "Pending", + "targetUserId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "workflowId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/json; charset=utf-8" + ] + }, + "matchingRules": { + "body": { + "$.approvalRequestId": { "combine": "AND", "matchers": [ { @@ -923,7 +3248,7 @@ } ] }, - "$.items[*].status": { + "$.requestedRoleId": { "combine": "AND", "matchers": [ { @@ -931,7 +3256,7 @@ } ] }, - "$.items[*].systemSuiteId": { + "$.requestedSystemId": { "combine": "AND", "matchers": [ { @@ -939,7 +3264,7 @@ } ] }, - "$.page": { + "$.status": { "combine": "AND", "matchers": [ { @@ -947,7 +3272,7 @@ } ] }, - "$.pageSize": { + "$.targetUserId": { "combine": "AND", "matchers": [ { @@ -955,7 +3280,7 @@ } ] }, - "$.totalItems": { + "$.workflowId": { "combine": "AND", "matchers": [ { @@ -969,8 +3294,7 @@ "combine": "AND", "matchers": [ { - "match": "regex", - "regex": "application/json; charset=utf-8" + "match": "type" } ] } @@ -981,11 +3305,11 @@ "type": "Synchronous/HTTP" }, { - "description": "a paginated list request for tenants", + "description": "a request for a specific audit record that exists", "pending": false, "providerStates": [ { - "name": "at least one tenant exists" + "name": "an audit record with id a2a2a2a2-0000-0000-0000-000000000001 exists" } ], "request": { @@ -1004,71 +3328,145 @@ } ] } + } + }, + "method": "GET", + "path": "/api/v1/audit-records/a2a2a2a2-0000-0000-0000-000000000001" + }, + "response": { + "body": { + "content": { + "affectedEntityId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "affectedEntityType": "ContractTest", + "auditRecordId": "a2a2a2a2-0000-0000-0000-000000000001", + "eventType": "ContractTestEvent", + "rootTenantId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "whoActed": "3fa85f64-5717-4562-b3fc-2c963f66afa6" }, - "query": { - "page": { + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/json; charset=utf-8" + ] + }, + "matchingRules": { + "body": { + "$.affectedEntityId": { "combine": "AND", "matchers": [ { - "match": "equality" + "match": "type" } ] }, - "pageSize": { + "$.affectedEntityType": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.auditRecordId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.eventType": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.rootTenantId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.whoActed": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "header": { + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + }, + "status": 200 + }, + "type": "Synchronous/HTTP" + }, + { + "description": "a request for a specific feature flag that exists", + "pending": false, + "providerStates": [ + { + "name": "a feature flag with id a3a3a3a3-0000-0000-0000-000000000001 exists" + } + ], + "request": { + "headers": { + "X-User-Id": [ + "dev-user" + ] + }, + "matchingRules": { + "header": { + "X-User-Id": { "combine": "AND", "matchers": [ { - "match": "equality" + "match": "type" } ] } } }, "method": "GET", - "path": "/api/v1/tenants", - "query": { - "page": [ - "1" - ], - "pageSize": [ - "10" - ] - } + "path": "/api/v1/feature-flags/a3a3a3a3-0000-0000-0000-000000000001" }, "response": { "body": { "content": { - "items": [ - { - "code": "ACME", - "name": "Acme Corp", - "tenantId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" - } - ], - "page": 1, - "pageSize": 10, - "totalItems": 1 + "featureFlagId": "a3a3a3a3-0000-0000-0000-000000000001", + "flagCode": "CONTRACT_TEST_FLAG", + "flagTargets": "all", + "flagType": "Boolean", + "status": "Inactive", + "systemSuiteId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" }, "contentType": "application/json", "encoded": false }, "headers": { "Content-Type": [ - "application/json.*" + "application/json; charset=utf-8" ] }, "matchingRules": { "body": { - "$.items": { - "combine": "AND", - "matchers": [ - { - "match": "type", - "min": 1 - } - ] - }, - "$.items[*].code": { + "$.featureFlagId": { "combine": "AND", "matchers": [ { @@ -1076,7 +3474,7 @@ } ] }, - "$.items[*].name": { + "$.flagCode": { "combine": "AND", "matchers": [ { @@ -1084,7 +3482,7 @@ } ] }, - "$.items[*].tenantId": { + "$.flagTargets": { "combine": "AND", "matchers": [ { @@ -1092,7 +3490,7 @@ } ] }, - "$.page": { + "$.flagType": { "combine": "AND", "matchers": [ { @@ -1100,7 +3498,7 @@ } ] }, - "$.pageSize": { + "$.status": { "combine": "AND", "matchers": [ { @@ -1108,7 +3506,7 @@ } ] }, - "$.totalItems": { + "$.systemSuiteId": { "combine": "AND", "matchers": [ { @@ -1122,8 +3520,7 @@ "combine": "AND", "matchers": [ { - "match": "regex", - "regex": "application/json; charset=utf-8" + "match": "type" } ] } @@ -1134,11 +3531,11 @@ "type": "Synchronous/HTTP" }, { - "description": "a paginated list request for user accounts", + "description": "a request for a specific permission template that exists", "pending": false, "providerStates": [ { - "name": "at least one user account exists" + "name": "a permission template with id 3fa85f64-5717-4562-b3fc-2c963f66afa6 exists" } ], "request": { @@ -1157,50 +3554,23 @@ } ] } - }, - "query": { - "page": { - "combine": "AND", - "matchers": [ - { - "match": "equality" - } - ] - }, - "pageSize": { - "combine": "AND", - "matchers": [ - { - "match": "equality" - } - ] - } } }, "method": "GET", - "path": "/api/v1/user-accounts", - "query": { - "page": [ - "1" - ], - "pageSize": [ - "10" - ] - } + "path": "/api/v1/permission-templates/3fa85f64-5717-4562-b3fc-2c963f66afa6" }, "response": { "body": { "content": { - "items": [ - { - "email": "user@example.com", - "status": "Active", - "userAccountId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" - } - ], - "page": 1, - "pageSize": 10, - "totalItems": 1 + "items": [], + "roleId": "a5367133-fe90-46c0-ab7e-fb2a961022be", + "roleName": "Administrator", + "status": "Published", + "systemSuiteId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "systemSuiteName": "User Management System", + "templateId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "tenantId": "11111111-1111-1111-1111-111111111111", + "version": "1.0.0" }, "contentType": "application/json", "encoded": false @@ -1212,16 +3582,15 @@ }, "matchingRules": { "body": { - "$.items": { + "$.roleId": { "combine": "AND", "matchers": [ { - "match": "type", - "min": 1 + "match": "type" } ] }, - "$.items[*].email": { + "$.roleName": { "combine": "AND", "matchers": [ { @@ -1229,7 +3598,7 @@ } ] }, - "$.items[*].status": { + "$.status": { "combine": "AND", "matchers": [ { @@ -1237,7 +3606,7 @@ } ] }, - "$.items[*].userAccountId": { + "$.systemSuiteId": { "combine": "AND", "matchers": [ { @@ -1245,7 +3614,7 @@ } ] }, - "$.page": { + "$.systemSuiteName": { "combine": "AND", "matchers": [ { @@ -1253,7 +3622,7 @@ } ] }, - "$.pageSize": { + "$.templateId": { "combine": "AND", "matchers": [ { @@ -1261,7 +3630,15 @@ } ] }, - "$.totalItems": { + "$.tenantId": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.version": { "combine": "AND", "matchers": [ { @@ -1287,17 +3664,17 @@ "type": "Synchronous/HTTP" }, { - "description": "a request for a specific permission template that exists", + "description": "a request for a specific role promotion request that exists", "pending": false, "providerStates": [ { - "name": "a permission template with id 3fa85f64-5717-4562-b3fc-2c963f66afa6 exists" + "name": "a role promotion request with id a4a4a4a4-0000-0000-0000-000000000001 exists" } ], "request": { "headers": { "X-User-Id": [ - "dev-user" + "0000000a-0000-0000-0000-00000000000a" ] }, "matchingRules": { @@ -1313,32 +3690,30 @@ } }, "method": "GET", - "path": "/api/v1/permission-templates/3fa85f64-5717-4562-b3fc-2c963f66afa6" + "path": "/api/v1/role-promotion-requests/a4a4a4a4-0000-0000-0000-000000000001" }, "response": { "body": { "content": { - "items": [], - "roleId": "a5367133-fe90-46c0-ab7e-fb2a961022be", - "roleName": "Administrator", - "status": "Published", - "systemSuiteId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "systemSuiteName": "User Management System", - "templateId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", - "tenantId": "11111111-1111-1111-1111-111111111111", - "version": "1.0.0" + "currentRoleId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "id": "a4a4a4a4-0000-0000-0000-000000000001", + "requesterId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "status": "Draft", + "targetRoleId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "targetUserId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "tenantId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" }, "contentType": "application/json", "encoded": false }, "headers": { "Content-Type": [ - "application/json.*" + "application/json; charset=utf-8" ] }, "matchingRules": { "body": { - "$.roleId": { + "$.currentRoleId": { "combine": "AND", "matchers": [ { @@ -1346,7 +3721,7 @@ } ] }, - "$.roleName": { + "$.id": { "combine": "AND", "matchers": [ { @@ -1354,7 +3729,7 @@ } ] }, - "$.status": { + "$.requesterId": { "combine": "AND", "matchers": [ { @@ -1362,7 +3737,7 @@ } ] }, - "$.systemSuiteId": { + "$.status": { "combine": "AND", "matchers": [ { @@ -1370,7 +3745,7 @@ } ] }, - "$.systemSuiteName": { + "$.targetRoleId": { "combine": "AND", "matchers": [ { @@ -1378,7 +3753,7 @@ } ] }, - "$.templateId": { + "$.targetUserId": { "combine": "AND", "matchers": [ { @@ -1393,14 +3768,6 @@ "match": "type" } ] - }, - "$.version": { - "combine": "AND", - "matchers": [ - { - "match": "type" - } - ] } }, "header": { @@ -1408,8 +3775,7 @@ "combine": "AND", "matchers": [ { - "match": "regex", - "regex": "application/json; charset=utf-8" + "match": "type" } ] } @@ -1689,89 +4055,51 @@ "type": "Synchronous/HTTP" }, { - "description": "a valid token request", + "description": "a request for an approval request that does not exist", "pending": false, "providerStates": [ { - "name": "a user account exists with valid credentials" + "name": "no approval request with id 00000000-0000-0000-0000-0000000000a1 exists" } ], "request": { - "body": { - "content": { - "email": "user@example.com", - "password": "ValidPassword123!" - }, - "contentType": "application/json", - "encoded": false - }, "headers": { - "Content-Type": [ - "application/json.*" + "X-User-Id": [ + "dev-user" ] }, "matchingRules": { - "body": { - "$.email": { - "combine": "AND", - "matchers": [ - { - "match": "type" - } - ] - }, - "$.password": { - "combine": "AND", - "matchers": [ - { - "match": "type" - } - ] - } - }, "header": { - "Content-Type": { + "X-User-Id": { "combine": "AND", "matchers": [ { - "match": "regex", - "regex": "application/json; charset=utf-8" + "match": "type" } ] } } }, - "method": "POST", - "path": "/api/v1/auth/token" + "method": "GET", + "path": "/api/v1/approval-requests/00000000-0000-0000-0000-0000000000a1" }, "response": { "body": { "content": { - "token": "eyJhbGciOiJIUzI1NiIsInR5cCI...", - "user": { - "email": "user@example.com", - "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" - } + "status": 404, + "title": "Not Found" }, "contentType": "application/json", "encoded": false }, "headers": { "Content-Type": [ - "application/json.*" + "application/problem+json" ] }, "matchingRules": { "body": { - "$.token": { - "combine": "AND", - "matchers": [ - { - "match": "type" - } - ] - }, - "$.user.email": { + "$.status": { "combine": "AND", "matchers": [ { @@ -1779,7 +4107,7 @@ } ] }, - "$.user.userId": { + "$.title": { "combine": "AND", "matchers": [ { @@ -1793,78 +4121,50 @@ "combine": "AND", "matchers": [ { - "match": "regex", - "regex": "application/json; charset=utf-8" + "match": "type" } ] } } }, - "status": 200 + "status": 404 }, "type": "Synchronous/HTTP" }, { - "description": "an invalid token request", + "description": "a request for an audit record that does not exist", "pending": false, "providerStates": [ { - "name": "a user account does not exist or credentials do not match" + "name": "no audit record with id 00000000-0000-0000-0000-0000000000a2 exists" } ], "request": { - "body": { - "content": { - "email": "wrong@example.com", - "password": "WrongPassword123!" - }, - "contentType": "application/json", - "encoded": false - }, "headers": { - "Content-Type": [ - "application/json.*" + "X-User-Id": [ + "dev-user" ] }, "matchingRules": { - "body": { - "$.email": { - "combine": "AND", - "matchers": [ - { - "match": "type" - } - ] - }, - "$.password": { - "combine": "AND", - "matchers": [ - { - "match": "type" - } - ] - } - }, "header": { - "Content-Type": { + "X-User-Id": { "combine": "AND", "matchers": [ { - "match": "regex", - "regex": "application/json; charset=utf-8" + "match": "type" } ] } } }, - "method": "POST", - "path": "/api/v1/auth/token" + "method": "GET", + "path": "/api/v1/audit-records/00000000-0000-0000-0000-0000000000a2" }, "response": { "body": { "content": { - "status": 401, - "title": "Unauthorized" + "status": 404, + "title": "Not Found" }, "contentType": "application/json", "encoded": false @@ -1904,7 +4204,7 @@ } } }, - "status": 401 + "status": 404 }, "type": "Synchronous/HTTP" } diff --git a/src/apps/ums.api/Ums.Domain.Test/Approvals/AccessEnforcementPolicy/AccessEnforcementPolicyTests.cs b/src/apps/ums.api/Ums.Domain.Test/Approvals/AccessEnforcementPolicy/AccessEnforcementPolicyTests.cs index 69306da5..5aa400a7 100644 --- a/src/apps/ums.api/Ums.Domain.Test/Approvals/AccessEnforcementPolicy/AccessEnforcementPolicyTests.cs +++ b/src/apps/ums.api/Ums.Domain.Test/Approvals/AccessEnforcementPolicy/AccessEnforcementPolicyTests.cs @@ -97,8 +97,21 @@ public void UpdateAction_WithValidData_ReturnsSuccess() Assert.Equal(newAction, policy.EnforcementAction); } + // ------------------------------------------------------------------------- + // INVARIANTE DE IRREVERSIBILIDAD (G-051) — NO DEBILITAR. + // + // Deactivate es una transicion terminal: el agregado no expone reactivacion, + // por lo que una politica desactivada queda congelada. UpdateAction sobre una + // politica desactivada es una transicion invalida sobre un estado terminal y + // debe rechazarse con Result.Failure (BrokenRule PolicyInactiveCannotUpdate). + // + // El test previo `UpdateAction_WhenInactive_StillSucceeds` afirmaba lo contrario + // (exito), fijando el defecto. Estos tests son el candado de la invariante: no + // deben relajarse para volver a permitir la mutacion de una politica inactiva. + // ------------------------------------------------------------------------- + [Fact] - public void UpdateAction_WhenInactive_StillSucceeds() + public void UpdateAction_WhenInactive_ReturnsFailure() { var policy = AccessEnforcementPolicy.Create(ValidTenantId, ValidProfileId, null, ValidAction, ValidActor).Value; policy.Deactivate(ValidActor); @@ -106,8 +119,20 @@ public void UpdateAction_WhenInactive_StillSucceeds() var result = policy.UpdateAction(newAction, ValidActor); - Assert.True(result.IsSuccess); - Assert.Equal(newAction, policy.EnforcementAction); + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Approvals.PolicyInactiveCannotUpdate, result.Error); + } + + [Fact] + public void UpdateAction_WhenInactive_DoesNotMutateEnforcementAction() + { + var policy = AccessEnforcementPolicy.Create(ValidTenantId, ValidProfileId, null, ValidAction, ValidActor).Value; + policy.Deactivate(ValidActor); + + policy.UpdateAction(AccessEnforcementAction.BlockUser, ValidActor); + + // La accion original permanece intacta: el estado terminal es inmutable. + Assert.Equal(ValidAction, policy.EnforcementAction); } #endregion diff --git a/src/apps/ums.api/Ums.Domain.Test/Approvals/ApprovalWorkflow/ApprovalWorkflowTests.cs b/src/apps/ums.api/Ums.Domain.Test/Approvals/ApprovalWorkflow/ApprovalWorkflowTests.cs index 0955a9d0..fab35d8f 100644 --- a/src/apps/ums.api/Ums.Domain.Test/Approvals/ApprovalWorkflow/ApprovalWorkflowTests.cs +++ b/src/apps/ums.api/Ums.Domain.Test/Approvals/ApprovalWorkflow/ApprovalWorkflowTests.cs @@ -110,7 +110,7 @@ public void RemoveRequiredDocument_WhenDocumentExists_ReturnsSuccess() var documentTypeId2 = DocumentTypeId.Load(Guid.NewGuid().ToString()); workflow.AddRequiredDocument(documentTypeId1, true, ValidActor); workflow.AddRequiredDocument(documentTypeId2, false, ValidActor); - var documentId = workflow.RequiredDocuments.First().Id; + var documentId = workflow.RequiredDocuments.First().GetId(); var result = workflow.RemoveRequiredDocument(documentId, ValidActor); @@ -125,7 +125,7 @@ public void RemoveRequiredDocument_WhenLastDocumentAndRequiresApproval_ReturnsFa ValidTenantId, ValidCode, ValidName, ValidDescription, ValidUserCategory, true, ValidSystemSuiteId, ValidActor, 1).Value; var documentTypeId = DocumentTypeId.Load(Guid.NewGuid().ToString()); workflow.AddRequiredDocument(documentTypeId, true, ValidActor); - var documentId = workflow.RequiredDocuments.First().Id; + var documentId = workflow.RequiredDocuments.First().GetId(); var result = workflow.RemoveRequiredDocument(documentId, ValidActor); diff --git a/src/apps/ums.api/Ums.Domain.Test/Approvals/RequiredDocumentChecklistTests.cs b/src/apps/ums.api/Ums.Domain.Test/Approvals/RequiredDocumentChecklistTests.cs new file mode 100644 index 00000000..2165725d --- /dev/null +++ b/src/apps/ums.api/Ums.Domain.Test/Approvals/RequiredDocumentChecklistTests.cs @@ -0,0 +1,122 @@ +namespace Ums.Domain.Test.Approvals; + +using Ums.Domain.Approvals; +using ApprovalWorkflowAggregate = Ums.Domain.Approvals.ApprovalWorkflow.ApprovalWorkflow; +using UserDocumentAggregate = Ums.Domain.Approvals.UserDocument.UserDocument; +using Xunit; + +public class RequiredDocumentChecklistTests +{ + private static readonly ActorId Actor = ActorId.Create("user-001"); + + private static ApprovalWorkflowAggregate MakeWorkflow() => + ApprovalWorkflowAggregate.Create( + TenantId.Load(Guid.NewGuid().ToString()), + Code.Create("WF-001"), + Name.Create("Onboarding"), + Description.Create("Onboarding workflow"), + UserCategory.Internal, + requiresApproval: true, + SystemSuiteId.Load(Guid.NewGuid().ToString()), + Actor, + requiredDocumentCount: 1).Value; + + private static ApprovalWorkflowAggregate MakeWorkflowRequiring(DocumentTypeId documentTypeId, bool isMandatory = true) + { + var workflow = MakeWorkflow(); + workflow.AddRequiredDocument(documentTypeId, isMandatory, Actor); + return workflow; + } + + private static UserDocumentAggregate MakeDocument(DocumentTypeId documentTypeId, bool valid) + { + var document = UserDocumentAggregate.Upload( + UserId.Load(Guid.NewGuid().ToString()), + documentTypeId, + new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), + DocumentCriticity.High, + TextValueObject.Create("/storage/doc.pdf"), + "checksum", + Actor).Value; + + if (valid) + { + document.Validate(Actor); + } + + return document; + } + + [Fact] + public void Evaluate_WhenNoRequiredDocuments_ReturnsSuccess() + { + var result = RequiredDocumentChecklist.Evaluate(MakeWorkflow(), Array.Empty()); + + Assert.True(result.IsSuccess); + } + + [Fact] + public void Evaluate_WhenMandatoryDocumentValid_ReturnsSuccess() + { + var docType = DocumentTypeId.Load(Guid.NewGuid()); + + var result = RequiredDocumentChecklist.Evaluate( + MakeWorkflowRequiring(docType), + new[] { MakeDocument(docType, valid: true) }); + + Assert.True(result.IsSuccess); + } + + [Fact] + public void Evaluate_WhenMandatoryDocumentMissing_FailsClosed() + { + var docType = DocumentTypeId.Load(Guid.NewGuid()); + + var result = RequiredDocumentChecklist.Evaluate( + MakeWorkflowRequiring(docType), + Array.Empty()); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Approvals.RequiredDocumentsIncomplete, result.Error); + } + + [Fact] + public void Evaluate_WhenMandatoryDocumentPresentButNotValid_FailsClosed() + { + var docType = DocumentTypeId.Load(Guid.NewGuid()); + + var result = RequiredDocumentChecklist.Evaluate( + MakeWorkflowRequiring(docType), + new[] { MakeDocument(docType, valid: false) }); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Approvals.RequiredDocumentsIncomplete, result.Error); + } + + [Fact] + public void Evaluate_WhenNonMandatoryDocumentMissing_ReturnsSuccess() + { + var docType = DocumentTypeId.Load(Guid.NewGuid()); + + var result = RequiredDocumentChecklist.Evaluate( + MakeWorkflowRequiring(docType, isMandatory: false), + Array.Empty()); + + Assert.True(result.IsSuccess); + } + + [Fact] + public void Evaluate_WhenValidDocumentOfDifferentType_FailsClosed() + { + var requiredType = DocumentTypeId.Load(Guid.NewGuid()); + var otherType = DocumentTypeId.Load(Guid.NewGuid()); + + var result = RequiredDocumentChecklist.Evaluate( + MakeWorkflowRequiring(requiredType), + new[] { MakeDocument(otherType, valid: true) }); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Approvals.RequiredDocumentsIncomplete, result.Error); + } +} diff --git a/src/apps/ums.api/Ums.Domain.Test/Approvals/UserDocument/UserDocumentTests.cs b/src/apps/ums.api/Ums.Domain.Test/Approvals/UserDocument/UserDocumentTests.cs index e1d11bfc..5ed2e155 100644 --- a/src/apps/ums.api/Ums.Domain.Test/Approvals/UserDocument/UserDocumentTests.cs +++ b/src/apps/ums.api/Ums.Domain.Test/Approvals/UserDocument/UserDocumentTests.cs @@ -7,8 +7,8 @@ public class UserDocumentTests { private static readonly UserId ValidUserId = UserId.Load(Guid.NewGuid().ToString()); private static readonly DocumentTypeId ValidDocumentTypeId = DocumentTypeId.Load(Guid.NewGuid().ToString()); - private static readonly DateTime ValidIssueDate = new(2024, 1, 1); - private static readonly DateTime ValidExpirationDate = new(2025, 1, 1); + private static readonly DateTime ValidIssueDate = new(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc); + private static readonly DateTime ValidExpirationDate = new(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); private static readonly DocumentCriticity ValidCriticity = DocumentCriticity.High; private static readonly TextValueObject ValidFileStoragePath = TextValueObject.Create("/storage/doc.pdf"); private static readonly string ValidFileChecksum = "abc123def456"; @@ -36,8 +36,8 @@ public void Upload_WithValidData_ReturnsSuccess() [Fact] public void Upload_WhenExpirationBeforeIssueDate_ReturnsFailure() { - var issueDate = new DateTime(2025, 1, 1); - var expirationDate = new DateTime(2024, 1, 1); + var issueDate = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var expirationDate = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc); var result = UserDocument.Upload( ValidUserId, ValidDocumentTypeId, issueDate, expirationDate, @@ -50,7 +50,7 @@ public void Upload_WhenExpirationBeforeIssueDate_ReturnsFailure() [Fact] public void Upload_WhenExpirationEqualsIssueDate_ReturnsFailure() { - var date = new DateTime(2024, 1, 1); + var date = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc); var result = UserDocument.Upload( ValidUserId, ValidDocumentTypeId, date, date, @@ -221,8 +221,8 @@ public void ReUpload_WhenExpired_ReturnsSuccess() ValidCriticity, ValidFileStoragePath, ValidFileChecksum, ValidActor).Value; document.Validate(ValidActor); document.Expire(ValidActor); - var newIssueDate = new DateTime(2025, 1, 1); - var newExpirationDate = new DateTime(2026, 1, 1); + var newIssueDate = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var newExpirationDate = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); var newStoragePath = TextValueObject.Create("/storage/new-doc.pdf"); var newChecksum = "newchecksum789"; @@ -240,8 +240,8 @@ public void ReUpload_WhenRejected_ReturnsSuccess() ValidUserId, ValidDocumentTypeId, ValidIssueDate, ValidExpirationDate, ValidCriticity, ValidFileStoragePath, ValidFileChecksum, ValidActor).Value; document.Reject("Invalid", ValidActor); - var newIssueDate = new DateTime(2025, 1, 1); - var newExpirationDate = new DateTime(2026, 1, 1); + var newIssueDate = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var newExpirationDate = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); var newStoragePath = TextValueObject.Create("/storage/new-doc.pdf"); var newChecksum = "newchecksum789"; @@ -258,8 +258,8 @@ public void ReUpload_WhenValid_ReturnsFailure() ValidUserId, ValidDocumentTypeId, ValidIssueDate, ValidExpirationDate, ValidCriticity, ValidFileStoragePath, ValidFileChecksum, ValidActor).Value; document.Validate(ValidActor); - var newIssueDate = new DateTime(2025, 1, 1); - var newExpirationDate = new DateTime(2026, 1, 1); + var newIssueDate = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var newExpirationDate = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); var newStoragePath = TextValueObject.Create("/storage/new-doc.pdf"); var newChecksum = "newchecksum789"; @@ -277,8 +277,8 @@ public void ReUpload_WithInvalidDates_ReturnsFailure() ValidCriticity, ValidFileStoragePath, ValidFileChecksum, ValidActor).Value; document.Validate(ValidActor); document.Expire(ValidActor); - var newIssueDate = new DateTime(2026, 1, 1); - var newExpirationDate = new DateTime(2025, 1, 1); + var newIssueDate = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var newExpirationDate = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); var newStoragePath = TextValueObject.Create("/storage/new-doc.pdf"); var newChecksum = "newchecksum789"; @@ -296,8 +296,8 @@ public void ReUpload_RaisesDocumentUploadedEvent() ValidCriticity, ValidFileStoragePath, ValidFileChecksum, ValidActor).Value; document.Validate(ValidActor); document.Expire(ValidActor); - var newIssueDate = new DateTime(2025, 1, 1); - var newExpirationDate = new DateTime(2026, 1, 1); + var newIssueDate = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var newExpirationDate = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); var newStoragePath = TextValueObject.Create("/storage/new-doc.pdf"); var newChecksum = "newchecksum789"; diff --git a/src/apps/ums.api/Ums.Domain.Test/Audit/AuditRecord/AuditRecordTests.cs b/src/apps/ums.api/Ums.Domain.Test/Audit/AuditRecord/AuditRecordTests.cs index 2a07d97c..f26b2fb8 100644 --- a/src/apps/ums.api/Ums.Domain.Test/Audit/AuditRecord/AuditRecordTests.cs +++ b/src/apps/ums.api/Ums.Domain.Test/Audit/AuditRecord/AuditRecordTests.cs @@ -167,7 +167,9 @@ public void Record_WithoutMetadata_SetsMetadataToNull() [Fact] public void Record_HasNoMutationMethods_AppendOnly() { - var record = AuditRecord.Record( + // Smoke: el agregado se construye; la aserción verifica la forma del tipo + // (sin métodos de mutación), por lo que la instancia se descarta. + _ = AuditRecord.Record( ValidWhoActed, ValidSubjectType, ValidWhatChanged, diff --git a/src/apps/ums.api/Ums.Domain.Test/Authorization/Profile/ProfileTests.cs b/src/apps/ums.api/Ums.Domain.Test/Authorization/Profile/ProfileTests.cs index 284550b1..5f5f9f1d 100644 --- a/src/apps/ums.api/Ums.Domain.Test/Authorization/Profile/ProfileTests.cs +++ b/src/apps/ums.api/Ums.Domain.Test/Authorization/Profile/ProfileTests.cs @@ -145,6 +145,35 @@ public void AssignTemplate_WithValidTemplate_ReturnsSuccess() Assert.NotEmpty(profile.Permissions); } + [Fact] + public void AssignTemplate_NoMaterializaLosItemsRetirados() + { + // ADR-0164, la mitad que de verdad importa: retirar dejó de borrar la fila, así que + // `template.Items` ahora incluye lo retirado. Si la materialización siguiera leyéndola, la + // concesión retirada se copiaría al perfil como permiso ACTIVO y el grafo concedería por + // ella. Habríamos cambiado un borrado por una brecha. + var profile = Profile.Create(ValidTenantId, ValidUserId, ValidRoleId, ValidBranchId, ValidActor).Value; + var template = PermissionTemplate.Create(ValidTenantId, ValidRoleId, SystemSuiteId.Load(Guid.NewGuid().ToString()), ValidActor).Value; + + var vigente = IdValueObject.Create(); + var retirado = IdValueObject.Create(); + template.AddItem(ExclusiveArcTarget.Module, vigente, ActionId.Load(Guid.NewGuid().ToString()), true, false, ValidActor); + template.AddItem(ExclusiveArcTarget.Module, retirado, ActionId.Load(Guid.NewGuid().ToString()), true, false, ValidActor); + + var itemRetirado = template.Items.Single(i => i.TargetId.GetValue() == retirado.GetValue()); + Assert.True(template.DeactivateItem(itemRetirado.GetId(), ValidActor).IsSuccess); + Assert.True(template.Publish(ValidActor).IsSuccess); + + var result = profile.AssignTemplate(template, ValidActor); + + Assert.True(result.IsSuccess); + // La plantilla conserva las dos filas; el perfil recibe UNA sola concesión. + Assert.Equal(2, template.Items.Count); + Assert.Single(profile.Permissions); + Assert.Equal(vigente.GetValue(), profile.Permissions.Single().TargetId.GetValue()); + Assert.DoesNotContain(profile.Permissions, p => p.TargetId.GetValue() == retirado.GetValue()); + } + [Fact] public void AssignTemplate_WhenProfileInactive_ReturnsFailure() { @@ -236,8 +265,91 @@ public void Deactivate_CascadesToAllActivePermissions() #endregion + #region ChangeRole (ADR-UMS-096: efecto de la promoción de rol IGA) + + [Fact] + public void ChangeRole_WhenActiveAndDifferentRole_ReturnsSuccessAndReassigns() + { + var profile = Profile.Create(ValidTenantId, ValidUserId, ValidRoleId, ValidBranchId, ValidActor).Value; + var newRoleId = RoleId.Load(Guid.NewGuid().ToString()); + + var result = profile.ChangeRole(newRoleId, ValidActor); + + Assert.True(result.IsSuccess); + Assert.Equal(newRoleId.GetValue(), profile.RoleId.GetValue()); + } + + [Fact] + public void ChangeRole_RaisesProfileRoleChangedEvent() + { + var profile = Profile.Create(ValidTenantId, ValidUserId, ValidRoleId, ValidBranchId, ValidActor).Value; + var newRoleId = RoleId.Load(Guid.NewGuid().ToString()); + + profile.ChangeRole(newRoleId, ValidActor); + + var events = profile.DomainEvents.GetUncommittedChanges().ToList(); + var changed = Assert.IsType(Assert.Single(events, e => e is ProfileRoleChangedEvent)); + Assert.Equal(ValidRoleId.GetValue(), changed.PreviousRoleId); + Assert.Equal(newRoleId.GetValue(), changed.NewRoleId); + } + + [Fact] + public void ChangeRole_WhenSameRole_ReturnsFailure() + { + var profile = Profile.Create(ValidTenantId, ValidUserId, ValidRoleId, ValidBranchId, ValidActor).Value; + + var result = profile.ChangeRole(ValidRoleId, ValidActor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Authorization.ProfileRoleUnchanged, result.Error); + Assert.Equal(ValidRoleId.GetValue(), profile.RoleId.GetValue()); + } + + [Fact] + public void ChangeRole_WhenProfileInactive_ReturnsFailure() + { + var profile = Profile.Create(ValidTenantId, ValidUserId, ValidRoleId, ValidBranchId, ValidActor).Value; + profile.Deactivate(ValidActor); + var newRoleId = RoleId.Load(Guid.NewGuid().ToString()); + + var result = profile.ChangeRole(newRoleId, ValidActor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Authorization.ProfileAlreadyInactive, result.Error); + } + + #endregion + #region Permission Overrides + // AT06/F1 (regresión): el override de un permiso REAL debe resolverse por su identidad canónica + // (Props.Id, la que expone el read-model vía GetId()). El Id base de Entity<> se regenera aleatorio + // en construcción — distinto de Props.Id ya desde Create — y la rehidratación no llama SetId, así + // que FindPermission buscando por el Id base devolvía PermissionNotFound (404 en la API: override/ + // activate/deactivate inalcanzables). Los tests previos solo cubrían casos negativos (id falso), + // por eso el bug pasó. Este test ejercita el camino positivo con el id canónico del permiso. + [Fact] + public void OverridePermissionDeny_WithCanonicalPermissionId_Succeeds() + { + var profile = Profile.Create(ValidTenantId, ValidUserId, ValidRoleId, ValidBranchId, ValidActor).Value; + var template = PermissionTemplate.Create(ValidTenantId, ValidRoleId, SystemSuiteId.Load(Guid.NewGuid().ToString()), ValidActor).Value; + template.AddItem(ExclusiveArcTarget.Module, IdValueObject.Create(), ActionId.Load(Guid.NewGuid().ToString()), true, false, ValidActor); + template.Publish(ValidActor); + profile.AssignTemplate(template, ValidActor); + + var permission = profile.Permissions.First(); + var canonicalId = permission.GetId(); // == Props.Id (lo que expone el DTO) + + // Documenta la causa raíz: el Id base diverge de la identidad canónica. + Assert.NotEqual(permission.Id.GetValue(), canonicalId.GetValue()); + + var result = profile.OverridePermissionDeny(canonicalId, ValidActor); + + Assert.True(result.IsSuccess); + Assert.True(permission.IsDenied); + Assert.True(permission.IsOverride); + } + [Fact] public void OverridePermissionAllow_WhenProfileInactive_ReturnsFailure() { diff --git a/src/apps/ums.api/Ums.Domain.Test/Authorization/SeedData/AuthorizationSeedDataTests.cs b/src/apps/ums.api/Ums.Domain.Test/Authorization/SeedData/AuthorizationSeedDataTests.cs index 7bd442f5..9abb2784 100644 --- a/src/apps/ums.api/Ums.Domain.Test/Authorization/SeedData/AuthorizationSeedDataTests.cs +++ b/src/apps/ums.api/Ums.Domain.Test/Authorization/SeedData/AuthorizationSeedDataTests.cs @@ -1,10 +1,13 @@ namespace Ums.Domain.Test.Authorization.SeedData; using Ums.Domain.Authorization.SystemSuite; +using Ums.Domain.Authorization.SystemSuite.MenuNode; using Ums.Domain.Authorization.Template; using Ums.Domain.Kernel.ValueObjects; using Ums.Domain.Enums; using Xunit; +using ModuleEntity = Ums.Domain.Authorization.SystemSuite.Module.Module; +using MenuNodeEntity = Ums.Domain.Authorization.SystemSuite.MenuNode.MenuNode; public class AuthorizationSeedDataTests { @@ -28,36 +31,53 @@ public void BuildSystemSuite_WithModules_ReturnsNonEmptyModules() } [Fact] - public void BuildModule_WithMenus_ReturnsNonEmptyMenus() + public void BuildModule_WithRootNodes_ReturnsNonEmptyNodes() { var suite = CreateSuiteWithModule("MOD1", out var module); - module.AddMenu(Code.Create("MENU1"), Name.Create("Menu 1"), Description.Create("Desc"), 1, TestActor); - module.AddMenu(Code.Create("MENU2"), Name.Create("Menu 2"), Description.Create("Desc"), 2, TestActor); + suite.AddModuleRootNode(module.Props.Id, NodeKind.Menu, Code.Create("MENU1"), Name.Create("Menu 1"), Description.Create("Desc"), 1, TestActor); + suite.AddModuleRootNode(module.Props.Id, NodeKind.Menu, Code.Create("MENU2"), Name.Create("Menu 2"), Description.Create("Desc"), 2, TestActor); - Assert.Equal(2, module.Menus.Count); + Assert.Equal(2, module.Nodes.Count); } [Fact] - public void BuildMenu_WithSubMenus_ReturnsNonEmptySubMenus() + public void BuildMenuNode_WithSubMenuChildren_ReturnsNonEmptyChildren() { - var suite = CreateSuiteWithModuleAndMenu("MOD1", "MENU1", out var menu); + var suite = CreateSuiteWithModuleAndMenu("MOD1", "MENU1", out var module, out var menuNode); - menu.AddSubMenu(Code.Create("SUB1"), Name.Create("Sub 1"), Description.Create("Desc"), 1, TestActor); - menu.AddSubMenu(Code.Create("SUB2"), Name.Create("Sub 2"), Description.Create("Desc"), 2, TestActor); + suite.AddModuleChildNode(module.Props.Id, menuNode.GetId(), NodeKind.SubMenu, Code.Create("SUB1"), Name.Create("Sub 1"), Description.Create("Desc"), 1, TestActor); + suite.AddModuleChildNode(module.Props.Id, menuNode.GetId(), NodeKind.SubMenu, Code.Create("SUB2"), Name.Create("Sub 2"), Description.Create("Desc"), 2, TestActor); - Assert.Equal(2, menu.SubMenus.Count); + Assert.Equal(2, menuNode.Children.Count); } [Fact] - public void BuildSubMenu_WithOptions_ReturnsNonEmptyOptions() + public void BuildSubMenuNode_WithOptionChildren_ReturnsNonEmptyChildren() { - var suite = CreateSuiteWithModuleMenuAndSubMenu("MOD1", "MENU1", "SUB1", out var subMenu); + var suite = CreateSuiteWithModuleMenuAndSubMenu("MOD1", "MENU1", "SUB1", out var module, out var subNode); - subMenu.AddOption(Code.Create("OPT1"), Name.Create("Opt 1"), Description.Create("Desc"), ActionCode.Create("READ"), 1, TestActor); - subMenu.AddOption(Code.Create("OPT2"), Name.Create("Opt 2"), Description.Create("Desc"), ActionCode.Create("CREATE"), 2, TestActor); + suite.AddModuleChildNode(module.Props.Id, subNode.GetId(), NodeKind.Option, Code.Create("OPT1"), Name.Create("Opt 1"), Description.Create("Desc"), 1, TestActor); + suite.AddModuleChildNode(module.Props.Id, subNode.GetId(), NodeKind.Option, Code.Create("OPT2"), Name.Create("Opt 2"), Description.Create("Desc"), 2, TestActor); - Assert.Equal(2, subMenu.Options.Count); + Assert.Equal(2, subNode.Children.Count); + } + + [Fact] + public void LinkNodeAction_OnOptionLeaf_RecordsActionCodesNm() + { + var suite = CreateSuiteWithModuleMenuAndSubMenu("MOD1", "MENU1", "SUB1", out var module, out var subNode); + suite.AddModuleChildNode(module.Props.Id, subNode.GetId(), NodeKind.Option, Code.Create("OPT1"), Name.Create("Opt 1"), Description.Create("Desc"), 1, TestActor); + var option = subNode.Children.First(); + + // G-046: an action must be registered in the suite catalog before it can be linked to a node. + suite.RegisterAction(ActionCode.Create("READ"), Name.Create("Read"), TestActor); + suite.RegisterAction(ActionCode.Create("CREATE"), Name.Create("Create"), TestActor); + + suite.LinkModuleNodeAction(module.Props.Id, option.GetId(), ActionCode.Create("READ"), TestActor); + suite.LinkModuleNodeAction(module.Props.Id, option.GetId(), ActionCode.Create("CREATE"), TestActor); + + Assert.Equal(2, option.ActionCodes.Count); } [Fact] @@ -93,22 +113,22 @@ public void FullHierarchy_SuiteHasAllLevels_ReturnsCompleteStructure() var module = suite.Modules.First(); suite.ActivateModule(module.Id, TestActor); - module.AddMenu(Code.Create("USERS"), Name.Create("Users"), Description.Create("User management"), 1, TestActor); - var menu = module.Menus.First(); + suite.AddModuleRootNode(module.Props.Id, NodeKind.Menu, Code.Create("USERS"), Name.Create("Users"), Description.Create("User management"), 1, TestActor); + var menuNode = module.Nodes.First(); - menu.AddSubMenu(Code.Create("USER_LIST"), Name.Create("User List"), Description.Create("List users"), 1, TestActor); - var subMenu = menu.SubMenus.First(); + suite.AddModuleChildNode(module.Props.Id, menuNode.GetId(), NodeKind.SubMenu, Code.Create("USER_LIST"), Name.Create("User List"), Description.Create("List users"), 1, TestActor); + var subNode = menuNode.Children.First(); - subMenu.AddOption(Code.Create("USER_VIEW"), Name.Create("View Users"), Description.Create("View"), ActionCode.Create("READ"), 1, TestActor); - subMenu.AddOption(Code.Create("USER_CREATE"), Name.Create("Create User"), Description.Create("Create"), ActionCode.Create("CREATE"), 2, TestActor); + suite.AddModuleChildNode(module.Props.Id, subNode.GetId(), NodeKind.Option, Code.Create("USER_VIEW"), Name.Create("View Users"), Description.Create("View"), 1, TestActor); + suite.AddModuleChildNode(module.Props.Id, subNode.GetId(), NodeKind.Option, Code.Create("USER_CREATE"), Name.Create("Create User"), Description.Create("Create"), 2, TestActor); suite.RegisterAction(ActionCode.Create("READ"), Name.Create("Read"), TestActor); suite.RegisterAction(ActionCode.Create("CREATE"), Name.Create("Create"), TestActor); Assert.Single(suite.Modules); - Assert.Single(suite.Modules.First().Menus); - Assert.Single(suite.Modules.First().Menus.First().SubMenus); - Assert.Equal(2, suite.Modules.First().Menus.First().SubMenus.First().Options.Count); + Assert.Single(module.Nodes); + Assert.Single(menuNode.Children); + Assert.Equal(2, subNode.Children.Count); Assert.Equal(2, suite.Actions.Count); } @@ -148,14 +168,14 @@ public void PermissionTemplate_WithMultipleTargetTypes_CoversAllArcLevels() TestActor).Value; var module = suite.Modules.First(); - var menu = module.Menus.First(); - var subMenu = menu.SubMenus.First(); - var option = subMenu.Options.First(); + var menuNode = module.Nodes.First(); + var subNode = menuNode.Children.First(); + var option = subNode.Children.First(); template.AddItem(ExclusiveArcTarget.SystemSuite, suite.GetId(), readActionId, true, false, TestActor); template.AddItem(ExclusiveArcTarget.Module, module.Id, createActionId, true, false, TestActor); - template.AddItem(ExclusiveArcTarget.Submodule, subMenu.Id, readActionId, true, false, TestActor); - template.AddItem(ExclusiveArcTarget.Option, option.Id, createActionId, false, true, TestActor); + template.AddItem(ExclusiveArcTarget.Submodule, subNode.GetId(), readActionId, true, false, TestActor); + template.AddItem(ExclusiveArcTarget.Option, option.GetId(), createActionId, false, true, TestActor); Assert.Equal(4, template.Items.Count); Assert.Contains(template.Items, i => i.TargetType == ExclusiveArcTarget.SystemSuite); @@ -226,7 +246,7 @@ public void PermissionTemplate_WithInactiveItem_HasDeactivatedEntry() template.AddItem(ExclusiveArcTarget.Module, module.Id, readActionId, true, false, TestActor); var item = template.Items.First(); - template.DeactivateItem(item.Id, TestActor); + template.DeactivateItem(item.GetId(), TestActor); Assert.False(template.Items.First().IsActive); } @@ -244,7 +264,6 @@ public void SystemTemplateCoherence_TemplateActionIdsExistInSystemSuite() TestActor).Value; var module = suite.Modules.First(); - var subMenu = module.Menus.First().SubMenus.First(); foreach (var action in suite.Actions.Take(5)) { @@ -262,8 +281,9 @@ public void SystemTemplateCoherence_TemplateTargetIdsExistInSystemSuite() { var suite = BuildCompleteSuite(); var moduleIds = suite.Modules.Select(m => m.Id.GetValue()).ToHashSet(); - var subMenuIds = suite.Modules.SelectMany(m => m.Menus.SelectMany(mn => mn.SubMenus)).Select(sm => sm.Id.GetValue()).ToHashSet(); - var optionIds = suite.Modules.SelectMany(m => m.Menus.SelectMany(mn => mn.SubMenus.SelectMany(sm => sm.Options))).Select(o => o.Id.GetValue()).ToHashSet(); + var allNodes = suite.Modules.SelectMany(m => FlattenNodes(m.Nodes)).ToList(); + var subMenuIds = allNodes.Where(n => n.Kind == NodeKind.SubMenu).Select(n => n.GetId().GetValue()).ToHashSet(); + var optionIds = allNodes.Where(n => n.Kind == NodeKind.Option).Select(n => n.GetId().GetValue()).ToHashSet(); var template = PermissionTemplate.Create( TestTenantId, @@ -273,12 +293,12 @@ public void SystemTemplateCoherence_TemplateTargetIdsExistInSystemSuite() var readActionId = suite.Actions.First(a => a.Code.GetValue() == "READ").GetId(); var module = suite.Modules.First(); - var subMenu = module.Menus.First().SubMenus.First(); - var option = subMenu.Options.First(); + var subNode = module.Nodes.First().Children.First(); + var option = subNode.Children.First(); template.AddItem(ExclusiveArcTarget.Module, module.Id, readActionId, true, false, TestActor); - template.AddItem(ExclusiveArcTarget.Submodule, subMenu.Id, readActionId, true, false, TestActor); - template.AddItem(ExclusiveArcTarget.Option, option.Id, readActionId, true, false, TestActor); + template.AddItem(ExclusiveArcTarget.Submodule, subNode.GetId(), readActionId, true, false, TestActor); + template.AddItem(ExclusiveArcTarget.Option, option.GetId(), readActionId, true, false, TestActor); foreach (var item in template.Items) { @@ -408,7 +428,19 @@ public void SeedData_WmsCustomActions_AllRegistered() } } - private static SystemSuite CreateSuiteWithModule(string moduleCode, out Ums.Domain.Authorization.SystemSuite.Module.Module module) + private static IEnumerable FlattenNodes(IEnumerable nodes) + { + foreach (var node in nodes) + { + yield return node; + foreach (var child in FlattenNodes(node.Children)) + { + yield return child; + } + } + } + + private static SystemSuite CreateSuiteWithModule(string moduleCode, out ModuleEntity module) { var suite = SystemSuite.Create( TestTenantId, @@ -423,19 +455,19 @@ private static SystemSuite CreateSuiteWithModule(string moduleCode, out Ums.Doma return suite; } - private static SystemSuite CreateSuiteWithModuleAndMenu(string moduleCode, string menuCode, out Ums.Domain.Authorization.SystemSuite.Menu.Menu menu) + private static SystemSuite CreateSuiteWithModuleAndMenu(string moduleCode, string menuCode, out ModuleEntity module, out MenuNodeEntity menuNode) { - var suite = CreateSuiteWithModule(moduleCode, out var module); - module.AddMenu(Code.Create(menuCode), Name.Create(menuCode), Description.Create("Desc"), 1, TestActor); - menu = module.Menus.First(); + var suite = CreateSuiteWithModule(moduleCode, out module); + suite.AddModuleRootNode(module.Props.Id, NodeKind.Menu, Code.Create(menuCode), Name.Create(menuCode), Description.Create("Desc"), 1, TestActor); + menuNode = module.Nodes.First(); return suite; } - private static SystemSuite CreateSuiteWithModuleMenuAndSubMenu(string moduleCode, string menuCode, string subMenuCode, out Ums.Domain.Authorization.SystemSuite.SubMenu.SubMenu subMenu) + private static SystemSuite CreateSuiteWithModuleMenuAndSubMenu(string moduleCode, string menuCode, string subMenuCode, out ModuleEntity module, out MenuNodeEntity subNode) { - var suite = CreateSuiteWithModuleAndMenu(moduleCode, menuCode, out var menu); - menu.AddSubMenu(Code.Create(subMenuCode), Name.Create(subMenuCode), Description.Create("Desc"), 1, TestActor); - subMenu = menu.SubMenus.First(); + var suite = CreateSuiteWithModuleAndMenu(moduleCode, menuCode, out module, out var menuNode); + suite.AddModuleChildNode(module.Props.Id, menuNode.GetId(), NodeKind.SubMenu, Code.Create(subMenuCode), Name.Create(subMenuCode), Description.Create("Desc"), 1, TestActor); + subNode = menuNode.Children.First(); return suite; } @@ -452,13 +484,13 @@ private static SystemSuite BuildCompleteSuite() var module = suite.Modules.First(); suite.ActivateModule(module.Id, TestActor); - module.AddMenu(Code.Create("MENU1"), Name.Create("Menu 1"), Description.Create("Desc"), 1, TestActor); - var menu = module.Menus.First(); + suite.AddModuleRootNode(module.Props.Id, NodeKind.Menu, Code.Create("MENU1"), Name.Create("Menu 1"), Description.Create("Desc"), 1, TestActor); + var menuNode = module.Nodes.First(); - menu.AddSubMenu(Code.Create("SUB1"), Name.Create("Sub 1"), Description.Create("Desc"), 1, TestActor); - var subMenu = menu.SubMenus.First(); + suite.AddModuleChildNode(module.Props.Id, menuNode.GetId(), NodeKind.SubMenu, Code.Create("SUB1"), Name.Create("Sub 1"), Description.Create("Desc"), 1, TestActor); + var subNode = menuNode.Children.First(); - subMenu.AddOption(Code.Create("OPT1"), Name.Create("Opt 1"), Description.Create("Desc"), ActionCode.Create("READ"), 1, TestActor); + suite.AddModuleChildNode(module.Props.Id, subNode.GetId(), NodeKind.Option, Code.Create("OPT1"), Name.Create("Opt 1"), Description.Create("Desc"), 1, TestActor); suite.RegisterAction(ActionCode.Create("READ"), Name.Create("Read"), TestActor); suite.RegisterAction(ActionCode.Create("CREATE"), Name.Create("Create"), TestActor); diff --git a/src/apps/ums.api/Ums.Domain.Test/Authorization/SystemSuite/MenuNode/MenuNodeMetadataEqualityTests.cs b/src/apps/ums.api/Ums.Domain.Test/Authorization/SystemSuite/MenuNode/MenuNodeMetadataEqualityTests.cs new file mode 100644 index 00000000..6fb45ac4 --- /dev/null +++ b/src/apps/ums.api/Ums.Domain.Test/Authorization/SystemSuite/MenuNode/MenuNodeMetadataEqualityTests.cs @@ -0,0 +1,101 @@ +namespace Ums.Domain.Test.Authorization.SystemSuite.MenuNode; + +using Ums.Domain.Authorization.SystemSuite.MenuNode; +using Xunit; + +/// +/// G-055: es un value object inmutable. Estas pruebas +/// fijan su igualdad por valor sobre los 7 campos normalizados (contenido igual ⇒ +/// iguales, hash consistente) y su uso correcto en HashSet/Dictionary. +/// +public class MenuNodeMetadataEqualityTests +{ + [Fact] + public void SameContent_AreEqualAndShareHash() + { + var a = MenuNodeMetadata.Create( + responsable: "Ana", + criticidad: "Alta", + productoImpactado: "UMS", + componenteTecnico: "Auth", + dependencias: "ninguna", + evidencias: "PR-80", + trazabilidadSdlc: "ADR-0090"); + + var b = MenuNodeMetadata.Create( + responsable: "Ana", + criticidad: "Alta", + productoImpactado: "UMS", + componenteTecnico: "Auth", + dependencias: "ninguna", + evidencias: "PR-80", + trazabilidadSdlc: "ADR-0090"); + + Assert.True(a.Equals(b)); + Assert.Equal(a, b); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + Assert.False(ReferenceEquals(a, b)); + } + + [Fact] + public void NormalizationMakesWhitespaceEquivalent() + { + var trimmed = MenuNodeMetadata.Create(responsable: "Ana"); + var padded = MenuNodeMetadata.Create(responsable: " Ana "); + + // Normalize() recorta, así que ambos representan el mismo valor. + Assert.Equal(trimmed, padded); + Assert.Equal(trimmed.GetHashCode(), padded.GetHashCode()); + } + + [Fact] + public void EmptyEqualsCreateWithoutArguments() + { + Assert.Equal(MenuNodeMetadata.Empty, MenuNodeMetadata.Create()); + Assert.Equal(MenuNodeMetadata.Empty.GetHashCode(), MenuNodeMetadata.Create().GetHashCode()); + } + + [Fact] + public void DifferentContent_AreNotEqual() + { + var a = MenuNodeMetadata.Create(responsable: "Ana"); + var b = MenuNodeMetadata.Create(responsable: "Beto"); + + Assert.NotEqual(a, b); + Assert.False(a.Equals(b)); + } + + [Fact] + public void DifferingOnlyInOneField_AreNotEqual() + { + var a = MenuNodeMetadata.Create(responsable: "Ana", criticidad: "Alta"); + var b = MenuNodeMetadata.Create(responsable: "Ana", criticidad: "Media"); + + Assert.NotEqual(a, b); + } + + [Fact] + public void NullAndOtherType_AreNotEqual() + { + var a = MenuNodeMetadata.Create(responsable: "Ana"); + + Assert.False(a.Equals(null)); + Assert.False(a.Equals("Ana")); + } + + [Fact] + public void WorksAsHashSetElementAndDictionaryKey() + { + var a1 = MenuNodeMetadata.Create(responsable: "Ana", criticidad: "Alta"); + var a2 = MenuNodeMetadata.Create(responsable: "Ana", criticidad: "Alta"); + var other = MenuNodeMetadata.Create(responsable: "Beto"); + + var set = new HashSet { a1, a2, other }; + Assert.Equal(2, set.Count); + Assert.Contains(a2, set); + + var map = new Dictionary { [a1] = "x" }; + Assert.True(map.TryGetValue(a2, out var found)); + Assert.Equal("x", found); + } +} diff --git a/src/apps/ums.api/Ums.Domain.Test/Authorization/SystemSuite/PresentacionDeNavegacionTests.cs b/src/apps/ums.api/Ums.Domain.Test/Authorization/SystemSuite/PresentacionDeNavegacionTests.cs new file mode 100644 index 00000000..ae170ea8 --- /dev/null +++ b/src/apps/ums.api/Ums.Domain.Test/Authorization/SystemSuite/PresentacionDeNavegacionTests.cs @@ -0,0 +1,111 @@ +namespace Ums.Domain.Test.Authorization.SystemSuite; + +using Ums.Domain.Authorization.SystemSuite; +using Ums.Domain.Authorization.SystemSuite.MenuNode; +using Xunit; + +/// +/// La presentación de la navegación —icono del módulo (G-182) e icono/ruta de los nodos (D-028)— +/// tiene que sobrevivir al alta. Estas pruebas existen porque no sobrevivía: el módulo aceptaba una +/// presentación para sus nodos raíz y la descartaba en silencio al crearlos, de modo que TODOS los +/// menús de primer nivel —que son justo los que pinta la barra— quedaban sin icono y sin ruta. +/// +public class PresentacionDeNavegacionTests +{ + private static readonly ActorId Actor = ActorId.Create("test-user"); + + private static (SystemSuite Suite, IdValueObject ModuleId) SuiteConModulo(string? icono = null) + { + var suite = SystemSuite.Create( + TenantId.Load(Guid.NewGuid().ToString()), + Code.Create("WMS"), + Name.Create("Almacén"), + Description.Create("Sistema de almacén"), + Actor).Value; + + suite.AddModule(Code.Create("INV"), Name.Create("Inventario"), Description.Create("Inventario"), 1, Actor, icono); + var moduleId = suite.Modules.First().Props.Id; + suite.ActivateModule(moduleId, Actor); + return (suite, moduleId); + } + + [Fact] + public void El_modulo_conserva_el_icono_con_el_que_se_dio_de_alta() + { + var (suite, _) = SuiteConModulo("package"); + + Assert.Equal("package", suite.Modules.First().Icon); + } + + [Fact] + public void Un_modulo_sin_icono_no_inventa_ninguno() + { + // Nulo y no una cadena por defecto: el cliente debe poder distinguir «sin configurar» + // de «configurado a algo», y elegir su respaldo. + var (suite, _) = SuiteConModulo(); + + Assert.Null(suite.Modules.First().Icon); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void El_icono_en_blanco_equivale_a_no_tener_icono(string icono) + { + var (suite, _) = SuiteConModulo(icono); + + Assert.Null(suite.Modules.First().Icon); + } + + [Fact] + public void El_icono_del_modulo_se_puede_cambiar_y_borrar() + { + var (suite, moduleId) = SuiteConModulo("package"); + + suite.SetModuleIcon(moduleId, " truck ", Actor); + Assert.Equal("truck", suite.Modules.First().Icon); + + suite.SetModuleIcon(moduleId, null, Actor); + Assert.Null(suite.Modules.First().Icon); + } + + [Fact] + public void El_nodo_raiz_conserva_su_icono_y_su_ruta() + { + // La regresión que estas pruebas cierran: el nodo raíz recibía la presentación y la perdía, + // así que el menú que ve el usuario se quedaba sin icono y sin destino. + var (suite, moduleId) = SuiteConModulo("package"); + + suite.AddModuleRootNode( + moduleId, + NodeKind.Menu, + Code.Create("STOCK"), + Name.Create("Stock"), + Description.Create("Stock"), + 1, + Actor, + presentation: MenuNodePresentation.Create("package", "/stock")); + + var nodo = suite.Modules.First().Nodes.First(); + Assert.Equal("package", nodo.Props.Presentation.Icon); + Assert.Equal("/stock", nodo.Props.Presentation.Route); + } + + [Fact] + public void El_nodo_hijo_conserva_su_icono_y_su_ruta() + { + var (suite, moduleId) = SuiteConModulo(); + + suite.AddModuleRootNode(moduleId, NodeKind.Menu, Code.Create("STOCK"), Name.Create("Stock"), + Description.Create("Stock"), 1, Actor); + var menuId = suite.Modules.First().Nodes.First().GetId(); + + suite.AddModuleChildNode(moduleId, menuId, NodeKind.Option, Code.Create("STOCK_VIEW"), + Name.Create("Ver Stock"), Description.Create("Ver Stock"), 1, Actor, + presentation: MenuNodePresentation.Create(null, "/stock/ver")); + + var hijo = suite.Modules.First().Nodes.First().Children.First(); + Assert.Null(hijo.Props.Presentation.Icon); + Assert.Equal("/stock/ver", hijo.Props.Presentation.Route); + } +} diff --git a/src/apps/ums.api/Ums.Domain.Test/Authorization/SystemSuite/SystemSuiteTests.cs b/src/apps/ums.api/Ums.Domain.Test/Authorization/SystemSuite/SystemSuiteTests.cs index f09222ee..a3182e12 100644 --- a/src/apps/ums.api/Ums.Domain.Test/Authorization/SystemSuite/SystemSuiteTests.cs +++ b/src/apps/ums.api/Ums.Domain.Test/Authorization/SystemSuite/SystemSuiteTests.cs @@ -2,6 +2,7 @@ namespace Ums.Domain.Test.Authorization.SystemSuite; using Ums.Domain.Authorization.SystemSuite; using Ums.Domain.Authorization.SystemSuite.DomainResource; +using Ums.Domain.Authorization.SystemSuite.MenuNode; using Xunit; public class SystemSuiteTests @@ -96,6 +97,109 @@ public void SetStatus_RaisesSystemSuiteStatusChangedEvent() #endregion + #region Delete (eliminación lógica, G-246) + + [Fact] + public void Delete_WhenStillInService_ReturnsFailure() + { + var suite = SystemSuite.Create(ValidTenantId, ValidCode, ValidName, ValidDescription, ValidActor).Value; + + var result = suite.Delete(SystemSuiteDependents.None, ValidActor); + + Assert.True(result.IsFailure); + Assert.Equal(DomainErrors.Authorization.SystemSuiteNotDeprecated, result.Error); + } + + [Fact] + public void Delete_WhenDeprecatedWithoutLiveReferences_MarksDeletedAndRaisesEvent() + { + var suite = SystemSuite.Create(ValidTenantId, ValidCode, ValidName, ValidDescription, ValidActor).Value; + // Su propia composición no cuenta como referencia externa: se oculta con el agregado. + suite.AddModule(Code.Create("MOD-001"), ValidName, ValidDescription, 1, ValidActor); + suite.SetStatus(SystemStatus.Deprecated, ValidActor); + + var result = suite.Delete(SystemSuiteDependents.None, ValidActor); + + Assert.True(result.IsSuccess); + // La prueba de que es borrado LÓGICO en el plano del dominio: el agregado sigue existiendo, + // con su composición intacta, y lo único que cambió es el estado. + Assert.Equal(SystemStatus.Deleted, suite.Status); + Assert.Single(suite.Modules); + Assert.Contains(suite.DomainEvents.GetUncommittedChanges(), e => e is SystemSuiteDeletedEvent); + } + + [Fact] + public void Delete_WithLiveReferences_ReturnsFailureAndLeavesStatusUntouched() + { + var suite = SystemSuite.Create(ValidTenantId, ValidCode, ValidName, ValidDescription, ValidActor).Value; + suite.SetStatus(SystemStatus.Deprecated, ValidActor); + + var result = suite.Delete(SystemSuiteDependents.None with { Roles = 1 }, ValidActor); + + Assert.True(result.IsFailure); + Assert.Equal(DomainErrors.Authorization.SystemSuiteHasDependents, result.Error); + Assert.Equal(SystemStatus.Deprecated, suite.Status); + Assert.DoesNotContain(suite.DomainEvents.GetUncommittedChanges(), e => e is SystemSuiteDeletedEvent); + } + + [Fact] + public void Delete_WithReferenceAlreadyLogicallyDeleted_Succeeds() + { + var suite = SystemSuite.Create(ValidTenantId, ValidCode, ValidName, ValidDescription, ValidActor).Value; + suite.SetStatus(SystemStatus.Deprecated, ValidActor); + + // El conteo llega ya depurado desde el repositorio: lo eliminado lógicamente no se cuenta, + // así que el dominio ve cero referencias vivas y deja eliminar. Es la otra mitad de la regla + // de cascada — la que evita que un sistema quede bloqueado para siempre por lápidas. + var result = suite.Delete(SystemSuiteDependents.None, ValidActor); + + Assert.True(result.IsSuccess); + Assert.Equal(SystemStatus.Deleted, suite.Status); + } + + [Fact] + public void Delete_WhenAlreadyDeleted_ReturnsFailure() + { + var suite = SystemSuite.Create(ValidTenantId, ValidCode, ValidName, ValidDescription, ValidActor).Value; + suite.SetStatus(SystemStatus.Deprecated, ValidActor); + suite.Delete(SystemSuiteDependents.None, ValidActor); + + var result = suite.Delete(SystemSuiteDependents.None, ValidActor); + + Assert.True(result.IsFailure); + Assert.Equal(DomainErrors.Authorization.SystemSuiteAlreadyDeleted, result.Error); + } + + [Fact] + public void SetStatus_ToDeleted_ReturnsFailure() + { + // Si esto se permitiera, `PUT /status {"status":"Deleted"}` eliminaría el sistema sin pasar + // por la guarda de cascada. La puerta de la eliminación es una sola. + var suite = SystemSuite.Create(ValidTenantId, ValidCode, ValidName, ValidDescription, ValidActor).Value; + + var result = suite.SetStatus(SystemStatus.Deleted, ValidActor); + + Assert.True(result.IsFailure); + Assert.Equal(DomainErrors.Authorization.SystemSuiteDeletedNotSettable, result.Error); + Assert.Equal(SystemStatus.Active, suite.Status); + } + + [Fact] + public void SetStatus_OnDeletedSuite_ReturnsFailure() + { + var suite = SystemSuite.Create(ValidTenantId, ValidCode, ValidName, ValidDescription, ValidActor).Value; + suite.SetStatus(SystemStatus.Deprecated, ValidActor); + suite.Delete(SystemSuiteDependents.None, ValidActor); + + var result = suite.SetStatus(SystemStatus.Active, ValidActor); + + Assert.True(result.IsFailure); + Assert.Equal(DomainErrors.Authorization.SystemSuiteDeletedIsTerminal, result.Error); + Assert.Equal(SystemStatus.Deleted, suite.Status); + } + + #endregion + #region AddModule [Fact] @@ -198,8 +302,19 @@ public void RemoveModule_WhenModuleHasActiveMenus_ReturnsFailure() var moduleDescription = Description.Create("A test module"); suite.AddModule(moduleCode, moduleName, moduleDescription, 1, ValidActor); var moduleId = suite.Modules.First().GetId(); + // El agregado cuenta sus propios nodos-menú activos (G-154): módulo activo + un nodo Menú + // (nace Active) → la remoción debe fallar por ModuleHasActiveMenus. + suite.ActivateModule(moduleId, ValidActor); + suite.AddModuleRootNode( + moduleId, + NodeKind.Menu, + Code.Create("MENU-001"), + Name.Create("Menú Activo"), + Description.Create("Un menú activo del módulo"), + 1, + ValidActor); - var result = suite.RemoveModule(moduleId, ValidActor, activeMenuCount: 1); + var result = suite.RemoveModule(moduleId, ValidActor); Assert.True(result.IsFailure); Assert.Contains(DomainErrors.Authorization.ModuleHasActiveMenus, result.Error); diff --git a/src/apps/ums.api/Ums.Domain.Test/Authorization/Template/PermissionTemplateTests.cs b/src/apps/ums.api/Ums.Domain.Test/Authorization/Template/PermissionTemplateTests.cs index 9b1a93eb..b953a664 100644 --- a/src/apps/ums.api/Ums.Domain.Test/Authorization/Template/PermissionTemplateTests.cs +++ b/src/apps/ums.api/Ums.Domain.Test/Authorization/Template/PermissionTemplateTests.cs @@ -40,6 +40,54 @@ public void Create_RaisesPermissionTemplateCreatedEvent() #endregion + #region CreateNextVersion + + [Fact] + public void CreateNextVersion_WhenNoExistingVersions_UsesInitial() + { + var result = PermissionTemplate.CreateNextVersion( + ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor, Array.Empty()); + + Assert.True(result.IsSuccess); + Assert.Equal("0.1.0", result.Value.Version.GetValue()); + } + + [Fact] + public void CreateNextVersion_WhenNullExistingVersions_UsesInitial() + { + var result = PermissionTemplate.CreateNextVersion( + ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor, null!); + + Assert.True(result.IsSuccess); + Assert.Equal("0.1.0", result.Value.Version.GetValue()); + } + + [Fact] + public void CreateNextVersion_WhenInitialExists_IncrementsMinor() + { + var result = PermissionTemplate.CreateNextVersion( + ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor, + new[] { TemplateVersion.Initial() }); + + Assert.True(result.IsSuccess); + Assert.Equal("0.2.0", result.Value.Version.GetValue()); + } + + [Fact] + public void CreateNextVersion_UsesMaxExistingVersion_NotStringOrder() + { + // 2.0.0 es la máxima real; el orden alfabético colocaría "10.0.0" < "2.0.0" pero + // aquí probamos la comparación numérica por segmento. + var result = PermissionTemplate.CreateNextVersion( + ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor, + new[] { TemplateVersion.Initial(), TemplateVersion.Create(2, 0, 0), TemplateVersion.Create(1, 5, 0) }); + + Assert.True(result.IsSuccess); + Assert.Equal("2.1.0", result.Value.Version.GetValue()); + } + + #endregion + #region Publish [Fact] @@ -205,6 +253,75 @@ public void Delete_RaisesPermissionTemplateDeletedEvent() Assert.Contains(events, e => e is PermissionTemplateDeletedEvent); } + // ── Borrado LÓGICO: el estado terminal es la política, no un efecto colateral ── + + [Fact] + public void Delete_TransicionaAlEstadoTerminalDeleted_YNoDestruyeElAgregado() + { + // Fija la política: eliminar es cambiar de estado, no desaparecer. Los ítems —el rastro de qué + // concesiones otorgó la plantilla— siguen ahí, que es lo que el negocio consulta hacia atrás. + var template = PermissionTemplate.Create(ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor).Value; + template.AddItem(ValidTargetType, ValidTargetId, ValidActionId, true, false, ValidActor); + + var result = template.Delete(ValidActor); + + Assert.True(result.IsSuccess); + Assert.Equal(TemplateStatus.Deleted, template.Status); + Assert.True(template.IsDeleted); + Assert.Single(template.Items); + } + + [Fact] + public void Delete_CuandoYaEstaEliminada_ReturnsFailure() + { + // El estado terminal no es repetible: un segundo borrado es conflicto, no éxito silencioso. + var template = PermissionTemplate.Create(ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor).Value; + template.Delete(ValidActor); + + var result = template.Delete(ValidActor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Authorization.TemplateAlreadyDeleted, result.Error); + } + + [Fact] + public void Delete_ConReferenciaViva_Bloquea_YConLaReferenciaYaEliminada_Permite() + { + // Regla de cascada en su forma más pura: el MISMO agregado se rechaza mientras la referencia + // está viva (activeProfileCount > 0) y se acepta en cuanto esa referencia deja de estarlo. + var template = PermissionTemplate.Create(ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor).Value; + + var bloqueado = template.Delete(ValidActor, activeProfileCount: 2); + + Assert.True(bloqueado.IsFailure); + Assert.Contains(DomainErrors.Authorization.TemplateHasActiveProfiles, bloqueado.Error); + Assert.NotEqual(TemplateStatus.Deleted, template.Status); + + // Cada petición HTTP rehidrata el agregado y el repositorio limpia las reglas rotas + // (`aggregate.BrokenRules.Clear()`); se replica aquí para que el segundo intento parta del + // mismo estado limpio que tendría en producción. + template.BrokenRules.Clear(); + + var permitido = template.Delete(ValidActor, activeProfileCount: 0); + + Assert.True(permitido.IsSuccess); + Assert.Equal(TemplateStatus.Deleted, template.Status); + } + + [Fact] + public void Delete_NoPermiteMutarLaPlantillaDespues() + { + // Una plantilla eliminada no puede recibir ítems ni volver a publicarse: el ciclo de vida acabó. + var template = PermissionTemplate.Create(ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor).Value; + template.Delete(ValidActor); + + var addResult = template.AddItem(ValidTargetType, ValidTargetId, ValidActionId, true, false, ValidActor); + Assert.True(addResult.IsFailure); + + var publishResult = template.Publish(ValidActor); + Assert.True(publishResult.IsFailure); + } + #endregion #region AddItem @@ -246,6 +363,46 @@ public void AddItem_WithDuplicateTarget_ReturnsFailure() Assert.Contains(DomainErrors.Authorization.TemplateItemTargetAlreadyExists, result.Error); } + /// + /// G-192 — el agregado NO restringe el arco a los cuatro destinos de navegación: admite + /// cualquier valor de , incluidos los objetos de dominio. + /// Esta prueba fija esa verdad para que la capa de aplicación no vuelva a estrecharla por su + /// cuenta: el validador rechazaba Aggregate y Entity mientras el grafo sí sabía proyectarlos. + /// + [Theory] + [InlineData(nameof(ExclusiveArcTarget.SystemSuite))] + [InlineData(nameof(ExclusiveArcTarget.Module))] + [InlineData(nameof(ExclusiveArcTarget.Submodule))] + [InlineData(nameof(ExclusiveArcTarget.Option))] + [InlineData(nameof(ExclusiveArcTarget.Aggregate))] + [InlineData(nameof(ExclusiveArcTarget.Entity))] + public void AddItem_AdmiteTodosLosDestinosDelArcoExclusivo(string targetTypeName) + { + var targetType = DomainEnumeration.GetAll().Single(t => t.Name == targetTypeName); + var template = PermissionTemplate.Create(ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor).Value; + + var result = template.AddItem(targetType, ValidTargetId, ValidActionId, true, false, ValidActor); + + Assert.True(result.IsSuccess); + Assert.Equal(targetType, template.Items.Single().TargetType); + } + + /// + /// G-192 — el destino forma parte de la clave del ítem: el mismo recurso y la misma acción + /// pueden concederse como Aggregate y como Entity sin que el agregado lo tome por duplicado. + /// + [Fact] + public void AddItem_MismoDestinoConTipoDistinto_NoEsDuplicado() + { + var template = PermissionTemplate.Create(ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor).Value; + Assert.True(template.AddItem(ExclusiveArcTarget.Aggregate, ValidTargetId, ValidActionId, true, false, ValidActor).IsSuccess); + + var result = template.AddItem(ExclusiveArcTarget.Entity, ValidTargetId, ValidActionId, true, false, ValidActor); + + Assert.True(result.IsSuccess); + Assert.Equal(2, template.Items.Count); + } + [Fact] public void AddItem_RaisesPermissionTemplateMutatedEvent() { @@ -259,47 +416,140 @@ public void AddItem_RaisesPermissionTemplateMutatedEvent() #endregion - #region RemoveItem + #region Retirada de ítems (ADR-0164) + + // El borrado FÍSICO de ítems se retiró del dominio: `RemoveItem` ya no existe y la colección no + // puede encoger. Estas pruebas fijan las cuatro afirmaciones que sostienen la política. + + [Fact] + public void ElDominioNoOfreceBorradoFisicoDeItems() + { + // La garantía es la AUSENCIA del método (ADR-0164 §2.1: «una capacidad que no debe usarse y + // sigue disponible acaba usándose»). Se comprueba por reflexión porque una llamada que no + // compila no puede escribirse como prueba: sin esto, nada impediría reintroducirlo mañana. + var metodos = typeof(PermissionTemplate).GetMethods().Select(m => m.Name).ToList(); + + Assert.DoesNotContain("RemoveItem", metodos); + Assert.DoesNotContain("DeleteItem", metodos); + } [Fact] - public void RemoveItem_WhenItemExists_ReturnsSuccess() + public void DeactivateItem_NoEncogeLaColeccion_LaConcesionSigueAhiRetirada() { var template = PermissionTemplate.Create(ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor).Value; template.AddItem(ValidTargetType, ValidTargetId, ValidActionId, true, false, ValidActor); - var itemId = template.Items.First().Id; + var itemId = template.Items.First().GetId(); - var result = template.RemoveItem(itemId, ValidActor); + var result = template.DeactivateItem(itemId, ValidActor); Assert.True(result.IsSuccess); - Assert.Empty(template.Items); + Assert.Single(template.Items); + Assert.False(template.Items.First().IsActive); + // Y la separación que evita la brecha: retirado deja de estar entre lo que la plantilla concede. + Assert.Empty(template.ActiveItems); } [Fact] - public void RemoveItem_WhenNotFound_ReturnsFailure() + public void CicloRetirarYReactivar_DevuelveLaConcesionAlServicio_SobreLaMismaFila() { var template = PermissionTemplate.Create(ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor).Value; - var fakeId = IdValueObject.Create(); + template.AddItem(ValidTargetType, ValidTargetId, ValidActionId, true, false, ValidActor); + var itemId = template.Items.First().GetId(); - var result = template.RemoveItem(fakeId, ValidActor); + Assert.True(template.DeactivateItem(itemId, ValidActor).IsSuccess); + Assert.True(template.ActivateItem(itemId, ValidActor).IsSuccess); + + Assert.Single(template.Items); + Assert.Single(template.ActiveItems); + Assert.Equal(itemId.GetValue(), template.Items.First().GetId().GetValue(), + comparer: EqualityComparer.Default); + } + + [Fact] + public void AddItem_SobreLaClaveDeUnItemRetirado_EsConflicto_LaClaveNoSeLibera() + { + // ADR-0164 §2.3: la clave natural no se reutiliza. El alta responde conflicto de DOMINIO + // legible —no un error de índice— y nombra la retirada, para que el cliente pueda proponer + // reactivar en vez de repetir el alta. + var template = PermissionTemplate.Create(ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor).Value; + template.AddItem(ValidTargetType, ValidTargetId, ValidActionId, true, false, ValidActor); + template.DeactivateItem(template.Items.First().GetId(), ValidActor); + + var result = template.AddItem(ValidTargetType, ValidTargetId, ValidActionId, true, false, ValidActor); Assert.True(result.IsFailure); - Assert.Contains(DomainErrors.Common.NotFound, result.Error); + Assert.Contains(DomainErrors.Authorization.TemplateItemTargetRetired, result.Error); + Assert.Single(template.Items); } [Fact] - public void RemoveItem_WhenNotDraft_ReturnsFailure() + public void AddItem_ConLaMismaClaveEnInstanciasDistintas_DetectaElDuplicado() { + // Contraprueba de la comparación por VALOR. IdValueObject no sobrecarga ==, así que la guarda + // anterior (i.TargetId == targetId) comparaba REFERENCIAS: sobre una plantilla rehidratada + // desde la base —donde cada ítem trae sus propias instancias— el duplicado pasaba de largo y + // el alta reventaba contra el índice único como 23505. Misma clase de bug que G-043. + var template = PermissionTemplate.Create(ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor).Value; + var targetId = IdValueObject.Create(); + var actionGuid = Guid.NewGuid(); + + Assert.True(template.AddItem( + ValidTargetType, IdValueObject.Load(targetId.GetValue()), ActionId.Load(actionGuid), + true, false, ValidActor).IsSuccess); + + var result = template.AddItem( + ValidTargetType, IdValueObject.Load(targetId.GetValue()), ActionId.Load(actionGuid), + true, false, ValidActor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Authorization.TemplateItemTargetAlreadyExists, result.Error); + } + + [Fact] + public void Publish_ConTodosLosItemsRetirados_Falla() + { + // Puerta que abre el borrado lógico: antes, retirar el último ítem lo borraba de la lista y + // `Items.Any()` bastaba para rechazar la publicación. Ahora la fila sobrevive, así que la + // guarda tiene que mirar lo VIGENTE; si no, se publicaría un contrato que no concede nada y + // sería asignable a perfiles. + var template = PermissionTemplate.Create(ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor).Value; + template.AddItem(ValidTargetType, ValidTargetId, ValidActionId, true, false, ValidActor); + template.DeactivateItem(template.Items.First().GetId(), ValidActor); + + var result = template.Publish(ValidActor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Authorization.TemplateItemsRequired, result.Error); + } + + [Fact] + public void DeactivateItem_CuandoNoEsBorrador_Falla() + { + // Publicada, la plantilla se congela: retirar una concesión de una plantilla vigente exige + // una versión nueva. Es lo que hace que el histórico viva en la cadena de versiones y no + // necesite un mecanismo propio. var template = PermissionTemplate.Create(ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor).Value; template.AddItem(ValidTargetType, ValidTargetId, ValidActionId, true, false, ValidActor); template.Publish(ValidActor); - var itemId = template.Items.First().Id; + var itemId = template.Items.First().GetId(); - var result = template.RemoveItem(itemId, ValidActor); + var result = template.DeactivateItem(itemId, ValidActor); Assert.True(result.IsFailure); Assert.Contains(DomainErrors.Authorization.TemplateNotDraft, result.Error); } + [Fact] + public void DeactivateItem_CuandoElItemNoExiste_Falla() + { + var template = PermissionTemplate.Create(ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor).Value; + + var result = template.DeactivateItem(IdValueObject.Create(), ValidActor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Common.NotFound, result.Error); + } + #endregion #region SetItemAllow @@ -309,7 +559,7 @@ public void SetItemAllow_WhenItemExists_ReturnsSuccess() { var template = PermissionTemplate.Create(ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor).Value; template.AddItem(ValidTargetType, ValidTargetId, ValidActionId, false, false, ValidActor); - var itemId = template.Items.First().Id; + var itemId = template.Items.First().GetId(); var result = template.SetItemAllow(itemId, ValidActor); @@ -333,7 +583,7 @@ public void SetItemAllow_WhenNotDraft_ReturnsFailure() var template = PermissionTemplate.Create(ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor).Value; template.AddItem(ValidTargetType, ValidTargetId, ValidActionId, true, false, ValidActor); template.Publish(ValidActor); - var itemId = template.Items.First().Id; + var itemId = template.Items.First().GetId(); var result = template.SetItemAllow(itemId, ValidActor); @@ -349,7 +599,7 @@ public void SetItemDeny_WhenItemExists_ReturnsSuccess() { var template = PermissionTemplate.Create(ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor).Value; template.AddItem(ValidTargetType, ValidTargetId, ValidActionId, false, false, ValidActor); - var itemId = template.Items.First().Id; + var itemId = template.Items.First().GetId(); var result = template.SetItemDeny(itemId, ValidActor); @@ -362,7 +612,7 @@ public void SetItemDeny_WhenNotDraft_ReturnsFailure() var template = PermissionTemplate.Create(ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor).Value; template.AddItem(ValidTargetType, ValidTargetId, ValidActionId, true, false, ValidActor); template.Publish(ValidActor); - var itemId = template.Items.First().Id; + var itemId = template.Items.First().GetId(); var result = template.SetItemDeny(itemId, ValidActor); @@ -378,7 +628,7 @@ public void SetItemNeutral_WhenItemExists_ReturnsSuccess() { var template = PermissionTemplate.Create(ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor).Value; template.AddItem(ValidTargetType, ValidTargetId, ValidActionId, true, false, ValidActor); - var itemId = template.Items.First().Id; + var itemId = template.Items.First().GetId(); var result = template.SetItemNeutral(itemId, ValidActor); @@ -394,7 +644,7 @@ public void ActivateItem_WhenItemExists_ReturnsSuccess() { var template = PermissionTemplate.Create(ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor).Value; template.AddItem(ValidTargetType, ValidTargetId, ValidActionId, true, false, ValidActor); - var itemId = template.Items.First().Id; + var itemId = template.Items.First().GetId(); var result = template.ActivateItem(itemId, ValidActor); @@ -407,7 +657,7 @@ public void ActivateItem_WhenNotDraft_ReturnsFailure() var template = PermissionTemplate.Create(ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor).Value; template.AddItem(ValidTargetType, ValidTargetId, ValidActionId, true, false, ValidActor); template.Publish(ValidActor); - var itemId = template.Items.First().Id; + var itemId = template.Items.First().GetId(); var result = template.ActivateItem(itemId, ValidActor); @@ -423,7 +673,7 @@ public void DeactivateItem_WhenItemExists_ReturnsSuccess() { var template = PermissionTemplate.Create(ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor).Value; template.AddItem(ValidTargetType, ValidTargetId, ValidActionId, true, false, ValidActor); - var itemId = template.Items.First().Id; + var itemId = template.Items.First().GetId(); var result = template.DeactivateItem(itemId, ValidActor); @@ -436,7 +686,7 @@ public void DeactivateItem_WhenNotDraft_ReturnsFailure() var template = PermissionTemplate.Create(ValidTenantId, ValidRoleId, ValidSystemSuiteId, ValidActor).Value; template.AddItem(ValidTargetType, ValidTargetId, ValidActionId, true, false, ValidActor); template.Publish(ValidActor); - var itemId = template.Items.First().Id; + var itemId = template.Items.First().GetId(); var result = template.DeactivateItem(itemId, ValidActor); diff --git a/src/apps/ums.api/Ums.Domain.Test/Configuration/AppConfiguration/AppConfigurationTests.cs b/src/apps/ums.api/Ums.Domain.Test/Configuration/AppConfiguration/AppConfigurationTests.cs index b6f7888a..5c67ab7b 100644 --- a/src/apps/ums.api/Ums.Domain.Test/Configuration/AppConfiguration/AppConfigurationTests.cs +++ b/src/apps/ums.api/Ums.Domain.Test/Configuration/AppConfiguration/AppConfigurationTests.cs @@ -338,4 +338,82 @@ public void Update_RaisesAppConfigUpdatedEvent() } #endregion + + // ========================================================================= + #region Delete (borrado LÓGICO — estado terminal, la fila nunca se pierde) + // ========================================================================= + + [Fact] + public void Delete_DesdeBorrador_TransicionaADeleted() + { + // Borrar no es un paso del ciclo de vida sino una retirada: debe poder ejercerse sobre un + // borrador que jamás se publicó (Archive, en cambio, exige Published). + var config = MakeDraft(); + + var result = config.Delete(ValidActor); + + Assert.True(result.IsSuccess); + Assert.Equal(ConfigStatus.Deleted, config.Status); + } + + [Fact] + public void Delete_DesdePublicado_TransicionaADeleted() + { + var config = MakePublished(); + + var result = config.Delete(ValidActor); + + Assert.True(result.IsSuccess); + Assert.Equal(ConfigStatus.Deleted, config.Status); + } + + [Fact] + public void Delete_DesdeArchivado_TransicionaADeleted() + { + var config = MakePublished(); + config.Archive(ValidActor); + + var result = config.Delete(ValidActor); + + Assert.True(result.IsSuccess); + Assert.Equal(ConfigStatus.Deleted, config.Status); + } + + [Fact] + public void Delete_DosVeces_ReturnsFailure() + { + var config = MakePublished(); + Assert.True(config.Delete(ValidActor).IsSuccess); + + var second = config.Delete(ValidActor); + + Assert.True(second.IsFailure); + Assert.Contains(DomainErrors.Configuration.AppConfigAlreadyDeleted, second.Error); + } + + [Fact] + public void Delete_CongelaLaConfiguracion() + { + // Estado terminal: ni se publica, ni se archiva, ni se actualiza una configuración borrada. + var config = MakeDraft(); + config.Delete(ValidActor); + + Assert.True(config.Publish(ValidActor).IsFailure); + Assert.True(config.Archive(ValidActor).IsFailure); + Assert.True(config.Update(ConfigurationValue.Create("v2"), Description.Create("d"), ValidActor).IsFailure); + Assert.Equal(ConfigStatus.Deleted, config.Status); + } + + [Fact] + public void Delete_RaisesAppConfigDeletedEvent() + { + var config = MakePublished(); + + config.Delete(ValidActor); + + var events = config.DomainEvents.GetUncommittedChanges().ToList(); + Assert.Contains(events, e => e is AppConfigDeletedEvent); + } + + #endregion } diff --git a/src/apps/ums.api/Ums.Domain.Test/Configuration/FeatureFlag/FeatureFlagTests.cs b/src/apps/ums.api/Ums.Domain.Test/Configuration/FeatureFlag/FeatureFlagTests.cs index 7e7ec455..3a9e0f66 100644 --- a/src/apps/ums.api/Ums.Domain.Test/Configuration/FeatureFlag/FeatureFlagTests.cs +++ b/src/apps/ums.api/Ums.Domain.Test/Configuration/FeatureFlag/FeatureFlagTests.cs @@ -1,3 +1,4 @@ +#pragma warning disable S1144 namespace Ums.Domain.Test.Configuration.FeatureFlag; using Ums.Domain.Configuration.FeatureFlag; @@ -436,3 +437,5 @@ public void Evaluate_WhenArchived_DoesNotAppendToLog() #endregion } + +#pragma warning restore S1144 diff --git a/src/apps/ums.api/Ums.Domain.Test/Configuration/IdpConfiguration/IdpConfigurationSelectorTests.cs b/src/apps/ums.api/Ums.Domain.Test/Configuration/IdpConfiguration/IdpConfigurationSelectorTests.cs new file mode 100644 index 00000000..ae6b19b0 --- /dev/null +++ b/src/apps/ums.api/Ums.Domain.Test/Configuration/IdpConfiguration/IdpConfigurationSelectorTests.cs @@ -0,0 +1,176 @@ +namespace Ums.Domain.Test.Configuration.IdpConfiguration; + +using Ums.Domain.Configuration.IdpConfiguration; +using Xunit; +using IdpConfigurationAggregate = Ums.Domain.Configuration.IdpConfiguration.IdpConfiguration; + +/// +/// FR-042 (ADR-UMS-097 §2.1/§4): la regla pura de selección de IdP por prioridad/suite/dominio, +/// con desempate por versión. Fuente única compartida por el login y el motor de consulta OIDC. +/// +public class IdpConfigurationSelectorTests +{ + private static readonly ActorId Actor = ActorId.Create("test"); + private static readonly Guid TenantGuid = Guid.NewGuid(); + + // ── Sin candidatos ────────────────────────────────────────────────────────── + + [Fact] + public void Select_WhenNoConfigurations_ReturnsNull() + { + var selection = IdpConfigurationSelector.Select( + new List(), systemSuiteId: null, emailDomain: null, providerType: null); + + Assert.Null(selection); + } + + [Fact] + public void Select_WhenOnlyDraftConfigurations_ReturnsNull() + { + // Draft (no activada) no es candidata. + var draft = BuildConfig(ProviderType.Keycloak, priority: 1, activate: false); + + var selection = IdpConfigurationSelector.Select( + new[] { draft }, systemSuiteId: null, emailDomain: null, providerType: null); + + Assert.Null(selection); + } + + // ── Prioridad ──────────────────────────────────────────────────────────────── + + [Fact] + public void Select_ByPriority_SelectsLowestPriorityNumber() + { + var low = BuildConfig(ProviderType.AzureAd, priority: 5); + var high = BuildConfig(ProviderType.Keycloak, priority: 10); + + var selection = IdpConfigurationSelector.Select( + new[] { high, low }, systemSuiteId: null, emailDomain: null, providerType: null); + + Assert.NotNull(selection); + Assert.Equal(ProviderType.AzureAd, selection!.Value.Configuration.ProviderType); + Assert.False(selection.Value.DomainMatched); + } + + [Fact] + public void Select_PriorityTie_SelectsHighestVersion() + { + var versionOne = BuildConfig(ProviderType.AzureAd, priority: 1); // Version 1 + var versionTwo = BuildConfig(ProviderType.Keycloak, priority: 1, bumpVersion: 1); // Version 2 + + var selection = IdpConfigurationSelector.Select( + new[] { versionOne, versionTwo }, systemSuiteId: null, emailDomain: null, providerType: null); + + Assert.NotNull(selection); + Assert.Equal(ProviderType.Keycloak, selection!.Value.Configuration.ProviderType); + } + + // ── Filtro por suite ───────────────────────────────────────────────────────── + + [Fact] + public void Select_SuiteFilter_OnlyConsidersMatchingSuite() + { + var suiteA = Guid.NewGuid(); + var suiteB = Guid.NewGuid(); + var inSuiteA = BuildConfig(ProviderType.AzureAd, priority: 1, suiteId: suiteA); + var inSuiteB = BuildConfig(ProviderType.Keycloak, priority: 99, suiteId: suiteB); + + var selection = IdpConfigurationSelector.Select( + new[] { inSuiteA, inSuiteB }, systemSuiteId: suiteB, emailDomain: null, providerType: null); + + Assert.NotNull(selection); + // La de suiteA tiene mejor prioridad pero se filtra fuera; gana la única de suiteB. + Assert.Equal(ProviderType.Keycloak, selection!.Value.Configuration.ProviderType); + } + + [Fact] + public void Select_SuiteFilter_WhenNoMatch_ReturnsNull() + { + var config = BuildConfig(ProviderType.AzureAd, priority: 1, suiteId: Guid.NewGuid()); + + var selection = IdpConfigurationSelector.Select( + new[] { config }, systemSuiteId: Guid.NewGuid(), emailDomain: null, providerType: null); + + Assert.Null(selection); + } + + // ── Routing por dominio ────────────────────────────────────────────────────── + + [Fact] + public void Select_DomainMatch_PrefersDomainMatchedOverPriority() + { + var domainConfig = BuildConfig(ProviderType.AzureAd, priority: 10, domainHints: new[] { "acme.com" }); + var betterPriority = BuildConfig(ProviderType.Keycloak, priority: 1); + + var selection = IdpConfigurationSelector.Select( + new[] { betterPriority, domainConfig }, systemSuiteId: null, emailDomain: "user@acme.com", providerType: null); + + Assert.NotNull(selection); + Assert.Equal(ProviderType.AzureAd, selection!.Value.Configuration.ProviderType); + Assert.True(selection.Value.DomainMatched); + } + + [Fact] + public void Select_WhenDomainMatchesNoHint_FallsBackToPrioritySelection() + { + var domainConfig = BuildConfig(ProviderType.AzureAd, priority: 10, domainHints: new[] { "acme.com" }); + var betterPriority = BuildConfig(ProviderType.Keycloak, priority: 1); + + var selection = IdpConfigurationSelector.Select( + new[] { betterPriority, domainConfig }, systemSuiteId: null, emailDomain: "user@other.com", providerType: null); + + Assert.NotNull(selection); + Assert.Equal(ProviderType.Keycloak, selection!.Value.Configuration.ProviderType); + Assert.False(selection.Value.DomainMatched); + } + + // ── Filtro por tipo de proveedor ───────────────────────────────────────────── + + [Fact] + public void Select_ProviderTypeFilter_OnlyConsidersMatchingType() + { + var azure = BuildConfig(ProviderType.AzureAd, priority: 1); + var keycloak = BuildConfig(ProviderType.Keycloak, priority: 99); + + var selection = IdpConfigurationSelector.Select( + new[] { azure, keycloak }, systemSuiteId: null, emailDomain: null, providerType: ProviderType.Keycloak.Name); + + Assert.NotNull(selection); + Assert.Equal(ProviderType.Keycloak, selection!.Value.Configuration.ProviderType); + } + + // ── Helpers ────────────────────────────────────────────────────────────────── + + private static IdpConfigurationAggregate BuildConfig( + ProviderType providerType, + int priority, + Guid? suiteId = null, + string[]? domainHints = null, + int bumpVersion = 0, + bool activate = true) + { + var config = IdpConfigurationAggregate.Create( + TenantId.Load(TenantGuid), + suiteId.HasValue ? SystemSuiteId.Load(suiteId.Value) : SystemSuiteId.Create(), + providerType, + domainHints ?? Array.Empty(), + "{\"issuer\":\"https://idp.example\"}", + "vault/secret/idp", + priority, + null, + Actor).Value; + + // Update() incrementa Version y solo se permite en Draft/Inactive: se usa para el desempate. + for (var i = 0; i < bumpVersion; i++) + { + config.Update("{\"issuer\":\"https://idp.example/v\"}", "vault/secret/idp", domainHints ?? Array.Empty(), Actor); + } + + if (activate) + { + config.Activate(Actor); + } + + return config; + } +} diff --git a/src/apps/ums.api/Ums.Domain.Test/Configuration/Parameter/ParameterIdEqualityTests.cs b/src/apps/ums.api/Ums.Domain.Test/Configuration/Parameter/ParameterIdEqualityTests.cs new file mode 100644 index 00000000..b3c8baf5 --- /dev/null +++ b/src/apps/ums.api/Ums.Domain.Test/Configuration/Parameter/ParameterIdEqualityTests.cs @@ -0,0 +1,125 @@ +namespace Ums.Domain.Test.Configuration.Parameter; + +using Ums.Domain.Configuration.Parameter; +using Xunit; + +/// +/// G-055: los identificadores de los agregados de Parameter son value objects de +/// identidad. Estas pruebas fijan que su igualdad es por valor (mismo Guid ⇒ iguales, +/// hash consistente) y que sirven en HashSet/Dictionary, no por referencia. +/// +public class ParameterIdEqualityTests +{ + private static readonly Guid GuidA = Guid.Parse("11111111-1111-1111-1111-111111111111"); + private static readonly Guid GuidB = Guid.Parse("22222222-2222-2222-2222-222222222222"); + + // ----- ParameterDefinitionId ----- + + [Fact] + public void ParameterDefinitionId_SameValue_AreEqualAndShareHash() + { + var a = ParameterDefinitionId.Load(GuidA); + var b = ParameterDefinitionId.Create(GuidA); + + Assert.True(a.Equals(b)); + Assert.True(a.Equals((object)b)); + Assert.Equal(a, b); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + Assert.False(ReferenceEquals(a, b)); + } + + [Fact] + public void ParameterDefinitionId_DifferentValue_AreNotEqual() + { + var a = ParameterDefinitionId.Load(GuidA); + var b = ParameterDefinitionId.Load(GuidB); + + Assert.NotEqual(a, b); + Assert.False(a.Equals(b)); + } + + [Fact] + public void ParameterDefinitionId_WorksAsHashSetElementAndDictionaryKey() + { + var a1 = ParameterDefinitionId.Load(GuidA); + var a2 = ParameterDefinitionId.Load(GuidA); + var b = ParameterDefinitionId.Load(GuidB); + + var set = new HashSet { a1, a2, b }; + Assert.Equal(2, set.Count); + Assert.Contains(a2, set); + + var map = new Dictionary { [a1] = "uno" }; + Assert.True(map.TryGetValue(a2, out var found)); + Assert.Equal("uno", found); + } + + [Fact] + public void ParameterDefinitionId_NullAndOtherType_AreNotEqual() + { + var a = ParameterDefinitionId.Load(GuidA); + + Assert.False(a.Equals(null)); + Assert.False(a.Equals("11111111-1111-1111-1111-111111111111")); + } + + // ----- ParameterGlobalValueId ----- + + [Fact] + public void ParameterGlobalValueId_SameValue_AreEqualAndShareHash() + { + var a = ParameterGlobalValueId.Load(GuidA); + var b = ParameterGlobalValueId.Create(GuidA); + + Assert.Equal(a, b); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void ParameterGlobalValueId_DifferentValue_AreNotEqual() + { + Assert.NotEqual(ParameterGlobalValueId.Load(GuidA), ParameterGlobalValueId.Load(GuidB)); + } + + [Fact] + public void ParameterGlobalValueId_WorksAsDictionaryKey() + { + var map = new Dictionary + { + [ParameterGlobalValueId.Load(GuidA)] = 7, + }; + + Assert.True(map.ContainsKey(ParameterGlobalValueId.Load(GuidA))); + Assert.False(map.ContainsKey(ParameterGlobalValueId.Load(GuidB))); + } + + // ----- ParameterTenantValueId ----- + + [Fact] + public void ParameterTenantValueId_SameValue_AreEqualAndShareHash() + { + var a = ParameterTenantValueId.Load(GuidA); + var b = ParameterTenantValueId.Create(GuidA); + + Assert.Equal(a, b); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void ParameterTenantValueId_DifferentValue_AreNotEqual() + { + Assert.NotEqual(ParameterTenantValueId.Load(GuidA), ParameterTenantValueId.Load(GuidB)); + } + + [Fact] + public void ParameterTenantValueId_HashSetDeduplicatesByValue() + { + var set = new HashSet + { + ParameterTenantValueId.Load(GuidA), + ParameterTenantValueId.Load(GuidA), + }; + + Assert.Single(set); + } +} diff --git a/src/apps/ums.api/Ums.Domain.Test/Configuration/Parameter/ParameterSoftDeleteTests.cs b/src/apps/ums.api/Ums.Domain.Test/Configuration/Parameter/ParameterSoftDeleteTests.cs new file mode 100644 index 00000000..db142b86 --- /dev/null +++ b/src/apps/ums.api/Ums.Domain.Test/Configuration/Parameter/ParameterSoftDeleteTests.cs @@ -0,0 +1,153 @@ +namespace Ums.Domain.Test.Configuration.Parameter; + +using Ums.Domain.Configuration.Parameter; +using Ums.Domain.Configuration.Parameter.ValueObjects; +using Xunit; + +/// +/// Invariantes de dominio del borrado LÓGICO del catálogo de parámetros. +/// +/// La política del propietario es que el borrado físico no existe: eliminar es una transición de +/// estado, y no se puede eliminar algo que todavía tiene referencias reales. Estas pruebas fijan +/// las dos mitades de esa regla en el agregado, donde vive la invariante. +/// +public class ParameterSoftDeleteTests +{ + private static readonly ActorId ValidActor = ActorId.Create("user-001"); + private static readonly TenantId ValidTenantId = TenantId.Load(Guid.NewGuid()); + private static readonly IdValueObject ValidDefinitionId = IdValueObject.Create(); + + private static ParameterDefinition MakeDefinition() => + ParameterDefinition.Create( + Code.Create("PARAM-001"), + ParameterName.Create("Parameter 1"), + Description.Create("Test parameter"), + ParameterDataType.String, + DefaultValue.Create("default"), + ParameterScope.GlobalAndTenant, + isActive: true, + isMandatory: false, + displayOrder: 1, + ValidActor).Value; + + // ── ParameterDefinition ────────────────────────────────────────────────── + + [Fact] + public void Definition_Delete_SinDependientesVivos_MarcaEliminadaYDesactiva() + { + var definition = MakeDefinition(); + + var result = definition.Delete(ValidActor); + + Assert.True(result.IsSuccess); + Assert.True(definition.IsDeleted); + Assert.False(definition.IsActive); + } + + [Fact] + public void Definition_Delete_ConValorGlobalVivo_ReturnsFailure() + { + var definition = MakeDefinition(); + + var result = definition.Delete(ValidActor, liveGlobalValueCount: 1); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Configuration.ParameterHasActiveValues, result.Error); + Assert.False(definition.IsDeleted); + } + + [Fact] + public void Definition_Delete_ConValorDeInquilinoVivo_ReturnsFailure() + { + var definition = MakeDefinition(); + + var result = definition.Delete(ValidActor, liveTenantValueCount: 3); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Configuration.ParameterHasActiveValues, result.Error); + Assert.False(definition.IsDeleted); + } + + [Fact] + public void Definition_Delete_DosVeces_ReturnsFailure() + { + var definition = MakeDefinition(); + Assert.True(definition.Delete(ValidActor).IsSuccess); + + var second = definition.Delete(ValidActor); + + Assert.True(second.IsFailure); + Assert.Contains(DomainErrors.Configuration.ParameterAlreadyDeleted, second.Error); + } + + // ── ParameterGlobalValue / ParameterTenantValue ────────────────────────── + + [Fact] + public void GlobalValue_Delete_DesdeBorrador_PasaAEstadoTerminal() + { + var value = ParameterGlobalValue.Create( + ValidDefinitionId, EffectiveValue.Create("hello"), ParameterDataType.String, ValidActor).Value; + + var result = value.Delete(ValidActor); + + Assert.True(result.IsSuccess); + Assert.Equal(ConfigStatus.Deleted, value.Status); + } + + [Fact] + public void GlobalValue_Delete_DesdeArchivado_TambienEsPosible() + { + // Archivar no libera la referencia; eliminar sí. Por eso Delete admite cualquier estado vivo. + var value = ParameterGlobalValue.Create( + ValidDefinitionId, EffectiveValue.Create("hello"), ParameterDataType.String, ValidActor).Value; + value.Publish(ValidActor); + value.Archive(ValidActor); + + var result = value.Delete(ValidActor); + + Assert.True(result.IsSuccess); + Assert.Equal(ConfigStatus.Deleted, value.Status); + } + + [Fact] + public void GlobalValue_Delete_DosVeces_ReturnsFailure() + { + var value = ParameterGlobalValue.Create( + ValidDefinitionId, EffectiveValue.Create("hello"), ParameterDataType.String, ValidActor).Value; + Assert.True(value.Delete(ValidActor).IsSuccess); + + Assert.True(value.Delete(ValidActor).IsFailure); + } + + [Fact] + public void TenantValue_Delete_PasaAEstadoTerminal() + { + var value = ParameterTenantValue.Create( + ValidTenantId, + ValidDefinitionId, + OverrideValue.Create("true"), + ParameterDataType.Boolean, + ParameterScope.GlobalAndTenant, + ValidActor).Value; + + var result = value.Delete(ValidActor); + + Assert.True(result.IsSuccess); + Assert.Equal(ConfigStatus.Deleted, value.Status); + } + + [Fact] + public void TenantValue_Delete_DosVeces_ReturnsFailure() + { + var value = ParameterTenantValue.Create( + ValidTenantId, + ValidDefinitionId, + OverrideValue.Create("true"), + ParameterDataType.Boolean, + ParameterScope.GlobalAndTenant, + ValidActor).Value; + Assert.True(value.Delete(ValidActor).IsSuccess); + + Assert.True(value.Delete(ValidActor).IsFailure); + } +} diff --git a/src/apps/ums.api/Ums.Domain.Test/IGA/RiskScoreTests.cs b/src/apps/ums.api/Ums.Domain.Test/IGA/RiskScoreTests.cs new file mode 100644 index 00000000..58cc8679 --- /dev/null +++ b/src/apps/ums.api/Ums.Domain.Test/IGA/RiskScoreTests.cs @@ -0,0 +1,37 @@ +namespace Ums.Domain.Test.IGA; + +using Xunit; + +/// Pruebas del objeto de valor (rango [0, 100], FR-061). +public class RiskScoreTests +{ + [Theory] + [InlineData(0)] + [InlineData(50)] + [InlineData(100)] + public void Create_WithinRange_ReturnsSuccess(int value) + { + var result = RiskScore.Create(value); + + Assert.True(result.IsSuccess); + Assert.Equal(value, result.Value.GetValue()); + } + + [Theory] + [InlineData(-1)] + [InlineData(101)] + [InlineData(1000)] + public void Create_OutOfRange_ReturnsFailure(int value) + { + var result = RiskScore.Create(value); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.RiskScoreOutOfRange, result.Error); + } + + [Fact] + public void RiskScore_WithSameValue_AreEqual() + { + Assert.Equal(RiskScore.Create(42).Value, RiskScore.Create(42).Value); + } +} diff --git a/src/apps/ums.api/Ums.Domain.Test/IGA/RoleMaturityStatusTests.cs b/src/apps/ums.api/Ums.Domain.Test/IGA/RoleMaturityStatusTests.cs new file mode 100644 index 00000000..796b163e --- /dev/null +++ b/src/apps/ums.api/Ums.Domain.Test/IGA/RoleMaturityStatusTests.cs @@ -0,0 +1,239 @@ +namespace Ums.Domain.Test.IGA; + +using Ums.Domain.IGA.RoleMaturityStatus; +using Xunit; + +/// +/// Pruebas de dominio del agregado (ADR-UMS-093, IGA). +/// +/// Cobertura por invariante: +/// INV-RMS1 — PerformanceScore ∈ [0, 5] (límites y fuera de rango). +/// INV-RMS2 — actualización de nivel debe apuntar a un nivel distinto. +/// INV-RMS3 — elegibilidad: sin incidencias, desempeño ≥ 3.0, tiempo mínimo en nivel; +/// Principal no promocionable. +/// Se verifica además la emisión de eventos de dominio por transición. +/// +public class RoleMaturityStatusTests +{ + private static readonly TenantId Tenant = TenantId.Create(); + private static readonly UserId User = UserId.Create(); + private static readonly RoleId Role = RoleId.Create(); + private static readonly ActorId Actor = ActorId.Create("actor-001"); + private static readonly DateTime Now = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + private static RoleMaturityStatus Make( + RoleMaturityLevel level = RoleMaturityLevel.Junior, + DateTime? assignedAt = null) + => RoleMaturityStatus.Create(Tenant, User, Role, level, assignedAt ?? Now, Actor).Value; + + // ── Create ─────────────────────────────────────────────────────────────── + + [Fact] + public void Create_WithValidData_ReturnsSuccess() + { + var result = RoleMaturityStatus.Create(Tenant, User, Role, RoleMaturityLevel.Junior, Now, Actor); + + Assert.True(result.IsSuccess); + Assert.Equal(RoleMaturityLevel.Junior, result.Value.CurrentMaturityLevel); + Assert.True(result.Value.HasNoComplianceIssues); + Assert.Equal(0m, result.Value.PerformanceScore); + Assert.Null(result.Value.NextEligibleMaturityLevel); + } + + [Fact] + public void Create_RaisesRoleMaturityStatusCreatedEvent() + { + var status = Make(); + + var events = status.DomainEvents.GetUncommittedChanges(); + Assert.Contains(events, e => e is RoleMaturityStatusCreatedEvent); + } + + // ── INV-RMS1 — PerformanceScore ∈ [0, 5] ────────────────────────────────── + + [Theory] + [InlineData(0)] + [InlineData(2.5)] + [InlineData(5)] + public void UpdatePerformanceScore_WithinRange_ReturnsSuccess(double score) + { + var status = Make(); + + var result = status.UpdatePerformanceScore((decimal)score, Actor); + + Assert.True(result.IsSuccess); + Assert.Equal((decimal)score, status.PerformanceScore); + } + + [Theory] + [InlineData(-0.1)] + [InlineData(5.1)] + [InlineData(100)] + public void UpdatePerformanceScore_OutOfRange_ReturnsFailure(double score) + { + var status = Make(); + + var result = status.UpdatePerformanceScore((decimal)score, Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.InvalidPerformanceScore, result.Error); + } + + // ── INV-RMS2 — nivel distinto ───────────────────────────────────────────── + + [Fact] + public void UpdateMaturityLevel_ToDifferentLevel_ReturnsSuccess() + { + var status = Make(RoleMaturityLevel.Junior); + + var result = status.UpdateMaturityLevel(RoleMaturityLevel.Intermediate, Now, Actor); + + Assert.True(result.IsSuccess); + Assert.Equal(RoleMaturityLevel.Intermediate, status.CurrentMaturityLevel); + Assert.Contains(status.DomainEvents.GetUncommittedChanges(), e => e is RoleMaturityLevelChangedEvent); + } + + [Fact] + public void UpdateMaturityLevel_ToSameLevel_ReturnsFailure() + { + var status = Make(RoleMaturityLevel.Junior); + + var result = status.UpdateMaturityLevel(RoleMaturityLevel.Junior, Now, Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.MaturityLevelUnchanged, result.Error); + } + + [Fact] + public void UpdateMaturityLevel_ResetsEligibilityMarkers() + { + // Elegible primero, luego un cambio de nivel debe limpiar los marcadores. + var status = Make(RoleMaturityLevel.Junior, Now.AddMonths(-7)); + status.UpdatePerformanceScore(4.0m, Actor); + status.EvaluateEligibility(Now, Actor); + Assert.NotNull(status.NextEligibleMaturityLevel); + + status.UpdateMaturityLevel(RoleMaturityLevel.Intermediate, Now, Actor); + + Assert.Null(status.NextEligibleMaturityLevel); + Assert.Null(status.EligibleForPromotionAt); + } + + // ── INV-RMS3 — elegibilidad ─────────────────────────────────────────────── + + [Fact] + public void EvaluateEligibility_WhenAllConditionsMet_ReturnsSuccess() + { + var status = Make(RoleMaturityLevel.Junior, Now.AddMonths(-7)); // > 6 meses + status.UpdatePerformanceScore(3.0m, Actor); + + var result = status.EvaluateEligibility(Now, Actor); + + Assert.True(result.IsSuccess); + Assert.Equal(RoleMaturityLevel.Intermediate, status.NextEligibleMaturityLevel); + Assert.Equal(Now, status.EligibleForPromotionAt); + Assert.Contains(status.DomainEvents.GetUncommittedChanges(), e => e is RoleMaturityEligibilityConfirmedEvent); + } + + [Fact] + public void EvaluateEligibility_WithActiveComplianceIssue_ReturnsFailure() + { + var status = Make(RoleMaturityLevel.Junior, Now.AddMonths(-7)); + status.UpdatePerformanceScore(4.0m, Actor); + status.MarkComplianceIssue(TextValueObject.Create("sancion abierta"), Actor); + + var result = status.EvaluateEligibility(Now, Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.ComplianceIssuesBlockPromotion, result.Error); + } + + [Fact] + public void EvaluateEligibility_WithInsufficientScore_ReturnsFailure() + { + var status = Make(RoleMaturityLevel.Junior, Now.AddMonths(-7)); + status.UpdatePerformanceScore(2.9m, Actor); + + var result = status.EvaluateEligibility(Now, Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.InsufficientPerformanceScore, result.Error); + } + + [Fact] + public void EvaluateEligibility_WithInsufficientTimeInLevel_ReturnsFailure() + { + var status = Make(RoleMaturityLevel.Junior, Now.AddMonths(-2)); // < 6 meses + status.UpdatePerformanceScore(4.0m, Actor); + + var result = status.EvaluateEligibility(Now, Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.InsufficientTimeInLevel, result.Error); + } + + [Theory] + [InlineData(2, RoleMaturityLevel.Intermediate, 12)] // Intermediate → Senior: 12m + [InlineData(3, RoleMaturityLevel.Senior, 18)] // Senior → Lead: 18m + [InlineData(4, RoleMaturityLevel.Lead, 24)] // Lead → Principal: 24m + public void EvaluateEligibility_RespectsPerLevelTimeThreshold(int _, RoleMaturityLevel level, int requiredMonths) + { + var justUnder = Make(level, Now.AddMonths(-(requiredMonths - 1))); + justUnder.UpdatePerformanceScore(4.0m, Actor); + Assert.True(justUnder.EvaluateEligibility(Now, Actor).IsFailure); + + var justOver = Make(level, Now.AddMonths(-(requiredMonths + 1))); + justOver.UpdatePerformanceScore(4.0m, Actor); + Assert.True(justOver.EvaluateEligibility(Now, Actor).IsSuccess); + } + + [Fact] + public void EvaluateEligibility_WhenPrincipal_ReturnsFailure() + { + var status = Make(RoleMaturityLevel.Principal, Now.AddMonths(-60)); + status.UpdatePerformanceScore(5.0m, Actor); + + var result = status.EvaluateEligibility(Now, Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.MaturityLevelAlreadyMax, result.Error); + } + + // ── Cumplimiento: marcar / resolver ─────────────────────────────────────── + + [Fact] + public void MarkComplianceIssue_SetsBlockAndRaisesEvent() + { + var status = Make(); + + var result = status.MarkComplianceIssue(TextValueObject.Create("documento vencido"), Actor); + + Assert.True(result.IsSuccess); + Assert.False(status.HasNoComplianceIssues); + Assert.Contains(status.DomainEvents.GetUncommittedChanges(), e => e is RoleMaturityComplianceIssueMarkedEvent); + } + + [Fact] + public void ResolveComplianceIssue_ClearsBlockAndRaisesEvent() + { + var status = Make(); + status.MarkComplianceIssue(TextValueObject.Create("documento vencido"), Actor); + + var result = status.ResolveComplianceIssue(Actor); + + Assert.True(result.IsSuccess); + Assert.True(status.HasNoComplianceIssues); + Assert.Contains(status.DomainEvents.GetUncommittedChanges(), e => e is RoleMaturityComplianceIssueResolvedEvent); + } + + [Fact] + public void MarkComplianceIssue_WithEmptyFactor_ReturnsFailure() + { + var status = Make(); + + var result = status.MarkComplianceIssue(TextValueObject.Create(""), Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.BlockingFactorRequired, result.Error); + } +} diff --git a/src/apps/ums.api/Ums.Domain.Test/IGA/RolePromotionRequestTests.cs b/src/apps/ums.api/Ums.Domain.Test/IGA/RolePromotionRequestTests.cs new file mode 100644 index 00000000..d29cbe68 --- /dev/null +++ b/src/apps/ums.api/Ums.Domain.Test/IGA/RolePromotionRequestTests.cs @@ -0,0 +1,508 @@ +namespace Ums.Domain.Test.IGA; + +using Ums.Domain.IGA.RolePromotionRequest; +using Xunit; + +/// +/// Pruebas de dominio del agregado (ADR-UMS-093, IGA). +/// +/// Cobertura: una prueba por transición de la máquina de estados Y por guarda: +/// – estado origen inválido (INV-RPR1) y saltos de estado ilegales; +/// – RiskScore inmutable tras congelar (INV-RPR2); +/// – segregación de funciones violada (INV-RPR3); +/// – elegibilidad fail-closed (INV-RPR4); +/// – sólo Approved→Execute y sólo Executed→Verify (INV-RPR5); +/// – enrutamiento por umbral de riesgo (alto ⇒ revisión de seguridad). +/// Cada transición emite su evento de dominio, también verificado. +/// +public class RolePromotionRequestTests +{ + private static readonly TenantId Tenant = TenantId.Create(); + private static readonly UserId Target = UserId.Create(); + private static readonly UserId Requester = UserId.Create(); + private static readonly UserId Approver = UserId.Create(); + private static readonly UserId Reviewer = UserId.Create(); + private static readonly UserId Executor = UserId.Create(); + private static readonly UserId Verifier = UserId.Create(); + private static readonly RoleId CurrentRole = RoleId.Create(); + private static readonly RoleId TargetRole = RoleId.Create(); + private static readonly ActorId Actor = ActorId.Create("actor-001"); + + private const int LowRisk = 30; + private const int HighRisk = 85; + + // ── Helpers ──────────────────────────────────────────────────────────────── + + private static RolePromotionRequest MakeDraft() + => RolePromotionRequest.Create(Tenant, Target, Requester, CurrentRole, TargetRole, Actor).Value; + + private static RolePromotionRequest MakeSubmitted(int risk = LowRisk) + { + var r = MakeDraft(); + r.Submit(RiskScore.Create(risk).Value, Actor); + return r; + } + + private static RolePromotionRequest MakePendingManagerApproval(int risk = LowRisk) + { + var r = MakeSubmitted(risk); + r.ConfirmEligibility(true, Actor); + return r; + } + + private static RolePromotionRequest MakeApprovedLowRisk() + { + var r = MakePendingManagerApproval(LowRisk); + r.ManagerApprove(Approver, Actor); + return r; + } + + private static RolePromotionRequest MakeApprovedHighRisk() + { + var r = MakePendingManagerApproval(HighRisk); + r.ManagerApprove(Approver, Actor); // → PendingSecurityReview + r.SecurityApprove(Reviewer, Actor); // → Approved (revisor registrado en Props) + return r; + } + + private static RolePromotionRequest MakeExecuted() + { + var r = MakeApprovedLowRisk(); + r.Execute(Executor, Actor); + return r; + } + + // ── Create + SoD (INV-RPR3) ────────────────────────────────────────────── + + [Fact] + public void Create_WithValidData_StartsInDraft() + { + var result = RolePromotionRequest.Create(Tenant, Target, Requester, CurrentRole, TargetRole, Actor); + + Assert.True(result.IsSuccess); + Assert.Equal(RolePromotionStatus.Draft, result.Value.Status); + Assert.Null(result.Value.RiskScore); + Assert.Contains(result.Value.DomainEvents.GetUncommittedChanges(), e => e is RolePromotionRequestedEvent); + } + + [Fact] + public void Create_WhenRequesterEqualsTarget_ReturnsFailure() + { + var result = RolePromotionRequest.Create(Tenant, Target, Target, CurrentRole, TargetRole, Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.SelfPromotionNotAllowed, result.Error); + } + + [Fact] + public void Create_WhenTargetRoleEqualsCurrentRole_ReturnsFailure() + { + var result = RolePromotionRequest.Create(Tenant, Target, Requester, CurrentRole, CurrentRole, Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.SameRolePromotion, result.Error); + } + + // ── Submit (Draft → PendingEligibilityCheck) + RiskScore congelado ──────── + + [Fact] + public void Submit_FromDraft_FreezesRiskScoreAndTransitions() + { + var r = MakeDraft(); + + var result = r.Submit(RiskScore.Create(55).Value, Actor); + + Assert.True(result.IsSuccess); + Assert.Equal(RolePromotionStatus.PendingEligibilityCheck, r.Status); + Assert.Equal(55, r.RiskScore!.GetValue()); + Assert.Contains(r.DomainEvents.GetUncommittedChanges(), e => e is RolePromotionSubmittedEvent); + } + + [Fact] + public void Submit_WhenNotDraft_ReturnsInvalidStateTransition() + { + var r = MakePendingManagerApproval(); + + var result = r.Submit(RiskScore.Create(10).Value, Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.InvalidStateTransition, result.Error); + } + + [Fact] + public void Submit_WhenRiskScoreAlreadyFrozen_ReturnsFailure() + { + // INV-RPR2: reintentar Submit sobre una solicitud ya congelada falla. + var r = MakeSubmitted(40); + + var result = r.Submit(RiskScore.Create(90).Value, Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.RiskScoreAlreadyFrozen, result.Error); + Assert.Equal(40, r.RiskScore!.GetValue()); // valor original intacto + } + + [Fact] + public void RiskScore_RemainsImmutableThroughLifecycle() + { + var r = MakeSubmitted(65); + var frozen = r.RiskScore!.GetValue(); + + r.ConfirmEligibility(true, Actor); + r.ManagerApprove(Approver, Actor); + + Assert.Equal(frozen, r.RiskScore!.GetValue()); + } + + // ── ConfirmEligibility (INV-RPR4 fail-closed) ───────────────────────────── + + [Fact] + public void ConfirmEligibility_WhenEligible_TransitionsToPendingManagerApproval() + { + var r = MakeSubmitted(); + + var result = r.ConfirmEligibility(true, Actor); + + Assert.True(result.IsSuccess); + Assert.Equal(RolePromotionStatus.PendingManagerApproval, r.Status); + Assert.Contains(r.DomainEvents.GetUncommittedChanges(), e => e is RolePromotionEligibilityConfirmedEvent); + } + + [Fact] + public void ConfirmEligibility_WhenNotEligible_TransitionsToRejected() + { + var r = MakeSubmitted(); + + var result = r.ConfirmEligibility(false, Actor); + + Assert.True(result.IsSuccess); + Assert.Equal(RolePromotionStatus.Rejected, r.Status); + Assert.Contains(r.DomainEvents.GetUncommittedChanges(), e => e is RolePromotionRejectedEvent); + } + + [Fact] + public void ConfirmEligibility_WhenNotInEligibilityCheck_ReturnsInvalidStateTransition() + { + var r = MakeDraft(); + + var result = r.ConfirmEligibility(true, Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.InvalidStateTransition, result.Error); + } + + // ── ManagerApprove (enrutamiento por riesgo + SoD) ──────────────────────── + + [Fact] + public void ManagerApprove_WithLowRisk_TransitionsToApproved() + { + var r = MakePendingManagerApproval(LowRisk); + + var result = r.ManagerApprove(Approver, Actor); + + Assert.True(result.IsSuccess); + Assert.Equal(RolePromotionStatus.Approved, r.Status); + Assert.Contains(r.DomainEvents.GetUncommittedChanges(), e => e is RolePromotionManagerApprovedEvent); + } + + [Fact] + public void ManagerApprove_WithHighRisk_TransitionsToPendingSecurityReview() + { + var r = MakePendingManagerApproval(HighRisk); + + var result = r.ManagerApprove(Approver, Actor); + + Assert.True(result.IsSuccess); + Assert.Equal(RolePromotionStatus.PendingSecurityReview, r.Status); + } + + [Fact] + public void ManagerApprove_WhenApproverIsTarget_ReturnsSoDViolation() + { + var r = MakePendingManagerApproval(); + + var result = r.ManagerApprove(Target, Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.SegregationOfDutiesViolation, result.Error); + } + + [Fact] + public void ManagerApprove_WhenApproverIsRequester_ReturnsSoDViolation() + { + var r = MakePendingManagerApproval(); + + var result = r.ManagerApprove(Requester, Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.SegregationOfDutiesViolation, result.Error); + } + + [Fact] + public void ManagerApprove_WhenNotPendingManagerApproval_ReturnsInvalidStateTransition() + { + var r = MakeSubmitted(); // aún en PendingEligibilityCheck + + var result = r.ManagerApprove(Approver, Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.InvalidStateTransition, result.Error); + } + + [Fact] + public void ManagerReject_FromPendingManagerApproval_TransitionsToRejected() + { + var r = MakePendingManagerApproval(); + + var result = r.ManagerReject(Approver, "no cumple el perfil", Actor); + + Assert.True(result.IsSuccess); + Assert.Equal(RolePromotionStatus.Rejected, r.Status); + Assert.Contains(r.DomainEvents.GetUncommittedChanges(), e => e is RolePromotionRejectedEvent); + } + + [Fact] + public void ManagerReject_WithoutReason_ReturnsFailure() + { + var r = MakePendingManagerApproval(); + + var result = r.ManagerReject(Approver, " ", Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.DecisionReasonRequired, result.Error); + } + + // ── SecurityApprove / SecurityReject (SoD reviewer ≠ approver) ───────────── + + [Fact] + public void SecurityApprove_FromPendingSecurityReview_TransitionsToApproved() + { + var r = MakePendingManagerApproval(HighRisk); + r.ManagerApprove(Approver, Actor); + + var result = r.SecurityApprove(Reviewer, Actor); + + Assert.True(result.IsSuccess); + Assert.Equal(RolePromotionStatus.Approved, r.Status); + Assert.Contains(r.DomainEvents.GetUncommittedChanges(), e => e is RolePromotionSecurityApprovedEvent); + } + + [Fact] + public void SecurityApprove_WhenReviewerIsApprover_ReturnsSoDViolation() + { + var r = MakePendingManagerApproval(HighRisk); + r.ManagerApprove(Approver, Actor); + + var result = r.SecurityApprove(Approver, Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.SegregationOfDutiesViolation, result.Error); + } + + [Fact] + public void SecurityApprove_WhenReviewerIsTarget_ReturnsSoDViolation() + { + var r = MakePendingManagerApproval(HighRisk); + r.ManagerApprove(Approver, Actor); + + var result = r.SecurityApprove(Target, Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.SegregationOfDutiesViolation, result.Error); + } + + [Fact] + public void SecurityApprove_WhenNotInSecurityReview_ReturnsInvalidStateTransition() + { + var r = MakeApprovedLowRisk(); // ya Approved, sin pasar por seguridad + + var result = r.SecurityApprove(Reviewer, Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.InvalidStateTransition, result.Error); + } + + [Fact] + public void SecurityReject_FromPendingSecurityReview_TransitionsToRejected() + { + var r = MakePendingManagerApproval(HighRisk); + r.ManagerApprove(Approver, Actor); + + var result = r.SecurityReject(Reviewer, "riesgo inaceptable", Actor); + + Assert.True(result.IsSuccess); + Assert.Equal(RolePromotionStatus.Rejected, r.Status); + } + + // ── Execute (INV-RPR5: sólo Approved) ───────────────────────────────────── + + [Fact] + public void Execute_FromApproved_TransitionsToExecuted() + { + var r = MakeApprovedLowRisk(); + + var result = r.Execute(Executor, Actor); + + Assert.True(result.IsSuccess); + Assert.Equal(RolePromotionStatus.Executed, r.Status); + Assert.Contains(r.DomainEvents.GetUncommittedChanges(), e => e is RolePromotionExecutedEvent); + } + + [Fact] + public void Execute_WhenNotApproved_ReturnsInvalidStateTransition() + { + var r = MakePendingManagerApproval(); + + var result = r.Execute(Executor, Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.InvalidStateTransition, result.Error); + } + + // SoD del ejecutor (ADR-UMS-096, INV-RPR3 endurecida): ejecutor ≠ objetivo ≠ aprobador ≠ revisor. + + [Fact] + public void Execute_WhenExecutorIsApprover_ReturnsSoDViolation() + { + var r = MakeApprovedLowRisk(); // aprobado por Approver + + var result = r.Execute(Approver, Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.SegregationOfDutiesViolation, result.Error); + Assert.Equal(RolePromotionStatus.Approved, r.Status); // no transiciona + } + + [Fact] + public void Execute_WhenExecutorIsSecurityReviewer_ReturnsSoDViolation() + { + var r = MakeApprovedHighRisk(); // revisado por Reviewer + + var result = r.Execute(Reviewer, Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.SegregationOfDutiesViolation, result.Error); + Assert.Equal(RolePromotionStatus.Approved, r.Status); // no transiciona + } + + [Fact] + public void Execute_WhenExecutorIsTarget_ReturnsSoDViolation() + { + var r = MakeApprovedLowRisk(); + + var result = r.Execute(Target, Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.SegregationOfDutiesViolation, result.Error); + } + + // ── Verify (INV-RPR5: sólo Executed + SoD verifier ≠ executor/target) ───── + + [Fact] + public void Verify_FromExecuted_TransitionsToVerified() + { + var r = MakeExecuted(); + + var result = r.Verify(Verifier, Actor); + + Assert.True(result.IsSuccess); + Assert.Equal(RolePromotionStatus.Verified, r.Status); + Assert.Contains(r.DomainEvents.GetUncommittedChanges(), e => e is RolePromotionVerifiedEvent); + } + + [Fact] + public void Verify_WhenVerifierIsExecutor_ReturnsSoDViolation() + { + var r = MakeExecuted(); + + var result = r.Verify(Executor, Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.SegregationOfDutiesViolation, result.Error); + } + + [Fact] + public void Verify_WhenVerifierIsTarget_ReturnsSoDViolation() + { + var r = MakeExecuted(); + + var result = r.Verify(Target, Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.SegregationOfDutiesViolation, result.Error); + } + + [Fact] + public void Verify_WhenNotExecuted_ReturnsInvalidStateTransition() + { + var r = MakeApprovedLowRisk(); + + var result = r.Verify(Verifier, Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.InvalidStateTransition, result.Error); + } + + // ── Cancel (sólo Draft) ─────────────────────────────────────────────────── + + [Fact] + public void Cancel_FromDraft_TransitionsToCancelled() + { + var r = MakeDraft(); + + var result = r.Cancel("ya no se requiere", Actor); + + Assert.True(result.IsSuccess); + Assert.Equal(RolePromotionStatus.Cancelled, r.Status); + Assert.Contains(r.DomainEvents.GetUncommittedChanges(), e => e is RolePromotionCancelledEvent); + } + + [Fact] + public void Cancel_WhenNotDraft_ReturnsInvalidStateTransition() + { + var r = MakeSubmitted(); + + var result = r.Cancel("tarde", Actor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.IGA.InvalidStateTransition, result.Error); + } + + // ── Saltos de estado ilegales ───────────────────────────────────────────── + + [Fact] + public void Execute_DirectlyFromDraft_IsIllegal() + { + var r = MakeDraft(); + + Assert.True(r.Execute(Executor, Actor).IsFailure); + } + + [Fact] + public void Verify_DirectlyFromDraft_IsIllegal() + { + var r = MakeDraft(); + + Assert.True(r.Verify(Verifier, Actor).IsFailure); + } + + [Fact] + public void Verified_IsTerminal_CannotBeExecutedAgain() + { + var r = MakeExecuted(); + r.Verify(Verifier, Actor); + + Assert.True(r.Execute(Executor, Actor).IsFailure); + Assert.Equal(RolePromotionStatus.Verified, r.Status); + } + + [Fact] + public void Rejected_IsTerminal_CannotBeApproved() + { + var r = MakePendingManagerApproval(); + r.ManagerReject(Approver, "rechazada", Actor); + + Assert.True(r.ManagerApprove(Approver, Actor).IsFailure); + Assert.Equal(RolePromotionStatus.Rejected, r.Status); + } +} diff --git a/src/apps/ums.api/Ums.Domain.Test/Identity/Auth/IdpAuthOutcomeClassifierTests.cs b/src/apps/ums.api/Ums.Domain.Test/Identity/Auth/IdpAuthOutcomeClassifierTests.cs new file mode 100644 index 00000000..ddf26d9a --- /dev/null +++ b/src/apps/ums.api/Ums.Domain.Test/Identity/Auth/IdpAuthOutcomeClassifierTests.cs @@ -0,0 +1,135 @@ +namespace Ums.Domain.Test.Identity.Auth; + +using System.Collections.Generic; +using Ums.Domain.Identity.Auth; +using Xunit; + +/// +/// FR-042 (ADR-UMS-097 §2.3, slice 2b): pruebas del clasificador de outcome IdP, que es la decisión de +/// seguridad que gobierna el fallback encadenado. Verifica que SOLO señales inequívocas de +/// indisponibilidad de infraestructura se clasifiquen como +/// (autorizan avanzar), y que TODO lo demás sea +/// (fail-closed, anti credential-spraying). +/// +public class IdpAuthOutcomeClassifierTests +{ + private static Result Success() + => Result.Success(new ExternalIdentity( + "user@ransa.pe", "sub-1", "User", new Dictionary())); + + private static Result Failure(string error) + => Result.Failure(error); + + // ── Éxito ──────────────────────────────────────────────────────────────────── + + [Fact] + public void Classify_Success_ReturnsSuccess() + { + Assert.Equal(IdpAuthOutcome.Success, IdpAuthOutcomeClassifier.Classify(Success())); + } + + // ── Indisponibilidad de infraestructura (ÚNICO caso que autoriza avanzar) ───── + + [Theory] + [InlineData("AUTH_012: No IDP adapter registered for strategy 'KEYCLOAK'.")] + [InlineData("AUTH_034: No se pudo obtener el JWKS del issuer. HTTP 503.")] + // G-108: el token endpoint OIDC 5xx/timeout/transporte emite AUTH_035 (rama de INFRA, distinta del + // AUTH_021 de credencial) → clasifica como InfraUnavailable → habilita el fallback encadenado. + [InlineData("AUTH_035: El token endpoint del IdP no está disponible (5xx/timeout/transporte). HTTP 503.")] + [InlineData("AUTH_035: El token endpoint del IdP no está disponible (5xx/timeout/transporte). No se pudo contactar el token endpoint (transporte/timeout).")] + public void Classify_InfraSignals_ReturnsInfraUnavailable(string error) + { + Assert.Equal(IdpAuthOutcome.InfraUnavailable, IdpAuthOutcomeClassifier.Classify(Failure(error))); + } + + // ── Fallos de credencial/token del IdP → TERMINAL (no avanza) ───────────────── + + [Theory] + [InlineData("AUTH_006: Invalid username or password.")] + [InlineData("AUTH_004: Authenticated IDP user has no UMS account.")] + [InlineData("AUTH_020: No se encontró configuración OIDC válida para el proveedor.")] + [InlineData("AUTH_026: La firma del 'id_token' es inválida.")] + [InlineData("AUTH_027: El 'iss' del 'id_token' no coincide con el issuer configurado.")] + [InlineData("AUTH_029: El 'id_token' está expirado ('exp').")] + [InlineData("AUTH_031: El 'nonce' del 'id_token' no coincide con el emitido en la autorización.")] + [InlineData("AUTH_033: El parámetro 'state' del callback no coincide con el emitido.")] + public void Classify_CredentialAndTokenRejections_ReturnsCredentialTerminal(string error) + { + Assert.Equal(IdpAuthOutcome.CredentialTerminal, IdpAuthOutcomeClassifier.Classify(Failure(error))); + } + + // ── GUARDA ANTI-REGRESIÓN DE SEGURIDAD (ADR-UMS-097 §2.3, G-108) ───────────────────────────── + // AUTH_021 es la rama de CREDENCIAL/4xx del intercambio de código (invalid_grant). DEBE seguir + // siendo TERMINAL: si se clasificara como infra, la cadena de fallback probaría la MISMA credencial + // contra cada IdP → credential spraying cross-IdP. Esta prueba NO debe "arreglarse" debilitándola + // (p. ej. metiendo AUTH_021 en InfraUnavailableCodes o clasificando por el texto «503»): la + // resiliencia se ganó separando ESTRUCTURALMENTE el 5xx en AUTH_035, no relajando el 4xx. + + [Fact] + public void Classify_TokenExchange4xxInvalidGrant_StaysTerminal_AntiSprayingGuard() + { + // Un 4xx invalid_grant (rechazo de credencial por el token endpoint) → AUTH_021 → TERMINAL. + Assert.Equal(IdpAuthOutcome.CredentialTerminal, + IdpAuthOutcomeClassifier.Classify(Failure("AUTH_021: Falló el intercambio del código por tokens contra el IdP. HTTP 400."))); + } + + [Fact] + public void Classify_TokenExchange4xx_IsNotInfra_EvenIfMessageMentions503_AntiSprayingGuard() + { + // Blindaje contra clasificación por texto: aunque el mensaje incluyera «503», el CÓDIGO AUTH_021 + // (credencial/4xx) manda ⇒ TERMINAL. La separación es por código/status estructural, nunca por texto. + Assert.Equal(IdpAuthOutcome.CredentialTerminal, + IdpAuthOutcomeClassifier.Classify(Failure("AUTH_021: Falló el intercambio del código por tokens contra el IdP. HTTP 503."))); + Assert.False(IdpAuthOutcomeClassifier.IsInfraUnavailable( + "AUTH_021: Falló el intercambio del código por tokens contra el IdP. HTTP 503.")); + } + + // ── AUTH_013 está reutilizado por flujos no-IdP → ambiguo → TERMINAL (fail-closed) ──── + + [Fact] + public void Classify_Auth013_IsTreatedAsTerminal_FailClosed() + { + Assert.Equal(IdpAuthOutcome.CredentialTerminal, + IdpAuthOutcomeClassifier.Classify(Failure("AUTH_013: Stub IDP only accepts credentials starting with 'MOCK-'."))); + } + + // ── Códigos desconocidos / error sin código → TERMINAL (fail-closed) ───────── + + [Theory] + [InlineData("AUTH_999: algo no clasificable")] + [InlineData("mensaje sin código de error")] + public void Classify_UnknownOrCodeless_ReturnsCredentialTerminal(string error) + { + Assert.Equal(IdpAuthOutcome.CredentialTerminal, IdpAuthOutcomeClassifier.Classify(Failure(error))); + } + + [Fact] + public void Classify_NullResult_ReturnsCredentialTerminal() + { + Assert.Equal(IdpAuthOutcome.CredentialTerminal, IdpAuthOutcomeClassifier.Classify(null!)); + } + + // El helper IsInfraUnavailable es defensivo ante error vacío/nulo (fail-closed). + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("mensaje sin código")] + [InlineData("AUTH_999: desconocido")] + public void IsInfraUnavailable_EmptyNullOrUnknown_ReturnsFalse(string? error) + { + Assert.False(IdpAuthOutcomeClassifier.IsInfraUnavailable(error)); + } + + // ── La lista blanca de infra es minimalista y explícita ────────────────────── + + [Fact] + public void InfraAllowlist_IsExactlyAdapterJwksAndTokenEndpoint() + { + // La lista blanca crece SOLO con señales inequívocas de infra. G-108 añade AUTH_035 (token endpoint + // OIDC 5xx/timeout/transporte). AUTH_021 (4xx credencial) NUNCA entra aquí. + Assert.Equal(new HashSet { "AUTH_012", "AUTH_034", "AUTH_035" }, + new HashSet(IdpAuthOutcomeClassifier.InfraUnavailableCodes)); + Assert.DoesNotContain("AUTH_021", IdpAuthOutcomeClassifier.InfraUnavailableCodes); + } +} diff --git a/src/apps/ums.api/Ums.Domain.Test/Identity/Tenant/TenantParameter/TenantParameterTests.cs b/src/apps/ums.api/Ums.Domain.Test/Identity/Tenant/TenantParameter/TenantParameterTests.cs new file mode 100644 index 00000000..29be7e80 --- /dev/null +++ b/src/apps/ums.api/Ums.Domain.Test/Identity/Tenant/TenantParameter/TenantParameterTests.cs @@ -0,0 +1,292 @@ +namespace Ums.Domain.Test.Identity.Tenant.TenantParameter; + +using Ums.Domain.Identity.Tenant.TenantParameter; +using Xunit; +using TenantAggregate = Ums.Domain.Identity.Tenant.Tenant; +using TenantParameterEntity = Ums.Domain.Identity.Tenant.TenantParameter.TenantParameter; + +public class TenantParameterTests +{ + private static readonly ActorId ValidActor = ActorId.Create("user-001"); + + private static TenantParameterEntity CreateStringParameter(string code = "STR-PARAM", string value = "old", string? allowedValues = null) + { + return TenantParameterEntity.Create( + TenantId.Create(), + code, + "Parametro de prueba", + value, + TenantParameterValueType.String, + TenantParameterCategory.Session, + isSensitive: false, + defaultValue: null, + allowedValues: allowedValues, + ValidActor).Value; + } + + #region Create + + [Fact] + public void Create_WithValidData_ReturnsSuccessActiveAndPreservesFields() + { + var result = TenantParameterEntity.Create( + TenantId.Create(), + "INT-PARAM", + "Parametro entero", + "10", + TenantParameterValueType.Integer, + TenantParameterCategory.Session, + isSensitive: false, + defaultValue: null, + allowedValues: null, + ValidActor); + + Assert.True(result.IsSuccess); + Assert.Equal("INT-PARAM", result.Value.Code.GetValue()); + Assert.Equal("10", result.Value.Value); + Assert.Equal(TenantParameterValueType.Integer, result.Value.ValueType); + Assert.Equal(TenantParameterCategory.Session, result.Value.Category); + Assert.True(result.Value.IsActive); + } + + [Fact] + public void Create_WithEmptyCode_ReturnsFailure() + { + var result = TenantParameterEntity.Create( + TenantId.Create(), + "", + "Parametro sin codigo", + "v", + TenantParameterValueType.String, + TenantParameterCategory.Session, + isSensitive: false, + defaultValue: null, + allowedValues: null, + ValidActor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Common.Required, result.Error); + } + + #endregion + + #region UpdateValue + + [Fact] + public void UpdateValue_WithValidValue_ReturnsSuccessAndRaisesUpdatedEvent() + { + var parameter = CreateStringParameter(value: "old"); + + var result = parameter.UpdateValue("new", ValidActor); + + Assert.True(result.IsSuccess); + Assert.Equal("new", parameter.Value); + var events = parameter.DomainEvents.GetUncommittedChanges().ToList(); + Assert.Contains(events, e => e is TenantParameterUpdatedEvent); + } + + [Fact] + public void UpdateValue_WithTypeMismatch_ReturnsFailureAndKeepsValue() + { + var parameter = TenantParameterEntity.Create( + TenantId.Create(), + "INT-PARAM", + "Parametro entero", + "10", + TenantParameterValueType.Integer, + TenantParameterCategory.Session, + isSensitive: false, + defaultValue: null, + allowedValues: null, + ValidActor).Value; + + var result = parameter.UpdateValue("no-es-entero", ValidActor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.TenantParameter.InvalidValueType, result.Error); + Assert.Equal("10", parameter.Value); + } + + [Fact] + public void UpdateValue_WhenNotInAllowedList_ReturnsFailureAndKeepsValue() + { + var parameter = CreateStringParameter(value: "ES", allowedValues: "ES,EN,PT"); + + var result = parameter.UpdateValue("ZZ", ValidActor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.TenantParameter.ValueNotInAllowedList, result.Error); + Assert.Equal("ES", parameter.Value); + } + + [Fact] + public void UpdateValue_WhenInAllowedList_ReturnsSuccess() + { + var parameter = CreateStringParameter(value: "ES", allowedValues: "ES,EN,PT"); + + var result = parameter.UpdateValue("EN", ValidActor); + + Assert.True(result.IsSuccess); + Assert.Equal("EN", parameter.Value); + } + + #endregion + + #region Deactivate / Reactivate + + [Fact] + public void Deactivate_SetsInactiveAndRaisesDeactivatedEvent() + { + var parameter = CreateStringParameter(); + + var result = parameter.Deactivate(ValidActor); + + Assert.True(result.IsSuccess); + Assert.False(parameter.IsActive); + var events = parameter.DomainEvents.GetUncommittedChanges().ToList(); + Assert.Contains(events, e => e is TenantParameterDeactivatedEvent); + } + + [Fact] + public void Reactivate_SetsActiveAndRaisesReactivatedEvent() + { + var parameter = CreateStringParameter(); + parameter.Deactivate(ValidActor); + + var result = parameter.Reactivate(ValidActor); + + Assert.True(result.IsSuccess); + Assert.True(parameter.IsActive); + var events = parameter.DomainEvents.GetUncommittedChanges().ToList(); + Assert.Contains(events, e => e is TenantParameterReactivatedEvent); + } + + #endregion + + #region Delete (borrado lógico) + + [Fact] + public void Delete_ConVinculoActivo_ReturnsFailure() + { + // Regla de cascada: un parámetro ACTIVO es una referencia viva de la configuración del + // inquilino —el provider lo resuelve ahora mismo por su código— y no se puede eliminar. + var parameter = CreateStringParameter(); + + var result = parameter.Delete(ValidActor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.TenantParameter.HasActiveBinding, result.Error); + Assert.False(parameter.IsDeleted); + } + + [Fact] + public void Delete_TrasDesactivar_ReturnsSuccessYMarcaEliminado() + { + // La desactivación ES la eliminación lógica del vínculo: una vez hecha, el borrado procede. + // La fila conserva todos sus datos (código, valor, auditoría): solo cambia de visibilidad. + var parameter = CreateStringParameter(value: "es-PE"); + parameter.Deactivate(ValidActor); + + var result = parameter.Delete(ValidActor); + + Assert.True(result.IsSuccess); + Assert.True(parameter.IsDeleted); + Assert.Equal("es-PE", parameter.Value); + var events = parameter.DomainEvents.GetUncommittedChanges().ToList(); + Assert.Contains(events, e => e is TenantParameterDeletedEvent); + } + + [Fact] + public void Delete_CuandoYaEstaEliminado_ReturnsFailure() + { + var parameter = CreateStringParameter(); + parameter.Deactivate(ValidActor); + parameter.Delete(ValidActor); + + var result = parameter.Delete(ValidActor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.TenantParameter.AlreadyDeleted, result.Error); + } + + [Fact] + public void Reactivate_TrasEliminar_ReturnsFailure() + { + // El borrado lógico es terminal: reactivar no puede ser la puerta trasera que lo resucite. + var parameter = CreateStringParameter(); + parameter.Deactivate(ValidActor); + parameter.Delete(ValidActor); + + var result = parameter.Reactivate(ValidActor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.TenantParameter.AlreadyDeleted, result.Error); + Assert.False(parameter.IsActive); + } + + #endregion + + #region Uniqueness among active parameters (Tenant aggregate) + + private static TenantAggregate CreateTenant() + { + return TenantAggregate.Create( + Code.Create("TEN-001"), + Name.Create("Inquilino de prueba"), + OrganizationType.INTERNAL, + ValidActor).Value; + } + + [Fact] + public void AddParameter_WithUniqueActiveCode_ReturnsSuccess() + { + var tenant = CreateTenant(); + + var result = tenant.AddParameter( + "PARAM-1", "desc", "v", + TenantParameterValueType.String, TenantParameterCategory.Session, + isSensitive: false, defaultValue: null, allowedValues: null, ValidActor); + + Assert.True(result.IsSuccess); + Assert.Single(tenant.Parameters); + } + + [Fact] + public void AddParameter_WithDuplicateActiveCode_ReturnsFailure() + { + var tenant = CreateTenant(); + tenant.AddParameter( + "PARAM-1", "desc", "v", + TenantParameterValueType.String, TenantParameterCategory.Session, + isSensitive: false, defaultValue: null, allowedValues: null, ValidActor); + + var result = tenant.AddParameter( + "PARAM-1", "otra desc", "w", + TenantParameterValueType.String, TenantParameterCategory.Session, + isSensitive: false, defaultValue: null, allowedValues: null, ValidActor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.TenantParameter.CodeNotUnique, result.Error); + } + + [Fact] + public void AddParameter_WithCodeOfDeactivatedParameter_ReturnsSuccess() + { + var tenant = CreateTenant(); + tenant.AddParameter( + "PARAM-1", "desc", "v", + TenantParameterValueType.String, TenantParameterCategory.Session, + isSensitive: false, defaultValue: null, allowedValues: null, ValidActor); + tenant.DeactivateParameter("PARAM-1", ValidActor); + + var result = tenant.AddParameter( + "PARAM-1", "desc nueva", "w", + TenantParameterValueType.String, TenantParameterCategory.Session, + isSensitive: false, defaultValue: null, allowedValues: null, ValidActor); + + Assert.True(result.IsSuccess); + Assert.Single(tenant.Parameters, p => p.IsActive); + } + + #endregion +} diff --git a/src/apps/ums.api/Ums.Domain.Test/Identity/Tenant/TenantTests.cs b/src/apps/ums.api/Ums.Domain.Test/Identity/Tenant/TenantTests.cs index 0874cdf0..c1a5c9cf 100644 --- a/src/apps/ums.api/Ums.Domain.Test/Identity/Tenant/TenantTests.cs +++ b/src/apps/ums.api/Ums.Domain.Test/Identity/Tenant/TenantTests.cs @@ -1,7 +1,7 @@ +#pragma warning disable S1144 namespace Ums.Domain.Test.Identity.Tenant; using Ums.Domain.Identity.Tenant; -using Ums.Domain.Identity.Tenant.Branding; using Xunit; public class TenantTests @@ -11,17 +11,6 @@ public class TenantTests private static readonly ActorId ValidActor = ActorId.Create("user-001"); private static readonly OrganizationType ValidType = OrganizationType.INTERNAL; private static readonly Description ValidDescription = Description.Create("Test IdP"); - private static BrandingSettings ValidBrandingSettings => BrandingSettings.CreateBuilder() - .WithLogo(Logo.Create("https://cdn.example.com/logo.png"), LogoFormat.Png) - .WithTheme(HexColor.Create("#FF5733"), BackgroundStyle.SolidColor) - .WithTexts( - LoginText.Create("Welcome"), - LoginText.Create("Sign in to continue"), - LoginText.Create("Sign In"), - LoginText.Create("Powered by UMS")) - .WithCustomDomain(null) - .WithMagicLinkFallback(false) - .Build(); #region Create @@ -145,65 +134,219 @@ public void AddBranch_WithEmptyName_ReturnsFailureWithBrokenRules() #endregion - #region RemoveBranch + #region CloseBranch (ADR-0164: el borrado es lógico) [Fact] - public void RemoveBranch_WhenBranchNotFound_ReturnsFailure() + public void CloseBranch_WhenBranchNotFound_ReturnsFailure() { var tenant = Tenant.Create(ValidCode, ValidName, ValidType, ValidActor).Value; var fakeId = IdValueObject.Create(); - var result = tenant.RemoveBranch(fakeId, ValidActor); + var result = tenant.CloseBranch(fakeId, ValidActor); Assert.True(result.IsFailure); Assert.Contains(DomainErrors.Common.NotFound, result.Error); } + /// + /// La prueba central de ADR-0164 §2.1 en el dominio: el cierre NO encoge la colección. Antes esto + /// hacía `_branches.Remove(...)` y el reconciliador de EF lo traducía en un DELETE. + /// [Fact] - public void RemoveBranch_WhenBranchIsActive_ReturnsFailure() + public void CloseBranch_NoQuitaLaSucursalDeLaColeccion() { - var tenant = Tenant.Create(ValidCode, ValidName, ValidType, ValidActor).Value; - var branchCode = Code.Create("BR-001"); - var branchName = Name.Create("Branch One"); - tenant.AddBranch(branchCode, branchName, ValidActor); - var branchId = tenant.Branches.First().GetId(); + var tenant = NuevoInquilinoConSucursal(out var branchId); - var result = tenant.RemoveBranch(branchId, ValidActor); + var result = tenant.CloseBranch(branchId, ValidActor); + + Assert.True(result.IsSuccess); + Assert.Single(tenant.Branches); + Assert.True(tenant.Branches.First().IsClosed); + Assert.False(tenant.Branches.First().IsActive); + Assert.NotNull(tenant.Branches.First().ClosedAtUtc); + Assert.Equal(ValidActor.GetValue(), tenant.Branches.First().ClosedBy); + } + + [Fact] + public void CloseBranch_SobreSucursalActiva_ProcedeSinExigirDesactivarAntes() + { + // Cerrar y desactivar son verbos independientes (ADR-0164 §2.4): ninguno es el paso previo + // del otro. Una sucursal que opera puede cerrarse sin la ceremonia de desactivarla primero. + var tenant = NuevoInquilinoConSucursal(out var branchId); + Assert.True(tenant.Branches.First().IsActive); + + var result = tenant.CloseBranch(branchId, ValidActor); + + Assert.True(result.IsSuccess); + } + + [Fact] + public void CloseBranch_ConUsuariosActivos_EsRechazado() + { + var tenant = NuevoInquilinoConSucursal(out var branchId); + + var result = tenant.CloseBranch(branchId, ValidActor, activeUserCount: 3); Assert.True(result.IsFailure); - Assert.Contains(DomainErrors.Common.Invalid, result.Error); + Assert.Contains(DomainErrors.Tenant.BranchHasLiveReferences, result.Error); + Assert.False(tenant.Branches.First().IsClosed); } [Fact] - public void RemoveBranch_WhenBranchIsInactive_ReturnsSuccess() + public void CloseBranch_ConPerfilesActivos_EsRechazado() { - var tenant = Tenant.Create(ValidCode, ValidName, ValidType, ValidActor).Value; - var branchCode = Code.Create("BR-001"); - var branchName = Name.Create("Branch One"); - tenant.AddBranch(branchCode, branchName, ValidActor); - var branchId = tenant.Branches.First().GetId(); - tenant.DeactivateBranch(branchId, ValidActor); + var tenant = NuevoInquilinoConSucursal(out var branchId); - var result = tenant.RemoveBranch(branchId, ValidActor); + var result = tenant.CloseBranch(branchId, ValidActor, activeProfileCount: 2); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Tenant.BranchHasLiveReferences, result.Error); + } + + [Fact] + public void CloseBranch_ConReferenciasYaEliminadas_Procede() + { + // Contraprueba obligatoria de ADR-0164 §2.2: lo YA eliminado no bloquea. Los recuentos que + // recibe el dominio son de referencias ACTIVAS, así que un cero significa «no queda nada + // vivo», no «nunca hubo nada». + var tenant = NuevoInquilinoConSucursal(out var branchId); + + var result = tenant.CloseBranch(branchId, ValidActor, activeUserCount: 0, activeProfileCount: 0); Assert.True(result.IsSuccess); - Assert.Empty(tenant.Branches); } [Fact] - public void RemoveBranch_RaisesBranchRemovedEvent() + public void CloseBranch_DosVeces_EsRechazado() + { + var tenant = NuevoInquilinoConSucursal(out var branchId); + tenant.CloseBranch(branchId, ValidActor); + + var result = tenant.CloseBranch(branchId, ValidActor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Tenant.BranchAlreadyClosed, result.Error); + } + + [Fact] + public void CloseBranch_RaisesBranchClosedEvent() + { + var tenant = NuevoInquilinoConSucursal(out var branchId); + + tenant.CloseBranch(branchId, ValidActor); + + var events = tenant.DomainEvents.GetUncommittedChanges().ToList(); + var cierre = Assert.Single(events.OfType()); + Assert.Equal("BR-001", cierre.Code); + } + + /// ADR-0164 §2.3: el código de una sucursal cerrada no se libera. + [Fact] + public void AddBranch_ConElCodigoDeUnaSucursalCerrada_EsRechazado() + { + var tenant = NuevoInquilinoConSucursal(out var branchId); + tenant.CloseBranch(branchId, ValidActor); + + var result = tenant.AddBranch(Code.Create("BR-001"), Name.Create("Otra sucursal"), ValidActor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Tenant.BranchCodeNotUnique, result.Error); + } + + [Fact] + public void ReactivateBranch_SobreSucursalCerrada_EsRechazado() + { + // La puerta de atrás cerrada (ADR-0164 §2.4): al estado terminal no se llega —ni se sale de + // él— manipulando el estado reversible. + var tenant = NuevoInquilinoConSucursal(out var branchId); + tenant.CloseBranch(branchId, ValidActor); + + var result = tenant.ReactivateBranch(branchId, ValidActor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Tenant.BranchClosed, result.Error); + Assert.False(tenant.Branches.First().IsActive); + } + + [Fact] + public void DeactivateBranch_SobreSucursalCerrada_EsRechazado() + { + var tenant = NuevoInquilinoConSucursal(out var branchId); + tenant.CloseBranch(branchId, ValidActor); + + var result = tenant.DeactivateBranch(branchId, ValidActor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Tenant.BranchClosed, result.Error); + } + + #endregion + + #region Bitácora de episodios (ADR-0164) + + [Fact] + public void AddBranch_AnotaElEpisodioDeApertura() + { + var tenant = NuevoInquilinoConSucursal(out _); + + var asientos = tenant.Branches.First().PendingLifecycleEntries; + + var apertura = Assert.Single(asientos); + Assert.Equal(BranchLifecycleEpisode.Opened, apertura.Episode); + Assert.Equal(ValidActor.GetValue(), apertura.ActorId); + Assert.Equal("Branch One", apertura.NameSnapshot); + } + + [Fact] + public void LaBitacora_RegistraCadaEpisodioConSuFechaYSuAutor() + { + var tenant = NuevoInquilinoConSucursal(out var branchId); + var otroActor = ActorId.Create("user-002"); + + tenant.DeactivateBranch(branchId, ValidActor, reason: "Cierre temporal por obras"); + tenant.ReactivateBranch(branchId, otroActor, reason: "Reapertura tras obras"); + tenant.CloseBranch(branchId, otroActor, reason: "Cese de operaciones en la plaza"); + + var asientos = tenant.Branches.First().PendingLifecycleEntries.ToList(); + + Assert.Equal(4, asientos.Count); + Assert.Equal(BranchLifecycleEpisode.Opened, asientos[0].Episode); + Assert.Equal(BranchLifecycleEpisode.Deactivated, asientos[1].Episode); + Assert.Equal(BranchLifecycleEpisode.Reactivated, asientos[2].Episode); + Assert.Equal(BranchLifecycleEpisode.Closed, asientos[3].Episode); + + Assert.Equal(ValidActor.GetValue(), asientos[1].ActorId); + Assert.Equal("Cierre temporal por obras", asientos[1].Reason); + Assert.Equal(otroActor.GetValue(), asientos[2].ActorId); + Assert.Equal(otroActor.GetValue(), asientos[3].ActorId); + Assert.Equal("Cese de operaciones en la plaza", asientos[3].Reason); + Assert.All(asientos, a => Assert.NotEqual(default, a.OccurredAtUtc)); + Assert.All(asientos, a => Assert.Equal(branchId.GetValue(), a.BranchId)); + } + + /// + /// La razón de ser de la bitácora: dos ÉPOCAS de la misma sucursal deben poder distinguirse. El + /// asiento guarda la foto (nombre, geocerca) de cada época, así que una auditoría posterior no ve + /// los datos de hoy proyectados sobre un despacho de entonces. + /// + [Fact] + public void LaBitacora_ConservaLaFotoDeCadaEpoca() { var tenant = Tenant.Create(ValidCode, ValidName, ValidType, ValidActor).Value; - var branchCode = Code.Create("BR-001"); - var branchName = Name.Create("Branch One"); - tenant.AddBranch(branchCode, branchName, ValidActor); + tenant.AddBranch(Code.Create("BR-001"), Name.Create("Almacén Callao"), ValidActor, Value.Create("LIMA-CALLAO")); var branchId = tenant.Branches.First().GetId(); + tenant.DeactivateBranch(branchId, ValidActor); + // Entre época y época la sucursal se muda y cambia de nombre. + tenant.UpdateBranch(branchId, Name.Create("Terminal Paita"), Value.Create("PIURA-PAITA"), ValidActor); + tenant.ReactivateBranch(branchId, ValidActor); - tenant.RemoveBranch(branchId, ValidActor); + var asientos = tenant.Branches.First().PendingLifecycleEntries.ToList(); - var events = tenant.DomainEvents.GetUncommittedChanges().ToList(); - Assert.Contains(events, e => e is BranchRemovedEvent); + Assert.Equal("Almacén Callao", asientos[1].NameSnapshot); + Assert.Equal("LIMA-CALLAO", asientos[1].GeofencingSnapshot); + Assert.Equal("Terminal Paita", asientos[2].NameSnapshot); + Assert.Equal("PIURA-PAITA", asientos[2].GeofencingSnapshot); } #endregion @@ -718,352 +861,6 @@ public void RemoveIdentityProvider_RaisesIdentityProviderRemovedEvent() #endregion - #region SetBranding - - [Fact] - public void SetBranding_WithValidData_ReturnsSuccess() - { - var tenant = Tenant.Create(ValidCode, ValidName, ValidType, ValidActor).Value; - - var result = tenant.SetBranding(ValidBrandingSettings, ValidActor); - - Assert.True(result.IsSuccess); - Assert.NotNull(tenant.Branding); - Assert.Equal(LogoFormat.Png, tenant.Branding.LogoFormat); - Assert.Equal(BackgroundStyle.SolidColor, tenant.Branding.BackgroundStyle); - Assert.False(tenant.Branding.MagicLinkFallbackEnabled); - } - - [Fact] - public void SetBranding_WhenAlreadyExists_ReturnsFailure() - { - var tenant = Tenant.Create(ValidCode, ValidName, ValidType, ValidActor).Value; - tenant.SetBranding(ValidBrandingSettings, ValidActor); - - var result = tenant.SetBranding(ValidBrandingSettings, ValidActor); - - Assert.True(result.IsFailure); - Assert.Contains(DomainErrors.Tenant.BrandingAlreadyExists, result.Error); - } - - [Fact] - public void SetBranding_WithInvalidHexColor_ReturnsFailure() - { - var tenant = Tenant.Create(ValidCode, ValidName, ValidType, ValidActor).Value; - var settings = BrandingSettings.CreateBuilder() - .WithLogo(Logo.Create("https://cdn.example.com/logo.png"), LogoFormat.Png) - .WithTheme(HexColor.Create("INVALID"), BackgroundStyle.SolidColor) - .WithTexts( - LoginText.Create("Welcome"), - LoginText.Create("Sign in"), - LoginText.Create("Sign In"), - LoginText.Create("Footer")) - .WithCustomDomain(null) - .WithMagicLinkFallback(false) - .Build(); - - var result = tenant.SetBranding(settings, ValidActor); - - Assert.True(result.IsFailure); - Assert.Contains(DomainErrors.Branding.InvalidHexColor, result.Error); - } - - [Fact] - public void SetBranding_WithCustomDomain_SetsDnsStatusToPending() - { - var tenant = Tenant.Create(ValidCode, ValidName, ValidType, ValidActor).Value; - var settings = BrandingSettings.CreateBuilder() - .WithLogo(Logo.Create("https://cdn.example.com/logo.png"), LogoFormat.Png) - .WithTheme(HexColor.Create("#FF5733"), BackgroundStyle.SolidColor) - .WithTexts( - LoginText.Create("Welcome"), - LoginText.Create("Sign in"), - LoginText.Create("Sign In"), - LoginText.Create("Footer")) - .WithCustomDomain(CustomDomain.Create("login.example.com")) - .WithMagicLinkFallback(false) - .Build(); - - tenant.SetBranding(settings, ValidActor); - - Assert.Equal(DnsVerificationStatus.Pending, tenant.Branding!.DnsVerificationStatus); - Assert.Equal("edge.platform.io", tenant.Branding.DnsCnameTarget.GetValue()); - } - - [Fact] - public void SetBranding_RaisesBrandingCreatedEvent() - { - var tenant = Tenant.Create(ValidCode, ValidName, ValidType, ValidActor).Value; - - tenant.SetBranding(ValidBrandingSettings, ValidActor); - - var events = tenant.DomainEvents.GetUncommittedChanges().ToList(); - Assert.Contains(events, e => e is BrandingCreatedEvent); - } - - #endregion - - #region UpdateBranding - - [Fact] - public void UpdateBranding_WhenBrandingExists_ReturnsSuccess() - { - var tenant = Tenant.Create(ValidCode, ValidName, ValidType, ValidActor).Value; - tenant.SetBranding(ValidBrandingSettings, ValidActor); - - var updatedSettings = BrandingSettings.CreateBuilder() - .WithLogo(Logo.Create("https://cdn.example.com/new-logo.svg"), LogoFormat.Svg) - .WithTheme(HexColor.Create("#00FF00"), BackgroundStyle.Gradient) - .WithTexts( - LoginText.Create("New Welcome"), - LoginText.Create("New secondary"), - LoginText.Create("New button"), - LoginText.Create("New footer")) - .WithCustomDomain(null) - .WithMagicLinkFallback(true) - .Build(); - - var result = tenant.UpdateBranding(updatedSettings, ValidActor); - - Assert.True(result.IsSuccess); - Assert.Equal(LogoFormat.Svg, tenant.Branding!.LogoFormat); - Assert.True(tenant.Branding.MagicLinkFallbackEnabled); - } - - [Fact] - public void UpdateBranding_WhenBrandingNotFound_ReturnsFailure() - { - var tenant = Tenant.Create(ValidCode, ValidName, ValidType, ValidActor).Value; - - var result = tenant.UpdateBranding(ValidBrandingSettings, ValidActor); - - Assert.True(result.IsFailure); - Assert.Contains(DomainErrors.Tenant.BrandingNotFound, result.Error); - } - - [Fact] - public void UpdateBranding_WhenCustomDomainChanged_ResetsDnsStatusToPending() - { - var tenant = Tenant.Create(ValidCode, ValidName, ValidType, ValidActor).Value; - var initialSettings = BrandingSettings.CreateBuilder() - .WithLogo(Logo.Create("https://cdn.example.com/logo.png"), LogoFormat.Png) - .WithTheme(HexColor.Create("#FF5733"), BackgroundStyle.SolidColor) - .WithTexts( - LoginText.Create("Welcome"), - LoginText.Create("Sign in"), - LoginText.Create("Sign In"), - LoginText.Create("Footer")) - .WithCustomDomain(CustomDomain.Create("old.example.com")) - .WithMagicLinkFallback(false) - .Build(); - tenant.SetBranding(initialSettings, ValidActor); - tenant.VerifyBrandingDns(ValidActor); - - var updatedSettings = BrandingSettings.CreateBuilder() - .WithLogo(Logo.Create("https://cdn.example.com/logo.png"), LogoFormat.Png) - .WithTheme(HexColor.Create("#FF5733"), BackgroundStyle.SolidColor) - .WithTexts( - LoginText.Create("Welcome"), - LoginText.Create("Sign in"), - LoginText.Create("Sign In"), - LoginText.Create("Footer")) - .WithCustomDomain(CustomDomain.Create("new.example.com")) - .WithMagicLinkFallback(false) - .Build(); - - tenant.UpdateBranding(updatedSettings, ValidActor); - - Assert.Equal(DnsVerificationStatus.Pending, tenant.Branding!.DnsVerificationStatus); - } - - [Fact] - public void UpdateBranding_RaisesBrandingUpdatedEvent() - { - var tenant = Tenant.Create(ValidCode, ValidName, ValidType, ValidActor).Value; - tenant.SetBranding(ValidBrandingSettings, ValidActor); - - tenant.UpdateBranding(ValidBrandingSettings, ValidActor); - - var events = tenant.DomainEvents.GetUncommittedChanges().ToList(); - Assert.Contains(events, e => e is BrandingUpdatedEvent); - } - - #endregion - - #region RemoveBranding - - [Fact] - public void RemoveBranding_WhenBrandingExists_ReturnsSuccess() - { - var tenant = Tenant.Create(ValidCode, ValidName, ValidType, ValidActor).Value; - tenant.SetBranding(ValidBrandingSettings, ValidActor); - - var result = tenant.RemoveBranding(ValidActor); - - Assert.True(result.IsSuccess); - Assert.Null(tenant.Branding); - } - - [Fact] - public void RemoveBranding_WhenBrandingNotFound_ReturnsFailure() - { - var tenant = Tenant.Create(ValidCode, ValidName, ValidType, ValidActor).Value; - - var result = tenant.RemoveBranding(ValidActor); - - Assert.True(result.IsFailure); - Assert.Contains(DomainErrors.Tenant.BrandingNotFound, result.Error); - } - - [Fact] - public void RemoveBranding_RaisesBrandingRemovedEvent() - { - var tenant = Tenant.Create(ValidCode, ValidName, ValidType, ValidActor).Value; - tenant.SetBranding(ValidBrandingSettings, ValidActor); - - tenant.RemoveBranding(ValidActor); - - var events = tenant.DomainEvents.GetUncommittedChanges().ToList(); - Assert.Contains(events, e => e is BrandingRemovedEvent); - } - - #endregion - - #region VerifyBrandingDns - - [Fact] - public void VerifyBrandingDns_WhenCustomDomainExists_ReturnsSuccess() - { - var tenant = Tenant.Create(ValidCode, ValidName, ValidType, ValidActor).Value; - var settings = BrandingSettings.CreateBuilder() - .WithLogo(Logo.Create("https://cdn.example.com/logo.png"), LogoFormat.Png) - .WithTheme(HexColor.Create("#FF5733"), BackgroundStyle.SolidColor) - .WithTexts( - LoginText.Create("Welcome"), - LoginText.Create("Sign in"), - LoginText.Create("Sign In"), - LoginText.Create("Footer")) - .WithCustomDomain(CustomDomain.Create("login.example.com")) - .WithMagicLinkFallback(false) - .Build(); - tenant.SetBranding(settings, ValidActor); - - var result = tenant.VerifyBrandingDns(ValidActor); - - Assert.True(result.IsSuccess); - Assert.Equal(DnsVerificationStatus.Verified, tenant.Branding!.DnsVerificationStatus); - } - - [Fact] - public void VerifyBrandingDns_WhenNoCustomDomain_ReturnsFailure() - { - var tenant = Tenant.Create(ValidCode, ValidName, ValidType, ValidActor).Value; - tenant.SetBranding(ValidBrandingSettings, ValidActor); - - var result = tenant.VerifyBrandingDns(ValidActor); - - Assert.True(result.IsFailure); - Assert.Contains(DomainErrors.Branding.DnsVerificationRequired, result.Error); - } - - [Fact] - public void VerifyBrandingDns_WhenBrandingNotFound_ReturnsFailure() - { - var tenant = Tenant.Create(ValidCode, ValidName, ValidType, ValidActor).Value; - - var result = tenant.VerifyBrandingDns(ValidActor); - - Assert.True(result.IsFailure); - Assert.Contains(DomainErrors.Tenant.BrandingNotFound, result.Error); - } - - [Fact] - public void VerifyBrandingDns_RaisesBrandingDnsVerifiedEvent() - { - var tenant = Tenant.Create(ValidCode, ValidName, ValidType, ValidActor).Value; - var settings = BrandingSettings.CreateBuilder() - .WithLogo(Logo.Create("https://cdn.example.com/logo.png"), LogoFormat.Png) - .WithTheme(HexColor.Create("#FF5733"), BackgroundStyle.SolidColor) - .WithTexts( - LoginText.Create("Welcome"), - LoginText.Create("Sign in"), - LoginText.Create("Sign In"), - LoginText.Create("Footer")) - .WithCustomDomain(CustomDomain.Create("login.example.com")) - .WithMagicLinkFallback(false) - .Build(); - tenant.SetBranding(settings, ValidActor); - - tenant.VerifyBrandingDns(ValidActor); - - var events = tenant.DomainEvents.GetUncommittedChanges().ToList(); - Assert.Contains(events, e => e is BrandingDnsVerifiedEvent); - } - - #endregion - - #region FailBrandingDns - - [Fact] - public void FailBrandingDns_WhenCustomDomainExists_ReturnsSuccess() - { - var tenant = Tenant.Create(ValidCode, ValidName, ValidType, ValidActor).Value; - var settings = BrandingSettings.CreateBuilder() - .WithLogo(Logo.Create("https://cdn.example.com/logo.png"), LogoFormat.Png) - .WithTheme(HexColor.Create("#FF5733"), BackgroundStyle.SolidColor) - .WithTexts( - LoginText.Create("Welcome"), - LoginText.Create("Sign in"), - LoginText.Create("Sign In"), - LoginText.Create("Footer")) - .WithCustomDomain(CustomDomain.Create("login.example.com")) - .WithMagicLinkFallback(false) - .Build(); - tenant.SetBranding(settings, ValidActor); - - var result = tenant.FailBrandingDns(ValidActor); - - Assert.True(result.IsSuccess); - Assert.Equal(DnsVerificationStatus.Failed, tenant.Branding!.DnsVerificationStatus); - } - - [Fact] - public void FailBrandingDns_WhenNoCustomDomain_ReturnsFailure() - { - var tenant = Tenant.Create(ValidCode, ValidName, ValidType, ValidActor).Value; - tenant.SetBranding(ValidBrandingSettings, ValidActor); - - var result = tenant.FailBrandingDns(ValidActor); - - Assert.True(result.IsFailure); - Assert.Contains(DomainErrors.Branding.DnsVerificationRequired, result.Error); - } - - [Fact] - public void FailBrandingDns_RaisesBrandingDnsFailedEvent() - { - var tenant = Tenant.Create(ValidCode, ValidName, ValidType, ValidActor).Value; - var settings = BrandingSettings.CreateBuilder() - .WithLogo(Logo.Create("https://cdn.example.com/logo.png"), LogoFormat.Png) - .WithTheme(HexColor.Create("#FF5733"), BackgroundStyle.SolidColor) - .WithTexts( - LoginText.Create("Welcome"), - LoginText.Create("Sign in"), - LoginText.Create("Sign In"), - LoginText.Create("Footer")) - .WithCustomDomain(CustomDomain.Create("login.example.com")) - .WithMagicLinkFallback(false) - .Build(); - tenant.SetBranding(settings, ValidActor); - - tenant.FailBrandingDns(ValidActor); - - var events = tenant.DomainEvents.GetUncommittedChanges().ToList(); - Assert.Contains(events, e => e is BrandingDnsFailedEvent); - } - - #endregion - #region Tenant Status Edge Cases [Fact] @@ -1111,4 +908,15 @@ public void ReactivateBranch_WhenBranchNotFound_ReturnsFailure() } #endregion + + /// Inquilino con una sucursal BR-001 «Branch One» recién dada de alta. + private static Tenant NuevoInquilinoConSucursal(out IdValueObject branchId) + { + var tenant = Tenant.Create(ValidCode, ValidName, ValidType, ValidActor).Value; + tenant.AddBranch(Code.Create("BR-001"), Name.Create("Branch One"), ValidActor); + branchId = tenant.Branches.First().GetId(); + return tenant; + } } + +#pragma warning restore S1144 diff --git a/src/apps/ums.api/Ums.Domain.Test/Identity/TenantSignupRequest/TenantSignupRequestTests.cs b/src/apps/ums.api/Ums.Domain.Test/Identity/TenantSignupRequest/TenantSignupRequestTests.cs new file mode 100644 index 00000000..abc250e6 --- /dev/null +++ b/src/apps/ums.api/Ums.Domain.Test/Identity/TenantSignupRequest/TenantSignupRequestTests.cs @@ -0,0 +1,115 @@ +namespace Ums.Domain.Test.Identity.TenantSignupRequest; + +using BeyondNetCode.Shell.Ddd.ValueObjects.Audit; +using Ums.Domain.Identity.TenantSignupRequest; +using Xunit; + +public class TenantSignupRequestTests +{ + private static readonly Name ValidCompanyName = Name.Create("Comercializadora del Sur S.A."); + private static readonly CompanyReference ValidCompanyReference = CompanyReference.Create("RUC-20100412447"); + private static readonly Name ValidContactName = Name.Create("Ana Torres"); + private static readonly Email ValidContactEmail = Email.Create("ana.torres@empresa.com"); + private static readonly ActorId ValidActor = ActorId.Create("user-001"); + + #region Create + + [Fact] + public void Create_WithValidData_ReturnsSuccessInPendingStatus() + { + var result = TenantSignupRequest.Create( + ValidCompanyName, ValidCompanyReference, ValidContactName, ValidContactEmail, ValidActor); + + Assert.True(result.IsSuccess); + Assert.Equal(TenantSignupRequestStatus.Pending, result.Value.Status); + Assert.Null(result.Value.ApprovedTenantId); + Assert.Equal(ValidCompanyName, result.Value.CompanyName); + Assert.Equal(ValidContactEmail, result.Value.ContactEmail); + } + + [Fact] + public void Create_WithEmptyCompanyName_ReturnsFailure() + { + var result = TenantSignupRequest.Create( + Name.Create(""), ValidCompanyReference, ValidContactName, ValidContactEmail, ValidActor); + + Assert.True(result.IsFailure); + } + + [Fact] + public void Create_WithEmptyContactName_ReturnsFailure() + { + var result = TenantSignupRequest.Create( + ValidCompanyName, ValidCompanyReference, Name.Create(""), ValidContactEmail, ValidActor); + + Assert.True(result.IsFailure); + } + + [Fact] + public void Create_WithInvalidContactEmail_ReturnsFailure() + { + var result = TenantSignupRequest.Create( + ValidCompanyName, ValidCompanyReference, ValidContactName, Email.Create("no-es-un-correo"), ValidActor); + + Assert.True(result.IsFailure); + } + + #endregion + + #region Approve + + [Fact] + public void Approve_WhenPending_TransitionsToApprovedAndFixesTenantId() + { + var request = TenantSignupRequest.Create( + ValidCompanyName, ValidCompanyReference, ValidContactName, ValidContactEmail, ValidActor).Value; + var tenantId = TenantId.Create(); + + var result = request.Approve(tenantId, ValidActor); + + Assert.True(result.IsSuccess); + Assert.Equal(TenantSignupRequestStatus.Approved, request.Status); + Assert.NotNull(request.ApprovedTenantId); + Assert.Equal(tenantId.GetValue(), request.ApprovedTenantId!.GetValue()); + } + + [Fact] + public void Approve_WhenAlreadyApproved_ReturnsFailureAndKeepsFirstTenantId() + { + var request = TenantSignupRequest.Create( + ValidCompanyName, ValidCompanyReference, ValidContactName, ValidContactEmail, ValidActor).Value; + var firstTenantId = TenantId.Create(); + request.Approve(firstTenantId, ValidActor); + + var secondTenantId = TenantId.Create(); + var result = request.Approve(secondTenantId, ValidActor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Tenant.SignupRequestNotPending, result.Error); + Assert.Equal(TenantSignupRequestStatus.Approved, request.Status); + Assert.Equal(firstTenantId.GetValue(), request.ApprovedTenantId!.GetValue()); + } + + [Fact] + public void Approve_WhenRejected_ReturnsFailure() + { + var props = new TenantSignupRequestProps( + IdValueObject.Create(), + ValidCompanyName, + ValidCompanyReference, + ValidContactName, + ValidContactEmail, + TenantSignupRequestStatus.Rejected, + null, + AuditValueObject.Create(ValidActor.GetValue())); + var request = new TenantSignupRequest(props); + + var result = request.Approve(TenantId.Create(), ValidActor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Tenant.SignupRequestNotPending, result.Error); + Assert.Null(request.ApprovedTenantId); + } + + #endregion +} diff --git a/src/apps/ums.api/Ums.Domain.Test/Identity/UserAccount/MfaEnrollment/MfaEnrollmentTests.cs b/src/apps/ums.api/Ums.Domain.Test/Identity/UserAccount/MfaEnrollment/MfaEnrollmentTests.cs new file mode 100644 index 00000000..5a86b52e --- /dev/null +++ b/src/apps/ums.api/Ums.Domain.Test/Identity/UserAccount/MfaEnrollment/MfaEnrollmentTests.cs @@ -0,0 +1,91 @@ +namespace Ums.Domain.Test.Identity.UserAccount.MfaEnrollment; + +using Ums.Domain.Identity.UserAccount.MfaEnrollment; +using Xunit; + +public class MfaEnrollmentTests +{ + private static readonly UserAccountId ValidUserAccountId = UserAccountId.Load(Guid.NewGuid().ToString()); + private static readonly ActorId ValidActor = ActorId.Create("user-001"); + + #region Create + + [Fact] + public void Create_WithValidData_ReturnsSuccess() + { + var result = MfaEnrollment.Create(ValidUserAccountId, MfaMethod.Totp, ValidActor); + + Assert.True(result.IsSuccess); + Assert.Equal(ValidUserAccountId, result.Value.UserAccountId); + Assert.Equal(MfaMethod.Totp, result.Value.Method); + } + + [Fact] + public void Create_StartsInEnrolledStatus_NotVerifiedNorNotEnrolled() + { + var result = MfaEnrollment.Create(ValidUserAccountId, MfaMethod.Totp, ValidActor); + + Assert.True(result.IsSuccess); + Assert.Equal(MfaEnrollmentStatus.Enrolled, result.Value.Status); + Assert.NotEqual(MfaEnrollmentStatus.NotEnrolled, result.Value.Status); + Assert.NotEqual(MfaEnrollmentStatus.Verified, result.Value.Status); + } + + [Theory] + [InlineData(1)] // Totp + [InlineData(2)] // WebAuthn + [InlineData(3)] // SmsOtp + [InlineData(4)] // EmailOtp + public void Create_WithAnyMethod_StartsEnrolled(int methodId) + { + var method = DomainEnumeration.FromValue(methodId)!; + + var result = MfaEnrollment.Create(ValidUserAccountId, method, ValidActor); + + Assert.True(result.IsSuccess); + Assert.Equal(method, result.Value.Method); + Assert.Equal(MfaEnrollmentStatus.Enrolled, result.Value.Status); + } + + [Fact] + public void GetId_ReturnsStableNonEmptyIdentifier() + { + var enrollment = MfaEnrollment.Create(ValidUserAccountId, MfaMethod.Totp, ValidActor).Value; + + var first = enrollment.GetId(); + var second = enrollment.GetId(); + + Assert.NotEqual(Guid.Empty, first.GetValue()); + Assert.Equal(first.GetValue(), second.GetValue()); + } + + #endregion + + #region Verify + + [Fact] + public void Verify_WhenEnrolled_TransitionsToVerified() + { + var enrollment = MfaEnrollment.Create(ValidUserAccountId, MfaMethod.Totp, ValidActor).Value; + + var result = enrollment.Verify(ValidActor); + + Assert.True(result.IsSuccess); + Assert.Equal(MfaEnrollmentStatus.Verified, enrollment.Status); + } + + [Fact] + public void Verify_WhenAlreadyVerified_ReturnsFailureAndKeepsStatus() + { + var enrollment = MfaEnrollment.Create(ValidUserAccountId, MfaMethod.Totp, ValidActor).Value; + enrollment.Verify(ValidActor); + + var result = enrollment.Verify(ValidActor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.UserAccount.MfaAlreadyVerified, result.Error); + Assert.Equal(MfaEnrollmentStatus.Verified, enrollment.Status); + } + + #endregion +} diff --git a/src/apps/ums.api/Ums.Domain.Test/Identity/UserAccount/UserAccountTests.cs b/src/apps/ums.api/Ums.Domain.Test/Identity/UserAccount/UserAccountTests.cs index 0aa78502..06f3bea4 100644 --- a/src/apps/ums.api/Ums.Domain.Test/Identity/UserAccount/UserAccountTests.cs +++ b/src/apps/ums.api/Ums.Domain.Test/Identity/UserAccount/UserAccountTests.cs @@ -1,3 +1,4 @@ +#pragma warning disable S4144 // Scaffolding intencional para testing In-Memory namespace Ums.Domain.Test.Identity.UserAccount; using Ums.Domain.Identity.UserAccount; @@ -288,7 +289,7 @@ public void ActivatePassword_WithValidId_ReturnsSuccess() user.AddPassword(ValidPasswordHash, ValidActor); var secondHash = PasswordHash.Create("newhashedpassword456"); user.AddPassword(secondHash, ValidActor); - var firstCredentialId = user.PasswordCredentials.First().Id; + var firstCredentialId = user.PasswordCredentials.First().GetId(); var result = user.ActivatePassword(firstCredentialId, ValidActor); @@ -317,7 +318,7 @@ public void ActivatePassword_WhenBlocked_ReturnsFailure() user.Activate(ValidActor); var reason = Reason.Create("Security violation"); user.Block(reason, ValidActor); - var credentialId = user.PasswordCredentials.First().Id; + var credentialId = user.PasswordCredentials.First().GetId(); var result = user.ActivatePassword(credentialId, ValidActor); @@ -336,7 +337,7 @@ public void RemovePassword_WhenMultiplePasswords_ReturnsSuccess() user.AddPassword(ValidPasswordHash, ValidActor); var secondHash = PasswordHash.Create("newhashedpassword456"); user.AddPassword(secondHash, ValidActor); - var firstCredentialId = user.PasswordCredentials.First().Id; + var firstCredentialId = user.PasswordCredentials.First().GetId(); var result = user.RemovePassword(firstCredentialId, ValidActor); @@ -349,7 +350,7 @@ public void RemovePassword_WhenLastPassword_ReturnsFailure() { var user = UserAccount.Create(ValidTenantId, ValidEmail, ValidCategory, null, null, ValidActor).Value; user.AddPassword(ValidPasswordHash, ValidActor); - var credentialId = user.PasswordCredentials.First().Id; + var credentialId = user.PasswordCredentials.First().GetId(); var result = user.RemovePassword(credentialId, ValidActor); @@ -438,7 +439,7 @@ public void VerifyMfaChallenge_WithValidEnrollment_ReturnsSuccess() var user = UserAccount.Create(ValidTenantId, ValidEmail, ValidCategory, null, null, ValidActor).Value; var method = MfaMethod.Totp; user.EnrollMfa(method, ValidActor); - var enrollmentId = user.MfaEnrollments.First().Id; + var enrollmentId = user.MfaEnrollments.First().GetId(); var result = user.VerifyMfaChallenge(enrollmentId, ValidActor); @@ -464,7 +465,7 @@ public void VerifyMfaChallenge_RaisesMfaVerifiedEvent() var user = UserAccount.Create(ValidTenantId, ValidEmail, ValidCategory, null, null, ValidActor).Value; var method = MfaMethod.Totp; user.EnrollMfa(method, ValidActor); - var enrollmentId = user.MfaEnrollments.First().Id; + var enrollmentId = user.MfaEnrollments.First().GetId(); user.VerifyMfaChallenge(enrollmentId, ValidActor); @@ -478,7 +479,7 @@ public void VerifyMfaChallenge_WhenAlreadyVerified_ReturnsFailure() var user = UserAccount.Create(ValidTenantId, ValidEmail, ValidCategory, null, null, ValidActor).Value; var method = MfaMethod.Totp; user.EnrollMfa(method, ValidActor); - var enrollmentId = user.MfaEnrollments.First().Id; + var enrollmentId = user.MfaEnrollments.First().GetId(); user.VerifyMfaChallenge(enrollmentId, ValidActor); var result = user.VerifyMfaChallenge(enrollmentId, ValidActor); @@ -559,12 +560,16 @@ public void SetValidityPeriod_UpdatesPreviousExpiresAtInEvent() #region RecordAuthenticationAttempt + // ADR-UMS-095: parámetros de política de bloqueo por intentos fallidos usados por las pruebas. + private const int MaxAttempts = 3; + private const int LockoutMinutes = 15; + [Fact] public void RecordAuthenticationAttempt_RaisesAuthenticationAttemptedEvent() { var user = UserAccount.Create(ValidTenantId, ValidEmail, ValidCategory, null, null, ValidActor).Value; - user.RecordAuthenticationAttempt(true, "Valid credentials", "192.168.1.1", ValidActor); + user.RecordAuthenticationAttempt(true, DateTimeOffset.UtcNow, MaxAttempts, LockoutMinutes, "Valid credentials", "192.168.1.1", ValidActor); var events = user.DomainEvents.GetUncommittedChanges().ToList(); Assert.Contains(events, e => e is AuthenticationAttemptedEvent); @@ -575,13 +580,108 @@ public void RecordAuthenticationAttempt_WithFailedAttempt_RaisesEventWithSuccess { var user = UserAccount.Create(ValidTenantId, ValidEmail, ValidCategory, null, null, ValidActor).Value; - user.RecordAuthenticationAttempt(false, "Invalid password", "192.168.1.1", ValidActor); + user.RecordAuthenticationAttempt(false, DateTimeOffset.UtcNow, MaxAttempts, LockoutMinutes, "Invalid password", "192.168.1.1", ValidActor); var events = user.DomainEvents.GetUncommittedChanges().ToList(); var authEvent = events.OfType().First(); Assert.False(authEvent.Success); } + // ── ADR-UMS-095: bloqueo temporal de cuenta por intentos fallidos ────────────── + + [Fact] + public void RecordAuthenticationAttempt_AfterMaxFailedAttempts_LocksAccount() + { + var user = UserAccount.Create(ValidTenantId, ValidEmail, ValidCategory, null, null, ValidActor).Value; + var now = DateTimeOffset.UtcNow; + + for (var attempt = 0; attempt < MaxAttempts; attempt++) + { + user.RecordAuthenticationAttempt(false, now, MaxAttempts, LockoutMinutes, "Invalid password", "192.168.1.1", ValidActor); + } + + Assert.True(user.IsLockedOut(now)); + Assert.Equal(MaxAttempts, user.FailedLoginAttempts); + Assert.Equal(now.AddMinutes(LockoutMinutes), user.LockedUntil); + } + + [Fact] + public void RecordAuthenticationAttempt_BelowThreshold_DoesNotLock() + { + var user = UserAccount.Create(ValidTenantId, ValidEmail, ValidCategory, null, null, ValidActor).Value; + var now = DateTimeOffset.UtcNow; + + user.RecordAuthenticationAttempt(false, now, MaxAttempts, LockoutMinutes, "Invalid password", "192.168.1.1", ValidActor); + + Assert.False(user.IsLockedOut(now)); + Assert.Equal(1, user.FailedLoginAttempts); + Assert.Null(user.LockedUntil); + } + + [Fact] + public void RecordAuthenticationAttempt_Success_ResetsCounterAndLock() + { + var user = UserAccount.Create(ValidTenantId, ValidEmail, ValidCategory, null, null, ValidActor).Value; + var now = DateTimeOffset.UtcNow; + user.RecordAuthenticationAttempt(false, now, MaxAttempts, LockoutMinutes, "Invalid password", "192.168.1.1", ValidActor); + user.RecordAuthenticationAttempt(false, now, MaxAttempts, LockoutMinutes, "Invalid password", "192.168.1.1", ValidActor); + + user.RecordAuthenticationAttempt(true, now, MaxAttempts, LockoutMinutes, "Login successful", "192.168.1.1", ValidActor); + + Assert.Equal(0, user.FailedLoginAttempts); + Assert.Null(user.LockedUntil); + Assert.False(user.IsLockedOut(now)); + } + + [Fact] + public void RecordAuthenticationAttempt_WhileLocked_DoesNotIncrement() + { + var user = UserAccount.Create(ValidTenantId, ValidEmail, ValidCategory, null, null, ValidActor).Value; + var now = DateTimeOffset.UtcNow; + for (var attempt = 0; attempt < MaxAttempts; attempt++) + { + user.RecordAuthenticationAttempt(false, now, MaxAttempts, LockoutMinutes, "Invalid password", "192.168.1.1", ValidActor); + } + var lockedUntilAfterLock = user.LockedUntil; + + // Un intento fallido adicional dentro de la ventana no incrementa ni extiende el bloqueo. + user.RecordAuthenticationAttempt(false, now.AddMinutes(1), MaxAttempts, LockoutMinutes, "Invalid password", "192.168.1.1", ValidActor); + + Assert.Equal(MaxAttempts, user.FailedLoginAttempts); + Assert.Equal(lockedUntilAfterLock, user.LockedUntil); + } + + [Fact] + public void IsLockedOut_AfterLockExpires_ReturnsFalse() + { + var user = UserAccount.Create(ValidTenantId, ValidEmail, ValidCategory, null, null, ValidActor).Value; + var now = DateTimeOffset.UtcNow; + for (var attempt = 0; attempt < MaxAttempts; attempt++) + { + user.RecordAuthenticationAttempt(false, now, MaxAttempts, LockoutMinutes, "Invalid password", "192.168.1.1", ValidActor); + } + + Assert.True(user.IsLockedOut(now)); + Assert.False(user.IsLockedOut(now.AddMinutes(LockoutMinutes).AddSeconds(1))); + } + + [Fact] + public void RecordAuthenticationAttempt_FailAfterLockExpired_StartsNewCount() + { + var user = UserAccount.Create(ValidTenantId, ValidEmail, ValidCategory, null, null, ValidActor).Value; + var now = DateTimeOffset.UtcNow; + for (var attempt = 0; attempt < MaxAttempts; attempt++) + { + user.RecordAuthenticationAttempt(false, now, MaxAttempts, LockoutMinutes, "Invalid password", "192.168.1.1", ValidActor); + } + var afterExpiry = now.AddMinutes(LockoutMinutes).AddSeconds(1); + + // Tras expirar el bloqueo, un nuevo fallo vuelve a contar (el contador sigue en curso hasta un login exitoso). + user.RecordAuthenticationAttempt(false, afterExpiry, MaxAttempts, LockoutMinutes, "Invalid password", "192.168.1.1", ValidActor); + + Assert.Equal(MaxAttempts + 1, user.FailedLoginAttempts); + } + #endregion #region RevokeEnrollment @@ -591,7 +691,7 @@ public void RevokeEnrollment_WithValidId_ReturnsSuccessAndRemovesEnrollment() { var user = UserAccount.Create(ValidTenantId, ValidEmail, ValidCategory, null, null, ValidActor).Value; user.EnrollMfa(MfaMethod.Totp, ValidActor); - var enrollmentId = user.MfaEnrollments.First().Id; + var enrollmentId = user.MfaEnrollments.First().GetId(); var result = user.RevokeEnrollment(enrollmentId, ValidActor); @@ -616,7 +716,7 @@ public void RevokeEnrollment_RaisesMfaEnrollmentRevokedEvent() { var user = UserAccount.Create(ValidTenantId, ValidEmail, ValidCategory, null, null, ValidActor).Value; user.EnrollMfa(MfaMethod.Totp, ValidActor); - var enrollmentId = user.MfaEnrollments.First().Id; + var enrollmentId = user.MfaEnrollments.First().GetId(); user.RevokeEnrollment(enrollmentId, ValidActor); @@ -638,7 +738,7 @@ public void HasVerifiedMfaEnrollment_WhenVerified_ReturnsTrue() { var user = UserAccount.Create(ValidTenantId, ValidEmail, ValidCategory, null, null, ValidActor).Value; user.EnrollMfa(MfaMethod.Totp, ValidActor); - var enrollmentId = user.MfaEnrollments.First().Id; + var enrollmentId = user.MfaEnrollments.First().GetId(); user.VerifyMfaChallenge(enrollmentId, ValidActor); Assert.True(user.HasVerifiedMfaEnrollment([MfaMethod.Totp])); @@ -657,7 +757,7 @@ public void HasVerifiedMfaEnrollment_WhenMethodNotAllowed_ReturnsFalse() { var user = UserAccount.Create(ValidTenantId, ValidEmail, ValidCategory, null, null, ValidActor).Value; user.EnrollMfa(MfaMethod.Totp, ValidActor); - var enrollmentId = user.MfaEnrollments.First().Id; + var enrollmentId = user.MfaEnrollments.First().GetId(); user.VerifyMfaChallenge(enrollmentId, ValidActor); Assert.False(user.HasVerifiedMfaEnrollment([MfaMethod.EmailOtp])); @@ -811,3 +911,5 @@ public void Deny_DeniedIsTerminalState_CannotBeActivated() #endregion } + +#pragma warning restore S4144 diff --git a/src/apps/ums.api/Ums.Domain.Test/Identity/UserManagementDelegation/UserManagementDelegationTests.cs b/src/apps/ums.api/Ums.Domain.Test/Identity/UserManagementDelegation/UserManagementDelegationTests.cs index 44137057..175a1fe9 100644 --- a/src/apps/ums.api/Ums.Domain.Test/Identity/UserManagementDelegation/UserManagementDelegationTests.cs +++ b/src/apps/ums.api/Ums.Domain.Test/Identity/UserManagementDelegation/UserManagementDelegationTests.cs @@ -135,6 +135,55 @@ public void Activate_RaisesDelegationActivatedEvent() Assert.Contains(events, e => e is DelegationActivatedEvent); } + [Fact] + public void Activate_WhenRequiresApproval_IsBlockedFailClosed() + { + // G-056: una delegación con requiresApproval=true NO puede activarse directamente. + // La única vía legítima a Active es SubmitForApproval → Approve. + var delegation = Ums.Domain.Identity.UserManagementDelegation.UserManagementDelegation.Create( + ValidTenantId, ValidDelegatingAdmin, ValidDelegatedAdmin, + ValidScopeType, null, ValidActions, ValidFrom, ValidUntil, null, requiresApproval: true, ValidActor).Value; + + var result = delegation.Activate(ValidActor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Delegation.ApprovalRequired, result.Error); + Assert.Equal(DelegationStatus.Draft, delegation.Status); + } + + [Fact] + public void Activate_WhenRequiresApproval_AndApproved_ReturnsSuccess() + { + // G-056: tras la aprobación, la delegación sí queda Active. La compuerta no impide el + // flujo legítimo, sólo el atajo que la eludía. + var delegation = Ums.Domain.Identity.UserManagementDelegation.UserManagementDelegation.Create( + ValidTenantId, ValidDelegatingAdmin, ValidDelegatedAdmin, + ValidScopeType, null, ValidActions, ValidFrom, ValidUntil, null, requiresApproval: true, ValidActor).Value; + + delegation.SubmitForApproval(Guid.NewGuid(), ValidActor); + var approveResult = delegation.Approve(ValidActor); + + Assert.True(approveResult.IsSuccess); + Assert.Equal(DelegationStatus.Active, delegation.Status); + } + + [Fact] + public void Activate_WhenRequiresApproval_AndSubmittedButNotApproved_IsBlocked() + { + // G-056: estando en PendingApproval, Activate directo sigue vedado mientras persista + // requiresApproval — sólo Approve puede promover a Active. + var delegation = Ums.Domain.Identity.UserManagementDelegation.UserManagementDelegation.Create( + ValidTenantId, ValidDelegatingAdmin, ValidDelegatedAdmin, + ValidScopeType, null, ValidActions, ValidFrom, ValidUntil, null, requiresApproval: true, ValidActor).Value; + + delegation.SubmitForApproval(Guid.NewGuid(), ValidActor); + var result = delegation.Activate(ValidActor); + + Assert.True(result.IsFailure); + Assert.Contains(DomainErrors.Delegation.ApprovalRequired, result.Error); + Assert.Equal(DelegationStatus.PendingApproval, delegation.Status); + } + #endregion #region SubmitForApproval @@ -188,6 +237,49 @@ public void Approve_WhenNotPendingApproval_ReturnsFailure() Assert.True(result.IsFailure); } + // G-150 / INV-DEL8 (separación de funciones): el administrador delegante no puede autoaprobar + // su propia delegación. El aprobador (actorId) que coincide con DelegatingAdminId → falla, + // el estado NO transita a Active y el motivo es SelfApprovalNotAllowed. + [Fact] + public void Approve_WhenApproverIsDelegatingAdmin_ReturnsFailure() + { + var delegatingAdminId = Guid.NewGuid(); + var delegation = Ums.Domain.Identity.UserManagementDelegation.UserManagementDelegation.Create( + ValidTenantId, + UserAccountId.Load(delegatingAdminId), + ValidDelegatedAdmin, + ValidScopeType, null, ValidActions, ValidFrom, ValidUntil, null, true, + ActorId.Create(delegatingAdminId.ToString())).Value; + delegation.SubmitForApproval(Guid.NewGuid(), ActorId.Create(delegatingAdminId.ToString())); + + // El mismo administrador delegante intenta aprobar → SoD lo impide. + var result = delegation.Approve(ActorId.Create(delegatingAdminId.ToString())); + + Assert.True(result.IsFailure); + Assert.Contains("self_approval_not_allowed", result.Error, StringComparison.OrdinalIgnoreCase); + Assert.Equal(DelegationStatus.PendingApproval, delegation.Status); + } + + // Control positivo de la SoD: un aprobador DISTINTO del delegante sí puede aprobar. + [Fact] + public void Approve_WhenApproverIsDifferentFromDelegatingAdmin_ReturnsSuccess() + { + var delegatingAdminId = Guid.NewGuid(); + var approverId = Guid.NewGuid(); + var delegation = Ums.Domain.Identity.UserManagementDelegation.UserManagementDelegation.Create( + ValidTenantId, + UserAccountId.Load(delegatingAdminId), + ValidDelegatedAdmin, + ValidScopeType, null, ValidActions, ValidFrom, ValidUntil, null, true, + ActorId.Create(delegatingAdminId.ToString())).Value; + delegation.SubmitForApproval(Guid.NewGuid(), ActorId.Create(delegatingAdminId.ToString())); + + var result = delegation.Approve(ActorId.Create(approverId.ToString())); + + Assert.True(result.IsSuccess); + Assert.Equal(DelegationStatus.Active, delegation.Status); + } + #endregion #region Reject diff --git a/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/ActionCodeTests.cs b/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/ActionCodeTests.cs index 65140ebb..cbc3d846 100644 --- a/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/ActionCodeTests.cs +++ b/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/ActionCodeTests.cs @@ -36,7 +36,7 @@ public void Create_WithEmptyString_HasBrokenRuleForRequired() var brokenRules = result.BrokenRules.GetBrokenRules(); Assert.NotEmpty(brokenRules); - Assert.Contains(DomainErrors.ValueObject.PropertyRequired, brokenRules.First().Message); + Assert.Contains(DomainErrors.ValueObject.PropertyRequired, brokenRules[0].Message); } [Fact] @@ -56,7 +56,7 @@ public void Create_WithCodeOver50Chars_HasBrokenRuleForTooLong() var brokenRules = result.BrokenRules.GetBrokenRules(); Assert.NotEmpty(brokenRules); - Assert.Contains(DomainErrors.SystemSuite.ActionCodeTooLong, brokenRules.First().Message); + Assert.Contains(DomainErrors.SystemSuite.ActionCodeTooLong, brokenRules[0].Message); } [Fact] diff --git a/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/ConfigurationKeyTests.cs b/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/ConfigurationKeyTests.cs index b99506fd..e3be7a1d 100644 --- a/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/ConfigurationKeyTests.cs +++ b/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/ConfigurationKeyTests.cs @@ -36,7 +36,7 @@ public void Create_WithEmptyString_HasBrokenRuleForRequired() var brokenRules = result.BrokenRules.GetBrokenRules(); Assert.NotEmpty(brokenRules); - Assert.Contains(DomainErrors.ValueObject.PropertyRequired, brokenRules.First().Message); + Assert.Contains(DomainErrors.ValueObject.PropertyRequired, brokenRules[0].Message); } [Fact] @@ -56,7 +56,7 @@ public void Create_WithKeyOver100Chars_HasBrokenRuleForTooLong() var brokenRules = result.BrokenRules.GetBrokenRules(); Assert.NotEmpty(brokenRules); - Assert.Contains(DomainErrors.SystemSuite.ConfigurationKeyTooLong, brokenRules.First().Message); + Assert.Contains(DomainErrors.SystemSuite.ConfigurationKeyTooLong, brokenRules[0].Message); } [Fact] diff --git a/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/ConfigurationValueTests.cs b/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/ConfigurationValueTests.cs index 5781c5e5..16e570f4 100644 --- a/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/ConfigurationValueTests.cs +++ b/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/ConfigurationValueTests.cs @@ -36,7 +36,7 @@ public void Create_WithEmptyString_HasBrokenRuleForRequired() var brokenRules = result.BrokenRules.GetBrokenRules(); Assert.NotEmpty(brokenRules); - Assert.Contains(DomainErrors.ValueObject.PropertyRequired, brokenRules.First().Message); + Assert.Contains(DomainErrors.ValueObject.PropertyRequired, brokenRules[0].Message); } [Fact] @@ -56,7 +56,7 @@ public void Create_WithValueOver2000Chars_HasBrokenRuleForTooLong() var brokenRules = result.BrokenRules.GetBrokenRules(); Assert.NotEmpty(brokenRules); - Assert.Contains(DomainErrors.SystemSuite.ConfigurationValueTooLong, brokenRules.First().Message); + Assert.Contains(DomainErrors.SystemSuite.ConfigurationValueTooLong, brokenRules[0].Message); } [Fact] diff --git a/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/CustomDomainTests.cs b/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/CustomDomainTests.cs index a73c23fe..8cf3d361 100644 --- a/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/CustomDomainTests.cs +++ b/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/CustomDomainTests.cs @@ -36,7 +36,7 @@ public void Create_WithEmptyString_HasBrokenRuleForRequired() var brokenRules = result.BrokenRules.GetBrokenRules(); Assert.NotEmpty(brokenRules); - Assert.Contains(DomainErrors.ValueObject.PropertyRequired, brokenRules.First().Message); + Assert.Contains(DomainErrors.ValueObject.PropertyRequired, brokenRules[0].Message); } [Fact] @@ -55,7 +55,7 @@ public void Create_WithInvalidDomain_HasBrokenRuleForInvalidFormat() var brokenRules = result.BrokenRules.GetBrokenRules(); Assert.NotEmpty(brokenRules); - Assert.Contains(DomainErrors.Branding.InvalidCustomDomain, brokenRules.First().Message); + Assert.Contains(DomainErrors.Branding.InvalidCustomDomain, brokenRules[0].Message); } [Fact] diff --git a/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/EmailTests.cs b/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/EmailTests.cs index 30a90b18..51764c1e 100644 --- a/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/EmailTests.cs +++ b/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/EmailTests.cs @@ -36,7 +36,7 @@ public void Create_WithEmptyString_HasBrokenRuleForRequired() var brokenRules = result.BrokenRules.GetBrokenRules(); Assert.NotEmpty(brokenRules); - Assert.Contains(DomainErrors.ValueObject.PropertyRequired, brokenRules.First().Message); + Assert.Contains(DomainErrors.ValueObject.PropertyRequired, brokenRules[0].Message); } [Fact] @@ -55,7 +55,7 @@ public void Create_WithInvalidEmailFormat_HasBrokenRuleForInvalidEmail() var brokenRules = result.BrokenRules.GetBrokenRules(); Assert.NotEmpty(brokenRules); - Assert.Contains(DomainErrors.UserAccount.InvalidEmail, brokenRules.First().Message); + Assert.Contains(DomainErrors.UserAccount.InvalidEmail, brokenRules[0].Message); } [Fact] diff --git a/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/GenericStringValidatorTests.cs b/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/GenericStringValidatorTests.cs index 799918bf..ed72e6fb 100644 --- a/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/GenericStringValidatorTests.cs +++ b/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/GenericStringValidatorTests.cs @@ -14,7 +14,7 @@ public void AddRules_WithRequiredAndEmptyValue_AddsBrokenRule() var brokenRules = vo.BrokenRules.GetBrokenRules(); Assert.NotEmpty(brokenRules); - Assert.Contains(DomainErrors.ValueObject.PropertyRequired, brokenRules.First().Message); + Assert.Contains(DomainErrors.ValueObject.PropertyRequired, brokenRules[0].Message); } [Fact] diff --git a/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/HexColorTests.cs b/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/HexColorTests.cs index 47407c8c..e5293afb 100644 --- a/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/HexColorTests.cs +++ b/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/HexColorTests.cs @@ -53,7 +53,7 @@ public void Create_WithEmptyString_HasBrokenRuleForRequired() var brokenRules = result.BrokenRules.GetBrokenRules(); Assert.NotEmpty(brokenRules); - Assert.Contains(DomainErrors.ValueObject.PropertyRequired, brokenRules.First().Message); + Assert.Contains(DomainErrors.ValueObject.PropertyRequired, brokenRules[0].Message); } [Fact] @@ -72,7 +72,7 @@ public void Create_WithInvalidHex_HasBrokenRuleForInvalidFormat() var brokenRules = result.BrokenRules.GetBrokenRules(); Assert.NotEmpty(brokenRules); - Assert.Contains(DomainErrors.Branding.InvalidHexColor, brokenRules.First().Message); + Assert.Contains(DomainErrors.Branding.InvalidHexColor, brokenRules[0].Message); } [Fact] diff --git a/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/IdValueObjectTests.cs b/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/IdValueObjectTests.cs index 5a6dd216..811cefc7 100644 --- a/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/IdValueObjectTests.cs +++ b/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/IdValueObjectTests.cs @@ -702,35 +702,6 @@ public void BranchId_Load_FromString_ReturnsParsedGuid() #endregion - #region BrandingId - - [Fact] - public void BrandingId_Create_GeneratesNewGuid() - { - var id1 = BrandingId.Create(); - var id2 = BrandingId.Create(); - - Assert.NotEqual(id1, id2); - } - - [Fact] - public void BrandingId_Load_FromGuid_ReturnsSameGuid() - { - var id = BrandingId.Load(TestGuid); - - Assert.Equal(TestGuid, id.GetValue()); - } - - [Fact] - public void BrandingId_Load_FromString_ReturnsParsedGuid() - { - var id = BrandingId.Load("12345678-1234-1234-1234-123456789abc"); - - Assert.Equal(TestGuid, id.GetValue()); - } - - #endregion - #region DocumentTypeId [Fact] diff --git a/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/LoginTextTests.cs b/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/LoginTextTests.cs index cbef6f54..413162a7 100644 --- a/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/LoginTextTests.cs +++ b/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/LoginTextTests.cs @@ -56,7 +56,7 @@ public void Create_WithTextOver200Chars_HasBrokenRuleForTooLong() var brokenRules = result.BrokenRules.GetBrokenRules(); Assert.NotEmpty(brokenRules); - Assert.Contains(DomainErrors.Branding.LoginTextTooLong, brokenRules.First().Message); + Assert.Contains(DomainErrors.Branding.LoginTextTooLong, brokenRules[0].Message); } [Fact] diff --git a/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/LogoTests.cs b/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/LogoTests.cs index fc5e1be9..adf95a7e 100644 --- a/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/LogoTests.cs +++ b/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/LogoTests.cs @@ -36,7 +36,7 @@ public void Create_WithEmptyString_HasBrokenRuleForRequired() var brokenRules = result.BrokenRules.GetBrokenRules(); Assert.NotEmpty(brokenRules); - Assert.Contains(DomainErrors.ValueObject.PropertyRequired, brokenRules.First().Message); + Assert.Contains(DomainErrors.ValueObject.PropertyRequired, brokenRules[0].Message); } [Fact] diff --git a/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/TemplateVersionTests.cs b/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/TemplateVersionTests.cs index 720d857b..77f6324d 100644 --- a/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/TemplateVersionTests.cs +++ b/src/apps/ums.api/Ums.Domain.Test/Kernel/ValueObjects/TemplateVersionTests.cs @@ -39,4 +39,30 @@ public void Initial_ReturnsVersion010() Assert.Equal("0.1.0", result.GetValue()); Assert.Empty(result.BrokenRules.GetBrokenRules()); } + + [Fact] + public void Segments_ParseSemVer() + { + var version = TemplateVersion.Create(3, 4, 5); + + Assert.Equal(3, version.Major); + Assert.Equal(4, version.Minor); + Assert.Equal(5, version.Patch); + } + + [Fact] + public void Next_IncrementsMinorAndResetsPatch() + { + Assert.Equal("0.2.0", TemplateVersion.Initial().Next().GetValue()); + Assert.Equal("1.3.0", TemplateVersion.Create(1, 2, 7).Next().GetValue()); + } + + [Fact] + public void CompareTo_OrdersNumericallyBySegment() + { + Assert.True(TemplateVersion.Create(2, 0, 0).CompareTo(TemplateVersion.Create(10, 0, 0)) < 0); + Assert.True(TemplateVersion.Create(1, 2, 0).CompareTo(TemplateVersion.Create(1, 1, 9)) > 0); + Assert.Equal(0, TemplateVersion.Create(1, 1, 1).CompareTo(TemplateVersion.Create(1, 1, 1))); + Assert.True(TemplateVersion.Initial().CompareTo(null) > 0); + } } diff --git a/src/apps/ums.api/Ums.Domain/Approvals/AccessEnforcementPolicy/AccessEnforcementPolicy.cs b/src/apps/ums.api/Ums.Domain/Approvals/AccessEnforcementPolicy/AccessEnforcementPolicy.cs index a3e2523c..0d3a4085 100644 --- a/src/apps/ums.api/Ums.Domain/Approvals/AccessEnforcementPolicy/AccessEnforcementPolicy.cs +++ b/src/apps/ums.api/Ums.Domain/Approvals/AccessEnforcementPolicy/AccessEnforcementPolicy.cs @@ -11,6 +11,7 @@ private AccessEnforcementPolicy(AccessEnforcementPolicyProps props) : base(props public RoleId? RoleId => Props.RoleId; public AccessEnforcementAction EnforcementAction => Props.EnforcementAction; public bool IsActive => Props.IsActive; + public int GracePeriodDays => Props.GracePeriodDays; public AccessEnforcementPolicyId GetId() => AccessEnforcementPolicyId.Load(Props.Id.GetValue()); @@ -19,14 +20,21 @@ public static Result Create( ProfileId? profileId, RoleId? roleId, AccessEnforcementAction enforcementAction, - ActorId createdBy) + ActorId createdBy, + int gracePeriodDays = 0) { if (profileId is null && roleId is null) { return Result.Failure(DomainErrors.Approvals.PolicyRequiresProfileOrRole); } - var props = new AccessEnforcementPolicyProps(IdValueObject.Create(), tenantId, profileId, roleId, enforcementAction, true, createdBy); + // G-120: el periodo de gracia no puede ser negativo (0 = enforcement inmediato). + if (gracePeriodDays < 0) + { + return Result.Failure(DomainErrors.Approvals.GracePeriodInvalid); + } + + var props = new AccessEnforcementPolicyProps(IdValueObject.Create(), tenantId, profileId, roleId, enforcementAction, true, gracePeriodDays, createdBy); var policy = new AccessEnforcementPolicy(props); if (!policy.IsValid()) @@ -57,6 +65,21 @@ public Result Deactivate(ActorId updatedBy) public Result UpdateAction(AccessEnforcementAction newAction, ActorId updatedBy) { + // Irreversibilidad (G-051): Deactivate es una transicion terminal — el agregado no + // expone reactivacion — por lo que una politica desactivada queda congelada. Mutar + // su accion de enforcement sobre un estado terminal es una transicion invalida: + // se rechaza fail-closed, coherente con el patron de estados terminales de los + // agregados de Configuration (FlagArchivedCannotChange, AppConfigAlreadyArchived). + if (!IsActive) + { + BrokenRules.Add(new BrokenRule(nameof(IsActive), DomainErrors.Approvals.PolicyInactiveCannotUpdate)); + } + + if (!IsValid()) + { + return Result.Failure(BrokenRules.GetBrokenRulesAsString()); + } + Props.EnforcementAction = newAction; TrackingState.MarkAsDirty(); Props.Audit.Update(updatedBy.GetValue()); diff --git a/src/apps/ums.api/Ums.Domain/Approvals/AccessEnforcementPolicy/AccessEnforcementPolicyProps.cs b/src/apps/ums.api/Ums.Domain/Approvals/AccessEnforcementPolicy/AccessEnforcementPolicyProps.cs index 46d5ca58..3aeb706c 100644 --- a/src/apps/ums.api/Ums.Domain/Approvals/AccessEnforcementPolicy/AccessEnforcementPolicyProps.cs +++ b/src/apps/ums.api/Ums.Domain/Approvals/AccessEnforcementPolicy/AccessEnforcementPolicyProps.cs @@ -8,6 +8,9 @@ public class AccessEnforcementPolicyProps : IProps public RoleId? RoleId { get; set; } public AccessEnforcementAction EnforcementAction { get; set; } public bool IsActive { get; set; } + // G-120 (FR-053): periodo de gracia en días antes de que la acción de enforcement se aplique a un + // documento crítico vencido/faltante. 0 = enforcement inmediato (comportamiento previo). + public int GracePeriodDays { get; set; } public AuditValueObject Audit { get; private set; } public AccessEnforcementPolicyProps( @@ -17,6 +20,7 @@ public AccessEnforcementPolicyProps( RoleId? roleId, AccessEnforcementAction enforcementAction, bool isActive, + int gracePeriodDays, ActorId createdBy) { Id = id; @@ -25,6 +29,7 @@ public AccessEnforcementPolicyProps( RoleId = roleId; EnforcementAction = enforcementAction; IsActive = isActive; + GracePeriodDays = gracePeriodDays; Audit = AuditValueObject.Create(createdBy.GetValue()); } diff --git a/src/apps/ums.api/Ums.Domain/Approvals/ApprovalRequest/ApprovalRequest.cs b/src/apps/ums.api/Ums.Domain/Approvals/ApprovalRequest/ApprovalRequest.cs index 50dbb985..c1391f0f 100644 --- a/src/apps/ums.api/Ums.Domain/Approvals/ApprovalRequest/ApprovalRequest.cs +++ b/src/apps/ums.api/Ums.Domain/Approvals/ApprovalRequest/ApprovalRequest.cs @@ -65,11 +65,9 @@ public Result Approve(ActorId approvedBy, RoleId grantedRoleId, string? decision Props.Status = ApprovalStatus.Approved; Props.GrantedRoleId = grantedRoleId; Props.DecisionReason = decisionReason; - DomainEvents.RaiseEvent(new ApprovalRequestApprovedEvent( - Props.Id.GetValue(), - Props.WorkflowId.GetValue(), - approvedBy.GetValue(), - DateTime.UtcNow)); + // El agregado no emite eventos de dominio en la transicion: el despacho es + // responsabilidad del handler/flujo de aprobacion. Emitirlos aqui provocaba + // la publicacion previa al guardado y dejaba la solicitud en Pending (G-051). TrackingState.MarkAsDirty(); Props.Audit.Update(approvedBy.GetValue()); return Result.Success(); @@ -85,12 +83,8 @@ public Result Reject(ActorId rejectedBy, string? decisionReason = null) Props.Status = ApprovalStatus.Rejected; Props.DecisionReason = decisionReason; - DomainEvents.RaiseEvent(new ApprovalRequestRejectedEvent( - Props.Id.GetValue(), - Props.WorkflowId.GetValue(), - rejectedBy.GetValue(), - decisionReason ?? string.Empty, - DateTime.UtcNow)); + // Simetrico con Approve: la transicion no emite eventos de dominio; el + // despacho corresponde al handler/flujo de aprobacion (G-051). TrackingState.MarkAsDirty(); Props.Audit.Update(rejectedBy.GetValue()); return Result.Success(); diff --git a/src/apps/ums.api/Ums.Domain/Approvals/ApprovalRequest/Events/ApprovalRequestDomainEventsManager.cs b/src/apps/ums.api/Ums.Domain/Approvals/ApprovalRequest/Events/ApprovalRequestDomainEventsManager.cs index 65cc796d..0932b53b 100644 --- a/src/apps/ums.api/Ums.Domain/Approvals/ApprovalRequest/Events/ApprovalRequestDomainEventsManager.cs +++ b/src/apps/ums.api/Ums.Domain/Approvals/ApprovalRequest/Events/ApprovalRequestDomainEventsManager.cs @@ -1,3 +1,4 @@ +#pragma warning disable S1144, S1186 namespace Ums.Domain.Approvals.ApprovalRequest.Events; public class ApprovalRequestDomainEventsManager : DomainEventsManager @@ -9,3 +10,5 @@ private void Apply(ApprovalRequestRejectedEvent @event) { } private void Apply(ProfileAssignedToUserEvent @event) { } private void Apply(ApprovalRequestCancelledEvent @event) { } } + +#pragma warning restore S1144, S1186 diff --git a/src/apps/ums.api/Ums.Domain/Approvals/ApprovalWorkflow/ApprovalWorkflow.cs b/src/apps/ums.api/Ums.Domain/Approvals/ApprovalWorkflow/ApprovalWorkflow.cs index 59de75a0..f2ecc0c7 100644 --- a/src/apps/ums.api/Ums.Domain/Approvals/ApprovalWorkflow/ApprovalWorkflow.cs +++ b/src/apps/ums.api/Ums.Domain/Approvals/ApprovalWorkflow/ApprovalWorkflow.cs @@ -55,7 +55,7 @@ public static Result Create( public Result AddRequiredDocument(DocumentTypeId documentTypeId, bool isMandatory, ActorId createdBy) { - if (_requiredDocuments.Any(d => d.DocumentTypeId == documentTypeId)) + if (_requiredDocuments.Any(d => d.DocumentTypeId.Equals(documentTypeId))) { BrokenRules.Add(new BrokenRule(nameof(RequiredDocuments), DomainErrors.Approvals.DocumentTypeAlreadyRequired)); } @@ -112,7 +112,10 @@ public Result RemoveRequiredDocument(IdValueObject documentId, ActorId updatedBy private Result FindRequiredDocument(IdValueObject documentId) { - var document = _requiredDocuments.FirstOrDefault(d => d.Id.GetValue() == documentId.GetValue()); + // AT06/F1 (misma clase de bug): identidad canónica = Props.Id; el Id base de Entity<> se + // regenera aleatorio en cada construcción y la rehidratación no llama SetId → buscar por d.Id + // fallaba tras recargar (remover documento requerido por id → 404). Cf. FindMfaEnrollment. + var document = _requiredDocuments.FirstOrDefault(d => d.Props.Id.GetValue() == documentId.GetValue()); return document is null ? Result.Failure(DomainErrors.Common.NotFound) : Result.Success(document); diff --git a/src/apps/ums.api/Ums.Domain/Approvals/ApprovalWorkflow/Events/ApprovalWorkflowDomainEventsManager.cs b/src/apps/ums.api/Ums.Domain/Approvals/ApprovalWorkflow/Events/ApprovalWorkflowDomainEventsManager.cs index dddbdfac..37dfeaf0 100644 --- a/src/apps/ums.api/Ums.Domain/Approvals/ApprovalWorkflow/Events/ApprovalWorkflowDomainEventsManager.cs +++ b/src/apps/ums.api/Ums.Domain/Approvals/ApprovalWorkflow/Events/ApprovalWorkflowDomainEventsManager.cs @@ -1,3 +1,4 @@ +#pragma warning disable S1144, S1186 namespace Ums.Domain.Approvals.ApprovalWorkflow.Events; public class ApprovalWorkflowDomainEventsManager : DomainEventsManager @@ -9,4 +10,5 @@ private void Apply(ApprovalWorkflowDocumentAddedEvent @event) { } private void Apply(ApprovalWorkflowDocumentRemovedEvent @event) { } private void Apply(ApprovalWorkflowActivatedEvent @event) { } private void Apply(ApprovalWorkflowDeactivatedEvent @event) { } -} \ No newline at end of file +} +#pragma warning restore S1144, S1186 diff --git a/src/apps/ums.api/Ums.Domain/Approvals/DocumentType/Events/DocumentTypeDomainEventsManager.cs b/src/apps/ums.api/Ums.Domain/Approvals/DocumentType/Events/DocumentTypeDomainEventsManager.cs index 85becd64..1c349d56 100644 --- a/src/apps/ums.api/Ums.Domain/Approvals/DocumentType/Events/DocumentTypeDomainEventsManager.cs +++ b/src/apps/ums.api/Ums.Domain/Approvals/DocumentType/Events/DocumentTypeDomainEventsManager.cs @@ -1,3 +1,4 @@ +#pragma warning disable S1144, S1186 namespace Ums.Domain.Approvals.DocumentType.Events; public class DocumentTypeDomainEventsManager : DomainEventsManager @@ -7,3 +8,5 @@ public DocumentTypeDomainEventsManager(IAggregateRoot aggregateRoot) : base(aggr private void Apply(DocumentTypeRegisteredEvent @event) { } private void Apply(DocumentTypeUpdatedEvent @event) { } } + +#pragma warning restore S1144, S1186 diff --git a/src/apps/ums.api/Ums.Domain/Approvals/RequiredDocumentChecklist.cs b/src/apps/ums.api/Ums.Domain/Approvals/RequiredDocumentChecklist.cs new file mode 100644 index 00000000..6b6b6a4d --- /dev/null +++ b/src/apps/ums.api/Ums.Domain/Approvals/RequiredDocumentChecklist.cs @@ -0,0 +1,47 @@ +namespace Ums.Domain.Approvals; + +using Ums.Domain.Enums; +using ApprovalWorkflowAggregate = Ums.Domain.Approvals.ApprovalWorkflow.ApprovalWorkflow; +using UserDocumentAggregate = Ums.Domain.Approvals.UserDocument.UserDocument; + +/// +/// Politica de dominio (pura, sin E/S) que hace cumplir el checklist de documentos +/// requeridos de un contra los documentos del +/// usuario objetivo al momento de aprobar una solicitud (G-051 F4). +/// +/// Es una regla de dominio cross-agregado de SOLO LECTURA: cruza el checklist declarado +/// por el workflow con el estado de los del usuario. +/// No muta ningun agregado, por lo que no introduce una excepcion a D-016; el handler +/// que la invoca sigue mutando un unico agregado de negocio (ApprovalRequest) en su +/// transaccion. La lectura precede a la transicion y es fail-closed. +/// +/// Semantica fail-closed: si un tipo de documento marcado como obligatorio no cuenta con +/// un UserDocument en estado para el usuario objetivo, +/// la evaluacion falla y la aprobacion no puede proceder. +/// +public static class RequiredDocumentChecklist +{ + /// + /// Evalua la completitud del checklist obligatorio. Devuelve Result.Success solo + /// cuando cada documento obligatorio del workflow tiene un UserDocument valido del usuario. + /// + /// Workflow que declara el checklist de documentos requeridos. + /// Documentos del usuario objetivo de la solicitud. + public static Result Evaluate( + ApprovalWorkflowAggregate workflow, + IReadOnlyCollection targetUserDocuments) + { + var validDocumentTypeIds = targetUserDocuments + .Where(document => document.Status == DocumentStatus.Valid) + .Select(document => document.DocumentTypeId.GetValue()) + .ToHashSet(); + + var hasMissingMandatoryDocument = workflow.RequiredDocuments + .Where(required => required.IsMandatory) + .Any(required => !validDocumentTypeIds.Contains(required.DocumentTypeId.GetValue())); + + return hasMissingMandatoryDocument + ? Result.Failure(DomainErrors.Approvals.RequiredDocumentsIncomplete) + : Result.Success(); + } +} diff --git a/src/apps/ums.api/Ums.Domain/Approvals/UserDocument/Events/UserDocumentDomainEventsManager.cs b/src/apps/ums.api/Ums.Domain/Approvals/UserDocument/Events/UserDocumentDomainEventsManager.cs index 93e3565a..7336c5c5 100644 --- a/src/apps/ums.api/Ums.Domain/Approvals/UserDocument/Events/UserDocumentDomainEventsManager.cs +++ b/src/apps/ums.api/Ums.Domain/Approvals/UserDocument/Events/UserDocumentDomainEventsManager.cs @@ -1,3 +1,4 @@ +#pragma warning disable S1144, S1186 namespace Ums.Domain.Approvals.UserDocument.Events; public class UserDocumentDomainEventsManager : DomainEventsManager @@ -11,3 +12,5 @@ private void Apply(DocumentExpiredEvent @event) { } private void Apply(DocumentNearExpirationEvent @event) { } private void Apply(EnforcementExecutedEvent @event) { } } + +#pragma warning restore S1144, S1186 diff --git a/src/apps/ums.api/Ums.Domain/Approvals/UserDocument/UserDocument.cs b/src/apps/ums.api/Ums.Domain/Approvals/UserDocument/UserDocument.cs index 97bb4598..fe72e0fb 100644 --- a/src/apps/ums.api/Ums.Domain/Approvals/UserDocument/UserDocument.cs +++ b/src/apps/ums.api/Ums.Domain/Approvals/UserDocument/UserDocument.cs @@ -113,10 +113,16 @@ public Result Reject(string rejectionReason, ActorId rejectedBy) // Background worker expires: VALID → EXPIRED (INV-UD3) public Result Expire(ActorId actor) { + // INV-UD3: EXPIRED solo es alcanzable desde VALID. Se rechaza cualquier salto + // del ciclo de vida (PENDING_REVIEW → EXPIRED o REJECTED → EXPIRED). if (Status == DocumentStatus.Expired) { BrokenRules.Add(new BrokenRule(nameof(Status), DomainErrors.Compliance.DocumentAlreadyExpired)); } + else if (Status != DocumentStatus.Valid) + { + BrokenRules.Add(new BrokenRule(nameof(Status), DomainErrors.Compliance.DocumentCannotTransition)); + } if (!IsValid()) { diff --git a/src/apps/ums.api/Ums.Domain/Authorization/AssignmentRule/Events/TemplateAssignmentRuleDomainEventsManager.cs b/src/apps/ums.api/Ums.Domain/Authorization/AssignmentRule/Events/TemplateAssignmentRuleDomainEventsManager.cs index c5b1134c..7519353d 100644 --- a/src/apps/ums.api/Ums.Domain/Authorization/AssignmentRule/Events/TemplateAssignmentRuleDomainEventsManager.cs +++ b/src/apps/ums.api/Ums.Domain/Authorization/AssignmentRule/Events/TemplateAssignmentRuleDomainEventsManager.cs @@ -1,3 +1,4 @@ +#pragma warning disable S1144, S1186 namespace Ums.Domain.Authorization.AssignmentRule.Events; using Ums.Domain.Events; @@ -10,3 +11,5 @@ private void Apply(AssignmentRuleCreatedEvent @event) { } private void Apply(AssignmentRuleDeactivatedEvent @event) { } private void Apply(AssignmentRuleReactivatedEvent @event) { } } + +#pragma warning restore S1144, S1186 diff --git a/src/apps/ums.api/Ums.Domain/Authorization/Graph/AuthorizationGraph.cs b/src/apps/ums.api/Ums.Domain/Authorization/Graph/AuthorizationGraph.cs index 99f11599..4d324898 100644 --- a/src/apps/ums.api/Ums.Domain/Authorization/Graph/AuthorizationGraph.cs +++ b/src/apps/ums.api/Ums.Domain/Authorization/Graph/AuthorizationGraph.cs @@ -26,12 +26,41 @@ public sealed record AuthorizationGraph /// Who is authenticated and in which context (tenant, suite, role, branch). public GraphContext Context { get; init; } + /// + /// G-043 — true cuando el usuario está autenticado y aprobado pero AÚN NO tiene un perfil activo + /// (onboarding pendiente). En ese caso el grafo es un "lobby": trae User y + /// Tenant reales pero SystemSuite/Role/Profile en null, y Actions/MenuAccess/DomainPermissions/Scopes + /// vacíos. El cliente debe detectar esta bandera y mostrar el flujo de onboarding en lugar de la app, + /// en vez de tratar el login como fallido. Por defecto false (usuarios con perfil). + /// + public bool OnboardingPending { get; init; } + + /// + /// Discriminador cerrado del estado de acceso (ADR-0156 §5.1). Existe porque + /// no basta desde que la autenticación admite filtrar por + /// sistema: «el usuario no tiene ningún perfil» y «tiene perfiles, pero ninguno en el sistema + /// que se pidió» son estados distintos, y responder el segundo como el primero le mostraría un + /// flujo de alta a alguien que ya está de alta. + /// + /// se conserva y equivale a + /// : un consumidor de 2.0–2.3 sigue + /// funcionando sin tocarlo. Los dos no pueden divergir porque los deriva + /// de la misma entrada. + /// + public GraphAccessState AccessState { get; init; } = GraphAccessState.Granted; + /// How the user authenticated and session timing. public GraphAuthentication Authentication { get; init; } /// All actions registered in the SystemSuite — the full action catalogue. public IReadOnlyList Actions { get; init; } + /// + /// Perfiles activos del usuario en este inquilino, con el vigente marcado. Permite al cliente + /// ofrecer el cambio de perfil sin una llamada adicional. Vacía en el grafo lobby (G-043). + /// + public IReadOnlyList Profiles { get; init; } = []; + /// /// Module → Menu → SubMenu → Option tree with resolved permission per option. /// Only modules that have at least one reachable (Allow) option are included. @@ -52,6 +81,20 @@ public sealed record AuthorizationGraph /// Effective tenant configuration resolved with tenant-level override precedence. public GraphEffectiveConfig EffectiveConfig { get; init; } + /// + /// Ajustes del sistema marcados como visibles para el cliente, agrupados por espacio de + /// nombres: branding, tema, disposición, idioma, parámetros funcionales… + /// + /// Es el bloque que permite al cliente inicializar la aplicación —logotipo, colores, página + /// inicial, idioma— sin una llamada adicional, y es EXTENSIBLE: añadir un espacio de nombres + /// nuevo no cambia la forma del contrato, solo aparece otra clave. + /// + /// Solo viaja lo marcado explícitamente (G-178). `AppSetting` es una bolsa clave/valor sin + /// tipo donde junto al color de la marca puede haber una cadena de conexión. + /// + public IReadOnlyDictionary> Settings { get; init; } + = new Dictionary>(); + /// /// OAuth2-style scopes derived from all Allow permissions. /// Format: "resourceCode.actionCode" (lowercase), e.g. "users.read", "inventory.write". @@ -80,14 +123,25 @@ public static AuthorizationGraph Build( IReadOnlyList featureFlags, GraphEffectiveConfig effectiveConfig, IReadOnlyList scopes, - DateTime generatedAt) + DateTime generatedAt, + bool onboardingPending = false, + IReadOnlyList? profiles = null, + IReadOnlyDictionary>? settings = null, + GraphAccessState accessState = GraphAccessState.Granted) { var validUntil = generatedAt.AddMinutes(effectiveConfig.SessionTimeoutMinutes); + // `onboardingPending` manda sobre `accessState`: son el mismo hecho contado dos veces —una + // para los consumidores de 2.0–2.3 y otra para los de 2.4— y derivarlos aquí es lo único + // que garantiza que ningún llamante pueda emitir un grafo donde se contradigan. + var estado = onboardingPending ? GraphAccessState.OnboardingPending : accessState; + return new AuthorizationGraph { SchemaVersion = global::Ums.Sdk.Contracts.SchemaVersion.Current, Context = context, + OnboardingPending = onboardingPending, + AccessState = estado, Authentication = authentication, Actions = actions, MenuAccess = menuAccess, @@ -95,6 +149,8 @@ public static AuthorizationGraph Build( FeatureFlags = featureFlags, EffectiveConfig = effectiveConfig, Scopes = scopes, + Profiles = profiles ?? [], + Settings = settings ?? new Dictionary>(), GeneratedAt = generatedAt, ValidUntil = validUntil, }; diff --git a/src/apps/ums.api/Ums.Domain/Authorization/Graph/GraphAccessState.cs b/src/apps/ums.api/Ums.Domain/Authorization/Graph/GraphAccessState.cs new file mode 100644 index 00000000..6255a661 --- /dev/null +++ b/src/apps/ums.api/Ums.Domain/Authorization/Graph/GraphAccessState.cs @@ -0,0 +1,30 @@ +namespace Ums.Domain.Authorization.Graph; + +/// +/// Estado de acceso del grafo (ADR-0156 §5.1). Enumeración CERRADA: el contrato publicado la +/// declara con `enum` en `auth-graph.schema.json`, así que añadir un valor es un cambio MAYOR de +/// esquema, no una ampliación silenciosa. +/// +public enum GraphAccessState +{ + /// + /// Hay perfil vigente: el grafo lleva navegación, permisos y ámbitos. `onboardingPending` + /// es `false`. + /// + Granted = 0, + + /// + /// El usuario TIENE perfiles activos en el inquilino, pero NINGUNO en el sistema que se pidió + /// —o el sistema pedido no existe, que es indistinguible por construcción: el filtro se aplica + /// sobre los sistemas de los perfiles del usuario y NUNCA consulta el catálogo por código + /// (ADR-0156 §6). `onboardingPending` es `false`: la cuenta está dada de alta y mostrarle un + /// flujo de onboarding sería mentirle. + /// + NoProfileInSystem = 1, + + /// + /// El usuario no tiene NINGÚN perfil activo en el inquilino (G-043, grafo lobby). + /// `onboardingPending` es `true`. + /// + OnboardingPending = 2, +} diff --git a/src/apps/ums.api/Ums.Domain/Authorization/Graph/GraphContext.cs b/src/apps/ums.api/Ums.Domain/Authorization/Graph/GraphContext.cs index dd72cc86..5e747755 100644 --- a/src/apps/ums.api/Ums.Domain/Authorization/Graph/GraphContext.cs +++ b/src/apps/ums.api/Ums.Domain/Authorization/Graph/GraphContext.cs @@ -9,10 +9,23 @@ namespace Ums.Domain.Authorization.Graph; public sealed record GraphContext( GraphUser User, GraphTenant Tenant, - GraphSystemSuite SystemSuite, - GraphRole Role, - GraphProfile Profile, - GraphBranch? Branch); // null when Scope == OrgWide + GraphSystemSuite? SystemSuite, // null en el grafo lobby (onboarding pendiente, G-043) + GraphRole? Role, // null en el grafo lobby + GraphProfile? Profile, // null en el grafo lobby + GraphBranch? Branch, // null when Scope == OrgWide + GraphRequestedSystem? RequestedSystem = null); // eco del sistema pedido (ADR-0156 §2.5) + +/// +/// Eco literal del sistema que el cliente pidió al autenticarse, ya normalizado. Es `null` cuando +/// no pidió ninguno (portal multiproducto). +/// +/// NO es una lectura del catálogo: no revela nada que el llamante no supiera ya, porque es su +/// propia entrada devuelta. Existe para que el cliente pueda decir «no tiene acceso a SDLC» sin +/// llevar su configuración al navegador, y para que un grafo capturado como evidencia sea +/// autodescriptivo — ante un grafo de otra suite, hoy no hay forma de saber si se pidió algo +/// distinto. +/// +public sealed record GraphRequestedSystem(string Code); public sealed record GraphUser( [property: JsonIgnore] Guid Id, @@ -51,3 +64,26 @@ public sealed record GraphBranch( [property: JsonIgnore] Guid Id, string Code, [property: JsonPropertyName("value")] string Name); + +/// +/// Un perfil al que el usuario autenticado tiene acceso: la combinación de un rol dentro de un +/// sistema, opcionalmente acotada a una sucursal. +/// +/// Viaja la lista completa para que el cliente pueda ofrecer el cambio de perfil sin preguntar de +/// nuevo (G-177). Antes se elegía uno y los demás se descartaban sin dejar rastro: el usuario no +/// sabía siquiera que existían. +/// +/// El sistema NO sale del perfil —que no lo guarda— sino de su rol, que pertenece a exactamente +/// uno. Se proyecta aquí porque el cliente lo necesita para rotular el selector. +/// +public sealed record GraphProfileOption( + [property: JsonIgnore] Guid Id, + string SystemCode, + [property: JsonPropertyName("systemValue")] string SystemName, + string RoleCode, + [property: JsonPropertyName("roleValue")] string RoleName, + int HierarchyLevel, + string? BranchCode, + [property: JsonPropertyName("branchValue")] string? BranchName, + string Scope, + bool IsCurrent); diff --git a/src/apps/ums.api/Ums.Domain/Authorization/Graph/GraphMenuAccess.cs b/src/apps/ums.api/Ums.Domain/Authorization/Graph/GraphMenuAccess.cs index c55ebbe1..74796967 100644 --- a/src/apps/ums.api/Ums.Domain/Authorization/Graph/GraphMenuAccess.cs +++ b/src/apps/ums.api/Ums.Domain/Authorization/Graph/GraphMenuAccess.cs @@ -3,37 +3,60 @@ namespace Ums.Domain.Authorization.Graph; /// -/// The complete SystemSuite menu hierarchy with effective permissions per option. -/// Each option carries the resolved effect (Allow/Deny/NotGranted) and its source -/// (Template or Override), giving the client system everything it needs to render -/// and enforce access at the UI level without re-querying UMS. +/// Navegación concedida de un módulo, como árbol recursivo. +/// +/// SUSTITUYE a la cadena rígida Módulo→Menú→Submenú→Opción que se proyectaba antes. Aquella +/// recorría exactamente tres niveles literales, de modo que un nodo colgado a otra profundidad +/// —topología que el modelo admite (ADR-0090) y que la API aceptaba— se persistía, se veía en la +/// administración y NO llegaba nunca al usuario: desaparecía del grafo sin error ni traza (G-171). +/// +/// Dos cambios de fondo respecto de la forma anterior: +/// +/// 1. **Profundidad libre.** El cliente recorre hasta +/// agotarlo, sin presuponer tres niveles. +/// 2. **Una opción, una entrada.** Antes una opción con cinco acciones producía cinco filas +/// idénticas salvo el actionCode. Ahora es un nodo con su lista de acciones. /// public sealed record GraphMenuModule( [property: JsonIgnore] Guid Id, - string Code, + string Code, [property: JsonPropertyName("value")] string Name, - int SortOrder, - string Status, - IReadOnlyList Menus); + int SortOrder, + string Status, + // Identificador de icono del módulo (no un recurso). Nulo si no se configuró. + string? Icon, + IReadOnlyList Nodes); -public sealed record GraphMenu( - [property: JsonIgnore] Guid Id, - string Code, - [property: JsonPropertyName("value")] string Label, - int SortOrder, - IReadOnlyList SubMenus); - -public sealed record GraphSubMenu( +/// +/// Nodo del árbol de navegación. clasifica su papel (Menu, SubMenu, Option) sin +/// fijar su profundidad. +/// +/// Solo viajan los nodos ALCANZABLES: una hoja sin ninguna acción concedida se omite, y una rama +/// que se queda sin hojas se omite con ella. La ausencia significa «no concedido», que es la misma +/// semántica fail-closed que ya regía (G-039); enviar decenas de filas NotGranted era +/// repetir en cada login lo que el contrato ya dice una vez. +/// +public sealed record GraphNavigationNode( [property: JsonIgnore] Guid Id, - string Code, - [property: JsonPropertyName("value")] string Label, - int SortOrder, - IReadOnlyList Options); + string Code, + [property: JsonPropertyName("value")] string Name, + string Kind, + int SortOrder, + /// Identificador de icono, no un recurso: el catálogo gráfico lo decide el cliente. + string? Icon, + /// Ruta destino. Sin ella el cliente sabe qué pintar pero no a dónde llevar. + string? Route, + IReadOnlyList Actions, + IReadOnlyList Children); -public sealed record GraphMenuOption( - [property: JsonIgnore] Guid Id, - string Code, - [property: JsonPropertyName("value")] string Label, +/// +/// Una acción concedida —o denegada explícitamente— sobre un nodo hoja. +/// +/// Deny viaja porque no es lo mismo que la ausencia: una denegación explícita gana sobre +/// cualquier concesión heredada, y el cliente necesita poder mostrarla como bloqueada en vez de +/// como inexistente. +/// +public sealed record GraphNodeAction( string ActionCode, - AccessEffect Effect, + AccessEffect Effect, PermissionSource Source); diff --git a/src/apps/ums.api/Ums.Domain/Authorization/Graph/GraphNavigation.cs b/src/apps/ums.api/Ums.Domain/Authorization/Graph/GraphNavigation.cs new file mode 100644 index 00000000..df783282 --- /dev/null +++ b/src/apps/ums.api/Ums.Domain/Authorization/Graph/GraphNavigation.cs @@ -0,0 +1,36 @@ +namespace Ums.Domain.Authorization.Graph; + +/// +/// Recorrido del árbol de navegación, en un solo sitio. +/// +/// El árbol admite cualquier profundidad (ADR-0090). Antes cada consumidor lo recorría con tres +/// bucles anidados escritos a mano —el constructor, el emisor de claims, los cuatro serializadores, +/// los endpoints— y todos compartían el mismo defecto: perdían en silencio lo que no encajara en +/// tres niveles (G-171). Un recorrido único evita que la próxima corrección haya que aplicarla +/// siete veces. +/// +public static class GraphNavigation +{ + /// Aplana el árbol en profundidad, en el orden en que se pinta. + public static IEnumerable Flatten(IEnumerable nodes) + { + foreach (var node in nodes) + { + yield return node; + foreach (var child in Flatten(node.Children)) yield return child; + } + } + + /// Todos los nodos de navegación del grafo, de todos sus módulos. + public static IEnumerable AllNodes(AuthorizationGraph graph) + => graph.MenuAccess.SelectMany(m => Flatten(m.Nodes)); + + /// + /// Pares (código de nodo, código de acción) con efecto Allow. Es la forma en que el + /// resto del sistema expresa «lo que este perfil puede hacer». + /// + public static IEnumerable<(string Code, string ActionCode)> AllowedPairs(AuthorizationGraph graph) + => AllNodes(graph) + .SelectMany(n => n.Actions.Where(a => a.Effect == AccessEffect.Allow), + (n, a) => (n.Code, a.ActionCode)); +} diff --git a/src/apps/ums.api/Ums.Domain/Authorization/Graph/IAuthorizationGraphBuilder.cs b/src/apps/ums.api/Ums.Domain/Authorization/Graph/IAuthorizationGraphBuilder.cs index 9bd55aa1..0ea0a352 100644 --- a/src/apps/ums.api/Ums.Domain/Authorization/Graph/IAuthorizationGraphBuilder.cs +++ b/src/apps/ums.api/Ums.Domain/Authorization/Graph/IAuthorizationGraphBuilder.cs @@ -17,21 +17,38 @@ public interface IAuthorizationGraphBuilder /// The authenticated user — already fetched by the command handler. /// The tenant in which the user authenticated. /// The resolved auth method (Local or IDP). + /// + /// Código del sistema que pide el grafo, OPCIONAL (ADR-0156 §3.2). Ausente —portal + /// multiproducto— el grafo se arma sobre todos los perfiles del usuario; presente, solo sobre + /// los perfiles cuyo sistema coincide. El filtro se aplica SOBRE LOS PERFILES DEL USUARIO y + /// tiene prohibido consultar el catálogo de sistemas por código: es lo que hace que un código + /// inexistente y uno sin perfil sean indistinguibles por construcción, sin dos ramas que + /// puedan divergir en un mensaje, un estado o un tiempo (ADR-0156 §6). + /// /// Cancellation token. Task> BuildAsync( UserAccountAggregate userAccount, Guid tenantId, AuthMethod authMethod, + string? systemCode = null, CancellationToken cancellationToken = default); /// /// Builds the authorization graph for a specific profile of the given user and tenant. /// Used by the admin preview flow to avoid resolving a different active profile. /// + /// + /// Acota el bloque `profiles` del grafo al sistema indicado, igual que en + /// . El PERFIL VIGENTE no lo elige este filtro —llega dado en + /// —, pero la lista de perfiles disponibles sí debe respetarlo: + /// devolverla completa entregaría a un satélite acotado a un sistema el inventario de los + /// demás sistemas en los que ese usuario trabaja (ADR-0156 §2.5). + /// Task> BuildForProfileAsync( UserAccountAggregate userAccount, Guid tenantId, Guid profileId, AuthMethod authMethod, + string? systemCode = null, CancellationToken cancellationToken = default); } diff --git a/src/apps/ums.api/Ums.Domain/Authorization/Profile/Events/ProfileDomainEventsManager.cs b/src/apps/ums.api/Ums.Domain/Authorization/Profile/Events/ProfileDomainEventsManager.cs index 9f32a64c..e0eced0d 100644 --- a/src/apps/ums.api/Ums.Domain/Authorization/Profile/Events/ProfileDomainEventsManager.cs +++ b/src/apps/ums.api/Ums.Domain/Authorization/Profile/Events/ProfileDomainEventsManager.cs @@ -1,3 +1,4 @@ +#pragma warning disable S1144, S1186 namespace Ums.Domain.Authorization.Profile; public class ProfileDomainEventsManager : DomainEventsManager @@ -6,8 +7,14 @@ public ProfileDomainEventsManager(IAggregateRoot aggregateRoot) : base(aggregate private void Apply(ProfileCreatedEvent @event) { } private void Apply(TemplateLinkedToProfileEvent @event) { } + // Sin este Apply, CreateProfileCommandHandler.MaterializeAutoAssignedTemplateAsync revienta + // con InvalidOperationException al emitir el evento (el manager exige un Apply por tipo). + private void Apply(TemplateAutoAssignedEvent @event) { } private void Apply(PermissionOverriddenEvent @event) { } private void Apply(PermissionStatusChangedEvent @event) { } private void Apply(ProfileDeactivatedEvent @event) { } private void Apply(ProfileActivatedEvent @event) { } + private void Apply(ProfileRoleChangedEvent @event) { } } + +#pragma warning restore S1144, S1186 diff --git a/src/apps/ums.api/Ums.Domain/Authorization/Profile/Profile.cs b/src/apps/ums.api/Ums.Domain/Authorization/Profile/Profile.cs index 2ef03ee6..33176cad 100644 --- a/src/apps/ums.api/Ums.Domain/Authorization/Profile/Profile.cs +++ b/src/apps/ums.api/Ums.Domain/Authorization/Profile/Profile.cs @@ -65,7 +65,12 @@ public Result AssignTemplate(PermissionTemplateEntity template, ActorId assigned BrokenRules.Add(new BrokenRule(nameof(IsActive), DomainErrors.Authorization.ProfileAlreadyInactive)); } - if (template.TenantId != TenantId) + // G-043 (causa raíz): comparar por VALOR (Guid subyacente). El operador != de los + // value objects de identidad (IdValueObject) resuelve por REFERENCIA, de modo que dos + // instancias con el mismo Guid se consideraban distintas y esta guarda marcaba SIEMPRE + // template_tenant_mismatch: AssignTemplate nunca materializaba (permissionCount=0) y el + // fallo se enmascaraba. El resto del dominio ya compara identidades vía GetValue(). + if (template.TenantId.GetValue() != TenantId.GetValue()) { BrokenRules.Add(new BrokenRule(nameof(Template), DomainErrors.Authorization.TemplateTenantMismatch)); } @@ -77,7 +82,7 @@ public Result AssignTemplate(PermissionTemplateEntity template, ActorId assigned var templateId = TemplateId.Load(template.GetId().GetValue()); - if (_permissions.Any(p => p.TemplateId.Equals(templateId))) + if (_permissions.Any(p => p.TemplateId.GetValue() == templateId.GetValue())) { BrokenRules.Add(new BrokenRule(nameof(Permissions), DomainErrors.Authorization.ProfileTemplateAlreadyLinked)); } @@ -87,7 +92,15 @@ public Result AssignTemplate(PermissionTemplateEntity template, ActorId assigned return Result.Failure(BrokenRules.GetBrokenRulesAsString()); } - foreach (var templateItem in template.Items) + // Se materializa desde ActiveItems, NO desde Items. + // + // Es la mitad que de verdad importa del borrado lógico. Mientras retirar un ítem lo borraba + // de la colección, iterar `Items` ya excluía lo retirado por construcción. Al dejar de + // borrar, `Items` pasa a incluir también las concesiones retiradas: seguir iterándola las + // copiaría al perfil como permisos ACTIVOS y el grafo resuelto concedería por ellas. Habríamos + // cambiado un borrado por una brecha — y silenciosa, porque el operador ve el ítem apagado + // en la plantilla mientras el usuario ejerce el permiso. + foreach (var templateItem in template.ActiveItems) { var targetId = IdValueObject.Load(templateItem.TargetId.GetValue()); var actionId = ActionId.Load(templateItem.ActionId.GetValue()); @@ -200,6 +213,42 @@ public Result Deactivate(ActorId updatedBy) return Result.Success(); } + /// + /// ADR-UMS-096: reasigna el rol del perfil. Es el efecto real de una promoción de rol IGA + /// (RolePromotionRequest.Execute), aplicado como paso diferido post-commit en su propia + /// transacción (D-016: un agregado por transacción). Invariantes: el perfil debe estar activo y + /// el rol destino debe diferir del actual. Devuelve (nunca excepción). + /// + public Result ChangeRole(RoleId newRoleId, ActorId updatedBy) + { + if (!IsActive) + { + BrokenRules.Add(new BrokenRule(nameof(IsActive), DomainErrors.Authorization.ProfileAlreadyInactive)); + } + + if (newRoleId.GetValue() == Props.RoleId.GetValue()) + { + BrokenRules.Add(new BrokenRule(nameof(RoleId), DomainErrors.Authorization.ProfileRoleUnchanged)); + } + + if (!IsValid()) + { + return Result.Failure(BrokenRules.GetBrokenRulesAsString()); + } + + var previousRoleId = Props.RoleId.GetValue(); + SetProps(Props.WithRoleId(newRoleId)); + DomainEvents.RaiseEvent(new ProfileRoleChangedEvent( + Props.Id.GetValue(), + Props.TenantId.GetValue(), + Props.UserId.GetValue(), + previousRoleId, + newRoleId.GetValue())); + TrackingState.MarkAsDirty(); + Props.Audit.Update(updatedBy.GetValue()); + return Result.Success(); + } + public Result Activate(ActorId updatedBy) { if (IsActive) @@ -221,7 +270,11 @@ public Result Activate(ActorId updatedBy) private Result FindPermission(IdValueObject permissionId) { - var permission = _permissions.FirstOrDefault(p => p.Id.GetValue() == permissionId.GetValue()); + // AT06/F1: la identidad canónica de un permiso es Props.Id (id persistido, lo que expone el DTO + // y reenvía el cliente). El Id base de Entity<> se regenera ALEATORIO en cada construcción + // (Entity ctor: Id = IdValueObject.Create()) y la rehidratación vía Construct<> NO llama SetId, + // así que buscar por p.Id nunca casaba tras recargar → override/activate/deactivate daban 404. + var permission = _permissions.FirstOrDefault(p => p.Props.Id.GetValue() == permissionId.GetValue()); return permission is null ? Result.Failure(DomainErrors.Common.NotFound) : Result.Success(permission); diff --git a/src/apps/ums.api/Ums.Domain/Authorization/Profile/ProfileProps.cs b/src/apps/ums.api/Ums.Domain/Authorization/Profile/ProfileProps.cs index bd5640fd..706c8f66 100644 --- a/src/apps/ums.api/Ums.Domain/Authorization/Profile/ProfileProps.cs +++ b/src/apps/ums.api/Ums.Domain/Authorization/Profile/ProfileProps.cs @@ -57,5 +57,13 @@ public ProfileProps WithIsActive(bool isActive) return clone; } + /// ADR-UMS-096: reasigna el rol del perfil (efecto de la promoción de rol IGA). + public ProfileProps WithRoleId(RoleId roleId) + { + var clone = (ProfileProps)MemberwiseClone(); + clone.RoleId = roleId; + return clone; + } + public object Clone() => MemberwiseClone(); } diff --git a/src/apps/ums.api/Ums.Domain/Authorization/Repositories.cs b/src/apps/ums.api/Ums.Domain/Authorization/Repositories.cs index 1202a69a..54906f6d 100644 --- a/src/apps/ums.api/Ums.Domain/Authorization/Repositories.cs +++ b/src/apps/ums.api/Ums.Domain/Authorization/Repositories.cs @@ -17,6 +17,15 @@ public interface IProfileRepository : IAggregateRepository Task> GetByTenantIdAsync(Guid tenantId, CancellationToken cancellationToken = default); Task> GetByUserIdAsync(Guid userId, CancellationToken cancellationToken = default); + /// + /// Perfiles ACTIVOS de un usuario en un inquilino, filtrados en la base. + /// + /// Es la consulta del login. Filtrar el estado en memoria traía filas que se descartaban y, + /// sobre todo, impedía usar el índice parcial `IX_Profiles_UserId_Active`: PostgreSQL solo + /// aprovecha un índice filtrado cuando la consulta lleva su misma condición. + /// + Task> GetActiveByUserAndTenantAsync(Guid userId, Guid tenantId, CancellationToken cancellationToken = default); + // ── Dependency guard queries (lightweight count-only) ─────────────────── /// Returns the number of active profiles assigned to a given role. Task CountActiveByRoleAsync(Guid roleId, CancellationToken cancellationToken = default); @@ -24,6 +33,14 @@ public interface IProfileRepository : IAggregateRepository Task CountActiveByTemplateAsync(Guid templateId, CancellationToken cancellationToken = default); /// Returns the number of active profiles owned by a given user. Task CountActiveByUserAsync(Guid userId, CancellationToken cancellationToken = default); + /// + /// ADR-0164 §2.2: perfiles ACTIVOS acotados a una sucursal. Es la segunda mitad de la guarda de + /// cascada del cierre definitivo —la otra son las cuentas—, y hace falta porque + /// Profiles.BranchId NO tiene clave ajena contra TenantBranches: la base no + /// impediría nada, así que la integridad la sostiene la aplicación o no la sostiene nadie. + /// Solo cuenta los activos: un perfil ya desactivado no bloquea. + /// + Task CountActiveByBranchAsync(Guid branchId, CancellationToken cancellationToken = default); } public interface ISystemSuiteRepository : IAggregateRepository @@ -32,6 +49,44 @@ public interface ISystemSuiteRepository : IAggregateRepository GetByCodeAsync(Code code, CancellationToken cancellationToken = default); Task> GetAllAsync(Guid? tenantId = null, CancellationToken cancellationToken = default); Task> GetByTenantIdAsync(Guid tenantId, CancellationToken cancellationToken = default); + + /// + /// Código y nombre de varios sistemas, sin cargar el agregado. + /// + /// Deliberadamente NO devuelve SystemSuiteAggregate: cargar la suite completa cuesta + /// siete consultas por sus módulos, nodos, acciones y recursos. Para pintar un selector de + /// perfiles hacen falta dos cadenas. + /// + Task> GetSummariesByIdsAsync(IReadOnlyCollection ids, CancellationToken cancellationToken = default); + + /// + /// Una página del catálogo, filtrada, ordenada y recortada EN LA BASE. + /// + /// El listado cargaba todas las suites del inquilino con su árbol completo —módulos, nodos, + /// acciones y recursos— y después filtraba, ordenaba y paginaba en memoria para devolver + /// veinte. Con cientos de sistemas eso es traer el catálogo entero a la aplicación para + /// tirar el 95 % (G-179). + /// + Task GetPageAsync(SystemSuitePageQuery query, CancellationToken cancellationToken = default); + + /// Agregados completos de una página, en el orden que la página fijó. + Task> GetByIdsAsync(IReadOnlyCollection ids, CancellationToken cancellationToken = default); + + // ── Guarda de cascada de la eliminación lógica (G-246) ────────────────── + /// + /// Cuántas referencias VIVAS —no eliminadas lógicamente— tiene el sistema, sin cargar ninguna. + /// + /// El conteo vive aquí —y no repartido en el repositorio de cada referente— porque «qué cuelga de + /// un sistema» es conocimiento del sistema: cuando aparezca una tabla nueva con + /// SystemSuiteId, se añade en un único sitio en vez de confiar en que alguien recuerde + /// tocar la guarda desde el otro extremo. + /// + Task GetDependentsAsync(Guid id, CancellationToken cancellationToken = default); + + // NO hay Delete/Remove, ni aquí ni en IAggregateRepository: la eliminación de un sistema es un + // cambio de estado a `SystemStatus.Deleted` que viaja por `UpdateAsync`, como cualquier otro. Que + // el contrato no ofrezca ninguna forma de borrar la fila es la garantía —comprobable leyendo esta + // interfaz— de que no existe una ruta de borrado físico que se pueda invocar por descuido. } public interface IPermissionTemplateRepository : IAggregateRepository @@ -39,6 +94,13 @@ public interface IPermissionTemplateRepository : IAggregateRepository GetByIdAsync(Guid id, CancellationToken cancellationToken = default); Task> GetAllAsync(Guid? tenantId = null, CancellationToken cancellationToken = default); Task> GetByTenantIdAsync(Guid tenantId, CancellationToken cancellationToken = default); + + /// + /// Devuelve las plantillas existentes para la terna (tenant, rol, suite), usada por el alta + /// para calcular la versión siguiente y evitar la colisión del índice único (G-140). + /// + Task> GetByTenantRoleSuiteAsync(Guid tenantId, Guid roleId, Guid systemSuiteId, CancellationToken cancellationToken = default); + Task DeleteAsync(Guid id, CancellationToken cancellationToken = default); // ── Dependency guard queries ──────────────────────────────────────────── @@ -55,6 +117,13 @@ public interface IRoleRepository : IAggregateRepository Task> GetBySystemSuiteIdAsync(Guid systemSuiteId, CancellationToken cancellationToken = default); Task> GetByTenantIdAsync(Guid tenantId, CancellationToken cancellationToken = default); + /// + /// Roles por lote. La lista de perfiles del usuario necesita, de cada uno, el nivel de + /// jerarquía y el sistema al que pertenece su rol: sin esto habría que emitir una consulta + /// por perfil, o cargar todos los roles del inquilino para quedarse con tres. + /// + Task> GetByIdsAsync(IReadOnlyCollection ids, CancellationToken cancellationToken = default); + // ── Dependency guard queries ──────────────────────────────────────────── /// Returns the number of active child roles for a given parent role. Task CountActiveChildRolesAsync(Guid parentRoleId, CancellationToken cancellationToken = default); diff --git a/src/apps/ums.api/Ums.Domain/Authorization/Role/Events/RoleDomainEventsManager.cs b/src/apps/ums.api/Ums.Domain/Authorization/Role/Events/RoleDomainEventsManager.cs index a71bf226..0f8912c8 100644 --- a/src/apps/ums.api/Ums.Domain/Authorization/Role/Events/RoleDomainEventsManager.cs +++ b/src/apps/ums.api/Ums.Domain/Authorization/Role/Events/RoleDomainEventsManager.cs @@ -1,3 +1,4 @@ +#pragma warning disable S1144, S1186 namespace Ums.Domain.Authorization.Role; public sealed class RoleDomainEventsManager : DomainEventsManager @@ -9,3 +10,5 @@ private void Apply(RoleUpdatedEvent @event) { } private void Apply(RoleActivatedEvent @event) { } private void Apply(RoleDeactivatedEvent @event) { } } + +#pragma warning restore S1144, S1186 diff --git a/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/AppSetting/AppSetting.cs b/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/AppSetting/AppSetting.cs index f593d232..8065061f 100644 --- a/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/AppSetting/AppSetting.cs +++ b/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/AppSetting/AppSetting.cs @@ -6,14 +6,33 @@ public class AppSetting public ConfigurationValue Value { get; } public ConfigurationScope Scope { get; } - private AppSetting(ConfigurationKey key, ConfigurationValue value, ConfigurationScope scope) + /// + /// Si este ajuste puede viajar al cliente en el grafo de autorización. + /// + /// El default es false y eso es deliberado (fail-closed). `AppSetting` es una bolsa + /// clave/valor sin tipo: junto al color de la marca puede haber una cadena de conexión o el + /// secreto de una integración. Volcarla entera al cliente por comodidad sería publicar + /// secretos por omisión — que un ajuste no esté marcado significa «nadie ha decidido que sea + /// público», no «no pasa nada» (G-178). + /// + /// A diferencia de AppConfiguration y TenantParameter, que sí distinguen lo + /// sensible, aquí no había ninguna marca. + /// + public bool IsClientVisible { get; } + + private AppSetting(ConfigurationKey key, ConfigurationValue value, ConfigurationScope scope, bool isClientVisible) { Key = key; Value = value; Scope = scope; + IsClientVisible = isClientVisible; } - public static Result Create(ConfigurationKey key, ConfigurationValue value, ConfigurationScope scope) + public static Result Create( + ConfigurationKey key, + ConfigurationValue value, + ConfigurationScope scope, + bool isClientVisible = false) { if (string.IsNullOrWhiteSpace(key.GetValue())) { @@ -25,10 +44,11 @@ public static Result Create(ConfigurationKey key, ConfigurationValue return Result.Failure(DomainErrors.ValueObject.PropertyRequired); } - return Result.Success(new AppSetting(key, value, scope)); + return Result.Success(new AppSetting(key, value, scope, isClientVisible)); } - public AppSetting WithValue(ConfigurationValue newValue) => new(Key, newValue, Scope); + /// Cambia el valor CONSERVANDO la marca de exposición: cambiar un valor no es decidir publicarlo. + public AppSetting WithValue(ConfigurationValue newValue) => new(Key, newValue, Scope, IsClientVisible); public override bool Equals(object? obj) { diff --git a/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/Events/SystemSuiteDomainEventsManager.cs b/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/Events/SystemSuiteDomainEventsManager.cs index c3e1ff44..12509e6a 100644 --- a/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/Events/SystemSuiteDomainEventsManager.cs +++ b/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/Events/SystemSuiteDomainEventsManager.cs @@ -1,3 +1,4 @@ +#pragma warning disable S1144, S1186 namespace Ums.Domain.Authorization.SystemSuite.Events; public class SystemSuiteDomainEventsManager : DomainEventsManager @@ -6,9 +7,12 @@ public SystemSuiteDomainEventsManager(IAggregateRoot aggregateRoot) : base(aggre private void Apply(SystemSuiteRegisteredEvent @event) { } private void Apply(SystemSuiteStatusChangedEvent @event) { } + private void Apply(SystemSuiteDeletedEvent @event) { } private void Apply(SystemSuiteModuleAddedEvent @event) { } private void Apply(SystemSuiteModuleRemovedEvent @event) { } private void Apply(SystemSuiteModuleStatusChangedEvent @event) { } private void Apply(SystemSuiteActionRegisteredEvent @event) { } private void Apply(SystemSuiteActionRemovedEvent @event) { } } + +#pragma warning restore S1144, S1186 diff --git a/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/Menu/Menu.cs b/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/Menu/Menu.cs deleted file mode 100644 index ae70a80e..00000000 --- a/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/Menu/Menu.cs +++ /dev/null @@ -1,131 +0,0 @@ -namespace Ums.Domain.Authorization.SystemSuite.Menu; - -using Ums.Domain.Authorization.SystemSuite.SubMenu; -using SubMenuEntity = Ums.Domain.Authorization.SystemSuite.SubMenu.SubMenu; - -public sealed class Menu : Entity -{ - private readonly List _subMenus = new(); - - private Menu(MenuProps props) : base(props) - { - } - - public ModuleId ModuleId => Props.ModuleId; - public Code Code => Props.Code; - public Name Label => Props.Label; - public Description Description => Props.Description; - public int SortOrder => Props.SortOrder; - - public IReadOnlyCollection SubMenus => _subMenus.AsReadOnly(); - - public MenuId GetId() => MenuId.Load(Props.Id.GetValue()); - - public static Result Create( - ModuleId moduleId, - Code code, - Name label, - Description description, - int sortOrder, - ActorId createdBy) - { - var props = new MenuProps(IdValueObject.Create(), moduleId, code, label, description, sortOrder, createdBy); - var menu = new Menu(props); - - if (!menu.IsValid()) - { - return Result.Failure(menu.BrokenRules.GetBrokenRulesAsString()); - } - - return Result.Success(menu); - } - - public Result Update(Name label, Description description, int sortOrder, ActorId updatedBy) - { - SetProps(Props.WithLabel(label).WithDescription(description).WithSortOrder(sortOrder)); - - if (!IsValid()) - { - return Result.Failure(BrokenRules.GetBrokenRulesAsString()); - } - - Props.Audit.Update(updatedBy.GetValue()); - return Result.Success(); - } - - public Result AddSubMenu(Code code, Name label, Description description, int sortOrder, ActorId createdBy) - { - BrokenRules.Clear(); - - if (_subMenus.Any(sm => sm.Code == code)) - { - BrokenRules.Add(new BrokenRule(nameof(SubMenus), DomainErrors.SystemSuite.SubMenuCodeNotUnique)); - } - - if (!IsValid()) - { - return Result.Failure(BrokenRules.GetBrokenRulesAsString()); - } - - var subMenuResult = SubMenuEntity.Create(GetId(), code, label, description, sortOrder, createdBy); - if (subMenuResult.IsFailure) - { - return Result.Failure(subMenuResult.Error); - } - - _subMenus.Add(subMenuResult.Value); - Props.Audit.Update(createdBy.GetValue()); - return Result.Success(); - } - - public Result RemoveSubMenu(IdValueObject subMenuId, ActorId updatedBy) - { - var subMenu = FindSubMenu(subMenuId); - if (subMenu.IsFailure) - { - BrokenRules.Add(new BrokenRule(nameof(SubMenus), DomainErrors.Common.NotFound)); - } - - if (!IsValid()) - { - return Result.Failure(BrokenRules.GetBrokenRulesAsString()); - } - - _subMenus.Remove(subMenu.Value); - Props.Audit.Update(updatedBy.GetValue()); - return Result.Success(); - } - - public Result UpdateSubMenu(IdValueObject subMenuId, Name label, Description description, int sortOrder, ActorId updatedBy) - { - var subMenu = FindSubMenu(subMenuId); - if (subMenu.IsFailure) - { - BrokenRules.Add(new BrokenRule(nameof(SubMenus), DomainErrors.Common.NotFound)); - } - - if (!IsValid()) - { - return Result.Failure(BrokenRules.GetBrokenRulesAsString()); - } - - var updateResult = subMenu.Value.Update(label, description, sortOrder, updatedBy); - if (updateResult.IsFailure) - { - return Result.Failure(updateResult.Error); - } - - Props.Audit.Update(updatedBy.GetValue()); - return Result.Success(); - } - - private Result FindSubMenu(IdValueObject subMenuId) - { - var subMenu = _subMenus.FirstOrDefault(sm => - sm.Props.Id.GetValue() == subMenuId.GetValue() || - sm.Id.GetValue() == subMenuId.GetValue()); - return subMenu is null - ? Result.Failure(DomainErrors.Common.NotFound) - : Result.Success(subMenu); - } -} diff --git a/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/Menu/MenuProps.cs b/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/Menu/MenuProps.cs deleted file mode 100644 index c0452ae6..00000000 --- a/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/Menu/MenuProps.cs +++ /dev/null @@ -1,56 +0,0 @@ -namespace Ums.Domain.Authorization.SystemSuite.Menu; - -using Ums.Domain.Authorization.SystemSuite.SubMenu; -using SubMenuEntity = Ums.Domain.Authorization.SystemSuite.SubMenu.SubMenu; - -public class MenuProps : IProps -{ - public IdValueObject Id { get; private set; } - public ModuleId ModuleId { get; private set; } - public Code Code { get; private set; } - public Name Label { get; private set; } - public Description Description { get; private set; } - public int SortOrder { get; private set; } - public AuditValueObject Audit { get; private set; } - - public MenuProps( - IdValueObject id, - ModuleId moduleId, - Code code, - Name label, - Description description, - int sortOrder, - ActorId createdBy) - { - Id = id; - ModuleId = moduleId; - Code = code; - Label = label; - Description = description; - SortOrder = sortOrder; - Audit = AuditValueObject.Create(createdBy.GetValue()); - } - - public MenuProps WithLabel(Name label) - { - var clone = (MenuProps)MemberwiseClone(); - clone.Label = label; - return clone; - } - - public MenuProps WithDescription(Description description) - { - var clone = (MenuProps)MemberwiseClone(); - clone.Description = description; - return clone; - } - - public MenuProps WithSortOrder(int sortOrder) - { - var clone = (MenuProps)MemberwiseClone(); - clone.SortOrder = sortOrder; - return clone; - } - - public object Clone() => MemberwiseClone(); -} \ No newline at end of file diff --git a/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/MenuNode/MenuNode.cs b/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/MenuNode/MenuNode.cs new file mode 100644 index 00000000..6c23f760 --- /dev/null +++ b/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/MenuNode/MenuNode.cs @@ -0,0 +1,216 @@ +namespace Ums.Domain.Authorization.SystemSuite.MenuNode; + +/// +/// Nodo recursivo de la topología de navegación (ADR-0090). Reemplaza la cadena +/// rígida Menú→SubMenú→Opción por un árbol adjacency-list de profundidad +/// arbitraria: un módulo puede tener opciones directas o jerarquías más +/// profundas, y el submenú es opcional por naturaleza. Una +/// es un nodo hoja que vincula funcionalidades (acciones) N:M. +/// +public sealed class MenuNode : Entity +{ + private readonly List _children = new(); + private readonly List _actionCodes = new(); + + private MenuNode(MenuNodeProps props) : base(props) + { + } + + public ModuleId ModuleId => Props.ModuleId; + public IdValueObject? ParentNodeId => Props.ParentNodeId; + public NodeKind Kind => Props.Kind; + public Code Code => Props.Code; + public Name Label => Props.Label; + public Description Description => Props.Description; + public ModuleStatus Status => Props.Status; + public int SortOrder => Props.SortOrder; + public MenuNodeMetadata Metadata => Props.Metadata; + + public IReadOnlyCollection Children => _children.AsReadOnly(); + public IReadOnlyCollection ActionCodes => _actionCodes.AsReadOnly(); + + public IdValueObject GetId() => Props.Id; + + public static Result Create( + ModuleId moduleId, + IdValueObject? parentNodeId, + NodeKind kind, + Code code, + Name label, + Description description, + int sortOrder, + ActorId createdBy, + MenuNodeMetadata? metadata = null, + MenuNodePresentation? presentation = null) + { + var props = new MenuNodeProps( + IdValueObject.Create(), + moduleId, + parentNodeId, + kind, + code, + label, + description, + ModuleStatus.Active, + sortOrder, + metadata ?? MenuNodeMetadata.Empty, + createdBy, + presentation ?? MenuNodePresentation.Empty); + + var node = new MenuNode(props); + if (!node.IsValid()) + { + return Result.Failure(node.BrokenRules.GetBrokenRulesAsString()); + } + + return Result.Success(node); + } + + public Result Update(Name label, Description description, int sortOrder, ActorId updatedBy) + { + SetProps(Props.WithLabel(label).WithDescription(description).WithSortOrder(sortOrder)); + + if (!IsValid()) + { + return Result.Failure(BrokenRules.GetBrokenRulesAsString()); + } + + Props.Audit.Update(updatedBy.GetValue()); + return Result.Success(); + } + + public Result Activate(ActorId updatedBy) + { + SetProps(Props.WithStatus(ModuleStatus.Active)); + Props.Audit.Update(updatedBy.GetValue()); + return Result.Success(); + } + + public Result Deactivate(ActorId updatedBy) + { + SetProps(Props.WithStatus(ModuleStatus.Inactive)); + Props.Audit.Update(updatedBy.GetValue()); + return Result.Success(); + } + + public Result SetMetadata(MenuNodeMetadata metadata, ActorId updatedBy) + { + SetProps(Props.WithMetadata(metadata ?? MenuNodeMetadata.Empty)); + Props.Audit.Update(updatedBy.GetValue()); + return Result.Success(); + } + + /// Añade un hijo. Regla: una opción es hoja y no admite hijos; el código es único entre hermanos. + public Result AddChild( + NodeKind kind, + Code code, + Name label, + Description description, + int sortOrder, + ActorId createdBy, + MenuNodeMetadata? metadata = null, + MenuNodePresentation? presentation = null) + { + BrokenRules.Clear(); + + if (Kind == NodeKind.Option) + { + BrokenRules.Add(new BrokenRule(nameof(Children), "Una opción es un nodo hoja y no admite hijos.")); + } + + if (_children.Any(c => c.Code.GetValue() == code.GetValue())) + { + BrokenRules.Add(new BrokenRule(nameof(Children), DomainErrors.Common.Duplicate)); + } + + if (!IsValid()) + { + return Result.Failure(BrokenRules.GetBrokenRulesAsString()); + } + + var childResult = Create(ModuleId, Props.Id, kind, code, label, description, sortOrder, createdBy, metadata, presentation); + if (childResult.IsFailure) + { + return Result.Failure(childResult.Error); + } + + _children.Add(childResult.Value); + Props.Audit.Update(createdBy.GetValue()); + return Result.Success(childResult.Value.GetId().GetValue()); + } + + public Result RemoveChild(IdValueObject childId, ActorId updatedBy) + { + var child = _children.FirstOrDefault(c => c.Props.Id.GetValue() == childId.GetValue()); + if (child is null) + { + return Result.Failure(DomainErrors.Common.NotFound); + } + + _children.Remove(child); + Props.Audit.Update(updatedBy.GetValue()); + return Result.Success(); + } + + /// Vincula una funcionalidad (acción) N:M. Solo nodos hoja (Opción). + public Result LinkAction(ActionCode actionCode, ActorId updatedBy) + { + BrokenRules.Clear(); + + if (Kind != NodeKind.Option) + { + BrokenRules.Add(new BrokenRule(nameof(ActionCodes), "Solo una opción puede vincular funcionalidades.")); + } + + if (_actionCodes.Any(a => a.GetValue() == actionCode.GetValue())) + { + BrokenRules.Add(new BrokenRule(nameof(ActionCodes), DomainErrors.Common.Duplicate)); + } + + if (!IsValid()) + { + return Result.Failure(BrokenRules.GetBrokenRulesAsString()); + } + + _actionCodes.Add(actionCode); + Props.Audit.Update(updatedBy.GetValue()); + return Result.Success(); + } + + public Result UnlinkAction(ActionCode actionCode, ActorId updatedBy) + { + var existing = _actionCodes.FirstOrDefault(a => a.GetValue() == actionCode.GetValue()); + if (existing is null) + { + return Result.Failure(DomainErrors.Common.NotFound); + } + + _actionCodes.Remove(existing); + Props.Audit.Update(updatedBy.GetValue()); + return Result.Success(); + } + + /// Busca este nodo o cualquier descendiente por id (recorrido en profundidad). + public MenuNode? Find(IdValueObject id) + { + if (Props.Id.GetValue() == id.GetValue()) + { + return this; + } + + foreach (var child in _children) + { + var found = child.Find(id); + if (found is not null) + { + return found; + } + } + + return null; + } + + /// Devuelve el hijo directo cuyo id coincide, o null. + public MenuNode? FindDirectChild(IdValueObject id) + => _children.FirstOrDefault(c => c.Props.Id.GetValue() == id.GetValue()); +} diff --git a/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/MenuNode/MenuNodeMetadata.cs b/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/MenuNode/MenuNodeMetadata.cs new file mode 100644 index 00000000..dca37304 --- /dev/null +++ b/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/MenuNode/MenuNodeMetadata.cs @@ -0,0 +1,84 @@ +namespace Ums.Domain.Authorization.SystemSuite.MenuNode; + +/// +/// Metadatos de gobernanza SDLC opcionales por nodo (ADR-0090). Todos los +/// campos son nulos por defecto: no obligan estructura donde no aporta. +/// Value object inmutable; se reemplaza entero vía . +/// +public sealed class MenuNodeMetadata : IEquatable +{ + public string? Responsable { get; } + public string? Criticidad { get; } + public string? ProductoImpactado { get; } + public string? ComponenteTecnico { get; } + public string? Dependencias { get; } + public string? Evidencias { get; } + public string? TrazabilidadSdlc { get; } + + private MenuNodeMetadata( + string? responsable, + string? criticidad, + string? productoImpactado, + string? componenteTecnico, + string? dependencias, + string? evidencias, + string? trazabilidadSdlc) + { + Responsable = Normalize(responsable); + Criticidad = Normalize(criticidad); + ProductoImpactado = Normalize(productoImpactado); + ComponenteTecnico = Normalize(componenteTecnico); + Dependencias = Normalize(dependencias); + Evidencias = Normalize(evidencias); + TrazabilidadSdlc = Normalize(trazabilidadSdlc); + } + + public static MenuNodeMetadata Create( + string? responsable = null, + string? criticidad = null, + string? productoImpactado = null, + string? componenteTecnico = null, + string? dependencias = null, + string? evidencias = null, + string? trazabilidadSdlc = null) + => new(responsable, criticidad, productoImpactado, componenteTecnico, dependencias, evidencias, trazabilidadSdlc); + + public static MenuNodeMetadata Empty { get; } = new(null, null, null, null, null, null, null); + + public bool IsEmpty => + Responsable is null && + Criticidad is null && + ProductoImpactado is null && + ComponenteTecnico is null && + Dependencias is null && + Evidencias is null && + TrazabilidadSdlc is null; + + private static string? Normalize(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + // G-055: value object con igualdad por valor. Antes de esto, dos instancias con el + // mismo contenido eran distintas (Equals/GetHashCode por referencia), lo que impedía + // usarlo en HashSet/Dictionary o deduplicar. La igualdad cubre los 7 campos ya + // normalizados, de modo que Create() sin argumentos equivale a Empty. + public bool Equals(MenuNodeMetadata? other) => + other is not null && + Responsable == other.Responsable && + Criticidad == other.Criticidad && + ProductoImpactado == other.ProductoImpactado && + ComponenteTecnico == other.ComponenteTecnico && + Dependencias == other.Dependencias && + Evidencias == other.Evidencias && + TrazabilidadSdlc == other.TrazabilidadSdlc; + + public override bool Equals(object? obj) => Equals(obj as MenuNodeMetadata); + + public override int GetHashCode() => HashCode.Combine( + Responsable, + Criticidad, + ProductoImpactado, + ComponenteTecnico, + Dependencias, + Evidencias, + TrazabilidadSdlc); +} diff --git a/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/MenuNode/MenuNodePresentation.cs b/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/MenuNode/MenuNodePresentation.cs new file mode 100644 index 00000000..0e46f27b --- /dev/null +++ b/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/MenuNode/MenuNodePresentation.cs @@ -0,0 +1,54 @@ +namespace Ums.Domain.Authorization.SystemSuite.MenuNode; + +/// +/// Cómo se pinta un nodo de navegación: su icono y la ruta a la que lleva. +/// +/// Va aparte de a propósito. Esa metadata es de GOBIERNO —quién es +/// el responsable, qué criticidad tiene, a qué trazabilidad SDLC responde— y su público es el +/// auditor. Esto es presentación, y su público es el navegador. Mezclarlas obligaría a exponer al +/// cliente campos de gobierno para poder darle un icono. +/// +/// Existe porque el escenario objetivo pide que el cliente construya la navegación sin llamadas +/// adicionales, y con solo código y etiqueta no puede: no sabe qué icono pintar ni a dónde llevar +/// al usuario al pulsar. +/// +public sealed class MenuNodePresentation : IEquatable +{ + /// + /// Identificador del icono, no una URL ni un SVG: el catálogo de iconos lo decide el cliente. + /// Guardar aquí un recurso ataría el servidor a la biblioteca gráfica de un frontend concreto. + /// + public string? Icon { get; } + + /// + /// Ruta relativa a la que lleva el nodo (`/portafolio`, `/prds/:id`). Tiene sentido sobre todo + /// en las hojas, pero un menú puede tener su propia pantalla de aterrizaje, así que no se + /// restringe por tipo de nodo. + /// + public string? Route { get; } + + private MenuNodePresentation(string? icon, string? route) + { + Icon = Normalizar(icon); + Route = Normalizar(route); + } + + public static readonly MenuNodePresentation Empty = new(null, null); + + public static MenuNodePresentation Create(string? icon, string? route) + { + var presentacion = new MenuNodePresentation(icon, route); + return presentacion.Icon is null && presentacion.Route is null ? Empty : presentacion; + } + + /// Cadena vacía y solo-espacios se tratan como ausencia: «sin icono», no «icono ''». + private static string? Normalizar(string? valor) + => string.IsNullOrWhiteSpace(valor) ? null : valor.Trim(); + + public bool Equals(MenuNodePresentation? other) + => other is not null && Icon == other.Icon && Route == other.Route; + + public override bool Equals(object? obj) => Equals(obj as MenuNodePresentation); + + public override int GetHashCode() => HashCode.Combine(Icon, Route); +} diff --git a/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/MenuNode/MenuNodeProps.cs b/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/MenuNode/MenuNodeProps.cs new file mode 100644 index 00000000..e2a24bb2 --- /dev/null +++ b/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/MenuNode/MenuNodeProps.cs @@ -0,0 +1,95 @@ +namespace Ums.Domain.Authorization.SystemSuite.MenuNode; + +/// +/// Props del nodo recursivo de navegación (ADR-0090). Adjacency list: +/// nulo ⇒ hijo directo del módulo. +/// +public class MenuNodeProps : IProps +{ + public IdValueObject Id { get; private set; } + public ModuleId ModuleId { get; private set; } + public IdValueObject? ParentNodeId { get; private set; } + public NodeKind Kind { get; private set; } + public Code Code { get; private set; } + public Name Label { get; private set; } + public Description Description { get; private set; } + public ModuleStatus Status { get; private set; } + public int SortOrder { get; private set; } + public MenuNodeMetadata Metadata { get; private set; } + + /// Icono y ruta del nodo. Nunca null: MenuNodePresentation.Empty representa la ausencia. + public MenuNodePresentation Presentation { get; private set; } + public AuditValueObject Audit { get; private set; } + + public MenuNodeProps( + IdValueObject id, + ModuleId moduleId, + IdValueObject? parentNodeId, + NodeKind kind, + Code code, + Name label, + Description description, + ModuleStatus status, + int sortOrder, + MenuNodeMetadata metadata, + ActorId createdBy, + MenuNodePresentation? presentation = null) + { + Id = id; + ModuleId = moduleId; + ParentNodeId = parentNodeId; + Kind = kind; + Code = code; + Label = label; + Description = description; + Status = status; + SortOrder = sortOrder; + Metadata = metadata; + Presentation = presentation ?? MenuNodePresentation.Empty; + Audit = AuditValueObject.Create(createdBy.GetValue()); + } + + public MenuNodeProps WithLabel(Name label) + { + var clone = (MenuNodeProps)MemberwiseClone(); + clone.Label = label; + return clone; + } + + public MenuNodeProps WithDescription(Description description) + { + var clone = (MenuNodeProps)MemberwiseClone(); + clone.Description = description; + return clone; + } + + public MenuNodeProps WithSortOrder(int sortOrder) + { + var clone = (MenuNodeProps)MemberwiseClone(); + clone.SortOrder = sortOrder; + return clone; + } + + public MenuNodeProps WithStatus(ModuleStatus status) + { + var clone = (MenuNodeProps)MemberwiseClone(); + clone.Status = status; + return clone; + } + + public MenuNodeProps WithPresentation(MenuNodePresentation presentation) + { + var clone = (MenuNodeProps)MemberwiseClone(); + clone.Presentation = presentation; + return clone; + } + + public MenuNodeProps WithMetadata(MenuNodeMetadata metadata) + { + var clone = (MenuNodeProps)MemberwiseClone(); + clone.Metadata = metadata; + return clone; + } + + public object Clone() => MemberwiseClone(); +} diff --git a/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/MenuNode/NodeKind.cs b/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/MenuNode/NodeKind.cs new file mode 100644 index 00000000..de024fe7 --- /dev/null +++ b/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/MenuNode/NodeKind.cs @@ -0,0 +1,13 @@ +namespace Ums.Domain.Authorization.SystemSuite.MenuNode; + +/// +/// Rol de un nodo de la topología de navegación (ADR-0090). Clasifica el nodo +/// sin fijar la profundidad: un o es +/// un nodo rama; una es un nodo hoja. +/// +public enum NodeKind +{ + Menu = 1, + SubMenu = 2, + Option = 3, +} diff --git a/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/Module/Module.cs b/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/Module/Module.cs index b1033ce3..215df241 100644 --- a/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/Module/Module.cs +++ b/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/Module/Module.cs @@ -1,11 +1,11 @@ namespace Ums.Domain.Authorization.SystemSuite.Module; -using Ums.Domain.Authorization.SystemSuite.Menu; -using MenuEntity = Ums.Domain.Authorization.SystemSuite.Menu.Menu; +using Ums.Domain.Authorization.SystemSuite.MenuNode; +using MenuNodeEntity = Ums.Domain.Authorization.SystemSuite.MenuNode.MenuNode; public sealed class Module : Entity { - private readonly List _menus = new(); + private readonly List _nodes = new(); private Module(ModuleProps props) : base(props) { @@ -18,7 +18,11 @@ private Module(ModuleProps props) : base(props) public ModuleStatus Status => Props.Status; public int SortOrder => Props.SortOrder; - public IReadOnlyCollection Menus => _menus.AsReadOnly(); + /// Icono del módulo (identificador, no recurso). Nulo si no se configuró. + public string? Icon => Props.Icon; + + /// Raíces del árbol de navegación recursivo (ADR-0090). Los hijos anidan dentro de cada nodo. + public IReadOnlyCollection Nodes => _nodes.AsReadOnly(); public ModuleId GetId() => ModuleId.Load(Props.Id.GetValue()); @@ -28,9 +32,10 @@ public static Result Create( Name name, Description description, int sortOrder, - ActorId createdBy) + ActorId createdBy, + string? icon = null) { - var props = new ModuleProps(IdValueObject.Create(), systemId, code, name, description, ModuleStatus.Inactive, sortOrder, createdBy); + var props = new ModuleProps(IdValueObject.Create(), systemId, code, name, description, ModuleStatus.Inactive, sortOrder, createdBy, NormalizarIcono(icon)); var module = new Module(props); if (!module.IsValid()) @@ -54,6 +59,18 @@ public Result Update(Name name, Description description, int sortOrder, ActorId return Result.Success(); } + /// Fija o borra el icono del módulo. Cadena vacía y solo-espacios equivalen a borrarlo. + public Result SetIcon(string? icon, ActorId updatedBy) + { + SetProps(Props.WithIcon(NormalizarIcono(icon))); + Props.Audit.Update(updatedBy.GetValue()); + return Result.Success(); + } + + /// «Sin icono», no «icono ''»: la misma regla que aplica el nodo de navegación. + private static string? NormalizarIcono(string? icon) + => string.IsNullOrWhiteSpace(icon) ? null : icon.Trim(); + public Result Activate(ActorId updatedBy) { BrokenRules.Clear(); @@ -92,84 +109,134 @@ public Result Deactivate(ActorId updatedBy) return Result.Success(); } - public Result AddMenu(Code code, Name label, Description description, int sortOrder, ActorId createdBy) + // ── Árbol de navegación recursivo (MenuNode, ADR-0090) ───────────────────── + + /// Añade un nodo raíz (hijo directo del módulo). Código único entre raíces. + public Result AddRootNode(NodeKind kind, Code code, Name label, Description description, int sortOrder, ActorId createdBy, MenuNodeMetadata? metadata = null, MenuNodePresentation? presentation = null) { BrokenRules.Clear(); if (Status == ModuleStatus.Inactive) { - BrokenRules.Add(new BrokenRule(nameof(Status), DomainErrors.SystemSuite.ModuleInactiveCannotAddMenu)); + BrokenRules.Add(new BrokenRule(nameof(Nodes), DomainErrors.SystemSuite.ModuleInactiveCannotAddMenu)); } - if (_menus.Any(m => m.Code == code)) + if (_nodes.Any(n => n.Code.GetValue() == code.GetValue())) { - BrokenRules.Add(new BrokenRule(nameof(Menus), DomainErrors.SystemSuite.MenuCodeNotUnique)); + BrokenRules.Add(new BrokenRule(nameof(Nodes), DomainErrors.SystemSuite.MenuCodeNotUnique)); } if (!IsValid()) { - return Result.Failure(BrokenRules.GetBrokenRulesAsString()); + return Result.Failure(BrokenRules.GetBrokenRulesAsString()); } - var menuResult = MenuEntity.Create(GetId(), code, label, description, sortOrder, createdBy); - if (menuResult.IsFailure) + var nodeResult = MenuNodeEntity.Create(GetId(), null, kind, code, label, description, sortOrder, createdBy, metadata, presentation); + if (nodeResult.IsFailure) { - return Result.Failure(menuResult.Error); + return Result.Failure(nodeResult.Error); } - _menus.Add(menuResult.Value); + _nodes.Add(nodeResult.Value); Props.Audit.Update(createdBy.GetValue()); - return Result.Success(); + return Result.Success(nodeResult.Value.GetId().GetValue()); } - public Result RemoveMenu(IdValueObject menuId, ActorId updatedBy) + /// Añade un nodo hijo bajo un nodo existente (a cualquier profundidad). + public Result AddChildNode(IdValueObject parentNodeId, NodeKind kind, Code code, Name label, Description description, int sortOrder, ActorId createdBy, MenuNodeMetadata? metadata = null, MenuNodePresentation? presentation = null) { - var menu = FindMenu(menuId); - if (menu.IsFailure) + var parent = FindNode(parentNodeId); + if (parent is null) { - BrokenRules.Add(new BrokenRule(nameof(Menus), DomainErrors.Common.NotFound)); + return Result.Failure(DomainErrors.Common.NotFound); } - if (!IsValid()) + var result = parent.AddChild(kind, code, label, description, sortOrder, createdBy, metadata, presentation); + if (result.IsSuccess) { - return Result.Failure(BrokenRules.GetBrokenRulesAsString()); + Props.Audit.Update(createdBy.GetValue()); } - _menus.Remove(menu.Value); - Props.Audit.Update(updatedBy.GetValue()); - return Result.Success(); + return result; } - public Result UpdateMenu(IdValueObject menuId, Name label, Description description, int sortOrder, ActorId updatedBy) + public Result UpdateNode(IdValueObject nodeId, Name label, Description description, int sortOrder, ActorId updatedBy) + => DelegateToNode(nodeId, node => node.Update(label, description, sortOrder, updatedBy), updatedBy); + + public Result ActivateNode(IdValueObject nodeId, ActorId updatedBy) + => DelegateToNode(nodeId, node => node.Activate(updatedBy), updatedBy); + + public Result DeactivateNode(IdValueObject nodeId, ActorId updatedBy) + => DelegateToNode(nodeId, node => node.Deactivate(updatedBy), updatedBy); + + public Result LinkNodeAction(IdValueObject nodeId, ActionCode actionCode, ActorId updatedBy) + => DelegateToNode(nodeId, node => node.LinkAction(actionCode, updatedBy), updatedBy); + + public Result UnlinkNodeAction(IdValueObject nodeId, ActionCode actionCode, ActorId updatedBy) + => DelegateToNode(nodeId, node => node.UnlinkAction(actionCode, updatedBy), updatedBy); + + public Result SetNodeMetadata(IdValueObject nodeId, MenuNodeMetadata metadata, ActorId updatedBy) + => DelegateToNode(nodeId, node => node.SetMetadata(metadata, updatedBy), updatedBy); + + /// Elimina un nodo (raíz o anidado) y todo su subárbol. + public Result RemoveNode(IdValueObject nodeId, ActorId updatedBy) { - var menu = FindMenu(menuId); - if (menu.IsFailure) + var node = FindNode(nodeId); + if (node is null) { - BrokenRules.Add(new BrokenRule(nameof(Menus), DomainErrors.Common.NotFound)); + return Result.Failure(DomainErrors.Common.NotFound); } - if (!IsValid()) + if (node.ParentNodeId is null) { - return Result.Failure(BrokenRules.GetBrokenRulesAsString()); + _nodes.Remove(node); + Props.Audit.Update(updatedBy.GetValue()); + return Result.Success(); } - var updateResult = menu.Value.Update(label, description, sortOrder, updatedBy); - if (updateResult.IsFailure) + var parent = FindNode(node.ParentNodeId); + if (parent is null) { - return Result.Failure(updateResult.Error); + return Result.Failure(DomainErrors.Common.NotFound); } - Props.Audit.Update(updatedBy.GetValue()); - return Result.Success(); + var result = parent.RemoveChild(nodeId, updatedBy); + if (result.IsSuccess) + { + Props.Audit.Update(updatedBy.GetValue()); + } + + return result; } - private Result FindMenu(IdValueObject menuId) + private Result DelegateToNode(IdValueObject nodeId, Func action, ActorId updatedBy) { - var menu = _menus.FirstOrDefault(m => - m.Props.Id.GetValue() == menuId.GetValue() || - m.Id.GetValue() == menuId.GetValue()); - return menu is null - ? Result.Failure(DomainErrors.Common.NotFound) - : Result.Success(menu); + var node = FindNode(nodeId); + if (node is null) + { + return Result.Failure(DomainErrors.Common.NotFound); + } + + var result = action(node); + if (result.IsSuccess) + { + Props.Audit.Update(updatedBy.GetValue()); + } + + return result; + } + + private MenuNodeEntity? FindNode(IdValueObject id) + { + foreach (var root in _nodes) + { + var found = root.Find(id); + if (found is not null) + { + return found; + } + } + + return null; } } diff --git a/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/Module/ModuleProps.cs b/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/Module/ModuleProps.cs index 218a5ff0..3f17422f 100644 --- a/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/Module/ModuleProps.cs +++ b/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/Module/ModuleProps.cs @@ -1,8 +1,5 @@ namespace Ums.Domain.Authorization.SystemSuite.Module; -using Ums.Domain.Authorization.SystemSuite.Menu; -using MenuEntity = Ums.Domain.Authorization.SystemSuite.Menu.Menu; - public class ModuleProps : IProps { public IdValueObject Id { get; private set; } @@ -12,6 +9,13 @@ public class ModuleProps : IProps public Description Description { get; private set; } public ModuleStatus Status { get; private set; } public int SortOrder { get; private set; } + + /// + /// Identificador del icono del módulo, no un recurso: igual que en el nodo de navegación, el + /// catálogo gráfico lo resuelve el cliente. Nulo = sin icono configurado. + /// + public string? Icon { get; private set; } + public AuditValueObject Audit { get; private set; } public ModuleProps( @@ -22,7 +26,8 @@ public ModuleProps( Description description, ModuleStatus status, int sortOrder, - ActorId createdBy) + ActorId createdBy, + string? icon = null) { Id = id; SystemId = systemId; @@ -31,6 +36,7 @@ public ModuleProps( Description = description; Status = status; SortOrder = sortOrder; + Icon = icon; Audit = AuditValueObject.Create(createdBy.GetValue()); } @@ -55,6 +61,13 @@ public ModuleProps WithSortOrder(int sortOrder) return clone; } + public ModuleProps WithIcon(string? icon) + { + var clone = (ModuleProps)MemberwiseClone(); + clone.Icon = icon; + return clone; + } + public ModuleProps WithStatus(ModuleStatus status) { var clone = (ModuleProps)MemberwiseClone(); diff --git a/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/Option/Option.cs b/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/Option/Option.cs deleted file mode 100644 index 3e6bad4f..00000000 --- a/src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/Option/Option.cs +++ /dev/null @@ -1,50 +0,0 @@ -namespace Ums.Domain.Authorization.SystemSuite.Option; - -public sealed class Option : Entity -{ - private Option(OptionProps props) : base(props) - { - } - - public SubMenuId SubMenuId => Props.SubMenuId; - public Code Code => Props.Code; - public Name Label => Props.Label; - public Description Description => Props.Description; - public ActionCode ActionCode => Props.ActionCode; - public int SortOrder => Props.SortOrder; - - public OptionId GetId() => OptionId.Load(Props.Id.GetValue()); - - public static Result public interface IAuthMethodResolver { Task> ResolveAsync( Guid tenantId, AuthAccessScope scope, + Guid? systemSuiteId = null, + string? emailDomain = null, CancellationToken cancellationToken = default); } diff --git a/src/apps/ums.api/Ums.Domain/Identity/Auth/IIdpChainAuthenticator.cs b/src/apps/ums.api/Ums.Domain/Identity/Auth/IIdpChainAuthenticator.cs new file mode 100644 index 00000000..f15d1fea --- /dev/null +++ b/src/apps/ums.api/Ums.Domain/Identity/Auth/IIdpChainAuthenticator.cs @@ -0,0 +1,34 @@ +using Ums.Domain.Identity.Tenant.IdentityProvider; +using TenantAggregate = Ums.Domain.Identity.Tenant.Tenant; + +namespace Ums.Domain.Identity.Auth; + +/// +/// Orquesta la autenticación federada del login recorriendo la cadena de fallback +/// IdpConfiguration.FallbackToId (FR-042 · ADR-UMS-097 §2.3/§2.4, slice 2b). +/// +/// Partiendo de la configuración ganadora del selector (2a), intenta autenticar; si el intento +/// es avanza al siguiente proveedor de la cadena; +/// si es se detiene y devuelve el error (sin avanzar, +/// anti credential spraying); si es devuelve la identidad +/// externa y el proveedor que efectivamente autenticó. Detecta ciclos y aplica un tope de saltos; +/// si la cadena se agota por indisponibilidad devuelve un fallo AUTH_018 (503, no 401). Audita +/// un evento por proveedor intentado. +/// +public interface IIdpChainAuthenticator +{ + Task> AuthenticateAsync( + TenantAggregate tenant, + string credential, + Guid? systemSuiteId, + string? emailDomain, + string clientIp, + CancellationToken cancellationToken = default); +} + +/// +/// Resultado exitoso del recorrido de la cadena: la identidad externa validada y el +/// que la autenticó (puede ser un proveedor de respaldo distinto +/// al primario cuando hubo fallback por indisponibilidad). +/// +public sealed record IdpChainOutcome(ExternalIdentity Identity, IdentityProvider Provider); diff --git a/src/apps/ums.api/Ums.Domain/Identity/Auth/IdpAuthOutcome.cs b/src/apps/ums.api/Ums.Domain/Identity/Auth/IdpAuthOutcome.cs new file mode 100644 index 00000000..2977e2cd --- /dev/null +++ b/src/apps/ums.api/Ums.Domain/Identity/Auth/IdpAuthOutcome.cs @@ -0,0 +1,24 @@ +namespace Ums.Domain.Identity.Auth; + +/// +/// Clasificación del resultado de un intento de autenticación contra un IdP federado, +/// para el fallback encadenado de FR-042 (ADR-UMS-097 §2.3). Es la decisión de seguridad +/// que gobierna si la cadena de FallbackToId AVANZA o se DETIENE: +/// +/// : el IdP validó la credencial y devolvió una identidad externa. +/// : indisponibilidad de infraestructura del IdP +/// (timeout, 5xx, JWKS inalcanzable, adaptador no registrado). Es el único caso que +/// autoriza avanzar al siguiente proveedor de la cadena. +/// : el IdP respondió y rechazó (credencial inválida, +/// token no válido, política) o el fallo no se puede clasificar con certeza como +/// infraestructura. Es TERMINAL: NUNCA avanza la cadena. Encadenar ante un fallo de +/// credenciales permitiría credential spraying cross-IdP; por eso, y por fail-closed, +/// ante la duda se detiene el intento (ADR-UMS-097 §2.3). +/// +/// +public enum IdpAuthOutcome +{ + Success, + InfraUnavailable, + CredentialTerminal, +} diff --git a/src/apps/ums.api/Ums.Domain/Identity/Auth/IdpAuthOutcomeClassifier.cs b/src/apps/ums.api/Ums.Domain/Identity/Auth/IdpAuthOutcomeClassifier.cs new file mode 100644 index 00000000..8879b8f2 --- /dev/null +++ b/src/apps/ums.api/Ums.Domain/Identity/Auth/IdpAuthOutcomeClassifier.cs @@ -0,0 +1,91 @@ +namespace Ums.Domain.Identity.Auth; + +/// +/// Clasificador explícito y fail-closed del resultado de un intento de autenticación +/// contra un IdP federado (FR-042 · ADR-UMS-097 §2.3). Traduce el que +/// devuelve el path IdP (IIdpAuthStrategy/adaptadores) a un , +/// que es lo que decide si el fallback encadenado avanza o se detiene. +/// +/// Señales de indisponibilidad de infraestructura (las únicas que autorizan avanzar), +/// derivadas del código real del path IdP y ambas listadas por ADR-UMS-097 §2.3: +/// +/// AUTH_012IdpAuthStrategyDispatcher: no hay adaptador registrado para la +/// estrategia del proveedor («adaptador no disponible/no registrado»). +/// AUTH_034HttpJwksProvider: no se pudo obtener el JWKS del issuer +/// (5xx/timeout/red) («error 5xx del IdP/JWKS»). +/// AUTH_035HttpOidcTokenClient: el token endpoint respondió 5xx, dio +/// timeout o falló el transporte (G-108). Es la rama de INFRA del intercambio de código, ahora +/// estructuralmente separada del 4xx de credencial (que sigue en AUTH_021). +/// +/// +/// Todo lo demás es TERMINAL (no avanza), por fail-closed. En particular: +/// +/// rechazos de credencial/token del IdP (AUTH_02x: firma, iss/aud/exp/nonce, etc.); +/// AUTH_021 (intercambio de código): 4xx del token endpoint (invalid_grant u +/// otra respuesta de cliente) o cuerpo 2xx malformado ⇒ TERMINAL. NUNCA debe entrar en la lista +/// blanca de infra: encadenar ante un fallo de credencial abriría credential spraying (§2.3); +/// AUTH_013 — código reutilizado por flujos no-IdP (stub de dev, contexto de gestión), +/// ambiguo ⇒ terminal; +/// códigos desconocidos o error sin código ⇒ terminal. +/// +/// +/// Ambigüedad resuelta (G-108): el disparo de fallback por «timeout/5xx del IdP» en el +/// token endpoint ya se puede activar con seguridad porque HttpOidcTokenClient emite un código +/// de infra estructuralmente distintoAUTH_035, por clase de status HTTP / tipo de excepción, +/// no por texto— del 4xx de credencial (AUTH_021, que sigue terminal). El 4xx +/// invalid_grant permanece TERMINAL bajo cualquier circunstancia: la separación es la que cierra el +/// vector de credential spraying (ADR-UMS-097 §2.3). +/// +public static class IdpAuthOutcomeClassifier +{ + /// + /// Lista blanca de códigos de error que se consideran indisponibilidad de infraestructura + /// (y por tanto autorizan avanzar la cadena). Cualquier código ausente de este conjunto es TERMINAL. + /// + public static readonly IReadOnlySet InfraUnavailableCodes = + new HashSet(StringComparer.Ordinal) + { + "AUTH_012", // dispatcher: sin adaptador registrado para la estrategia + "AUTH_034", // JWKS inalcanzable (5xx/timeout/red) + "AUTH_035", // token endpoint OIDC inalcanzable: 5xx/timeout/transporte (G-108). SOLO la rama de + // infra de HttpOidcTokenClient lo emite; el 4xx invalid_grant sigue en AUTH_021 + // (TERMINAL), jamás aquí ⇒ no abre credential spraying (ADR-UMS-097 §2.3). + }; + + /// + /// Clasifica el resultado de un intento IdP. ⇒ + /// ; un fallo cuyo código está en la lista blanca de infra ⇒ + /// ; cualquier otro fallo ⇒ + /// (fail-closed). + /// + public static IdpAuthOutcome Classify(Result attempt) + { + if (attempt is null) + { + return IdpAuthOutcome.CredentialTerminal; + } + + if (attempt.IsSuccess) + { + return IdpAuthOutcome.Success; + } + + return IsInfraUnavailable(attempt.Error) + ? IdpAuthOutcome.InfraUnavailable + : IdpAuthOutcome.CredentialTerminal; + } + + /// + /// Indica si el error corresponde a una señal INEQUÍVOCA de indisponibilidad de infraestructura. + /// Cualquier ambigüedad devuelve false (fail-closed). + /// + public static bool IsInfraUnavailable(string? error) + => !string.IsNullOrWhiteSpace(error) && InfraUnavailableCodes.Contains(ExtractCode(error)); + + /// Extrae el prefijo AUTH_0xx del mensaje de error (todo antes del primer «:»). + private static string ExtractCode(string error) + { + var separatorIndex = error.IndexOf(':'); + return (separatorIndex > 0 ? error[..separatorIndex] : error).Trim(); + } +} diff --git a/src/apps/ums.api/Ums.Domain/Identity/Repositories.cs b/src/apps/ums.api/Ums.Domain/Identity/Repositories.cs index 37781af5..c52c7335 100644 --- a/src/apps/ums.api/Ums.Domain/Identity/Repositories.cs +++ b/src/apps/ums.api/Ums.Domain/Identity/Repositories.cs @@ -1,5 +1,6 @@ namespace Ums.Domain.Identity; using Ums.Domain.Identity.Tenant; +using Ums.Domain.Identity.Tenant.Branch; using Ums.Domain.Identity.UserAccount; using Ums.Domain.Identity.UserManagementDelegation; using TenantAggregate = Ums.Domain.Identity.Tenant.Tenant; @@ -10,6 +11,8 @@ public interface ITenantRepository : IAggregateRepository { Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); Task GetByCodeAsync(string code, CancellationToken cancellationToken = default); + Task> GetAllAsync(Guid? tenantId = null, CancellationToken cancellationToken = default); + /// /// G-161: ¿existe ya una sucursal con este código bajo el inquilino? Consulta autoritativa que /// IGNORA el filtro global por inquilino, para que la verificación de unicidad sea correcta aun @@ -18,7 +21,19 @@ public interface ITenantRepository : IAggregateRepository /// `Tenant.AddBranch` no vería el duplicado. Es de solo lectura y se usa en la vía de escritura. /// Task BranchCodeExistsAsync(Guid tenantId, string code, CancellationToken cancellationToken = default); - Task> GetAllAsync(Guid? tenantId = null, CancellationToken cancellationToken = default); + + /// + /// ADR-0164: BITÁCORA de una sucursal — sus episodios (apertura, desactivación, reactivación, + /// cierre definitivo) en orden cronológico, con fecha, autor y la foto de cómo era la sucursal + /// en cada uno. + /// + /// Es una lectura APARTE y no una colección del agregado a propósito: ninguna invariante + /// depende de la historia, y cargarla con cada GetByIdAsync —ruta caliente, la usa hasta + /// el grafo de autorización— traería filas que nadie mira. Devuelve la bitácora aunque la + /// sucursal esté cerrada: preguntar por el pasado de algo cerrado es justamente el caso de uso. + /// + Task> GetBranchLifecycleAsync( + Guid tenantId, Guid branchId, CancellationToken cancellationToken = default); /// /// REC-12: Server-side paginated query. SQL implementations use Skip/Take at the DB level. /// InMemory implementations call GetAllAsync then apply in-memory pagination. @@ -26,9 +41,15 @@ public interface ITenantRepository : IAggregateRepository /// When tenantId is provided, only returns the matching tenant and its direct children (ParentTenantId == tenantId). /// Null tenantId means cross-tenant access (internal admins only). /// + /// + /// Campo sobre el que aplica ("code" | "name"). Es independiente de + /// (que solo ordena). Si es nulo/vacío se usa + /// por compatibilidad. Antes el campo de búsqueda se derivaba de sortBy, ignorando el parámetro + /// `criteria` del API (búsqueda por código imposible sin ordenar por código) — G-014 residual. + /// Task<(IReadOnlyList Items, int TotalCount)> GetPagedAsync( int page, int pageSize, string? search, string? status, string sortBy, string sortOrder, - Guid? tenantId = null, CancellationToken cancellationToken = default); + Guid? tenantId = null, CancellationToken cancellationToken = default, string? searchField = null); /// /// REC-16: Soft-delete a tenant by ID. Marks IsDeleted=true and records who deleted it. @@ -48,6 +69,10 @@ public interface IUserAccountRepository : IAggregateRepositoryReturns the number of non-deleted users in the given tenant. Task CountActiveByTenantAsync(Guid tenantId, CancellationToken cancellationToken = default); + /// G-046: Returns the number of active users bound to the given branch. Used as a + /// dependency guard so a branch with active accounts cannot be deactivated/removed and leave + /// dangling branch references. + Task CountActiveByBranchAsync(Guid branchId, CancellationToken cancellationToken = default); /// /// REC-12: Server-side paginated query. SQL implementations use Skip/Take at the DB level. /// diff --git a/src/apps/ums.api/Ums.Domain/Identity/Tenant/Branch/Branch.cs b/src/apps/ums.api/Ums.Domain/Identity/Tenant/Branch/Branch.cs index e589f5f7..9125e6a7 100644 --- a/src/apps/ums.api/Ums.Domain/Identity/Tenant/Branch/Branch.cs +++ b/src/apps/ums.api/Ums.Domain/Identity/Tenant/Branch/Branch.cs @@ -2,6 +2,19 @@ namespace Ums.Domain.Identity.Tenant.Branch; public sealed class Branch : Entity { + /// + /// Episodios registrados en ESTA unidad de trabajo y todavía sin persistir. + /// + /// La bitácora histórica NO se carga con el agregado a propósito: ninguna invariante de + /// Tenant depende de ella, y arrastrarla en cada lectura del inquilino —que es una ruta + /// caliente, la usa hasta el grafo de autorización— costaría filas que nadie mira. Lo que sí es + /// obligatorio es que el asiento se escriba en la MISMA transacción que el cambio de estado: un + /// manejador post-commit es, por contrato (ADR-0098 D4), best-effort, y una bitácora que puede + /// perder episodios no prueba nada. De ahí este búfer: el dominio decide y anota, el repositorio + /// vuelca en el mismo SaveChanges. + /// + private readonly List _pendingLifecycleEntries = []; + private Branch(BranchProps props) : base(props) { } @@ -12,6 +25,16 @@ private Branch(BranchProps props) : base(props) public Value? GeofencingMetadata => Props.GeofencingMetadata; public bool IsActive => Props.IsActive; + /// + /// Verdadero cuando la sucursal está CERRADA DEFINITIVAMENTE (ADR-0164 §2.1). La fila sigue en + /// la base —y su código sigue ocupado— pero la sucursal ya no existe para el negocio. + /// + public bool IsClosed => Props.IsClosed; + public DateTime? ClosedAtUtc => Props.ClosedAtUtc; + public string? ClosedBy => Props.ClosedBy; + + public IReadOnlyCollection PendingLifecycleEntries => _pendingLifecycleEntries.AsReadOnly(); + public BranchId GetId() => BranchId.Load(Props.Id.GetValue()); public static Result Create(TenantId tenantId, Code code, Name name, ActorId createdBy, Value? geofencingMetadata = null) @@ -24,12 +47,21 @@ public static Result Create(TenantId tenantId, Code code, Name name, Act return Result.Failure(branch.BrokenRules.GetBrokenRulesAsString()); } + // La apertura es el episodio cero: sin él la bitácora empezaría a media historia y no se + // podría situar el primer despacho de la sucursal en ninguna época. + branch.RecordEpisode(BranchLifecycleEpisode.Opened, createdBy, reason: null); + return Result.Success(branch); } internal Result CanDeactivate() { - if (!IsActive) + if (IsClosed) + { + // Una sucursal cerrada no admite NINGUNA transición: su ciclo de vida terminó. + BrokenRules.Add(new BrokenRule(nameof(IsClosed), DomainErrors.Tenant.BranchClosed)); + } + else if (!IsActive) { BrokenRules.Add(new BrokenRule(nameof(IsActive), DomainErrors.Common.Invalid)); } @@ -41,7 +73,15 @@ internal Result CanDeactivate() internal Result CanReactivate() { - if (IsActive) + if (IsClosed) + { + // El cierre es TERMINAL: reactivar no puede ser la puerta de atrás que resucite una + // sucursal cerrada (ADR-0164 §2.4). Si el negocio vuelve a abrir en esa plaza, se da de + // alta una sucursal NUEVA —con otro código, porque el anterior queda ocupado— y la + // cerrada permanece como histórico consultable. + BrokenRules.Add(new BrokenRule(nameof(IsClosed), DomainErrors.Tenant.BranchClosed)); + } + else if (IsActive) { BrokenRules.Add(new BrokenRule(nameof(IsActive), DomainErrors.Common.Invalid)); } @@ -51,13 +91,82 @@ internal Result CanReactivate() : Result.Failure(BrokenRules.GetBrokenRulesAsString()); } - internal void DeactivateInternal() + /// + /// ¿Puede cerrarse definitivamente? Solo comprueba lo que el propio agregado sabe; las + /// referencias vivas de FUERA del agregado (usuarios y perfiles activos) las verifica + /// Tenant.CloseBranch con los recuentos que le pasa la aplicación. + /// + internal Result CanClose() + { + if (IsClosed) + { + BrokenRules.Add(new BrokenRule(nameof(IsClosed), DomainErrors.Tenant.BranchAlreadyClosed)); + } + + return IsValid() + ? Result.Success() + : Result.Failure(BrokenRules.GetBrokenRulesAsString()); + } + + // FS-26 (G-024): actualiza los datos editables de la sucursal (nombre, geocerca). + // Invocado por el agregado raíz Tenant (Tenant.UpdateBranch). + internal void UpdateInternal(Name name, Value? geofencingMetadata) + { + Props.Update(name, geofencingMetadata); + } + + internal void DeactivateInternal(ActorId actor, string? reason) { Props.IsActive = false; + Props.Audit.Update(actor.GetValue()); + RecordEpisode(BranchLifecycleEpisode.Deactivated, actor, reason); } - internal void ReactivateInternal() + internal void ReactivateInternal(ActorId actor, string? reason) { Props.IsActive = true; + Props.Audit.Update(actor.GetValue()); + RecordEpisode(BranchLifecycleEpisode.Reactivated, actor, reason); + } + + /// + /// Cierre DEFINITIVO. Marca el estado terminal y apaga IsActive como CONSECUENCIA —una + /// sucursal cerrada evidentemente no opera—, nunca como mecanismo: lo que impide la vuelta es + /// , y por eso lo mira a él. + /// + internal void CloseInternal(ActorId closedBy, string? reason) + { + var now = DateTime.UtcNow; + + Props.IsClosed = true; + Props.ClosedAtUtc = now; + Props.ClosedBy = closedBy.GetValue(); + Props.IsActive = false; + Props.Audit.Update(closedBy.GetValue()); + + RecordEpisode(BranchLifecycleEpisode.Closed, closedBy, reason, now); + } + + /// + /// El repositorio llama a esto tras confirmar la transacción, igual que + /// DomainEvents.MarkChangesAsCommitted(): los asientos ya están en la base y el búfer no + /// debe volver a volcarlos. + /// + internal void MarkLifecycleEntriesAsCommitted() => _pendingLifecycleEntries.Clear(); + + private void RecordEpisode(BranchLifecycleEpisode episode, ActorId actor, string? reason, DateTime? occurredAtUtc = null) + { + _pendingLifecycleEntries.Add(new BranchLifecycleEntry( + Id: Guid.NewGuid(), + TenantId: Props.TenantId.GetValue(), + BranchId: Props.Id.GetValue(), + Episode: episode, + OccurredAtUtc: occurredAtUtc ?? DateTime.UtcNow, + ActorId: actor.GetValue(), + // La foto se toma DESPUÉS de aplicar el cambio de estado, así que un asiento de cierre + // guarda el nombre y la geocerca con los que la sucursal llegó a su último día. + NameSnapshot: Props.Name.GetValue(), + GeofencingSnapshot: Props.GeofencingMetadata?.GetValue(), + Reason: reason)); } } diff --git a/src/apps/ums.api/Ums.Domain/Identity/Tenant/Branch/BranchLifecycleEntry.cs b/src/apps/ums.api/Ums.Domain/Identity/Tenant/Branch/BranchLifecycleEntry.cs new file mode 100644 index 00000000..47ddeb3b --- /dev/null +++ b/src/apps/ums.api/Ums.Domain/Identity/Tenant/Branch/BranchLifecycleEntry.cs @@ -0,0 +1,41 @@ +namespace Ums.Domain.Identity.Tenant.Branch; + +/// +/// Asiento de la BITÁCORA de una sucursal: un episodio con su fecha, su autor y la foto de cómo era +/// la sucursal en ese instante. +/// +/// Por qué existe. El estado (IsActive, IsClosed) responde «cómo está la +/// sucursal HOY». La pregunta de auditoría de un operador logístico aduanero es otra: «¿cómo estaba +/// en marzo de 2024, cuando salió ESTE despacho?». Sin bitácora esa pregunta no tiene respuesta, +/// porque la fila de la sucursal solo guarda su último estado y su última marca de auditoría: dos +/// épocas distintas de la misma sucursal se ven idénticas. +/// +/// Por qué guarda una foto y no solo el evento. Reconstruir «qué había en marzo» +/// necesita el nombre y la geocerca que regían ENTONCES, no los de hoy. Guardarlos en el asiento +/// —al cerrar y al reabrir, que son las fronteras de cada época— hace la respuesta directa: se lee +/// el asiento anterior a la fecha preguntada. La alternativa, versionar la sucursal entera de forma +/// bitemporal, resuelve más casos a un coste desproporcionado para el problema que hay. +/// +/// Es inmutable y solo crece. No hay corrección ni borrado de asientos: una bitácora +/// que se puede reescribir no prueba nada. Es un record por eso mismo (ADR-0041: dominio +/// inmutable). +/// +/// Identidad del asiento. +/// Inquilino dueño de la sucursal; sostiene el aislamiento (filtro global y RLS). +/// Sucursal a la que pertenece el episodio. +/// Qué ocurrió (apertura, desactivación, reactivación, cierre definitivo). +/// Cuándo ocurrió, en UTC. +/// Quién lo hizo. Es el actor de la operación, no el usuario técnico. +/// Cómo se llamaba la sucursal en ese instante. +/// Dónde estaba (geocerca) en ese instante; nulo si no tenía. +/// Motivo declarado por quien operó, si lo dio. +public sealed record BranchLifecycleEntry( + Guid Id, + Guid TenantId, + Guid BranchId, + BranchLifecycleEpisode Episode, + DateTime OccurredAtUtc, + string ActorId, + string NameSnapshot, + string? GeofencingSnapshot, + string? Reason); diff --git a/src/apps/ums.api/Ums.Domain/Identity/Tenant/Branch/BranchProps.cs b/src/apps/ums.api/Ums.Domain/Identity/Tenant/Branch/BranchProps.cs index bbe45c84..eb904d69 100644 --- a/src/apps/ums.api/Ums.Domain/Identity/Tenant/Branch/BranchProps.cs +++ b/src/apps/ums.api/Ums.Domain/Identity/Tenant/Branch/BranchProps.cs @@ -10,6 +10,21 @@ public class BranchProps : IProps public bool IsActive { get; set; } public AuditValueObject Audit { get; private set; } + /// + /// Marca de CIERRE DEFINITIVO (ADR-0164 §2.1). Es un eje INDEPENDIENTE de + /// y no una reutilización suya: «inactiva» es un estado vivo y reversible —Reactivate la + /// devuelve al servicio— mientras que «cerrada» es terminal y no admite vuelta. Colgar el cierre + /// de permitiría resucitar una sucursal cerrada con una simple + /// reactivación, que es justo lo que ADR-0164 §2.4 prohíbe. + /// + public bool IsClosed { get; set; } + + /// Instante del cierre definitivo, en UTC. Nulo mientras la sucursal siga existiendo. + public DateTime? ClosedAtUtc { get; set; } + + /// Actor que cerró la sucursal. Nulo mientras no se haya cerrado. + public string? ClosedBy { get; set; } + public BranchProps(IdValueObject id, TenantId tenantId, Code code, Name name, Value? geofencingMetadata, ActorId createdBy) { Id = id; @@ -18,9 +33,17 @@ public BranchProps(IdValueObject id, TenantId tenantId, Code code, Name name, Va Name = name; GeofencingMetadata = geofencingMetadata; IsActive = true; + IsClosed = false; Audit = AuditValueObject.Create(createdBy.GetValue()); } + // FS-26 (G-024): datos editables de la sucursal. El Code es único dentro del tenant y no cambia. + public void Update(Name name, Value? geofencingMetadata) + { + Name = name; + GeofencingMetadata = geofencingMetadata; + } + public object Clone() { return this.MemberwiseClone(); diff --git a/src/apps/ums.api/Ums.Domain/Identity/Tenant/Events/TenantDomainEventsManager.cs b/src/apps/ums.api/Ums.Domain/Identity/Tenant/Events/TenantDomainEventsManager.cs index f47c8340..67615cf8 100644 --- a/src/apps/ums.api/Ums.Domain/Identity/Tenant/Events/TenantDomainEventsManager.cs +++ b/src/apps/ums.api/Ums.Domain/Identity/Tenant/Events/TenantDomainEventsManager.cs @@ -1,3 +1,4 @@ +#pragma warning disable S1144, S1186 namespace Ums.Domain.Identity.Tenant.Events; public class TenantDomainEventsManager : DomainEventsManager @@ -5,20 +6,26 @@ public class TenantDomainEventsManager : DomainEventsManager public TenantDomainEventsManager(IAggregateRoot aggregateRoot) : base(aggregateRoot) { } private void Apply(TenantCreatedEvent @event) { } + private void Apply(TenantUpdatedEvent @event) { } private void Apply(TenantSuspendedEvent @event) { } private void Apply(TenantActivatedEvent @event) { } private void Apply(TenantArchivedEvent @event) { } private void Apply(BranchCreatedEvent @event) { } - private void Apply(BranchRemovedEvent @event) { } + private void Apply(BranchUpdatedEvent @event) { } + private void Apply(BranchClosedEvent @event) { } private void Apply(BranchDeactivatedEvent @event) { } private void Apply(BranchReactivatedEvent @event) { } private void Apply(IdentityProviderRegisteredEvent @event) { } private void Apply(IdentityProviderActivatedEvent @event) { } private void Apply(IdentityProviderDeactivatedEvent @event) { } private void Apply(IdentityProviderRemovedEvent @event) { } + + // Branding por inquilino: eventos propios del satélite. private void Apply(BrandingCreatedEvent @event) { } private void Apply(BrandingUpdatedEvent @event) { } private void Apply(BrandingRemovedEvent @event) { } private void Apply(BrandingDnsVerifiedEvent @event) { } private void Apply(BrandingDnsFailedEvent @event) { } } + +#pragma warning restore S1144, S1186 diff --git a/src/apps/ums.api/Ums.Domain/Identity/Tenant/Tenant.cs b/src/apps/ums.api/Ums.Domain/Identity/Tenant/Tenant.cs index b2a2122c..ed4c105f 100644 --- a/src/apps/ums.api/Ums.Domain/Identity/Tenant/Tenant.cs +++ b/src/apps/ums.api/Ums.Domain/Identity/Tenant/Tenant.cs @@ -1,19 +1,21 @@ namespace Ums.Domain.Identity.Tenant; using Ums.Domain.Identity.Tenant.Branch; using Ums.Domain.Identity.Tenant.IdentityProvider; -using Ums.Domain.Identity.Tenant.Branding; using Ums.Domain.Identity.Tenant.TenantParameter; using Ums.Domain.Identity.Tenant.Events; +using Ums.Domain.Identity.Tenant.Branding; using BranchEntity = Ums.Domain.Identity.Tenant.Branch.Branch; using IdentityProviderEntity = Ums.Domain.Identity.Tenant.IdentityProvider.IdentityProvider; -using BrandingEntity = Ums.Domain.Identity.Tenant.Branding.Branding; using TenantParameterEntity = Ums.Domain.Identity.Tenant.TenantParameter.TenantParameter; +using BrandingEntity = Ums.Domain.Identity.Tenant.Branding.Branding; public sealed class Tenant : AggregateRoot { private readonly List _branches = new(); private readonly List _identityProviders = new(); private readonly List _parameters = new(); + + // Branding por inquilino: entidad hija propia del satélite (no existe en la plataforma de origen). private BrandingEntity? _branding; public new TenantDomainEventsManager DomainEvents { get; } @@ -37,11 +39,23 @@ private Tenant(TenantProps props) : base(props) public bool IsManagementOwner => Props.IsManagementOwner; public TenantStatus Status => Props.Status; + // FR-042 (ADR-UMS-097 §2.2): suite por defecto del inquilino (null = suite única / sin filtro). + public SystemSuiteId? DefaultSystemSuiteId => Props.DefaultSystemSuiteId; + + /// + /// TODAS las sucursales del inquilino, incluidas las cerradas definitivamente. La colección no + /// encoge nunca (ADR-0164 §2.1): la vía de escritura necesita verlas para que el código siga + /// ocupado y para que la resolución por id de un perfil antiguo siga encontrando su sucursal. + /// Quien LISTA para un humano debe filtrar por !IsClosed; ver + /// GetBranchesByTenantIdQueryHandler. + /// public IReadOnlyCollection Branches => _branches.AsReadOnly(); public IReadOnlyCollection IdentityProviders => _identityProviders.AsReadOnly(); - public BrandingEntity? Branding => _branding; public IReadOnlyCollection Parameters => _parameters.AsReadOnly(); + /// Branding del inquilino (portal de login por marca). Nulo mientras no se configure. + public BrandingEntity? Branding => _branding; + public IdentityProviderEntity? GetActiveIdentityProvider() { return _identityProviders.FirstOrDefault(ip => ip.IsActive); @@ -84,6 +98,11 @@ public static Result Create( public Result AddBranch(Code code, Name name, ActorId createdBy, Value? geofencingMetadata = null) { + // La comparación NO excluye las sucursales cerradas, y es deliberado: el código de una + // sucursal cerrada queda ocupado PARA SIEMPRE (ADR-0164 §2.3). Liberarlo permitiría dos + // sucursales distintas con el mismo código bajo el mismo inquilino, y una consulta sobre un + // despacho de 2024 no podría decir a cuál de las dos se refiere. El índice único de la base + // tampoco filtra por estado, así que ambos lados dicen lo mismo. if (_branches.Any(b => b.Code.Equals(code))) { BrokenRules.Add(new BrokenRule(nameof(Branches), DomainErrors.Tenant.BranchCodeNotUnique)); @@ -107,7 +126,31 @@ public Result AddBranch(Code code, Name name, ActorId createdBy, V return Result.Success(branchResult.Value); } - public Result RemoveBranch(IdValueObject branchId, ActorId updatedBy) + /// + /// CIERRE DEFINITIVO de una sucursal (ADR-0164 §2.1): el verbo terminal del ciclo de vida. + /// + /// Sustituye al antiguo RemoveBranch, que quitaba la sucursal de la colección y el + /// reconciliador de EF traducía en un DELETE real. Ese borrado dejaba huérfanos en + /// silencio —Profiles.BranchId y UserAccounts.BranchId no tienen clave ajena + /// contra TenantBranches— y, sobre todo, hacía imposible explicar un despacho pasado: + /// la sucursal de la que salió ya no existía. La colección ya NUNCA encoge. + /// + /// Es un verbo distinto de , no otro nombre para lo mismo + /// (ADR-0164 §2.4): desactivar es una pausa reversible; cerrar no se revierte, y no se llega a + /// él cambiando IsActive. + /// + /// + /// Cuentas ACTIVAS asignadas a la sucursal. Las cuenta la aplicación —viven en otro agregado— y + /// el dominio solo decide con ellas, igual que UserAccount.Delete(activeProfileCount). + /// Lo ya eliminado no bloquea, porque el recuento solo mira lo activo (ADR-0164 §2.2). + /// + /// Perfiles ACTIVOS acotados a la sucursal, con la misma regla. + public Result CloseBranch( + IdValueObject branchId, + ActorId closedBy, + int activeUserCount = 0, + int activeProfileCount = 0, + string? reason = null) { var branch = FindBranch(branchId); if (branch.IsFailure) @@ -115,9 +158,20 @@ public Result RemoveBranch(IdValueObject branchId, ActorId updatedBy) BrokenRules.Add(new BrokenRule(nameof(Branches), DomainErrors.Common.NotFound)); } - if (branch.IsSuccess && branch.Value.IsActive) + if (branch.IsSuccess) + { + var canClose = branch.Value.CanClose(); + if (canClose.IsFailure) + { + BrokenRules.Add(new BrokenRule(nameof(Branches), canClose.Error)); + } + } + + // Guarda de cascada, el análogo de un ON DELETE RESTRICT: se verifica ANTES de actuar y se + // rechaza nombrando qué bloquea, en vez de arrastrar en cascada o de huerfanizar. + if (activeUserCount > 0 || activeProfileCount > 0) { - BrokenRules.Add(new BrokenRule(nameof(Branches), DomainErrors.Common.Invalid)); + BrokenRules.Add(new BrokenRule(nameof(Branches), DomainErrors.Tenant.BranchHasLiveReferences)); } if (!IsValid()) @@ -125,14 +179,17 @@ public Result RemoveBranch(IdValueObject branchId, ActorId updatedBy) return Result.Failure(BrokenRules.GetBrokenRulesAsString()); } - _branches.Remove(branch.Value); - DomainEvents.RaiseEvent(new BranchRemovedEvent(Props.Id.GetValue(), branch.Value.GetId().GetValue())); + branch.Value.CloseInternal(closedBy, reason); + DomainEvents.RaiseEvent(new BranchClosedEvent( + Props.Id.GetValue(), + branch.Value.GetId().GetValue(), + branch.Value.Code.GetValue())); TrackingState.MarkAsDirty(); - Props.Audit.Update(updatedBy.GetValue()); + Props.Audit.Update(closedBy.GetValue()); return Result.Success(); } - public Result DeactivateBranch(IdValueObject branchId, ActorId updatedBy) + public Result DeactivateBranch(IdValueObject branchId, ActorId updatedBy, string? reason = null) { var branch = FindBranch(branchId); if (branch.IsFailure) @@ -145,7 +202,10 @@ public Result DeactivateBranch(IdValueObject branchId, ActorId updatedBy) var canDeactivate = branch.Value.CanDeactivate(); if (canDeactivate.IsFailure) { - BrokenRules.Add(new BrokenRule(nameof(Branches), DomainErrors.Common.Invalid)); + // Se propaga el código REAL en vez de colapsarlo a `common.invalid`: «ya está + // inactiva» (400) y «está cerrada» (409) son rechazos distintos, y la presentación + // solo puede distinguirlos si el código llega hasta ella. + BrokenRules.Add(new BrokenRule(nameof(Branches), canDeactivate.Error)); } } @@ -154,14 +214,14 @@ public Result DeactivateBranch(IdValueObject branchId, ActorId updatedBy) return Result.Failure(BrokenRules.GetBrokenRulesAsString()); } - branch.Value.DeactivateInternal(); + branch.Value.DeactivateInternal(updatedBy, reason); DomainEvents.RaiseEvent(new BranchDeactivatedEvent(Props.Id.GetValue(), branch.Value.GetId().GetValue())); TrackingState.MarkAsDirty(); Props.Audit.Update(updatedBy.GetValue()); return Result.Success(); } - public Result ReactivateBranch(IdValueObject branchId, ActorId updatedBy) + public Result ReactivateBranch(IdValueObject branchId, ActorId updatedBy, string? reason = null) { var branch = FindBranch(branchId); if (branch.IsFailure) @@ -174,7 +234,8 @@ public Result ReactivateBranch(IdValueObject branchId, ActorId updatedBy) var canReactivate = branch.Value.CanReactivate(); if (canReactivate.IsFailure) { - BrokenRules.Add(new BrokenRule(nameof(Branches), DomainErrors.Common.Invalid)); + // Igual que arriba: una sucursal CERRADA no se reactiva, y ese rechazo debe verse. + BrokenRules.Add(new BrokenRule(nameof(Branches), canReactivate.Error)); } } @@ -183,16 +244,28 @@ public Result ReactivateBranch(IdValueObject branchId, ActorId updatedBy) return Result.Failure(BrokenRules.GetBrokenRulesAsString()); } - branch.Value.ReactivateInternal(); + branch.Value.ReactivateInternal(updatedBy, reason); DomainEvents.RaiseEvent(new BranchReactivatedEvent(Props.Id.GetValue(), branch.Value.GetId().GetValue())); TrackingState.MarkAsDirty(); Props.Audit.Update(updatedBy.GetValue()); return Result.Success(); } + /// + /// Vacía el búfer de asientos de bitácora ya persistidos. Lo llama el repositorio tras confirmar + /// la transacción, en paralelo a DomainEvents.MarkChangesAsCommitted(). + /// + public void MarkBranchLifecycleAsCommitted() + { + foreach (var branch in _branches) + { + branch.MarkLifecycleEntriesAsCommitted(); + } + } + public Result RegisterIdentityProvider(Code code, Name name, Description description, IdpStrategy strategy, ActorId createdBy) { - if (_identityProviders.Any(ip => ip.Code == code)) + if (_identityProviders.Any(ip => ip.Code.Equals(code))) { BrokenRules.Add(new BrokenRule(nameof(IdentityProviders), DomainErrors.Tenant.IdpCodeNotUnique)); } @@ -303,6 +376,7 @@ public Result RemoveIdentityProvider(IdValueObject identityProviderId, ActorId u return Result.Success(); } + // ── Branding por inquilino (activo propio del satélite; ausente en la plataforma de origen) ── public Result SetBranding(BrandingSettings settings, ActorId createdBy) { if (_branding is not null) @@ -585,6 +659,56 @@ public Result SetManagementOwner(bool value, ActorId updatedBy) return Result.Success(); } + // FR-042 (ADR-UMS-097 §2.2): fija/limpia la suite por defecto del inquilino, que alimenta la + // procedencia de la suite pre-autenticación cuando el AccessScope no la fija. Pasar null la limpia + // (inquilino de suite única → la resolución omite el filtro por suite, comportamiento previo). + public Result SetDefaultSystemSuite(SystemSuiteId? defaultSystemSuiteId, ActorId updatedBy) + { + SetProps(Props.WithDefaultSystemSuite(defaultSystemSuiteId)); + TrackingState.MarkAsDirty(); + Props.Audit.Update(updatedBy.GetValue()); + return Result.Success(); + } + + // FS-26 (G-024): actualiza los datos generales editables del tenant (nombre, tipo, + // referencia fiscal). El Code permanece inmutable (identificador único). + public Result UpdateGeneralData(Name name, OrganizationType type, CompanyReference? companyReference, ActorId updatedBy) + { + SetProps(Props.WithGeneralData(name, type, companyReference)); + + if (!IsValid()) + { + return Result.Failure(BrokenRules.GetBrokenRulesAsString()); + } + + DomainEvents.RaiseEvent(new TenantUpdatedEvent(Props.Id.GetValue())); + TrackingState.MarkAsDirty(); + Props.Audit.Update(updatedBy.GetValue()); + return Result.Success(); + } + + // FS-26 (G-024): actualiza los datos editables de una sucursal (nombre, geocerca). + // El Code de sucursal es único dentro del tenant y permanece inmutable. + public Result UpdateBranch(IdValueObject branchId, Name name, Value? geofencingMetadata, ActorId updatedBy) + { + var branch = FindBranch(branchId); + if (branch.IsFailure) + { + BrokenRules.Add(new BrokenRule(nameof(Branches), DomainErrors.Common.NotFound)); + } + + if (!IsValid()) + { + return Result.Failure(BrokenRules.GetBrokenRulesAsString()); + } + + branch.Value.UpdateInternal(name, geofencingMetadata); + DomainEvents.RaiseEvent(new BranchUpdatedEvent(Props.Id.GetValue(), branch.Value.GetId().GetValue())); + TrackingState.MarkAsDirty(); + Props.Audit.Update(updatedBy.GetValue()); + return Result.Success(); + } + public Result Activate(ActorId updatedBy) { if (Props.Status == TenantStatus.Archived) diff --git a/src/apps/ums.api/Ums.Domain/Identity/Tenant/TenantParameter/Events/TenantParameterDomainEventsManager.cs b/src/apps/ums.api/Ums.Domain/Identity/Tenant/TenantParameter/Events/TenantParameterDomainEventsManager.cs index fd7b52e0..bf32313e 100644 --- a/src/apps/ums.api/Ums.Domain/Identity/Tenant/TenantParameter/Events/TenantParameterDomainEventsManager.cs +++ b/src/apps/ums.api/Ums.Domain/Identity/Tenant/TenantParameter/Events/TenantParameterDomainEventsManager.cs @@ -1,3 +1,4 @@ +#pragma warning disable S1144, S1186 namespace Ums.Domain.Identity.Tenant.TenantParameter.Events; public class TenantParameterDomainEventsManager : DomainEventsManager @@ -8,4 +9,6 @@ private void Apply(TenantParameterCreatedEvent @event) { } private void Apply(TenantParameterUpdatedEvent @event) { } private void Apply(TenantParameterDeactivatedEvent @event) { } private void Apply(TenantParameterReactivatedEvent @event) { } -} \ No newline at end of file + private void Apply(TenantParameterDeletedEvent @event) { } +} +#pragma warning restore S1144, S1186 diff --git a/src/apps/ums.api/Ums.Domain/Identity/Tenant/TenantParameter/TenantParameter.cs b/src/apps/ums.api/Ums.Domain/Identity/Tenant/TenantParameter/TenantParameter.cs index 197b7c0a..0b69166b 100644 --- a/src/apps/ums.api/Ums.Domain/Identity/Tenant/TenantParameter/TenantParameter.cs +++ b/src/apps/ums.api/Ums.Domain/Identity/Tenant/TenantParameter/TenantParameter.cs @@ -25,6 +25,8 @@ private TenantParameter(TenantParameterProps props) : base(props) public TenantParameterValueType ValueType => Props.ValueType; public TenantParameterCategory Category => Props.Category; public bool IsActive => Props.IsActive; + /// Verdadero cuando el parámetro está lógicamente eliminado; las lecturas deben ocultarlo. + public bool IsDeleted => Props.IsDeleted; public bool IsSensitive => Props.IsSensitive; public string? DefaultValue => Props.DefaultValue; public string? AllowedValues => Props.AllowedValues; @@ -109,6 +111,13 @@ public Result UpdateValue(string newValue, ActorId updatedBy) public Result Deactivate(ActorId updatedBy) { + if (IsDeleted) + { + // Un parámetro eliminado ya no admite transiciones: su ciclo de vida terminó. + BrokenRules.Add(new BrokenRule(nameof(IsDeleted), DomainErrors.TenantParameter.AlreadyDeleted)); + return Result.Failure(BrokenRules.GetBrokenRulesAsString()); + } + SetProps(Props.WithIsActive(false)); Props.Audit.Update(updatedBy.GetValue()); TrackingState.MarkAsDirty(); @@ -118,6 +127,15 @@ public Result Deactivate(ActorId updatedBy) public Result Reactivate(ActorId updatedBy) { + if (IsDeleted) + { + // El borrado lógico es TERMINAL: reactivar no puede ser una puerta trasera para resucitar + // un parámetro eliminado. Si el inquilino vuelve a necesitar ese código, se da de alta uno + // nuevo y el eliminado queda como histórico consultable. + BrokenRules.Add(new BrokenRule(nameof(IsDeleted), DomainErrors.TenantParameter.AlreadyDeleted)); + return Result.Failure(BrokenRules.GetBrokenRulesAsString()); + } + SetProps(Props.WithIsActive(true)); Props.Audit.Update(updatedBy.GetValue()); TrackingState.MarkAsDirty(); @@ -125,6 +143,39 @@ public Result Reactivate(ActorId updatedBy) return Result.Success(); } + /// + /// Borrado LÓGICO: marca el parámetro como eliminado sin quitar la fila de la base. Solo existe + /// borrado lógico porque el negocio consulta la configuración histórica de un inquilino (qué valor + /// regía en una fecha dada) y un DELETE real la perdería para siempre. + /// + /// Regla de cascada: un parámetro ACTIVO es una referencia VIVA —la configuración del inquilino lo + /// resuelve ahora mismo por su código a través de ITenantParameterProvider— y no se puede + /// eliminar mientras lo sea. Hay que desactivarlo antes con : esa + /// desactivación ES la eliminación lógica del vínculo, y una vez hecha el borrado sí procede. + /// + public Result Delete(ActorId deletedBy) + { + if (IsDeleted) + { + BrokenRules.Add(new BrokenRule(nameof(IsDeleted), DomainErrors.TenantParameter.AlreadyDeleted)); + } + else if (IsActive) + { + BrokenRules.Add(new BrokenRule(nameof(IsActive), DomainErrors.TenantParameter.HasActiveBinding)); + } + + if (!IsValid()) + { + return Result.Failure(BrokenRules.GetBrokenRulesAsString()); + } + + SetProps(Props.WithIsDeleted(true)); + Props.Audit.Update(deletedBy.GetValue()); + TrackingState.MarkAsDirty(); + DomainEvents.RaiseEvent(new TenantParameterDeletedEvent(Props.TenantId.GetValue(), GetId().GetValue(), Props.Code.GetValue())); + return Result.Success(); + } + public string GetTypedValue() { return Props.Value; diff --git a/src/apps/ums.api/Ums.Domain/Identity/Tenant/TenantParameter/TenantParameterProps.cs b/src/apps/ums.api/Ums.Domain/Identity/Tenant/TenantParameter/TenantParameterProps.cs index 52a5d751..eb274ce4 100644 --- a/src/apps/ums.api/Ums.Domain/Identity/Tenant/TenantParameter/TenantParameterProps.cs +++ b/src/apps/ums.api/Ums.Domain/Identity/Tenant/TenantParameter/TenantParameterProps.cs @@ -18,7 +18,8 @@ public TenantParameterProps( bool isSensitive, string? defaultValue, string? allowedValues, - AuditValueObject audit) + AuditValueObject audit, + bool isDeleted = false) { Id = id; TenantId = tenantId; @@ -32,6 +33,7 @@ public TenantParameterProps( DefaultValue = defaultValue; AllowedValues = allowedValues; Audit = audit; + IsDeleted = isDeleted; } public IdValueObject Id { get; private set; } @@ -47,6 +49,14 @@ public TenantParameterProps( public string? AllowedValues { get; private set; } public AuditValueObject Audit { get; private set; } + /// + /// Marca de borrado LÓGICO. Es un eje INDEPENDIENTE de : «inactivo» es un + /// estado vivo y reversible —Reactivate lo devuelve al servicio— mientras que «eliminado» + /// es terminal y desaparece de toda lectura. Reutilizar IsActive para el borrado permitiría + /// resucitar un parámetro eliminado con una simple reactivación. + /// + public bool IsDeleted { get; private set; } + public TenantParameterProps WithValue(string value) { var clone = (TenantParameterProps)MemberwiseClone(); @@ -61,6 +71,13 @@ public TenantParameterProps WithIsActive(bool isActive) return clone; } + public TenantParameterProps WithIsDeleted(bool isDeleted) + { + var clone = (TenantParameterProps)MemberwiseClone(); + clone.IsDeleted = isDeleted; + return clone; + } + public object Clone() { return MemberwiseClone(); diff --git a/src/apps/ums.api/Ums.Domain/Identity/Tenant/TenantProps.cs b/src/apps/ums.api/Ums.Domain/Identity/Tenant/TenantProps.cs index 0ca0ebe5..441a5eb2 100644 --- a/src/apps/ums.api/Ums.Domain/Identity/Tenant/TenantProps.cs +++ b/src/apps/ums.api/Ums.Domain/Identity/Tenant/TenantProps.cs @@ -11,6 +11,11 @@ public class TenantProps : IProps public TenantId? ParentTenantId { get; private set; } public bool IsManagementOwner { get; private set; } public TenantStatus Status { get; private set; } + + // FR-042 (ADR-UMS-097 §2.2): suite por defecto del inquilino. Es la procedencia de la suite + // pre-autenticación cuando el AccessScope no la fija. Nullable/retrocompatible: un inquilino de + // suite única la deja en null y la resolución omite el filtro por suite (comportamiento previo). + public SystemSuiteId? DefaultSystemSuiteId { get; private set; } public AuditValueObject Audit { get; private set; } public TenantProps( @@ -45,6 +50,7 @@ public TenantProps( CompanyReference? companyReference, TenantId? parentTenantId, bool isManagementOwner, + SystemSuiteId? defaultSystemSuiteId, TenantStatus status, AuditValueObject audit) { @@ -56,6 +62,7 @@ public TenantProps( CompanyReference = companyReference; ParentTenantId = parentTenantId; IsManagementOwner = isManagementOwner; + DefaultSystemSuiteId = defaultSystemSuiteId; Status = status; Audit = audit; } @@ -67,6 +74,17 @@ public TenantProps WithManagementOwner(bool isManagementOwner) return clone; } + // FS-26 (G-024): actualización de los datos generales editables del tenant. + // El Code es el identificador único y permanece inmutable. + public TenantProps WithGeneralData(Name name, OrganizationType type, CompanyReference? companyReference) + { + var clone = (TenantProps)MemberwiseClone(); + clone.Name = name; + clone.Type = type; + clone.CompanyReference = companyReference; + return clone; + } + public TenantProps WithStatus(TenantStatus status) { var clone = (TenantProps)MemberwiseClone(); @@ -74,6 +92,14 @@ public TenantProps WithStatus(TenantStatus status) return clone; } + // FR-042 (ADR-UMS-097 §2.2): fija/limpia la suite por defecto del inquilino. + public TenantProps WithDefaultSystemSuite(SystemSuiteId? defaultSystemSuiteId) + { + var clone = (TenantProps)MemberwiseClone(); + clone.DefaultSystemSuiteId = defaultSystemSuiteId; + return clone; + } + public object Clone() { return this.MemberwiseClone(); diff --git a/src/apps/ums.api/Ums.Domain/Identity/UserAccount/Events/UserAccountDomainEventsManager.cs b/src/apps/ums.api/Ums.Domain/Identity/UserAccount/Events/UserAccountDomainEventsManager.cs index b781d86a..170a50fb 100644 --- a/src/apps/ums.api/Ums.Domain/Identity/UserAccount/Events/UserAccountDomainEventsManager.cs +++ b/src/apps/ums.api/Ums.Domain/Identity/UserAccount/Events/UserAccountDomainEventsManager.cs @@ -1,3 +1,4 @@ +#pragma warning disable S1144, S1186 namespace Ums.Domain.Identity.UserAccount; public class UserAccountDomainEventsManager : DomainEventsManager @@ -16,3 +17,5 @@ private void Apply(MfaEnrollmentRevokedEvent @event) { } private void Apply(ValidityPeriodModifiedEvent @event) { } private void Apply(AuthenticationAttemptedEvent @event) { } } + +#pragma warning restore S1144, S1186 diff --git a/src/apps/ums.api/Ums.Domain/Identity/UserAccount/UserAccount.cs b/src/apps/ums.api/Ums.Domain/Identity/UserAccount/UserAccount.cs index 63d771da..5ee2f2bd 100644 --- a/src/apps/ums.api/Ums.Domain/Identity/UserAccount/UserAccount.cs +++ b/src/apps/ums.api/Ums.Domain/Identity/UserAccount/UserAccount.cs @@ -39,6 +39,10 @@ private UserAccount(UserAccountProps props) : base(props) public IdentityReferenceType? IdentityReferenceType => Props.IdentityReferenceType; public DateTimeOffset? ExpiresAt => Props.ExpiresAt; + // ADR-UMS-095: estado del bloqueo temporal por intentos fallidos. + public int FailedLoginAttempts => Props.FailedLoginAttempts; + public DateTimeOffset? LockedUntil => Props.LockedUntil; + public IReadOnlyCollection MfaEnrollments => _mfaEnrollments.AsReadOnly(); public IReadOnlyCollection PasswordCredentials => _passwordCredentials.AsReadOnly(); @@ -415,8 +419,51 @@ public bool HasVerifiedMfaEnrollment(IEnumerable? allowedMethods = nu return verifiedEnrollments.Any(enrollment => allowedMethodNames.Contains(enrollment.Method.Name)); } - public Result RecordAuthenticationAttempt(bool success, string reason, string ipAddress, ActorId actor) + /// + /// ADR-UMS-095: registra un intento de autenticación y aplica el bloqueo temporal por + /// intentos fallidos. El dominio es determinista: recibe el instante actual () + /// y los parámetros de política (, ) + /// desde la aplicación; no lee reloj ni configuración por su cuenta. + /// + /// + /// - success == false: incrementa ; al alcanzar + /// fija = + + /// . No incrementa si ya está bloqueado (). + /// - success == true: resetea contador y bloqueo. + /// El bloqueo temporal es independiente del bloqueo administrativo permanente ( / + /// ), que prevalece porque el flujo de autenticación rechaza antes + /// a las cuentas no activas. + /// + public Result RecordAuthenticationAttempt( + bool success, + DateTimeOffset now, + int maxAttempts, + int lockoutMinutes, + string reason, + string ipAddress, + ActorId actor) { + if (success) + { + if (Props.FailedLoginAttempts != 0 || Props.LockedUntil is not null) + { + SetProps(Props.WithLockoutState(0, null)); + TrackingState.MarkAsDirty(); + Props.Audit.Update(actor.GetValue()); + } + } + else if (!IsLockedOut(now)) + { + var attempts = Props.FailedLoginAttempts + 1; + var lockedUntil = maxAttempts > 0 && attempts >= maxAttempts + ? (DateTimeOffset?)now.AddMinutes(lockoutMinutes) + : Props.LockedUntil; + + SetProps(Props.WithLockoutState(attempts, lockedUntil)); + TrackingState.MarkAsDirty(); + Props.Audit.Update(actor.GetValue()); + } + DomainEvents.RaiseEvent(new AuthenticationAttemptedEvent( Props.Id.GetValue(), Props.TenantId.GetValue(), @@ -426,9 +473,18 @@ public Result RecordAuthenticationAttempt(bool success, string reason, string ip return Result.Success(); } + /// + /// ADR-UMS-095: consulta pura — indica si la cuenta está bajo bloqueo temporal en el instante dado. + /// + public bool IsLockedOut(DateTimeOffset now) + => Props.LockedUntil is not null && Props.LockedUntil > now; + private Result FindPasswordCredential(IdValueObject credentialId) { - var credential = _passwordCredentials.FirstOrDefault(c => c.Id.GetValue() == credentialId.GetValue()); + // AT06/F1 (misma clase de bug): identidad canónica = Props.Id; el Id base de Entity<> se + // regenera aleatorio en cada construcción y la rehidratación no llama SetId → buscar por c.Id + // fallaba tras recargar (activate/remove de credencial por id → 404). Cf. FindMfaEnrollment. + var credential = _passwordCredentials.FirstOrDefault(c => c.Props.Id.GetValue() == credentialId.GetValue()); return credential is null ? Result.Failure(DomainErrors.Common.NotFound) : Result.Success(credential); @@ -436,7 +492,7 @@ private Result FindPasswordCredential(IdValueObject cr private Result FindMfaEnrollment(IdValueObject enrollmentId) { - var enrollment = _mfaEnrollments.FirstOrDefault(e => e.Id.GetValue() == enrollmentId.GetValue()); + var enrollment = _mfaEnrollments.FirstOrDefault(e => e.GetId().GetValue() == enrollmentId.GetValue()); return enrollment is null ? Result.Failure(DomainErrors.Common.NotFound) : Result.Success(enrollment); diff --git a/src/apps/ums.api/Ums.Domain/Identity/UserAccount/UserAccountProps.cs b/src/apps/ums.api/Ums.Domain/Identity/UserAccount/UserAccountProps.cs index 05b89c09..73048ab9 100644 --- a/src/apps/ums.api/Ums.Domain/Identity/UserAccount/UserAccountProps.cs +++ b/src/apps/ums.api/Ums.Domain/Identity/UserAccount/UserAccountProps.cs @@ -12,6 +12,13 @@ public class UserAccountProps : IProps public IdentityReference? IdentityReference { get; private set; } public IdentityReferenceType? IdentityReferenceType { get; private set; } public DateTimeOffset? ExpiresAt { get; private set; } + + // ADR-UMS-095: bloqueo temporal de cuenta por intentos fallidos de autenticación. + // Distinto del bloqueo administrativo permanente (Status == Blocked): este es + // auto-expirable vía LockedUntil y no requiere intervención de un administrador. + public int FailedLoginAttempts { get; private set; } + public DateTimeOffset? LockedUntil { get; private set; } + public AuditValueObject Audit { get; private set; } public UserAccountProps( @@ -34,6 +41,8 @@ public UserAccountProps( Status = UserStatus.Pending; IdentityReference = identityReference; IdentityReferenceType = identityReferenceType; + FailedLoginAttempts = 0; + LockedUntil = null; Audit = AuditValueObject.Create(createdBy.GetValue()); } @@ -48,7 +57,9 @@ public UserAccountProps( IdentityReferenceType? identityReferenceType, AuditValueObject audit, Name? displayName = null, - DateTimeOffset? expiresAt = null) + DateTimeOffset? expiresAt = null, + int failedLoginAttempts = 0, + DateTimeOffset? lockedUntil = null) { Id = id; TenantId = tenantId; @@ -60,6 +71,8 @@ public UserAccountProps( IdentityReference = identityReference; IdentityReferenceType = identityReferenceType; ExpiresAt = expiresAt; + FailedLoginAttempts = failedLoginAttempts; + LockedUntil = lockedUntil; Audit = audit; } @@ -77,5 +90,14 @@ public UserAccountProps WithExpiresAt(DateTimeOffset expiresAt) return clone; } + // ADR-UMS-095: muta el estado de bloqueo temporal de forma atómica (contador + vencimiento). + public UserAccountProps WithLockoutState(int failedLoginAttempts, DateTimeOffset? lockedUntil) + { + var clone = (UserAccountProps)MemberwiseClone(); + clone.FailedLoginAttempts = failedLoginAttempts; + clone.LockedUntil = lockedUntil; + return clone; + } + public object Clone() => MemberwiseClone(); } diff --git a/src/apps/ums.api/Ums.Domain/Identity/UserManagementDelegation/Events/UserManagementDelegationEventsManager.cs b/src/apps/ums.api/Ums.Domain/Identity/UserManagementDelegation/Events/UserManagementDelegationEventsManager.cs index ded30b1a..d634e6f7 100644 --- a/src/apps/ums.api/Ums.Domain/Identity/UserManagementDelegation/Events/UserManagementDelegationEventsManager.cs +++ b/src/apps/ums.api/Ums.Domain/Identity/UserManagementDelegation/Events/UserManagementDelegationEventsManager.cs @@ -1,3 +1,4 @@ +#pragma warning disable S1144, S1186 namespace Ums.Domain.Identity.UserManagementDelegation; using Ums.Domain.Events; @@ -13,3 +14,5 @@ private void Apply(DelegationExpiredEvent @event) { } private void Apply(DelegationRejectedEvent @event) { } private void Apply(DelegationArchivedEvent @event) { } } + +#pragma warning restore S1144, S1186 diff --git a/src/apps/ums.api/Ums.Domain/Identity/UserManagementDelegation/UserManagementDelegation.cs b/src/apps/ums.api/Ums.Domain/Identity/UserManagementDelegation/UserManagementDelegation.cs index 664da8c6..35dbab3c 100644 --- a/src/apps/ums.api/Ums.Domain/Identity/UserManagementDelegation/UserManagementDelegation.cs +++ b/src/apps/ums.api/Ums.Domain/Identity/UserManagementDelegation/UserManagementDelegation.cs @@ -97,6 +97,13 @@ public static Result Create( public Result Activate(ActorId actorId) { + // FAIL-CLOSED: si la delegación requiere aprobación, la única vía legítima a Active es + // SubmitForApproval → Approve. La activación directa queda vedada. + if (Props.RequiresApproval) + { + BrokenRules.Add(new BrokenRule(nameof(Status), DomainErrors.Delegation.ApprovalRequired)); + } + // INV-DEL7: REVOKED/EXPIRED → cannot re-activate if (Props.Status == DelegationStatus.Revoked || Props.Status == DelegationStatus.Expired) { @@ -149,6 +156,16 @@ public Result Approve(ActorId actorId) BrokenRules.Add(new BrokenRule(nameof(Status), DomainErrors.Delegation.CannotActivateFromCurrentStatus)); } + // INV-DEL8 (SoD / separación de funciones): el aprobador no puede ser el administrador + // delegante (quien solicita/crea la delegación). Auto-aprobar una delegación que otorga + // autoridad de administrador es escalada de privilegios. Invariante de dominio, hecha + // cumplir aquí (además de la guarda de autorización de la capa de aplicación). + if (Guid.TryParse(actorId.GetValue(), out var approverId) && + approverId == Props.DelegatingAdminId.GetValue()) + { + BrokenRules.Add(new BrokenRule(nameof(DelegatingAdminId), DomainErrors.Delegation.SelfApprovalNotAllowed)); + } + if (!IsValid()) { return Result.Failure(BrokenRules.GetBrokenRulesAsString()); diff --git a/src/apps/ums.api/Ums.Domain/Kernel/DomainErrors.cs b/src/apps/ums.api/Ums.Domain/Kernel/DomainErrors.cs index 4237c1bb..773d83e4 100644 --- a/src/apps/ums.api/Ums.Domain/Kernel/DomainErrors.cs +++ b/src/apps/ums.api/Ums.Domain/Kernel/DomainErrors.cs @@ -20,6 +20,11 @@ public static class Tenant public const string BranchCodeNotUnique = "tenant.branch_code_not_unique"; public const string BranchNotFound = "tenant.branch_not_found"; public const string BranchActive = "tenant.branch_active"; + // ADR-0164: cierre definitivo de sucursal (verbo terminal del ciclo de vida). + /// La sucursal ya está cerrada: reintentar el cierre choca con su estado actual. + public const string BranchAlreadyClosed = "tenant.branch_already_closed"; + /// La sucursal está cerrada y por eso no admite desactivarse ni reactivarse. + public const string BranchClosed = "tenant.branch_closed"; public const string ArchivedCannotSuspend = "tenant.archived_cannot_suspend"; public const string ArchivedCannotActivate = "tenant.archived_cannot_activate"; public const string AlreadyActive = "tenant.already_active"; @@ -28,16 +33,32 @@ public static class Tenant // ── Dependency guard errors ────────────────────────────────────────── public const string HasActiveUsers = "TENANT_HAS_ACTIVE_USERS"; public const string HasActiveBranches = "TENANT_HAS_ACTIVE_BRANCHES"; + public const string BranchHasActiveUsers = "BRANCH_HAS_ACTIVE_USERS"; + /// + /// ADR-0164 §2.2: no se cierra una sucursal con referencias VIVAS. Es un código único aunque + /// las referencias sean de dos clases (cuentas y perfiles) porque el desglose de QUÉ bloquea + /// viaja aparte, en las BlockingDependency: así la respuesta nombra las dos a la vez + /// en lugar de obligar a quien opera a descubrirlas de una en una. + /// + public const string BranchHasLiveReferences = "BRANCH_HAS_LIVE_REFERENCES"; public const string HasActiveIdpConfig = "TENANT_HAS_ACTIVE_IDP"; public const string IdpCodeNotUnique = "tenant.idp_code_not_unique"; public const string IdpNotFound = "tenant.idp_not_found"; public const string IdpAlreadyActive = "tenant.idp_already_active"; public const string IdpAlreadyInactive = "tenant.idp_already_inactive"; public const string NoActiveIdp = "tenant.no_active_idp"; + // G-037/G-045: la propiedad de gestión (management owner) es única en todo el sistema. + // Otorgarla a un segundo inquilino debe resolver 409 con este código estable y su + // regla de negocio, no una violación de índice único que colapsa a 500. + public const string ManagementOwnerAlreadyExists = "TENANT_OWNER_ALREADY_EXISTS"; + // Branding por inquilino: activo propio del satélite. public const string BrandingAlreadyExists = "tenant.branding_already_exists"; public const string BrandingNotFound = "tenant.branding_not_found"; } + /// + /// Branding por inquilino: activo propio del satélite (no existe en la plataforma de origen). + /// public static class Branding { public const string InvalidHexColor = "branding.invalid_hex_color"; @@ -92,6 +113,7 @@ public static class SystemSuite public const string RoleAlreadyInactive = "system_suite.role_already_inactive"; public const string ActionAlreadyGranted = "system_suite.action_already_granted"; public const string ActionNotGranted = "system_suite.action_not_granted"; + public const string ActionNotRegistered = "system_suite.action_not_registered"; public const string ConfigurationKeyAlreadyExists = "system_suite.configuration_key_already_exists"; public const string ConfigurationKeyNotFound = "system_suite.configuration_key_not_found"; public const string ActionRequiresOwner = "system_suite.action_requires_owner"; @@ -106,10 +128,37 @@ public static class Authorization public const string TemplateAlreadyPublished = "authorization.template_already_published"; public const string TemplateAlreadyDeprecated = "authorization.template_already_deprecated"; public const string TemplateNotDeletable = "authorization.template_not_deletable"; + /// La plantilla ya está en el estado terminal Deleted: el borrado lógico no es repetible. + public const string TemplateAlreadyDeleted = "authorization.template_already_deleted"; public const string TemplateItemTargetAlreadyExists = "authorization.template_item_target_already_exists"; + /// + /// Ya existe un ítem RETIRADO para esa terna (destino, tipo de destino, acción) en la plantilla. + /// + /// Es un conflicto distinto de a propósito: + /// la clave natural del ítem no se libera al retirarlo (ADR-0164 §2.3), así que el alta no puede + /// proceder; pero el operador no está ante un duplicado sino ante una concesión suya que + /// desactivó, y el verbo que la devuelve es reactivar, no volver a añadir. Un único mensaje para + /// los dos casos le diría «ya existe» sobre algo que no ve en la lista de vigentes. + /// + public const string TemplateItemTargetRetired = "authorization.template_item_target_retired"; public const string InvalidPermissionEffect = "authorization.invalid_permission_effect"; + /// + /// Ya existe un perfil ACTIVO del mismo usuario, rol y sucursal en el inquilino. + /// + /// No existía, y nada lo impedía: cada llamada a `POST /profiles` con los mismos + /// datos creaba otro perfil. Un aprovisionamiento reejecutado dejaba al usuario con dos + /// perfiles del mismo rol, uno con permisos y otro vacío —porque la plantilla solo se + /// asigna a uno—, y el selector de perfil le ofrecía ambos. Elegir el vacío es entrar a un + /// sistema sin una sola concesión (G-215). + /// + /// Se restaura tras perderse en la resolución de conflictos del merge de la firma + /// RS256 (PR #192): el fichero volvió a su versión anterior, pero el manejador que la usa + /// no, así que `develop` dejó de compilar. + /// + public const string ProfileAlreadyExistsForRole = "authorization.profile_already_exists_for_role"; public const string ProfileAlreadyActive = "authorization.profile_already_active"; public const string ProfileAlreadyInactive = "authorization.profile_already_inactive"; + public const string ProfileRoleUnchanged = "authorization.profile_role_unchanged"; // ADR-UMS-096: ChangeRole exige rol distinto public const string PermissionAlreadyExists = "authorization.permission_already_exists"; public const string PermissionNotFound = "authorization.permission_not_found"; public const string TemplateNotPublishedForProfile = "authorization.template_not_published_for_profile"; @@ -133,6 +182,20 @@ public static class Authorization public const string TemplateHasActiveProfiles = "TEMPLATE_HAS_ACTIVE_PROFILES"; public const string DomainResourceHasTemplateItems = "DOMAIN_RESOURCE_HAS_TEMPLATE_ITEMS"; public const string ModuleHasActiveMenus = "MODULE_HAS_ACTIVE_MENUS"; + // G-246: eliminación LÓGICA de un sistema. El borrado físico no existe: se hacen consultas + // sobre datos antiguos y una fila borrada de verdad no se recupera. + // · NotDeprecated → hay que archivar antes de eliminar; un sistema en servicio no se va + // por un DELETE suelto. + // · HasDependents → regla de cascada: algo VIVO todavía apunta al sistema. La respuesta + // 409 enumera qué, para que el llamador sepa qué eliminar primero. + // · AlreadyDeleted → ya está eliminado lógicamente; el estado es terminal e idempotente. + public const string SystemSuiteNotDeprecated = "SYSTEM_SUITE_NOT_DEPRECATED"; + public const string SystemSuiteHasDependents = "SYSTEM_SUITE_HAS_DEPENDENTS"; + public const string SystemSuiteAlreadyDeleted = "SYSTEM_SUITE_ALREADY_DELETED"; + // Dos puertas que hay que cerrar para que la guarda de cascada no se pueda esquivar por + // `PUT /system-suites/{id}/status`: ni se entra a «eliminado» por ahí, ni se sale de él. + public const string SystemSuiteDeletedNotSettable = "SYSTEM_SUITE_DELETED_NOT_SETTABLE"; + public const string SystemSuiteDeletedIsTerminal = "SYSTEM_SUITE_DELETED_IS_TERMINAL"; public const string TemplateItemsRequired = "authorization.template_items_required"; public const string AssignmentRulePriorityMustBePositive = "authorization.assignment_rule_priority_must_be_positive"; public const string AssignmentRuleAlreadyActive = "authorization.assignment_rule_already_active"; @@ -147,11 +210,15 @@ public static class Approvals public const string DocumentAlreadyExpired = "approvals.document_already_expired"; public const string PolicyRequiresProfileOrRole = "approvals.policy_requires_profile_or_role"; public const string PolicyAlreadyInactive = "approvals.policy_already_inactive"; + public const string PolicyInactiveCannotUpdate = "approvals.policy_inactive_cannot_update"; public const string RuleAlreadyInactive = "approvals.rule_already_inactive"; public const string RuleAlreadyActive = "approvals.rule_already_active"; public const string DuplicateNotificationRule = "approvals.duplicate_notification_rule"; public const string RequiresDocumentsIfApprovalRequired = "approvals.requires_documents_if_approval_required"; public const string WorkflowNotAllowedForUserCategory = "approvals.workflow_not_allowed_for_user_category"; + public const string RequiredDocumentsIncomplete = "approvals.required_documents_incomplete"; + public const string SelfApprovalNotAllowed = "approvals.self_approval_not_allowed"; + public const string GracePeriodInvalid = "approvals.grace_period_invalid"; } public static class Configuration @@ -163,9 +230,11 @@ public static class Configuration public const string IdpConfigAlreadyArchived = "configuration.idp_config_already_archived"; public const string IdpConfigArchivedCannotChange = "configuration.idp_config_archived_cannot_change"; public const string IdpConfigPayloadInvalid = "configuration.idp_config_payload_invalid"; + public const string IdpFallbackNotFound = "configuration.idp_fallback_not_found"; public const string AppConfigNotDraft = "configuration.app_config_not_draft"; public const string AppConfigNotPublished = "configuration.app_config_not_published"; public const string AppConfigAlreadyArchived = "configuration.app_config_already_archived"; + public const string AppConfigAlreadyDeleted = "configuration.app_config_already_deleted"; public const string AppConfigNonOverridable = "configuration.app_config_non_overridable"; public const string FlagArchivedCannotChange = "configuration.flag_archived_cannot_change"; public const string FlagAlreadyActive = "configuration.flag_already_active"; @@ -179,6 +248,7 @@ public static class Configuration public const string CriteriaValueRequired = "configuration.criteria_value_required"; public const string ParameterCodeNotUnique = "configuration.parameter_code_not_unique"; public const string ParameterHasActiveValues = "configuration.parameter_has_active_values"; + public const string ParameterAlreadyDeleted = "configuration.parameter_already_deleted"; public const string ParameterValueInvalidType = "configuration.parameter_value_invalid_type"; public const string ParameterOverrideNotAllowed = "configuration.parameter_override_not_allowed"; public const string ParameterGlobalValueInUse = "configuration.parameter_global_value_in_use"; @@ -208,6 +278,8 @@ public static class Delegation public const string RevocationReasonRequired = "delegation.revocation_reason_required"; public const string CannotArchiveFromCurrentStatus = "delegation.cannot_archive_from_current_status"; public const string NotActive = "delegation.not_active"; + public const string ApprovalRequired = "delegation.approval_required"; + public const string SelfApprovalNotAllowed = "delegation.self_approval_not_allowed"; } public static class TenantParameter @@ -218,6 +290,18 @@ public static class TenantParameter public const string ValueNotInAllowedList = "tenant_parameter.value_not_in_allowed_list"; public const string CannotDeactivateActive = "tenant_parameter.cannot_deactivate_active"; public const string CannotDeleteWithChildren = "tenant_parameter.cannot_delete_with_children"; + /// El parámetro ya está en el estado terminal de borrado lógico. + public const string AlreadyDeleted = "tenant_parameter.already_deleted"; + // ── Dependency guard errors ────────────────────────────────────────── + /// + /// Guardia de cascada del borrado lógico: un parámetro ACTIVO es una referencia VIVA de la + /// configuración del inquilino —ITenantParameterProvider lo resuelve ahora mismo por su + /// código y ocupa el índice único parcial IX_TenantParameters_TenantId_Code_IsActive—. Primero + /// hay que desactivarlo, que es la eliminación lógica de ese vínculo. Se nombra en MAYÚSCULAS + /// porque es un código de operación bloqueada (409 + BlockedOperationResponse), no un error de + /// validación de dominio. + /// + public const string HasActiveBinding = "TENANT_PARAMETER_HAS_ACTIVE_BINDING"; } public static class ValueObject @@ -226,4 +310,35 @@ public static class ValueObject public const string PropertyRequired = "value_object.property_required"; public const string DateRangeInvalid = "value_object.date_range_invalid"; } + + /// + /// Errores del contexto acotado IGA (Identity Governance & Administration), ADR-UMS-093. + /// Cubren los invariantes del agregado de elegibilidad (RoleMaturityStatus, INV-RMS1..3) + /// y del agregado de promoción (RolePromotionRequest, INV-RPR1..5). + /// + public static class IGA + { + // ── RoleMaturityStatus (INV-RMS1..3) ───────────────────────────────── + public const string InvalidPerformanceScore = "iga.invalid_performance_score"; // INV-RMS1 + public const string MaturityLevelUnchanged = "iga.maturity_level_unchanged"; // INV-RMS2 + public const string MaturityLevelAlreadyMax = "iga.maturity_level_already_max"; // INV-RMS3 (Principal no promocionable) + public const string ComplianceIssuesBlockPromotion = "iga.compliance_issues_block_promotion"; // INV-RMS3 + public const string InsufficientPerformanceScore = "iga.insufficient_performance_score"; // INV-RMS3 + public const string InsufficientTimeInLevel = "iga.insufficient_time_in_level"; // INV-RMS3 + public const string BlockingFactorRequired = "iga.blocking_factor_required"; + + // ── RolePromotionRequest (INV-RPR1..5) ─────────────────────────────── + public const string InvalidStateTransition = "iga.invalid_state_transition"; // INV-RPR1 / INV-RPR5 + public const string RiskScoreOutOfRange = "iga.risk_score_out_of_range"; // VO RiskScore + public const string RiskScoreAlreadyFrozen = "iga.risk_score_already_frozen"; // INV-RPR2 + public const string SegregationOfDutiesViolation = "iga.segregation_of_duties_violation"; // INV-RPR3 + public const string NotEligibleForPromotion = "iga.not_eligible_for_promotion"; // INV-RPR4 (fail-closed) + public const string SelfPromotionNotAllowed = "iga.self_promotion_not_allowed"; // INV-RPR3 (solicitante ≠ objetivo) + public const string DecisionReasonRequired = "iga.decision_reason_required"; + public const string SameRolePromotion = "iga.same_role_promotion"; + // G-100: código estable e idioma-agnóstico para «solicitud de promoción no hallada por id». + // Reemplaza el mensaje en español que los handlers devolvían crudo y que el mapeador HTTP, + // al buscar el substring en inglés «not found», clasificaba erróneamente como 400 en vez de 404. + public const string RolePromotionRequestNotFound = "iga.role_promotion_request_not_found"; + } } diff --git a/src/apps/ums.api/Ums.Domain/Kernel/ValueObjects/EmailAddress.cs b/src/apps/ums.api/Ums.Domain/Kernel/ValueObjects/EmailAddress.cs index f199e8c4..4aa61183 100644 --- a/src/apps/ums.api/Ums.Domain/Kernel/ValueObjects/EmailAddress.cs +++ b/src/apps/ums.api/Ums.Domain/Kernel/ValueObjects/EmailAddress.cs @@ -11,7 +11,7 @@ public static Result Create(string value) var email = new EmailAddress(value.Trim().ToLowerInvariant()); if (!email.IsValid) { - return Result.Failure(email.BrokenRules.GetBrokenRules().First().Message); + return Result.Failure(email.BrokenRules.GetBrokenRules()[0].Message); } return Result.Success(email); } diff --git a/src/apps/ums.api/Ums.Domain/Kernel/ValueObjects/EmailAddressValidator.cs b/src/apps/ums.api/Ums.Domain/Kernel/ValueObjects/EmailAddressValidator.cs index d82d84d8..99518458 100644 --- a/src/apps/ums.api/Ums.Domain/Kernel/ValueObjects/EmailAddressValidator.cs +++ b/src/apps/ums.api/Ums.Domain/Kernel/ValueObjects/EmailAddressValidator.cs @@ -6,7 +6,8 @@ public class EmailAddressValidator : AbstractRuleValidator> { private static readonly Regex EmailRegex = new( @"^[^@\s]+@[^@\s]+\.[^@\s]+$", - RegexOptions.Compiled | RegexOptions.IgnoreCase); + RegexOptions.Compiled | RegexOptions.IgnoreCase, + TimeSpan.FromSeconds(1)); public EmailAddressValidator(ValueObject subject) : base(subject) { } diff --git a/src/apps/ums.api/Ums.Domain/Kernel/ValueObjects/RiskScore.cs b/src/apps/ums.api/Ums.Domain/Kernel/ValueObjects/RiskScore.cs new file mode 100644 index 00000000..b3534afb --- /dev/null +++ b/src/apps/ums.api/Ums.Domain/Kernel/ValueObjects/RiskScore.cs @@ -0,0 +1,35 @@ +namespace Ums.Domain.Kernel.ValueObjects; + +using BeyondNetCode.Shell.Ddd.ValueObjects.Common; + +/// +/// Objeto de valor inmutable que representa el RiskScore de impacto tóxico de una +/// promoción de rol (FR-061, ADR-UMS-093). Su rango válido es [0, 100]. Una vez congelado +/// dentro de RolePromotionRequest al salir de Draft, no puede recalcularse: +/// recalcular exige una nueva solicitud (inmutabilidad por solicitud, INV-RPR2). +/// +public sealed class RiskScore : IntValueObject +{ + public const int Min = 0; + public const int Max = 100; + + private RiskScore(int value) : base(value) { } + + /// + /// Crea un validando el rango [0, 100]. Fuera de rango + /// devuelve Result.Failure con + /// (nunca excepción). + /// + public static Result Create(int value) + { + if (value < Min || value > Max) + { + return Result.Failure(DomainErrors.IGA.RiskScoreOutOfRange); + } + + return Result.Success(new RiskScore(value)); + } + + /// Rehidrata un valor ya persistido (se asume dentro de rango). + public static RiskScore Load(int value) => new RiskScore(value); +} diff --git a/src/apps/ums.api/Ums.Domain/Kernel/ValueObjects/RoleMaturityStatusId.cs b/src/apps/ums.api/Ums.Domain/Kernel/ValueObjects/RoleMaturityStatusId.cs new file mode 100644 index 00000000..78a8315a --- /dev/null +++ b/src/apps/ums.api/Ums.Domain/Kernel/ValueObjects/RoleMaturityStatusId.cs @@ -0,0 +1,9 @@ +namespace Ums.Domain.Kernel.ValueObjects; + +public class RoleMaturityStatusId : IdValueObject +{ + private RoleMaturityStatusId(Guid value) : base(value) { } + public static new RoleMaturityStatusId Create() => new RoleMaturityStatusId(Guid.NewGuid()); + public static new RoleMaturityStatusId Load(Guid value) => new RoleMaturityStatusId(value); + public static new RoleMaturityStatusId Load(string value) => new RoleMaturityStatusId(Guid.Parse(value)); +} diff --git a/src/apps/ums.api/Ums.Domain/Kernel/ValueObjects/RolePromotionRequestId.cs b/src/apps/ums.api/Ums.Domain/Kernel/ValueObjects/RolePromotionRequestId.cs new file mode 100644 index 00000000..9595eabd --- /dev/null +++ b/src/apps/ums.api/Ums.Domain/Kernel/ValueObjects/RolePromotionRequestId.cs @@ -0,0 +1,9 @@ +namespace Ums.Domain.Kernel.ValueObjects; + +public class RolePromotionRequestId : IdValueObject +{ + private RolePromotionRequestId(Guid value) : base(value) { } + public static new RolePromotionRequestId Create() => new RolePromotionRequestId(Guid.NewGuid()); + public static new RolePromotionRequestId Load(Guid value) => new RolePromotionRequestId(value); + public static new RolePromotionRequestId Load(string value) => new RolePromotionRequestId(Guid.Parse(value)); +} diff --git a/src/apps/ums.api/Ums.Domain/Kernel/ValueObjects/TemplateVersion.cs b/src/apps/ums.api/Ums.Domain/Kernel/ValueObjects/TemplateVersion.cs index 90863263..d24bb113 100644 --- a/src/apps/ums.api/Ums.Domain/Kernel/ValueObjects/TemplateVersion.cs +++ b/src/apps/ums.api/Ums.Domain/Kernel/ValueObjects/TemplateVersion.cs @@ -1,6 +1,6 @@ namespace Ums.Domain.Kernel.ValueObjects; -public class TemplateVersion : StringValueObject +public class TemplateVersion : StringValueObject, IComparable { private TemplateVersion(string value) : base(value) { } @@ -11,6 +11,40 @@ public static TemplateVersion Create(int major, int minor, int patch) public static TemplateVersion Initial() => new TemplateVersion("0.1.0"); + /// Segmento MAJOR del semver (0 si el valor no es parseable). + public int Major => Segment(0); + + /// Segmento MINOR del semver (0 si el valor no es parseable). + public int Minor => Segment(1); + + /// Segmento PATCH del semver (0 si el valor no es parseable). + public int Patch => Segment(2); + + /// + /// Devuelve la versión inmediatamente siguiente incrementando el segmento MINOR y + /// reiniciando PATCH (p. ej. 0.1.0 → 0.2.0). Cf. G-140 / ADR-UMS-140: el alta de una + /// plantilla sobre una terna (tenant, rol, suite) ya plantillada genera una revisión nueva. + /// + public TemplateVersion Next() => Create(Major, Minor + 1, 0); + + public int CompareTo(TemplateVersion? other) + { + if (other is null) return 1; + var major = Major.CompareTo(other.Major); + if (major != 0) return major; + var minor = Minor.CompareTo(other.Minor); + if (minor != 0) return minor; + return Patch.CompareTo(other.Patch); + } + + private int Segment(int index) + { + var value = GetValue(); + if (string.IsNullOrWhiteSpace(value)) return 0; + var parts = value.Split('.'); + return index < parts.Length && int.TryParse(parts[index], out var n) ? n : 0; + } + public override void AddValidators() { base.AddValidators(); diff --git a/src/apps/ums.api/Ums.Globalization/Access/StringLocalizer.cs b/src/apps/ums.api/Ums.Globalization/Access/StringLocalizer.cs index 7c8453b3..e1642435 100644 --- a/src/apps/ums.api/Ums.Globalization/Access/StringLocalizer.cs +++ b/src/apps/ums.api/Ums.Globalization/Access/StringLocalizer.cs @@ -1,3 +1,4 @@ +#pragma warning disable S1144 using System.Collections.Concurrent; using System.Reflection; using System.Text.Json; @@ -59,3 +60,5 @@ private static Dictionary LoadResources(string language) return merged; } } + +#pragma warning restore S1144 diff --git a/src/apps/ums.api/Ums.Infrastructure/Aop/AuditTrailChannelSink.cs b/src/apps/ums.api/Ums.Infrastructure/Aop/AuditTrailChannelSink.cs deleted file mode 100644 index 2e0f8e27..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Aop/AuditTrailChannelSink.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace Ums.Infrastructure.Aop; - -public sealed class AuditTrailChannelSink : IAuditTrailSink -{ - private readonly Channel _channel; - - public AuditTrailChannelSink(Channel channel) - { - _channel = channel; - } - - public bool TryWrite(AuditTrailEntry entry) => _channel.Writer.TryWrite(entry); -} diff --git a/src/apps/ums.api/Ums.Infrastructure/Aop/AuditTrailOutboxSink.cs b/src/apps/ums.api/Ums.Infrastructure/Aop/AuditTrailOutboxSink.cs new file mode 100644 index 00000000..125b1680 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Aop/AuditTrailOutboxSink.cs @@ -0,0 +1,62 @@ +using MassTransit; +using Microsoft.Extensions.DependencyInjection; +using Ums.Infrastructure.Persistence; + +namespace Ums.Infrastructure.Aop; + +/// +/// G-040: encola el registro de auditoría automática por el Transactional Outbox de +/// MassTransit (ADR-0098 D6: efecto durable cuya pérdida es irreparable — no-repudiación). +/// Reemplaza el canal en memoria con descarte silencioso. +/// +/// La auditoría se captura DESPUÉS del commit del caso de uso (el AuditTrailAspect es el +/// aspecto más interno, Order=100). Por eso: +/// - Publica por el con ámbito de petición. Bajo kind/prod con +/// UseBusOutbox() ese endpoint escribe en la tabla de salida; el mensaje se materializa +/// al llamar SaveChangesAsync sobre el de la petición +/// (flush) y el servicio de entrega lo despacha POST-commit al bróker; el consumidor lo +/// persiste append-only con idempotencia por el inbox EF. +/// - En dev/tests (bus en memoria, sin outbox EF) no hay +/// registrado: el publish entrega directamente al consumidor en proceso y el flush se omite. +/// - Cualquier fallo se registra como error alertable; nunca se descarta (fin del +/// TryWrite==false silencioso). +/// +public sealed class AuditTrailOutboxSink( + IPublishEndpoint publishEndpoint, + IServiceProvider serviceProvider, + ILogger logger, + Ums.Application.Common.Interfaces.IFunctionalTransaction functionalTransaction) : IAuditTrailSink +{ + public async Task PublishAsync(AuditTrailEntry entry, CancellationToken cancellationToken = default) + { + try + { + // G-040 (FR-072): desinfecta la metadata ANTES de encolar — la traza es append-only e + // inmutable (G-081), así que un secreto (hash/PIN/llave/token) que se filtrase quedaría + // irreparable. Este sink es el único punto por el que pasan TODAS las emisiones automáticas + // (AuditTrailAspect y ConfigurationAuditService), así que el saneo aquí las cubre a todas y, + // al hacerlo antes del Publish, el secreto tampoco llega a la tabla de salida del outbox. + var sanitizedEntry = entry with { Metadata = AuditMetadataSanitizer.Sanitize(entry.Metadata) }; + + functionalTransaction.RecordEffect("message.publish", "outbox", $"AuditTrailEntry-{sanitizedEntry.EventType}", Ums.Application.Common.Interfaces.EffectReversibility.PendingCompensation); + await publishEndpoint.Publish(sanitizedEntry, cancellationToken); + + // Flush del bus-outbox: materializa el mensaje de salida en la conexión de la petición + // para que el servicio de entrega lo despache POST-commit. En modos sin outbox EF + // (dev/tests en memoria) el contexto de escritura no está registrado y el publish ya + // entregó en proceso, así que se omite. + var writeDbContext = serviceProvider.GetService(); + if (writeDbContext is not null) + await writeDbContext.SaveChangesAsync(cancellationToken); + } + catch (Exception ex) + { + // Sin descarte silencioso (G-040): la auditoría es no repudiable; el fallo se alerta. + logger.LogError(ex, + "Auditoría no confiable: fallo al encolar el registro de auditoría {EventType} para {AffectedEntityType}/{AffectedEntityId}. Evento alertable.", + entry.EventType, + entry.AffectedEntityType, + entry.AffectedEntityId); + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Aop/FactoryLoggingInterceptor.cs b/src/apps/ums.api/Ums.Infrastructure/Aop/FactoryLoggingInterceptor.cs index 3ab5bf08..8ca5fc14 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Aop/FactoryLoggingInterceptor.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Aop/FactoryLoggingInterceptor.cs @@ -30,9 +30,9 @@ public override void OnSuccess(TTarget target, string name, I services.Count, typeof(TService).Name, typeof(TTarget).Name); } - public override void OnError(TTarget target, string name, IList services, Exception ex) + public override void OnError(TTarget target, string name, IList services, Exception exception) { - _logger.LogError(ex, "Factory resolution failed for Service={ServiceType}, Target={TargetType}, Group={GroupName}", + _logger.LogError(exception, "Factory resolution failed for Service={ServiceType}, Target={TargetType}, Group={GroupName}", typeof(TService).Name, typeof(TTarget).Name, name ?? "default"); } diff --git a/src/apps/ums.api/Ums.Infrastructure/Aop/UmsSerilogLogger.cs b/src/apps/ums.api/Ums.Infrastructure/Aop/UmsSerilogLogger.cs index 70707c58..136ce83e 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Aop/UmsSerilogLogger.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Aop/UmsSerilogLogger.cs @@ -1,7 +1,9 @@ using System; +using System.Diagnostics; using System.Linq; using Microsoft.Extensions.Logging; using Ums.Application.Common.Aop; +using Ums.Application.Common.Interfaces; using BeyondNetCode.Shell.Aop; using BeyondNetCode.Shell.Aop.Aspects; @@ -11,62 +13,80 @@ namespace Ums.Infrastructure.Aop; /// -/// Serilog-backed, observability-aware implementation of . -/// -/// Enriches every AOP log entry with the full UMS observability envelope: -/// -/// TenantId — from (multi-tenant dimension) -/// CorrelationId — from W3C Activity baggage key "correlation.id" -/// (written by CorrelationIdMiddleware) -/// TraceId / SpanId — from (W3C trace context); -/// also emitted automatically by the Serilog OTel sink, but made explicit here so -/// non-OTel sinks (Console, Loki, Seq) carry the same fields -/// BoundedContext — inferred from the second namespace segment of the -/// handler type (e.g. Ums.Application.Identity.Tenant.Commands → "Identity") -/// -/// -/// Log levels: +/// Implementación de respaldada por Serilog y consciente de la +/// observabilidad. Enriquece cada línea de log AOP con la envolvente de UMS: /// -/// — method entry and successful exit -/// — unhandled exceptions +/// TenantId — de (dimensión multi-tenant) +/// TraceId / SpanId — del contexto de traza W3C +/// ( / traceparent), tras la unificación W3C (ADR-0046). +/// También los emite el enricher de Serilog en todas las líneas; aquí se hacen explícitos +/// para las líneas AOP. +/// CorrelationId — es el trace_id W3C: la correlación deja de +/// generarse a mano (se retiró el shim vendorizado y el X-Correlation-Id). +/// SessionTrackingId — de (rastreo de sesión de UMS) +/// BoundedContext — inferido del namespace del handler /// /// -/// PII safety: argument values are never emitted — only parameter names and CLR types. -/// -/// Registration (Infrastructure DI): -/// -/// services.AddKeyedTransient<AopILogger, UmsSerilogLogger>(typeof(IUmsLogger)); -/// +/// Seguridad PII: nunca se emiten los valores de los argumentos, solo su nombre y tipo CLR. /// public sealed class UmsSerilogLogger( MelILoggerFactory loggerFactory, IUserContext userContext, - IExecutionContextAccessor executionContextAccessor) : StructuredAopLoggerBase(executionContextAccessor), IUmsLogger + IRequestContext requestContext) : IUmsLogger { // ── Helpers ─────────────────────────────────────────────────────────────────────────── private MelILogger Logger(IJoinPoint jp) => loggerFactory.CreateLogger(jp.TargetType); - /// Tenant from scoped IUserContext, or "system" when running outside a user request. + /// Tenant del IUserContext scoped, o "system" fuera de una petición de usuario. private string TenantId() => userContext.TenantId ?? "system"; - private static string BoundedContext(Type targetType) => InferBoundedContext(targetType); + private (string CorrelationId, string SessionTrackingId, string TraceId, string SpanId) Correlation() + { + var activity = Activity.Current; + var traceId = activity is { IdFormat: ActivityIdFormat.W3C } ? activity.TraceId.ToString() : string.Empty; + var spanId = activity is { IdFormat: ActivityIdFormat.W3C } ? activity.SpanId.ToString() : string.Empty; + + return ( + CorrelationId: requestContext.CorrelationId ?? traceId, + SessionTrackingId: requestContext.SessionTrackingId ?? string.Empty, + TraceId: traceId, + SpanId: spanId); + } + + private static string BoundedContext(Type targetType) + { + // Ums.Application..… → el contexto acotado es el 3.er segmento + // (p. ej. Ums.Application.Identity.Tenant.Commands → "Identity"). + // + // El índice es 2, no 3: la resincronización acortó el namespace en un segmento + // (Unimar.Ums.Application.X → Ums.Application.X) y con el índice viejo esto devolvía el + // segmento SIGUIENTE —"Tenant" en vez de "Identity", "FeatureFlag" en vez de + // "Configuration"—, etiquetando mal cada traza sin que nada fallara a la vista. + var parts = targetType.Namespace?.Split('.') ?? Array.Empty(); + if (parts.Length >= 3) + { + return parts[2]; + } + + return parts.Length >= 2 ? parts[1] : targetType.Name; + } - // ── ILogger contract ───────────────────────────────────────────────────────────────── + // ── Contrato ILogger ───────────────────────────────────────────────────────────────── /// - public override void OnEntry(IJoinPoint joinPoint, Argument[] arguments, string requestId) + public void OnEntry(IJoinPoint joinPoint, Argument[] arguments, string requestId) { - var log = Logger(joinPoint); + var log = Logger(joinPoint); if (!log.IsEnabled(LogLevel.Information)) return; - var executionContext = ResolveExecutionContext(requestId); - var tenantId = TenantId(); - var bc = BoundedContext(joinPoint.TargetType); + var (correlationId, sessionTrackingId, traceId, spanId) = Correlation(); + var tenantId = TenantId(); + var bc = BoundedContext(joinPoint.TargetType); - // PII-safe: only names + CLR types, never values. + // PII-safe: solo nombres + tipos CLR, nunca valores. var argSummary = arguments is { Length: > 0 } ? string.Join(", ", arguments.Select(a => $"{a.Name}:{a.Type}")) : string.Empty; @@ -76,87 +96,65 @@ public override void OnEntry(IJoinPoint joinPoint, Argument[] arguments, string log.LogInformation( "→ {BoundedContext} {Handler}.{Method} | tenant={TenantId} cid={CorrelationId} sid={SessionTrackingId} trace={TraceId} span={SpanId}", bc, joinPoint.TargetType.Name, joinPoint.MethodInfo.Name, - tenantId, executionContext.CorrelationId, executionContext.SessionTrackingId, executionContext.TraceId, executionContext.SpanId); + tenantId, correlationId, sessionTrackingId, traceId, spanId); } else { log.LogInformation( "→ {BoundedContext} {Handler}.{Method} params=[{Params}] | tenant={TenantId} cid={CorrelationId} sid={SessionTrackingId} trace={TraceId} span={SpanId}", bc, joinPoint.TargetType.Name, joinPoint.MethodInfo.Name, argSummary, - tenantId, executionContext.CorrelationId, executionContext.SessionTrackingId, executionContext.TraceId, executionContext.SpanId); + tenantId, correlationId, sessionTrackingId, traceId, spanId); } } /// - public override void OnExit(IJoinPoint joinPoint, Return @return, string requestId, long duration) - { - var log = Logger(joinPoint); - if (!log.IsEnabled(LogLevel.Information)) return; - - var executionContext = ResolveExecutionContext(requestId); - var tenantId = TenantId(); - - log.LogInformation( - "← {BoundedContext} {Handler}.{Method} in {Duration}ms | tenant={TenantId} cid={CorrelationId} sid={SessionTrackingId} trace={TraceId} span={SpanId}", - BoundedContext(joinPoint.TargetType), - joinPoint.TargetType.Name, joinPoint.MethodInfo.Name, - duration, tenantId, executionContext.CorrelationId, executionContext.SessionTrackingId, executionContext.TraceId, executionContext.SpanId); - } + public void OnExit(IJoinPoint joinPoint, Return @return, string requestId, long duration) + => LogExit(joinPoint, duration); /// - public override void OnExit(IJoinPoint joinPoint, string requestId, long duration) - { - var log = Logger(joinPoint); - if (!log.IsEnabled(LogLevel.Information)) return; - - var executionContext = ResolveExecutionContext(requestId); - var tenantId = TenantId(); - - log.LogInformation( - "← {BoundedContext} {Handler}.{Method} in {Duration}ms | tenant={TenantId} cid={CorrelationId} sid={SessionTrackingId} trace={TraceId} span={SpanId}", - BoundedContext(joinPoint.TargetType), - joinPoint.TargetType.Name, joinPoint.MethodInfo.Name, - duration, tenantId, executionContext.CorrelationId, executionContext.SessionTrackingId, executionContext.TraceId, executionContext.SpanId); - } + public void OnExit(IJoinPoint joinPoint, string requestId, long duration) + => LogExit(joinPoint, duration); /// - public override void OnExit(IJoinPoint joinPoint, Return @return, string requestId) - { - var log = Logger(joinPoint); - if (!log.IsEnabled(LogLevel.Information)) return; - - var executionContext = ResolveExecutionContext(requestId); - var tenantId = TenantId(); - - log.LogInformation( - "← {BoundedContext} {Handler}.{Method} | tenant={TenantId} cid={CorrelationId} sid={SessionTrackingId} trace={TraceId} span={SpanId}", - BoundedContext(joinPoint.TargetType), - joinPoint.TargetType.Name, joinPoint.MethodInfo.Name, - tenantId, executionContext.CorrelationId, executionContext.SessionTrackingId, executionContext.TraceId, executionContext.SpanId); - } + public void OnExit(IJoinPoint joinPoint, Return @return, string requestId) + => LogExit(joinPoint, duration: null); /// - public override void OnExit(IJoinPoint joinPoint, string requestId) + public void OnExit(IJoinPoint joinPoint, string requestId) + => LogExit(joinPoint, duration: null); + + private void LogExit(IJoinPoint joinPoint, long? duration) { - var log = Logger(joinPoint); + var log = Logger(joinPoint); if (!log.IsEnabled(LogLevel.Information)) return; - var executionContext = ResolveExecutionContext(requestId); - var tenantId = TenantId(); + var (correlationId, sessionTrackingId, traceId, spanId) = Correlation(); + var tenantId = TenantId(); - log.LogInformation( - "← {BoundedContext} {Handler}.{Method} | tenant={TenantId} cid={CorrelationId} sid={SessionTrackingId} trace={TraceId} span={SpanId}", - BoundedContext(joinPoint.TargetType), - joinPoint.TargetType.Name, joinPoint.MethodInfo.Name, - tenantId, executionContext.CorrelationId, executionContext.SessionTrackingId, executionContext.TraceId, executionContext.SpanId); + if (duration is { } ms) + { + log.LogInformation( + "← {BoundedContext} {Handler}.{Method} in {Duration}ms | tenant={TenantId} cid={CorrelationId} sid={SessionTrackingId} trace={TraceId} span={SpanId}", + BoundedContext(joinPoint.TargetType), + joinPoint.TargetType.Name, joinPoint.MethodInfo.Name, + ms, tenantId, correlationId, sessionTrackingId, traceId, spanId); + } + else + { + log.LogInformation( + "← {BoundedContext} {Handler}.{Method} | tenant={TenantId} cid={CorrelationId} sid={SessionTrackingId} trace={TraceId} span={SpanId}", + BoundedContext(joinPoint.TargetType), + joinPoint.TargetType.Name, joinPoint.MethodInfo.Name, + tenantId, correlationId, sessionTrackingId, traceId, spanId); + } } /// - public override void OnException(IJoinPoint joinPoint, string requestId, Exception ex) + public void OnException(IJoinPoint joinPoint, string requestId, Exception ex) { - var log = Logger(joinPoint); - var executionContext = ResolveExecutionContext(requestId); - var tenantId = TenantId(); + var log = Logger(joinPoint); + var (correlationId, sessionTrackingId, traceId, spanId) = Correlation(); + var tenantId = TenantId(); log.LogError( ex, @@ -164,6 +162,6 @@ public override void OnException(IJoinPoint joinPoint, string requestId, Excepti BoundedContext(joinPoint.TargetType), joinPoint.TargetType.Name, joinPoint.MethodInfo.Name, ex.GetType().Name, - tenantId, executionContext.CorrelationId, executionContext.SessionTrackingId, executionContext.TraceId, executionContext.SpanId); + tenantId, correlationId, sessionTrackingId, traceId, spanId); } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Approvals/NotificationRule/NotificationRecipientStrategies.cs b/src/apps/ums.api/Ums.Infrastructure/Approvals/NotificationRule/NotificationRecipientStrategies.cs index b917f445..4cfc87a1 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Approvals/NotificationRule/NotificationRecipientStrategies.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Approvals/NotificationRule/NotificationRecipientStrategies.cs @@ -30,7 +30,7 @@ public override Result Normalize(string recipient) internal sealed class SmsNotificationRecipientStrategy : NotificationRecipientStrategyBase { - private static readonly Regex AllowedCharacters = new(@"^[\d\+\-\(\)\s]+$", RegexOptions.Compiled); + private static readonly Regex AllowedCharacters = new(@"^[\d\+\-\(\)\s]+$", RegexOptions.Compiled, TimeSpan.FromSeconds(1)); public override Result Normalize(string recipient) { diff --git a/src/apps/ums.api/Ums.Infrastructure/Authorization/Graph/CsvAuthorizationGraphSerializer.cs b/src/apps/ums.api/Ums.Infrastructure/Authorization/Graph/CsvAuthorizationGraphSerializer.cs index 8d353dff..1bfeb53b 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Authorization/Graph/CsvAuthorizationGraphSerializer.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Authorization/Graph/CsvAuthorizationGraphSerializer.cs @@ -14,38 +14,41 @@ public sealed class CsvAuthorizationGraphSerializer : IAuthorizationGraphSeriali public string ContentType => "text/csv"; public string FileExtension => "csv"; - public string Serialize(AuthorizationGraph g, GraphSerializationOptions? options = null) + public string Serialize(AuthorizationGraph graph, GraphSerializationOptions? options = null) { var opts = options ?? GraphSerializationOptions.Default; var sb = new StringBuilder(); - sb.AppendLine($"# UMS Authorization Graph — {g.Context.Tenant.Code} / {g.Context.SystemSuite.Code} / {g.Context.Role.Code}"); - sb.AppendLine($"# Generated: {g.GeneratedAt:O} ValidUntil: {g.ValidUntil:O}"); - sb.AppendLine($"# Auth: {g.Authentication.Method} MFA: {g.Authentication.MfaRequired}"); + sb.AppendLine($"# UMS Authorization Graph — {graph.Context.Tenant.Code} / {graph.Context.SystemSuite.Code} / {graph.Context.Role.Code}"); + sb.AppendLine($"# Generated: {graph.GeneratedAt:O} ValidUntil: {graph.ValidUntil:O}"); + sb.AppendLine($"# Auth: {graph.Authentication.Method} MFA: {graph.Authentication.MfaRequired}"); sb.AppendLine(); - sb.AppendLine("Section,ModuleCode,ModuleValue,MenuCode,MenuValue,SubMenuCode,SubMenuValue,OptionCode,OptionValue,ActionCode,Effect,Source"); - foreach (var module in g.MenuAccess) - foreach (var menu in module.Menus) - foreach (var sub in menu.SubMenus) - foreach (var opt in sub.Options) + // El árbol admite cualquier profundidad, así que la columna que lo identifica es la RUTA + // completa (`MENU/SUBMENU/OPCION`), no tres columnas fijas que se quedarían cortas. + sb.AppendLine("Section,ModuleCode,ModuleValue,NodePath,NodeCode,NodeValue,NodeKind,ActionCode,Effect,Source"); + foreach (var module in graph.MenuAccess) { - sb.AppendLine(string.Join(",", - "Menu", - Esc(module.Code), Esc(module.Name), - Esc(menu.Code), Esc(menu.Label), - Esc(sub.Code), Esc(sub.Label), - Esc(opt.Code), Esc(opt.Label), - Esc(opt.ActionCode), - opt.Effect.ToString(), - opt.Source.ToString())); + foreach (var (nodo, ruta) in Aplanar(module.Nodes, string.Empty)) + { + foreach (var accion in nodo.Actions) + { + sb.AppendLine(string.Join(",", + "Menu", + Esc(module.Code), Esc(module.Name), + Esc(ruta), Esc(nodo.Code), Esc(nodo.Name), Esc(nodo.Kind), + Esc(accion.ActionCode), + accion.Effect.ToString(), + accion.Source.ToString())); + } + } } sb.AppendLine(); sb.AppendLine("Section,ResourceType,ResourceCode,ResourceValue,ActionCode,ActionValue,Effect,Source"); - foreach (var res in g.DomainPermissions) - foreach (var act in res.Actions) + foreach (var (res, act) in graph.DomainPermissions + .SelectMany(res => res.Actions, (res, act) => (res, act))) { sb.AppendLine(string.Join(",", "Domain", @@ -57,7 +60,7 @@ public string Serialize(AuthorizationGraph g, GraphSerializationOptions? options sb.AppendLine(); sb.AppendLine("Section,FlagCode,IsEnabled,MatchedCriteria"); - foreach (var f in g.FeatureFlags) + foreach (var f in graph.FeatureFlags) { sb.AppendLine(string.Join(",", "Feature", @@ -69,24 +72,36 @@ public string Serialize(AuthorizationGraph g, GraphSerializationOptions? options sb.AppendLine(); sb.AppendLine("Section,Scope"); - foreach (var s in g.Scopes) + foreach (var s in graph.Scopes) sb.AppendLine($"Scope,{Esc(s)}"); if (opts.IncludeTechnicalMetadata) { sb.AppendLine(); sb.AppendLine("Section,TechnicalField,Value"); - sb.AppendLine($"Technical,UserId,{Esc(g.Context.User.Id.ToString())}"); - sb.AppendLine($"Technical,TenantId,{Esc(g.Context.Tenant.Id.ToString())}"); - sb.AppendLine($"Technical,SystemSuiteId,{Esc(g.Context.SystemSuite.Id.ToString())}"); - sb.AppendLine($"Technical,RoleId,{Esc(g.Context.Role.Id.ToString())}"); - if (g.Context.Branch is not null) - sb.AppendLine($"Technical,BranchId,{Esc(g.Context.Branch.Id.ToString())}"); + sb.AppendLine($"Technical,UserId,{Esc(graph.Context.User.Id.ToString())}"); + sb.AppendLine($"Technical,TenantId,{Esc(graph.Context.Tenant.Id.ToString())}"); + sb.AppendLine($"Technical,SystemSuiteId,{Esc(graph.Context.SystemSuite.Id.ToString())}"); + sb.AppendLine($"Technical,RoleId,{Esc(graph.Context.Role.Id.ToString())}"); + if (graph.Context.Branch is not null) + sb.AppendLine($"Technical,BranchId,{Esc(graph.Context.Branch.Id.ToString())}"); } return sb.ToString(); } + /// Recorre el árbol devolviendo cada nodo con su ruta de códigos. + private static IEnumerable<(Ums.Domain.Authorization.Graph.GraphNavigationNode Nodo, string Ruta)> Aplanar( + IEnumerable nodos, string prefijo) + { + foreach (var nodo in nodos) + { + var ruta = string.IsNullOrEmpty(prefijo) ? nodo.Code : $"{prefijo}/{nodo.Code}"; + yield return (nodo, ruta); + foreach (var hijo in Aplanar(nodo.Children, ruta)) yield return hijo; + } + } + private static string Esc(string? v) { if (v is null) return ""; diff --git a/src/apps/ums.api/Ums.Infrastructure/Authorization/Graph/JsonAuthorizationGraphSerializer.cs b/src/apps/ums.api/Ums.Infrastructure/Authorization/Graph/JsonAuthorizationGraphSerializer.cs index ae691471..adb86824 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Authorization/Graph/JsonAuthorizationGraphSerializer.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Authorization/Graph/JsonAuthorizationGraphSerializer.cs @@ -1,11 +1,25 @@ using System.Text.Json; using System.Text.Json.Serialization; +using Ums.Application.Authorization.Graph; using Ums.Application.Authorization.Graph.Serializers; using Ums.Domain.Authorization.Graph; namespace Ums.Infrastructure.Authorization.Graph; -/// Serializes AuthorizationGraph to JSON (default format). +/// +/// Serializa el a JSON (formato por defecto). +/// +/// La FORMA la define , compartida con el login +/// web: este serializador solo elige la representación. Antes proyectaba su +/// propio modelo y por eso el mismo grafo salía distinto según el endpoint +/// (G-167). +/// +/// No se ignoran los nulos: branch, systemSuite, provider o +/// matchedCriteriaType viajan explícitamente como null. Los +/// campos verdaderamente opcionales —los identificadores técnicos— los omite el +/// mapeador, no el serializador, para que un cliente pueda distinguir «este +/// inquilino no publica ids» de «este objeto no tiene valor». +/// public sealed class JsonAuthorizationGraphSerializer : IAuthorizationGraphSerializer { public string ContentType => "application/json"; @@ -13,131 +27,30 @@ public sealed class JsonAuthorizationGraphSerializer : IAuthorizationGraphSerial private static readonly JsonSerializerOptions _options = new() { - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - Converters = { new JsonStringEnumConverter() }, + WriteIndented = true, + Converters = { new JsonStringEnumConverter() }, }; - public string Serialize(AuthorizationGraph graph, GraphSerializationOptions? options = null) + private static readonly JsonSerializerOptions _compact = new() { - var opts = options ?? GraphSerializationOptions.Default; - var model = BuildModel(graph, opts); - return JsonSerializer.Serialize(model, _options); - } + WriteIndented = false, + Converters = { new JsonStringEnumConverter() }, + }; - private static object BuildModel(AuthorizationGraph g, GraphSerializationOptions opts) + public string Serialize(AuthorizationGraph graph, GraphSerializationOptions? options = null) { - var includeMeta = opts.IncludeTechnicalMetadata; + var opts = options ?? GraphSerializationOptions.Default; + var model = AuthGraphPayload.Build(graph, opts); + var json = JsonSerializer.Serialize(model, opts.PrettyPrint ? _options : _compact); + + // Tamaño ANTES de comprimir: es el que crece con el catálogo y el que hay que vigilar. + // Lo que viaja por el cable lo mide la capa HTTP, y la diferencia entre ambos es + // justamente el ahorro de la compresión. + GraphMetrics.TamanoPayload.Record( + System.Text.Encoding.UTF8.GetByteCount(json), + new KeyValuePair("system", graph.Context.SystemSuite?.Code ?? "n/a"), + new KeyValuePair("role", graph.Context.Role?.Code ?? "n/a")); - return new - { - context = new - { - user = new - { - id = includeMeta ? g.Context.User.Id.ToString() : null, - g.Context.User.Email, - g.Context.User.Username, - value = g.Context.User.DisplayName, - g.Context.User.Status, - }, - tenant = new - { - id = includeMeta ? g.Context.Tenant.Id.ToString() : null, - g.Context.Tenant.Code, - value = g.Context.Tenant.Name, - g.Context.Tenant.Status, - g.Context.Tenant.IsManagementOwner, - }, - systemSuite = new - { - id = includeMeta ? g.Context.SystemSuite.Id.ToString() : null, - g.Context.SystemSuite.Code, - value = g.Context.SystemSuite.Name, - g.Context.SystemSuite.Status, - }, - role = new - { - id = includeMeta ? g.Context.Role.Id.ToString() : null, - g.Context.Role.Code, - value = g.Context.Role.Name, - g.Context.Role.HierarchyLevel, - }, - profile = new - { - id = includeMeta ? g.Context.Profile.Id.ToString() : null, - g.Context.Profile.Scope, - g.Context.Profile.IsActive, - }, - branch = g.Context.Branch is null ? null : new - { - id = includeMeta ? g.Context.Branch.Id.ToString() : null, - g.Context.Branch.Code, - value = g.Context.Branch.Name, - }, - }, - authentication = new - { - method = g.Authentication.Method, - provider = g.Authentication.Provider is null ? null : new - { - id = includeMeta ? g.Authentication.Provider.Id.ToString() : null, - g.Authentication.Provider.Name, - g.Authentication.Provider.Code, - value = g.Authentication.Provider.Strategy, - }, - mfaRequired = g.Authentication.MfaRequired, - issuedAt = g.Authentication.IssuedAt.ToString("O"), - sessionExpiresAt = g.Authentication.SessionExpiresAt.ToString("O"), - }, - actions = g.Actions.Select(a => new { a.Code, value = a.Name }), - menuAccess = g.MenuAccess.Select(m => new - { - m.Code, value = m.Name, m.Status, - menus = m.Menus.Select(menu => new - { - menu.Code, value = menu.Label, - subMenus = menu.SubMenus.Select(sub => new - { - sub.Code, value = sub.Label, - options = sub.Options.Select(o => new - { - o.Code, value = o.Label, o.ActionCode, - effect = o.Effect.ToString(), - source = o.Source.ToString(), - }) - }) - }) - }), - domainPermissions = g.DomainPermissions.Select(r => new - { - resourceId = includeMeta ? r.ResourceId.ToString() : null, - r.ResourceType, - code = r.ResourceCode, - value = r.ResourceName, - moduleId = includeMeta && r.ModuleId.HasValue ? r.ModuleId.Value.ToString() : null, - parentResourceId = includeMeta && r.ParentResourceId.HasValue ? r.ParentResourceId.Value.ToString() : null, - actions = r.Actions.Select(a => new - { - code = a.ActionCode, - value = a.ActionName, - effect = a.Effect.ToString(), - source = a.Source.ToString(), - }) - }), - featureFlags = g.FeatureFlags.Select(f => new { code = f.FlagCode, isEnabled = f.IsEnabled, f.MatchedCriteriaType }), - effectiveConfig = new - { - g.EffectiveConfig.SessionTimeoutMinutes, - g.EffectiveConfig.MaxLoginAttempts, - g.EffectiveConfig.MinPasswordLength, - g.EffectiveConfig.MfaRequiredForAdmin, - g.EffectiveConfig.MfaAllowedMethods, - g.EffectiveConfig.AuthUseExternalIdp, - }, - scopes = g.Scopes, - generatedAt = g.GeneratedAt.ToString("O"), - validUntil = g.ValidUntil.ToString("O"), - }; + return json; } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Authorization/Graph/XmlAuthorizationGraphSerializer.cs b/src/apps/ums.api/Ums.Infrastructure/Authorization/Graph/XmlAuthorizationGraphSerializer.cs index a8d6e4dc..33d7e5d7 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Authorization/Graph/XmlAuthorizationGraphSerializer.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Authorization/Graph/XmlAuthorizationGraphSerializer.cs @@ -11,9 +11,12 @@ public sealed class XmlAuthorizationGraphSerializer : IAuthorizationGraphSeriali public string ContentType => "application/xml"; public string FileExtension => "xml"; - public string Serialize(AuthorizationGraph g, GraphSerializationOptions? options = null) + public string Serialize(AuthorizationGraph graph, GraphSerializationOptions? options = null) { var opts = options ?? GraphSerializationOptions.Default; + var g = graph; + XAttribute? FormatId(Guid id) => + opts.IncludeTechnicalMetadata ? new XAttribute("id", id) : null; XElement? BuildBranch() { @@ -21,7 +24,7 @@ public string Serialize(AuthorizationGraph g, GraphSerializationOptions? options return null; return new XElement("branch", - opts.IncludeTechnicalMetadata ? new XAttribute("id", g.Context.Branch.Id) : null, + FormatId(g.Context.Branch.Id), new XAttribute("code", g.Context.Branch.Code), new XAttribute("value", g.Context.Branch.Name)); } @@ -32,7 +35,7 @@ public string Serialize(AuthorizationGraph g, GraphSerializationOptions? options return null; return new XElement("provider", - opts.IncludeTechnicalMetadata ? new XAttribute("id", g.Authentication.Provider.Id) : null, + FormatId(g.Authentication.Provider.Id), new XAttribute("code", g.Authentication.Provider.Code), new XAttribute("name", g.Authentication.Provider.Name), new XAttribute("value", g.Authentication.Provider.Strategy)); @@ -41,29 +44,30 @@ public string Serialize(AuthorizationGraph g, GraphSerializationOptions? options XElement BuildContext() => new("context", new XElement("user", - opts.IncludeTechnicalMetadata ? new XAttribute("id", g.Context.User.Id) : null, + FormatId(g.Context.User.Id), new XAttribute("email", g.Context.User.Email), new XAttribute("username", g.Context.User.Username), new XAttribute("value", g.Context.User.DisplayName), new XAttribute("status", g.Context.User.Status)), new XElement("tenant", - opts.IncludeTechnicalMetadata ? new XAttribute("id", g.Context.Tenant.Id) : null, + FormatId(g.Context.Tenant.Id), new XAttribute("code", g.Context.Tenant.Code), new XAttribute("value", g.Context.Tenant.Name), new XAttribute("status", g.Context.Tenant.Status), new XAttribute("isManagementOwner", g.Context.Tenant.IsManagementOwner)), - new XElement("systemSuite", - opts.IncludeTechnicalMetadata ? new XAttribute("id", g.Context.SystemSuite.Id) : null, + // G-043: en el grafo lobby SystemSuite/Role/Profile son null → se omiten los elementos. + g.Context.SystemSuite is null ? null : new XElement("systemSuite", + FormatId(g.Context.SystemSuite.Id), new XAttribute("code", g.Context.SystemSuite.Code), new XAttribute("value", g.Context.SystemSuite.Name), new XAttribute("status", g.Context.SystemSuite.Status)), - new XElement("role", - opts.IncludeTechnicalMetadata ? new XAttribute("id", g.Context.Role.Id) : null, + g.Context.Role is null ? null : new XElement("role", + FormatId(g.Context.Role.Id), new XAttribute("code", g.Context.Role.Code), new XAttribute("value", g.Context.Role.Name), new XAttribute("level", g.Context.Role.HierarchyLevel)), - new XElement("profile", - opts.IncludeTechnicalMetadata ? new XAttribute("id", g.Context.Profile.Id) : null, + g.Context.Profile is null ? null : new XElement("profile", + FormatId(g.Context.Profile.Id), new XAttribute("scope", g.Context.Profile.Scope), new XAttribute("isActive", g.Context.Profile.IsActive)), BuildBranch()); @@ -82,29 +86,30 @@ XElement BuildActions() new XAttribute("code", a.Code), new XAttribute("value", a.Name)))); + // Un solo elemento `node` recursivo: el árbol no tiene profundidad fija. + XElement BuildNode(Ums.Domain.Authorization.Graph.GraphNavigationNode n) + => new("node", + new XAttribute("code", n.Code), + new XAttribute("value", n.Name), + new XAttribute("kind", n.Kind), + n.Actions.Select(a => new XElement("action", + new XAttribute("actionCode", a.ActionCode), + new XAttribute("effect", a.Effect.ToString()), + new XAttribute("source", a.Source.ToString()))), + n.Children.Select(BuildNode)); + XElement BuildMenuAccess() => new("menuAccess", g.MenuAccess.Select(m => new XElement("module", new XAttribute("code", m.Code), new XAttribute("value", m.Name), new XAttribute("status", m.Status), - m.Menus.Select(menu => new XElement("menu", - new XAttribute("code", menu.Code), - new XAttribute("value", menu.Label), - menu.SubMenus.Select(sub => new XElement("subMenu", - new XAttribute("code", sub.Code), - new XAttribute("value", sub.Label), - sub.Options.Select(o => new XElement("option", - new XAttribute("code", o.Code), - new XAttribute("value", o.Label), - new XAttribute("actionCode", o.ActionCode), - new XAttribute("effect", o.Effect.ToString()), - new XAttribute("source", o.Source.ToString())))))))))); + m.Nodes.Select(BuildNode)))); XElement BuildDomainPermissions() => new("domainPermissions", g.DomainPermissions.Select(r => new XElement("resource", - opts.IncludeTechnicalMetadata ? new XAttribute("id", r.ResourceId) : null, + FormatId(r.ResourceId), opts.IncludeTechnicalMetadata && r.ModuleId.HasValue ? new XAttribute("moduleId", r.ModuleId.Value) : null, opts.IncludeTechnicalMetadata && r.ParentResourceId.HasValue ? new XAttribute("parentResourceId", r.ParentResourceId.Value) : null, new XAttribute("type", r.ResourceType), diff --git a/src/apps/ums.api/Ums.Infrastructure/Authorization/Graph/YamlAuthorizationGraphSerializer.cs b/src/apps/ums.api/Ums.Infrastructure/Authorization/Graph/YamlAuthorizationGraphSerializer.cs index 60ea4487..89fd086f 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Authorization/Graph/YamlAuthorizationGraphSerializer.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Authorization/Graph/YamlAuthorizationGraphSerializer.cs @@ -10,69 +10,71 @@ public sealed class YamlAuthorizationGraphSerializer : IAuthorizationGraphSerial public string ContentType => "application/x-yaml"; public string FileExtension => "yaml"; - public string Serialize(AuthorizationGraph g, GraphSerializationOptions? options = null) + public string Serialize(AuthorizationGraph graph, GraphSerializationOptions? options = null) { var opts = options ?? GraphSerializationOptions.Default; var sb = new StringBuilder(); sb.AppendLine("# UMS Authorization Graph"); - sb.AppendLine($"# Generated: {g.GeneratedAt:O}"); - sb.AppendLine($"# Valid Until: {g.ValidUntil:O}"); + sb.AppendLine($"# Generated: {graph.GeneratedAt:O}"); + sb.AppendLine($"# Valid Until: {graph.ValidUntil:O}"); + sb.AppendLine(); + sb.AppendLine($"schemaVersion: {graph.SchemaVersion}"); sb.AppendLine(); sb.AppendLine("context:"); sb.AppendLine(" user:"); - if (opts.IncludeTechnicalMetadata) sb.AppendLine($" id: {g.Context.User.Id}"); - sb.AppendLine($" email: {g.Context.User.Email}"); - sb.AppendLine($" username: {g.Context.User.Username}"); - sb.AppendLine($" value: {g.Context.User.DisplayName}"); - sb.AppendLine($" status: {g.Context.User.Status}"); + if (opts.IncludeTechnicalMetadata) sb.AppendLine($" id: {graph.Context.User.Id}"); + sb.AppendLine($" email: {graph.Context.User.Email}"); + sb.AppendLine($" username: {graph.Context.User.Username}"); + sb.AppendLine($" value: {graph.Context.User.DisplayName}"); + sb.AppendLine($" status: {graph.Context.User.Status}"); sb.AppendLine(" tenant:"); - if (opts.IncludeTechnicalMetadata) sb.AppendLine($" id: {g.Context.Tenant.Id}"); - sb.AppendLine($" code: {g.Context.Tenant.Code}"); - sb.AppendLine($" value: {g.Context.Tenant.Name}"); - sb.AppendLine($" status: {g.Context.Tenant.Status}"); - sb.AppendLine($" isManagementOwner: {g.Context.Tenant.IsManagementOwner.ToString().ToLower()}"); + if (opts.IncludeTechnicalMetadata) sb.AppendLine($" id: {graph.Context.Tenant.Id}"); + sb.AppendLine($" code: {graph.Context.Tenant.Code}"); + sb.AppendLine($" value: {graph.Context.Tenant.Name}"); + sb.AppendLine($" status: {graph.Context.Tenant.Status}"); + sb.AppendLine($" isManagementOwner: {graph.Context.Tenant.IsManagementOwner.ToString().ToLower()}"); sb.AppendLine(" systemSuite:"); - if (opts.IncludeTechnicalMetadata) sb.AppendLine($" id: {g.Context.SystemSuite.Id}"); - sb.AppendLine($" code: {g.Context.SystemSuite.Code}"); - sb.AppendLine($" value: {g.Context.SystemSuite.Name}"); - sb.AppendLine($" status: {g.Context.SystemSuite.Status}"); + if (opts.IncludeTechnicalMetadata) sb.AppendLine($" id: {graph.Context.SystemSuite.Id}"); + sb.AppendLine($" code: {graph.Context.SystemSuite.Code}"); + sb.AppendLine($" value: {graph.Context.SystemSuite.Name}"); + sb.AppendLine($" status: {graph.Context.SystemSuite.Status}"); sb.AppendLine(" role:"); - if (opts.IncludeTechnicalMetadata) sb.AppendLine($" id: {g.Context.Role.Id}"); - sb.AppendLine($" code: {g.Context.Role.Code}"); - sb.AppendLine($" value: {g.Context.Role.Name}"); - sb.AppendLine($" hierarchyLevel: {g.Context.Role.HierarchyLevel}"); + if (opts.IncludeTechnicalMetadata) sb.AppendLine($" id: {graph.Context.Role.Id}"); + sb.AppendLine($" code: {graph.Context.Role.Code}"); + sb.AppendLine($" value: {graph.Context.Role.Name}"); + sb.AppendLine($" hierarchyLevel: {graph.Context.Role.HierarchyLevel}"); sb.AppendLine(" profile:"); - if (opts.IncludeTechnicalMetadata) sb.AppendLine($" id: {g.Context.Profile.Id}"); - sb.AppendLine($" scope: {g.Context.Profile.Scope}"); - sb.AppendLine($" isActive: {g.Context.Profile.IsActive.ToString().ToLower()}"); - if (g.Context.Branch is not null) + if (opts.IncludeTechnicalMetadata) sb.AppendLine($" id: {graph.Context.Profile.Id}"); + sb.AppendLine($" scope: {graph.Context.Profile.Scope}"); + sb.AppendLine($" isActive: {graph.Context.Profile.IsActive.ToString().ToLower()}"); + if (graph.Context.Branch is not null) { sb.AppendLine(" branch:"); - if (opts.IncludeTechnicalMetadata) sb.AppendLine($" id: {g.Context.Branch.Id}"); - sb.AppendLine($" code: {g.Context.Branch.Code}"); - sb.AppendLine($" value: {g.Context.Branch.Name}"); + if (opts.IncludeTechnicalMetadata) sb.AppendLine($" id: {graph.Context.Branch.Id}"); + sb.AppendLine($" code: {graph.Context.Branch.Code}"); + sb.AppendLine($" value: {graph.Context.Branch.Name}"); } sb.AppendLine(); sb.AppendLine("authentication:"); - sb.AppendLine($" method: {g.Authentication.Method}"); - sb.AppendLine($" mfaRequired: {g.Authentication.MfaRequired.ToString().ToLower()}"); - sb.AppendLine($" issuedAt: {g.Authentication.IssuedAt:O}"); - sb.AppendLine($" sessionExpiresAt: {g.Authentication.SessionExpiresAt:O}"); - if (g.Authentication.Provider is not null) + sb.AppendLine($" method: {graph.Authentication.Method}"); + sb.AppendLine($" mfaRequired: {graph.Authentication.MfaRequired.ToString().ToLower()}"); + sb.AppendLine($" issuedAt: {graph.Authentication.IssuedAt:O}"); + sb.AppendLine($" sessionExpiresAt: {graph.Authentication.SessionExpiresAt:O}"); + if (graph.Authentication.Provider is not null) { sb.AppendLine(" provider:"); - if (opts.IncludeTechnicalMetadata) sb.AppendLine($" id: {g.Authentication.Provider.Id}"); - sb.AppendLine($" code: {g.Authentication.Provider.Code}"); - sb.AppendLine($" name: {g.Authentication.Provider.Name}"); - sb.AppendLine($" value: {g.Authentication.Provider.Strategy}"); + if (opts.IncludeTechnicalMetadata) sb.AppendLine($" id: {graph.Authentication.Provider.Id}"); + sb.AppendLine($" code: {graph.Authentication.Provider.Code}"); + sb.AppendLine($" name: {graph.Authentication.Provider.Name}"); + sb.AppendLine($" value: {graph.Authentication.Provider.Strategy}"); } sb.AppendLine(); sb.AppendLine("actions:"); - foreach (var a in g.Actions) + foreach (var a in graph.Actions) { sb.AppendLine($" - code: {a.Code}"); sb.AppendLine($" value: {a.Name}"); @@ -80,37 +82,18 @@ public string Serialize(AuthorizationGraph g, GraphSerializationOptions? options sb.AppendLine(); sb.AppendLine("menuAccess:"); - foreach (var module in g.MenuAccess) + foreach (var module in graph.MenuAccess) { sb.AppendLine($" - code: {module.Code}"); sb.AppendLine($" value: {module.Name}"); sb.AppendLine($" status: {module.Status}"); - sb.AppendLine(" menus:"); - foreach (var menu in module.Menus) - { - sb.AppendLine($" - code: {menu.Code}"); - sb.AppendLine($" value: {menu.Label}"); - sb.AppendLine(" subMenus:"); - foreach (var sub in menu.SubMenus) - { - sb.AppendLine($" - code: {sub.Code}"); - sb.AppendLine($" value: {sub.Label}"); - sb.AppendLine(" options:"); - foreach (var opt in sub.Options) - { - sb.AppendLine($" - code: {opt.Code}"); - sb.AppendLine($" value: {opt.Label}"); - sb.AppendLine($" actionCode: {opt.ActionCode}"); - sb.AppendLine($" effect: {opt.Effect}"); - sb.AppendLine($" source: {opt.Source}"); - } - } - } + sb.AppendLine(" nodes:"); + EscribirNodos(sb, module.Nodes, " "); } sb.AppendLine(); sb.AppendLine("domainPermissions:"); - foreach (var res in g.DomainPermissions) + foreach (var res in graph.DomainPermissions) { sb.AppendLine(" -"); if (opts.IncludeTechnicalMetadata) sb.AppendLine($" id: {res.ResourceId}"); @@ -135,27 +118,65 @@ public string Serialize(AuthorizationGraph g, GraphSerializationOptions? options sb.AppendLine(); sb.AppendLine("featureFlags:"); - foreach (var f in g.FeatureFlags) + foreach (var f in graph.FeatureFlags) { sb.AppendLine(" -"); - sb.AppendLine($" code: {f.FlagCode}"); + sb.AppendLine($" flagCode: {f.FlagCode}"); + sb.AppendLine($" systemSuiteId: {f.SystemSuiteId}"); sb.AppendLine($" isEnabled: {f.IsEnabled.ToString().ToLower()}"); if (!string.IsNullOrWhiteSpace(f.MatchedCriteriaType)) - sb.AppendLine($" matchedCriteria: {f.MatchedCriteriaType}"); + sb.AppendLine($" matchedCriteriaType: {f.MatchedCriteriaType}"); } sb.AppendLine(); sb.AppendLine("effectiveConfig:"); - sb.AppendLine($" sessionTimeoutMinutes: {g.EffectiveConfig.SessionTimeoutMinutes}"); - sb.AppendLine($" maxLoginAttempts: {g.EffectiveConfig.MaxLoginAttempts}"); - sb.AppendLine($" mfaRequiredForAdmin: {g.EffectiveConfig.MfaRequiredForAdmin.ToString().ToLower()}"); - sb.AppendLine($" mfaAllowedMethods: [{string.Join(", ", g.EffectiveConfig.MfaAllowedMethods)}]"); + sb.AppendLine($" sessionTimeoutMinutes: {graph.EffectiveConfig.SessionTimeoutMinutes}"); + sb.AppendLine($" maxLoginAttempts: {graph.EffectiveConfig.MaxLoginAttempts}"); + sb.AppendLine($" minPasswordLength: {graph.EffectiveConfig.MinPasswordLength}"); + sb.AppendLine($" mfaRequiredForAdmin: {graph.EffectiveConfig.MfaRequiredForAdmin.ToString().ToLower()}"); + sb.AppendLine($" accessTokenDurationMs: {graph.EffectiveConfig.AccessTokenDurationMs}"); + sb.AppendLine($" authUseExternalIdp: {graph.EffectiveConfig.AuthUseExternalIdp.ToString().ToLower()}"); sb.AppendLine(); sb.AppendLine("scopes:"); - foreach (var scope in g.Scopes) + foreach (var scope in graph.Scopes) sb.AppendLine($" - {scope}"); return sb.ToString(); } + + /// + /// Escribe el árbol de navegación con sangría creciente. Recursivo porque el árbol no tiene + /// profundidad fija (ADR-0090): la versión anterior tenía tres bucles anidados y perdía en + /// silencio cualquier nodo que no encajara en ellos. + /// + private static void EscribirNodos( + System.Text.StringBuilder sb, + IEnumerable nodos, + string sangria) + { + foreach (var nodo in nodos) + { + sb.AppendLine($"{sangria}- code: {nodo.Code}"); + sb.AppendLine($"{sangria} value: {nodo.Name}"); + sb.AppendLine($"{sangria} kind: {nodo.Kind}"); + + if (nodo.Actions.Count > 0) + { + sb.AppendLine($"{sangria} actions:"); + foreach (var accion in nodo.Actions) + { + sb.AppendLine($"{sangria} - actionCode: {accion.ActionCode}"); + sb.AppendLine($"{sangria} effect: {accion.Effect}"); + sb.AppendLine($"{sangria} source: {accion.Source}"); + } + } + + if (nodo.Children.Count > 0) + { + sb.AppendLine($"{sangria} children:"); + EscribirNodos(sb, nodo.Children, sangria + " "); + } + } + } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Configuration/CadenaDeRedis.cs b/src/apps/ums.api/Ums.Infrastructure/Configuration/CadenaDeRedis.cs new file mode 100644 index 00000000..d8de3d01 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Configuration/CadenaDeRedis.cs @@ -0,0 +1,43 @@ +namespace Ums.Infrastructure.Configuration; + +/// +/// La cadena de conexión a Redis, en la forma que entiende StackExchange.Redis. +/// +/// POR QUÉ EXISTE. El chart de Helm declara `REDIS_CONNECTION` como URI —`redis://ums-redis:6379`, +/// que es la convención de casi todo el mundo— y ese valor se pasaba TAL CUAL a +/// `ConnectionMultiplexer.Connect`, que no entiende el esquema: interpreta `redis` como host y +/// acaba componiendo `redis://ums-redis:6379:6379`, con el puerto duplicado. El síntoma es una +/// `RedisConnectionException` que acusa a Redis —vivo y sano— en vez de a la notación del valor. +/// +/// Medido el 2026-08-02 al desplegar una imagen actual en el clúster: el proceso NO arrancaba. El +/// defecto llevaba latente desde que se escribió el camino de código, porque el clúster corría una +/// imagen anterior y nunca se ejecutaba. +/// +/// VIVE AQUÍ, y no junto a uno de sus consumidores, porque son DOS: el registro de caché e +/// invalidación (`DependencyInjection`) y la protección de datos (`AuthenticationExtensions`). +/// Normalizar en uno solo es lo que ya pasó: el arranque siguió fallando desde el otro, con el +/// mismo mensaje y treinta minutos de diagnóstico por delante. +/// +public static class CadenaDeRedis +{ + /// + /// Quita el esquema de una URI `redis://` o `rediss://` y deja intacto lo demás — puerto y + /// cualquier cadena de opciones que venga detrás—, porque recortar de más cambiaría la + /// conexión en vez de solo su notación. Un valor ya en forma `host:puerto` se devuelve igual. + /// + public static string? Normalizar(string? valor) + { + if (string.IsNullOrWhiteSpace(valor)) return valor; + + var limpio = valor.Trim(); + foreach (var esquema in new[] { "redis://", "rediss://" }) + { + if (limpio.StartsWith(esquema, StringComparison.OrdinalIgnoreCase)) + { + return limpio[esquema.Length..]; + } + } + + return limpio; + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Configuration/ConfigurationLoader.cs b/src/apps/ums.api/Ums.Infrastructure/Configuration/ConfigurationLoader.cs index 884c4e17..749dabdc 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Configuration/ConfigurationLoader.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Configuration/ConfigurationLoader.cs @@ -27,9 +27,8 @@ public static class ConfigurationLoaderExtensions { public static IServiceCollection AddConfigurationProvider(this IServiceCollection services) { - // TODO(TD-003): Swap InMemoryConfigurationCache for a Redis-backed IConfigurationCache - // when distributed cache infrastructure is available. - services.AddSingleton(); + // El caché se inyecta dinámicamente en DependencyInjection.cs + // (RedisConfigurationCache si hay cadena de conexión, sino InMemoryConfigurationCache). services.AddSingleton(); services.AddHostedService(); return services; diff --git a/src/apps/ums.api/Ums.Infrastructure/Configuration/ConfigurationProvider.cs b/src/apps/ums.api/Ums.Infrastructure/Configuration/ConfigurationProvider.cs index 5dbbc42f..fe52612f 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Configuration/ConfigurationProvider.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Configuration/ConfigurationProvider.cs @@ -50,6 +50,13 @@ public async Task LoadAsync(CancellationToken cancellationToken = default) .Where(c => c.Scope.Id == 1 && c.Props.Status.Id == 2) .Select(DecryptIfNeeded)); + // Suite (scope 4) and Module (scope 5) configs are cross-tenant by nature and + // typically carry no TenantId. The per-tenant load below only reaches configs that + // belong to a tenant, so these tenant-less scoped configs would otherwise never be + // bucketed and would silently drop out of precedence resolution (G-048). Bucket them + // first so any tenant-specific override can still win afterwards. + BucketTenantlessScopedConfigs(allConfigs.Where(c => c.Props.TenantId is null)); + // Populate per-tenant entries — scope 2 = Tenant, 4 = Suite, 5 = Module (ConfigurationScope). var tenantIds = allConfigs .Where(c => c.Props.TenantId is not null) @@ -189,7 +196,18 @@ public void Set(string code, string value, Guid? tenantId = null) ConfigurationChanged?.Invoke(this, new ConfigurationChangedEventArgs(code, tenantId, oldValue, value)); } - public void Dispose() => _cache.InvalidateAll(); + /// + /// No invalida nada. La caché vive y muere con el proceso: vaciar sus diccionarios al + /// apagar no libera nada que el recolector no vaya a liberar igual. + /// + /// Antes llamaba a InvalidateAll(), y con la caché distribuida activa eso publicaba + /// un aviso de invalidación total: CADA apagado de pod en un despliegue progresivo forzaba + /// una recarga completa de configuración en todos los pods restantes, justo cuando el resto + /// del clúster absorbe el tráfico del que se va (G-170). + /// + public void Dispose() + { + } // ── Private helpers ─────────────────────────────────────────────────────── @@ -217,6 +235,28 @@ private void BucketTenantConfigs(Guid tenantId, IReadOnlyList + /// Buckets Suite (scope 4) and Module (scope 5) configs that carry no TenantId. + /// These cross-tenant overrides are otherwise never populated by the per-tenant load, + /// which would silently drop them from precedence resolution (G-048). Only Published + /// entries participate (BR-1); encrypted values are decrypted to plaintext. + /// + private void BucketTenantlessScopedConfigs(IEnumerable configs) + { + var published = configs + .Where(c => c.Props.Status.Id == 2) + .Select(DecryptIfNeeded) + .ToList(); + + foreach (var grp in published.Where(c => c.Scope.Id == 4 && c.Props.SystemSuiteId is not null) + .GroupBy(c => c.Props.SystemSuiteId!.GetValue())) + _cache.PopulateSuite(grp.Key, grp); + + foreach (var grp in published.Where(c => c.Scope.Id == 5 && c.Props.ModuleId is not null) + .GroupBy(c => c.Props.ModuleId!.GetValue())) + _cache.PopulateModule(grp.Key, grp); + } + /// /// Returns a copy of the aggregate with the value decrypted when IsEncrypted=true. /// Used during cache population so the runtime resolver always works with plaintext. diff --git a/src/apps/ums.api/Ums.Infrastructure/Configuration/FeatureFlagEvaluator.cs b/src/apps/ums.api/Ums.Infrastructure/Configuration/FeatureFlagEvaluator.cs index 94ef7594..c22fdff6 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Configuration/FeatureFlagEvaluator.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Configuration/FeatureFlagEvaluator.cs @@ -6,9 +6,35 @@ namespace Ums.Infrastructure.Configuration; public sealed class FeatureFlagEvaluator : IFeatureFlagEvaluator { public FlagEvaluationResult Evaluate(FeatureFlag flag, EvaluationContext context) + { + // Fail-closed lifecycle (G-048): only an Active flag can resolve as enabled. + // A non-Active flag (Inactive/Archived) always evaluates as disabled. + if (flag.Status != FlagStatus.Active) + return new FlagEvaluationResult(false, null, $"Flag is not active (status: {flag.Status.Name})"); + + // All targeting criteria must match before any rollout is considered. + var criteriaFailure = EvaluateCriteria(flag, context); + if (criteriaFailure is not null) + return criteriaFailure; + + // Percentage rollout (G-048): FlagType.Percentage honours RolloutPercentage + // through deterministic per-subject bucketing. + if (flag.FlagType == FlagType.Percentage) + return EvaluatePercentage(flag, context); + + return flag.Criteria.Any() + ? new FlagEvaluationResult(true, null, "All criteria matched") + : new FlagEvaluationResult(true, null, "No restrictions — active for all"); + } + + /// + /// Evaluates targeting criteria. Returns a disabled result when a criteria group + /// fails to match; returns null when there are no criteria or all groups pass. + /// + private static FlagEvaluationResult? EvaluateCriteria(FeatureFlag flag, EvaluationContext context) { if (!flag.Criteria.Any()) - return new FlagEvaluationResult(true, null, "No restrictions — active for all"); + return null; var groups = flag.Criteria.GroupBy(c => c.CriteriaType); @@ -33,7 +59,55 @@ public FlagEvaluationResult Evaluate(FeatureFlag flag, EvaluationContext context return new FlagEvaluationResult(false, group.Key, $"No match for CriteriaType {group.Key}"); } - return new FlagEvaluationResult(true, null, "All criteria matched"); + return null; + } + + /// + /// Applies the percentage rollout for flags. + /// Uses a stable per-subject bucket so a given subject is consistently in or out. + /// Fail-closed: when no identifying context is available the flag is disabled. + /// + private static FlagEvaluationResult EvaluatePercentage(FeatureFlag flag, EvaluationContext context) + { + var percentage = flag.RolloutPercentage ?? 0; + + if (percentage <= 0) + return new FlagEvaluationResult(false, null, "Rollout percentage is 0"); + if (percentage >= 100) + return new FlagEvaluationResult(true, null, "Rollout percentage is 100"); + + var subject = context.ProfileId?.ToString() + ?? context.TenantId?.ToString() + ?? context.BranchId?.ToString() + ?? context.RoleCode + ?? context.Environment; + + if (string.IsNullOrEmpty(subject)) + return new FlagEvaluationResult(false, null, "Percentage rollout requires identifying context (fail-closed)"); + + var bucket = ComputeBucket($"{flag.FlagCode}:{subject}"); + return bucket < percentage + ? new FlagEvaluationResult(true, null, $"In rollout: bucket {bucket} < {percentage}%") + : new FlagEvaluationResult(false, null, $"Out of rollout: bucket {bucket} >= {percentage}%"); + } + + /// + /// Deterministic 0..99 bucket via FNV-1a — independent of the process hash seed, + /// so the same subject always lands in the same bucket across runs and nodes. + /// + private static int ComputeBucket(string key) + { + const uint offsetBasis = 2166136261; + const uint prime = 16777619; + + var hash = offsetBasis; + foreach (var b in System.Text.Encoding.UTF8.GetBytes(key)) + { + hash ^= b; + hash *= prime; + } + + return (int)(hash % 100); } private static string? GetContextValue(EvaluationContext context, string criteriaType) => @@ -71,7 +145,7 @@ private static bool EvaluateDateRange(string contextValue, string criteriaValue) { var range = JsonSerializer.Deserialize(criteriaValue); if (range is null) return false; - var current = DateTime.Parse(contextValue, null, System.Globalization.DateTimeStyles.RoundtripKind); + var current = DateTime.Parse(contextValue, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.RoundtripKind); return current >= range.From && current <= range.To; } catch { return false; } diff --git a/src/apps/ums.api/Ums.Infrastructure/Configuration/IdpResolution/IdpConfigurationResolver.cs b/src/apps/ums.api/Ums.Infrastructure/Configuration/IdpResolution/IdpConfigurationResolver.cs index 7c799e32..b50c17f8 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Configuration/IdpResolution/IdpConfigurationResolver.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Configuration/IdpResolution/IdpConfigurationResolver.cs @@ -27,27 +27,16 @@ public async Task> ResolveAsync( { var configurations = await _repository.GetByTenantIdAsync(tenantId, cancellationToken); - var candidates = configurations - .Where(configuration => configuration.Status == IdpConfigStatus.Active) - .Where(configuration => !systemSuiteId.HasValue || configuration.SystemSuiteId.GetValue() == systemSuiteId.Value) - .Where(configuration => string.IsNullOrWhiteSpace(providerType) || string.Equals(configuration.ProviderType.Name, providerType, StringComparison.OrdinalIgnoreCase)) - .ToList(); + // La regla de selección de FR-042 es única y vive en el dominio; el motor de consulta OIDC + // y la resolución del login la comparten para aplicar exactamente el mismo criterio. + var selection = IdpConfigurationSelector.Select(configurations, systemSuiteId, emailDomain, providerType); - if (candidates.Count == 0) + if (selection is null) { return Result.Failure("Active IdP configuration not found."); } - var normalizedDomain = NormalizeDomain(emailDomain); - var domainMatchedCandidates = string.IsNullOrWhiteSpace(normalizedDomain) - ? [] - : candidates.Where(configuration => MatchesDomain(configuration, normalizedDomain)).ToList(); - - var domainMatched = domainMatchedCandidates.Count > 0; - var selected = (domainMatched ? domainMatchedCandidates : candidates) - .OrderBy(configuration => configuration.ResolutionPriority) - .ThenByDescending(configuration => configuration.Version) - .First(); + var selected = selection.Value.Configuration; var strategy = _factory.Create( new IdpResolutionStrategyCriteria(selected.ProviderType.Name)) @@ -58,21 +47,6 @@ public async Task> ResolveAsync( return Result.Failure($"No IdP resolution strategy is registered for provider type '{selected.ProviderType.Name}'."); } - return Result.Success(strategy.Resolve(new IdpResolutionContext(selected, domainMatched))); - } - - private static bool MatchesDomain(IdpConfiguration configuration, string normalizedDomain) - => configuration.Props.DomainHints.Any(hint => string.Equals(hint.Trim(), normalizedDomain, StringComparison.OrdinalIgnoreCase)); - - private static string? NormalizeDomain(string? emailDomain) - { - if (string.IsNullOrWhiteSpace(emailDomain)) - { - return null; - } - - var trimmed = emailDomain.Trim(); - var atIndex = trimmed.IndexOf('@'); - return atIndex >= 0 ? trimmed[(atIndex + 1)..].Trim().ToLowerInvariant() : trimmed.ToLowerInvariant(); + return Result.Success(strategy.Resolve(new IdpResolutionContext(selected, selection.Value.DomainMatched))); } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Configuration/InMemoryConfigurationCache.cs b/src/apps/ums.api/Ums.Infrastructure/Configuration/InMemoryConfigurationCache.cs index 0f0ed6ca..6ab4f82a 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Configuration/InMemoryConfigurationCache.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Configuration/InMemoryConfigurationCache.cs @@ -7,8 +7,9 @@ namespace Ums.Infrastructure.Configuration; /// /// In-process, thread-safe implementation of . /// -/// TODO(TD-003): Replace this phase-1 in-memory cache with a Redis-backed implementation +/// TODO(G-069): Replace this phase-1 in-memory cache with a Redis-backed implementation /// that wraps IDistributedCache when distributed cache infrastructure is available. +/// No es coherente entre réplicas al escalar horizontalmente. Ver GAPS.md G-069. /// /// Resolution order (BR-1): Module → Suite → Tenant → Global (most specific wins). /// Each scope has its own dictionary keyed by the scope's natural ID: diff --git a/src/apps/ums.api/Ums.Infrastructure/Configuration/ParameterResolutionService.cs b/src/apps/ums.api/Ums.Infrastructure/Configuration/ParameterResolutionService.cs index 8d5cb15d..85d94711 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Configuration/ParameterResolutionService.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Configuration/ParameterResolutionService.cs @@ -27,14 +27,21 @@ public interface IParameterResolutionService public sealed class ParameterResolutionService : IParameterResolutionService { private readonly UmsPlatformDbContext _dbContext; - private readonly ITenantContext _tenantContext; - public ParameterResolutionService(UmsPlatformDbContext dbContext, ITenantContext tenantContext) + public ParameterResolutionService(UmsPlatformDbContext dbContext) { _dbContext = dbContext; - _tenantContext = tenantContext; } + /// + /// Estado terminal de borrado lógico. Todas las lecturas de esta clase lo excluyen, y no es un + /// detalle cosmético: desde que el índice único pasó a ser PARCIAL, una definición puede tener + /// una fila de valor viva y N lápidas. Indexar por `ParameterDefinitionId` sin este filtro haría + /// estallar `ToDictionaryAsync` con clave duplicada, y `FirstOrDefault` resolvería un valor + /// retirado hace meses. El filtro es lo que garantiza que la resolución devuelve LA VIVA. + /// + private static readonly int DeletedStatusId = ConfigStatus.Deleted.Id; + public async Task> GetGlobalParametersAsync(CancellationToken cancellationToken = default) { var definitions = await _dbContext.ParameterDefinitions @@ -45,6 +52,7 @@ public async Task> GetGlobalParametersAsync(Can var globalValues = await _dbContext.ParameterGlobalValues .IgnoreQueryFilters() + .Where(v => v.StatusId != DeletedStatusId) .ToDictionaryAsync(v => v.ParameterDefinitionId, cancellationToken); var results = new List(); @@ -80,22 +88,25 @@ public async Task> GetTenantParametersAsync(Gui var globalValues = await _dbContext.ParameterGlobalValues .IgnoreQueryFilters() + .Where(v => v.StatusId != DeletedStatusId) .ToDictionaryAsync(v => v.ParameterDefinitionId, cancellationToken); var tenantValues = await _dbContext.ParameterTenantValues .IgnoreQueryFilters() - .Where(v => v.TenantId == tenantId) + .Where(v => v.TenantId == tenantId && v.StatusId != DeletedStatusId) .ToDictionaryAsync(v => v.ParameterDefinitionId, cancellationToken); var results = new List(); foreach (var def in definitions) { var hasTenantOverride = tenantValues.TryGetValue(def.Id, out var tenantValue); - var effectiveValue = hasTenantOverride - ? tenantValue.OverrideValue - : def.ScopeId == 3 && globalValues.TryGetValue(def.Id, out var globalValue) - ? globalValue.EffectiveValue - : def.DefaultValue; + string effectiveValue; + if (hasTenantOverride) + effectiveValue = tenantValue.OverrideValue; + else if (def.ScopeId == 3 && globalValues.TryGetValue(def.Id, out var globalValue)) + effectiveValue = globalValue.EffectiveValue; + else + effectiveValue = def.DefaultValue; string status; if (hasTenantOverride) @@ -133,7 +144,11 @@ public async Task GetEffectiveValueAsync(Guid? tenantId, string code, Ca { var tenantValue = await _dbContext.ParameterTenantValues .IgnoreQueryFilters() - .FirstOrDefaultAsync(v => v.TenantId == tenantId.Value && v.ParameterDefinitionId == definition.Id, cancellationToken); + .Where(v => v.TenantId == tenantId.Value + && v.ParameterDefinitionId == definition.Id + && v.StatusId != DeletedStatusId) + .OrderByDescending(v => v.CreatedAtUtc) + .FirstOrDefaultAsync(cancellationToken); if (tenantValue is not null) return tenantValue.OverrideValue; @@ -143,7 +158,9 @@ public async Task GetEffectiveValueAsync(Guid? tenantId, string code, Ca { var globalValue = await _dbContext.ParameterGlobalValues .IgnoreQueryFilters() - .FirstOrDefaultAsync(v => v.ParameterDefinitionId == definition.Id, cancellationToken); + .Where(v => v.ParameterDefinitionId == definition.Id && v.StatusId != DeletedStatusId) + .OrderByDescending(v => v.CreatedAtUtc) + .FirstOrDefaultAsync(cancellationToken); if (globalValue is not null) return globalValue.EffectiveValue; @@ -151,4 +168,4 @@ public async Task GetEffectiveValueAsync(Guid? tenantId, string code, Ca return definition.DefaultValue; } -} \ No newline at end of file +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Configuration/RedisConfigurationCache.cs b/src/apps/ums.api/Ums.Infrastructure/Configuration/RedisConfigurationCache.cs new file mode 100644 index 00000000..f70638ac --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Configuration/RedisConfigurationCache.cs @@ -0,0 +1,291 @@ +namespace Ums.Infrastructure.Configuration; + +using System.Collections.Concurrent; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using StackExchange.Redis; +using Ums.Application.Configuration.Services; +using AppConfigurationAggregate = Ums.Domain.Configuration.AppConfiguration.AppConfiguration; + +/// +/// Hybrid implementation of backed by Redis Pub/Sub (G-069). +/// +/// Provides lightning-fast synchronous reads using local memory dictionaries (0 network latency), +/// while ensuring cross-pod consistency by subscribing to Redis Pub/Sub invalidation channels. +/// When a configuration is populated or invalidated, an event is broadcasted so other pods +/// drop their local cache and reload from the database. +/// +public sealed class RedisConfigurationCache : IConfigurationCache, IDisposable +{ + private readonly ConcurrentDictionary _global + = new(StringComparer.OrdinalIgnoreCase); + + private readonly ConcurrentDictionary> _tenant = new(); + private readonly ConcurrentDictionary> _suite = new(); + private readonly ConcurrentDictionary> _module = new(); + + private readonly IConnectionMultiplexer _redis; + private readonly ILogger _logger; + private readonly IServiceScopeFactory _scopeFactory; + private ISubscriber? _subscriber; + + private const string ChannelPrefix = "ums:config:invalidate"; + + /// + /// Identidad de ESTA instancia. Viaja en cada mensaje publicado para que un pod + /// descarte sus propios avisos: la suscripción es por patrón, así que sin esto cada + /// pod se escucha a sí mismo (G-170). + /// + private static readonly string OriginId = Guid.NewGuid().ToString("N"); + + /// + /// Marca «estoy aplicando una invalidación que llegó de otro pod». Mientras está activa, + /// no publica. + /// + /// Es la pieza que rompe el ciclo: el manejador de la suscripción llama a + /// ReloadAsync/ReloadTenantAsync, que a su vez invocan + /// /. Sin esta marca, aplicar un + /// aviso genera otro aviso, y con N réplicas el tráfico se autoamplifica hasta la tormenta + /// (G-170). y no un campo: el manejador es asíncrono y la marca + /// debe seguir al flujo lógico, no al hilo. + /// + private static readonly AsyncLocal AplicandoRemoto = new(); + + public RedisConfigurationCache( + IConnectionMultiplexer redis, + ILogger logger, + IServiceScopeFactory scopeFactory) + { + _redis = redis; + _logger = logger; + _scopeFactory = scopeFactory; + + SubscribeToInvalidations(); + } + + private void SubscribeToInvalidations() + { + _subscriber = _redis.GetSubscriber(); + _subscriber.Subscribe(new RedisChannel($"{ChannelPrefix}:*", RedisChannel.PatternMode.Pattern), async (channel, message) => + { + try + { + var channelName = (string)channel!; + var (origen, payload) = DesempaquetarMensaje((string)message!); + + // Un pod no se recarga por su propio aviso. + if (origen == OriginId) + { + return; + } + + using var scope = _scopeFactory.CreateScope(); + var provider = scope.ServiceProvider.GetRequiredService(); + + AplicandoRemoto.Value = true; + try + { + if (channelName.EndsWith(":all")) + { + _logger.LogInformation("Received Redis invalidation for ALL configurations. Reloading..."); + await provider.ReloadAsync(); + } + else if (channelName.EndsWith(":tenant") && Guid.TryParse(payload, out var tenantId)) + { + _logger.LogInformation("Received Redis invalidation for Tenant {TenantId}. Reloading...", tenantId); + await provider.ReloadTenantAsync(tenantId); + } + } + finally + { + AplicandoRemoto.Value = false; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing Redis configuration invalidation message."); + } + }); + } + + private void PublishInvalidation(string suffix, string payload = "") + { + // Aplicar un aviso ajeno NO genera un aviso nuevo (G-170). + if (AplicandoRemoto.Value) + { + return; + } + + try + { + // Fire and forget to avoid blocking the caller + _ = _subscriber?.PublishAsync( + new RedisChannel($"{ChannelPrefix}:{suffix}", RedisChannel.PatternMode.Literal), + $"{OriginId}|{payload}"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to publish Redis invalidation for {Suffix}", suffix); + } + } + + /// + /// Separa `origen|carga`. Tolera el formato antiguo sin origen para que una actualización + /// progresiva —pods viejos y nuevos conviviendo— no pierda invalidaciones. + /// + private static (string Origen, string Payload) DesempaquetarMensaje(string mensaje) + { + var corte = mensaje.IndexOf('|'); + return corte < 0 + ? (string.Empty, mensaje) + : (mensaje[..corte], mensaje[(corte + 1)..]); + } + + // ── Read (Local Memory) ────────────────────────────────────────────────── + + public AppConfigurationAggregate? GetGlobal(string code) + { + _global.TryGetValue(code, out var config); + return config; + } + + public AppConfigurationAggregate? GetForTenant(Guid tenantId, string code) + => TryGet(_tenant, tenantId, code); + + public AppConfigurationAggregate? GetForSuite(Guid suiteId, string code) + => TryGet(_suite, suiteId, code); + + public AppConfigurationAggregate? GetForModule(Guid moduleId, string code) + => TryGet(_module, moduleId, code); + + public AppConfigurationAggregate? GetWithPrecedence( + string code, + Guid? tenantId, + Guid? suiteId = null, + Guid? moduleId = null) + { + if (moduleId.HasValue) + { + var moduleValue = TryGet(_module, moduleId.Value, code); + if (moduleValue is not null) return moduleValue; + } + + if (suiteId.HasValue) + { + var suiteValue = TryGet(_suite, suiteId.Value, code); + if (suiteValue is not null) return suiteValue; + } + + if (tenantId.HasValue) + { + var tenantValue = TryGet(_tenant, tenantId.Value, code); + if (tenantValue is not null) return tenantValue; + } + + _global.TryGetValue(code, out var global); + return global; + } + + public IReadOnlyList GetAllGlobal() + => _global.Values.ToList(); + + public IReadOnlyList GetAllForTenant(Guid tenantId) + => _tenant.TryGetValue(tenantId, out var cache) ? cache.Values.ToList() : []; + + public bool HasTenantOverride(string code, Guid tenantId) + => TryGet(_tenant, tenantId, code) is not null; + + // ── Write (Local Memory + PubSub) ──────────────────────────────────────── + + public void PopulateGlobal(IEnumerable configs) + { + foreach (var config in configs) + _global[config.Code.GetValue()] = config; + } + + public void PopulateTenant(Guid tenantId, IEnumerable configs) + => PopulateScope(_tenant, tenantId, configs); + + public void PopulateSuite(Guid suiteId, IEnumerable configs) + => PopulateScope(_suite, suiteId, configs); + + public void PopulateModule(Guid moduleId, IEnumerable configs) + => PopulateScope(_module, moduleId, configs); + + /// + /// Invalida la configuración de un inquilino y AVISA SIEMPRE a los demás pods. + /// + /// El aviso estaba condicionado a que este pod tuviera algo que borrar + /// (if (_tenant.TryRemove(...))), y esa condición es justo la que no se cumple en el + /// caso que importa: un inquilino dado de alta DESPUÉS del arranque no está en la memoria de + /// este pod, así que no había nada que quitar y el aviso nunca salía. Los demás pods no se + /// enteraban de su configuración nunca —hasta reiniciar—, no con un desfase. + /// + /// Medido en vivo el 2026-08-04 con dos réplicas: al publicar + /// AUTH_REFRESH_TOKEN_ENABLED para un inquilino recién creado, el pod que lo publicó + /// emitía refresh token y el otro no; con un inquilino que ya existía al arrancar, ambos + /// coincidían al instante. La diferencia era esta línea. + /// + /// El aviso es un hecho del mundo —«la configuración de este inquilino cambió»—, no una + /// nota sobre el estado local de quien lo emite. Condicionarlo a la memoria propia era mezclar + /// las dos cosas. El coste de publicar de más es un mensaje por escritura de configuración; el + /// de publicar de menos era servir permisos obsoletos en la mitad del clúster. + /// + /// No hay riesgo de tormenta: ya calla mientras se + /// aplica un aviso ajeno (G-170), así que un pod que recarga por un mensaje remoto no genera + /// otro. + /// + public void InvalidateTenant(Guid tenantId) + { + _tenant.TryRemove(tenantId, out _); + PublishInvalidation("tenant", tenantId.ToString()); + } + + // Suite y módulo no publican a propósito: sus únicos llamadores en producción están DENTRO + // de ConfigurationProvider.ReloadTenantAsync, que ya viaja como invalidación de inquilino y + // repuebla ambos ámbitos en el pod remoto. Publicar por separado exigiría un manejador de + // recarga por suite —que hoy no existe— y dejaría el bucket vacío resolviendo por herencia + // al valor del inquilino: un valor incorrecto, no una ausencia. Si algún día se invalida una + // suite fuera de esa ruta, hay que añadir el canal Y su recarga, no solo el canal. + public void InvalidateSuite(Guid suiteId) => _suite.TryRemove(suiteId, out _); + + public void InvalidateModule(Guid moduleId) => _module.TryRemove(moduleId, out _); + + public void InvalidateAll() + { + _global.Clear(); + _tenant.Clear(); + _suite.Clear(); + _module.Clear(); + PublishInvalidation("all"); + } + + public void Dispose() + { + if (_subscriber != null) + { + _subscriber.UnsubscribeAll(); + } + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private static AppConfigurationAggregate? TryGet( + ConcurrentDictionary> store, + Guid key, + string code) + => store.TryGetValue(key, out var inner) && inner.TryGetValue(code, out var config) + ? config + : null; + + private static void PopulateScope( + ConcurrentDictionary> store, + Guid key, + IEnumerable configs) + { + var bucket = store.GetOrAdd(key, _ => new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase)); + foreach (var config in configs) + bucket[config.Code.GetValue()] = config; + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/DependencyInjection.Factories.cs b/src/apps/ums.api/Ums.Infrastructure/DependencyInjection.Factories.cs index d02cbc4f..c099af8b 100644 --- a/src/apps/ums.api/Ums.Infrastructure/DependencyInjection.Factories.cs +++ b/src/apps/ums.api/Ums.Infrastructure/DependencyInjection.Factories.cs @@ -71,6 +71,37 @@ public static IServiceCollection AddUmsFactories(this IServiceCollection service } }); + + // El shell resuelve la IMPLEMENTACIÓN del contenedor cuando la factoría elige una + // rama, así que cada tipo concreto tiene que estar registrado por sí mismo, no solo + // detrás de su interfaz. `BeyondNetCode.Shell.Factory.Installer` no lo hace por su + // cuenta —a diferencia del shell de la plataforma de origen, que sí registraba el + // concreto dentro de su propio AddTransient—, y sin esto la resolución falla en + // tiempo de ejecución con «No service for type ... has been registered». + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddSingleton(); return services; diff --git a/src/apps/ums.api/Ums.Infrastructure/DependencyInjection.cs b/src/apps/ums.api/Ums.Infrastructure/DependencyInjection.cs index c95b5471..13f4382e 100644 --- a/src/apps/ums.api/Ums.Infrastructure/DependencyInjection.cs +++ b/src/apps/ums.api/Ums.Infrastructure/DependencyInjection.cs @@ -1,5 +1,7 @@ namespace Ums.Infrastructure; +#pragma warning disable S125 + using MediatR; using MassTransit; using System.Reflection; @@ -16,6 +18,8 @@ namespace Ums.Infrastructure; using Ums.Domain.Configuration; using Ums.Domain.Identity; using Ums.Domain.Identity.Repositories.TenantParameter; +using Ums.Domain.IGA; +using Ums.Infrastructure.Persistence.Iga; using Ums.Infrastructure.Persistence.Audit; using Ums.Infrastructure.Persistence.Approvals; using Ums.Infrastructure.Persistence.Authorization; @@ -57,7 +61,7 @@ public static IServiceCollection AddInfrastructure( .Validate( options => options.Provider == PersistenceProvider.InMemory || !string.IsNullOrWhiteSpace(configuration.GetConnectionString("DefaultConnection")), - "ConnectionStrings:DefaultConnection is required when Persistence.Provider is Sqlite or PostgreSql.") + "ConnectionStrings:DefaultConnection is required when Persistence.Provider is PostgreSql.") .ValidateOnStart(); services.AddHttpContextAccessor(); @@ -65,28 +69,51 @@ public static IServiceCollection AddInfrastructure( services.AddScoped(); services.AddScoped(); services.AddSingleton(); - services.AddScoped(); - services.AddScoped(sp => sp.GetRequiredService()); - services.AddScoped(sp => sp.GetRequiredService()); + services.AddScoped(); + services.AddScoped(sp => sp.GetRequiredService()); + + // Trazabilidad funcional (ADR-0096; ADR-UMS-084/085): localizador legible + transacción + // funcional con narrativa y desenlace. Concreta e interfaz comparten la misma instancia + // con alcance de petición para que middleware y handlers narren la misma transacción. + services.AddScoped(); + services.AddScoped(sp => + sp.GetRequiredService()); + services.AddScoped(); + services.AddScoped(sp => + sp.GetRequiredService()); services.AddSingleton(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddSingleton(Channel.CreateUnbounded(new UnboundedChannelOptions - { - SingleReader = true, - SingleWriter = false, - AllowSynchronousContinuations = false, - })); - services.AddSingleton(); + // G-040: la auditoría automática se encola por el Transactional Outbox de MassTransit + // (entrega POST-commit al AuditTrailPersistenceConsumer), no por un canal en memoria con + // descarte silencioso. Scoped porque publica con el IPublishEndpoint de la petición. + services.AddScoped(); + // G-066 / ADR-0098 D7: única vía de salida al bróker inter-sistema (eventos de integración + // explícitos). Los eventos de dominio se despachan en proceso (MediatR, post-commit). + services.AddScoped(); var isNotProduction = environment is null || !environment.IsProduction(); services.AddUmsFactories(isNotProduction); services.AddScoped(); services.AddScoped(); + // Política de refresh token resuelta desde la config jerárquica, fail-closed (ADR-UMS-091 / FR-015). + services.AddScoped(); + // Almacén de refresh tokens (solo hash) sobre UmsPlatformDbContext (ADR-UMS-091 / FR-015). + services.AddScoped(); + // Almacén de tokens de restablecimiento de contraseña (solo hash) — G-188. + services.AddScoped(); + // Nivelador del tiempo de respuesta de los flujos anónimos indistinguibles (G-188). + // Singleton: no tiene estado y su presupuesto es una constante del proceso. + services.AddSingleton(); // Auth Graph Engine services services.AddScoped(); services.AddScoped(); + // FR-042 (ADR-UMS-097 §2.3/§2.4, slice 2b): orquestador del fallback encadenado por indisponibilidad. + services.AddScoped(); services.AddScoped(); + + // --- Adaptador OIDC real (Authorization Code + PKCE) — ADR-UMS-094 / G-049 (slice 1) --- + // Puertos HTTP inyectables (token endpoint + JWKS): en producción hablan con el IdP; + // en unit tests se sustituyen por fakes con llaves/respuestas controladas. + services.AddHttpClient(); + services.AddHttpClient(); + // Endpoints OIDC leídos de la configuración del inquilino (IdpConfiguration), no hardcodeados. + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + // El adaptador lo instancia Shell.Factory (por estrategia); registrado también aquí + // para que sus dependencias resuelvan vía el proveedor de servicios. + services.AddScoped(); // Default serializer (JSON) — injected directly into CommandHandler services.AddTransient(); // OPS-01 / HARDENING-03: Token revocation store. - // When Redis:Connection is configured → use RedisTokenRevocationStore (all pods share state). - // Fallback → InMemoryTokenRevocationStore (fine for single-node / dev / tests). - var redisConnection = configuration["Redis:Connection"]; + // G-069: Redis-backed configuration cache (Pub/Sub). + // Con Redis configurado → estado e invalidaciones compartidos entre pods. + // Sin él → InMemory, correcto solo en nodo único / dev / pruebas. + // + // G-169: se acepta también `REDIS_CONNECTION` porque es la variable que inyecta el chart + // de Helm. ASP.NET solo mapea variables de entorno a claves jerárquicas con doble guion + // bajo (`Redis__Connection`), así que leer únicamente `Redis:Connection` hacía que en + // Kubernetes NUNCA se activara Redis: el despliegue creía tener caché distribuida y + // corría con las implementaciones en memoria, sin un solo aviso. + // Normalizado: el chart entrega una URI y `ConnectionMultiplexer` quiere `host:puerto`. + // Ver `CadenaDeRedis` — el puerto duplicado que tumbaba el arranque salía de aquí. + var redisConnection = Ums.Infrastructure.Configuration.CadenaDeRedis.Normalizar( + configuration["Redis:Connection"] ?? configuration["REDIS_CONNECTION"]); if (!string.IsNullOrWhiteSpace(redisConnection)) { services.AddStackExchangeRedisCache(options => @@ -116,18 +172,37 @@ public static IServiceCollection AddInfrastructure( options.Configuration = redisConnection; options.InstanceName = "ums:"; // Namespace prefix to avoid key collisions in shared Redis. }); + + // Required for Pub/Sub in RedisConfigurationCache + services.AddSingleton(sp => + StackExchange.Redis.ConnectionMultiplexer.Connect(redisConnection)); + services.AddSingleton(); + services.AddSingleton(); + // G-248: el cupo de peticiones se cuenta donde lo ven todas las réplicas. + services.AddSingleton(); } else { + // Respaldo en proceso: `IDistributedCache` debe resolver siempre porque la + // idempotencia depende de él (G-169). Sin Redis solo deduplica dentro del pod. + services.AddDistributedMemoryCache(); services.AddSingleton(); + services.AddSingleton(); + // Sin Redis el cupo vuelve a ser por proceso: correcto solo con UNA réplica, y el + // arranque ya lo declara en el log (G-248). + services.AddSingleton(); } + // G-247: las sesiones cerradas van sobre `IDistributedCache`, que aquí arriba ya quedó + // resuelto a Redis o a memoria del proceso. Una sola implementación: lo que cambia entre + // los dos modos es el alcance, no el código. + services.AddSingleton(); + // HARDENING-03: Register Infrastructure MediatR notification handlers (UserDeleted, UserBlocked → revoke tokens). // MediatR.AddApplication() only scans Ums.Application; Infrastructure handlers must be registered here. services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly)); services.AddHostedService(); - services.AddHostedService(); var persistence = configuration.GetSection(PersistenceOptions.SectionName).Get() ?? new(); @@ -175,8 +250,10 @@ void ConfigurePayload(IBusFactoryConfigurator cfg) => "the MMS tenant projection must not fall back to the platform DB or localhost."); services.AddDbContext(options => +#pragma warning disable S2068 // Cadena de conexión de FALLBACK local de desarrollo; la real llega por configuración (masterDataDb). No es un secreto de producción. options.UseNpgsql( masterDataDb ?? "Host=localhost;Port=5432;Database=ums;Username=postgres;Password=postgres")); +#pragma warning restore S2068 // GAP-001 / DS-08: migrate the projection store at startup. The platform context is // migrated in InitializeUmsPlatformAsync, but the isolated projection context had no @@ -200,6 +277,15 @@ void ConfigurePayload(IBusFactoryConfigurator cfg) => // Cross-service broker (kind/prod): receive MMS tenant events over RabbitMQ, // with the EF inbox for exactly-once-effective consumption (ADR-0033/ADR-0063). x.AddEntityFrameworkOutbox(o => o.UsePostgres()); + + // G-066 / ADR-0098 D4 / KB-TXN-001: transactional bus-outbox on the WRITE context so + // domain events are staged with the aggregate change set and delivered AFTER commit + // (no «phantom messages»). UseBusOutbox() routes IPublishEndpoint through the outbox. + x.AddEntityFrameworkOutbox(o => + { + o.UsePostgres(); + o.UseBusOutbox(); + }); x.UsingRabbitMq((context, cfg) => { cfg.Host(rabbitMqConnection); @@ -219,41 +305,19 @@ void ConfigurePayload(IBusFactoryConfigurator cfg) => }); // REC-04: Cross-aggregate transaction scope - if (persistence.Provider == PersistenceProvider.Sqlite || persistence.Provider == PersistenceProvider.PostgreSql) + if (persistence.Provider == PersistenceProvider.PostgreSql) services.AddScoped(); else services.AddSingleton(); - if (persistence.Provider == PersistenceProvider.Sqlite) - { - var connectionString = configuration.GetConnectionString("DefaultConnection") - ?? throw new InvalidOperationException("ConnectionStrings:DefaultConnection must be configured for SQLite persistence."); - - if (connectionString.Contains("Server=", StringComparison.OrdinalIgnoreCase)) - { - connectionString = "Data Source=umsdev.db"; - } - - services.AddScoped(); - services.AddScoped(); // FIX-08: auto-stamp audit columns - - services.AddDbContext((serviceProvider, options) => - { - options.UseSqlite(connectionString); - - options.AddInterceptors( - serviceProvider.GetRequiredService(), - serviceProvider.GetRequiredService()); - }); - } - - else if (persistence.Provider == PersistenceProvider.PostgreSql) + if (persistence.Provider == PersistenceProvider.PostgreSql) { var connectionString = configuration.GetConnectionString("DefaultConnection") ?? throw new InvalidOperationException("ConnectionStrings:DefaultConnection must be configured for PostgreSQL persistence."); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // G-081: rechaza UPDATE/DELETE de la traza de auditoría (no repudio) services.AddScoped(); // rotate bytea RowVersion on UPDATE (no rowversion type in PostgreSQL) services.AddResiliencePipeline("ums-postgres", pipelineBuilder => @@ -285,6 +349,7 @@ void ConfigurePayload(IBusFactoryConfigurator cfg) => options.AddInterceptors( serviceProvider.GetRequiredService(), serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService(), serviceProvider.GetRequiredService()); options.ConfigureWarnings(warnings => @@ -296,8 +361,7 @@ void ConfigurePayload(IBusFactoryConfigurator cfg) => options.UseNpgsql(connectionString, pgOptions => pgOptions.EnableRetryOnFailure(3))); } - if ((persistence.Provider == PersistenceProvider.Sqlite && persistence.UseSqliteIdentityStores) || - (persistence.Provider == PersistenceProvider.PostgreSql && persistence.UsePostgreSqlIdentityStores)) + if (persistence.Provider == PersistenceProvider.PostgreSql && persistence.UsePostgreSqlIdentityStores) { services.AddScoped(); services.AddScoped(); @@ -323,8 +387,7 @@ void ConfigurePayload(IBusFactoryConfigurator cfg) => services.AddSingleton(sp => sp.GetRequiredService()); } - if ((persistence.Provider == PersistenceProvider.Sqlite && persistence.UseSqliteAuthorizationStores) || - (persistence.Provider == PersistenceProvider.PostgreSql && persistence.UsePostgreSqlAuthorizationStores)) + if (persistence.Provider == PersistenceProvider.PostgreSql && persistence.UsePostgreSqlAuthorizationStores) { services.AddScoped(); services.AddScoped(); @@ -350,8 +413,7 @@ void ConfigurePayload(IBusFactoryConfigurator cfg) => services.AddSingleton(sp => sp.GetRequiredService()); } - if ((persistence.Provider == PersistenceProvider.Sqlite && persistence.UseSqliteConfigurationStores) || - (persistence.Provider == PersistenceProvider.PostgreSql && persistence.UsePostgreSqlConfigurationStores)) + if (persistence.Provider == PersistenceProvider.PostgreSql && persistence.UsePostgreSqlConfigurationStores) { services.AddScoped(); services.AddScoped(); @@ -377,7 +439,7 @@ void ConfigurePayload(IBusFactoryConfigurator cfg) => services.AddSingleton(sp => sp.GetRequiredService()); } - if (persistence.Provider == PersistenceProvider.Sqlite || persistence.Provider == PersistenceProvider.PostgreSql) + if (persistence.Provider == PersistenceProvider.PostgreSql) { services.AddScoped(); } @@ -387,8 +449,7 @@ void ConfigurePayload(IBusFactoryConfigurator cfg) => services.AddSingleton(sp => sp.GetRequiredService()); } - if ((persistence.Provider == PersistenceProvider.Sqlite && persistence.UseSqliteApprovalsStores) || - (persistence.Provider == PersistenceProvider.PostgreSql && persistence.UsePostgreSqlApprovalsStores)) + if (persistence.Provider == PersistenceProvider.PostgreSql && persistence.UsePostgreSqlApprovalsStores) { services.AddScoped(); services.AddScoped(); @@ -418,6 +479,22 @@ void ConfigurePayload(IBusFactoryConfigurator cfg) => services.AddSingleton(sp => sp.GetRequiredService()); } + // IGA (ADR-UMS-093): repositorios del contexto de gobierno de identidad. Bajo PostgreSQL usan + // UmsPlatformDbContext (scoped); en dev/tests la variante en memoria (singleton). + if (persistence.Provider == PersistenceProvider.PostgreSql && persistence.UsePostgreSqlIgaStores) + { + services.AddScoped(); + services.AddScoped(); + } + else + { + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + } + // ── AOP: DispatchProxy aspect-oriented infrastructure ────────────────────── // AddAop() registers the built-in aspects (LoggerAspect, AdviceAspect, RetryAspect), // the PointCut, AspectExecutor and IFactory / IFactory singletons. @@ -498,21 +575,16 @@ public static IServiceCollection AddInfrastructureHealthChecks( var builder = services.AddHealthChecks(); - if (persistence.Provider == PersistenceProvider.Sqlite || persistence.Provider == PersistenceProvider.PostgreSql) + if (persistence.Provider == PersistenceProvider.PostgreSql) { - if (persistence.Provider == PersistenceProvider.PostgreSql) + var connectionString = configuration.GetConnectionString("DefaultConnection"); + if (!string.IsNullOrWhiteSpace(connectionString)) { - var connectionString = configuration.GetConnectionString("DefaultConnection"); - if (!string.IsNullOrWhiteSpace(connectionString)) - { - builder.AddNpgSql( - connectionString, - name: "postgresql", - tags: ["ready", "db"]); - } + builder.AddNpgSql( + connectionString, + name: "postgresql", + tags: ["ready", "db"]); } - - } return services; diff --git a/src/apps/ums.api/Ums.Infrastructure/GlobalUsings.cs b/src/apps/ums.api/Ums.Infrastructure/GlobalUsings.cs index d7291448..4467a700 100644 --- a/src/apps/ums.api/Ums.Infrastructure/GlobalUsings.cs +++ b/src/apps/ums.api/Ums.Infrastructure/GlobalUsings.cs @@ -17,4 +17,4 @@ global using Ums.Application.Common.Aop; global using Ums.Application.Common.Interfaces; global using Ums.Infrastructure.Aop; -global using BeyondNetCode.Shell.Aop.Aspects.Logger.Serilog; +global using Ums.Infrastructure.Observability; diff --git a/src/apps/ums.api/Ums.Infrastructure/Hosting/AuditTrailPersistenceBackgroundService.cs b/src/apps/ums.api/Ums.Infrastructure/Hosting/AuditTrailPersistenceBackgroundService.cs deleted file mode 100644 index eb700f4f..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Hosting/AuditTrailPersistenceBackgroundService.cs +++ /dev/null @@ -1,84 +0,0 @@ -using Ums.Domain.Audit.AuditRecord; - -namespace Ums.Infrastructure.Hosting; - -internal sealed class AuditTrailPersistenceBackgroundService( - Channel auditTrailChannel, - IServiceScopeFactory scopeFactory, - ILogger logger) : BackgroundService -{ - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - await foreach (var entry in auditTrailChannel.Reader.ReadAllAsync(stoppingToken)) - { - try - { - await PersistAsync(entry, stoppingToken); - } - catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) - { - break; - } - catch (Exception ex) - { - logger.LogWarning(ex, - "AuditTrailPersistence: failed to persist audit entry {EventType} for {AffectedEntityType}/{AffectedEntityId}.", - entry.EventType, - entry.AffectedEntityType, - entry.AffectedEntityId); - } - } - } - - private async Task PersistAsync(AuditTrailEntry entry, CancellationToken cancellationToken) - { - await using var scope = scopeFactory.CreateAsyncScope(); - var repository = scope.ServiceProvider.GetRequiredService(); - - var subjectType = ResolveSubjectType(entry.SubjectType); - var auditResult = ResolveAuditResult(entry.AuditResult); - - var auditRecordResult = AuditRecord.Record( - entry.WhoActed, - subjectType, - entry.WhatChanged, - entry.EventType, - auditResult, - entry.AffectedEntityId, - entry.AffectedEntityType, - entry.RootTenantId, - entry.Metadata); - - if (auditRecordResult.IsFailure) - { - logger.LogWarning( - "AuditTrailPersistence: discarded invalid audit entry {EventType} for {AffectedEntityType}/{AffectedEntityId}: {Error}", - entry.EventType, - entry.AffectedEntityType, - entry.AffectedEntityId, - auditRecordResult.Error); - return; - } - - await repository.AppendAsync(auditRecordResult.Value, cancellationToken); - await repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); - } - - private static SubjectType ResolveSubjectType(string value) - => value switch - { - nameof(SubjectType.User) => SubjectType.User, - nameof(SubjectType.Admin) => SubjectType.Admin, - nameof(SubjectType.System) => SubjectType.System, - "BACKGROUND_WORKER" => SubjectType.BackgroundWorker, - _ => SubjectType.System, - }; - - private static AuditResult ResolveAuditResult(string value) - => value switch - { - nameof(AuditResult.Failure) => AuditResult.Failure, - nameof(AuditResult.Partial) => AuditResult.Partial, - _ => AuditResult.Success, - }; -} diff --git a/src/apps/ums.api/Ums.Infrastructure/Hosting/AuditTrailPersistenceConsumer.cs b/src/apps/ums.api/Ums.Infrastructure/Hosting/AuditTrailPersistenceConsumer.cs new file mode 100644 index 00000000..7dc757c6 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Hosting/AuditTrailPersistenceConsumer.cs @@ -0,0 +1,67 @@ +using MassTransit; +using Ums.Application.Common.Aop; +using Ums.Domain.Audit.AuditRecord; + +namespace Ums.Infrastructure.Hosting; + +/// +/// G-040: consumidor que persiste el registro de auditoría automática entregado POST-commit por el +/// Transactional Outbox de MassTransit. Reemplaza el BackgroundService que leía un canal en +/// memoria (con descarte silencioso). Persistencia append-only vía +/// (ADR-0016). La idempotencia ante reentrega la aporta el inbox EF de MassTransit. +/// +public sealed class AuditTrailPersistenceConsumer( + IAuditRecordRepository repository, + ILogger logger) : IConsumer +{ + public async Task Consume(ConsumeContext context) + { + var entry = context.Message; + + var subjectType = ResolveSubjectType(entry.SubjectType); + var auditResult = ResolveAuditResult(entry.AuditResult); + + var auditRecordResult = AuditRecord.Record( + entry.WhoActed, + subjectType, + entry.WhatChanged, + entry.EventType, + auditResult, + entry.AffectedEntityId, + entry.AffectedEntityType, + entry.RootTenantId, + entry.Metadata); + + if (auditRecordResult.IsFailure) + { + logger.LogWarning( + "AuditTrailPersistence: descarta registro de auditoría inválido {EventType} para {AffectedEntityType}/{AffectedEntityId}: {Error}", + entry.EventType, + entry.AffectedEntityType, + entry.AffectedEntityId, + auditRecordResult.Error); + return; + } + + await repository.AppendAsync(auditRecordResult.Value, context.CancellationToken); + await repository.UnitOfWork.SaveEntitiesAsync(context.CancellationToken); + } + + private static SubjectType ResolveSubjectType(string value) + => value switch + { + nameof(SubjectType.User) => SubjectType.User, + nameof(SubjectType.Admin) => SubjectType.Admin, + nameof(SubjectType.System) => SubjectType.System, + "BACKGROUND_WORKER" => SubjectType.BackgroundWorker, + _ => SubjectType.System, + }; + + private static AuditResult ResolveAuditResult(string value) + => value switch + { + nameof(AuditResult.Failure) => AuditResult.Failure, + nameof(AuditResult.Partial) => AuditResult.Partial, + _ => AuditResult.Success, + }; +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Hosting/EventHandlers/UserRevocationEventHandlers.cs b/src/apps/ums.api/Ums.Infrastructure/Hosting/EventHandlers/UserRevocationEventHandlers.cs index c0a83037..010daaf3 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Hosting/EventHandlers/UserRevocationEventHandlers.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Hosting/EventHandlers/UserRevocationEventHandlers.cs @@ -1,29 +1,32 @@ -using MassTransit; +using MediatR; using Microsoft.Extensions.Logging; using Ums.Domain.Events; -using BeyondNetCode.Shell.Ddd.Interfaces; namespace Ums.Infrastructure.Hosting.EventHandlers; +// G-066 / ADR-0098 D4/D7: la revocación de tokens es un handoff EN PROCESO (efecto intra-servicio, +// no cruza frontera de sistema). Se maneja como manejador de notificación MediatR despachado +// post-commit por UmsPlatformDbContext, no como consumidor del bróker inter-sistema (antes +// IConsumer<> sobre MassTransit, lo que exponía el evento de dominio crudo al transporte). + /// -/// HARDENING-03: Revokes tokens immediately when a user is deleted. -/// The revocation expiry is set to 24 hours — well beyond any reasonable JWT TTL. -/// Once the revocation entry expires, the user cannot have a valid token anyway -/// (they are deleted, so no IdP will issue them a new one). +/// HARDENING-03: revoca los tokens de inmediato cuando se elimina un usuario. La ventana de +/// revocación es de 24 h — muy por encima de cualquier TTL razonable de JWT. Cuando la entrada +/// expira, el usuario (eliminado) ya no puede tener token válido. /// public sealed class UserDeletedTokenRevocationHandler( ITokenRevocationStore revocationStore, ILogger logger) - : IConsumer + : INotificationHandler { private static readonly TimeSpan RevocationWindow = TimeSpan.FromHours(24); - public async Task Consume(ConsumeContext context) + public async Task Handle(UserDeletedEvent notification, CancellationToken cancellationToken) { - var userId = context.Message.UserId.ToString(); + var userId = notification.UserId.ToString(); var revokeUntil = DateTime.UtcNow.Add(RevocationWindow); - await revocationStore.RevokeAsync(userId, revokeUntil, context.CancellationToken); + await revocationStore.RevokeAsync(userId, revokeUntil, cancellationToken); logger.LogInformation( "HARDENING-03: Revoked tokens for deleted user {UserId} until {RevokeUntil:O}.", @@ -32,27 +35,26 @@ public async Task Consume(ConsumeContext context) } /// -/// HARDENING-03: Revokes tokens immediately when a user is blocked. -/// Unlike deletion, blocking may be temporary, so the revocation window is shorter (4 h). -/// If the user is later restored, their next successful login will issue a fresh token -/// that is not in the revocation list. +/// HARDENING-03: revoca los tokens de inmediato cuando se bloquea un usuario. A diferencia de la +/// eliminación, el bloqueo puede ser temporal, así que la ventana es más corta (4 h). Si luego se +/// restaura, su próximo login exitoso emitirá un token nuevo fuera de la lista de revocación. /// public sealed class UserBlockedTokenRevocationHandler( ITokenRevocationStore revocationStore, ILogger logger) - : IConsumer + : INotificationHandler { private static readonly TimeSpan RevocationWindow = TimeSpan.FromHours(4); - public async Task Consume(ConsumeContext context) + public async Task Handle(UserBlockedEvent notification, CancellationToken cancellationToken) { - var userId = context.Message.UserId.ToString(); + var userId = notification.UserId.ToString(); var revokeUntil = DateTime.UtcNow.Add(RevocationWindow); - await revocationStore.RevokeAsync(userId, revokeUntil, context.CancellationToken); + await revocationStore.RevokeAsync(userId, revokeUntil, cancellationToken); logger.LogInformation( "HARDENING-03: Revoked tokens for blocked user {UserId} until {RevokeUntil:O}. Reason: {Reason}", - userId, revokeUntil, context.Message.Reason); + userId, revokeUntil, notification.Reason); } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Hosting/PersistenceRuntimeReporter.cs b/src/apps/ums.api/Ums.Infrastructure/Hosting/PersistenceRuntimeReporter.cs index 4e2288c7..9a863b17 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Hosting/PersistenceRuntimeReporter.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Hosting/PersistenceRuntimeReporter.cs @@ -14,7 +14,7 @@ public Task StartAsync(CancellationToken cancellationToken) var options = persistenceOptions.Value; logger.LogInformation( - "UMS persistence configured with provider {Provider}, aggregate store mode {AggregateStoreMode}, identity PostgreSQL stores {UsePostgreSqlIdentityStores}, authorization PostgreSQL stores {UsePostgreSqlAuthorizationStores}, outbox enabled {EnableOutbox}.", + "UMS persistence configured with provider {Provider}, aggregate store mode {AggregateStoreMode}, PostgreSQL identity stores {UsePostgreSqlIdentityStores}, PostgreSQL authorization stores {UsePostgreSqlAuthorizationStores}, outbox enabled {EnableOutbox}.", options.Provider, options.AggregateStoreMode, options.UsePostgreSqlIdentityStores, @@ -29,16 +29,6 @@ public Task StartAsync(CancellationToken cancellationToken) "PostgreSQL is configured as the platform provider, but aggregate repositories still run in-memory. This is a valid transitional modular-monolith mode, not the final production persistence model."); } - if (options.Provider == PersistenceProvider.PostgreSql && options.UsePostgreSqlIdentityStores) - { - logger.LogInformation("Identity aggregates are configured to run on PostgreSQL repositories while the remaining contexts stay in transitional mode."); - } - - if (options.Provider == PersistenceProvider.PostgreSql && options.UsePostgreSqlAuthorizationStores) - { - logger.LogInformation("Authorization profile aggregates are configured to run on PostgreSQL repositories."); - } - return Task.CompletedTask; } diff --git a/src/apps/ums.api/Ums.Infrastructure/Hosting/RolePromotionRoleAssignmentConsumer.cs b/src/apps/ums.api/Ums.Infrastructure/Hosting/RolePromotionRoleAssignmentConsumer.cs new file mode 100644 index 00000000..2a5c45dd --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Hosting/RolePromotionRoleAssignmentConsumer.cs @@ -0,0 +1,122 @@ +using MassTransit; +using Microsoft.Extensions.Logging; +using Ums.Domain.Authorization; +using Ums.Domain.Events; + +namespace Ums.Infrastructure.Hosting; + +// G-094 (endurece ADR-UMS-096 / G-093): el EFECTO de la promoción de rol IGA —reasignar Profile.RoleId— +// ya NO se aplica por un manejador in-process best-effort (INotificationHandler despachado +// post-commit), cuyo fallo dejaba la promoción Executed sin privilegio aplicado y solo con una +// advertencia en log (fallo silencioso, riesgo de cumplimiento). Ahora llega por el Transactional +// Outbox de MassTransit: ExecuteRolePromotionCommandHandler publica +// RolePromotionExecutedIntegrationEvent dentro de la transacción que confirma Execute, y este +// consumidor lo aplica con ENTREGA GARANTIZADA (reintentos + dead-letter, ver +// RolePromotionRoleAssignmentConsumerDefinition). Calca el patrón de AuditTrailPersistenceConsumer. +// +// La reasignación se hace en la PROPIA transacción del agregado Profile (Autorización), separada de +// la de RolePromotionRequest — respeta D-016 (un agregado por transacción). Es idempotente ante +// reentrega: tras aplicarse, el perfil ya no tiene el rol origen (CurrentRoleId), de modo que la +// regla de selección deja de encontrarlo y el reconsumo termina sin efecto. + +/// +/// Aplica el cambio de rol del usuario objetivo cuando una promoción IGA se ejecuta +/// (). +/// +/// Regla de selección de perfil (ADR-UMS-096): se reasignan los perfiles ACTIVOS del usuario objetivo, +/// en el inquilino de la solicitud, cuyo rol actual coincide con el rol origen de la promoción +/// (CurrentRoleId). En el caso normal es exactamente uno. El modelo de promoción no lleva +/// dimensión de sucursal (BranchId): un usuario con varios perfiles del mismo rol en distintas +/// sucursales verá reasignados TODOS (la promoción eleva «ese rol» del usuario). +/// +/// Semántica de fallo (G-094): si un ChangeRole falla se LANZA excepción para que MassTransit +/// reintente y, agotados los reintentos, mueva el mensaje a la dead-letter — el efecto nunca se +/// descarta en silencio. Si no hay perfil coincidente no hay sujeto sobre el que actuar: se registra +/// advertencia y se confirma el mensaje (reintentar no encontraría un perfil inexistente); el efecto +/// es reconstruible (misma decisión que ADR-UMS-096). +/// +public sealed class RolePromotionRoleAssignmentConsumer( + IProfileRepository profileRepository, + ILogger logger) + : IConsumer +{ + public Task Consume(ConsumeContext context) + => ApplyRoleAssignmentAsync(context.Message, context.CancellationToken); + + /// + /// Núcleo del efecto, expuesto para pruebas aisladas del consumidor (dado el evento, aplica la + /// reasignación; ante un ChangeRole fallido lanza y no confirma éxito). + /// + public async Task ApplyRoleAssignmentAsync( + RolePromotionExecutedIntegrationEvent message, + CancellationToken cancellationToken) + { + var profiles = await profileRepository.GetByUserIdAsync(message.TargetUserId, cancellationToken); + + var toReassign = profiles + .Where(p => p.TenantId.GetValue() == message.TenantId + && p.IsActive + && p.RoleId.GetValue() == message.CurrentRoleId) + .ToList(); + + if (toReassign.Count == 0) + { + logger.LogWarning( + "G-094: promoción {RequestId} ejecutada, pero el usuario objetivo {TargetUserId} no tiene " + + "perfil ACTIVO con el rol origen {CurrentRoleId} en el inquilino {TenantId}; no se reasignó " + + "ningún rol. El efecto es reconstruible.", + message.RequestId, message.TargetUserId, message.CurrentRoleId, message.TenantId); + return; + } + + var actor = ActorId.Create(message.ExecutorId.ToString()); + var newRoleId = RoleId.Load(message.TargetRoleId); + + foreach (var profile in toReassign) + { + var result = profile.ChangeRole(newRoleId, actor); + if (result.IsFailure) + { + // G-094: NO silenciar. Lanzar hace que MassTransit reintente y, agotados los intentos, + // envíe el mensaje a la dead-letter — el efecto de la promoción no se descarta. + throw new InvalidOperationException( + $"G-094: no se pudo reasignar el rol del perfil {profile.GetId().GetValue()} " + + $"(promoción {message.RequestId}): {result.Error}"); + } + + await profileRepository.UpdateAsync(profile, cancellationToken); + } + + // D-016: transacción propia del agregado Profile, separada de la de RolePromotionRequest. + await profileRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + logger.LogInformation( + "G-094: promoción {RequestId} aplicada — {Count} perfil(es) del usuario {TargetUserId} reasignado(s) " + + "del rol {CurrentRoleId} al {TargetRoleId}.", + message.RequestId, toReassign.Count, message.TargetUserId, message.CurrentRoleId, message.TargetRoleId); + } +} + +/// +/// G-094: política de reintentos explícita para . +/// ConfigureEndpoints no aplica reintentos por defecto, así que ante un fallo transitorio +/// (BD, concurrencia) el mensaje iría directo a la dead-letter. Con backoff exponencial se reintenta +/// varias veces y solo entonces se descarta a _error para inspección — nunca en silencio. +/// Auto-registrada por AddConsumers(Assembly.GetExecutingAssembly()) junto al consumidor. +/// +public sealed class RolePromotionRoleAssignmentConsumerDefinition + : ConsumerDefinition +{ + protected override void ConfigureConsumer( + IReceiveEndpointConfigurator endpointConfigurator, + IConsumerConfigurator consumerConfigurator, + IRegistrationContext context) + { + endpointConfigurator.UseMessageRetry(r => + r.Exponential( + retryLimit: 5, + minInterval: TimeSpan.FromMilliseconds(200), + maxInterval: TimeSpan.FromSeconds(10), + intervalDelta: TimeSpan.FromSeconds(1))); + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/AuthAuditService.cs b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/AuthAuditService.cs index 98576a61..768aa651 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/AuthAuditService.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/AuthAuditService.cs @@ -43,6 +43,12 @@ public async Task RecordAuthEventAsync(AuthAuditEvent evt, CancellationToken can rootTenantId: rootTenantId); if (record.IsSuccess) + { await _auditRepo.AppendAsync(record.Value, cancellationToken); + // Auto-commit: los eventos Auth.* deben dejar traza siempre, incluso si el + // flujo que los origina no cierra una unidad de trabajo (p. ej. logout, o un + // login fallido que no persiste nada más). Persistir aquí lo garantiza. + await _auditRepo.UnitOfWork.SaveChangesAsync(cancellationToken); + } } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/IdpAuthAdapterFactorySetup.cs b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/IdpAuthAdapterFactorySetup.cs index 4c7884f5..0c9715b4 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/IdpAuthAdapterFactorySetup.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/IdpAuthAdapterFactorySetup.cs @@ -2,6 +2,7 @@ using Ums.Application.Identity.Auth; using Ums.Domain.Enums; using Ums.Domain.Identity.Auth; +using Ums.Infrastructure.Identity.Auth.Oidc; namespace Ums.Infrastructure.Identity.Auth; @@ -15,33 +16,43 @@ namespace Ums.Infrastructure.Identity.Auth; /// /// Pattern: identical to IdpResolutionStrategyFactorySetup. /// -/// To add a real adapter for a strategy (e.g. AzureAd): -/// 1. Implement AzureAdIdpAuthAdapter : IIdpAuthAdapter -/// 2. Add the .Create line below and register the class in DependencyInjection.cs +/// ADR-UMS-094 / G-049 (slice 1): el real (Authorization +/// Code + PKCE, validación estricta del id_token) atiende las estrategias OIDC — +/// Keycloak, GenericOidc y la familia OIDC (AzureAd/Okta/Zitadel/Auth0/Google). Nunca +/// se usa el stub en producción. Las estrategias no-OIDC (SAML2/LDAP) siguen sin +/// adaptador → AUTH_012 hasta implementarse sobre el mismo contrato. /// internal sealed class IdpAuthAdapterFactorySetup : AbstractFactorySetupSource { public IdpAuthAdapterFactorySetup() { - // Production adapters registered per strategy. - // Until a real adapter is implemented for a strategy, remove its comment - // and add the class. An unregistered strategy returns null → AUTH_012. + For() + .Create() + .When(x => x.StrategyName == IdpStrategy.Keycloak.Name); + + For() + .Create() + .When(x => x.StrategyName == IdpStrategy.GenericOidc.Name); + + For() + .Create() + .When(x => x.StrategyName == IdpStrategy.AzureAd.Name); - // For() - // .Create() - // .When(x => x.StrategyName == IdpStrategy.AzureAd.Name); + For() + .Create() + .When(x => x.StrategyName == IdpStrategy.Okta.Name); - // For() - // .Create() - // .When(x => x.StrategyName == IdpStrategy.Okta.Name); + For() + .Create() + .When(x => x.StrategyName == IdpStrategy.Zitadel.Name); - // For() - // .Create() - // .When(x => x.StrategyName == IdpStrategy.Zitadel.Name); + For() + .Create() + .When(x => x.StrategyName == IdpStrategy.Auth0.Name); - // For() - // .Create() - // .When(x => x.StrategyName == IdpStrategy.GenericOidc.Name); + For() + .Create() + .When(x => x.StrategyName == IdpStrategy.Google.Name); } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/HttpJwksProvider.cs b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/HttpJwksProvider.cs new file mode 100644 index 00000000..c8103eed --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/HttpJwksProvider.cs @@ -0,0 +1,83 @@ +using System.Net.Http; +using System.Text.Json; + +namespace Ums.Infrastructure.Identity.Auth.Oidc; + +/// +/// Implementación HTTP real de : obtiene el documento +/// JWKS del issuer y extrae las llaves RSA de firma. En los unit tests se sustituye +/// por un fake con llaves controladas; el JWKS real se ejerce en el arnés Keycloak +/// (slice 2, ADR-UMS-094). +/// +public sealed class HttpJwksProvider : IJwksProvider +{ + private readonly HttpClient _httpClient; + + public HttpJwksProvider(HttpClient httpClient) + { + _httpClient = httpClient; + } + + public async Task>> GetSigningKeysAsync( + OidcEndpoints endpoints, + CancellationToken cancellationToken = default) + { + try + { + using var response = await _httpClient.GetAsync(endpoints.JwksUri, cancellationToken); + if (!response.IsSuccessStatusCode) + { + return Result>.Failure( + OidcAuthErrors.JwksFetchFailed($"HTTP {(int)response.StatusCode}.")); + } + + var body = await response.Content.ReadAsStringAsync(cancellationToken); + var keys = ParseKeys(body); + return Result>.Success(keys); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException) + { + return Result>.Failure( + OidcAuthErrors.JwksFetchFailed("No se pudo contactar el endpoint JWKS.")); + } + } + + private static IReadOnlyList ParseKeys(string body) + { + var keys = new List(); + + using var doc = JsonDocument.Parse(body); + if (!doc.RootElement.TryGetProperty("keys", out var keysElement) || keysElement.ValueKind != JsonValueKind.Array) + { + return keys; + } + + foreach (var key in keysElement.EnumerateArray()) + { + var kty = ReadString(key, "kty"); + var n = ReadString(key, "n"); + var e = ReadString(key, "e"); + + if (!string.Equals(kty, "RSA", StringComparison.OrdinalIgnoreCase) || + string.IsNullOrEmpty(n) || string.IsNullOrEmpty(e)) + { + continue; + } + + keys.Add(new OidcJsonWebKey( + Kid: ReadString(key, "kid") ?? string.Empty, + Kty: kty!, + Alg: ReadString(key, "alg"), + Use: ReadString(key, "use"), + N: n!, + E: e!)); + } + + return keys; + } + + private static string? ReadString(JsonElement element, string name) + => element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/HttpOidcTokenClient.cs b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/HttpOidcTokenClient.cs new file mode 100644 index 00000000..5d93ad42 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/HttpOidcTokenClient.cs @@ -0,0 +1,112 @@ +using System.Net.Http; +using System.Text.Json; + +namespace Ums.Infrastructure.Identity.Auth.Oidc; + +/// +/// Implementación HTTP real de : intercambia el +/// authorization code por tokens contra el token endpoint del IdP +/// (grant_type=authorization_code + PKCE code_verifier). +/// Usa client_secret_post cuando el cliente es confidencial. +/// +/// En los unit tests este cliente se sustituye por un fake; el IdP real (Keycloak) +/// se ejerce sin mocks en el arnés de integración (slice 2, ADR-UMS-094). +/// +public sealed class HttpOidcTokenClient : IOidcTokenClient +{ + private readonly HttpClient _httpClient; + + public HttpOidcTokenClient(HttpClient httpClient) + { + _httpClient = httpClient; + } + + public async Task> ExchangeAuthorizationCodeAsync( + OidcEndpoints endpoints, + OidcClientSettings client, + string code, + string codeVerifier, + string redirectUri, + CancellationToken cancellationToken = default) + { + var form = new List> + { + new("grant_type", "authorization_code"), + new("code", code), + new("redirect_uri", redirectUri), + new("client_id", client.ClientId), + new("code_verifier", codeVerifier), + }; + + if (!string.IsNullOrEmpty(client.ClientSecret)) + { + form.Add(new KeyValuePair("client_secret", client.ClientSecret)); + } + + try + { + using var content = new FormUrlEncodedContent(form); + using var response = await _httpClient.PostAsync(endpoints.TokenEndpoint, content, cancellationToken); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + + if (!response.IsSuccessStatusCode) + { + // G-108 · ADR-UMS-097 §2.3 — separación ESTRUCTURAL por clase de status HTTP (NUNCA por texto): + // • 5xx → INDISPONIBILIDAD de infraestructura del IdP → AUTH_035 (TokenEndpointUnavailable). + // Entra en la lista blanca del clasificador y HABILITA el fallback encadenado. + // • 4xx → fallo de credencial/petición (p. ej. invalid_grant) → AUTH_021 (TokenExchangeFailed), + // TERMINAL. NUNCA debe entrar en la lista blanca de infra: encadenar ante un 4xx + // abriría credential spraying cross-IdP (invariante irrenunciable de ADR-UMS-097 §2.3). + // • Cualquier otro no-éxito (p. ej. 3xx inesperado) → TERMINAL (fail-closed: ante la duda, + // no se avanza la cadena; errar hacia terminal es lo correcto). + return (int)response.StatusCode >= 500 + ? Result.Failure( + OidcAuthErrors.TokenEndpointUnavailable($"HTTP {(int)response.StatusCode}.")) + : Result.Failure( + OidcAuthErrors.TokenExchangeFailed($"HTTP {(int)response.StatusCode}.")); + } + + var token = Parse(body); + return Result.Success(token); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Cancelación pedida por el LLAMADOR (no es indisponibilidad del IdP): se propaga tal cual. + throw; + } + catch (Exception ex) when (ex is HttpRequestException or OperationCanceledException) + { + // Fallo de TRANSPORTE (conexión/red/DNS) o TIMEOUT del token endpoint (el HttpClient aborta con + // TaskCanceledException —derivada de OperationCanceledException— sin que el token del llamador + // esté cancelado) → INDISPONIBILIDAD de infraestructura → AUTH_035 (habilita el fallback). + return Result.Failure( + OidcAuthErrors.TokenEndpointUnavailable("No se pudo contactar el token endpoint (transporte/timeout).")); + } + catch (JsonException) + { + // Respuesta 2xx con cuerpo NO-JSON: la credencial fue aceptada, pero el cuerpo no parsea. No es + // ni un rechazo de credencial ni una indisponibilidad inequívoca → TERMINAL (fail-closed): no se + // avanza la cadena por una anomalía de formato de una respuesta por lo demás exitosa. + return Result.Failure( + OidcAuthErrors.TokenExchangeFailed("La respuesta del token endpoint no es un JSON válido.")); + } + } + + private static OidcTokenResponse Parse(string body) + { + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + + return new OidcTokenResponse( + IdToken: ReadString(root, "id_token"), + AccessToken: ReadString(root, "access_token"), + RefreshToken: ReadString(root, "refresh_token"), + TokenType: ReadString(root, "token_type"), + ExpiresIn: root.TryGetProperty("expires_in", out var e) && e.TryGetInt32(out var v) ? v : null); + } + + private static string? ReadString(JsonElement element, string name) + => element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/IdpConfigurationOidcProviderConfigStore.cs b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/IdpConfigurationOidcProviderConfigStore.cs new file mode 100644 index 00000000..5e6dda0f --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/IdpConfigurationOidcProviderConfigStore.cs @@ -0,0 +1,66 @@ +using Ums.Domain.Configuration; +using Ums.Domain.Configuration.IdpConfiguration; +using Ums.Domain.Enums; +using Ums.Domain.Identity.Tenant.IdentityProvider; + +namespace Ums.Infrastructure.Identity.Auth.Oidc; + +/// +/// Resuelve la del proveedor a partir del +/// IdpConfiguration activo del inquilino, parseando su ConfigPayload +/// (ADR-UMS-094: endpoints leídos de configuración, nunca hardcodeados). +/// +/// La resolución completa por prioridad/suite/dominio (FR-042) y la desencriptación +/// del secreto desde el SecretRef se integran en el slice 2; aquí se toma el +/// primer IdpConfiguration activo cuyo ProviderType corresponde a la +/// estrategia del proveedor, ordenado por prioridad. +/// +public sealed class IdpConfigurationOidcProviderConfigStore : IOidcProviderConfigStore +{ + private static readonly IReadOnlyDictionary StrategyToProviderType = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [IdpStrategy.Keycloak.Name] = ProviderType.Keycloak.Name, + [IdpStrategy.GenericOidc.Name] = ProviderType.GenericOidc.Name, + [IdpStrategy.AzureAd.Name] = ProviderType.AzureAd.Name, + [IdpStrategy.Okta.Name] = ProviderType.Okta.Name, + [IdpStrategy.Zitadel.Name] = ProviderType.Zitadel.Name, + [IdpStrategy.Auth0.Name] = ProviderType.Auth0.Name, + [IdpStrategy.Google.Name] = ProviderType.Google.Name, + }; + + private readonly IIdpConfigurationRepository _repository; + + public IdpConfigurationOidcProviderConfigStore(IIdpConfigurationRepository repository) + { + _repository = repository; + } + + public async Task> GetAsync( + IdentityProvider provider, + CancellationToken cancellationToken = default) + { + if (!StrategyToProviderType.TryGetValue(provider.Strategy.Name, out var providerTypeName)) + { + return Result.Failure( + OidcAuthErrors.ConfigNotFound($"La estrategia '{provider.Strategy.Name}' no es OIDC.")); + } + + var configurations = await _repository.GetByTenantIdAsync(provider.TenantId.GetValue(), cancellationToken); + + var selected = configurations + .Where(configuration => configuration.Status == IdpConfigStatus.Active) + .Where(configuration => string.Equals(configuration.ProviderType.Name, providerTypeName, StringComparison.OrdinalIgnoreCase)) + .OrderBy(configuration => configuration.ResolutionPriority) + .ThenByDescending(configuration => configuration.Version) + .FirstOrDefault(); + + if (selected is null) + { + return Result.Failure( + OidcAuthErrors.ConfigNotFound($"No hay IdpConfiguration activo para '{providerTypeName}' en el inquilino.")); + } + + return OidcProviderConfigParser.Parse(selected.Props.ConfigPayload); + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcAuthErrors.cs b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcAuthErrors.cs new file mode 100644 index 00000000..f1290e7d --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcAuthErrors.cs @@ -0,0 +1,82 @@ +namespace Ums.Infrastructure.Identity.Auth.Oidc; + +/// +/// Códigos y mensajes de error del flujo OIDC (Result Pattern, textos en español). +/// Convergen con la normalización Result→HTTP (G-045): fallos de configuración / +/// validación → 401/409, nunca 503 ni excepción (ADR-UMS-094). +/// +public static class OidcAuthErrors +{ + public static string ConfigNotFound(string detalle) => + $"AUTH_020: No se encontró configuración OIDC válida para el proveedor. {detalle}"; + + public static string CallbackInvalid(string detalle) => + $"AUTH_033: Callback OIDC inválido. {detalle}"; + + public const string StateMismatch = + "AUTH_033: El parámetro 'state' del callback no coincide con el emitido en la autorización."; + + /// + /// AUTH_021 — fallo del intercambio de código atribuible al CLIENTE/CREDENCIAL: respuesta 4xx del + /// token endpoint (p. ej. invalid_grant) o cuerpo 2xx malformado. Es TERMINAL: nunca debe + /// entrar en la lista blanca de infra del clasificador, porque encadenar el fallback ante un fallo de + /// credencial abriría credential spraying cross-IdP (invariante irrenunciable de ADR-UMS-097 §2.3). + /// La rama de INFRAESTRUCTURA (5xx/timeout/transporte) usa un código estructuralmente distinto + /// ( = AUTH_035); ver G-108. + /// + public static string TokenExchangeFailed(string detalle) => + $"AUTH_021: Falló el intercambio del código por tokens contra el IdP. {detalle}"; + + /// + /// AUTH_035 — INDISPONIBILIDAD de infraestructura del token endpoint del IdP: respuesta 5xx, timeout o + /// fallo de transporte (conexión/red/DNS). Es estructuralmente distinto de + /// (AUTH_021 = 4xx/credencial → TERMINAL): esa separación por clase de + /// status HTTP / tipo de excepción —no por texto— es la que permite habilitar el fallback encadenado + /// SOLO por infra (ADR-UMS-097 §2.3, G-108) sin abrir credential spraying. Se añade a la lista + /// blanca IdpAuthOutcomeClassifier.InfraUnavailableCodes y mapea a HTTP 503. + /// + /// Se eligió un código NUEVO en la familia OIDC (AUTH_020..AUTH_034; local a este flujo, no vive + /// en el catálogo SDK) en lugar de reutilizar AUTH_013=IdpCallFailed: AUTH_013 está contaminado + /// por un rechazo de credencial del stub de dev (StubIdpAuthAdapter) y por errores de gestión, + /// de modo que reusarlo para infra arriesgaría clasificar un rechazo de credencial como infra + /// (regresión de seguridad). AUTH_035 no colisiona con ninguna semántica previa (G-108). + /// + public static string TokenEndpointUnavailable(string detalle) => + $"AUTH_035: El token endpoint del IdP no está disponible (5xx/timeout/transporte). {detalle}"; + + public const string MissingIdToken = + "AUTH_022: La respuesta del token endpoint no contiene 'id_token'."; + + public const string MalformedIdToken = + "AUTH_023: El 'id_token' no tiene el formato JWT esperado (cabecera.carga.firma)."; + + public static string UnsupportedAlgorithm(string? alg) => + $"AUTH_024: Algoritmo de firma no soportado ('{alg ?? "none"}'). Se exige RS256."; + + public static string SigningKeyNotFound(string? kid) => + $"AUTH_025: No se encontró la llave de firma (kid='{kid ?? "-"}') en el JWKS del issuer."; + + public const string InvalidSignature = + "AUTH_026: La firma del 'id_token' es inválida."; + + public const string InvalidIssuer = + "AUTH_027: El 'iss' del 'id_token' no coincide con el issuer configurado."; + + public const string InvalidAudience = + "AUTH_028: El 'aud' del 'id_token' no incluye el client_id configurado."; + + public const string Expired = + "AUTH_029: El 'id_token' está expirado ('exp')."; + + public const string NotYetValid = + "AUTH_030: El 'id_token' aún no es válido ('nbf')."; + + public const string NonceMismatch = + "AUTH_031: El 'nonce' del 'id_token' no coincide con el emitido en la autorización."; + + public const string MissingEmail = + "AUTH_032: El 'id_token' no contiene el claim 'email'; no se puede mapear la identidad UMS."; + + public static string JwksFetchFailed(string detalle) => + $"AUTH_034: No se pudo obtener el JWKS del issuer. {detalle}"; +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcAuthorizationRequestFactory.cs b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcAuthorizationRequestFactory.cs new file mode 100644 index 00000000..66d75074 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcAuthorizationRequestFactory.cs @@ -0,0 +1,47 @@ +namespace Ums.Infrastructure.Identity.Auth.Oidc; + +/// +/// Construye la URL de autorización del flujo Authorization Code + PKCE (S256), +/// emitiendo los artefactos de sesión (state, nonce, code_verifier) que deben +/// persistirse para validarse en el callback (ADR-UMS-094). +/// +public sealed class OidcAuthorizationRequestFactory +{ + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Minor Code Smell", "S2325:Methods and properties that don't access instance data should be static", + Justification = "Servicio registrado en DI (AddScoped) e invocado por instancia; la API de fábrica " + + "es de instancia por diseño (ADR-UMS-094). Volverla estática rompería los llamadores.")] + public OidcAuthorizationRequest Build(OidcProviderConfig config, string? loginHint = null) + { + var codeVerifier = OidcPkce.GenerateCodeVerifier(); + var codeChallenge = OidcPkce.ComputeS256Challenge(codeVerifier); + var state = OidcPkce.GenerateOpaqueValue(); + var nonce = OidcPkce.GenerateOpaqueValue(); + + var parameters = new List> + { + new("response_type", "code"), + new("client_id", config.Client.ClientId), + new("redirect_uri", config.Client.RedirectUri), + new("scope", config.Client.Scopes), + new("state", state), + new("nonce", nonce), + new("code_challenge", codeChallenge), + new("code_challenge_method", "S256"), + }; + + if (!string.IsNullOrWhiteSpace(loginHint)) + { + parameters.Add(new KeyValuePair("login_hint", loginHint)); + } + + var query = string.Join( + '&', + parameters.Select(p => $"{Uri.EscapeDataString(p.Key)}={Uri.EscapeDataString(p.Value)}")); + + var separator = config.Endpoints.AuthorizationEndpoint.Contains('?') ? '&' : '?'; + var url = $"{config.Endpoints.AuthorizationEndpoint}{separator}{query}"; + + return new OidcAuthorizationRequest(url, state, nonce, codeVerifier, codeChallenge); + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcBase64Url.cs b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcBase64Url.cs new file mode 100644 index 00000000..c3e1ed17 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcBase64Url.cs @@ -0,0 +1,26 @@ +namespace Ums.Infrastructure.Identity.Auth.Oidc; + +/// +/// Codificación/decodificación Base64Url (RFC 7515) sin dependencias externas. +/// Usada por PKCE y por la verificación de firma del id_token. +/// +internal static class OidcBase64Url +{ + public static string Encode(ReadOnlySpan bytes) => + Convert.ToBase64String(bytes) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + + public static byte[] Decode(string input) + { + var s = input.Replace('-', '+').Replace('_', '/'); + s += (s.Length % 4) switch + { + 2 => "==", + 3 => "=", + _ => string.Empty, + }; + return Convert.FromBase64String(s); + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcIdTokenValidator.cs b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcIdTokenValidator.cs new file mode 100644 index 00000000..9ee5577a --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcIdTokenValidator.cs @@ -0,0 +1,239 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Ums.Domain.Identity.Auth; + +namespace Ums.Infrastructure.Identity.Auth.Oidc; + +/// +/// Validación estricta del id_token OIDC y mapeo de claims → identidad UMS +/// (ADR-UMS-094). Verifica: +/// +/// formato JWT y algoritmo RS256 (rechaza none y confusión de algoritmo); +/// firma RSASSA-PKCS1-v1_5/SHA-256 contra la llave del JWKS del issuer (por kid); +/// claims iss, aud, exp, nbf y nonce. +/// +/// Un adaptador laxo es una vulnerabilidad: cualquier discrepancia devuelve +/// de fallo con código de dominio, nunca una excepción. +/// +public sealed class OidcIdTokenValidator +{ + private const string ExpectedAlgorithm = "RS256"; + + private readonly IJwksProvider _jwksProvider; + private readonly TimeProvider _timeProvider; + private readonly TimeSpan _clockSkew; + + public OidcIdTokenValidator( + IJwksProvider jwksProvider, + TimeProvider? timeProvider = null, + TimeSpan? clockSkew = null) + { + _jwksProvider = jwksProvider; + _timeProvider = timeProvider ?? TimeProvider.System; + _clockSkew = clockSkew ?? TimeSpan.FromMinutes(2); + } + + public async Task> ValidateAsync( + string idToken, + OidcEndpoints endpoints, + string expectedAudience, + string expectedNonce, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(idToken)) + { + return Result.Failure(OidcAuthErrors.MalformedIdToken); + } + + var parts = idToken.Split('.'); + if (parts.Length != 3) + { + return Result.Failure(OidcAuthErrors.MalformedIdToken); + } + + JsonElement header; + JsonElement payload; + try + { + header = ParseSegment(parts[0]); + payload = ParseSegment(parts[1]); + } + catch (Exception ex) when (ex is JsonException or FormatException) + { + return Result.Failure(OidcAuthErrors.MalformedIdToken); + } + + // 1) Algoritmo: sólo RS256 (rechaza 'none' y confusión de algoritmo HS/RS). + var alg = ReadString(header, "alg"); + if (!string.Equals(alg, ExpectedAlgorithm, StringComparison.Ordinal)) + { + return Result.Failure(OidcAuthErrors.UnsupportedAlgorithm(alg)); + } + + // 2) Firma: llave por 'kid' desde el JWKS del issuer. + var kid = ReadString(header, "kid"); + var keysResult = await _jwksProvider.GetSigningKeysAsync(endpoints, cancellationToken); + if (keysResult.IsFailure) + { + return Result.Failure(OidcAuthErrors.JwksFetchFailed(keysResult.Error)); + } + + var key = SelectKey(keysResult.Value, kid); + if (key is null) + { + return Result.Failure(OidcAuthErrors.SigningKeyNotFound(kid)); + } + + if (!VerifySignature(parts[0], parts[1], parts[2], key)) + { + return Result.Failure(OidcAuthErrors.InvalidSignature); + } + + // 3) iss + var iss = ReadString(payload, "iss"); + if (!string.Equals(iss, endpoints.Issuer, StringComparison.Ordinal)) + { + return Result.Failure(OidcAuthErrors.InvalidIssuer); + } + + // 4) aud (string o arreglo) + if (!AudienceMatches(payload, expectedAudience)) + { + return Result.Failure(OidcAuthErrors.InvalidAudience); + } + + // 5) exp / 6) nbf + var now = _timeProvider.GetUtcNow(); + if (TryReadUnixTime(payload, "exp", out var exp) && now > exp + _clockSkew) + { + return Result.Failure(OidcAuthErrors.Expired); + } + + if (TryReadUnixTime(payload, "nbf", out var nbf) && now < nbf - _clockSkew) + { + return Result.Failure(OidcAuthErrors.NotYetValid); + } + + // 7) nonce + var nonce = ReadString(payload, "nonce"); + if (!string.IsNullOrEmpty(expectedNonce) && !string.Equals(nonce, expectedNonce, StringComparison.Ordinal)) + { + return Result.Failure(OidcAuthErrors.NonceMismatch); + } + + // 8) Mapeo claims → identidad UMS. + return MapIdentity(payload); + } + + private static Result MapIdentity(JsonElement payload) + { + var email = ReadString(payload, "email"); + if (string.IsNullOrWhiteSpace(email)) + { + return Result.Failure(OidcAuthErrors.MissingEmail); + } + + var subject = ReadString(payload, "sub"); + var displayName = ReadString(payload, "name") + ?? ReadString(payload, "preferred_username") + ?? email.Split('@')[0]; + + var claims = new Dictionary(StringComparer.Ordinal); + foreach (var prop in payload.EnumerateObject()) + { + if (prop.Value.ValueKind == JsonValueKind.String) + { + claims[prop.Name] = prop.Value.GetString()!; + } + else if (prop.Value.ValueKind is JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False) + { + claims[prop.Name] = prop.Value.ToString(); + } + } + + var identity = new ExternalIdentity(email, subject, displayName, claims); + return Result.Success(identity); + } + + private static bool VerifySignature(string headerSegment, string payloadSegment, string signatureSegment, OidcJsonWebKey key) + { + try + { + var signingInput = Encoding.ASCII.GetBytes($"{headerSegment}.{payloadSegment}"); + var signature = OidcBase64Url.Decode(signatureSegment); + + using var rsa = RSA.Create(); + rsa.ImportParameters(new RSAParameters + { + Modulus = OidcBase64Url.Decode(key.N), + Exponent = OidcBase64Url.Decode(key.E), + }); + + return rsa.VerifyData(signingInput, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + } + catch (Exception ex) when (ex is FormatException or CryptographicException) + { + return false; + } + } + + private static OidcJsonWebKey? SelectKey(IReadOnlyList keys, string? kid) + { + var rsaKeys = keys.Where(k => string.Equals(k.Kty, "RSA", StringComparison.OrdinalIgnoreCase)).ToList(); + + if (!string.IsNullOrEmpty(kid)) + { + return rsaKeys.FirstOrDefault(k => string.Equals(k.Kid, kid, StringComparison.Ordinal)); + } + + // Sin 'kid': sólo es determinista si hay exactamente una llave RSA. + return rsaKeys.Count == 1 ? rsaKeys[0] : null; + } + + private static bool AudienceMatches(JsonElement payload, string expectedAudience) + { + if (!payload.TryGetProperty("aud", out var aud)) + { + return false; + } + + return aud.ValueKind switch + { + JsonValueKind.String => string.Equals(aud.GetString(), expectedAudience, StringComparison.Ordinal), + JsonValueKind.Array => aud.EnumerateArray().Any(a => + a.ValueKind == JsonValueKind.String && + string.Equals(a.GetString(), expectedAudience, StringComparison.Ordinal)), + _ => false, + }; + } + + private static bool TryReadUnixTime(JsonElement payload, string name, out DateTimeOffset value) + { + value = default; + if (!payload.TryGetProperty(name, out var element) || element.ValueKind != JsonValueKind.Number) + { + return false; + } + + if (!element.TryGetInt64(out var seconds)) + { + return false; + } + + value = DateTimeOffset.FromUnixTimeSeconds(seconds); + return true; + } + + private static JsonElement ParseSegment(string segment) + { + var json = Encoding.UTF8.GetString(OidcBase64Url.Decode(segment)); + using var doc = JsonDocument.Parse(json); + return doc.RootElement.Clone(); + } + + private static string? ReadString(JsonElement element, string name) + => element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcIdpAuthAdapter.cs b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcIdpAuthAdapter.cs new file mode 100644 index 00000000..fd5b5c8f --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcIdpAuthAdapter.cs @@ -0,0 +1,137 @@ +using System.Text.Json; +using Ums.Domain.Identity.Auth; +using Ums.Domain.Identity.Tenant.IdentityProvider; + +namespace Ums.Infrastructure.Identity.Auth.Oidc; + +/// +/// Adaptador de autenticación OIDC real (Authorization Code + PKCE) sobre el +/// contrato (ADR-UMS-094 · G-049). Reemplaza al +/// StubIdpAuthAdapter para las estrategias OIDC/Keycloak/GenericOidc. +/// +/// El contrato recibe un credential +/// único; en la fase de callback el nivel de presentación empaqueta en él —como JSON— +/// el (código + artefactos de sesión state/nonce/code_verifier). +/// El adaptador entonces: +/// +/// resuelve los endpoints OIDC del inquilino (sin hardcodear); +/// valida que state coincida con el emitido en la autorización; +/// intercambia el código por tokens contra el token endpoint; +/// valida estrictamente el id_token (firma JWKS, iss/aud/exp/nbf/nonce); +/// mapea los claims a la identidad externa UMS. +/// +/// La construcción de la URL de autorización vive en +/// . +/// +public sealed class OidcIdpAuthAdapter : IIdpAuthAdapter +{ + private readonly IOidcProviderConfigStore _configStore; + private readonly IOidcTokenClient _tokenClient; + private readonly OidcIdTokenValidator _idTokenValidator; + + public OidcIdpAuthAdapter( + IOidcProviderConfigStore configStore, + IOidcTokenClient tokenClient, + OidcIdTokenValidator idTokenValidator) + { + _configStore = configStore; + _tokenClient = tokenClient; + _idTokenValidator = idTokenValidator; + } + + public async Task> ValidateAsync( + IdentityProvider provider, + string credential, + CancellationToken cancellationToken = default) + { + var callbackResult = ParseCallback(credential); + if (callbackResult.IsFailure) + { + return Result.Failure(callbackResult.Error); + } + + var callback = callbackResult.Value; + + var configResult = await _configStore.GetAsync(provider, cancellationToken); + if (configResult.IsFailure) + { + return Result.Failure(configResult.Error); + } + + var config = configResult.Value; + + // Defensa CSRF: el 'state' del callback debe coincidir con el emitido. + if (!FixedTimeEquals(callback.State, callback.ExpectedState)) + { + return Result.Failure(OidcAuthErrors.StateMismatch); + } + + var tokenResult = await _tokenClient.ExchangeAuthorizationCodeAsync( + config.Endpoints, + config.Client, + callback.Code, + callback.CodeVerifier, + callback.RedirectUri, + cancellationToken); + + if (tokenResult.IsFailure) + { + return Result.Failure(tokenResult.Error); + } + + var idToken = tokenResult.Value.IdToken; + if (string.IsNullOrWhiteSpace(idToken)) + { + return Result.Failure(OidcAuthErrors.MissingIdToken); + } + + return await _idTokenValidator.ValidateAsync( + idToken, + config.Endpoints, + config.Client.ClientId, + callback.ExpectedNonce, + cancellationToken); + } + + private static Result ParseCallback(string credential) + { + if (string.IsNullOrWhiteSpace(credential)) + { + return Result.Failure( + OidcAuthErrors.CallbackInvalid("El 'credential' del callback está vacío.")); + } + + try + { + var callback = JsonSerializer.Deserialize(credential, SerializerOptions); + if (callback is null || + string.IsNullOrWhiteSpace(callback.Code) || + string.IsNullOrWhiteSpace(callback.State) || + string.IsNullOrWhiteSpace(callback.CodeVerifier) || + string.IsNullOrWhiteSpace(callback.RedirectUri)) + { + return Result.Failure( + OidcAuthErrors.CallbackInvalid("Faltan campos obligatorios en el callback OIDC.")); + } + + return Result.Success(callback); + } + catch (JsonException) + { + return Result.Failure( + OidcAuthErrors.CallbackInvalid("El 'credential' no es un callback OIDC JSON válido.")); + } + } + + private static bool FixedTimeEquals(string a, string b) + { + var ba = System.Text.Encoding.UTF8.GetBytes(a); + var bb = System.Text.Encoding.UTF8.GetBytes(b); + return System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(ba, bb); + } + + private static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNameCaseInsensitive = true, + }; +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcModels.cs b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcModels.cs new file mode 100644 index 00000000..5fc7da82 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcModels.cs @@ -0,0 +1,78 @@ +namespace Ums.Infrastructure.Identity.Auth.Oidc; + +/// +/// Endpoints OIDC del inquilino. Se leen de la configuración del proveedor +/// (IdpConfiguration.ConfigPayload), nunca se hardcodean (ADR-UMS-094). +/// +public sealed record OidcEndpoints( + string AuthorizationEndpoint, + string TokenEndpoint, + string JwksUri, + string Issuer); + +/// +/// Parámetros del cliente OIDC registrado ante el IdP del inquilino. +/// El ClientSecret proviene del secreto cifrado en reposo y nunca se +/// registra en logs, proyecciones ni grafo (ADR-UMS-094). +/// +public sealed record OidcClientSettings( + string ClientId, + string? ClientSecret, + string RedirectUri, + string Scopes = "openid email profile"); + +/// +/// Configuración OIDC completa resuelta para un proveedor del inquilino: +/// endpoints + parámetros de cliente. +/// +public sealed record OidcProviderConfig( + OidcEndpoints Endpoints, + OidcClientSettings Client); + +/// +/// Petición de autorización construida por el adaptador (Authorization Code + PKCE). +/// El State, Nonce y CodeVerifier deben persistirse en la sesión +/// del lado servidor para validarse en el callback. +/// +public sealed record OidcAuthorizationRequest( + string AuthorizationUrl, + string State, + string Nonce, + string CodeVerifier, + string CodeChallenge); + +/// +/// Datos del retorno (callback) OIDC más los artefactos de sesión emitidos en la +/// fase de autorización. El adaptador valida que State coincida con +/// ExpectedState y que el nonce del id_token coincida con +/// ExpectedNonce. +/// +public sealed record OidcCallback( + string Code, + string State, + string ExpectedState, + string ExpectedNonce, + string CodeVerifier, + string RedirectUri); + +/// +/// Respuesta del token endpoint del IdP tras el intercambio del código. +/// +public sealed record OidcTokenResponse( + string? IdToken, + string? AccessToken, + string? RefreshToken, + string? TokenType, + int? ExpiresIn); + +/// +/// Llave pública RSA publicada por el JWKS del issuer (subconjunto usado para +/// verificar la firma RS256 del id_token). +/// +public sealed record OidcJsonWebKey( + string Kid, + string Kty, + string? Alg, + string? Use, + string N, + string E); diff --git a/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcPkce.cs b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcPkce.cs new file mode 100644 index 00000000..424e7720 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcPkce.cs @@ -0,0 +1,26 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Ums.Infrastructure.Identity.Auth.Oidc; + +/// +/// Primitivas PKCE (RFC 7636) y valores anti-repetición (state/nonce) para el flujo +/// Authorization Code + PKCE. Todos los valores se generan con un RNG criptográfico. +/// +public static class OidcPkce +{ + /// Genera un code_verifier de alta entropía (43 chars, 32 bytes). + public static string GenerateCodeVerifier() + => OidcBase64Url.Encode(RandomNumberGenerator.GetBytes(32)); + + /// Calcula el code_challenge con el método S256: BASE64URL(SHA256(verifier)). + public static string ComputeS256Challenge(string codeVerifier) + { + var hash = SHA256.HashData(Encoding.ASCII.GetBytes(codeVerifier)); + return OidcBase64Url.Encode(hash); + } + + /// Genera un valor opaco (state/nonce) de 32 bytes. + public static string GenerateOpaqueValue() + => OidcBase64Url.Encode(RandomNumberGenerator.GetBytes(32)); +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcProviderConfigParser.cs b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcProviderConfigParser.cs new file mode 100644 index 00000000..225c39f3 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/OidcProviderConfigParser.cs @@ -0,0 +1,94 @@ +using System.Text.Json; + +namespace Ums.Infrastructure.Identity.Auth.Oidc; + +/// +/// Parsea la configuración OIDC (endpoints + cliente) desde el ConfigPayload +/// JSON del IdpConfiguration del inquilino. No hardcodea endpoints (ADR-UMS-094). +/// +/// Acepta claves tanto en estilo OIDC discovery (authorization_endpoint, +/// token_endpoint, jwks_uri, issuer) como abreviadas. Si el +/// payload sólo trae issuer/authority, deriva los endpoints estándar +/// de Keycloak/OIDC. +/// +public static class OidcProviderConfigParser +{ + public static Result Parse(string configPayload, string? clientSecret = null) + { + if (string.IsNullOrWhiteSpace(configPayload)) + { + return Result.Failure( + OidcAuthErrors.ConfigNotFound("El 'configPayload' del proveedor está vacío.")); + } + + JsonDocument doc; + try + { + doc = JsonDocument.Parse(configPayload); + } + catch (JsonException) + { + return Result.Failure( + OidcAuthErrors.ConfigNotFound("El 'configPayload' del proveedor no es JSON válido.")); + } + + using (doc) + { + var root = doc.RootElement; + + var issuer = Read(root, "issuer", "authority"); + var authorization = Read(root, "authorization_endpoint", "authorizationEndpoint"); + var token = Read(root, "token_endpoint", "tokenEndpoint"); + var jwks = Read(root, "jwks_uri", "jwksUri", "jwks"); + + // Derivación estándar OIDC/Keycloak a partir del issuer si faltan endpoints. + if (!string.IsNullOrWhiteSpace(issuer)) + { + var baseUrl = issuer!.TrimEnd('/'); + authorization ??= $"{baseUrl}/protocol/openid-connect/auth"; + token ??= $"{baseUrl}/protocol/openid-connect/token"; + jwks ??= $"{baseUrl}/protocol/openid-connect/certs"; + } + + var clientId = Read(root, "client_id", "clientId", "applicationId"); + var redirectUri = Read(root, "redirect_uri", "redirectUri"); + var scopes = Read(root, "scope", "scopes") ?? "openid email profile"; + var secret = clientSecret ?? Read(root, "client_secret", "clientSecret"); + + var missing = new List(); + if (string.IsNullOrWhiteSpace(issuer)) missing.Add("issuer"); + if (string.IsNullOrWhiteSpace(authorization)) missing.Add("authorization_endpoint"); + if (string.IsNullOrWhiteSpace(token)) missing.Add("token_endpoint"); + if (string.IsNullOrWhiteSpace(jwks)) missing.Add("jwks_uri"); + if (string.IsNullOrWhiteSpace(clientId)) missing.Add("client_id"); + if (string.IsNullOrWhiteSpace(redirectUri)) missing.Add("redirect_uri"); + + if (missing.Count > 0) + { + return Result.Failure( + OidcAuthErrors.ConfigNotFound($"Faltan claves obligatorias: {string.Join(", ", missing)}.")); + } + + var config = new OidcProviderConfig( + new OidcEndpoints(authorization!, token!, jwks!, issuer!), + new OidcClientSettings(clientId!, secret, redirectUri!, scopes)); + + return Result.Success(config); + } + } + + private static string? Read(JsonElement root, params string[] names) + { + foreach (var name in names) + { + if (root.TryGetProperty(name, out var value) && + value.ValueKind == JsonValueKind.String && + !string.IsNullOrWhiteSpace(value.GetString())) + { + return value.GetString(); + } + } + + return null; + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/Ports.cs b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/Ports.cs new file mode 100644 index 00000000..ac2ca165 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Identity/Auth/Oidc/Ports.cs @@ -0,0 +1,45 @@ +using Ums.Domain.Identity.Tenant.IdentityProvider; + +namespace Ums.Infrastructure.Identity.Auth.Oidc; + +/// +/// Puerto de acceso HTTP al token endpoint del IdP. Se abstrae para poder +/// unit-testear la orquestación del adaptador sin un IdP vivo (ADR-UMS-094 · slice 1). +/// La implementación real (HTTP) es . +/// +public interface IOidcTokenClient +{ + Task> ExchangeAuthorizationCodeAsync( + OidcEndpoints endpoints, + OidcClientSettings client, + string code, + string codeVerifier, + string redirectUri, + CancellationToken cancellationToken = default); +} + +/// +/// Puerto de obtención del JWKS (llaves públicas de firma) del issuer. Se abstrae +/// para validar la firma del id_token con llaves controladas en los unit +/// tests, sin depender de un IdP real (ADR-UMS-094 · slice 1). +/// La implementación real (HTTP) es . +/// +public interface IJwksProvider +{ + Task>> GetSigningKeysAsync( + OidcEndpoints endpoints, + CancellationToken cancellationToken = default); +} + +/// +/// Puerto que resuelve los endpoints y parámetros OIDC del proveedor a partir de la +/// configuración del inquilino (IdpConfiguration), de modo que el adaptador +/// nunca hardcodee endpoints (ADR-UMS-094). La implementación real es +/// . +/// +public interface IOidcProviderConfigStore +{ + Task> GetAsync( + IdentityProvider provider, + CancellationToken cancellationToken = default); +} diff --git a/src/apps/ums.api/Ums.Infrastructure/MasterData/Contracts/TenantEvent.cs b/src/apps/ums.api/Ums.Infrastructure/MasterData/Contracts/TenantEvent.cs index 81f19518..b2b412cb 100644 --- a/src/apps/ums.api/Ums.Infrastructure/MasterData/Contracts/TenantEvent.cs +++ b/src/apps/ums.api/Ums.Infrastructure/MasterData/Contracts/TenantEvent.cs @@ -1,10 +1,16 @@ namespace Evolith.Contracts.MasterData; /// -/// Wire contract for master-Tenant events published by MMS (ADR-0106 / ADR-0050). Duplicated -/// verbatim from the producer's canonical Evolith.Contracts.MasterData namespace so -/// MassTransit routes the message to this consumer (until a shared Evolith.Messaging.Contracts -/// package exists). Do not change field names/namespace independently of the producer. +/// Contrato de mensajería (wire) para eventos de Tenant maestro publicados por MMS. +/// El nombre canónico del contrato es el del productor, Evolith.Contracts.MasterData, +/// reproducido aquí verbatim (ADR-0106): UMS es CONSUMIDOR del Tenant Maestro que MMS posee +/// dentro de Evolith Core, y MassTransit enruta por el URN del tipo. Este namespace NO sigue +/// la convención Ums.* del satélite a propósito. +/// Decisión D-006 / G-011: hoy NO existe un productor MMS real (prototipo), por lo que +/// este consumidor está inerte por ausencia de mensajes — no hay riesgo latente. Cuando +/// exista integración real, el productor debe publicar con este mismo namespace+tipo +/// (MassTransit enruta por el URN del mensaje); no cambiar nombres de campo ni namespace +/// de forma unilateral respecto del productor. /// public sealed record TenantEvent { diff --git a/src/apps/ums.api/Ums.Infrastructure/MasterData/TenantProjectionDbContextFactory.cs b/src/apps/ums.api/Ums.Infrastructure/MasterData/TenantProjectionDbContextFactory.cs index 1ec6f88e..9db76d57 100644 --- a/src/apps/ums.api/Ums.Infrastructure/MasterData/TenantProjectionDbContextFactory.cs +++ b/src/apps/ums.api/Ums.Infrastructure/MasterData/TenantProjectionDbContextFactory.cs @@ -9,7 +9,9 @@ public sealed class TenantProjectionDbContextFactory : IDesignTimeDbContextFacto public TenantProjectionDbContext CreateDbContext(string[] args) { var options = new DbContextOptionsBuilder() +#pragma warning disable S2068 // Cadena de conexión de DISEÑO (EF Core tooling), solo local; el runtime resuelve por configuración. No es un secreto de producción. .UseNpgsql("Host=localhost;Port=5432;Database=ums;Username=postgres;Password=postgres") +#pragma warning restore S2068 .Options; return new TenantProjectionDbContext(options); } diff --git a/src/apps/ums.api/Ums.Infrastructure/Messaging/MassTransitIntegrationEventPublisher.cs b/src/apps/ums.api/Ums.Infrastructure/Messaging/MassTransitIntegrationEventPublisher.cs new file mode 100644 index 00000000..9104bcf8 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Messaging/MassTransitIntegrationEventPublisher.cs @@ -0,0 +1,24 @@ +using MassTransit; +using Ums.Application.Common.Interfaces; +using Ums.Domain.Events; + +namespace Ums.Infrastructure.Messaging; + +/// +/// ADR-0098 D7 / opción F: publica eventos de integración por el bus de MassTransit. Bajo kind/prod +/// el con ámbito de petición está respaldado por el bus-outbox de +/// UmsPlatformDbContext (UseBusOutbox()): el mensaje se estaciona con el cambio del +/// agregado y se entrega POST-commit. Es la frontera explícita entre el dominio (en proceso) y el +/// transporte inter-sistema; ningún evento de dominio crudo la cruza. +/// +public sealed class MassTransitIntegrationEventPublisher( + IPublishEndpoint publishEndpoint, + IFunctionalTransaction functionalTransaction) + : IIntegrationEventPublisher +{ + public Task PublishAsync(IIntegrationEvent integrationEvent, CancellationToken cancellationToken = default) + { + functionalTransaction.RecordEffect("message.publish", "outbox", integrationEvent.GetType().Name, EffectReversibility.PendingCompensation); + return publishEndpoint.Publish(integrationEvent, integrationEvent.GetType(), cancellationToken); + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Observability/FunctionalTransaction.cs b/src/apps/ums.api/Ums.Infrastructure/Observability/FunctionalTransaction.cs new file mode 100644 index 00000000..5b2cadfc --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Observability/FunctionalTransaction.cs @@ -0,0 +1,321 @@ +namespace Ums.Infrastructure.Observability; + +using System.Diagnostics; +using Microsoft.Extensions.Logging; +using Ums.Application.Common.Interfaces; + +/// +/// Transacción funcional con alcance de petición (ADR-0096; decisión UMS en ADR-UMS-085). +/// Modela el proceso como un relato y emite la narrativa —apertura, etapas, decisiones, +/// efectos y desenlace— como logs estructurados enriquecidos con el vocabulario +/// beyondnet.transaction.* / beyondnet.stage / beyondnet.effect.*, además de +/// etiquetas sobre la en curso para correlación en Tempo. +/// +/// El desenlace lo garantiza el middleware por construcción +/// (FunctionalTransactionMiddleware): abre con y cierra siempre +/// con , incluso en la ruta de fallo. Una apertura sin desenlace +/// es un defecto alertable, detectable en el store de observabilidad. +/// +public sealed class FunctionalTransaction : IFunctionalTransaction +{ + private readonly ILogger _logger; + private readonly ITransactionLocatorFactory _locatorFactory; + private readonly SemaphoreSlim _mintGate = new(1, 1); + + private long _startTimestamp; + private int _stagesCompleted; + private int _stagesFailed; + private bool _opened; + private bool _completed; + + public FunctionalTransaction( + ILogger logger, + ITransactionLocatorFactory locatorFactory) + { + _logger = logger; + _locatorFactory = locatorFactory; + } + + public string? Locator { get; private set; } + + public string Name { get; private set; } = "(sin nombre)"; + + public string? Actor { get; private set; } + + public TransactionState State { get; private set; } = TransactionState.Started; + + // --- Superficie del middleware (apertura y desenlace) --- + + /// Abre la transacción: registra la apertura de la narrativa. + public void Open(string name, string? actor) + { + if (_opened) + { + return; + } + + _opened = true; + Name = string.IsNullOrWhiteSpace(name) ? Name : name; + Actor = actor; + State = TransactionState.Started; + _startTimestamp = Stopwatch.GetTimestamp(); + + var activity = Activity.Current; + activity?.SetTag(ObservabilityKeys.TransactionName, Name); + activity?.SetTag(ObservabilityKeys.TransactionActor, Actor); + activity?.SetTag(ObservabilityKeys.TransactionState, TransactionStateNames.Started); + + using (BeginNarrativeScope(TransactionStateNames.Started)) + { + _logger.LogInformation( + "Apertura de transacción funcional «{TransactionName}» por {Actor}.", + Name, + Actor ?? "(anónimo)"); + } + } + + /// Actualiza el actor una vez resuelta la autenticación. + public void SetActor(string? actor) + { + Actor = actor; + Activity.Current?.SetTag(ObservabilityKeys.TransactionActor, actor); + } + + /// + /// Cierra la transacción emitiendo el desenlace (siempre, incluso en fallo). + /// No lanza: el desenlace no debe convertirse él mismo en un fallo silencioso. + /// + public async Task CompleteAsync( + TransactionState finalState, + int statusCode, + CancellationToken cancellationToken = default) + { + if (_completed) + { + return; + } + + _completed = true; + + // Reconciliación con las etapas: un éxito nominal con etapas fallidas es parcial. + if (finalState == TransactionState.Completed && _stagesFailed > 0) + { + finalState = _stagesCompleted > 0 + ? TransactionState.PartiallyCompleted + : TransactionState.Failed; + } + + State = finalState; + + // El localizador legible se muestra al usuario ante un desenlace no exitoso. + if (IsUnsuccessful(finalState)) + { + try + { + await GetOrMintLocatorAsync(cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "No se pudo acuñar el localizador en el desenlace de la transacción."); + } + } + + var durationMs = Stopwatch.GetElapsedTime(_startTimestamp).TotalMilliseconds; + var stateName = ToStateName(finalState); + + var activity = Activity.Current; + activity?.SetTag(ObservabilityKeys.TransactionState, stateName); + activity?.SetTag(ObservabilityKeys.TransactionOutcome, true); + activity?.SetTag(ObservabilityKeys.TransactionDurationMs, durationMs); + + using (BeginDesenlaceScope(stateName, durationMs)) + { + var level = IsUnsuccessful(finalState) ? LogLevel.Warning : LogLevel.Information; + _logger.Log( + level, + "Desenlace de transacción funcional «{TransactionName}»: {TransactionOutcomeState} " + + "(HTTP {StatusCode}) en {DurationMs:F1} ms; etapas {StagesCompleted} completadas, " + + "{StagesFailed} fallidas; referencia {TransactionLocator}.", + Name, + stateName, + statusCode, + durationMs, + _stagesCompleted, + _stagesFailed, + Locator ?? "(sin localizador — identificada por traceId)"); + } + } + + // --- Puerto IFunctionalTransaction (uso desde la capa de aplicación) --- + + public async Task GetOrMintLocatorAsync(CancellationToken cancellationToken = default) + { + if (Locator is not null) + { + return Locator; + } + + await _mintGate.WaitAsync(cancellationToken); + try + { + if (Locator is null) + { + Locator = await _locatorFactory.NextAsync(cancellationToken); + Activity.Current?.SetTag(ObservabilityKeys.TransactionId, Locator); + } + } + finally + { + _mintGate.Release(); + } + + return Locator; + } + + public void RecordStage(string stage, string? detail = null, bool failed = false) + { + if (failed) + { + _stagesFailed++; + } + else + { + _stagesCompleted++; + } + + var status = failed ? "failed" : "completed"; + var activity = Activity.Current; + activity?.AddEvent(new ActivityEvent( + stage, + tags: new ActivityTagsCollection + { + [ObservabilityKeys.Stage] = stage, + [ObservabilityKeys.StageStatus] = status, + })); + + using (BeginScope(new Dictionary + { + [ObservabilityKeys.Stage] = stage, + [ObservabilityKeys.StageStatus] = status, + })) + { + _logger.Log( + failed ? LogLevel.Warning : LogLevel.Information, + "Etapa «{Stage}» {StageStatus}{StageDetail}.", + stage, + status, + string.IsNullOrWhiteSpace(detail) ? string.Empty : $": {detail}"); + } + } + + public void RecordDecision(string decision, string reason) + { + using (BeginScope(new Dictionary + { + [ObservabilityKeys.Decision] = decision, + [ObservabilityKeys.DecisionReason] = reason, + })) + { + _logger.LogInformation( + "Decisión «{Decision}» porque {DecisionReason}.", + decision, + reason); + } + } + + public void RecordEffect( + string effectType, + string target, + string? reference, + EffectReversibility reversibility) + { + var reversibilityName = ToReversibilityName(reversibility); + + var activity = Activity.Current; + activity?.AddEvent(new ActivityEvent( + "effect", + tags: new ActivityTagsCollection + { + [ObservabilityKeys.EffectType] = effectType, + [ObservabilityKeys.EffectTarget] = target, + [ObservabilityKeys.EffectRef] = reference, + [ObservabilityKeys.EffectReversibility] = reversibilityName, + })); + + using (BeginScope(new Dictionary + { + [ObservabilityKeys.EffectType] = effectType, + [ObservabilityKeys.EffectTarget] = target, + [ObservabilityKeys.EffectRef] = reference ?? string.Empty, + [ObservabilityKeys.EffectReversibility] = reversibilityName, + })) + { + _logger.LogInformation( + "Efecto «{EffectType}» sobre {EffectTarget} (ref {EffectRef}, {EffectReversibility}).", + effectType, + target, + reference ?? "(sin ref)", + reversibilityName); + } + } + + public void MarkState(TransactionState state) + { + State = state; + Activity.Current?.SetTag(ObservabilityKeys.TransactionState, ToStateName(state)); + } + + // --- Auxiliares --- + + private IDisposable? BeginNarrativeScope(string stateName) + => BeginScope(new Dictionary + { + [ObservabilityKeys.TransactionName] = Name, + [ObservabilityKeys.TransactionState] = stateName, + [ObservabilityKeys.TransactionActor] = Actor ?? "(anónimo)", + }); + + private IDisposable? BeginDesenlaceScope(string stateName, double durationMs) + => BeginScope(new Dictionary + { + [ObservabilityKeys.TransactionName] = Name, + [ObservabilityKeys.TransactionState] = stateName, + [ObservabilityKeys.TransactionOutcome] = true, + [ObservabilityKeys.TransactionActor] = Actor ?? "(anónimo)", + [ObservabilityKeys.TransactionDurationMs] = durationMs, + [ObservabilityKeys.TransactionStagesCompleted] = _stagesCompleted, + [ObservabilityKeys.TransactionStagesFailed] = _stagesFailed, + [ObservabilityKeys.TransactionId] = Locator ?? string.Empty, + }); + + private IDisposable? BeginScope(Dictionary state) + => _logger.BeginScope(state); + + private static bool IsUnsuccessful(TransactionState state) + => state is TransactionState.Failed + or TransactionState.PartiallyCompleted + or TransactionState.TimedOut + or TransactionState.Cancelled; + + private static string ToStateName(TransactionState state) => state switch + { + TransactionState.Started => TransactionStateNames.Started, + TransactionState.InProgress => TransactionStateNames.InProgress, + TransactionState.Waiting => TransactionStateNames.Waiting, + TransactionState.Retrying => TransactionStateNames.Retrying, + TransactionState.Completed => TransactionStateNames.Completed, + TransactionState.PartiallyCompleted => TransactionStateNames.PartiallyCompleted, + TransactionState.Failed => TransactionStateNames.Failed, + TransactionState.Cancelled => TransactionStateNames.Cancelled, + TransactionState.TimedOut => TransactionStateNames.TimedOut, + _ => TransactionStateNames.Failed, + }; + + private static string ToReversibilityName(EffectReversibility reversibility) => reversibility switch + { + EffectReversibility.Compensated => "compensado", + EffectReversibility.PendingCompensation => "pendiente-de-compensar", + EffectReversibility.Irreversible => "irreversible", + _ => "pendiente-de-compensar", + }; +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Observability/ObservabilityConstants.cs b/src/apps/ums.api/Ums.Infrastructure/Observability/ObservabilityConstants.cs new file mode 100644 index 00000000..6da2e582 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Observability/ObservabilityConstants.cs @@ -0,0 +1,100 @@ +namespace Ums.Infrastructure.Observability; + +/// +/// Constantes de observabilidad propias de UMS (ADR-0046). +/// +/// Sustituyen al andamiaje que se vendorizó bajo el namespace de los shells +/// (BeyondNetCode.Shell.Aop.Aspects.Logger.Serilog, gap G-015): ese shim se +/// retiró al unificar la correlación en W3C TraceContext. La correlación deja de +/// generarse a mano (ya no hay X-Correlation-Id): el trace_id/span_id +/// se derivan de / traceparent. +/// +/// Se conserva únicamente el SessionTrackingId, que es un identificador de +/// producto de UMS (rastreo de sesión de negocio) y no forma parte del estándar W3C. +/// +public static class ObservabilityHeaders +{ + /// Cabecera HTTP del identificador de rastreo de sesión de negocio de UMS. + public const string SessionTrackingId = "X-Session-Tracking-Id"; +} + +/// Claves de baggage/etiquetas de la propias de UMS. +public static class ObservabilityKeys +{ + /// Clave de baggage/etiqueta para el identificador de rastreo de sesión. + /// ADR-0096 §2.1 regla 1: los atributos propios viven bajo el prefijo evolith.. + public const string SessionTrackingId = "evolith.session.tracking_id"; + + // --- Vocabulario de trazabilidad funcional (ADR-0096 §2.2–§2.4) --- + // Todos bajo el prefijo canónico `evolith.`. Son a la vez etiquetas de span (Tempo) + // y propiedades de log estructurado (Loki), lo que habilita la búsqueda sin ID + // (ADR-UMS-085): consulta del store de observabilidad por estos atributos. + + /// Localizador legible de la transacción funcional (TX-AAAA-NNNNNN). + public const string TransactionId = "evolith.transaction.id"; + + /// Nombre funcional de la transacción (acción del actor). + public const string TransactionName = "evolith.transaction.name"; + + /// Localizador de la transacción padre, si esta es hija de otra. + public const string TransactionParentId = "evolith.transaction.parent_id"; + + /// Estado de la transacción (valores de ). + public const string TransactionState = "evolith.transaction.state"; + + /// Actor que originó la transacción. + public const string TransactionActor = "evolith.transaction.actor"; + + /// Duración total de la transacción en milisegundos (en el desenlace). + public const string TransactionDurationMs = "evolith.transaction.duration_ms"; + + /// Etapas completadas al momento del desenlace. + public const string TransactionStagesCompleted = "evolith.transaction.stages_completed"; + + /// Etapas fallidas al momento del desenlace. + public const string TransactionStagesFailed = "evolith.transaction.stages_failed"; + + /// Marca el evento de log/span como el desenlace de la transacción. + public const string TransactionOutcome = "evolith.transaction.outcome"; + + /// Nombre de la etapa de negocio en curso. + public const string Stage = "evolith.stage"; + + /// Estado de la etapa (completed | failed). + public const string StageStatus = "evolith.stage.status"; + + /// Decisión de negocio tomada. + public const string Decision = "evolith.decision"; + + /// El porqué de la decisión. + public const string DecisionReason = "evolith.decision.reason"; + + /// Tipo de efecto sobre el mundo externo (p. ej. message.publish). + public const string EffectType = "evolith.effect.type"; + + /// Objetivo del efecto (recurso afectado). + public const string EffectTarget = "evolith.effect.target"; + + /// Referencia del efecto (id, clave o identificador del resultado). + public const string EffectRef = "evolith.effect.ref"; + + /// Reversibilidad del efecto (compensado | pendiente-de-compensar | irreversible). + public const string EffectReversibility = "evolith.effect.reversibility"; +} + +/// +/// Nombres canónicos de los estados de transacción (ADR-0095, referidos por ADR-0096 §2.3). +/// Son los valores que viajan en la etiqueta . +/// +public static class TransactionStateNames +{ + public const string Started = "STARTED"; + public const string InProgress = "IN_PROGRESS"; + public const string Waiting = "WAITING"; + public const string Retrying = "RETRYING"; + public const string Completed = "COMPLETED"; + public const string PartiallyCompleted = "PARTIALLY_COMPLETED"; + public const string Failed = "FAILED"; + public const string Cancelled = "CANCELLED"; + public const string TimedOut = "TIMED_OUT"; +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Observability/RequestContext.cs b/src/apps/ums.api/Ums.Infrastructure/Observability/RequestContext.cs new file mode 100644 index 00000000..b92d97e3 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Observability/RequestContext.cs @@ -0,0 +1,59 @@ +namespace Ums.Infrastructure.Observability; + +using System.Diagnostics; + +/// +/// Contexto de ejecución de la petición (scoped). Expone a la capa de aplicación los +/// datos de correlación W3C y el identificador de rastreo de sesión de UMS. +/// +/// Reemplaza a RequestContextAccessor + ExecutionContextSnapshot + +/// IExecutionContextAccessor, andamiaje que se vendorizó bajo el namespace de +/// los shells (gap G-015) y se retiró al unificar la correlación en W3C TraceContext +/// (ADR-0046): +/// +/// TraceId / SpanId — derivados de +/// (contexto de traza W3C propagado por traceparent). Ya no se calculan a mano. +/// CorrelationId — es el trace_id W3C: la correlación deja de +/// generarse como un GUID propio (X-Correlation-Id). +/// SessionTrackingId — identificador de rastreo de sesión de negocio +/// de UMS, ajeno al estándar W3C; lo fija SessionTrackingMiddleware. +/// ClientTimezone — zona horaria IANA del cliente (ADR-0076 D2); +/// la fija CultureMiddleware. +/// +/// +public sealed class RequestContext : IRequestContext +{ + private string? _sessionTrackingId; + private string? _clientTimezone; + + public string? SessionTrackingId => _sessionTrackingId; + + /// Unificación W3C (ADR-0046): la correlación es el trace_id de traceparent. + public string? CorrelationId => TraceId; + + public string? TraceId + { + get + { + var activity = Activity.Current; + return activity is { IdFormat: ActivityIdFormat.W3C } ? activity.TraceId.ToString() : null; + } + } + + public string? SpanId + { + get + { + var activity = Activity.Current; + return activity is { IdFormat: ActivityIdFormat.W3C } ? activity.SpanId.ToString() : null; + } + } + + public string? ClientTimezone => _clientTimezone; + + public void SetSessionTrackingId(string? sessionTrackingId) + => _sessionTrackingId = string.IsNullOrWhiteSpace(sessionTrackingId) ? null : sessionTrackingId; + + public void SetClientTimezone(string? timezone) + => _clientTimezone = string.IsNullOrWhiteSpace(timezone) ? null : timezone; +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Observability/TransactionLocatorFactory.cs b/src/apps/ums.api/Ums.Infrastructure/Observability/TransactionLocatorFactory.cs new file mode 100644 index 00000000..f3721aac --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Observability/TransactionLocatorFactory.cs @@ -0,0 +1,87 @@ +namespace Ums.Infrastructure.Observability; + +using System.Collections.Concurrent; +using System.Data; +using System.Globalization; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; +using Ums.Application.Common.Interfaces; +using Ums.Infrastructure.Persistence; + +/// +/// Acuña el localizador legible TX-AAAA-NNNNNN con una secuencia PostgreSQL por +/// año (ADR-UMS-084). La secuencia se crea de forma perezosa e idempotente +/// (CREATE SEQUENCE IF NOT EXISTS) y reinicia al cambiar de año, porque cada año +/// tiene su propia secuencia (tx_locator_AAAA). Las secuencias de PostgreSQL son +/// no transaccionales: nextval persiste aunque la transacción de la petición +/// se revierta, lo que da un localizador monotónico sin huecos reutilizables. +/// +/// Fuera de un proveedor relacional (p. ej. el proveedor InMemory de las pruebas) se cae a +/// un contador en memoria del proceso, de modo que el formato del localizador se mantiene +/// verificable sin una base de datos real. +/// +public sealed class TransactionLocatorFactory : ITransactionLocatorFactory +{ + private static readonly ConcurrentDictionary InMemoryCounters = new(); + + private readonly UmsPlatformDbContext _dbContext; + + public TransactionLocatorFactory(UmsPlatformDbContext dbContext) + => _dbContext = dbContext; + + public async Task NextAsync(CancellationToken cancellationToken = default) + { + var year = DateTimeOffset.UtcNow.Year; + var next = _dbContext.Database.IsRelational() + ? await NextFromSequenceAsync(year, cancellationToken) + : InMemoryCounters.AddOrUpdate(year, 1, static (_, current) => current + 1); + + return Format(year, next); + } + + internal static string Format(int year, long sequence) + => string.Create(CultureInfo.InvariantCulture, $"TX-{year:D4}-{sequence:D6}"); + + private async Task NextFromSequenceAsync(int year, CancellationToken cancellationToken) + { + // El nombre de la secuencia se compone de un entero validado (el año), no de + // entrada del usuario: no hay superficie de inyección. + var sequenceName = $"\"{UmsPlatformDbContext.DefaultSchema}\".\"tx_locator_{year}\""; + + var connection = _dbContext.Database.GetDbConnection(); + var mustClose = connection.State != ConnectionState.Open; + if (mustClose) + { + await connection.OpenAsync(cancellationToken); + } + + try + { + await using var command = connection.CreateCommand(); + + var currentTransaction = _dbContext.Database.CurrentTransaction; + if (currentTransaction is not null) + { + command.Transaction = currentTransaction.GetDbTransaction(); + } + + // S2077: el identificador de secuencia NO es parametrizable en SQL (los nombres de objeto no + // admiten binding). sequenceName se compone de un entero validado (el año) y un esquema constante, + // no de entrada del usuario (ver comentario superior): no hay superficie de inyección. +#pragma warning disable S2077 + command.CommandText = + $"CREATE SEQUENCE IF NOT EXISTS {sequenceName}; SELECT nextval('{sequenceName}');"; +#pragma warning restore S2077 + + var scalar = await command.ExecuteScalarAsync(cancellationToken); + return Convert.ToInt64(scalar, CultureInfo.InvariantCulture); + } + finally + { + if (mustClose) + { + await connection.CloseAsync(); + } + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Approvals/Entities/AccessEnforcementPolicyRecord.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Approvals/Entities/AccessEnforcementPolicyRecord.cs index b4bda599..e1c34575 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Approvals/Entities/AccessEnforcementPolicyRecord.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Approvals/Entities/AccessEnforcementPolicyRecord.cs @@ -11,6 +11,7 @@ public sealed class AccessEnforcementPolicyRecord : IAuditableRecord public Guid? RoleId { get; set; } public int EnforcementActionId { get; set; } public bool IsActive { get; set; } + public int GracePeriodDays { get; set; } public string CreatedBy { get; set; } = string.Empty; public DateTime CreatedAtUtc { get; set; } public string? UpdatedBy { get; set; } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Approvals/PostgreSqlAccessEnforcementPolicyRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Approvals/PostgreSqlAccessEnforcementPolicyRepository.cs index 6963d148..46bce43d 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Approvals/PostgreSqlAccessEnforcementPolicyRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Approvals/PostgreSqlAccessEnforcementPolicyRepository.cs @@ -117,6 +117,7 @@ private static AccessEnforcementPolicyRecord ToRecord(AccessEnforcementPolicyAgg RoleId = aggregate.RoleId?.GetValue(), EnforcementActionId = aggregate.EnforcementAction.Id, IsActive = aggregate.IsActive, + GracePeriodDays = aggregate.GracePeriodDays, CreatedBy = audit.CreatedBy, CreatedAtUtc = audit.CreatedAt, UpdatedBy = audit.UpdatedBy, @@ -134,6 +135,7 @@ private static void Apply(AccessEnforcementPolicyRecord target, AccessEnforcemen target.RoleId = replacement.RoleId; target.EnforcementActionId = replacement.EnforcementActionId; target.IsActive = replacement.IsActive; + target.GracePeriodDays = replacement.GracePeriodDays; target.CreatedBy = replacement.CreatedBy; target.CreatedAtUtc = replacement.CreatedAtUtc; target.UpdatedBy = replacement.UpdatedBy; diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Audit/PostgreSqlAuditRecordRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Audit/PostgreSqlAuditRecordRepository.cs index 7a7b4336..61049374 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Audit/PostgreSqlAuditRecordRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Audit/PostgreSqlAuditRecordRepository.cs @@ -4,6 +4,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; +using Ums.Application.Common.Interfaces; using Ums.Domain.Audit.AuditRecord; using Ums.Infrastructure.Persistence.Audit.Entities; using Ums.Infrastructure.Persistence.Reflection; @@ -16,19 +17,40 @@ namespace Ums.Infrastructure.Persistence.Audit; public sealed class PostgreSqlAuditRecordRepository : IAuditRecordRepository, BeyondNetCode.Shell.Ddd.Interfaces.IUnitOfWork { private readonly UmsPlatformDbContext _dbContext; + private readonly ITenantContext _tenantContext; private readonly HashSet _trackedAggregates = []; - public PostgreSqlAuditRecordRepository(UmsPlatformDbContext dbContext) + public PostgreSqlAuditRecordRepository(UmsPlatformDbContext dbContext, ITenantContext tenantContext) { _dbContext = dbContext; + _tenantContext = tenantContext; } BeyondNetCode.Shell.Ddd.Interfaces.IUnitOfWork IRepository.UnitOfWork => this; public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) { - var record = await _dbContext.Set() - .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + // G-103 — Aislamiento por inquilino en la lectura por Id. Sin este filtro, un actor podía + // leer la traza de OTRO inquilino conociendo su Id (fuga cross-tenant). La tabla de + // auditoría NO lleva query filter global de EF a propósito: rompería la lectura cross-tenant + // legítima del admin de plataforma (GetAllAuditRecordsQueryHandler fija + // effectiveTenantId = IsInternalAdmin ? request.TenantId : OrganizationId, que para el admin + // difiere de OrganizationId) y las QueryBy* que reciben el inquilino de forma explícita. Por + // eso el aislamiento se aplica aquí, en la capa de aplicación, igual que hacen las QueryBy*. + // + // Bypass legítimo (misma semántica que effectiveTenantId del handler y el convenio FIX-05): + // - IsInternalAdmin → admin de plataforma con vista cross-tenant. + // - OrganizationId nulo → contexto de sistema/segundo plano (sembrado, outbox). + // En cualquier otro caso se restringe a la traza del inquilino del contexto. + var query = _dbContext.Set().Where(x => x.Id == id); + + var organizationId = _tenantContext.OrganizationId; + if (!_tenantContext.IsInternalAdmin && organizationId.HasValue) + { + query = query.Where(x => x.RootTenantId == organizationId.Value); + } + + var record = await query.FirstOrDefaultAsync(cancellationToken); return record is null ? null : Rehydrate(record); } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/PermissionTemplateItemRecordConfiguration.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/PermissionTemplateItemRecordConfiguration.cs index 83591b0c..2419643c 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/PermissionTemplateItemRecordConfiguration.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/PermissionTemplateItemRecordConfiguration.cs @@ -16,5 +16,11 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.AuditTimeSpan).HasMaxLength(100).IsRequired(); builder.HasIndex(x => new { x.TemplateId, x.TargetTypeId, x.TargetId, x.ActionId }).IsUnique(); + + // El índice único empieza por TemplateId, así que no sirve para preguntar «qué plantillas + // tocan este destino», que es lo que hacen las guardas de dependencia antes de borrar una + // opción o un recurso. Sin él, esa comprobación recorre la tabla entera. + builder.HasIndex(x => new { x.TargetId, x.IsActive }) + .HasDatabaseName("IX_PermissionTemplateItems_TargetId_IsActive"); } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/ProfilePermissionRecordConfiguration.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/ProfilePermissionRecordConfiguration.cs index c5a1c042..bee8e531 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/ProfilePermissionRecordConfiguration.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/ProfilePermissionRecordConfiguration.cs @@ -17,5 +17,10 @@ public void Configure(EntityTypeBuilder builder) builder.HasIndex(x => x.ProfileId); builder.HasIndex(x => new { x.ProfileId, x.TemplateId, x.ActionId, x.TargetId }); + + // El compuesto anterior lidera por ProfileId: no cubre «cuántos perfiles usan esta + // plantilla», la guarda que corre antes de despublicar o borrar una plantilla. + builder.HasIndex(x => x.TemplateId) + .HasDatabaseName("IX_ProfilePermissions_TemplateId"); } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/ProfileRecordConfiguration.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/ProfileRecordConfiguration.cs index dd73429d..2953997d 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/ProfileRecordConfiguration.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/ProfileRecordConfiguration.cs @@ -17,7 +17,13 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.RowVersion).IsRowVersion(); // FIX-03: optimistic concurrency builder.HasIndex(x => x.TenantId); - builder.HasIndex(x => x.UserId); + builder.HasIndex(x => x.UserId, "IX_Profiles_UserId"); + + // Índice PARCIAL: el login pregunta siempre por los perfiles ACTIVOS de un usuario. Al + // filtrar el índice por esa condición, las filas inactivas —que solo crecen— no ocupan + // espacio en él ni ensucian el plan. + builder.HasIndex(x => x.UserId, "IX_Profiles_UserId_Active") + .HasFilter("\"IsActive\" = true"); builder.HasIndex(x => new { x.TenantId, x.UserId, x.RoleId, x.BranchId }); builder.HasMany(x => x.Permissions) diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteAppSettingRecordConfiguration.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteAppSettingRecordConfiguration.cs index be0bbf0d..dd368649 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteAppSettingRecordConfiguration.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteAppSettingRecordConfiguration.cs @@ -14,6 +14,10 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.ConfigKey).HasMaxLength(100).IsRequired(); builder.Property(x => x.ConfigValue).HasMaxLength(4000).IsRequired(); + // G-178: fail-closed también en la base. Las filas que ya existan quedan en `false`, que + // es lo correcto: nadie decidió publicarlas. + builder.Property(x => x.IsClientVisible).IsRequired().HasDefaultValue(false); + builder.HasIndex(x => new { x.SystemSuiteId, x.ConfigKey, x.ScopeId }).IsUnique(); } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteDomainResourceRecordConfiguration.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteDomainResourceRecordConfiguration.cs index 96a0fb73..826c2d1c 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteDomainResourceRecordConfiguration.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteDomainResourceRecordConfiguration.cs @@ -20,6 +20,11 @@ public void Configure(EntityTypeBuilder builder builder.Property(x => x.UpdatedBy).HasMaxLength(100); builder.Property(x => x.AuditTimeSpan).HasMaxLength(100).IsRequired(); + builder.HasIndex(x => x.ModuleId) + + .HasDatabaseName("IX_SystemSuiteDomainResources_ModuleId"); + + builder.HasIndex(x => new { x.SystemSuiteId, x.Code }).IsUnique(); } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteMenuRecordConfiguration.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteMenuRecordConfiguration.cs deleted file mode 100644 index 52b8fce9..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteMenuRecordConfiguration.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Ums.Infrastructure.Persistence.Authorization.Entities; - -namespace Ums.Infrastructure.Persistence.Authorization.Configurations; - -public sealed class SystemSuiteMenuRecordConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("SystemSuiteMenus", AuthorizationPersistenceConstants.Schema); - builder.HasKey(x => x.Id); - - builder.Property(x => x.Code).HasMaxLength(100).IsRequired(); - builder.Property(x => x.Label).HasMaxLength(200).IsRequired(); - builder.Property(x => x.Description).HasMaxLength(1000).IsRequired(); - builder.Property(x => x.CreatedBy).HasMaxLength(100).IsRequired(); - builder.Property(x => x.UpdatedBy).HasMaxLength(100); - builder.Property(x => x.AuditTimeSpan).HasMaxLength(100).IsRequired(); - - builder.HasIndex(x => new { x.ModuleId, x.Code }).IsUnique(); - - builder.HasMany(x => x.SubMenus) - .WithOne(x => x.Menu) - .HasForeignKey(x => x.MenuId) - .OnDelete(DeleteBehavior.Cascade); - } -} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteModuleRecordConfiguration.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteModuleRecordConfiguration.cs index aae40a6d..d4d957a1 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteModuleRecordConfiguration.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteModuleRecordConfiguration.cs @@ -14,15 +14,11 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.Code).HasMaxLength(100).IsRequired(); builder.Property(x => x.Name).HasMaxLength(200).IsRequired(); builder.Property(x => x.Description).HasMaxLength(1000).IsRequired(); + builder.Property(x => x.Icon).HasMaxLength(64); builder.Property(x => x.CreatedBy).HasMaxLength(100).IsRequired(); builder.Property(x => x.UpdatedBy).HasMaxLength(100); builder.Property(x => x.AuditTimeSpan).HasMaxLength(100).IsRequired(); builder.HasIndex(x => new { x.SystemSuiteId, x.Code }).IsUnique(); - - builder.HasMany(x => x.Menus) - .WithOne(x => x.Module) - .HasForeignKey(x => x.ModuleId) - .OnDelete(DeleteBehavior.Cascade); } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteNodeActionRecordConfiguration.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteNodeActionRecordConfiguration.cs new file mode 100644 index 00000000..1bf5da15 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteNodeActionRecordConfiguration.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Ums.Infrastructure.Persistence.Authorization.Entities; + +namespace Ums.Infrastructure.Persistence.Authorization.Configurations; + +public sealed class SystemSuiteNodeActionRecordConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("SystemSuiteNodeActions", AuthorizationPersistenceConstants.Schema); + builder.HasKey(x => x.Id); + + builder.Property(x => x.ActionCode).HasMaxLength(100).IsRequired(); + + // Una funcionalidad no se vincula dos veces al mismo nodo. + builder.HasIndex(x => new { x.NodeId, x.ActionCode }).IsUnique(); + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteNodeRecordConfiguration.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteNodeRecordConfiguration.cs new file mode 100644 index 00000000..372d24fe --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteNodeRecordConfiguration.cs @@ -0,0 +1,53 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Ums.Infrastructure.Persistence.Authorization.Entities; + +namespace Ums.Infrastructure.Persistence.Authorization.Configurations; + +public sealed class SystemSuiteNodeRecordConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("SystemSuiteNodes", AuthorizationPersistenceConstants.Schema); + builder.HasKey(x => x.Id); + + builder.Property(x => x.Code).HasMaxLength(100).IsRequired(); + builder.Property(x => x.Label).HasMaxLength(200).IsRequired(); + builder.Property(x => x.Description).HasMaxLength(1000).IsRequired(); + builder.Property(x => x.CreatedBy).HasMaxLength(100).IsRequired(); + builder.Property(x => x.UpdatedBy).HasMaxLength(100); + builder.Property(x => x.AuditTimeSpan).HasMaxLength(100).IsRequired(); + + // Metadatos SDLC opcionales + builder.Property(x => x.Responsable).HasMaxLength(200); + builder.Property(x => x.Criticidad).HasMaxLength(50); + builder.Property(x => x.ProductoImpactado).HasMaxLength(200); + builder.Property(x => x.ComponenteTecnico).HasMaxLength(200); + builder.Property(x => x.Dependencias).HasMaxLength(2000); + builder.Property(x => x.Evidencias).HasMaxLength(2000); + builder.Property(x => x.TrazabilidadSdlc).HasMaxLength(2000); + + // Código único entre hermanos (por módulo + padre). En Postgres los NULL + // de ParentNodeId son distintos, así que la unicidad de raíces la garantiza + // adicionalmente el dominio; el índice cubre consultas y ramas no-raíz. + builder.Property(x => x.Icon).HasMaxLength(64); + builder.Property(x => x.Route).HasMaxLength(400); + + builder.HasIndex(x => new { x.ModuleId, x.ParentNodeId, x.Code }).IsUnique(); + + builder.HasOne(x => x.Module) + .WithMany(m => m.Nodes) + .HasForeignKey(x => x.ModuleId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(x => x.Parent) + .WithMany(p => p.Children) + .HasForeignKey(x => x.ParentNodeId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasMany(x => x.Actions) + .WithOne(a => a.Node) + .HasForeignKey(a => a.NodeId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteOptionRecordConfiguration.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteOptionRecordConfiguration.cs deleted file mode 100644 index 6f39071c..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteOptionRecordConfiguration.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Ums.Infrastructure.Persistence.Authorization.Entities; - -namespace Ums.Infrastructure.Persistence.Authorization.Configurations; - -public sealed class SystemSuiteOptionRecordConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("SystemSuiteOptions", AuthorizationPersistenceConstants.Schema); - builder.HasKey(x => x.Id); - - builder.Property(x => x.Code).HasMaxLength(100).IsRequired(); - builder.Property(x => x.Label).HasMaxLength(200).IsRequired(); - builder.Property(x => x.Description).HasMaxLength(1000).IsRequired(); - builder.Property(x => x.ActionCode).HasMaxLength(100).IsRequired(); - builder.Property(x => x.CreatedBy).HasMaxLength(100).IsRequired(); - builder.Property(x => x.UpdatedBy).HasMaxLength(100); - builder.Property(x => x.AuditTimeSpan).HasMaxLength(100).IsRequired(); - - builder.HasIndex(x => new { x.SubMenuId, x.Code }).IsUnique(); - } -} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteSubMenuRecordConfiguration.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteSubMenuRecordConfiguration.cs deleted file mode 100644 index cfbb35c8..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Configurations/SystemSuiteSubMenuRecordConfiguration.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Ums.Infrastructure.Persistence.Authorization.Entities; - -namespace Ums.Infrastructure.Persistence.Authorization.Configurations; - -public sealed class SystemSuiteSubMenuRecordConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("SystemSuiteSubMenus", AuthorizationPersistenceConstants.Schema); - builder.HasKey(x => x.Id); - - builder.Property(x => x.Code).HasMaxLength(100).IsRequired(); - builder.Property(x => x.Label).HasMaxLength(200).IsRequired(); - builder.Property(x => x.Description).HasMaxLength(1000).IsRequired(); - builder.Property(x => x.CreatedBy).HasMaxLength(100).IsRequired(); - builder.Property(x => x.UpdatedBy).HasMaxLength(100); - builder.Property(x => x.AuditTimeSpan).HasMaxLength(100).IsRequired(); - - builder.HasIndex(x => new { x.MenuId, x.Code }).IsUnique(); - - builder.HasMany(x => x.Options) - .WithOne(x => x.SubMenu) - .HasForeignKey(x => x.SubMenuId) - .OnDelete(DeleteBehavior.Cascade); - } -} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteAppSettingRecord.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteAppSettingRecord.cs index 071adc06..1fa67f07 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteAppSettingRecord.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteAppSettingRecord.cs @@ -8,5 +8,8 @@ public sealed class SystemSuiteAppSettingRecord public string ConfigValue { get; set; } = string.Empty; public int ScopeId { get; set; } + /// Marca de exposición al cliente (G-178). Falso por defecto: fail-closed. + public bool IsClientVisible { get; set; } + public SystemSuiteRecord SystemSuite { get; set; } = null!; } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteMenuRecord.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteMenuRecord.cs deleted file mode 100644 index a1495a82..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteMenuRecord.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace Ums.Infrastructure.Persistence.Authorization.Entities; - -public sealed class SystemSuiteMenuRecord : IAuditableRecord -{ - public Guid Id { get; set; } - public Guid ModuleId { get; set; } - public string Code { get; set; } = string.Empty; - public string Label { get; set; } = string.Empty; - public string Description { get; set; } = string.Empty; - public int SortOrder { get; set; } - public string CreatedBy { get; set; } = string.Empty; - public DateTime CreatedAtUtc { get; set; } - public string? UpdatedBy { get; set; } - public DateTime? UpdatedAtUtc { get; set; } - public string AuditTimeSpan { get; set; } = string.Empty; - - public SystemSuiteModuleRecord Module { get; set; } = null!; - public List SubMenus { get; set; } = []; -} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteModuleRecord.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteModuleRecord.cs index f6d24e2d..775850d2 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteModuleRecord.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteModuleRecord.cs @@ -9,6 +9,7 @@ public sealed class SystemSuiteModuleRecord : IAuditableRecord public string Description { get; set; } = string.Empty; public int StatusId { get; set; } public int SortOrder { get; set; } + public string? Icon { get; set; } public string CreatedBy { get; set; } = string.Empty; public DateTime CreatedAtUtc { get; set; } public string? UpdatedBy { get; set; } @@ -16,5 +17,5 @@ public sealed class SystemSuiteModuleRecord : IAuditableRecord public string AuditTimeSpan { get; set; } = string.Empty; public SystemSuiteRecord SystemSuite { get; set; } = null!; - public List Menus { get; set; } = []; + public List Nodes { get; set; } = []; } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteNodeActionRecord.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteNodeActionRecord.cs new file mode 100644 index 00000000..5f979337 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteNodeActionRecord.cs @@ -0,0 +1,15 @@ +namespace Ums.Infrastructure.Persistence.Authorization.Entities; + +/// +/// Puente N:M entre un nodo hoja (Opción) y una funcionalidad del catálogo +/// (`SystemSuiteActions.Code`) — ADR-0090. Una opción puede vincular varias +/// funcionalidades y una funcionalidad puede estar en varias opciones. +/// +public sealed class SystemSuiteNodeActionRecord +{ + public Guid Id { get; set; } + public Guid NodeId { get; set; } + public string ActionCode { get; set; } = string.Empty; + + public SystemSuiteNodeRecord Node { get; set; } = null!; +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteNodeRecord.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteNodeRecord.cs new file mode 100644 index 00000000..364ea698 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteNodeRecord.cs @@ -0,0 +1,45 @@ +namespace Ums.Infrastructure.Persistence.Authorization.Entities; + +/// +/// Nodo del árbol de navegación recursivo (ADR-0090). Adjacency list: +/// nulo ⇒ hijo directo del módulo. Reemplazará a +/// SystemSuiteMenus/SubMenus/Options (convivencia aditiva durante la migración). +/// +public sealed class SystemSuiteNodeRecord : IAuditableRecord +{ + public Guid Id { get; set; } + public Guid ModuleId { get; set; } + public Guid? ParentNodeId { get; set; } + public int NodeKindId { get; set; } + public string Code { get; set; } = string.Empty; + public string Label { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public int StatusId { get; set; } + public int SortOrder { get; set; } + + /// Presentación (G-178): identificador de icono y ruta destino. Opcionales. + public string? Icon { get; set; } + public string? Route { get; set; } + + // ── Metadatos de gobernanza SDLC (opcionales) ── + public string? Responsable { get; set; } + public string? Criticidad { get; set; } + public string? ProductoImpactado { get; set; } + public string? ComponenteTecnico { get; set; } + public string? Dependencias { get; set; } + public string? Evidencias { get; set; } + public string? TrazabilidadSdlc { get; set; } + + // ── Auditoría ── + public string CreatedBy { get; set; } = string.Empty; + public DateTime CreatedAtUtc { get; set; } + public string? UpdatedBy { get; set; } + public DateTime? UpdatedAtUtc { get; set; } + public string AuditTimeSpan { get; set; } = string.Empty; + + // ── Navegaciones ── + public SystemSuiteModuleRecord Module { get; set; } = null!; + public SystemSuiteNodeRecord? Parent { get; set; } + public List Children { get; set; } = []; + public List Actions { get; set; } = []; +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteOptionRecord.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteOptionRecord.cs deleted file mode 100644 index 773767d9..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteOptionRecord.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace Ums.Infrastructure.Persistence.Authorization.Entities; - -public sealed class SystemSuiteOptionRecord : IAuditableRecord -{ - public Guid Id { get; set; } - public Guid SubMenuId { get; set; } - public string Code { get; set; } = string.Empty; - public string Label { get; set; } = string.Empty; - public string Description { get; set; } = string.Empty; - public string ActionCode { get; set; } = string.Empty; - public int SortOrder { get; set; } - public string CreatedBy { get; set; } = string.Empty; - public DateTime CreatedAtUtc { get; set; } - public string? UpdatedBy { get; set; } - public DateTime? UpdatedAtUtc { get; set; } - public string AuditTimeSpan { get; set; } = string.Empty; - - public SystemSuiteSubMenuRecord SubMenu { get; set; } = null!; -} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteSubMenuRecord.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteSubMenuRecord.cs deleted file mode 100644 index 0e8b007d..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Entities/SystemSuiteSubMenuRecord.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace Ums.Infrastructure.Persistence.Authorization.Entities; - -public sealed class SystemSuiteSubMenuRecord : IAuditableRecord -{ - public Guid Id { get; set; } - public Guid MenuId { get; set; } - public string Code { get; set; } = string.Empty; - public string Label { get; set; } = string.Empty; - public string Description { get; set; } = string.Empty; - public int SortOrder { get; set; } - public string CreatedBy { get; set; } = string.Empty; - public DateTime CreatedAtUtc { get; set; } - public string? UpdatedBy { get; set; } - public DateTime? UpdatedAtUtc { get; set; } - public string AuditTimeSpan { get; set; } = string.Empty; - - public SystemSuiteMenuRecord Menu { get; set; } = null!; - public List Options { get; set; } = []; -} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Exporters/ProfileCsvExporter.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Exporters/ProfileCsvExporter.cs index 12445edf..46c2f874 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Exporters/ProfileCsvExporter.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Exporters/ProfileCsvExporter.cs @@ -29,7 +29,7 @@ public string Export(ProfileDto profile, ExportConfiguration? configuration = nu { var targetNameEscaped = p.TargetName.Replace("\"", "\"\""); var actionNameEscaped = p.ActionName.Replace("\"", "\"\""); - var effect = p.IsAllowed ? "Allow" : (p.IsDenied ? "Deny" : "Neutral"); + var effect = ProfileExporterBase.GetEffect(p.IsAllowed, p.IsDenied); sb.AppendLine($"{p.TargetType},\"{targetNameEscaped}\",\"{actionNameEscaped}\",{effect},{p.IsActive},{p.IsOverride}"); } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Exporters/ProfileExporterBase.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Exporters/ProfileExporterBase.cs index 616612cd..8892868d 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Exporters/ProfileExporterBase.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Exporters/ProfileExporterBase.cs @@ -5,26 +5,44 @@ namespace Ums.Infrastructure.Persistence.Authorization.Exporters; public abstract class ProfileExporterBase { + public static string GetEffect(bool isAllowed, bool isDenied) + { + if (isAllowed) return "Allow"; + if (isDenied) return "Deny"; + return "Neutral"; + } + + public static string FormatId(Guid id, ExportConfiguration config) + { + return config.MaskGuids ? MaskGuid(id) : id.ToString(); + } + + public static string? FormatId(Guid? id, ExportConfiguration config) + { + if (!id.HasValue) return null; + return config.MaskGuids ? MaskGuid(id.Value) : id.Value.ToString(); + } + protected static object MapPermission(ProfilePermissionDto p, ExportConfiguration config) { return new { - id = config.MaskGuids ? MaskGuid(p.PermissionId) : p.PermissionId.ToString(), - targetId = config.MaskGuids ? MaskGuid(p.TargetId) : p.TargetId.ToString(), + id = FormatId(p.PermissionId, config), + targetId = FormatId(p.TargetId, config), targetType = p.TargetType, targetName = p.TargetName, - actionId = config.MaskGuids ? MaskGuid(p.ActionId) : p.ActionId.ToString(), + actionId = FormatId(p.ActionId, config), actionName = p.ActionName, - effect = p.IsAllowed ? "Allow" : (p.IsDenied ? "Deny" : "Neutral"), + effect = GetEffect(p.IsAllowed, p.IsDenied), isActive = p.IsActive, isOverride = p.IsOverride, originalFromTemplate = p.OriginalFromTemplate != null ? new { - id = config.MaskGuids ? MaskGuid(p.OriginalFromTemplate.ItemId) : p.OriginalFromTemplate.ItemId.ToString(), - targetId = config.MaskGuids ? MaskGuid(p.OriginalFromTemplate.TargetId) : p.OriginalFromTemplate.TargetId.ToString(), + id = FormatId(p.OriginalFromTemplate.ItemId, config), + targetId = FormatId(p.OriginalFromTemplate.TargetId, config), targetType = p.OriginalFromTemplate.TargetType, targetName = p.OriginalFromTemplate.TargetName, - effect = p.OriginalFromTemplate.IsAllowed ? "Allow" : (p.OriginalFromTemplate.IsDenied ? "Deny" : "Neutral"), + effect = GetEffect(p.OriginalFromTemplate.IsAllowed, p.OriginalFromTemplate.IsDenied), isActive = p.OriginalFromTemplate.IsActive } : null }; diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Exporters/ProfileJsonExporter.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Exporters/ProfileJsonExporter.cs index f4f2b86a..2b38aabb 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Exporters/ProfileJsonExporter.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Exporters/ProfileJsonExporter.cs @@ -28,7 +28,7 @@ public string Export(ProfileDto profile, ExportConfiguration? configuration = nu .Select(p => MapPermission(p, config)) .ToList(); - var effectivePermissionsSummary = BuildEffectivePermissionsSummary(profile, config); + var effectivePermissionsSummary = BuildEffectivePermissionsSummary(profile); object result; @@ -63,9 +63,7 @@ public string Export(ProfileDto profile, ExportConfiguration? configuration = nu name = profile.RoleName, status = profile.IsActive ? "Active" : "Inactive", roleLevel = profile.Scope, - permissionTemplateId = profile.Permissions.FirstOrDefault()?.TemplateId != null - ? (config.MaskGuids ? MaskGuid(profile.Permissions.First().TemplateId) : profile.Permissions.First().TemplateId.ToString()) - : null, + permissionTemplateId = ProfileExporterBase.FormatId(profile.Permissions.FirstOrDefault()?.TemplateId, config), permissionCount = profile.PermissionCount }, authorizationGraph = new @@ -125,22 +123,22 @@ private static object MapPermission(ProfilePermissionDto p, ExportConfiguration targetName = p.TargetName, actionId = config.MaskGuids ? MaskGuid(p.ActionId) : p.ActionId.ToString(), actionName = p.ActionName, - effect = p.IsAllowed ? "Allow" : (p.IsDenied ? "Deny" : "Neutral"), + effect = ProfileExporterBase.GetEffect(p.IsAllowed, p.IsDenied), isActive = p.IsActive, isOverride = p.IsOverride, originalFromTemplate = p.OriginalFromTemplate != null ? new { - id = config.MaskGuids ? MaskGuid(p.OriginalFromTemplate.ItemId) : p.OriginalFromTemplate.ItemId.ToString(), - targetId = config.MaskGuids ? MaskGuid(p.OriginalFromTemplate.TargetId) : p.OriginalFromTemplate.TargetId.ToString(), + id = ProfileExporterBase.FormatId(p.OriginalFromTemplate.ItemId, config), + targetId = ProfileExporterBase.FormatId(p.OriginalFromTemplate.TargetId, config), targetType = p.OriginalFromTemplate.TargetType, targetName = p.OriginalFromTemplate.TargetName, - effect = p.OriginalFromTemplate.IsAllowed ? "Allow" : (p.OriginalFromTemplate.IsDenied ? "Deny" : "Neutral"), + effect = ProfileExporterBase.GetEffect(p.OriginalFromTemplate.IsAllowed, p.OriginalFromTemplate.IsDenied), isActive = p.OriginalFromTemplate.IsActive } : null }; } - private static object BuildEffectivePermissionsSummary(ProfileDto profile, ExportConfiguration config) + private static object BuildEffectivePermissionsSummary(ProfileDto profile) { var totalPermissions = profile.Permissions.Count; var allowedPermissions = profile.Permissions.Count(p => p.IsAllowed); @@ -159,5 +157,9 @@ private static object BuildEffectivePermissionsSummary(ProfileDto profile, Expor }; } + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Major Code Smell", "S1172:Unused method parameters should be removed", + Justification = "Enmascaramiento PII: descarta el valor a propósito y devuelve una máscara fija. " + + "El parámetro conserva la firma de una función de enmascarado.")] private static string MaskGuid(Guid guid) => "****-****-****-****"; } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Exporters/ProfileXmlExporter.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Exporters/ProfileXmlExporter.cs index 6d1c48f3..f7301f52 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Exporters/ProfileXmlExporter.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Exporters/ProfileXmlExporter.cs @@ -21,7 +21,7 @@ public string Export(ProfileDto profile, ExportConfiguration? configuration = nu new XElement("TargetType", p.TargetType), new XElement("TargetName", p.TargetName), new XElement("ActionName", p.ActionName), - new XElement("Effect", p.IsAllowed ? "Allow" : (p.IsDenied ? "Deny" : "Neutral")), + new XElement("Effect", ProfileExporterBase.GetEffect(p.IsAllowed, p.IsDenied)), new XElement("IsActive", p.IsActive), new XElement("IsOverride", p.IsOverride) ); @@ -61,7 +61,7 @@ public string Export(ProfileDto profile, ExportConfiguration? configuration = nu modulesElement.Add(new XElement("Module", new XElement("TargetName", p.TargetName), new XElement("ActionName", p.ActionName), - new XElement("Effect", p.IsAllowed ? "Allow" : (p.IsDenied ? "Deny" : "Neutral")))); + new XElement("Effect", ProfileExporterBase.GetEffect(p.IsAllowed, p.IsDenied)))); } graphElement.Add(modulesElement); @@ -71,7 +71,7 @@ public string Export(ProfileDto profile, ExportConfiguration? configuration = nu domainElement.Add(new XElement("Resource", new XElement("TargetName", p.TargetName), new XElement("ActionName", p.ActionName), - new XElement("Effect", p.IsAllowed ? "Allow" : (p.IsDenied ? "Deny" : "Neutral")))); + new XElement("Effect", ProfileExporterBase.GetEffect(p.IsAllowed, p.IsDenied)))); } graphElement.Add(domainElement); @@ -81,7 +81,7 @@ public string Export(ProfileDto profile, ExportConfiguration? configuration = nu actionsElement.Add(new XElement("Action", new XElement("TargetName", p.TargetName), new XElement("ActionName", p.ActionName), - new XElement("Effect", p.IsAllowed ? "Allow" : (p.IsDenied ? "Deny" : "Neutral")))); + new XElement("Effect", ProfileExporterBase.GetEffect(p.IsAllowed, p.IsDenied)))); } graphElement.Add(actionsElement); diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Exporters/ProfileYamlExporter.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Exporters/ProfileYamlExporter.cs index d2684500..7ab631f4 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Exporters/ProfileYamlExporter.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/Exporters/ProfileYamlExporter.cs @@ -64,7 +64,7 @@ public string Export(ProfileDto profile, ExportConfiguration? configuration = nu sb.AppendLine($" targetType: {p.TargetType}"); sb.AppendLine($" targetName: {p.TargetName}"); sb.AppendLine($" actionName: {p.ActionName}"); - sb.AppendLine($" effect: {(p.IsAllowed ? "Allow" : (p.IsDenied ? "Deny" : "Neutral"))}"); + sb.AppendLine($" effect: {(ProfileExporterBase.GetEffect(p.IsAllowed, p.IsDenied))}"); sb.AppendLine($" isActive: {p.IsActive}"); sb.AppendLine($" isOverride: {p.IsOverride}"); } @@ -83,7 +83,7 @@ public string Export(ProfileDto profile, ExportConfiguration? configuration = nu sb.AppendLine($" targetType: {p.TargetType}"); sb.AppendLine($" targetName: {p.TargetName}"); sb.AppendLine($" actionName: {p.ActionName}"); - sb.AppendLine($" effect: {(p.IsAllowed ? "Allow" : (p.IsDenied ? "Deny" : "Neutral"))}"); + sb.AppendLine($" effect: {(ProfileExporterBase.GetEffect(p.IsAllowed, p.IsDenied))}"); sb.AppendLine($" isActive: {p.IsActive}"); sb.AppendLine($" isOverride: {p.IsOverride}"); } @@ -102,7 +102,7 @@ public string Export(ProfileDto profile, ExportConfiguration? configuration = nu sb.AppendLine($" targetType: {p.TargetType}"); sb.AppendLine($" targetName: {p.TargetName}"); sb.AppendLine($" actionName: {p.ActionName}"); - sb.AppendLine($" effect: {(p.IsAllowed ? "Allow" : (p.IsDenied ? "Deny" : "Neutral"))}"); + sb.AppendLine($" effect: {(ProfileExporterBase.GetEffect(p.IsAllowed, p.IsDenied))}"); sb.AppendLine($" isActive: {p.IsActive}"); sb.AppendLine($" isOverride: {p.IsOverride}"); } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/PostgreSqlPermissionTemplateRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/PostgreSqlPermissionTemplateRepository.cs index 515320e4..4535f471 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/PostgreSqlPermissionTemplateRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/PostgreSqlPermissionTemplateRepository.cs @@ -13,6 +13,13 @@ public sealed class PostgreSqlPermissionTemplateRepository(UmsPlatformDbContext { private readonly HashSet _trackedAggregates = []; + /// + /// Id del estado terminal de borrado lógico (TemplateStatus.Deleted). Se materializa como + /// constante entera porque el filtro tiene que traducirse a SQL: EF no sabe evaluar la comparación + /// de un DomainEnumeration del dominio dentro de una expresión LINQ. + /// + private const int DeletedStatusId = 4; + public IUnitOfWork UnitOfWork => this; public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) @@ -20,7 +27,7 @@ public sealed class PostgreSqlPermissionTemplateRepository(UmsPlatformDbContext var record = await dbContext.PermissionTemplates .AsSplitQuery() .Include(x => x.Items) - .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + .FirstOrDefaultAsync(x => x.Id == id && x.StatusId != DeletedStatusId, cancellationToken); return record is null ? null : Rehydrate(record); } @@ -30,14 +37,17 @@ public sealed class PostgreSqlPermissionTemplateRepository(UmsPlatformDbContext var record = await dbContext.PermissionTemplates .AsSplitQuery() .Include(x => x.Items) - .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == id, cancellationToken); + .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == id && x.StatusId != DeletedStatusId, cancellationToken); return record is null ? null : Rehydrate(record); } public async Task> GetAllAsync(Guid? tenantId = null, CancellationToken cancellationToken = default) { - IQueryable query = dbContext.PermissionTemplates.AsSplitQuery().Include(x => x.Items); + IQueryable query = dbContext.PermissionTemplates + .AsSplitQuery() + .Include(x => x.Items) + .Where(x => x.StatusId != DeletedStatusId); if (tenantId.HasValue) { @@ -54,7 +64,7 @@ public async Task> GetByTenantIdAsync var records = await dbContext.PermissionTemplates .AsSplitQuery() .Include(x => x.Items) - .Where(x => x.TenantId == tenantId) + .Where(x => x.TenantId == tenantId && x.StatusId != DeletedStatusId) .OrderBy(x => x.RoleId) .ThenBy(x => x.Version) .ToListAsync(cancellationToken); @@ -62,6 +72,25 @@ public async Task> GetByTenantIdAsync return records.Select(Rehydrate).ToList(); } + /// + /// ÚNICA lectura que SÍ ve las plantillas lógicamente eliminadas, y es deliberado: su único + /// consumidor es CreatePermissionTemplateCommandHandler para calcular la versión siguiente + /// (CreateNextVersion, G-140). Una fila eliminada lógicamente SIGUE OCUPANDO su versión en + /// IX_PermissionTemplates_TenantId_RoleId_SystemSuiteId_Version, que es un índice único sobre toda + /// la tabla. Si la filtrásemos, el alta siguiente reutilizaría la versión de una eliminada y + /// chocaría con 23505 → 409 opaco. + /// + public async Task> GetByTenantRoleSuiteAsync(Guid tenantId, Guid roleId, Guid systemSuiteId, CancellationToken cancellationToken = default) + { + var records = await dbContext.PermissionTemplates + .AsSplitQuery() + .Include(x => x.Items) + .Where(x => x.TenantId == tenantId && x.RoleId == roleId && x.SystemSuiteId == systemSuiteId) + .ToListAsync(cancellationToken); + + return records.Select(Rehydrate).ToList(); + } + public Task AddAsync(PermissionTemplateAggregate aggregate, CancellationToken cancellationToken = default) { dbContext.PermissionTemplates.Add(ToRecord(aggregate)); @@ -100,6 +129,17 @@ public async Task SaveEntitiesAsync(CancellationToken cancellationToken = var id = (Guid)(entry?.Property("Id").CurrentValue ?? Guid.Empty); throw new ConcurrencyConflictException(entry?.Metadata.Name ?? "Unknown", id); } + catch (DbUpdateException ex) + when (ex.InnerException is Npgsql.PostgresException { SqlState: Npgsql.PostgresErrorCodes.UniqueViolation }) + { + // G-140: red de seguridad. El alta ya resuelve la versión siguiente en el handler, pero + // ante una carrera dos altas concurrentes sobre la misma terna pueden calcular la misma + // versión y violar IX_PermissionTemplates_TenantId_RoleId_SystemSuiteId_Version (23505). + // Se traduce a 409 Conflict en vez de dejar que caiga a 500 opaco (cf. PostgreSqlTenantRepository). + var entry = ex.Entries.FirstOrDefault(); + var id = (Guid)(entry?.Property("Id").CurrentValue ?? Guid.Empty); + throw new ConcurrencyConflictException(entry?.Metadata.Name ?? "Unknown", id); + } foreach (var aggregate in _trackedAggregates) { @@ -110,14 +150,24 @@ public async Task SaveEntitiesAsync(CancellationToken cancellationToken = return true; } + /// + /// Borrado LÓGICO. Marca la fila con el estado terminal Deleted; NUNCA la quita de la tabla. + /// + /// Antes hacía dbContext.PermissionTemplates.Remove(record), que además arrastraba por + /// cascada todos los PermissionTemplateItems: se perdía sin remedio el rastro de qué + /// concesiones había otorgado la plantilla, justo lo que el negocio consulta hacia atrás. + /// + /// Devuelve false si la fila no existe o si YA estaba eliminada, para que el handler + /// distinga la carrera de dos borrados concurrentes (mismo contrato booleano que antes). + /// public async Task DeleteAsync(Guid id, CancellationToken cancellationToken = default) { var record = await dbContext.PermissionTemplates - .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + .FirstOrDefaultAsync(x => x.Id == id && x.StatusId != DeletedStatusId, cancellationToken); if (record is null) return false; - dbContext.PermissionTemplates.Remove(record); + record.StatusId = DeletedStatusId; return true; } @@ -128,9 +178,16 @@ public Task CountPublishedByRoleAsync(Guid roleId, CancellationToken cancel t => t.RoleId == roleId && t.StatusId == 2 /* Published */, cancellationToken); + /// + /// Los ítems de una plantilla lógicamente eliminada NO bloquean: su plantilla contenedora ya está + /// eliminada, así que la referencia al recurso de dominio también lo está. Sin este cruce, borrar + /// una plantilla dejaría bloqueado para siempre el recurso al que apuntaban sus ítems. + /// public Task CountItemsByTargetAsync(Guid targetId, CancellationToken cancellationToken = default) => dbContext.PermissionTemplateItems.CountAsync( - i => i.TargetId == targetId && i.IsActive, + i => i.TargetId == targetId + && i.IsActive + && dbContext.PermissionTemplates.Any(t => t.Id == i.TemplateId && t.StatusId != DeletedStatusId), cancellationToken); public void Dispose() => dbContext.Dispose(); diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/PostgreSqlProfileRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/PostgreSqlProfileRepository.cs index 3e4df3bf..09540e8b 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/PostgreSqlProfileRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/PostgreSqlProfileRepository.cs @@ -73,6 +73,21 @@ public async Task> GetByUserIdAsync(Guid userId, return records.Select(Rehydrate).ToList(); } + public async Task> GetActiveByUserAndTenantAsync( + Guid userId, Guid tenantId, CancellationToken cancellationToken = default) + { + var records = await dbContext.Profiles + .AsNoTracking() + .AsSplitQuery() + .Include(x => x.Permissions) + // El `IsActive == true` va explícito para que el planificador pueda usar el índice + // parcial `IX_Profiles_UserId_Active`. + .Where(x => x.UserId == userId && x.TenantId == tenantId && x.IsActive) + .ToListAsync(cancellationToken); + + return records.Select(Rehydrate).ToList(); + } + public Task AddAsync(ProfileAggregate aggregate, CancellationToken cancellationToken = default) { dbContext.Profiles.Add(ToRecord(aggregate)); @@ -142,6 +157,11 @@ public Task CountActiveByTemplateAsync(Guid templateId, CancellationToken c public Task CountActiveByUserAsync(Guid userId, CancellationToken cancellationToken = default) => dbContext.Profiles.CountAsync(p => p.UserId == userId && p.IsActive, cancellationToken); + // ADR-0164 §2.2: guarda de cascada del cierre de sucursal. Solo los ACTIVOS bloquean; un perfil + // ya desactivado es una referencia muerta y no impide nada. + public Task CountActiveByBranchAsync(Guid branchId, CancellationToken cancellationToken = default) + => dbContext.Profiles.CountAsync(p => p.BranchId == branchId && p.IsActive, cancellationToken); + private static ProfileAggregate Rehydrate(ProfileRecord record) => AuthorizationAggregateFactory.RehydrateProfile(record, record.Permissions); diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/PostgreSqlRoleRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/PostgreSqlRoleRepository.cs index fe80efb2..811811ca 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/PostgreSqlRoleRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/PostgreSqlRoleRepository.cs @@ -56,6 +56,18 @@ public async Task> GetByTenantIdAsync(Guid tenantId return records.Select(AuthorizationAggregateFactory.RehydrateRole).ToList(); } + public async Task> GetByIdsAsync(IReadOnlyCollection ids, CancellationToken cancellationToken = default) + { + if (ids.Count == 0) return []; + + var records = await dbContext.Roles + .AsNoTracking() + .Where(x => ids.Contains(x.Id)) + .ToListAsync(cancellationToken); + + return records.Select(AuthorizationAggregateFactory.RehydrateRole).ToList(); + } + public Task AddAsync(RoleAggregate aggregate, CancellationToken cancellationToken = default) { dbContext.Roles.Add(ToRecord(aggregate)); @@ -107,7 +119,6 @@ private static RoleRecord ToRecord(RoleAggregate aggregate) if (props == null) throw new InvalidOperationException("Role aggregate has null Props"); var audit = props.Audit?.GetValue(); - var roleId = props.Id?.GetValue() ?? Guid.Empty; var now = DateTime.UtcNow; return new RoleRecord diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/PostgreSqlSystemSuiteRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/PostgreSqlSystemSuiteRepository.cs index 9d3e0f64..2a92e312 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/PostgreSqlSystemSuiteRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Authorization/PostgreSqlSystemSuiteRepository.cs @@ -7,6 +7,7 @@ namespace Ums.Infrastructure.Persistence.Authorization; using SystemSuiteAggregate = Ums.Domain.Authorization.SystemSuite.SystemSuite; +using MenuNodeEntity = Ums.Domain.Authorization.SystemSuite.MenuNode.MenuNode; public sealed class PostgreSqlSystemSuiteRepository(UmsPlatformDbContext dbContext) : ISystemSuiteRepository, IUnitOfWork { @@ -18,7 +19,7 @@ public sealed class PostgreSqlSystemSuiteRepository(UmsPlatformDbContext dbConte { var record = await dbContext.SystemSuites .AsSplitQuery() - .Include(x => x.Modules).ThenInclude(x => x.Menus).ThenInclude(x => x.SubMenus).ThenInclude(x => x.Options) + .Include(x => x.Modules).ThenInclude(m => m.Nodes).ThenInclude(n => n.Actions) .Include(x => x.AppSettings) .Include(x => x.Actions) .Include(x => x.DomainResources) @@ -31,7 +32,7 @@ public sealed class PostgreSqlSystemSuiteRepository(UmsPlatformDbContext dbConte { var record = await dbContext.SystemSuites .AsSplitQuery() - .Include(x => x.Modules).ThenInclude(x => x.Menus).ThenInclude(x => x.SubMenus).ThenInclude(x => x.Options) + .Include(x => x.Modules).ThenInclude(m => m.Nodes).ThenInclude(n => n.Actions) .Include(x => x.AppSettings) .Include(x => x.Actions) .Include(x => x.DomainResources) @@ -40,11 +41,23 @@ public sealed class PostgreSqlSystemSuiteRepository(UmsPlatformDbContext dbConte return record is null ? null : Rehydrate(record); } + /// + /// Sonda de unicidad del código, no una lectura del catálogo: la usa el alta para no chocar con + /// el índice único (TenantId, Code). + /// + /// Es la ÚNICA consulta que ve también los sistemas eliminados lógicamente, y tiene que + /// verlos: la lápida conserva su código y el índice único de PostgreSQL la sigue contando. Si + /// esta sonda la ignorara, reutilizar el código de un sistema eliminado pasaría la validación y + /// reventaría después con una violación de integridad —un 500 en vez de un 409 con sentido—. + /// Se apaga solo el filtro de borrado; el de inquilino sigue puesto, porque la unicidad es por + /// inquilino y no debe filtrarse la existencia de códigos ajenos (G-246). + /// public async Task GetByCodeAsync(Code code, CancellationToken cancellationToken = default) { var record = await dbContext.SystemSuites + .IgnoreQueryFilters([UmsPlatformDbContext.SystemSuiteSoftDeleteFilter]) .AsSplitQuery() - .Include(x => x.Modules).ThenInclude(x => x.Menus).ThenInclude(x => x.SubMenus).ThenInclude(x => x.Options) + .Include(x => x.Modules).ThenInclude(m => m.Nodes).ThenInclude(n => n.Actions) .Include(x => x.AppSettings) .Include(x => x.Actions) .Include(x => x.DomainResources) @@ -56,7 +69,7 @@ public sealed class PostgreSqlSystemSuiteRepository(UmsPlatformDbContext dbConte public async Task> GetAllAsync(Guid? tenantId = null, CancellationToken cancellationToken = default) { IQueryable query = dbContext.SystemSuites.AsSplitQuery() - .Include(x => x.Modules).ThenInclude(x => x.Menus).ThenInclude(x => x.SubMenus).ThenInclude(x => x.Options) + .Include(x => x.Modules).ThenInclude(m => m.Nodes).ThenInclude(n => n.Actions) .Include(x => x.AppSettings) .Include(x => x.Actions) .Include(x => x.DomainResources); @@ -75,7 +88,7 @@ public async Task> GetByTenantIdAsync(Guid t { var records = await dbContext.SystemSuites .AsSplitQuery() - .Include(x => x.Modules).ThenInclude(x => x.Menus).ThenInclude(x => x.SubMenus).ThenInclude(x => x.Options) + .Include(x => x.Modules).ThenInclude(m => m.Nodes).ThenInclude(n => n.Actions) .Include(x => x.AppSettings) .Include(x => x.Actions) .Include(x => x.DomainResources) @@ -86,6 +99,143 @@ public async Task> GetByTenantIdAsync(Guid t return records.Select(Rehydrate).ToList(); } + public async Task> GetSummariesByIdsAsync(IReadOnlyCollection ids, CancellationToken cancellationToken = default) + { + if (ids.Count == 0) return []; + + // Proyección directa a columnas: ni `Include`, ni rehidratación, ni seguimiento de cambios. + return await dbContext.SystemSuites + .AsNoTracking() + .Where(x => ids.Contains(x.Id)) + .Select(x => new Ums.Domain.Authorization.SystemSuite.SystemSuiteSummary(x.Id, x.Code, x.Name)) + .ToListAsync(cancellationToken); + } + + public async Task GetPageAsync( + Ums.Domain.Authorization.SystemSuite.SystemSuitePageQuery query, + CancellationToken cancellationToken = default) + { + // Sin Include: aquí solo se decide QUÉ suites entran en la página. El árbol de cada una + // lo carga después `GetByIdsAsync`, y solo el de las que sobreviven al filtro. + IQueryable consulta = dbContext.SystemSuites.AsNoTracking(); + + if (query.TenantId.HasValue) + consulta = consulta.Where(x => x.TenantId == query.TenantId.Value); + + if (!string.IsNullOrWhiteSpace(query.Status)) + { + // El estado se persiste como identificador, no como nombre: se traduce aquí para + // filtrar por columna indexable en vez de por una expresión. + var estadoId = DomainEnumerationMapper.FromName(query.Status).Id; + consulta = consulta.Where(x => x.StatusId == estadoId); + } + + if (!string.IsNullOrWhiteSpace(query.Search)) + { + var patron = $"%{query.Search}%"; + consulta = query.SearchField switch + { + // ILIKE de PostgreSQL: la comparación insensible a mayúsculas se resuelve en la + // base, no trayendo filas para compararlas en memoria. + "code" => consulta.Where(x => EF.Functions.ILike(x.Code, patron)), + "id" => consulta.Where(x => EF.Functions.ILike(x.Id.ToString(), patron)), + _ => consulta.Where(x => EF.Functions.ILike(x.Name, patron)), + }; + } + + var total = await consulta.CountAsync(cancellationToken); + + consulta = (query.SortBy, query.Descending) switch + { + ("code", true) => consulta.OrderByDescending(x => x.Code), + ("code", false) => consulta.OrderBy(x => x.Code), + // Se ordena por el NOMBRE del estado, no por su identificador, para conservar el + // orden que el listado ya mostraba (Active, Deprecated, Maintenance). EF lo traduce + // a un CASE, sin traer filas. + // La expresión va INLINE, no en un método: EF traduce un condicional a CASE, pero + // no sabe traducir la llamada a un método propio — lo haría en memoria o reventaría. + ("status", true) => consulta.OrderByDescending(x => x.StatusId == 1 ? "Active" : x.StatusId == 2 ? "Maintenance" : "Deprecated"), + ("status", false) => consulta.OrderBy(x => x.StatusId == 1 ? "Active" : x.StatusId == 2 ? "Maintenance" : "Deprecated"), + (_, true) => consulta.OrderByDescending(x => x.Name), + _ => consulta.OrderBy(x => x.Name), + }; + + var ids = await consulta + .Skip((query.Page - 1) * query.PageSize) + .Take(query.PageSize) + .Select(x => x.Id) + .ToListAsync(cancellationToken); + + return new Ums.Domain.Authorization.SystemSuite.SystemSuitePage(ids, total); + } + + public async Task> GetByIdsAsync(IReadOnlyCollection ids, CancellationToken cancellationToken = default) + { + if (ids.Count == 0) return []; + + var records = await dbContext.SystemSuites + .AsSplitQuery() + .Include(x => x.Modules).ThenInclude(m => m.Nodes).ThenInclude(n => n.Actions) + .Include(x => x.AppSettings) + .Include(x => x.Actions) + .Include(x => x.DomainResources) + .Where(x => ids.Contains(x.Id)) + .ToListAsync(cancellationToken); + + // Se respeta el orden que decidió la página: `IN (...)` no lo garantiza. + var porId = records.ToDictionary(r => r.Id); + return ids.Where(porId.ContainsKey).Select(id => Rehydrate(porId[id])).ToList(); + } + + public async Task GetDependentsAsync( + Guid id, + CancellationToken cancellationToken = default) + { + // Conteos, no cargas: la guarda solo necesita saber CUÁNTAS referencias vivas apuntan al + // sistema. Traer los agregados para contarlos sería el patrón que ADR-0041 proscribe, y aquí + // ni siquiera se mira un solo campo de ellos. Todas las columnas implicadas están indexadas. + // + // Sobre qué se considera «vivo» (regla de cascada, G-246): de estas siete tablas, solo + // `Tenants` tiene eliminación lógica —su filtro global `!IsDeleted` deja fuera a los + // inquilinos ya eliminados, de modo que un inquilino difunto NO bloquea—. Las otras seis no + // tienen marca de borrado lógico: `Roles.IsActive`, `PermissionTemplates.StatusId`, + // `FeatureFlags/IdpConfigurations/AppConfigurations` con su `Archived` y `ApprovalWorkflows` + // sin estado alguno describen ciclo de vida, no borrado. Se cuenta TODA fila existente: + // fail-closed. Cuando alguna de ellas gane su estado terminal, aquí se añade el predicado y + // en ningún sitio más. + var roles = await dbContext.Roles + .CountAsync(x => x.SystemSuiteId == id, cancellationToken).ConfigureAwait(false); + var templates = await dbContext.PermissionTemplates + .CountAsync(x => x.SystemSuiteId == id, cancellationToken).ConfigureAwait(false); + var featureFlags = await dbContext.FeatureFlags + .CountAsync(x => x.SystemSuiteId == id, cancellationToken).ConfigureAwait(false); + var idpConfigurations = await dbContext.IdpConfigurations + .CountAsync(x => x.SystemSuiteId == id, cancellationToken).ConfigureAwait(false); + var appConfigurations = await dbContext.AppConfigurations + .CountAsync(x => x.SystemSuiteId == id, cancellationToken).ConfigureAwait(false); + var approvalWorkflows = await dbContext.ApprovalWorkflows + .CountAsync(x => x.SystemSuiteId == id, cancellationToken).ConfigureAwait(false); + // Es la referencia más peligrosa: si el sistema por defecto de un inquilino deja de leerse, + // su resolución de método de autenticación se queda sin destino. El filtro global de + // `Tenants` (`!IsDeleted`) es el que materializa aquí la regla del propietario: un inquilino + // ya eliminado lógicamente es una lápida y no cuenta. + var tenantsUsingAsDefault = await dbContext.Tenants + .CountAsync(x => x.DefaultSystemSuiteId == id, cancellationToken).ConfigureAwait(false); + + return new Ums.Domain.Authorization.SystemSuite.SystemSuiteDependents( + roles, + templates, + featureFlags, + idpConfigurations, + appConfigurations, + approvalWorkflows, + tenantsUsingAsDefault); + } + + // No hay DeleteAsync, y su ausencia es deliberada: la eliminación de un sistema es un cambio de + // estado a `SystemStatus.Deleted` que viaja por `UpdateAsync`. Sobre el catálogo se consultan + // datos antiguos, y una fila borrada de verdad no se recupera. + public Task AddAsync(SystemSuiteAggregate aggregate, CancellationToken cancellationToken = default) { dbContext.SystemSuites.Add(ToRecord(aggregate)); @@ -107,7 +257,7 @@ public async Task UpdateAsync(SystemSuiteAggregate aggregate, CancellationToken .FirstOrDefault(e => e.Entity.Id == id) ?.Entity ?? await dbContext.SystemSuites - .Include(x => x.Modules).ThenInclude(x => x.Menus).ThenInclude(x => x.SubMenus).ThenInclude(x => x.Options) + .Include(x => x.Modules).ThenInclude(m => m.Nodes).ThenInclude(n => n.Actions) .Include(x => x.AppSettings) .Include(x => x.Actions) .Include(x => x.DomainResources) @@ -175,6 +325,7 @@ private static SystemSuiteRecord ToRecord(SystemSuiteAggregate aggregate) Id = Guid.NewGuid(), SystemSuiteId = aggregate.Props.Id.GetValue(), ConfigKey = x.Key.GetValue(), + IsClientVisible = x.IsClientVisible, ConfigValue = x.Value.GetValue(), ScopeId = x.Scope.Id, }).ToList(), @@ -231,73 +382,75 @@ private static SystemSuiteModuleRecord ToRecord(Ums.Domain.Authorization.SystemS Description = module.Props.Description.GetValue(), StatusId = module.Props.Status.Id, SortOrder = module.Props.SortOrder, + Icon = module.Props.Icon, CreatedBy = audit.CreatedBy, CreatedAtUtc = audit.CreatedAt, UpdatedBy = audit.UpdatedBy, UpdatedAtUtc = audit.UpdatedAt, AuditTimeSpan = audit.TimeSpan, - Menus = module.Menus.Select(ToRecord).ToList(), + Nodes = FlattenNodes(module.Nodes), }; } - private static SystemSuiteMenuRecord ToRecord(Ums.Domain.Authorization.SystemSuite.Menu.Menu menu) + private static SystemSuiteNodeRecord ToRecord(MenuNodeEntity node) { - var audit = menu.Props.Audit.GetValue(); - return new SystemSuiteMenuRecord + var audit = node.Props.Audit.GetValue(); + var meta = node.Metadata; + var nodeId = node.GetId().GetValue(); + return new SystemSuiteNodeRecord { - Id = menu.Props.Id.GetValue(), - ModuleId = menu.Props.ModuleId.GetValue(), - Code = menu.Props.Code.GetValue(), - Label = menu.Props.Label.GetValue(), - Description = menu.Props.Description.GetValue(), - SortOrder = menu.Props.SortOrder, + Id = nodeId, + ModuleId = node.ModuleId.GetValue(), + ParentNodeId = node.ParentNodeId?.GetValue(), + NodeKindId = (int)node.Kind, + Code = node.Code.GetValue(), + Label = node.Label.GetValue(), + Description = node.Description.GetValue(), + StatusId = node.Status.Id, + SortOrder = node.SortOrder, + Icon = node.Props.Presentation.Icon, + Route = node.Props.Presentation.Route, + Responsable = meta.Responsable, + Criticidad = meta.Criticidad, + ProductoImpactado = meta.ProductoImpactado, + ComponenteTecnico = meta.ComponenteTecnico, + Dependencias = meta.Dependencias, + Evidencias = meta.Evidencias, + TrazabilidadSdlc = meta.TrazabilidadSdlc, CreatedBy = audit.CreatedBy, CreatedAtUtc = audit.CreatedAt, UpdatedBy = audit.UpdatedBy, UpdatedAtUtc = audit.UpdatedAt, AuditTimeSpan = audit.TimeSpan, - SubMenus = menu.SubMenus.Select(ToRecord).ToList(), + Actions = node.ActionCodes.Select(ac => new SystemSuiteNodeActionRecord + { + Id = Guid.NewGuid(), + NodeId = nodeId, + ActionCode = ac.GetValue(), + }).ToList(), }; } - private static SystemSuiteSubMenuRecord ToRecord(Ums.Domain.Authorization.SystemSuite.SubMenu.SubMenu subMenu) + /// Aplana el árbol de MenuNode a filas planas (ParentNodeId enlaza el árbol). + private static List FlattenNodes(IEnumerable roots) { - var audit = subMenu.Props.Audit.GetValue(); - return new SystemSuiteSubMenuRecord + var flat = new List(); + + void Walk(MenuNodeEntity node) { - Id = subMenu.Props.Id.GetValue(), - MenuId = subMenu.Props.MenuId.GetValue(), - Code = subMenu.Props.Code.GetValue(), - Label = subMenu.Props.Label.GetValue(), - Description = subMenu.Props.Description.GetValue(), - SortOrder = subMenu.Props.SortOrder, - CreatedBy = audit.CreatedBy, - CreatedAtUtc = audit.CreatedAt, - UpdatedBy = audit.UpdatedBy, - UpdatedAtUtc = audit.UpdatedAt, - AuditTimeSpan = audit.TimeSpan, - Options = subMenu.Options.Select(ToRecord).ToList(), - }; - } + flat.Add(ToRecord(node)); + foreach (var child in node.Children) + { + Walk(child); + } + } - private static SystemSuiteOptionRecord ToRecord(Ums.Domain.Authorization.SystemSuite.Option.Option option) - { - var audit = option.Props.Audit.GetValue(); - return new SystemSuiteOptionRecord + foreach (var root in roots) { - Id = option.Props.Id.GetValue(), - SubMenuId = option.Props.SubMenuId.GetValue(), - Code = option.Props.Code.GetValue(), - Label = option.Props.Label.GetValue(), - Description = option.Props.Description.GetValue(), - ActionCode = option.Props.ActionCode.GetValue(), - SortOrder = option.Props.SortOrder, - CreatedBy = audit.CreatedBy, - CreatedAtUtc = audit.CreatedAt, - UpdatedBy = audit.UpdatedBy, - UpdatedAtUtc = audit.UpdatedAt, - AuditTimeSpan = audit.TimeSpan, - }; + Walk(root); + } + + return flat; } private void Apply(SystemSuiteRecord target, SystemSuiteAggregate source) @@ -441,72 +594,56 @@ private void ReconcileModules( existing.UpdatedAtUtc = rep.UpdatedAtUtc; existing.AuditTimeSpan = rep.AuditTimeSpan; - ReconcileMenus(existing.Menus, rep.Menus); + ReconcileNodes(existing.Nodes, rep.Nodes); }); } - private void ReconcileMenus( - IList tracked, - IList replacement) + /// + /// Reconcilia el árbol de nodos en PLANO por Id (el árbol se enlaza por + /// ParentNodeId). Cubre inserción, actualización y borrado de subárboles. + /// + private void ReconcileNodes( + IList tracked, + IList replacement) { ReconcileByKey( tracked, replacement, - m => m.Id, + n => n.Id, (existing, rep) => { - existing.Code = rep.Code; - existing.Label = rep.Label; - existing.Description = rep.Description; - existing.SortOrder = rep.SortOrder; - existing.UpdatedBy = rep.UpdatedBy; - existing.UpdatedAtUtc = rep.UpdatedAtUtc; - existing.AuditTimeSpan = rep.AuditTimeSpan; - - ReconcileSubMenus(existing.SubMenus, rep.SubMenus); + existing.ParentNodeId = rep.ParentNodeId; + existing.NodeKindId = rep.NodeKindId; + existing.Code = rep.Code; + existing.Label = rep.Label; + existing.Description = rep.Description; + existing.StatusId = rep.StatusId; + existing.SortOrder = rep.SortOrder; + existing.Responsable = rep.Responsable; + existing.Criticidad = rep.Criticidad; + existing.ProductoImpactado = rep.ProductoImpactado; + existing.ComponenteTecnico = rep.ComponenteTecnico; + existing.Dependencias = rep.Dependencias; + existing.Evidencias = rep.Evidencias; + existing.TrazabilidadSdlc = rep.TrazabilidadSdlc; + existing.UpdatedBy = rep.UpdatedBy; + existing.UpdatedAtUtc = rep.UpdatedAtUtc; + existing.AuditTimeSpan = rep.AuditTimeSpan; + + ReconcileNodeActions(existing.Actions, rep.Actions); }); } - private void ReconcileSubMenus( - IList tracked, - IList replacement) + private void ReconcileNodeActions( + IList tracked, + IList replacement) { + // Id se regenera en cada ToRecord → reconciliar por ActionCode (la clave real). ReconcileByKey( tracked, replacement, - sm => sm.Id, - (existing, rep) => - { - existing.Code = rep.Code; - existing.Label = rep.Label; - existing.Description = rep.Description; - existing.SortOrder = rep.SortOrder; - existing.UpdatedBy = rep.UpdatedBy; - existing.UpdatedAtUtc = rep.UpdatedAtUtc; - existing.AuditTimeSpan = rep.AuditTimeSpan; - - ReconcileOptions(existing.Options, rep.Options); - }); + a => a.ActionCode, + (_, _) => { /* solo la clave es relevante; nada mutable que actualizar */ }); } - private void ReconcileOptions( - IList tracked, - IList replacement) - { - ReconcileByKey( - tracked, - replacement, - o => o.Id, - (existing, rep) => - { - existing.Code = rep.Code; - existing.Label = rep.Label; - existing.Description = rep.Description; - existing.ActionCode = rep.ActionCode; - existing.SortOrder = rep.SortOrder; - existing.UpdatedBy = rep.UpdatedBy; - existing.UpdatedAtUtc = rep.UpdatedAtUtc; - existing.AuditTimeSpan = rep.AuditTimeSpan; - }); - } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/ConfigurationPersistenceConstants.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/ConfigurationPersistenceConstants.cs index 8cfa36f9..5d783f1a 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/ConfigurationPersistenceConstants.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/ConfigurationPersistenceConstants.cs @@ -3,4 +3,22 @@ namespace Ums.Infrastructure.Persistence.Configuration; internal static class ConfigurationPersistenceConstants { public const string Schema = "ums_configuration"; + + /// + /// Predicado SQL de «fila viva» para los índices únicos PARCIALES de este esquema. + /// + /// ADR-0164 §2.3 dejó los índices únicos SIN filtrar por borrado, de modo que la clave natural + /// quedaba ocupada para siempre. El propietario del producto acotó esa regla el 2026-08-04: vale + /// para lo que identifica algo del mundo real —una sucursal, un sistema— pero NO para una RANURA + /// de configuración. El código de una configuración sale de un catálogo cerrado + /// (`MFA_REQUIRED_FOR_ADMIN` es *el* nombre del parámetro, no uno que se elija), así que dejar la + /// ranura ocupada equivale a impedir para siempre volver a configurar ese parámetro. + /// + /// El filtro se escribe en SQL y no como expresión C# porque un índice parcial vive en la base; + /// el literal 4 es . Se interpola desde el enum, no se teclea: + /// si ese id cambiara, la foto del modelo dejaría de cuadrar y la migración de verificación + /// saldría NO vacía, que es exactamente la señal que se quiere. + /// + public static readonly string LiveRowIndexFilter = + $"\"StatusId\" != {ConfigStatus.Deleted.Id}"; } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/Configurations/AppConfigurationRecordConfiguration.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/Configurations/AppConfigurationRecordConfiguration.cs index aeca0de7..06752667 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/Configurations/AppConfigurationRecordConfiguration.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/Configurations/AppConfigurationRecordConfiguration.cs @@ -20,7 +20,19 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.AuditTimeSpan).HasMaxLength(100).IsRequired(); builder.Property(x => x.RowVersion).IsRowVersion(); // FIX-03: optimistic concurrency - builder.HasIndex(x => new { x.TenantId, x.SystemSuiteId, x.ModuleId, x.Code }).IsUnique(); + // Índice único PARCIAL: la ranura (ámbito, código) la ocupa solo una configuración VIVA. + // Una configuración eliminada lógicamente sale del índice —su fila permanece en la tabla, + // que es lo que ADR-0164 §2.3 protege— y su código vuelve a poder configurarse. Sin este + // filtro, borrar la configuración global de `MFA_REQUIRED_FOR_ADMIN` impedía volver a + // configurar ese parámetro NUNCA, porque su código no lo inventa quien opera: viene de un + // catálogo cerrado. Lo midió `app-configuration-state.spec.ts` (409 donde esperaba 201). + // + // Sucursales y SystemSuite NO cambian: ahí el código sí identifica algo del mundo real y la + // regla original —clave ocupada para siempre— se mantiene por decisión explícita. + builder.HasIndex(x => new { x.TenantId, x.SystemSuiteId, x.ModuleId, x.Code }) + .IsUnique() + .HasFilter(ConfigurationPersistenceConstants.LiveRowIndexFilter); + builder.HasIndex(x => x.ScopeId); builder.HasIndex(x => x.StatusId); } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/Configurations/FeatureFlagRecordConfiguration.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/Configurations/FeatureFlagRecordConfiguration.cs index 93732d98..c0bef74b 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/Configurations/FeatureFlagRecordConfiguration.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/Configurations/FeatureFlagRecordConfiguration.cs @@ -21,7 +21,9 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.SystemSuiteId).IsRequired(); builder.Property(x => x.TenantId); - builder.HasIndex(x => new { x.SystemSuiteId, x.FlagCode }).IsUnique(); + builder.HasIndex(x => new { x.SystemSuiteId, x.FlagCode }) + .IsUnique() + .HasFilter("\"StatusId\" != 3"); builder.HasIndex(x => x.SystemSuiteId); builder.HasIndex(x => x.StatusId); builder.HasIndex(x => x.FlagTypeId); diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/Configurations/ParameterDefinitionRecordConfiguration.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/Configurations/ParameterDefinitionRecordConfiguration.cs index d743ad24..fca7e76e 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/Configurations/ParameterDefinitionRecordConfiguration.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/Configurations/ParameterDefinitionRecordConfiguration.cs @@ -12,7 +12,14 @@ public void Configure(EntityTypeBuilder builder) builder.HasKey(x => x.Id); builder.Property(x => x.Code).HasMaxLength(100).IsRequired(); - builder.HasIndex(x => x.Code).IsUnique(); + + // Índice único PARCIAL sobre las definiciones VIVAS. El código de un parámetro es una ranura + // del catálogo, no la identidad de una cosa del mundo real: si la lápida siguiera ocupando el + // índice, retirar una definición prohibiría volver a declarar ese mismo parámetro para + // siempre. La fila eliminada se conserva (ADR-0164 §2.1); lo que se libera es la ranura. + builder.HasIndex(x => x.Code) + .IsUnique() + .HasFilter("\"IsDeleted\" = false"); builder.Property(x => x.Name).HasMaxLength(200).IsRequired(); builder.Property(x => x.Description).HasMaxLength(1000); @@ -20,9 +27,13 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.Version).HasMaxLength(50).IsRequired(); builder.Property(x => x.CreatedBy).HasMaxLength(100).IsRequired(); builder.Property(x => x.UpdatedBy).HasMaxLength(100); + builder.Property(x => x.DeletedBy).HasMaxLength(100); builder.Property(x => x.AuditTimeSpan).HasMaxLength(100).IsRequired(); builder.HasIndex(x => x.ScopeId); builder.HasIndex(x => x.IsActive); + // Toda lectura del catálogo filtra por IsDeleted (filtro global de consulta): sin índice, + // ese predicado se paga con un recorrido completo en cada listado. + builder.HasIndex(x => x.IsDeleted); } } \ No newline at end of file diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/Configurations/ParameterGlobalValueRecordConfiguration.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/Configurations/ParameterGlobalValueRecordConfiguration.cs index 44ff8060..2e4084f7 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/Configurations/ParameterGlobalValueRecordConfiguration.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/Configurations/ParameterGlobalValueRecordConfiguration.cs @@ -17,7 +17,14 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.UpdatedBy).HasMaxLength(100); builder.Property(x => x.AuditTimeSpan).HasMaxLength(100).IsRequired(); - builder.HasIndex(x => x.ParameterDefinitionId).IsUnique(); + // Índice único PARCIAL: una definición tiene como mucho UN valor global vivo. El valor + // eliminado se queda en la tabla como historia —explica por qué el sistema se comportó como + // se comportó— pero deja de reservar la ranura, así que el parámetro puede volver a recibir + // un valor global. Sin el filtro, borrar el valor global era irreversible. + builder.HasIndex(x => x.ParameterDefinitionId) + .IsUnique() + .HasFilter(ConfigurationPersistenceConstants.LiveRowIndexFilter); + builder.HasIndex(x => x.StatusId); } } \ No newline at end of file diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/Configurations/ParameterTenantValueRecordConfiguration.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/Configurations/ParameterTenantValueRecordConfiguration.cs index afee5e1f..dda05597 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/Configurations/ParameterTenantValueRecordConfiguration.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/Configurations/ParameterTenantValueRecordConfiguration.cs @@ -17,7 +17,13 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.UpdatedBy).HasMaxLength(100); builder.Property(x => x.AuditTimeSpan).HasMaxLength(100).IsRequired(); - builder.HasIndex(x => new { x.TenantId, x.ParameterDefinitionId }).IsUnique(); + // Índice único PARCIAL: un inquilino tiene como mucho UN override vivo por definición. Igual + // que en el valor global, la fila eliminada permanece pero libera la ranura; de lo contrario + // un inquilino que retira su override no podría volver a fijarlo jamás. + builder.HasIndex(x => new { x.TenantId, x.ParameterDefinitionId }) + .IsUnique() + .HasFilter(ConfigurationPersistenceConstants.LiveRowIndexFilter); + builder.HasIndex(x => x.TenantId); builder.HasIndex(x => x.StatusId); } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/Entities/ParameterDefinitionRecord.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/Entities/ParameterDefinitionRecord.cs index 2e6a5169..8aff8c3f 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/Entities/ParameterDefinitionRecord.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/Entities/ParameterDefinitionRecord.cs @@ -14,6 +14,13 @@ public sealed class ParameterDefinitionRecord : IAuditableRecord public bool IsActive { get; set; } public bool IsMandatory { get; set; } public int DisplayOrder { get; set; } + + // Soft-delete (política del propietario: nunca se borra la fila). Mismos tres campos que + // UserAccountRecord (REC-16) para que el patrón sea uno solo en todo el repositorio. + public bool IsDeleted { get; set; } + public DateTime? DeletedAtUtc { get; set; } + public string? DeletedBy { get; set; } + public string Version { get; set; } = string.Empty; public string CreatedBy { get; set; } = string.Empty; public DateTime CreatedAtUtc { get; set; } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/PostgreSqlAppConfigurationRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/PostgreSqlAppConfigurationRepository.cs index 15f2f4ea..4f80028a 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/PostgreSqlAppConfigurationRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/PostgreSqlAppConfigurationRepository.cs @@ -14,10 +14,12 @@ public sealed class PostgreSqlAppConfigurationRepository(UmsPlatformDbContext db public IUnitOfWork UnitOfWork => this; + // Las lecturas ocultan lo eliminado lógicamente: la fila permanece en la tabla —la consulta + // histórica la necesita— pero un GET sobre ella responde 404 y no aparece en ningún listado. public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) { var record = await dbContext.AppConfigurations - .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + .FirstOrDefaultAsync(x => x.Id == id && x.StatusId != ConfigStatus.Deleted.Id, cancellationToken); return record is null ? null : Rehydrate(record); } @@ -25,6 +27,21 @@ public sealed class PostgreSqlAppConfigurationRepository(UmsPlatformDbContext db public Task GetByIdAsync(Guid tenantId, Guid id, CancellationToken cancellationToken = default) => GetByIdAsync(id, cancellationToken); + /// + /// Lookup por ámbito+código: devuelve quién OCUPA esa ranura ahora mismo, nunca una lápida. + /// + /// Antes no filtraba lo eliminado, porque el índice único tampoco lo hacía y ocultarlo habría + /// convertido un conflicto legible en un 23505 → 500. Ahora el índice es parcial (solo filas + /// vivas), así que la comprobación de unicidad tiene que mirar exactamente lo mismo que mira el + /// índice: si siguiera viendo la lápida, el alta devolvería 409 sobre una ranura que la base + /// considera libre. Es el 409-donde-se-esperaba-201 que midió la certificación E2E. + /// + /// El orden hace la respuesta DETERMINISTA cuando conviven varias filas con el mismo código: + /// una viva y N eliminadas es el caso normal tras liberar la ranura, y las eliminadas ya quedan + /// fuera; el desempate por estado y fecha cubre además el caso —permitido por el handler desde + /// CFG-06— de una archivada conviviendo con la nueva: gana la que no está archivada, y entre + /// iguales la más reciente. Sin ese orden, `FirstOrDefault` devolvería una fila arbitraria. + /// public async Task GetByScopeAndCodeAsync(Guid? tenantId, Guid? systemSuiteId, Guid? moduleId, string code, CancellationToken cancellationToken = default) { IQueryable query = dbContext.AppConfigurations; @@ -34,12 +51,19 @@ public sealed class PostgreSqlAppConfigurationRepository(UmsPlatformDbContext db query = query.IgnoreQueryFilters(); } + var deletedStatusId = ConfigStatus.Deleted.Id; + var archivedStatusId = ConfigStatus.Archived.Id; + var record = await query - .FirstOrDefaultAsync(x => + .Where(x => x.TenantId == tenantId && x.SystemSuiteId == systemSuiteId && x.ModuleId == moduleId - && x.Code == code, cancellationToken); + && x.Code == code + && x.StatusId != deletedStatusId) + .OrderBy(x => x.StatusId == archivedStatusId ? 1 : 0) + .ThenByDescending(x => x.CreatedAtUtc) + .FirstOrDefaultAsync(cancellationToken); return record is null ? null : Rehydrate(record); } @@ -53,7 +77,11 @@ public async Task> GetAllAsync(Guid? te query = query.IgnoreQueryFilters().Where(x => x.TenantId == tenantId.Value); } - var records = await query.OrderBy(x => x.Code).ToListAsync(cancellationToken); + // Lo eliminado lógicamente no se lista (ni en la API ni en la carga de la caché). + var records = await query + .Where(x => x.StatusId != ConfigStatus.Deleted.Id) + .OrderBy(x => x.Code) + .ToListAsync(cancellationToken); return records.Select(Rehydrate).ToList(); } @@ -91,6 +119,9 @@ public async Task UpdateAsync( _trackedAggregates.Add(aggregate); } + // Aquí NO hay DeleteAsync: el borrado es lógico y viaja por UpdateAsync como cualquier otro + // cambio de estado (StatusId → Deleted). Ninguna ruta de este repositorio retira filas. + public Task SaveChangesAsync(CancellationToken cancellationToken = default) => dbContext.SaveChangesAsync(cancellationToken); diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/PostgreSqlFeatureFlagRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/PostgreSqlFeatureFlagRepository.cs index 01eff4fc..602b7118 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/PostgreSqlFeatureFlagRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/PostgreSqlFeatureFlagRepository.cs @@ -67,6 +67,19 @@ public async Task> GetAllAsync(Guid? tenantI return records.Select(Rehydrate).ToList(); } + public async Task> GetBySystemSuiteIdForEvaluationAsync(Guid systemSuiteId, CancellationToken cancellationToken = default) + { + var records = await dbContext.FeatureFlags + .AsNoTracking() + .AsSplitQuery() + .Include(x => x.Criteria) + .Where(x => x.SystemSuiteId == systemSuiteId) + .OrderBy(x => x.FlagCode) + .ToListAsync(cancellationToken); + + return records.Select(Rehydrate).ToList(); + } + public async Task> GetBySystemSuiteIdAsync(Guid systemSuiteId, CancellationToken cancellationToken = default) { var records = await dbContext.FeatureFlags diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/PostgreSqlParameterRepositories.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/PostgreSqlParameterRepositories.cs index 1f340445..dd194421 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/PostgreSqlParameterRepositories.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Configuration/PostgreSqlParameterRepositories.cs @@ -11,67 +11,85 @@ namespace Ums.Infrastructure.Persistence.Configuration; public sealed class PostgreSqlParameterDefinitionRepository(UmsPlatformDbContext db) : IParameterDefinitionRepository { - public async Task GetByIdAsync(Guid id, CancellationToken ct = default) + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) { - var r = await db.ParameterDefinitions.FirstOrDefaultAsync(x => x.Id == id, ct); + var r = await db.ParameterDefinitions.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); return r is null ? null : ConfigurationAggregateFactory.RehydrateParameterDefinition(r); } - public async Task GetByCodeAsync(string code, CancellationToken ct = default) + public async Task GetByCodeAsync(string code, CancellationToken cancellationToken = default) { var upper = code.ToUpperInvariant(); - var r = await db.ParameterDefinitions.FirstOrDefaultAsync(x => x.Code == upper, ct); + var r = await db.ParameterDefinitions.FirstOrDefaultAsync(x => x.Code == upper, cancellationToken); return r is null ? null : ConfigurationAggregateFactory.RehydrateParameterDefinition(r); } - public async Task> GetAllAsync(CancellationToken ct = default) + public async Task> GetAllAsync(CancellationToken cancellationToken = default) { - var records = await db.ParameterDefinitions.OrderBy(x => x.DisplayOrder).ToListAsync(ct); + var records = await db.ParameterDefinitions.OrderBy(x => x.DisplayOrder).ToListAsync(cancellationToken); return records.Select(ConfigurationAggregateFactory.RehydrateParameterDefinition).ToList(); } - public async Task AddAsync(ParameterDefinition d, CancellationToken ct = default) - => await db.ParameterDefinitions.AddAsync(ToRecord(d), ct); + public async Task AddAsync(ParameterDefinition definition, CancellationToken cancellationToken = default) + => await db.ParameterDefinitions.AddAsync(ToRecord(definition), cancellationToken); - public async Task UpdateAsync(ParameterDefinition d, CancellationToken ct = default) + public async Task UpdateAsync(ParameterDefinition definition, CancellationToken cancellationToken = default) { var existing = await db.ParameterDefinitions - .FirstOrDefaultAsync(x => x.Id == d.Props.Id.GetValue(), ct) - ?? throw new InvalidOperationException($"ParameterDefinition {d.Props.Id.GetValue()} not found."); - Apply(existing, d); + .FirstOrDefaultAsync(x => x.Id == definition.Props.Id.GetValue(), cancellationToken) + ?? throw new InvalidOperationException($"ParameterDefinition {definition.Props.Id.GetValue()} not found."); + Apply(existing, definition); } - public Task CountByCodeAsync(string code, CancellationToken ct = default) - => db.ParameterDefinitions.CountAsync(x => x.Code == code.ToUpperInvariant(), ct); + /// + public Task CountByCodeAsync(string code, CancellationToken cancellationToken = default) + // Cuenta solo las VIVAS: el filtro global `!IsDeleted` basta y ya no se apaga. El índice + // único de `Code` pasó a ser parcial, así que una lápida no reserva el código; contarla + // devolvería un 409 sobre una ranura que la base considera libre. + => db.ParameterDefinitions + .CountAsync(x => x.Code == code.ToUpperInvariant(), cancellationToken); - public Task CountGlobalValuesAsync(Guid definitionId, CancellationToken ct = default) - => db.ParameterGlobalValues.CountAsync(x => x.ParameterDefinitionId == definitionId, ct); + /// + public Task CountLiveGlobalValuesAsync(Guid definitionId, CancellationToken cancellationToken = default) + => db.ParameterGlobalValues.CountAsync( + x => x.ParameterDefinitionId == definitionId && x.StatusId != ConfigStatus.Deleted.Id, + cancellationToken); - public Task CountTenantValuesAsync(Guid definitionId, CancellationToken ct = default) - => db.ParameterTenantValues.CountAsync(x => x.ParameterDefinitionId == definitionId, ct); + /// + public Task CountLiveTenantValuesAsync(Guid definitionId, CancellationToken cancellationToken = default) + => db.ParameterTenantValues + // IgnoreQueryFilters a propósito: la integridad referencial es una pregunta del SISTEMA, + // no del inquilino en curso. Con el filtro de inquilino activo, un administrador interno + // operando con X-Tenant-Id no vería los overrides de los demás inquilinos y borraría la + // definición dejándolos huérfanos. + .IgnoreQueryFilters() + .CountAsync( + x => x.ParameterDefinitionId == definitionId && x.StatusId != ConfigStatus.Deleted.Id, + cancellationToken); - public async Task SaveChangesAsync(CancellationToken ct = default) + public async Task SaveChangesAsync(CancellationToken cancellationToken = default) { - await db.SaveChangesAsync(ct); + await db.SaveChangesAsync(cancellationToken); return true; } - private static ParameterDefinitionRecord ToRecord(ParameterDefinition d) + private static ParameterDefinitionRecord ToRecord(ParameterDefinition definition) { - var audit = d.Props.Audit.GetValue(); + var audit = definition.Props.Audit.GetValue(); return new ParameterDefinitionRecord { - Id = d.Props.Id.GetValue(), - Code = d.Props.Code.GetValue(), - Name = d.Props.Name.Value, - Description = d.Props.Description.GetValue(), - DataTypeId = d.Props.DataType.Id, - DefaultValue = d.Props.DefaultValue.Value, - ScopeId = d.Props.Scope.Id, - IsActive = d.Props.IsActive, - IsMandatory = d.Props.IsMandatory, - DisplayOrder = d.Props.DisplayOrder, - Version = d.Props.Version, + Id = definition.Props.Id.GetValue(), + Code = definition.Props.Code.GetValue(), + Name = definition.Props.Name.Value, + Description = definition.Props.Description.GetValue(), + DataTypeId = definition.Props.DataType.Id, + DefaultValue = definition.Props.DefaultValue.Value, + ScopeId = definition.Props.Scope.Id, + IsActive = definition.Props.IsActive, + IsMandatory = definition.Props.IsMandatory, + DisplayOrder = definition.Props.DisplayOrder, + IsDeleted = definition.Props.IsDeleted, + Version = definition.Props.Version, CreatedBy = audit.CreatedBy, CreatedAtUtc = audit.CreatedAt, UpdatedBy = audit.UpdatedBy, @@ -80,66 +98,83 @@ private static ParameterDefinitionRecord ToRecord(ParameterDefinition d) }; } - private static void Apply(ParameterDefinitionRecord t, ParameterDefinition d) + private static void Apply(ParameterDefinitionRecord t, ParameterDefinition definition) { - var audit = d.Props.Audit.GetValue(); - t.Name = d.Props.Name.Value; - t.Description = d.Props.Description.GetValue(); - t.DefaultValue = d.Props.DefaultValue.Value; - t.ScopeId = d.Props.Scope.Id; - t.IsActive = d.Props.IsActive; - t.IsMandatory = d.Props.IsMandatory; - t.DisplayOrder = d.Props.DisplayOrder; - t.Version = d.Props.Version; + var audit = definition.Props.Audit.GetValue(); + t.Name = definition.Props.Name.Value; + t.Description = definition.Props.Description.GetValue(); + t.DefaultValue = definition.Props.DefaultValue.Value; + t.ScopeId = definition.Props.Scope.Id; + t.IsActive = definition.Props.IsActive; + t.IsMandatory = definition.Props.IsMandatory; + t.DisplayOrder = definition.Props.DisplayOrder; + t.Version = definition.Props.Version; t.UpdatedBy = audit.UpdatedBy; t.UpdatedAtUtc = audit.UpdatedAt; t.AuditTimeSpan = audit.TimeSpan; + + // Sello de borrado lógico: se estampa una sola vez, en la transición. Si ya estaba puesto no + // se reescribe, para que la fecha y el actor del borrado original no se pierdan. + if (definition.Props.IsDeleted && !t.IsDeleted) + { + t.IsDeleted = true; + t.DeletedAtUtc = audit.UpdatedAt ?? DateTime.UtcNow; + t.DeletedBy = audit.UpdatedBy; + } } } public sealed class PostgreSqlParameterGlobalValueRepository(UmsPlatformDbContext db) : IParameterGlobalValueRepository { - public async Task GetByIdAsync(Guid id, CancellationToken ct = default) + /// + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) { - var r = await db.ParameterGlobalValues.FirstOrDefaultAsync(x => x.Id == id, ct); + var r = await db.ParameterGlobalValues + .FirstOrDefaultAsync(x => x.Id == id && x.StatusId != ConfigStatus.Deleted.Id, cancellationToken); return r is null ? null : ConfigurationAggregateFactory.RehydrateParameterGlobalValue(r); } - public async Task GetByDefinitionIdAsync(Guid definitionId, CancellationToken ct = default) + /// + public async Task GetByDefinitionIdAsync(Guid definitionId, CancellationToken cancellationToken = default) { + // Solo la fila VIVA: el índice único es ahora parcial, así que las lápidas no reservan la + // ranura y no deben devolverse. Devolver una haría dos daños a la vez: bloquear un alta que + // la base permite, y dar por «el valor global» uno que se retiró hace meses. var r = await db.ParameterGlobalValues - .FirstOrDefaultAsync(x => x.ParameterDefinitionId == definitionId, ct); + .Where(x => x.ParameterDefinitionId == definitionId && x.StatusId != ConfigStatus.Deleted.Id) + .OrderByDescending(x => x.CreatedAtUtc) + .FirstOrDefaultAsync(cancellationToken); return r is null ? null : ConfigurationAggregateFactory.RehydrateParameterGlobalValue(r); } - public async Task AddAsync(ParameterGlobalValue v, CancellationToken ct = default) - => await db.ParameterGlobalValues.AddAsync(ToRecord(v), ct); + public async Task AddAsync(ParameterGlobalValue value, CancellationToken cancellationToken = default) + => await db.ParameterGlobalValues.AddAsync(ToRecord(value), cancellationToken); - public async Task UpdateAsync(ParameterGlobalValue v, CancellationToken ct = default) + public async Task UpdateAsync(ParameterGlobalValue value, CancellationToken cancellationToken = default) { var existing = await db.ParameterGlobalValues - .FirstOrDefaultAsync(x => x.Id == v.Props.Id.GetValue(), ct) - ?? throw new InvalidOperationException($"ParameterGlobalValue {v.Props.Id.GetValue()} not found."); - Apply(existing, v); + .FirstOrDefaultAsync(x => x.Id == value.Props.Id.GetValue(), cancellationToken) + ?? throw new InvalidOperationException($"ParameterGlobalValue {value.Props.Id.GetValue()} not found."); + Apply(existing, value); } - public async Task SaveChangesAsync(CancellationToken ct = default) + public async Task SaveChangesAsync(CancellationToken cancellationToken = default) { - await db.SaveChangesAsync(ct); + await db.SaveChangesAsync(cancellationToken); return true; } - private static ParameterGlobalValueRecord ToRecord(ParameterGlobalValue v) + private static ParameterGlobalValueRecord ToRecord(ParameterGlobalValue value) { - var audit = v.Props.Audit.GetValue(); + var audit = value.Props.Audit.GetValue(); return new ParameterGlobalValueRecord { - Id = v.Props.Id.GetValue(), - ParameterDefinitionId = v.Props.ParameterDefinitionId.GetValue(), - EffectiveValue = v.Props.Value.Value, - StatusId = v.Props.Status.Id, - Version = v.Props.Version, + Id = value.Props.Id.GetValue(), + ParameterDefinitionId = value.Props.ParameterDefinitionId.GetValue(), + EffectiveValue = value.Props.Value.Value, + StatusId = value.Props.Status.Id, + Version = value.Props.Version, CreatedBy = audit.CreatedBy, CreatedAtUtc = audit.CreatedAt, UpdatedBy = audit.UpdatedBy, @@ -148,12 +183,12 @@ private static ParameterGlobalValueRecord ToRecord(ParameterGlobalValue v) }; } - private static void Apply(ParameterGlobalValueRecord t, ParameterGlobalValue v) + private static void Apply(ParameterGlobalValueRecord t, ParameterGlobalValue value) { - var audit = v.Props.Audit.GetValue(); - t.EffectiveValue = v.Props.Value.Value; - t.StatusId = v.Props.Status.Id; - t.Version = v.Props.Version; + var audit = value.Props.Audit.GetValue(); + t.EffectiveValue = value.Props.Value.Value; + t.StatusId = value.Props.Status.Id; + t.Version = value.Props.Version; t.UpdatedBy = audit.UpdatedBy; t.UpdatedAtUtc = audit.UpdatedAt; t.AuditTimeSpan = audit.TimeSpan; @@ -163,48 +198,57 @@ private static void Apply(ParameterGlobalValueRecord t, ParameterGlobalValue v) public sealed class PostgreSqlParameterTenantValueRepository(UmsPlatformDbContext db) : IParameterTenantValueRepository { - public async Task GetByIdAsync(Guid id, CancellationToken ct = default) + /// + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) { - var r = await db.ParameterTenantValues.FirstOrDefaultAsync(x => x.Id == id, ct); + var r = await db.ParameterTenantValues + .FirstOrDefaultAsync(x => x.Id == id && x.StatusId != ConfigStatus.Deleted.Id, cancellationToken); return r is null ? null : ConfigurationAggregateFactory.RehydrateParameterTenantValue(r); } + /// public async Task GetByTenantAndDefinitionAsync( - Guid tenantId, Guid definitionId, CancellationToken ct = default) + Guid tenantId, Guid definitionId, CancellationToken cancellationToken = default) { + // Solo la fila VIVA, por la misma razón que en el valor global: el índice único parcial + // (TenantId, ParameterDefinitionId) ya no cuenta las lápidas. var r = await db.ParameterTenantValues - .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.ParameterDefinitionId == definitionId, ct); + .Where(x => x.TenantId == tenantId + && x.ParameterDefinitionId == definitionId + && x.StatusId != ConfigStatus.Deleted.Id) + .OrderByDescending(x => x.CreatedAtUtc) + .FirstOrDefaultAsync(cancellationToken); return r is null ? null : ConfigurationAggregateFactory.RehydrateParameterTenantValue(r); } - public async Task AddAsync(ParameterTenantValue v, CancellationToken ct = default) - => await db.ParameterTenantValues.AddAsync(ToRecord(v), ct); + public async Task AddAsync(ParameterTenantValue value, CancellationToken cancellationToken = default) + => await db.ParameterTenantValues.AddAsync(ToRecord(value), cancellationToken); - public async Task UpdateAsync(ParameterTenantValue v, CancellationToken ct = default) + public async Task UpdateAsync(ParameterTenantValue value, CancellationToken cancellationToken = default) { var existing = await db.ParameterTenantValues - .FirstOrDefaultAsync(x => x.Id == v.Props.Id.GetValue(), ct) - ?? throw new InvalidOperationException($"ParameterTenantValue {v.Props.Id.GetValue()} not found."); - Apply(existing, v); + .FirstOrDefaultAsync(x => x.Id == value.Props.Id.GetValue(), cancellationToken) + ?? throw new InvalidOperationException($"ParameterTenantValue {value.Props.Id.GetValue()} not found."); + Apply(existing, value); } - public async Task SaveChangesAsync(CancellationToken ct = default) + public async Task SaveChangesAsync(CancellationToken cancellationToken = default) { - await db.SaveChangesAsync(ct); + await db.SaveChangesAsync(cancellationToken); return true; } - private static ParameterTenantValueRecord ToRecord(ParameterTenantValue v) + private static ParameterTenantValueRecord ToRecord(ParameterTenantValue value) { - var audit = v.Props.Audit.GetValue(); + var audit = value.Props.Audit.GetValue(); return new ParameterTenantValueRecord { - Id = v.Props.Id.GetValue(), - TenantId = v.Props.TenantId.GetValue(), - ParameterDefinitionId = v.Props.ParameterDefinitionId.GetValue(), - OverrideValue = v.Props.Value.Value, - StatusId = v.Props.Status.Id, - Version = v.Props.Version, + Id = value.Props.Id.GetValue(), + TenantId = value.Props.TenantId.GetValue(), + ParameterDefinitionId = value.Props.ParameterDefinitionId.GetValue(), + OverrideValue = value.Props.Value.Value, + StatusId = value.Props.Status.Id, + Version = value.Props.Version, CreatedBy = audit.CreatedBy, CreatedAtUtc = audit.CreatedAt, UpdatedBy = audit.UpdatedBy, @@ -213,12 +257,12 @@ private static ParameterTenantValueRecord ToRecord(ParameterTenantValue v) }; } - private static void Apply(ParameterTenantValueRecord t, ParameterTenantValue v) + private static void Apply(ParameterTenantValueRecord t, ParameterTenantValue value) { - var audit = v.Props.Audit.GetValue(); - t.OverrideValue = v.Props.Value.Value; - t.StatusId = v.Props.Status.Id; - t.Version = v.Props.Version; + var audit = value.Props.Audit.GetValue(); + t.OverrideValue = value.Props.Value.Value; + t.StatusId = value.Props.Status.Id; + t.Version = value.Props.Version; t.UpdatedBy = audit.UpdatedBy; t.UpdatedAtUtc = audit.UpdatedAt; t.AuditTimeSpan = audit.TimeSpan; diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/IDistributedLockProvider.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/IDistributedLockProvider.cs index 149e7b1d..3ef2ea75 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/IDistributedLockProvider.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/IDistributedLockProvider.cs @@ -4,7 +4,7 @@ namespace Ums.Infrastructure.Persistence; /// /// Abstraction for database-level distributed locks, enabling the architecture -/// to support multiple providers (e.g. SQL Server's sp_getapplock vs PostgreSQL's pg_advisory_lock) +/// to support the PostgreSQL advisory-lock implementation (pg_advisory_lock). /// without coupling the bootstrapper or outbox dispatcher to a specific database engine. /// public interface IDistributedLockProvider diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/PasswordResetTokenRecordConfiguration.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/PasswordResetTokenRecordConfiguration.cs new file mode 100644 index 00000000..87c2755d --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/PasswordResetTokenRecordConfiguration.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Ums.Infrastructure.Persistence.Identity.Entities; + +namespace Ums.Infrastructure.Persistence.Identity.Configurations; + +public sealed class PasswordResetTokenRecordConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("PasswordResetTokens", IdentityPersistenceConstants.Schema); + builder.HasKey(x => x.Id); + + // Único: el canje localiza el token por hash, y dos filas con el mismo hash harían + // ambigua la resolución de cuál gastar. + builder.Property(x => x.TokenHash).HasMaxLength(128).IsRequired(); + builder.HasIndex(x => x.TokenHash).IsUnique(); + + builder.Property(x => x.Status).HasMaxLength(20).IsRequired(); + builder.Property(x => x.InvalidatedReason).HasMaxLength(60); + + builder.HasIndex(x => new { x.TenantId, x.UserId }); // invalidación de los vivos del usuario + builder.HasIndex(x => x.ExpiresAtUtc); // purga de vencidos + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/RefreshTokenRecordConfiguration.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/RefreshTokenRecordConfiguration.cs new file mode 100644 index 00000000..e8cada7b --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/RefreshTokenRecordConfiguration.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Ums.Infrastructure.Persistence.Identity.Entities; + +namespace Ums.Infrastructure.Persistence.Identity.Configurations; + +public sealed class RefreshTokenRecordConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("RefreshTokens", IdentityPersistenceConstants.Schema); + builder.HasKey(x => x.Id); + + // Solo el hash del token; único para localizarlo en renovación/revocación. + builder.Property(x => x.TokenHash).HasMaxLength(128).IsRequired(); + builder.HasIndex(x => x.TokenHash).IsUnique(); + + builder.Property(x => x.Status).HasMaxLength(20).IsRequired(); + builder.Property(x => x.RevokedReason).HasMaxLength(60); + + builder.HasIndex(x => x.FamilyId); // invalidación de familia (reuso/revocación) + builder.HasIndex(x => new { x.TenantId, x.UserId }); + builder.HasIndex(x => x.ExpiresAtUtc); + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/TenantBranchLifecycleEntryRecordConfiguration.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/TenantBranchLifecycleEntryRecordConfiguration.cs new file mode 100644 index 00000000..25d6690d --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/TenantBranchLifecycleEntryRecordConfiguration.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Ums.Infrastructure.Persistence.Identity.Entities; + +namespace Ums.Infrastructure.Persistence.Identity.Configurations; + +public sealed class TenantBranchLifecycleEntryRecordConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("TenantBranchLifecycleEntries", IdentityPersistenceConstants.Schema); + builder.HasKey(x => x.Id); + + builder.Property(x => x.ActorId).HasMaxLength(100).IsRequired(); + builder.Property(x => x.NameSnapshot).HasMaxLength(200).IsRequired(); + builder.Property(x => x.GeofencingSnapshot).HasMaxLength(4000); + builder.Property(x => x.Reason).HasMaxLength(500); + builder.Property(x => x.EpisodeId).IsRequired(); + builder.Property(x => x.OccurredAtUtc).IsRequired(); + + // La consulta de la bitácora es siempre «los episodios de ESTA sucursal en orden»; el índice + // compuesto la resuelve sin ordenar en memoria. + builder.HasIndex(x => new { x.BranchId, x.OccurredAtUtc }); + + // Aislamiento por inquilino: el filtro global y la política RLS filtran por TenantId. + builder.HasIndex(x => x.TenantId); + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/TenantBranchRecordConfiguration.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/TenantBranchRecordConfiguration.cs index a0182507..5ad43e44 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/TenantBranchRecordConfiguration.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/TenantBranchRecordConfiguration.cs @@ -18,6 +18,25 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.UpdatedBy).HasMaxLength(100); builder.Property(x => x.AuditTimeSpan).HasMaxLength(100).IsRequired(); + // ADR-0164 §2.1: cierre definitivo. + builder.Property(x => x.IsClosed).HasDefaultValue(false).IsRequired(); + builder.Property(x => x.ClosedBy).HasMaxLength(100); + + // ADR-0164 §2.3: el índice único NO se filtra por el estado de cierre. Es la mitad de base de + // datos de la misma decisión que toma `Tenant.AddBranch`: el código de una sucursal cerrada + // queda ocupado para siempre, así que un alta con ese código choca aquí igual que arriba y + // ninguna consulta histórica queda ambigua. builder.HasIndex(x => new { x.TenantId, x.Code }).IsUnique(); + + // Índice parcial que cubre el predicado de las LECTURAS de listado (`IsClosed = false`), + // el mismo patrón que ya usan Tenants y UserAccounts para su borrado lógico. + builder.HasIndex(x => x.IsClosed).HasFilter("\"IsClosed\" = false"); + + // La bitácora cuelga de la sucursal. El borrado en cascada es teórico —la sucursal ya no se + // borra nunca— pero se declara para que el modelo no dependa de esa promesa. + builder.HasMany(x => x.LifecycleEntries) + .WithOne(x => x.Branch) + .HasForeignKey(x => x.BranchId) + .OnDelete(DeleteBehavior.Cascade); } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/TenantParameterRecordConfiguration.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/TenantParameterRecordConfiguration.cs index 144ebcd2..491a4a67 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/TenantParameterRecordConfiguration.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/TenantParameterRecordConfiguration.cs @@ -17,6 +17,9 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.ValueTypeId).IsRequired(); builder.Property(x => x.CategoryId).IsRequired(); builder.Property(x => x.IsActive).IsRequired(); + // REC-16 (mismo patrón que TenantRecord/UserAccountRecord): la marca de borrado lógico nace en + // false para toda fila existente, así que la migración no reescribe datos. + builder.Property(x => x.IsDeleted).HasDefaultValue(false).IsRequired(); builder.Property(x => x.IsSensitive).IsRequired(); builder.Property(x => x.DefaultValue).HasMaxLength(4000); builder.Property(x => x.AllowedValues).HasMaxLength(2000); @@ -24,9 +27,16 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.UpdatedBy).HasMaxLength(100); builder.Property(x => x.AuditTimeSpan).HasMaxLength(100).IsRequired(); + // El índice único parcial sigue cubriendo solo las filas ACTIVAS. Un parámetro eliminado + // lógicamente está siempre inactivo (Delete exige desactivación previa), así que queda fuera + // del índice y no impide dar de alta de nuevo el mismo código. builder.HasIndex(x => new { x.TenantId, x.Code, x.IsActive }) .HasFilter("\"IsActive\" = true") .IsUnique() .HasDatabaseName("IX_TenantParameters_TenantId_Code_IsActive"); + + // Índice parcial sobre las filas vivas: es el predicado que el filtro global añade a TODA + // consulta de parámetros, igual que en Tenants y UserAccounts. + builder.HasIndex(x => x.IsDeleted).HasFilter("\"IsDeleted\" = false"); } } \ No newline at end of file diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/TenantRecordConfiguration.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/TenantRecordConfiguration.cs index 67d64ba9..9e1cd769 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/TenantRecordConfiguration.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/TenantRecordConfiguration.cs @@ -28,6 +28,13 @@ public void Configure(EntityTypeBuilder builder) builder.HasIndex(x => x.Code).IsUnique(); builder.HasIndex(x => x.ParentTenantId); + // ADR-0071 / FS-26 (G-025): invariante de un único propietario de gestión (Admin Root) + // por ecosistema. Índice único filtrado: a lo sumo una fila con IsManagementOwner = true. + builder.HasIndex(x => x.IsManagementOwner) + .IsUnique() + .HasFilter("\"IsManagementOwner\" = true") + .HasDatabaseName("IX_Tenants_SingleManagementOwner"); + builder.HasMany(x => x.Branches) .WithOne(x => x.Tenant) .HasForeignKey(x => x.TenantId) @@ -37,10 +44,5 @@ public void Configure(EntityTypeBuilder builder) .WithOne(x => x.Tenant) .HasForeignKey(x => x.TenantId) .OnDelete(DeleteBehavior.Cascade); - - builder.HasOne(x => x.Branding) - .WithOne(x => x.Tenant) - .HasForeignKey(x => x.TenantId) - .OnDelete(DeleteBehavior.Cascade); } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/UserAccountRecordConfiguration.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/UserAccountRecordConfiguration.cs index abe2491c..3168aa64 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/UserAccountRecordConfiguration.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Configurations/UserAccountRecordConfiguration.cs @@ -19,6 +19,9 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.AuditTimeSpan).HasMaxLength(100).IsRequired(); builder.Property(x => x.RowVersion).IsRowVersion(); // FIX-03: optimistic concurrency + // ADR-UMS-095: bloqueo temporal de cuenta por intentos fallidos. + builder.Property(x => x.FailedLoginAttempts).HasDefaultValue(0).IsRequired(); + // REC-16: Soft-delete + GDPR builder.Property(x => x.IsDeleted).HasDefaultValue(false).IsRequired(); builder.Property(x => x.DeletedBy).HasMaxLength(100); diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/PasswordResetTokenRecord.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/PasswordResetTokenRecord.cs new file mode 100644 index 00000000..93e29e1f --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/PasswordResetTokenRecord.cs @@ -0,0 +1,35 @@ +namespace Ums.Infrastructure.Persistence.Identity.Entities; + +using Ums.Application.Identity.Auth; + +/// +/// Registro de persistencia de un token de restablecimiento de contraseña (G-188). +/// +/// Seguridad: se guarda ÚNICAMENTE el . El plaintext sale una +/// sola vez hacia el buzón del titular y no se persiste, ni se registra en logs, ni viaja en +/// ninguna respuesta HTTP. +/// +public class PasswordResetTokenRecord +{ + public Guid Id { get; set; } + + public Guid TenantId { get; set; } + + public Guid UserId { get; set; } + + /// SHA-256 (hex) del token en claro. + public string TokenHash { get; set; } = string.Empty; + + /// Active | Used | Invalidated. + public string Status { get; set; } = PasswordResetTokenStatuses.Active; + + public DateTime IssuedAtUtc { get; set; } + + public DateTime ExpiresAtUtc { get; set; } + + /// Momento en que el token dejó de ser canjeable, sea por canje o por invalidación. + public DateTime? ConsumedAtUtc { get; set; } + + /// Motivo de la invalidación (reissue, password-reset, account-change). + public string? InvalidatedReason { get; set; } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/RefreshTokenRecord.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/RefreshTokenRecord.cs new file mode 100644 index 00000000..090c1c74 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/RefreshTokenRecord.cs @@ -0,0 +1,47 @@ +namespace Ums.Infrastructure.Persistence.Identity.Entities; + +using Ums.Application.Identity.Auth; + +/// +/// Registro de persistencia de un refresh token (ADR-UMS-091 / FR-015/016). +/// +/// Seguridad: se guarda ÚNICAMENTE el (SHA-256 del +/// plaintext); el token en claro se devuelve una sola vez al cliente y nunca se persiste, +/// registra ni aparece en el grafo. La búsqueda en renovación/revocación se hace por hash. +/// +/// agrupa la cadena de rotaciones de una misma sesión: la +/// detección de reuso invalida la familia entera. es +/// Active | Rotated | Revoked | Used. +/// +public class RefreshTokenRecord +{ + public Guid Id { get; set; } + + public Guid TenantId { get; set; } + + public Guid UserId { get; set; } + + /// Familia de la cadena de rotación (misma sesión). + public Guid FamilyId { get; set; } + + /// SHA-256 (hex) del token en claro. Nunca se guarda el plaintext. + public string TokenHash { get; set; } = string.Empty; + + /// Active | Rotated | Revoked | Used. + public string Status { get; set; } = RefreshTokenStatuses.Active; + + public DateTime IssuedAtUtc { get; set; } + + public DateTime ExpiresAtUtc { get; set; } + + /// Nº de renovaciones ya realizadas en esta familia (tope opcional). + public int RenewalCount { get; set; } + + /// Token que reemplazó a este al rotar (cadena de rotación). + public Guid? ReplacedByTokenId { get; set; } + + public DateTime? RevokedAtUtc { get; set; } + + /// Motivo de revocación (logout, block, permission-change, reuse). + public string? RevokedReason { get; set; } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/TenantBranchLifecycleEntryRecord.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/TenantBranchLifecycleEntryRecord.cs new file mode 100644 index 00000000..5781059d --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/TenantBranchLifecycleEntryRecord.cs @@ -0,0 +1,33 @@ +namespace Ums.Infrastructure.Persistence.Identity.Entities; + +/// +/// Fila de la BITÁCORA de una sucursal (ADR-0164). Append-only por diseño: no lleva columnas de +/// auditoría de modificación porque un asiento no se modifica —el instante y el autor del episodio +/// SON el dato—, y una bitácora que se puede reescribir no prueba nada. +/// +public sealed class TenantBranchLifecycleEntryRecord +{ + public Guid Id { get; set; } + + /// Inquilino dueño de la sucursal. Sostiene el filtro global y la política RLS. + public Guid TenantId { get; set; } + + public Guid BranchId { get; set; } + + /// Identificador de BranchLifecycleEpisode. Estable: se persiste. + public int EpisodeId { get; set; } + + public DateTime OccurredAtUtc { get; set; } + + public string ActorId { get; set; } = string.Empty; + + /// Nombre de la sucursal en el instante del episodio (foto de la época). + public string NameSnapshot { get; set; } = string.Empty; + + /// Geocerca en el instante del episodio; nula si la sucursal no tenía. + public string? GeofencingSnapshot { get; set; } + + public string? Reason { get; set; } + + public TenantBranchRecord Branch { get; set; } = default!; +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/TenantBranchRecord.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/TenantBranchRecord.cs index 6a4ae27c..6c402cae 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/TenantBranchRecord.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/TenantBranchRecord.cs @@ -10,6 +10,13 @@ public sealed class TenantBranchRecord : IAuditableRecord public string Name { get; set; } = string.Empty; public string? GeofencingMetadata { get; set; } public bool IsActive { get; set; } + + // ADR-0164 §2.1: cierre definitivo. La fila permanece; lo que cambia es que deja de listarse y + // deja de admitir transiciones. `IsActive` sigue siendo el eje reversible y no se toca. + public bool IsClosed { get; set; } + public DateTime? ClosedAtUtc { get; set; } + public string? ClosedBy { get; set; } + public string CreatedBy { get; set; } = string.Empty; public DateTime CreatedAtUtc { get; set; } public string? UpdatedBy { get; set; } @@ -17,4 +24,7 @@ public sealed class TenantBranchRecord : IAuditableRecord public string AuditTimeSpan { get; set; } = string.Empty; public TenantRecord Tenant { get; set; } = default!; + + /// Bitácora de episodios de la sucursal. Solo crece: nunca se actualiza ni se borra. + public ICollection LifecycleEntries { get; set; } = new List(); } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/TenantParameterRecord.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/TenantParameterRecord.cs index 3a0b635b..155cc730 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/TenantParameterRecord.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/TenantParameterRecord.cs @@ -12,6 +12,11 @@ public sealed class TenantParameterRecord : IAuditableRecord public int ValueTypeId { get; set; } public int CategoryId { get; set; } public bool IsActive { get; set; } + /// + /// Borrado LÓGICO (política del propietario: el borrado físico no se permite). Independiente de + /// : inactivo es reversible, eliminado es terminal. + /// + public bool IsDeleted { get; set; } public bool IsSensitive { get; set; } public string? DefaultValue { get; set; } public string? AllowedValues { get; set; } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/TenantRecord.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/TenantRecord.cs index 767271fb..a38d7857 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/TenantRecord.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/TenantRecord.cs @@ -12,6 +12,10 @@ public sealed class TenantRecord : IAuditableRecord public string? CompanyReference { get; set; } public Guid? ParentTenantId { get; set; } public bool IsManagementOwner { get; set; } + + // FR-042 (ADR-UMS-097 §2.2): suite por defecto del inquilino. Columna nullable/retrocompatible: + // los inquilinos existentes quedan NULL y la resolución de IdP omite el filtro por suite. + public Guid? DefaultSystemSuiteId { get; set; } public int StatusId { get; set; } public string CreatedBy { get; set; } = string.Empty; public DateTime CreatedAtUtc { get; set; } @@ -27,5 +31,7 @@ public sealed class TenantRecord : IAuditableRecord public List Branches { get; set; } = []; public List IdentityProviders { get; set; } = []; + + // Branding por inquilino: activo propio del satélite (no existe en la plataforma de origen). public TenantBrandingRecord? Branding { get; set; } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/UserAccountRecord.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/UserAccountRecord.cs index e7746c34..d03c2c29 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/UserAccountRecord.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/Entities/UserAccountRecord.cs @@ -23,6 +23,10 @@ public sealed class UserAccountRecord : IAuditableRecord // FS-19: Validity period management public DateTime? ExpiresAtUtc { get; set; } + // ADR-UMS-095: bloqueo temporal de cuenta por intentos fallidos de autenticación. + public int FailedLoginAttempts { get; set; } + public DateTime? LockedUntilUtc { get; set; } + // REC-16: Soft-delete + GDPR anonymization public bool IsDeleted { get; set; } public DateTime? DeletedAtUtc { get; set; } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/InMemoryTenantSignupRequestRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/InMemoryTenantSignupRequestRepository.cs index af636ae9..be5c2c70 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/InMemoryTenantSignupRequestRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/InMemoryTenantSignupRequestRepository.cs @@ -1,3 +1,4 @@ +#pragma warning disable S4144 // Scaffolding intencional para testing In-Memory using System.Collections.Concurrent; using Ums.Domain.Identity; using Ums.Domain.Identity.TenantSignupRequest; @@ -50,3 +51,5 @@ public void Seed(TenantSignupRequestAggregate aggregate) public void Dispose() { } } + +#pragma warning restore S4144 diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/PasswordResetTokenStore.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/PasswordResetTokenStore.cs new file mode 100644 index 00000000..9b80329b --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/PasswordResetTokenStore.cs @@ -0,0 +1,126 @@ +namespace Ums.Infrastructure.Persistence.Identity; + +using Microsoft.EntityFrameworkCore; +using Ums.Application.Identity.Auth; +using Ums.Infrastructure.Persistence.Identity.Entities; + +/// +/// Implementación de sobre . +/// Persiste solo el hash del token (G-188). Funciona con Npgsql y con el EF InMemory de los tests. +/// +public sealed class PasswordResetTokenStore : IPasswordResetTokenStore +{ + private readonly UmsPlatformDbContext _db; + + public PasswordResetTokenStore(UmsPlatformDbContext db) + { + _db = db; + } + + public async Task IssueAsync( + Guid tenantId, + Guid userId, + string tokenHash, + DateTime issuedAtUtc, + DateTime expiresAtUtc, + CancellationToken cancellationToken = default) + { + // Un solo secreto pendiente por usuario: pedir el restablecimiento de nuevo anula el + // enlace anterior. Sin esto, cada solicitud dejaría un token vivo más y la ventana de + // exposición crecería con el número de solicitudes, no con la vida del token. + MarkAsInvalidated( + await ActiveTokensOf(tenantId, userId).ToListAsync(cancellationToken).ConfigureAwait(false), + "reissue", + issuedAtUtc); + + _db.PasswordResetTokens.Add(new PasswordResetTokenRecord + { + Id = Guid.NewGuid(), + TenantId = tenantId, + UserId = userId, + TokenHash = tokenHash, + Status = PasswordResetTokenStatuses.Active, + IssuedAtUtc = issuedAtUtc, + ExpiresAtUtc = expiresAtUtc, + }); + + // Un único SaveChanges ⇒ la invalidación de los anteriores y la emisión del nuevo + // entran juntas o no entra ninguna. + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + public async Task FindByHashAsync( + string tokenHash, + CancellationToken cancellationToken = default) + { + var record = await _db.PasswordResetTokens + .AsNoTracking() + .FirstOrDefaultAsync(r => r.TokenHash == tokenHash, cancellationToken) + .ConfigureAwait(false); + + return record is null + ? null + : new PasswordResetTokenSnapshot( + record.Id, + record.TenantId, + record.UserId, + record.Status, + record.IssuedAtUtc, + record.ExpiresAtUtc); + } + + public async Task ConsumeAsync( + Guid tokenId, + DateTime consumedAtUtc, + CancellationToken cancellationToken = default) + { + var record = await _db.PasswordResetTokens + .FirstOrDefaultAsync(r => r.Id == tokenId && r.Status == PasswordResetTokenStatuses.Active, cancellationToken) + .ConfigureAwait(false); + + if (record is null) + { + return; + } + + record.Status = PasswordResetTokenStatuses.Used; + record.ConsumedAtUtc = consumedAtUtc; + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + public async Task InvalidateActiveForUserAsync( + Guid tenantId, + Guid userId, + string reason, + DateTime invalidatedAtUtc, + CancellationToken cancellationToken = default) + { + var records = await ActiveTokensOf(tenantId, userId).ToListAsync(cancellationToken).ConfigureAwait(false); + if (records.Count == 0) + { + return; + } + + MarkAsInvalidated(records, reason, invalidatedAtUtc); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + private IQueryable ActiveTokensOf(Guid tenantId, Guid userId) => + _db.PasswordResetTokens.Where(r => + r.TenantId == tenantId + && r.UserId == userId + && r.Status == PasswordResetTokenStatuses.Active); + + private static void MarkAsInvalidated( + List records, + string reason, + DateTime invalidatedAtUtc) + { + foreach (var record in records) + { + record.Status = PasswordResetTokenStatuses.Invalidated; + record.InvalidatedReason = reason; + record.ConsumedAtUtc = invalidatedAtUtc; + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/PostgreSqlTenantRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/PostgreSqlTenantRepository.cs index 1a4bb081..846ddc5f 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/PostgreSqlTenantRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/PostgreSqlTenantRepository.cs @@ -1,6 +1,8 @@ using System.Data; using Microsoft.EntityFrameworkCore; +using Ums.Domain.Enums; using Ums.Domain.Identity; +using Ums.Domain.Identity.Tenant.Branch; using Ums.Domain.Kernel; using Ums.Infrastructure.Persistence; using Ums.Infrastructure.Persistence.Identity.Entities; @@ -22,14 +24,8 @@ public sealed class PostgreSqlTenantRepository(UmsPlatformDbContext dbContext) : .AsSingleQuery() .Include(x => x.Branches) .Include(x => x.IdentityProviders) - .Include(x => x.Branding) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); - if (record is not null) - { - await LoadSqliteTenantChildrenAsync(record, cancellationToken); - } - return record is null ? null : Rehydrate(record); } @@ -41,24 +37,46 @@ public sealed class PostgreSqlTenantRepository(UmsPlatformDbContext dbContext) : // del CONTEXTO; un admin interno que provisiona sobre otro inquilino cargaría una colección vacía y // la guarda en memoria no vería el duplicado. La lectura sigue aislada (esta consulta no expone // datos: solo devuelve un booleano y se usa dentro de un handler ya acotado a management-owner). + // + // ADR-0164 §2.3: tampoco filtra por `IsClosed`. El código de una sucursal cerrada sigue ocupado, + // y esta consulta es la que hace que el alta lo diga con un conflicto legible en vez de dejar + // que reviente el índice único con un 23505. public Task BranchCodeExistsAsync(Guid tenantId, string code, CancellationToken cancellationToken = default) => dbContext.TenantBranches .IgnoreQueryFilters() .AnyAsync(b => b.TenantId == tenantId && b.Code == code, cancellationToken); + /// + public async Task> GetBranchLifecycleAsync( + Guid tenantId, Guid branchId, CancellationToken cancellationToken = default) + { + var filas = await dbContext.TenantBranchLifecycleEntries + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.BranchId == branchId) + .OrderBy(x => x.OccurredAtUtc) + .ThenBy(x => x.EpisodeId) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + return filas.Select(f => new BranchLifecycleEntry( + f.Id, + f.TenantId, + f.BranchId, + DomainEnumerationMapper.FromValue(f.EpisodeId), + f.OccurredAtUtc, + f.ActorId, + f.NameSnapshot, + f.GeofencingSnapshot, + f.Reason)).ToList(); + } + public async Task GetByCodeAsync(string code, CancellationToken cancellationToken = default) { var record = await dbContext.Tenants .Include(x => x.Branches) .Include(x => x.IdentityProviders) - .Include(x => x.Branding) .FirstOrDefaultAsync(x => x.Code == code, cancellationToken); - if (record is not null) - { - await LoadSqliteTenantChildrenAsync(record, cancellationToken); - } - return record is null ? null : Rehydrate(record); } @@ -76,7 +94,6 @@ public async Task> GetAllAsync(Guid? tenantId = n .AsSplitQuery() .Include(x => x.Branches) .Include(x => x.IdentityProviders) - .Include(x => x.Branding) .OrderBy(x => x.Name) .ToListAsync(cancellationToken); @@ -86,7 +103,7 @@ public async Task> GetAllAsync(Guid? tenantId = n /// public async Task<(IReadOnlyList Items, int TotalCount)> GetPagedAsync( int page, int pageSize, string? search, string? status, string sortBy, string sortOrder, - Guid? tenantId = null, CancellationToken cancellationToken = default) + Guid? tenantId = null, CancellationToken cancellationToken = default, string? searchField = null) { // REC-12: Apply all filtering at the DB level before Skip/Take to avoid loading full tables. var query = dbContext.Tenants.AsQueryable(); @@ -101,7 +118,10 @@ public async Task> GetAllAsync(Guid? tenantId = n if (!string.IsNullOrWhiteSpace(search)) { var lower = search.ToLower(); - query = (sortBy.ToLower()) switch + // El campo de búsqueda lo determina `searchField` (parámetro `criteria` del API), no el + // orden: buscar por código no debe exigir ordenar por código. Fallback a sortBy si viene vacío. + var field = string.IsNullOrWhiteSpace(searchField) ? sortBy : searchField; + query = (field.ToLower()) switch { "code" => query.Where(t => t.Code.ToLower().Contains(lower)), _ => query.Where(t => t.Name.ToLower().Contains(lower)), @@ -134,7 +154,6 @@ public async Task> GetAllAsync(Guid? tenantId = n .AsSplitQuery() .Include(x => x.Branches) .Include(x => x.IdentityProviders) - .Include(x => x.Branding) .Skip((page - 1) * pageSize) .Take(pageSize) .ToListAsync(cancellationToken); @@ -176,7 +195,6 @@ public async Task UpdateAsync(TenantAggregate aggregate, CancellationToken cance var existing = await dbContext.Tenants .Include(x => x.Branches) .Include(x => x.IdentityProviders) - .Include(x => x.Branding) .FirstOrDefaultAsync(x => x.Id == aggregate.Props.Id.GetValue(), cancellationToken) ?? throw new InvalidOperationException($"Tenant {aggregate.Props.Id.GetValue()} does not exist."); @@ -191,6 +209,8 @@ public async Task SaveEntitiesAsync(CancellationToken cancellationToken = { // Capture outbox messages BEFORE committing so events are not lost on save failure. // MarkChangesAsCommitted() is called AFTER SaveChangesAsync succeeds (FIX-01). + // Publicación pre-commit = bug (mensaje fantasma); se stagea vía outbox y se entrega + // post-commit. Patrón e interpretación: KB-TXN-001 (evolith-core); ADR-0098 D4.1 / G-066. foreach (var aggregate in _trackedAggregates) { await dbContext.PublishDomainEventsAsync(aggregate.DomainEvents.GetUncommittedChanges(), cancellationToken); @@ -207,10 +227,23 @@ public async Task SaveEntitiesAsync(CancellationToken cancellationToken = var id = (Guid)(entry?.Property("Id").CurrentValue ?? Guid.Empty); throw new ConcurrencyConflictException(entry?.Metadata.Name ?? "Unknown", id); } + catch (Microsoft.EntityFrameworkCore.DbUpdateException ex) + when (ex.InnerException is Npgsql.PostgresException { SqlState: Npgsql.PostgresErrorCodes.UniqueViolation }) + { + // G-045/G-037: red de seguridad. Si una violación de índice único (23505) escapa a las + // guardas de aplicación (p.ej. IX_Tenants_SingleManagementOwner ante una carrera), + // la traducimos a 409 Conflict en vez de dejar que caiga a 500. + var entry = ex.Entries.FirstOrDefault(); + var id = (Guid)(entry?.Property("Id").CurrentValue ?? Guid.Empty); + throw new ConcurrencyConflictException(entry?.Metadata.Name ?? "Unknown", id); + } foreach (var aggregate in _trackedAggregates) { aggregate.DomainEvents.MarkChangesAsCommitted(); + // Los asientos de bitácora ya están en la base: vaciar el búfer evita que un segundo + // guardado del mismo agregado vuelva a intentar insertarlos (ADR-0164). + aggregate.MarkBranchLifecycleAsCommitted(); } _trackedAggregates.Clear(); @@ -220,74 +253,7 @@ public async Task SaveEntitiesAsync(CancellationToken cancellationToken = public void Dispose() => dbContext.Dispose(); private static TenantAggregate Rehydrate(TenantRecord record) - => IdentityAggregateFactory.RehydrateTenant(record, record.Branches, record.IdentityProviders, record.Branding); - - private async Task LoadSqliteTenantChildrenAsync(TenantRecord record, CancellationToken cancellationToken) - { - if (!dbContext.Database.IsSqlite()) - { - return; - } - - var tenantId = record.Id.ToString(); - - record.Branches = await LoadSqliteTenantBranchesAsync(tenantId, cancellationToken); - - record.IdentityProviders = await dbContext.TenantIdentityProviders - .FromSqlInterpolated($"SELECT * FROM TenantIdentityProviders WHERE lower(TenantId) = lower({tenantId})") - .ToListAsync(cancellationToken); - - record.Branding = await dbContext.TenantBrandings - .FromSqlInterpolated($"SELECT * FROM TenantBrandings WHERE lower(TenantId) = lower({tenantId})") - .FirstOrDefaultAsync(cancellationToken); - } - - private async Task> LoadSqliteTenantBranchesAsync( - string tenantId, - CancellationToken cancellationToken) - { - var connection = dbContext.Database.GetDbConnection(); - if (connection.State != ConnectionState.Open) - { - await connection.OpenAsync(cancellationToken); - } - - await using var command = connection.CreateCommand(); - command.CommandText = """ - SELECT Id, TenantId, Code, Name, GeofencingMetadata, IsActive, CreatedBy, CreatedAtUtc, UpdatedBy, UpdatedAtUtc, AuditTimeSpan - FROM TenantBranches - WHERE lower(TenantId) = lower($tenantId) - ORDER BY Code - """; - - var parameter = command.CreateParameter(); - parameter.ParameterName = "$tenantId"; - parameter.Value = tenantId; - command.Parameters.Add(parameter); - - var branches = new List(); - await using var reader = await command.ExecuteReaderAsync(cancellationToken); - - while (await reader.ReadAsync(cancellationToken)) - { - branches.Add(new TenantBranchRecord - { - Id = Guid.Parse(reader.GetString(0)), - TenantId = Guid.Parse(reader.GetString(1)), - Code = reader.GetString(2), - Name = reader.GetString(3), - GeofencingMetadata = reader.IsDBNull(4) ? null : reader.GetString(4), - IsActive = reader.GetBoolean(5), - CreatedBy = reader.GetString(6), - CreatedAtUtc = reader.GetDateTime(7), - UpdatedBy = reader.IsDBNull(8) ? null : reader.GetString(8), - UpdatedAtUtc = reader.IsDBNull(9) ? null : reader.GetDateTime(9), - AuditTimeSpan = reader.GetString(10), - }); - } - - return branches; - } + => IdentityAggregateFactory.RehydrateTenant(record, record.Branches, record.IdentityProviders); private static TenantRecord ToRecord(TenantAggregate aggregate) { @@ -303,6 +269,8 @@ private static TenantRecord ToRecord(TenantAggregate aggregate) CompanyReference = aggregate.CompanyReference?.GetValue(), ParentTenantId = aggregate.ParentTenantId?.GetValue(), IsManagementOwner = aggregate.IsManagementOwner, + // FR-042 (ADR-UMS-097 §2.2): suite por defecto del inquilino (nullable/retrocompatible). + DefaultSystemSuiteId = aggregate.DefaultSystemSuiteId?.GetValue(), StatusId = aggregate.Status.Id, CreatedBy = audit.CreatedBy, CreatedAtUtc = audit.CreatedAt, @@ -320,11 +288,17 @@ private static TenantRecord ToRecord(TenantAggregate aggregate) Name = branch.Name.GetValue(), GeofencingMetadata = branch.GeofencingMetadata?.GetValue(), IsActive = branch.IsActive, + IsClosed = branch.IsClosed, + ClosedAtUtc = branch.ClosedAtUtc, + ClosedBy = branch.ClosedBy, CreatedBy = a.CreatedBy, CreatedAtUtc = a.CreatedAt, UpdatedBy = a.UpdatedBy, UpdatedAtUtc = a.UpdatedAt, AuditTimeSpan = a.TimeSpan, + // Solo los asientos de ESTA unidad de trabajo: la bitácora histórica no se carga + // con el agregado, así que aquí no hay nada que reescribir, solo que añadir. + LifecycleEntries = branch.PendingLifecycleEntries.Select(ToLifecycleRecord).ToList(), }; }).ToList(), IdentityProviders = aggregate.IdentityProviders.Select(provider => @@ -346,34 +320,6 @@ private static TenantRecord ToRecord(TenantAggregate aggregate) AuditTimeSpan = a.TimeSpan, }; }).ToList(), - Branding = aggregate.Branding is null ? null : ToBrandingRecord(aggregate.Branding), - }; - } - - private static TenantBrandingRecord ToBrandingRecord(Ums.Domain.Identity.Tenant.Branding.Branding branding) - { - var audit = branding.Props.Audit.GetValue(); - return new TenantBrandingRecord - { - Id = branding.Props.Id.GetValue(), - TenantId = branding.Props.TenantId.GetValue(), - Logo = branding.Logo.GetValue(), - LogoFormatId = branding.LogoFormat.Id, - PrimaryColor = branding.PrimaryColor.GetValue(), - BackgroundStyleId = branding.BackgroundStyle.Id, - HeadlineText = branding.HeadlineText.GetValue(), - SecondaryText = branding.SecondaryText.GetValue(), - PrimaryButtonLabel = branding.PrimaryButtonLabel.GetValue(), - FooterText = branding.FooterText.GetValue(), - CustomDomain = branding.CustomDomain?.GetValue(), - DnsVerificationStatusId = branding.DnsVerificationStatus.Id, - DnsCnameTarget = branding.DnsCnameTarget.GetValue(), - MagicLinkFallbackEnabled = branding.MagicLinkFallbackEnabled, - CreatedBy = audit.CreatedBy, - CreatedAtUtc = audit.CreatedAt, - UpdatedBy = audit.UpdatedBy, - UpdatedAtUtc = audit.UpdatedAt, - AuditTimeSpan = audit.TimeSpan, }; } @@ -388,6 +334,7 @@ private void Apply(TenantRecord target, TenantAggregate source) target.CompanyReference = replacement.CompanyReference; target.ParentTenantId = replacement.ParentTenantId; target.IsManagementOwner = replacement.IsManagementOwner; + target.DefaultSystemSuiteId = replacement.DefaultSystemSuiteId; target.StatusId = replacement.StatusId; target.CreatedBy = replacement.CreatedBy; target.CreatedAtUtc = replacement.CreatedAtUtc; @@ -398,19 +345,81 @@ private void Apply(TenantRecord target, TenantAggregate source) UpsertBranches(target.Branches, replacement.Branches); UpsertIdentityProviders(target.IdentityProviders, replacement.IdentityProviders); - target.Branding = replacement.Branding; } private void UpsertBranches(ICollection target, IEnumerable source) { + var reemplazo = source.ToList(); + EfChildCollectionReconciler.ReconcileById( dbContext, target, - source, + reemplazo, branch => branch.Id, UpdateBranch); + + AppendLifecycleEntries(target, reemplazo); + } + + /// + /// Vuelca los asientos de bitácora del agregado en la MISMA transacción que el cambio de estado. + /// + /// Va aquí y no en un manejador de eventos post-commit porque ese despacho es, por + /// contrato (ADR-0098 D4), best-effort: un fallo se registra como advertencia y no revierte + /// nada. Una bitácora que puede perder episodios en silencio no sirve como prueba de auditoría. + /// + /// Solo AÑADE. No actualiza ni borra, y por eso no usa el reconciliador: un asiento es + /// inmutable, y la colección rastreada no está cargada —la bitácora no viaja con el agregado—, + /// así que reconciliar por ausencia habría intentado borrar toda la historia. + /// + private void AppendLifecycleEntries(ICollection target, IEnumerable source) + { + var porId = target.ToDictionary(b => b.Id); + + foreach (var origen in source) + { + if (origen.LifecycleEntries.Count == 0) continue; + if (!porId.TryGetValue(origen.Id, out var destino)) continue; + + // Cuando la sucursal es NUEVA, el reconciliador ya insertó en `target` el mismo objeto + // que trae los asientos: no hay nada que trasvasar, solo que marcarlos como altas. + var esLaMismaInstancia = ReferenceEquals(destino, origen); + var yaPresentes = esLaMismaInstancia + ? [] + : destino.LifecycleEntries.Select(e => e.Id).ToHashSet(); + + foreach (var asiento in origen.LifecycleEntries.ToList()) + { + if (!esLaMismaInstancia) + { + if (!yaPresentes.Add(asiento.Id)) continue; + + asiento.BranchId = destino.Id; + destino.LifecycleEntries.Add(asiento); + } + + // Se marca explícitamente en vez de confiar en que la detección de cambios descubra + // los hijos nuevos a través de la navegación: el asiento es la evidencia de + // auditoría, y no puede depender de una inferencia. + dbContext.Entry(asiento).State = EntityState.Added; + } + } } + private static TenantBranchLifecycleEntryRecord ToLifecycleRecord(BranchLifecycleEntry entry) + => new() + { + Id = entry.Id, + TenantId = entry.TenantId, + BranchId = entry.BranchId, + EpisodeId = entry.Episode.Id, + OccurredAtUtc = entry.OccurredAtUtc, + ActorId = entry.ActorId, + NameSnapshot = entry.NameSnapshot, + GeofencingSnapshot = entry.GeofencingSnapshot, + Reason = entry.Reason, + }; + private void UpsertIdentityProviders(ICollection target, IEnumerable source) { EfChildCollectionReconciler.ReconcileById( @@ -428,6 +437,9 @@ private static void UpdateBranch(TenantBranchRecord target, TenantBranchRecord s target.Name = source.Name; target.GeofencingMetadata = source.GeofencingMetadata; target.IsActive = source.IsActive; + target.IsClosed = source.IsClosed; + target.ClosedAtUtc = source.ClosedAtUtc; + target.ClosedBy = source.ClosedBy; target.CreatedBy = source.CreatedBy; target.CreatedAtUtc = source.CreatedAtUtc; target.UpdatedBy = source.UpdatedBy; diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/PostgreSqlTenantSignupRequestRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/PostgreSqlTenantSignupRequestRepository.cs index 25e5e580..7a3b6d6a 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/PostgreSqlTenantSignupRequestRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/PostgreSqlTenantSignupRequestRepository.cs @@ -137,7 +137,7 @@ private static TenantSignupRequestRecord ToRecord(TenantSignupRequestAggregate a }; } - private void Apply(TenantSignupRequestRecord target, TenantSignupRequestAggregate source) + private static void Apply(TenantSignupRequestRecord target, TenantSignupRequestAggregate source) { var replacement = ToRecord(source); target.CompanyName = replacement.CompanyName; diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/PostgreSqlUserAccountRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/PostgreSqlUserAccountRepository.cs index 4b364ad4..6d5eb2ff 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/PostgreSqlUserAccountRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/PostgreSqlUserAccountRepository.cs @@ -155,6 +155,7 @@ public async Task SoftDeleteAsync(Guid id, string deletedBy, CancellationT // so modifying `record` here is additive to the changes Apply() already staged. var record = await dbContext.UserAccounts .IgnoreQueryFilters() + .Include(x => x.PasswordCredentials) .FirstOrDefaultAsync(x => x.Id == id && !x.IsDeleted, cancellationToken); if (record is null) return false; @@ -166,6 +167,14 @@ public async Task SoftDeleteAsync(Guid id, string deletedBy, CancellationT // GDPR: replace PII with a deterministic, irreversible token (SHA-256 of the GUID). record.Email = BuildAnonymizedEmail(id); record.IdentityReference = null; + // GDPR (user-account.md:464): DisplayName is PII and the BCrypt hash must be nulled on + // terminal deletion — the presence of a credential record would otherwise imply a local + // account. Both are cleared here so no reversible secret or PII survives the soft-delete. + record.DisplayName = null; + foreach (var credential in record.PasswordCredentials) + { + credential.PasswordHash = string.Empty; + } record.AnonymizedAtUtc = now; // EF change tracker now has IsDeleted=true + anonymized email pending; SaveChangesAsync // (called from SaveEntitiesAsync by the handler) will commit everything atomically. @@ -238,6 +247,11 @@ public Task CountActiveByTenantAsync(Guid tenantId, CancellationToken cance u => u.TenantId == tenantId && u.StatusId == 2 /* Active */, cancellationToken); + public Task CountActiveByBranchAsync(Guid branchId, CancellationToken cancellationToken = default) + => dbContext.UserAccounts.CountAsync( + u => u.BranchId == branchId && u.StatusId == 2 /* Active */, + cancellationToken); + private static UserAccountAggregate Rehydrate(UserAccountRecord record) => IdentityAggregateFactory.RehydrateUserAccount(record, record.MfaEnrollments, record.PasswordCredentials); @@ -256,6 +270,9 @@ private static UserAccountRecord ToRecord(UserAccountAggregate aggregate) IdentityReference = aggregate.IdentityReference?.GetValue(), IdentityReferenceTypeId = aggregate.IdentityReferenceType?.Id, ExpiresAtUtc = aggregate.ExpiresAt.HasValue ? aggregate.ExpiresAt.Value.UtcDateTime : null, + // ADR-UMS-095: bloqueo temporal por intentos fallidos. + FailedLoginAttempts = aggregate.FailedLoginAttempts, + LockedUntilUtc = aggregate.LockedUntil.HasValue ? aggregate.LockedUntil.Value.UtcDateTime : null, CreatedBy = audit.CreatedBy, CreatedAtUtc = audit.CreatedAt, UpdatedBy = audit.UpdatedBy, @@ -309,6 +326,9 @@ private void Apply(UserAccountRecord target, UserAccountAggregate source) target.IdentityReference = replacement.IdentityReference; target.IdentityReferenceTypeId = replacement.IdentityReferenceTypeId; target.ExpiresAtUtc = replacement.ExpiresAtUtc; + // ADR-UMS-095: bloqueo temporal por intentos fallidos. + target.FailedLoginAttempts = replacement.FailedLoginAttempts; + target.LockedUntilUtc = replacement.LockedUntilUtc; target.CreatedBy = replacement.CreatedBy; target.CreatedAtUtc = replacement.CreatedAtUtc; target.UpdatedBy = replacement.UpdatedBy; diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/RefreshTokenStore.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/RefreshTokenStore.cs new file mode 100644 index 00000000..141b62f0 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/RefreshTokenStore.cs @@ -0,0 +1,161 @@ +namespace Ums.Infrastructure.Persistence.Identity; + +using Microsoft.EntityFrameworkCore; +using Ums.Application.Identity.Auth; +using Ums.Infrastructure.Persistence.Identity.Entities; + +/// +/// Implementación de sobre . +/// Persiste solo el hash del token (ADR-UMS-091). Funciona con el proveedor Npgsql y con +/// el EF InMemory de los tests; bajo la postura fail-closed solo se invoca cuando un +/// inquilino activa la capacidad, así que en tests (deshabilitado por defecto) no se toca. +/// +public sealed class RefreshTokenStore : IRefreshTokenStore +{ + private readonly UmsPlatformDbContext _db; + + public RefreshTokenStore(UmsPlatformDbContext db) + { + _db = db; + } + + public async Task IssueAsync( + Guid tenantId, + Guid userId, + Guid familyId, + string tokenHash, + DateTime issuedAtUtc, + DateTime expiresAtUtc, + CancellationToken cancellationToken = default) + { + var record = new RefreshTokenRecord + { + Id = Guid.NewGuid(), + TenantId = tenantId, + UserId = userId, + FamilyId = familyId, + TokenHash = tokenHash, + Status = RefreshTokenStatuses.Active, + IssuedAtUtc = issuedAtUtc, + ExpiresAtUtc = expiresAtUtc, + RenewalCount = 0, + }; + + _db.RefreshTokens.Add(record); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + public async Task FindByHashAsync( + string tokenHash, + CancellationToken cancellationToken = default) + { + var record = await _db.RefreshTokens + .AsNoTracking() + .FirstOrDefaultAsync(r => r.TokenHash == tokenHash, cancellationToken) + .ConfigureAwait(false); + + if (record is null) + { + return null; + } + + return new RefreshTokenSnapshot( + record.Id, + record.TenantId, + record.UserId, + record.FamilyId, + record.Status, + record.IssuedAtUtc, + record.ExpiresAtUtc, + record.RenewalCount); + } + + public async Task RotateAsync( + RefreshTokenSnapshot current, + Guid newTokenId, + string newTokenHash, + DateTime issuedAtUtc, + DateTime expiresAtUtc, + CancellationToken cancellationToken = default) + { + // Rotación: el token vigente queda Rotated apuntando al nuevo, y el nuevo entra + // Active en la misma familia. Un único SaveChanges ⇒ atómico. + var old = await _db.RefreshTokens + .FirstOrDefaultAsync(r => r.Id == current.Id, cancellationToken) + .ConfigureAwait(false); + + if (old is not null) + { + old.Status = RefreshTokenStatuses.Rotated; + old.ReplacedByTokenId = newTokenId; + } + + _db.RefreshTokens.Add(new RefreshTokenRecord + { + Id = newTokenId, + TenantId = current.TenantId, + UserId = current.UserId, + FamilyId = current.FamilyId, + TokenHash = newTokenHash, + Status = RefreshTokenStatuses.Active, + IssuedAtUtc = issuedAtUtc, + ExpiresAtUtc = expiresAtUtc, + RenewalCount = current.RenewalCount + 1, + }); + + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + public async Task RevokeFamilyAsync( + Guid familyId, + string reason, + DateTime revokedAtUtc, + CancellationToken cancellationToken = default) + { + // Invalida la familia entera (reuso o revocación explícita). Idempotente: + // solo toca los que aún no están revocados. + var members = await _db.RefreshTokens + .Where(r => r.FamilyId == familyId && r.Status != RefreshTokenStatuses.Revoked) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + foreach (var member in members) + { + member.Status = RefreshTokenStatuses.Revoked; + member.RevokedAtUtc = revokedAtUtc; + member.RevokedReason = reason; + } + + if (members.Count > 0) + { + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + } + + public async Task RevokeAllForUserAsync( + Guid tenantId, + Guid userId, + string reason, + DateTime revokedAtUtc, + CancellationToken cancellationToken = default) + { + // Revocación explícita del logout: cierra todas las familias vivas del usuario + // en el inquilino. Idempotente: solo toca los que aún no están revocados. + var members = await _db.RefreshTokens + .Where(r => r.TenantId == tenantId && r.UserId == userId && r.Status != RefreshTokenStatuses.Revoked) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + foreach (var member in members) + { + member.Status = RefreshTokenStatuses.Revoked; + member.RevokedAtUtc = revokedAtUtc; + member.RevokedReason = reason; + } + + if (members.Count > 0) + { + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/TenantParameter/InMemoryTenantParameterRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/TenantParameter/InMemoryTenantParameterRepository.cs index 371a8d0e..c11de915 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/TenantParameter/InMemoryTenantParameterRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/TenantParameter/InMemoryTenantParameterRepository.cs @@ -45,22 +45,34 @@ public Task UpdateAsync(TenantParameterAggregate aggregate, CancellationToken ca return Task.CompletedTask; } + /// + /// Borrado LÓGICO: la entrada NO se saca de la lista; antes hacía RemoveAll y perdía la + /// fila. El agregado ya trae la marca puesta por TenantParameter.Delete (aquí la lista + /// guarda la MISMA instancia) y las lecturas de abajo lo ocultan. Igual que el store PostgreSQL, + /// se corta si nadie pasó por el dominio. + /// public Task DeleteAsync(TenantParameterAggregate aggregate, CancellationToken cancellationToken = default) { - _parameters.RemoveAll(p => p.GetId().GetValue() == aggregate.GetId().GetValue()); - _committedIds.Remove(aggregate.GetId().GetValue()); + if (!aggregate.IsDeleted) + { + throw new InvalidOperationException( + $"Tenant parameter {aggregate.GetId().GetValue()} must be logically deleted via TenantParameter.Delete before persisting."); + } + + var index = _parameters.FindIndex(p => p.GetId().GetValue() == aggregate.GetId().GetValue()); + if (index >= 0) _parameters[index] = aggregate; return Task.CompletedTask; } public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) { - return await Task.FromResult(_parameters.FirstOrDefault(p => p.GetId().GetValue() == id)); + return await Task.FromResult(_parameters.FirstOrDefault(p => !p.IsDeleted && p.GetId().GetValue() == id)); } public async Task GetByIdAsync(Guid tenantId, Guid id, CancellationToken cancellationToken = default) { return await Task.FromResult(_parameters.FirstOrDefault(p => - p.TenantId.GetValue() == tenantId && p.GetId().GetValue() == id)); + !p.IsDeleted && p.TenantId.GetValue() == tenantId && p.GetId().GetValue() == id)); } Task IAggregateRepository.GetByIdAsync(Guid tenantId, Guid id, CancellationToken cancellationToken) @@ -71,29 +83,29 @@ public Task DeleteAsync(TenantParameterAggregate aggregate, CancellationToken ca public async Task GetByCodeAsync(Guid tenantId, string code, CancellationToken cancellationToken = default) { return await Task.FromResult(_parameters.FirstOrDefault(p => - p.TenantId.GetValue() == tenantId && p.Code.GetValue() == code)); + !p.IsDeleted && p.TenantId.GetValue() == tenantId && p.Code.GetValue() == code)); } public async Task> GetByTenantIdAsync(Guid tenantId, CancellationToken cancellationToken = default) { - return await Task.FromResult(_parameters.Where(p => p.TenantId.GetValue() == tenantId).ToList()); + return await Task.FromResult(_parameters.Where(p => !p.IsDeleted && p.TenantId.GetValue() == tenantId).ToList()); } public async Task> GetActiveByTenantIdAsync(Guid tenantId, CancellationToken cancellationToken = default) { - return await Task.FromResult(_parameters.Where(p => p.TenantId.GetValue() == tenantId && p.IsActive).ToList()); + return await Task.FromResult(_parameters.Where(p => !p.IsDeleted && p.TenantId.GetValue() == tenantId && p.IsActive).ToList()); } public async Task> GetByCategoryAsync(Guid tenantId, string category, CancellationToken cancellationToken = default) { return await Task.FromResult(_parameters.Where(p => - p.TenantId.GetValue() == tenantId && p.IsActive && p.Category.Name.Equals(category, StringComparison.OrdinalIgnoreCase)).ToList()); + !p.IsDeleted && p.TenantId.GetValue() == tenantId && p.IsActive && p.Category.Name.Equals(category, StringComparison.OrdinalIgnoreCase)).ToList()); } public async Task ExistsActiveCodeAsync(Guid tenantId, string code, CancellationToken cancellationToken = default) { return await Task.FromResult(_parameters.Any(p => - p.TenantId.GetValue() == tenantId && p.Code.GetValue() == code && p.IsActive)); + !p.IsDeleted && p.TenantId.GetValue() == tenantId && p.Code.GetValue() == code && p.IsActive)); } public void Seed(TenantParameterAggregate aggregate) diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/TenantParameter/PostgreSqlTenantParameterRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/TenantParameter/PostgreSqlTenantParameterRepository.cs index b491d104..944f7150 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/TenantParameter/PostgreSqlTenantParameterRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Identity/TenantParameter/PostgreSqlTenantParameterRepository.cs @@ -66,11 +66,6 @@ public void Dispose() return record is null ? null : Rehydrate(record); } - Task IAggregateRepository.GetByIdAsync(Guid tenantId, Guid id, CancellationToken cancellationToken) - { - return GetByIdAsync(tenantId, id, cancellationToken); - } - public async Task GetByIdAsync(Guid tenantId, Guid id, CancellationToken cancellationToken = default) { var record = await dbContext.TenantParameters @@ -79,6 +74,11 @@ public void Dispose() return record is null ? null : Rehydrate(record); } + Task IAggregateRepository.GetByIdAsync(Guid tenantId, Guid id, CancellationToken cancellationToken) + { + return GetByIdAsync(tenantId, id, cancellationToken); + } + public async Task GetByCodeAsync(Guid tenantId, string code, CancellationToken cancellationToken = default) { var record = await dbContext.TenantParameters @@ -145,10 +145,28 @@ public Task UpdateAsync(TenantParameterAggregate aggregate, CancellationToken ca return Task.CompletedTask; } + /// + /// Borrado LÓGICO. Persiste la marca IsDeleted del agregado; NUNCA quita la fila. + /// + /// Antes hacía dbContext.TenantParameters.Remove(...): la configuración histórica del + /// inquilino —qué valor regía y quién lo puso— se perdía sin remedio, y el negocio consulta hacia + /// atrás. La política del propietario es explícita: solo existe borrado lógico. + /// + /// La DECISIÓN de eliminar es del dominio (TenantParameter.Delete, que aplica la guardia de + /// cascada sobre el vínculo activo); el repositorio solo la persiste. Si el agregado no viene ya + /// en estado eliminado, es un error de programación —alguien se saltó el dominio— y se corta aquí + /// en vez de escribir en la base un borrado que nadie validó. + /// public Task DeleteAsync(TenantParameterAggregate aggregate, CancellationToken cancellationToken = default) { - dbContext.TenantParameters.Remove(ToRecord(aggregate)); - _trackedAggregates.Remove(aggregate); + if (!aggregate.IsDeleted) + { + throw new InvalidOperationException( + $"Tenant parameter {aggregate.GetId().GetValue()} must be logically deleted via TenantParameter.Delete before persisting."); + } + + dbContext.TenantParameters.Update(ToRecord(aggregate)); + _trackedAggregates.Add(aggregate); return Task.CompletedTask; } @@ -167,6 +185,7 @@ private static TenantParameterRecord ToRecord(TenantParameterAggregate aggregate ValueTypeId = aggregate.ValueType.Id, CategoryId = aggregate.Category.Id, IsActive = aggregate.IsActive, + IsDeleted = aggregate.IsDeleted, IsSensitive = aggregate.IsSensitive, DefaultValue = aggregate.DefaultValue, AllowedValues = aggregate.AllowedValues, diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Iga/Configurations/RoleMaturityStatusRecordConfiguration.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Iga/Configurations/RoleMaturityStatusRecordConfiguration.cs new file mode 100644 index 00000000..46496fea --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Iga/Configurations/RoleMaturityStatusRecordConfiguration.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Ums.Infrastructure.Persistence.Iga.Entities; + +namespace Ums.Infrastructure.Persistence.Iga.Configurations; + +public sealed class RoleMaturityStatusRecordConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("RoleMaturityStatuses", IgaPersistenceConstants.Schema); + builder.HasKey(x => x.Id); + + builder.Property(x => x.PerformanceScore).HasColumnType("numeric(4,2)"); + builder.Property(x => x.BlockingFactor).HasMaxLength(500); + builder.Property(x => x.CreatedBy).HasMaxLength(100).IsRequired(); + builder.Property(x => x.UpdatedBy).HasMaxLength(100); + builder.Property(x => x.AuditTimeSpan).HasMaxLength(100).IsRequired(); + + builder.HasIndex(x => x.TenantId); + builder.HasIndex(x => new { x.TenantId, x.UserId }); + // Un estado de madurez por usuario y rol dentro del inquilino (fuente de elegibilidad). + builder.HasIndex(x => new { x.TenantId, x.UserId, x.RoleId }).IsUnique(); + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Iga/Configurations/RolePromotionRequestRecordConfiguration.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Iga/Configurations/RolePromotionRequestRecordConfiguration.cs new file mode 100644 index 00000000..37f9aa08 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Iga/Configurations/RolePromotionRequestRecordConfiguration.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Ums.Infrastructure.Persistence.Iga.Entities; + +namespace Ums.Infrastructure.Persistence.Iga.Configurations; + +public sealed class RolePromotionRequestRecordConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("RolePromotionRequests", IgaPersistenceConstants.Schema); + builder.HasKey(x => x.Id); + + builder.Property(x => x.DecisionReason).HasMaxLength(1000); + builder.Property(x => x.CreatedBy).HasMaxLength(100).IsRequired(); + builder.Property(x => x.UpdatedBy).HasMaxLength(100); + builder.Property(x => x.AuditTimeSpan).HasMaxLength(100).IsRequired(); + + builder.HasIndex(x => x.TargetUserId); + builder.HasIndex(x => x.TenantId); + builder.HasIndex(x => new { x.TenantId, x.StatusId }); + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Iga/Entities/RoleMaturityStatusRecord.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Iga/Entities/RoleMaturityStatusRecord.cs new file mode 100644 index 00000000..feab11e2 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Iga/Entities/RoleMaturityStatusRecord.cs @@ -0,0 +1,45 @@ +using System; + +namespace Ums.Infrastructure.Persistence.Iga.Entities; + +/// +/// Registro de persistencia del agregado de elegibilidad RoleMaturityStatus (IGA, ADR-UMS-093, FR-062). +/// +/// Tabla acotada por inquilino (): la fuente de verdad de elegibilidad +/// nunca cruza fronteras de inquilino. Los niveles de madurez ( / +/// ) se guardan como el valor entero del enum +/// RoleMaturityLevel; los eventos de dominio no se persisten como filas — fluyen al Outbox +/// (ADR-0052) al guardar. +/// +public sealed class RoleMaturityStatusRecord : IAuditableRecord +{ + public Guid Id { get; set; } + public Guid TenantId { get; set; } + public Guid UserId { get; set; } + public Guid RoleId { get; set; } + + /// Valor entero del enum RoleMaturityLevel (Junior=1 … Principal=5). + public int CurrentMaturityLevel { get; set; } + + /// Siguiente nivel elegible (nulo mientras no se confirme la elegibilidad). + public int? NextEligibleMaturityLevel { get; set; } + + public DateTime AssignedAt { get; set; } + public DateTime CurrentLevelSince { get; set; } + public DateTime? EligibleForPromotionAt { get; set; } + public int CompletedCertificationsCount { get; set; } + public int CompletedTrainingsCount { get; set; } + public decimal PerformanceScore { get; set; } + public bool HasNoComplianceIssues { get; set; } + + /// Factor de bloqueo de cumplimiento activo (nulo si no hay incidencia). + public string? BlockingFactor { get; set; } + + public DateTime? LastReviewedAt { get; set; } + + public string CreatedBy { get; set; } = string.Empty; + public DateTime CreatedAtUtc { get; set; } + public string? UpdatedBy { get; set; } + public DateTime? UpdatedAtUtc { get; set; } + public string AuditTimeSpan { get; set; } = string.Empty; +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Iga/Entities/RolePromotionRequestRecord.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Iga/Entities/RolePromotionRequestRecord.cs new file mode 100644 index 00000000..905c3904 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Iga/Entities/RolePromotionRequestRecord.cs @@ -0,0 +1,41 @@ +using System; + +namespace Ums.Infrastructure.Persistence.Iga.Entities; + +/// +/// Registro de persistencia del agregado de promoción RolePromotionRequest (IGA, ADR-UMS-093, FR-060/061). +/// +/// Tabla acotada por inquilino (). El estado de la máquina se guarda como +/// (valor de la enumeración de dominio RolePromotionStatus) y el +/// congelado se guarda como entero (nulo en Draft). Los identificadores +/// de aprobador/revisor/ejecutor/verificador se registran a medida que avanza la máquina para poder +/// auditar la segregación de funciones. Los eventos de transición fluyen al Outbox (ADR-0052) al +/// guardar; no se persisten como filas. +/// +public sealed class RolePromotionRequestRecord : IAuditableRecord +{ + public Guid Id { get; set; } + public Guid TenantId { get; set; } + public Guid TargetUserId { get; set; } + public Guid RequesterId { get; set; } + public Guid CurrentRoleId { get; set; } + public Guid TargetRoleId { get; set; } + + /// Identificador de la enumeración de dominio RolePromotionStatus (Draft=1 … Cancelled=9). + public int StatusId { get; set; } + + /// RiskScore [0,100] congelado al salir de Draft (nulo mientras es Draft). + public int? RiskScore { get; set; } + + public Guid? ApproverId { get; set; } + public Guid? SecurityReviewerId { get; set; } + public Guid? ExecutorId { get; set; } + public Guid? VerifierId { get; set; } + public string? DecisionReason { get; set; } + + public string CreatedBy { get; set; } = string.Empty; + public DateTime CreatedAtUtc { get; set; } + public string? UpdatedBy { get; set; } + public DateTime? UpdatedAtUtc { get; set; } + public string AuditTimeSpan { get; set; } = string.Empty; +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Iga/IgaPersistenceConstants.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Iga/IgaPersistenceConstants.cs new file mode 100644 index 00000000..b8593f45 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Iga/IgaPersistenceConstants.cs @@ -0,0 +1,10 @@ +namespace Ums.Infrastructure.Persistence.Iga; + +/// +/// Constantes de persistencia del contexto acotado IGA (ADR-UMS-093). +/// Las tablas de gobierno de identidad viven en su propio esquema, acotado por inquilino. +/// +internal static class IgaPersistenceConstants +{ + public const string Schema = "iga"; +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Iga/PostgreSqlRoleMaturityStatusRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Iga/PostgreSqlRoleMaturityStatusRepository.cs new file mode 100644 index 00000000..00fe2a23 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Iga/PostgreSqlRoleMaturityStatusRepository.cs @@ -0,0 +1,175 @@ +using Microsoft.EntityFrameworkCore; +using Ums.Domain.IGA; +using Ums.Domain.Kernel; +using Ums.Infrastructure.Persistence.Iga.Entities; +using Ums.Infrastructure.Persistence.Reflection; + +namespace Ums.Infrastructure.Persistence.Iga; + +using RoleMaturityStatusAggregate = Ums.Domain.IGA.RoleMaturityStatus.RoleMaturityStatus; + +/// +/// Implementación PostgreSQL de sobre +/// (IGA, ADR-UMS-093). Mapea el agregado a/desde +/// y publica los eventos de dominio hacia el Outbox +/// (ADR-0052) en . El aislamiento por inquilino lo garantiza el +/// filtro global del contexto; las consultas acotadas añaden además el filtro explícito. +/// +public sealed class PostgreSqlRoleMaturityStatusRepository : IRoleMaturityStatusRepository, IUnitOfWork +{ + private readonly UmsPlatformDbContext _dbContext; + private readonly HashSet _trackedAggregates = []; + + public PostgreSqlRoleMaturityStatusRepository(UmsPlatformDbContext dbContext) + { + _dbContext = dbContext; + } + + public IUnitOfWork UnitOfWork => this; + + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + var record = await _dbContext.Set() + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + + return record is null ? null : IgaAggregateFactory.RehydrateRoleMaturityStatus(record); + } + + public Task GetByIdAsync(Guid tenantId, Guid id, CancellationToken cancellationToken = default) + => GetByIdAsync(id, cancellationToken); + + public async Task> GetByUserAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken = default) + { + var records = await _dbContext.Set() + .Where(x => x.TenantId == tenantId && x.UserId == userId) + .ToListAsync(cancellationToken); + + return records.Select(IgaAggregateFactory.RehydrateRoleMaturityStatus).ToList(); + } + + public async Task GetByUserAndRoleAsync(Guid tenantId, Guid userId, Guid roleId, CancellationToken cancellationToken = default) + { + var record = await _dbContext.Set() + .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.UserId == userId && x.RoleId == roleId, cancellationToken); + + return record is null ? null : IgaAggregateFactory.RehydrateRoleMaturityStatus(record); + } + + public async Task> GetAllAsync(Guid? tenantId = null, CancellationToken cancellationToken = default) + { + var query = _dbContext.Set().AsQueryable(); + + if (tenantId.HasValue) + { + query = query.Where(x => x.TenantId == tenantId.Value); + } + + var records = await query.ToListAsync(cancellationToken); + + return records.Select(IgaAggregateFactory.RehydrateRoleMaturityStatus).ToList(); + } + + public Task AddAsync(RoleMaturityStatusAggregate aggregate, CancellationToken cancellationToken = default) + { + _dbContext.Set().Add(ToRecord(aggregate)); + _trackedAggregates.Add(aggregate); + return Task.CompletedTask; + } + + public async Task UpdateAsync(RoleMaturityStatusAggregate aggregate, CancellationToken cancellationToken = default) + { + var existing = await _dbContext.Set() + .FirstOrDefaultAsync(x => x.Id == aggregate.Props.Id.GetValue(), cancellationToken) + ?? throw new InvalidOperationException($"Role maturity status {aggregate.Props.Id.GetValue()} does not exist."); + + Apply(existing, aggregate); + _trackedAggregates.Add(aggregate); + } + + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + => _dbContext.SaveChangesAsync(cancellationToken); + + public async Task SaveEntitiesAsync(CancellationToken cancellationToken = default) + { + foreach (var aggregate in _trackedAggregates) + { + await _dbContext.PublishDomainEventsAsync(aggregate.DomainEvents.GetUncommittedChanges(), cancellationToken); + } + + try + { + await _dbContext.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateConcurrencyException ex) + { + var entry = ex.Entries.FirstOrDefault(); + var id = (Guid)(entry?.Property("Id").CurrentValue ?? Guid.Empty); + throw new ConcurrencyConflictException(entry?.Metadata.Name ?? "Unknown", id); + } + + foreach (var aggregate in _trackedAggregates) + { + aggregate.DomainEvents.MarkChangesAsCommitted(); + } + + _trackedAggregates.Clear(); + return true; + } + + public void Dispose() + { + } + + private static RoleMaturityStatusRecord ToRecord(RoleMaturityStatusAggregate aggregate) + { + var audit = aggregate.Props.Audit.GetValue(); + return new RoleMaturityStatusRecord + { + Id = aggregate.Props.Id.GetValue(), + TenantId = aggregate.TenantId.GetValue(), + UserId = aggregate.UserId.GetValue(), + RoleId = aggregate.RoleId.GetValue(), + CurrentMaturityLevel = (int)aggregate.CurrentMaturityLevel, + NextEligibleMaturityLevel = aggregate.NextEligibleMaturityLevel.HasValue ? (int)aggregate.NextEligibleMaturityLevel.Value : null, + AssignedAt = aggregate.AssignedAt, + CurrentLevelSince = aggregate.CurrentLevelSince, + EligibleForPromotionAt = aggregate.EligibleForPromotionAt, + CompletedCertificationsCount = aggregate.CompletedCertificationsCount, + CompletedTrainingsCount = aggregate.CompletedTrainingsCount, + PerformanceScore = aggregate.PerformanceScore, + HasNoComplianceIssues = aggregate.HasNoComplianceIssues, + BlockingFactor = aggregate.BlockingFactor?.GetValue(), + LastReviewedAt = aggregate.LastReviewedAt, + CreatedBy = audit.CreatedBy, + CreatedAtUtc = audit.CreatedAt, + UpdatedBy = audit.UpdatedBy, + UpdatedAtUtc = audit.UpdatedAt, + AuditTimeSpan = audit.TimeSpan, + }; + } + + private static void Apply(RoleMaturityStatusRecord target, RoleMaturityStatusAggregate source) + { + var replacement = ToRecord(source); + + target.TenantId = replacement.TenantId; + target.UserId = replacement.UserId; + target.RoleId = replacement.RoleId; + target.CurrentMaturityLevel = replacement.CurrentMaturityLevel; + target.NextEligibleMaturityLevel = replacement.NextEligibleMaturityLevel; + target.AssignedAt = replacement.AssignedAt; + target.CurrentLevelSince = replacement.CurrentLevelSince; + target.EligibleForPromotionAt = replacement.EligibleForPromotionAt; + target.CompletedCertificationsCount = replacement.CompletedCertificationsCount; + target.CompletedTrainingsCount = replacement.CompletedTrainingsCount; + target.PerformanceScore = replacement.PerformanceScore; + target.HasNoComplianceIssues = replacement.HasNoComplianceIssues; + target.BlockingFactor = replacement.BlockingFactor; + target.LastReviewedAt = replacement.LastReviewedAt; + target.CreatedBy = replacement.CreatedBy; + target.CreatedAtUtc = replacement.CreatedAtUtc; + target.UpdatedBy = replacement.UpdatedBy; + target.UpdatedAtUtc = replacement.UpdatedAtUtc; + target.AuditTimeSpan = replacement.AuditTimeSpan; + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Iga/PostgreSqlRolePromotionRequestRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Iga/PostgreSqlRolePromotionRequestRepository.cs new file mode 100644 index 00000000..50704e9d --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Iga/PostgreSqlRolePromotionRequestRepository.cs @@ -0,0 +1,180 @@ +using Microsoft.EntityFrameworkCore; +using BeyondNetCode.Shell.Ddd; +using Ums.Domain.IGA; +using Ums.Domain.IGA.RolePromotionRequest; +using Ums.Domain.Kernel; +using Ums.Infrastructure.Persistence.Iga.Entities; +using Ums.Infrastructure.Persistence.Reflection; + +namespace Ums.Infrastructure.Persistence.Iga; + +using RolePromotionRequestAggregate = Ums.Domain.IGA.RolePromotionRequest.RolePromotionRequest; + +/// +/// Implementación PostgreSQL de sobre +/// (IGA, ADR-UMS-093). Mapea el agregado a/desde +/// y publica los eventos de transición de la máquina de +/// estados hacia el Outbox (ADR-0052) en — así la traza de auditoría +/// append-only recibe cada transición sin infraestructura adicional. +/// +public sealed class PostgreSqlRolePromotionRequestRepository : IRolePromotionRequestRepository, IUnitOfWork +{ + private readonly UmsPlatformDbContext _dbContext; + private readonly HashSet _trackedAggregates = []; + + public PostgreSqlRolePromotionRequestRepository(UmsPlatformDbContext dbContext) + { + _dbContext = dbContext; + } + + public IUnitOfWork UnitOfWork => this; + + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + var record = await _dbContext.Set() + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + + return record is null ? null : IgaAggregateFactory.RehydrateRolePromotionRequest(record); + } + + public Task GetByIdAsync(Guid tenantId, Guid id, CancellationToken cancellationToken = default) + => GetByIdAsync(id, cancellationToken); + + public async Task> GetAllAsync(Guid? tenantId = null, CancellationToken cancellationToken = default) + { + var query = _dbContext.Set().AsQueryable(); + + if (tenantId.HasValue) + { + query = query.Where(x => x.TenantId == tenantId.Value); + } + + var records = await query.ToListAsync(cancellationToken); + + return records.Select(IgaAggregateFactory.RehydrateRolePromotionRequest).ToList(); + } + + public async Task> GetByTenantIdAsync(Guid tenantId, CancellationToken cancellationToken = default) + { + var records = await _dbContext.Set() + .Where(x => x.TenantId == tenantId) + .ToListAsync(cancellationToken); + + return records.Select(IgaAggregateFactory.RehydrateRolePromotionRequest).ToList(); + } + + public async Task> GetByTenantAndStatusAsync(Guid tenantId, string status, CancellationToken cancellationToken = default) + { + var statusEnum = DomainEnumeration.FromDisplayName(status); + if (statusEnum is null) + { + return Array.Empty(); + } + + var records = await _dbContext.Set() + .Where(x => x.TenantId == tenantId && x.StatusId == statusEnum.Id) + .ToListAsync(cancellationToken); + + return records.Select(IgaAggregateFactory.RehydrateRolePromotionRequest).ToList(); + } + + public Task AddAsync(RolePromotionRequestAggregate aggregate, CancellationToken cancellationToken = default) + { + _dbContext.Set().Add(ToRecord(aggregate)); + _trackedAggregates.Add(aggregate); + return Task.CompletedTask; + } + + public async Task UpdateAsync(RolePromotionRequestAggregate aggregate, CancellationToken cancellationToken = default) + { + var existing = await _dbContext.Set() + .FirstOrDefaultAsync(x => x.Id == aggregate.Props.Id.GetValue(), cancellationToken) + ?? throw new InvalidOperationException($"Role promotion request {aggregate.Props.Id.GetValue()} does not exist."); + + Apply(existing, aggregate); + _trackedAggregates.Add(aggregate); + } + + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + => _dbContext.SaveChangesAsync(cancellationToken); + + public async Task SaveEntitiesAsync(CancellationToken cancellationToken = default) + { + foreach (var aggregate in _trackedAggregates) + { + await _dbContext.PublishDomainEventsAsync(aggregate.DomainEvents.GetUncommittedChanges(), cancellationToken); + } + + try + { + await _dbContext.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateConcurrencyException ex) + { + var entry = ex.Entries.FirstOrDefault(); + var id = (Guid)(entry?.Property("Id").CurrentValue ?? Guid.Empty); + throw new ConcurrencyConflictException(entry?.Metadata.Name ?? "Unknown", id); + } + + foreach (var aggregate in _trackedAggregates) + { + aggregate.DomainEvents.MarkChangesAsCommitted(); + } + + _trackedAggregates.Clear(); + return true; + } + + public void Dispose() + { + } + + private static RolePromotionRequestRecord ToRecord(RolePromotionRequestAggregate aggregate) + { + var audit = aggregate.Props.Audit.GetValue(); + return new RolePromotionRequestRecord + { + Id = aggregate.Props.Id.GetValue(), + TenantId = aggregate.TenantId.GetValue(), + TargetUserId = aggregate.TargetUserId.GetValue(), + RequesterId = aggregate.RequesterId.GetValue(), + CurrentRoleId = aggregate.CurrentRoleId.GetValue(), + TargetRoleId = aggregate.TargetRoleId.GetValue(), + StatusId = aggregate.Status.Id, + RiskScore = aggregate.RiskScore?.GetValue(), + ApproverId = aggregate.ApproverId?.GetValue(), + SecurityReviewerId = aggregate.SecurityReviewerId?.GetValue(), + ExecutorId = aggregate.ExecutorId?.GetValue(), + VerifierId = aggregate.VerifierId?.GetValue(), + DecisionReason = aggregate.DecisionReason, + CreatedBy = audit.CreatedBy, + CreatedAtUtc = audit.CreatedAt, + UpdatedBy = audit.UpdatedBy, + UpdatedAtUtc = audit.UpdatedAt, + AuditTimeSpan = audit.TimeSpan, + }; + } + + private static void Apply(RolePromotionRequestRecord target, RolePromotionRequestAggregate source) + { + var replacement = ToRecord(source); + + target.TenantId = replacement.TenantId; + target.TargetUserId = replacement.TargetUserId; + target.RequesterId = replacement.RequesterId; + target.CurrentRoleId = replacement.CurrentRoleId; + target.TargetRoleId = replacement.TargetRoleId; + target.StatusId = replacement.StatusId; + target.RiskScore = replacement.RiskScore; + target.ApproverId = replacement.ApproverId; + target.SecurityReviewerId = replacement.SecurityReviewerId; + target.ExecutorId = replacement.ExecutorId; + target.VerifierId = replacement.VerifierId; + target.DecisionReason = replacement.DecisionReason; + target.CreatedBy = replacement.CreatedBy; + target.CreatedAtUtc = replacement.CreatedAtUtc; + target.UpdatedBy = replacement.UpdatedBy; + target.UpdatedAtUtc = replacement.UpdatedAtUtc; + target.AuditTimeSpan = replacement.AuditTimeSpan; + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryAccessEnforcementPolicyRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryAccessEnforcementPolicyRepository.cs index 10b6fec8..bb0d9c75 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryAccessEnforcementPolicyRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryAccessEnforcementPolicyRepository.cs @@ -1,3 +1,4 @@ +#pragma warning disable S4144 // Scaffolding intencional para testing In-Memory namespace Ums.Infrastructure.Persistence; using System.Collections.Concurrent; @@ -22,10 +23,10 @@ public Task> GetAllAsync(Guid? t public Task> GetByTenantIdAsync(Guid tenantId, CancellationToken cancellationToken = default) { var f = _store.Values.Where(e => e.Props.TenantId.GetValue() == tenantId).ToList(); f.ForEach(e => e.BrokenRules.Clear()); return Task.FromResult>(f); } - public Task AddAsync(AccessEnforcementPolicyAggregate a, CancellationToken c = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } - public Task UpdateAsync(AccessEnforcementPolicyAggregate a, CancellationToken c = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } - public Task SaveChangesAsync(CancellationToken c = default) => Task.FromResult(1); - public Task SaveEntitiesAsync(CancellationToken c = default) => Task.FromResult(true); + public Task AddAsync(AccessEnforcementPolicyAggregate a, CancellationToken cancellationToken = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } + public Task UpdateAsync(AccessEnforcementPolicyAggregate a, CancellationToken cancellationToken = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } + public Task SaveChangesAsync(CancellationToken cancellationToken = default) => Task.FromResult(1); + public Task SaveEntitiesAsync(CancellationToken cancellationToken = default) => Task.FromResult(true); public void Seed(AccessEnforcementPolicyAggregate a) { a.DomainEvents.MarkChangesAsCommitted(); @@ -33,3 +34,5 @@ public void Seed(AccessEnforcementPolicyAggregate a) } public void Dispose() { } } + +#pragma warning restore S4144 diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryAppConfigurationRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryAppConfigurationRepository.cs index 384372df..09a39a07 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryAppConfigurationRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryAppConfigurationRepository.cs @@ -1,3 +1,4 @@ +#pragma warning disable S4144 // Scaffolding intencional para testing In-Memory namespace Ums.Infrastructure.Persistence; using System.Collections.Concurrent; @@ -10,32 +11,43 @@ public sealed class InMemoryAppConfigurationRepository : IAppConfigurationReposi private readonly ConcurrentDictionary _store = new(); public IUnitOfWork UnitOfWork => this; + // Las lecturas ocultan lo eliminado lógicamente; el agregado NUNCA sale del diccionario. public Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) { _store.TryGetValue(id, out var entity); entity?.BrokenRules.Clear(); - return Task.FromResult(entity); + return Task.FromResult(entity is not null && entity.Status != ConfigStatus.Deleted ? entity : null); } public Task GetByIdAsync(Guid tenantId, Guid id, CancellationToken cancellationToken = default) => GetByIdAsync(id, cancellationToken); + // Lookup de la ranura (ámbito, código): devuelve quién la ocupa AHORA, nunca una lápida. Espejo + // exacto del índice único parcial de PostgreSQL; si esta copia viera lo eliminado, las pruebas + // en memoria certificarían un comportamiento que la base ya no tiene. El orden desempata igual: + // gana la no archivada y, entre iguales, la más reciente. public Task GetByScopeAndCodeAsync(Guid? tenantId, Guid? systemSuiteId, Guid? moduleId, string code, CancellationToken cancellationToken = default) { - var entity = _store.Values.FirstOrDefault(item => - string.Equals(item.Props.Code.GetValue(), code, StringComparison.OrdinalIgnoreCase) - && item.Props.TenantId?.GetValue() == tenantId - && item.Props.SystemSuiteId?.GetValue() == systemSuiteId - && item.Props.ModuleId?.GetValue() == moduleId); + var entity = _store.Values + .Where(item => + string.Equals(item.Props.Code.GetValue(), code, StringComparison.OrdinalIgnoreCase) + && item.Props.TenantId?.GetValue() == tenantId + && item.Props.SystemSuiteId?.GetValue() == systemSuiteId + && item.Props.ModuleId?.GetValue() == moduleId + && item.Status != ConfigStatus.Deleted) + .OrderBy(item => item.Status == ConfigStatus.Archived ? 1 : 0) + .ThenByDescending(item => item.Props.Audit.GetValue().CreatedAt) + .FirstOrDefault(); entity?.BrokenRules.Clear(); return Task.FromResult(entity); } public Task> GetAllAsync(Guid? tenantId = null, CancellationToken cancellationToken = default) { + var live = _store.Values.Where(item => item.Status != ConfigStatus.Deleted); var items = tenantId.HasValue - ? _store.Values.Where(item => item.Props.TenantId?.GetValue() == tenantId.Value).ToList() - : _store.Values.ToList(); + ? live.Where(item => item.Props.TenantId?.GetValue() == tenantId.Value).ToList() + : live.ToList(); items.ForEach(item => item.BrokenRules.Clear()); return Task.FromResult>(items); } @@ -56,10 +68,22 @@ public Task UpdateAsync(AppConfigurationAggregate aggregate, CancellationToken c public Task UpdateAsync(AppConfigurationAggregate aggregate, byte[]? expectedRowVersion, CancellationToken cancellationToken = default) => UpdateAsync(aggregate, cancellationToken); + // Sin DeleteAsync: el borrado lógico se persiste por UpdateAsync (el agregado ya trae Deleted). + public Task SaveChangesAsync(CancellationToken cancellationToken = default) => Task.FromResult(1); public Task SaveEntitiesAsync(CancellationToken cancellationToken = default) => Task.FromResult(true); + /// + /// Lectura SIN filtros de ocultación: espejo en memoria de `IgnoreQueryFilters()`. + /// + /// Existe porque ADR-0164 §5 exige demostrar que la fila sobrevive al borrado, y preguntar a la + /// API no distingue «oculto» de «borrado». Desde que la ranura se libera, `GetByScopeAndCodeAsync` + /// devuelve solo la viva, así que ya no sirve como sonda del almacén. Ninguna ruta de producción + /// la usa: es la contraparte del `IgnoreQueryFilters()` explícito del repositorio PostgreSQL. + /// + public IReadOnlyList GetAllIncludingDeleted() => _store.Values.ToList(); + public void Seed(AppConfigurationAggregate aggregate) { aggregate.DomainEvents.MarkChangesAsCommitted(); @@ -68,3 +92,5 @@ public void Seed(AppConfigurationAggregate aggregate) public void Dispose() { } } + +#pragma warning restore S4144 diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryApprovalRequestRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryApprovalRequestRepository.cs index a66ac40e..023052ab 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryApprovalRequestRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryApprovalRequestRepository.cs @@ -1,3 +1,4 @@ +#pragma warning disable S4144 // Scaffolding intencional para testing In-Memory namespace Ums.Infrastructure.Persistence; using System.Collections.Concurrent; @@ -33,10 +34,10 @@ public Task ExistsPendingForScopeAsync(Guid userId, Guid systemId, Guid? b return Task.FromResult(exists); } - public Task AddAsync(ApprovalRequestAggregate a, CancellationToken c = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } - public Task UpdateAsync(ApprovalRequestAggregate a, CancellationToken c = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } - public Task SaveChangesAsync(CancellationToken c = default) => Task.FromResult(1); - public Task SaveEntitiesAsync(CancellationToken c = default) => Task.FromResult(true); + public Task AddAsync(ApprovalRequestAggregate a, CancellationToken cancellationToken = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } + public Task UpdateAsync(ApprovalRequestAggregate a, CancellationToken cancellationToken = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } + public Task SaveChangesAsync(CancellationToken cancellationToken = default) => Task.FromResult(1); + public Task SaveEntitiesAsync(CancellationToken cancellationToken = default) => Task.FromResult(true); public void Seed(ApprovalRequestAggregate a) { a.DomainEvents.MarkChangesAsCommitted(); @@ -44,3 +45,5 @@ public void Seed(ApprovalRequestAggregate a) } public void Dispose() { } } + +#pragma warning restore S4144 diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryApprovalWorkflowRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryApprovalWorkflowRepository.cs index f8a9a9bf..3736804e 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryApprovalWorkflowRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryApprovalWorkflowRepository.cs @@ -1,3 +1,4 @@ +#pragma warning disable S4144 // Scaffolding intencional para testing In-Memory namespace Ums.Infrastructure.Persistence; using System.Collections.Concurrent; @@ -22,10 +23,10 @@ public Task> GetAllAsync(Guid? tenantId public Task> GetByTenantIdAsync(Guid tenantId, CancellationToken cancellationToken = default) { var f = _store.Values.Where(e => e.Props.TenantId.GetValue() == tenantId).ToList(); f.ForEach(e => e.BrokenRules.Clear()); return Task.FromResult>(f); } - public Task AddAsync(ApprovalWorkflowAggregate a, CancellationToken c = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } - public Task UpdateAsync(ApprovalWorkflowAggregate a, CancellationToken c = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } - public Task SaveChangesAsync(CancellationToken c = default) => Task.FromResult(1); - public Task SaveEntitiesAsync(CancellationToken c = default) => Task.FromResult(true); + public Task AddAsync(ApprovalWorkflowAggregate a, CancellationToken cancellationToken = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } + public Task UpdateAsync(ApprovalWorkflowAggregate a, CancellationToken cancellationToken = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } + public Task SaveChangesAsync(CancellationToken cancellationToken = default) => Task.FromResult(1); + public Task SaveEntitiesAsync(CancellationToken cancellationToken = default) => Task.FromResult(true); public void Seed(ApprovalWorkflowAggregate a) { a.DomainEvents.MarkChangesAsCommitted(); @@ -33,3 +34,5 @@ public void Seed(ApprovalWorkflowAggregate a) } public void Dispose() { } } + +#pragma warning restore S4144 diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryDocumentTypeRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryDocumentTypeRepository.cs index 7bf2d903..2949ddea 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryDocumentTypeRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryDocumentTypeRepository.cs @@ -1,3 +1,4 @@ +#pragma warning disable S4144 // Scaffolding intencional para testing In-Memory namespace Ums.Infrastructure.Persistence; using System.Collections.Concurrent; @@ -22,10 +23,10 @@ public Task> GetAllAsync(Guid? tenantId = n public Task> GetByTenantIdAsync(Guid tenantId, CancellationToken cancellationToken = default) { var f = _store.Values.Where(e => e.Props.TenantId.GetValue() == tenantId).ToList(); f.ForEach(e => e.BrokenRules.Clear()); return Task.FromResult>(f); } - public Task AddAsync(DocumentTypeAggregate a, CancellationToken c = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } - public Task UpdateAsync(DocumentTypeAggregate a, CancellationToken c = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } - public Task SaveChangesAsync(CancellationToken c = default) => Task.FromResult(1); - public Task SaveEntitiesAsync(CancellationToken c = default) => Task.FromResult(true); + public Task AddAsync(DocumentTypeAggregate a, CancellationToken cancellationToken = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } + public Task UpdateAsync(DocumentTypeAggregate a, CancellationToken cancellationToken = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } + public Task SaveChangesAsync(CancellationToken cancellationToken = default) => Task.FromResult(1); + public Task SaveEntitiesAsync(CancellationToken cancellationToken = default) => Task.FromResult(true); public void Seed(DocumentTypeAggregate a) { a.DomainEvents.MarkChangesAsCommitted(); @@ -33,3 +34,5 @@ public void Seed(DocumentTypeAggregate a) } public void Dispose() { } } + +#pragma warning restore S4144 diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryFeatureFlagRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryFeatureFlagRepository.cs index 29b8b747..99f5cf96 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryFeatureFlagRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryFeatureFlagRepository.cs @@ -1,3 +1,4 @@ +#pragma warning disable S4144 // Scaffolding intencional para testing In-Memory namespace Ums.Infrastructure.Persistence; using System.Collections.Concurrent; @@ -55,6 +56,10 @@ public Task> GetBySystemSuiteIdAsync(Guid sy return Task.FromResult>(items); } + // En memoria no hay `Include` que evitar: la distinción sirve al lado PostgreSQL. + public Task> GetBySystemSuiteIdForEvaluationAsync(Guid systemSuiteId, CancellationToken cancellationToken = default) + => GetBySystemSuiteIdAsync(systemSuiteId, cancellationToken); + public Task AddAsync(FeatureFlagAggregate aggregate, CancellationToken cancellationToken = default) { _store[aggregate.Props.Id.GetValue()] = aggregate; @@ -79,3 +84,5 @@ public void Seed(FeatureFlagAggregate aggregate) public void Dispose() { } } + +#pragma warning restore S4144 diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryIdpConfigurationRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryIdpConfigurationRepository.cs index 559796b8..91fff62d 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryIdpConfigurationRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryIdpConfigurationRepository.cs @@ -1,3 +1,4 @@ +#pragma warning disable S4144 // Scaffolding intencional para testing In-Memory namespace Ums.Infrastructure.Persistence; using System.Collections.Concurrent; @@ -60,3 +61,5 @@ public void Seed(IdpConfigurationAggregate aggregate) public void Dispose() { } } + +#pragma warning restore S4144 diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryNotificationRuleRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryNotificationRuleRepository.cs index ec8c02b7..cbf3767a 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryNotificationRuleRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryNotificationRuleRepository.cs @@ -1,3 +1,4 @@ +#pragma warning disable S4144 // Scaffolding intencional para testing In-Memory namespace Ums.Infrastructure.Persistence; using System.Collections.Concurrent; @@ -32,10 +33,10 @@ public Task ExistsDuplicateAsync(Guid tenantId, string channel, string rec return Task.FromResult(exists); } - public Task AddAsync(NotificationRuleAggregate a, CancellationToken c = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } - public Task UpdateAsync(NotificationRuleAggregate a, CancellationToken c = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } - public Task SaveChangesAsync(CancellationToken c = default) => Task.FromResult(1); - public Task SaveEntitiesAsync(CancellationToken c = default) => Task.FromResult(true); + public Task AddAsync(NotificationRuleAggregate a, CancellationToken cancellationToken = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } + public Task UpdateAsync(NotificationRuleAggregate a, CancellationToken cancellationToken = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } + public Task SaveChangesAsync(CancellationToken cancellationToken = default) => Task.FromResult(1); + public Task SaveEntitiesAsync(CancellationToken cancellationToken = default) => Task.FromResult(true); public void Seed(NotificationRuleAggregate a) { a.DomainEvents.MarkChangesAsCommitted(); @@ -43,3 +44,5 @@ public void Seed(NotificationRuleAggregate a) } public void Dispose() { } } + +#pragma warning restore S4144 diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryParameterRepositories.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryParameterRepositories.cs index c7ec3d46..bdfb0cc2 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryParameterRepositories.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryParameterRepositories.cs @@ -1,3 +1,4 @@ +#pragma warning disable S4144 // Scaffolding intencional para testing In-Memory namespace Ums.Infrastructure.Persistence; using System.Collections.Concurrent; @@ -20,85 +21,115 @@ public sealed class InMemoryParameterRepositories // ── IParameterDefinitionRepository ─────────────────────────────────────── - Task IParameterDefinitionRepository.GetByIdAsync(Guid id, CancellationToken ct) - => Task.FromResult(_defs.GetValueOrDefault(id)); + // Las lecturas ocultan lo eliminado lógicamente, igual que el filtro global de consulta del + // contexto PostgreSQL: la fila sigue en el diccionario (nunca se retira), simplemente no se sirve. + Task IParameterDefinitionRepository.GetByIdAsync(Guid id, CancellationToken cancellationToken) + { + var definition = _defs.GetValueOrDefault(id); + return Task.FromResult(definition is { Props.IsDeleted: false } ? definition : null); + } - Task IParameterDefinitionRepository.GetByCodeAsync(string code, CancellationToken ct) - => Task.FromResult(_defs.Values.FirstOrDefault(d => - string.Equals(d.Props.Code.GetValue(), code, StringComparison.OrdinalIgnoreCase))); + Task IParameterDefinitionRepository.GetByCodeAsync(string code, CancellationToken cancellationToken) + => Task.FromResult(_defs.Values.FirstOrDefault(definition => + !definition.Props.IsDeleted && + string.Equals(definition.Props.Code.GetValue(), code, StringComparison.OrdinalIgnoreCase))); - Task> IParameterDefinitionRepository.GetAllAsync(CancellationToken ct) + Task> IParameterDefinitionRepository.GetAllAsync(CancellationToken cancellationToken) => Task.FromResult>( - _defs.Values.OrderBy(d => d.Props.DisplayOrder).ToList()); + _defs.Values + .Where(definition => !definition.Props.IsDeleted) + .OrderBy(definition => definition.Props.DisplayOrder) + .ToList()); - Task IParameterDefinitionRepository.AddAsync(ParameterDefinition d, CancellationToken ct) + Task IParameterDefinitionRepository.AddAsync(ParameterDefinition definition, CancellationToken cancellationToken) { - _defs[d.Props.Id.GetValue()] = d; + _defs[definition.Props.Id.GetValue()] = definition; return Task.CompletedTask; } - Task IParameterDefinitionRepository.UpdateAsync(ParameterDefinition d, CancellationToken ct) + Task IParameterDefinitionRepository.UpdateAsync(ParameterDefinition definition, CancellationToken cancellationToken) { - _defs[d.Props.Id.GetValue()] = d; + _defs[definition.Props.Id.GetValue()] = definition; return Task.CompletedTask; } - Task IParameterDefinitionRepository.CountByCodeAsync(string code, CancellationToken ct) - => Task.FromResult(_defs.Values.Count(d => - string.Equals(d.Props.Code.GetValue(), code, StringComparison.OrdinalIgnoreCase))); + // Cuenta solo las VIVAS: el índice único de `Code` es parcial, así que una definición eliminada + // ya no reserva su código y volver a declararlo debe poder hacerse. + Task IParameterDefinitionRepository.CountByCodeAsync(string code, CancellationToken cancellationToken) + => Task.FromResult(_defs.Values.Count(definition => + !definition.Props.IsDeleted && + string.Equals(definition.Props.Code.GetValue(), code, StringComparison.OrdinalIgnoreCase))); - Task IParameterDefinitionRepository.CountGlobalValuesAsync(Guid defId, CancellationToken ct) - => Task.FromResult(_gv.Values.Count(v => v.Props.ParameterDefinitionId.GetValue() == defId)); + Task IParameterDefinitionRepository.CountLiveGlobalValuesAsync(Guid definitionId, CancellationToken cancellationToken) + => Task.FromResult(_gv.Values.Count(value => + value.Props.ParameterDefinitionId.GetValue() == definitionId && + value.Props.Status != ConfigStatus.Deleted)); - Task IParameterDefinitionRepository.CountTenantValuesAsync(Guid defId, CancellationToken ct) - => Task.FromResult(_tv.Values.Count(v => v.Props.ParameterDefinitionId.GetValue() == defId)); + Task IParameterDefinitionRepository.CountLiveTenantValuesAsync(Guid definitionId, CancellationToken cancellationToken) + => Task.FromResult(_tv.Values.Count(value => + value.Props.ParameterDefinitionId.GetValue() == definitionId && + value.Props.Status != ConfigStatus.Deleted)); - Task IParameterDefinitionRepository.SaveChangesAsync(CancellationToken ct) => Task.FromResult(true); + Task IParameterDefinitionRepository.SaveChangesAsync(CancellationToken cancellationToken) => Task.FromResult(true); // ── IParameterGlobalValueRepository ────────────────────────────────────── - Task IParameterGlobalValueRepository.GetByIdAsync(Guid id, CancellationToken ct) - => Task.FromResult(_gv.GetValueOrDefault(id)); + Task IParameterGlobalValueRepository.GetByIdAsync(Guid id, CancellationToken cancellationToken) + { + var value = _gv.GetValueOrDefault(id); + return Task.FromResult(value is not null && value.Props.Status != ConfigStatus.Deleted ? value : null); + } - Task IParameterGlobalValueRepository.GetByDefinitionIdAsync(Guid defId, CancellationToken ct) - => Task.FromResult(_gv.Values.FirstOrDefault(v => v.Props.ParameterDefinitionId.GetValue() == defId)); + // Solo el valor VIVO: el índice único parcial dejó de contar las lápidas, así que devolverlas + // bloquearía un alta que la base permite y daría por vigente un valor ya retirado. + Task IParameterGlobalValueRepository.GetByDefinitionIdAsync(Guid definitionId, CancellationToken cancellationToken) + => Task.FromResult(_gv.Values.FirstOrDefault(value => + value.Props.ParameterDefinitionId.GetValue() == definitionId && + value.Props.Status != ConfigStatus.Deleted)); - Task IParameterGlobalValueRepository.AddAsync(ParameterGlobalValue v, CancellationToken ct) + Task IParameterGlobalValueRepository.AddAsync(ParameterGlobalValue value, CancellationToken cancellationToken) { - _gv[v.Props.Id.GetValue()] = v; + _gv[value.Props.Id.GetValue()] = value; return Task.CompletedTask; } - Task IParameterGlobalValueRepository.UpdateAsync(ParameterGlobalValue v, CancellationToken ct) + Task IParameterGlobalValueRepository.UpdateAsync(ParameterGlobalValue value, CancellationToken cancellationToken) { - _gv[v.Props.Id.GetValue()] = v; + _gv[value.Props.Id.GetValue()] = value; return Task.CompletedTask; } - Task IParameterGlobalValueRepository.SaveChangesAsync(CancellationToken ct) => Task.FromResult(true); + Task IParameterGlobalValueRepository.SaveChangesAsync(CancellationToken cancellationToken) => Task.FromResult(true); // ── IParameterTenantValueRepository ────────────────────────────────────── - Task IParameterTenantValueRepository.GetByIdAsync(Guid id, CancellationToken ct) - => Task.FromResult(_tv.GetValueOrDefault(id)); + Task IParameterTenantValueRepository.GetByIdAsync(Guid id, CancellationToken cancellationToken) + { + var value = _tv.GetValueOrDefault(id); + return Task.FromResult(value is not null && value.Props.Status != ConfigStatus.Deleted ? value : null); + } + // Solo el override VIVO, por la misma razón que en el valor global. Task IParameterTenantValueRepository.GetByTenantAndDefinitionAsync( - Guid tenantId, Guid defId, CancellationToken ct) - => Task.FromResult(_tv.Values.FirstOrDefault(v => - v.Props.TenantId.GetValue() == tenantId && - v.Props.ParameterDefinitionId.GetValue() == defId)); + Guid tenantId, Guid definitionId, CancellationToken cancellationToken) + => Task.FromResult(_tv.Values.FirstOrDefault(value => + value.Props.TenantId.GetValue() == tenantId && + value.Props.ParameterDefinitionId.GetValue() == definitionId && + value.Props.Status != ConfigStatus.Deleted)); - Task IParameterTenantValueRepository.AddAsync(ParameterTenantValue v, CancellationToken ct) + Task IParameterTenantValueRepository.AddAsync(ParameterTenantValue value, CancellationToken cancellationToken) { - _tv[v.Props.Id.GetValue()] = v; + _tv[value.Props.Id.GetValue()] = value; return Task.CompletedTask; } - Task IParameterTenantValueRepository.UpdateAsync(ParameterTenantValue v, CancellationToken ct) + Task IParameterTenantValueRepository.UpdateAsync(ParameterTenantValue value, CancellationToken cancellationToken) { - _tv[v.Props.Id.GetValue()] = v; + _tv[value.Props.Id.GetValue()] = value; return Task.CompletedTask; } - Task IParameterTenantValueRepository.SaveChangesAsync(CancellationToken ct) => Task.FromResult(true); + Task IParameterTenantValueRepository.SaveChangesAsync(CancellationToken cancellationToken) => Task.FromResult(true); } + +#pragma warning restore S4144 diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryPermissionTemplateRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryPermissionTemplateRepository.cs index 4a9c531a..d6670a1c 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryPermissionTemplateRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryPermissionTemplateRepository.cs @@ -1,3 +1,4 @@ +#pragma warning disable S4144 // Scaffolding intencional para testing In-Memory namespace Ums.Infrastructure.Persistence; using System.Collections.Concurrent; @@ -13,7 +14,10 @@ public sealed class InMemoryPermissionTemplateRepository : IPermissionTemplateRe public Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) { + // Las lecturas ocultan lo lógicamente eliminado, igual que el store PostgreSQL: quien pida + // por id una plantilla eliminada debe recibir 404, no el agregado en estado terminal. _store.TryGetValue(id, out var template); + if (template is not null && template.IsDeleted) template = null; template?.BrokenRules.Clear(); return Task.FromResult(template); } @@ -23,16 +27,29 @@ public sealed class InMemoryPermissionTemplateRepository : IPermissionTemplateRe public Task> GetAllAsync(Guid? tenantId = null, CancellationToken cancellationToken = default) { + var vivas = _store.Values.Where(t => !t.IsDeleted); var all = tenantId.HasValue - ? _store.Values.Where(t => t.Props.TenantId.GetValue() == tenantId.Value).ToList() - : _store.Values.ToList(); + ? vivas.Where(t => t.Props.TenantId.GetValue() == tenantId.Value).ToList() + : vivas.ToList(); all.ForEach(t => t.BrokenRules.Clear()); return Task.FromResult>(all); } public Task> GetByTenantIdAsync(Guid tenantId, CancellationToken cancellationToken = default) { - var filtered = _store.Values.Where(t => t.Props.TenantId.GetValue() == tenantId).ToList(); + var filtered = _store.Values.Where(t => !t.IsDeleted && t.Props.TenantId.GetValue() == tenantId).ToList(); + filtered.ForEach(t => t.BrokenRules.Clear()); + return Task.FromResult>(filtered); + } + + // Sin filtro de eliminadas, igual que el store PostgreSQL: alimenta el cálculo de la versión + // siguiente y una plantilla eliminada sigue ocupando su versión en el índice único. + public Task> GetByTenantRoleSuiteAsync(Guid tenantId, Guid roleId, Guid systemSuiteId, CancellationToken cancellationToken = default) + { + var filtered = _store.Values.Where(t => + t.Props.TenantId.GetValue() == tenantId && + t.Props.RoleId.GetValue() == roleId && + t.Props.SystemSuiteId.GetValue() == systemSuiteId).ToList(); filtered.ForEach(t => t.BrokenRules.Clear()); return Task.FromResult>(filtered); } @@ -49,10 +66,14 @@ public Task UpdateAsync(PermissionTemplateAggregate aggregate, CancellationToken return Task.CompletedTask; } + /// + /// Borrado LÓGICO: la entrada se queda en el store; antes hacía TryRemove y perdía la fila. + /// Replica el contrato booleano del store real: false si no existe o si ya estaba eliminada. + /// public Task DeleteAsync(Guid id, CancellationToken cancellationToken = default) { - var removed = _store.TryRemove(id, out _); - return Task.FromResult(removed); + if (!_store.TryGetValue(id, out var template) || template.IsDeleted) return Task.FromResult(false); + return Task.FromResult(true); } public Task SaveChangesAsync(CancellationToken cancellationToken = default) => Task.FromResult(1); @@ -72,7 +93,10 @@ public Task CountPublishedByRoleAsync(Guid roleId, CancellationToken cancel t.Props.RoleId.GetValue() == roleId && t.Status == Ums.Domain.Enums.TemplateStatus.Published)); + // Los ítems de una plantilla eliminada no bloquean: su contenedor ya está lógicamente eliminado. public Task CountItemsByTargetAsync(Guid targetId, CancellationToken cancellationToken = default) - => Task.FromResult(_store.Values.SelectMany(t => t.Items) + => Task.FromResult(_store.Values.Where(t => !t.IsDeleted).SelectMany(t => t.Items) .Count(i => i.TargetId.GetValue() == targetId && i.IsActive)); } + +#pragma warning restore S4144 diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryProfileRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryProfileRepository.cs index 73c07e18..829469ec 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryProfileRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryProfileRepository.cs @@ -1,3 +1,4 @@ +#pragma warning disable S4144 // Scaffolding intencional para testing In-Memory namespace Ums.Infrastructure.Persistence; using System.Collections.Concurrent; @@ -58,6 +59,18 @@ public Task> GetByUserIdAsync(Guid userId, Cance return Task.FromResult>(filtered); } + public Task> GetActiveByUserAndTenantAsync( + Guid userId, Guid tenantId, CancellationToken cancellationToken = default) + { + var items = _store.Values + .Where(x => x.Props.UserId.GetValue() == userId + && x.Props.TenantId.GetValue() == tenantId + && x.IsActive) + .ToList(); + items.ForEach(item => item.BrokenRules.Clear()); + return Task.FromResult>(items); + } + public Task AddAsync(ProfileAggregate aggregate, CancellationToken cancellationToken = default) { _store[aggregate.Props.Id.GetValue()] = aggregate; @@ -92,4 +105,10 @@ public Task CountActiveByTemplateAsync(Guid templateId, CancellationToken c public Task CountActiveByUserAsync(Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(_store.Values.Count(p => p.Props.UserId.GetValue() == userId && p.IsActive)); + + // ADR-0164 §2.2: guarda de cascada del cierre de sucursal. + public Task CountActiveByBranchAsync(Guid branchId, CancellationToken cancellationToken = default) + => Task.FromResult(_store.Values.Count(p => p.Props.BranchId?.GetValue() == branchId && p.IsActive)); } + +#pragma warning restore S4144 diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryRoleMaturityStatusRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryRoleMaturityStatusRepository.cs new file mode 100644 index 00000000..40864199 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryRoleMaturityStatusRepository.cs @@ -0,0 +1,67 @@ +#pragma warning disable S4144 // Scaffolding intencional para testing In-Memory +namespace Ums.Infrastructure.Persistence; + +using System.Collections.Concurrent; +using Ums.Domain.IGA; +using Ums.Domain.Kernel; +using RoleMaturityStatusAggregate = Ums.Domain.IGA.RoleMaturityStatus.RoleMaturityStatus; + +/// +/// Implementación en memoria de (IGA, ADR-UMS-093) para +/// dev/tests sin PostgreSQL. Guarda los agregados por identificador y respeta el acotamiento por +/// inquilino en las consultas. Mantiene la misma superficie que la variante PostgreSQL. +/// +public sealed class InMemoryRoleMaturityStatusRepository : IRoleMaturityStatusRepository, IUnitOfWork +{ + private readonly ConcurrentDictionary _store = new(); + public IUnitOfWork UnitOfWork => this; + + public Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { _store.TryGetValue(id, out var e); e?.BrokenRules.Clear(); return Task.FromResult(e); } + + public Task GetByIdAsync(Guid tenantId, Guid id, CancellationToken cancellationToken = default) + => GetByIdAsync(id, cancellationToken); + + public Task> GetByUserAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken = default) + { + var items = _store.Values + .Where(x => x.TenantId.GetValue() == tenantId && x.UserId.GetValue() == userId) + .ToList(); + items.ForEach(e => e.BrokenRules.Clear()); + return Task.FromResult>(items); + } + + public Task GetByUserAndRoleAsync(Guid tenantId, Guid userId, Guid roleId, CancellationToken cancellationToken = default) + { + var item = _store.Values.FirstOrDefault(x => + x.TenantId.GetValue() == tenantId && + x.UserId.GetValue() == userId && + x.RoleId.GetValue() == roleId); + item?.BrokenRules.Clear(); + return Task.FromResult(item); + } + + public Task> GetAllAsync(Guid? tenantId = null, CancellationToken cancellationToken = default) + { + var items = _store.Values + .Where(x => !tenantId.HasValue || x.TenantId.GetValue() == tenantId.Value) + .ToList(); + items.ForEach(e => e.BrokenRules.Clear()); + return Task.FromResult>(items); + } + + public Task AddAsync(RoleMaturityStatusAggregate a, CancellationToken cancellationToken = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } + public Task UpdateAsync(RoleMaturityStatusAggregate a, CancellationToken cancellationToken = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } + public Task SaveChangesAsync(CancellationToken cancellationToken = default) => Task.FromResult(1); + public Task SaveEntitiesAsync(CancellationToken cancellationToken = default) => Task.FromResult(true); + + public void Seed(RoleMaturityStatusAggregate a) + { + a.DomainEvents.MarkChangesAsCommitted(); + _store[a.Props.Id.GetValue()] = a; + } + + public void Dispose() { } +} + +#pragma warning restore S4144 diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryRolePromotionRequestRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryRolePromotionRequestRepository.cs new file mode 100644 index 00000000..c5919777 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryRolePromotionRequestRepository.cs @@ -0,0 +1,65 @@ +#pragma warning disable S4144 // Scaffolding intencional para testing In-Memory +namespace Ums.Infrastructure.Persistence; + +using System.Collections.Concurrent; +using Ums.Domain.IGA; +using Ums.Domain.Kernel; +using RolePromotionRequestAggregate = Ums.Domain.IGA.RolePromotionRequest.RolePromotionRequest; + +/// +/// Implementación en memoria de (IGA, ADR-UMS-093) para +/// dev/tests sin PostgreSQL. Guarda los agregados por identificador y respeta el acotamiento por +/// inquilino y el filtrado por estado en las consultas. Mantiene la misma superficie que la variante +/// PostgreSQL. +/// +public sealed class InMemoryRolePromotionRequestRepository : IRolePromotionRequestRepository, IUnitOfWork +{ + private readonly ConcurrentDictionary _store = new(); + public IUnitOfWork UnitOfWork => this; + + public Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { _store.TryGetValue(id, out var e); e?.BrokenRules.Clear(); return Task.FromResult(e); } + + public Task GetByIdAsync(Guid tenantId, Guid id, CancellationToken cancellationToken = default) + => GetByIdAsync(id, cancellationToken); + + public Task> GetAllAsync(Guid? tenantId = null, CancellationToken cancellationToken = default) + { + var items = _store.Values + .Where(x => !tenantId.HasValue || x.TenantId.GetValue() == tenantId.Value) + .ToList(); + items.ForEach(e => e.BrokenRules.Clear()); + return Task.FromResult>(items); + } + + public Task> GetByTenantIdAsync(Guid tenantId, CancellationToken cancellationToken = default) + { + var items = _store.Values.Where(x => x.TenantId.GetValue() == tenantId).ToList(); + items.ForEach(e => e.BrokenRules.Clear()); + return Task.FromResult>(items); + } + + public Task> GetByTenantAndStatusAsync(Guid tenantId, string status, CancellationToken cancellationToken = default) + { + var items = _store.Values + .Where(x => x.TenantId.GetValue() == tenantId && x.Status.Name == status) + .ToList(); + items.ForEach(e => e.BrokenRules.Clear()); + return Task.FromResult>(items); + } + + public Task AddAsync(RolePromotionRequestAggregate a, CancellationToken cancellationToken = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } + public Task UpdateAsync(RolePromotionRequestAggregate a, CancellationToken cancellationToken = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } + public Task SaveChangesAsync(CancellationToken cancellationToken = default) => Task.FromResult(1); + public Task SaveEntitiesAsync(CancellationToken cancellationToken = default) => Task.FromResult(true); + + public void Seed(RolePromotionRequestAggregate a) + { + a.DomainEvents.MarkChangesAsCommitted(); + _store[a.Props.Id.GetValue()] = a; + } + + public void Dispose() { } +} + +#pragma warning restore S4144 diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryRoleRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryRoleRepository.cs index 1b11f242..c9e5e51c 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryRoleRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryRoleRepository.cs @@ -1,3 +1,4 @@ +#pragma warning disable S4144 // Scaffolding intencional para testing In-Memory namespace Ums.Infrastructure.Persistence; using System.Collections.Concurrent; @@ -71,6 +72,13 @@ public void Seed(RoleAggregate aggregate) _store[aggregate.Props.Id.GetValue()] = aggregate; } + public Task> GetByIdsAsync(IReadOnlyCollection ids, CancellationToken cancellationToken = default) + { + var items = _store.Values.Where(x => ids.Contains(x.GetId().GetValue())).ToList(); + items.ForEach(item => item.BrokenRules.Clear()); + return Task.FromResult>(items); + } + public Task AddAsync(RoleAggregate aggregate, CancellationToken cancellationToken = default) { _store[aggregate.GetId().GetValue()] = aggregate; @@ -92,3 +100,5 @@ public void Dispose() { } public Task CountActiveChildRolesAsync(Guid parentRoleId, CancellationToken cancellationToken = default) => Task.FromResult(_store.Values.Count(r => r.Props.ParentRoleId?.GetValue() == parentRoleId && r.IsActive)); } + +#pragma warning restore S4144 diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemorySystemSuiteRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemorySystemSuiteRepository.cs index e0394f79..0f921968 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemorySystemSuiteRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemorySystemSuiteRepository.cs @@ -1,3 +1,4 @@ +#pragma warning disable S4144 // Scaffolding intencional para testing In-Memory namespace Ums.Infrastructure.Persistence; using System.Collections.Concurrent; @@ -12,9 +13,23 @@ public sealed class InMemorySystemSuiteRepository : ISystemSuiteRepository, IUni public IUnitOfWork UnitOfWork => this; + /// + /// Equivalente en memoria del filtro global de PostgreSQL (G-246): el sistema eliminado + /// lógicamente sigue en el almacén —igual que la fila sigue en la tabla— pero no se devuelve en + /// ninguna lectura. Sin esto, el host en memoria y el de PostgreSQL responderían distinto a la + /// misma petición, que es la clase de divergencia que hace inútil un doble. + /// + private static bool EsVisible(SystemSuiteAggregate suite) + => suite.Props.Status.Id != Ums.Domain.Enums.SystemStatus.Deleted.Id; + public Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) { _store.TryGetValue(id, out var systemSuite); + if (systemSuite is not null && !EsVisible(systemSuite)) + { + systemSuite = null; + } + systemSuite?.BrokenRules.Clear(); return Task.FromResult(systemSuite); } @@ -24,27 +39,94 @@ public sealed class InMemorySystemSuiteRepository : ISystemSuiteRepository, IUni public Task> GetAllAsync(Guid? tenantId = null, CancellationToken cancellationToken = default) { + var visibles = _store.Values.Where(EsVisible); var all = tenantId.HasValue - ? _store.Values.Where(s => s.Props.TenantId.GetValue() == tenantId.Value).ToList() - : _store.Values.ToList(); + ? visibles.Where(s => s.Props.TenantId.GetValue() == tenantId.Value).ToList() + : visibles.ToList(); all.ForEach(s => s.BrokenRules.Clear()); return Task.FromResult>(all); } + // Sonda de unicidad del código, no lectura del catálogo: incluye a propósito los eliminados + // lógicamente, igual que su equivalente de PostgreSQL, porque la lápida conserva el código. public Task GetByCodeAsync(Code code, CancellationToken cancellationToken = default) { - var systemSuite = _store.Values.FirstOrDefault(s => string.Equals(s.Props.Code.GetValue(), code.GetValue(), StringComparison.Ordinal)); + var systemSuite = _store.Values + .FirstOrDefault(s => string.Equals(s.Props.Code.GetValue(), code.GetValue(), StringComparison.Ordinal)); systemSuite?.BrokenRules.Clear(); return Task.FromResult(systemSuite); } public Task> GetByTenantIdAsync(Guid tenantId, CancellationToken cancellationToken = default) { - var filtered = _store.Values.Where(s => s.Props.TenantId.GetValue() == tenantId).ToList(); + var filtered = _store.Values.Where(EsVisible).Where(s => s.Props.TenantId.GetValue() == tenantId).ToList(); filtered.ForEach(s => s.BrokenRules.Clear()); return Task.FromResult>(filtered); } + public Task> GetSummariesByIdsAsync(IReadOnlyCollection ids, CancellationToken cancellationToken = default) + { + var items = _store.Values + .Where(EsVisible) + .Where(x => ids.Contains(x.GetId().GetValue())) + .Select(x => new Ums.Domain.Authorization.SystemSuite.SystemSuiteSummary(x.GetId().GetValue(), x.Props.Code.GetValue(), x.Props.Name.GetValue())) + .ToList(); + + return Task.FromResult>(items); + } + + public Task GetPageAsync( + Ums.Domain.Authorization.SystemSuite.SystemSuitePageQuery query, + CancellationToken cancellationToken = default) + { + var items = _store.Values.Where(EsVisible); + + if (query.TenantId.HasValue) + items = items.Where(x => x.Props.TenantId.GetValue() == query.TenantId.Value); + + if (!string.IsNullOrWhiteSpace(query.Status)) + items = items.Where(x => string.Equals(x.Props.Status.ToString(), query.Status, StringComparison.OrdinalIgnoreCase)); + + if (!string.IsNullOrWhiteSpace(query.Search)) + { + var s = query.Search; + items = query.SearchField switch + { + "code" => items.Where(x => x.Props.Code.GetValue().Contains(s, StringComparison.OrdinalIgnoreCase)), + "id" => items.Where(x => x.GetId().GetValue().ToString().Contains(s, StringComparison.OrdinalIgnoreCase)), + _ => items.Where(x => x.Props.Name.GetValue().Contains(s, StringComparison.OrdinalIgnoreCase)), + }; + } + + var lista = items.ToList(); + + var ordenados = (query.SortBy, query.Descending) switch + { + ("code", true) => lista.OrderByDescending(x => x.Props.Code.GetValue()), + ("code", false) => lista.OrderBy(x => x.Props.Code.GetValue()), + ("status", true) => lista.OrderByDescending(x => x.Props.Status.ToString()), + ("status", false) => lista.OrderBy(x => x.Props.Status.ToString()), + (_, true) => lista.OrderByDescending(x => x.Props.Name.GetValue()), + _ => lista.OrderBy(x => x.Props.Name.GetValue()), + }; + + var ids = ordenados + .Skip((query.Page - 1) * query.PageSize) + .Take(query.PageSize) + .Select(x => x.GetId().GetValue()) + .ToList(); + + return Task.FromResult(new Ums.Domain.Authorization.SystemSuite.SystemSuitePage(ids, lista.Count)); + } + + public Task> GetByIdsAsync(IReadOnlyCollection ids, CancellationToken cancellationToken = default) + { + var porId = _store.Values.Where(EsVisible).ToDictionary(x => x.GetId().GetValue()); + var items = ids.Where(porId.ContainsKey).Select(id => porId[id]).ToList(); + items.ForEach(item => item.BrokenRules.Clear()); + return Task.FromResult>(items); + } + public Task AddAsync(SystemSuiteAggregate aggregate, CancellationToken cancellationToken = default) { _store[aggregate.Props.Id.GetValue()] = aggregate; @@ -57,6 +139,16 @@ public Task UpdateAsync(SystemSuiteAggregate aggregate, CancellationToken cancel return Task.CompletedTask; } + // El almacén en memoria solo conoce sistemas: no ve roles, plantillas ni inquilinos, así que no + // puede responder por ellos. Declara «sin referencias vivas» en lugar de inventar un bloqueo; la + // guarda de cascada se ejerce y se verifica contra PostgreSQL (G-246). + public Task GetDependentsAsync(Guid id, CancellationToken cancellationToken = default) + => Task.FromResult(Ums.Domain.Authorization.SystemSuite.SystemSuiteDependents.None); + + // Aquí tampoco hay borrado: `UpdateAsync` reemplaza el agregado con su estado `Deleted` y a + // partir de ahí `EsVisible` lo oculta. El sistema sigue en el almacén, igual que la fila sigue + // en la tabla. + public Task SaveChangesAsync(CancellationToken cancellationToken = default) => Task.FromResult(1); public Task SaveEntitiesAsync(CancellationToken cancellationToken = default) => Task.FromResult(true); @@ -69,3 +161,5 @@ public void Seed(SystemSuiteAggregate aggregate) public void Dispose() { } } + +#pragma warning restore S4144 diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryTemplateAssignmentRuleRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryTemplateAssignmentRuleRepository.cs index 15659487..81c010d5 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryTemplateAssignmentRuleRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryTemplateAssignmentRuleRepository.cs @@ -1,3 +1,4 @@ +#pragma warning disable S4144 // Scaffolding intencional para testing In-Memory namespace Ums.Infrastructure.Persistence; using System.Collections.Concurrent; @@ -73,3 +74,5 @@ public Task UpdateAsync(AssignmentRuleAggregate aggregate, CancellationToken can public Task SaveEntitiesAsync(CancellationToken cancellationToken = default) => Task.FromResult(true); public void Dispose() { } } + +#pragma warning restore S4144 diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryTenantRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryTenantRepository.cs index 361c91a3..57d94871 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryTenantRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryTenantRepository.cs @@ -1,9 +1,12 @@ +#pragma warning disable S4144 // Scaffolding intencional para testing In-Memory +#pragma warning disable S1144 namespace Ums.Infrastructure.Persistence; using System.Collections.Concurrent; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Ums.Domain.Identity; +using Ums.Domain.Identity.Tenant.Branch; using Ums.Domain.Kernel; using TenantAggregate = Ums.Domain.Identity.Tenant.Tenant; @@ -34,7 +37,8 @@ public InMemoryTenantRepository(IHttpContextAccessor? httpContextAccessor = null public Task GetByIdAsync(Guid tenantId, Guid id, CancellationToken cancellationToken = default) => GetByIdAsync(id, cancellationToken); - // G-161: en memoria no hay filtro global, así que la colección del agregado está siempre completa. + // G-161: en memoria no hay filtro global, así que la colección está siempre completa. + // ADR-0164 §2.3: incluye las cerradas — su código sigue ocupado. public Task BranchCodeExistsAsync(Guid tenantId, string code, CancellationToken cancellationToken = default) { _store.TryGetValue(tenantId, out var tenant); @@ -43,6 +47,27 @@ public Task BranchCodeExistsAsync(Guid tenantId, string code, Cancellation return Task.FromResult(exists); } + /// + /// + /// El almacén en memoria conserva la MISMA instancia del agregado, así que los asientos que el + /// dominio anotó siguen en el búfer de la sucursal: no hay volcado que simular. Basta con leerlos + /// en orden para que la bitácora sea consultable igual que contra PostgreSQL. + /// + public Task> GetBranchLifecycleAsync( + Guid tenantId, Guid branchId, CancellationToken cancellationToken = default) + { + _store.TryGetValue(tenantId, out var tenant); + + var asientos = tenant?.Branches + .FirstOrDefault(b => b.Props.Id.GetValue() == branchId)? + .PendingLifecycleEntries + .OrderBy(e => e.OccurredAtUtc) + .ThenBy(e => e.Episode.Id) + .ToList() ?? []; + + return Task.FromResult>(asientos); + } + public Task> GetAllAsync(Guid? tenantId = null, CancellationToken cancellationToken = default) { var all = _store.Values.ToList(); @@ -63,7 +88,7 @@ public Task> GetAllAsync(Guid? tenantId = null, C // REC-12: InMemory — delegate to in-memory filter (acceptable for test/dev data volumes) public async Task<(IReadOnlyList Items, int TotalCount)> GetPagedAsync( int page, int pageSize, string? search, string? status, string sortBy, string sortOrder, - Guid? tenantId = null, CancellationToken cancellationToken = default) + Guid? tenantId = null, CancellationToken cancellationToken = default, string? searchField = null) { var all = await GetAllAsync(tenantId, cancellationToken); var query = all.AsEnumerable(); @@ -71,7 +96,9 @@ public Task> GetAllAsync(Guid? tenantId = null, C if (!string.IsNullOrWhiteSpace(search)) { var lower = search.ToLower(); - query = sortBy.ToLower() == "code" + // El campo de búsqueda lo determina `searchField` (parámetro `criteria` del API), no el orden. + var field = string.IsNullOrWhiteSpace(searchField) ? sortBy : searchField; + query = field.ToLower() == "code" ? query.Where(t => t.Code.GetValue().ToLower().Contains(lower)) : query.Where(t => t.Props.Name.GetValue().ToLower().Contains(lower)); } @@ -134,3 +161,7 @@ public void Seed(TenantAggregate aggregate) public void Dispose() { } } + +#pragma warning restore S1144 + +#pragma warning restore S4144 diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryUserAccountRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryUserAccountRepository.cs index a99ca8a1..5b283f00 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryUserAccountRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryUserAccountRepository.cs @@ -1,3 +1,4 @@ +#pragma warning disable S4144 // Scaffolding intencional para testing In-Memory namespace Ums.Infrastructure.Persistence; using System.Collections.Concurrent; @@ -141,4 +142,12 @@ public Task CountActiveByTenantAsync(Guid tenantId, CancellationToken cance => Task.FromResult(_store.Values.Count(u => u.Props.TenantId.GetValue() == tenantId && u.Props.Status == Ums.Domain.Enums.UserStatus.Active)); + + public Task CountActiveByBranchAsync(Guid branchId, CancellationToken cancellationToken = default) + => Task.FromResult(_store.Values.Count(u => + u.Props.BranchId != null && + u.Props.BranchId.GetValue() == branchId && + u.Props.Status == Ums.Domain.Enums.UserStatus.Active)); } + +#pragma warning restore S4144 diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryUserDocumentRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryUserDocumentRepository.cs index 0c9f24e1..997ddfff 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryUserDocumentRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryUserDocumentRepository.cs @@ -1,3 +1,4 @@ +#pragma warning disable S4144 // Scaffolding intencional para testing In-Memory namespace Ums.Infrastructure.Persistence; using System.Collections.Concurrent; @@ -22,10 +23,10 @@ public Task> GetAllAsync(Guid? tenantId = n public Task> GetByUserIdAsync(Guid userId, CancellationToken cancellationToken = default) { var f = _store.Values.Where(e => e.Props.UserId.GetValue() == userId).ToList(); f.ForEach(e => e.BrokenRules.Clear()); return Task.FromResult>(f); } - public Task AddAsync(UserDocumentAggregate a, CancellationToken c = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } - public Task UpdateAsync(UserDocumentAggregate a, CancellationToken c = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } - public Task SaveChangesAsync(CancellationToken c = default) => Task.FromResult(1); - public Task SaveEntitiesAsync(CancellationToken c = default) => Task.FromResult(true); + public Task AddAsync(UserDocumentAggregate a, CancellationToken cancellationToken = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } + public Task UpdateAsync(UserDocumentAggregate a, CancellationToken cancellationToken = default) { _store[a.Props.Id.GetValue()] = a; return Task.CompletedTask; } + public Task SaveChangesAsync(CancellationToken cancellationToken = default) => Task.FromResult(1); + public Task SaveEntitiesAsync(CancellationToken cancellationToken = default) => Task.FromResult(true); public void Seed(UserDocumentAggregate a) { a.DomainEvents.MarkChangesAsCommitted(); @@ -33,3 +34,5 @@ public void Seed(UserDocumentAggregate a) } public void Dispose() { } } + +#pragma warning restore S4144 diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryUserManagementDelegationRepository.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryUserManagementDelegationRepository.cs index af2a6227..801948d8 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryUserManagementDelegationRepository.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/InMemoryUserManagementDelegationRepository.cs @@ -1,3 +1,4 @@ +#pragma warning disable S4144 // Scaffolding intencional para testing In-Memory namespace Ums.Infrastructure.Persistence; using System.Collections.Concurrent; @@ -106,3 +107,5 @@ public Task UpdateAsync(UserManagementDelegationAggregate aggregate, Cancellatio public void Dispose() { } } + +#pragma warning restore S4144 diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Interceptors/AuditAppendOnlyGuardInterceptor.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Interceptors/AuditAppendOnlyGuardInterceptor.cs new file mode 100644 index 00000000..b23787d8 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Interceptors/AuditAppendOnlyGuardInterceptor.cs @@ -0,0 +1,89 @@ +using System; +using System.Globalization; +using System.Linq; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Ums.Infrastructure.Persistence.Audit.Entities; + +namespace Ums.Infrastructure.Persistence.Interceptors; + +/// +/// G-081 — Anti-tamper de la traza de auditoría a NIVEL DE PERSISTENCIA (no repudio). +/// +/// +/// La traza de auditoría —, tabla audit."AuditRecords"— es +/// APPEND-ONLY: el dominio no expone métodos de mutación (INV-AU1) y el repositorio +/// (PostgreSqlAuditRecordRepository) solo ofrece AppendAsync/GetByIdAsync/ +/// QueryBy*. Faltaba, sin embargo, el enforcement en la capa de persistencia: nada impedía +/// que un SaveChanges mutara o borrara una traza ya escrita. Este interceptor cierra esa +/// brecha: intercepta el pipeline de SaveChanges del y +/// RECHAZA (lanza) cualquier entrada de en estado +/// o . Solo se permite +/// (INSERT). +/// +/// +/// +/// Defensa app-layer y portable (independiente del motor y del rol de BD). A diferencia de un +/// REVOKE UPDATE, DELETE sobre la tabla de auditoría —que un rol owner/superuser +/// ignora, misma causa que G-020: la app se conecta como postgres—, este guard actúa +/// siempre que la escritura pase por el . +/// +/// +/// +/// NO altera el estampado de que realiza +/// : aquel opera sobre la interfaz en TODAS las entidades +/// (y permite Modified para refrescar UpdatedBy/UpdatedAtUtc); éste opera solo +/// sobre la entidad concreta de la traza de auditoría y prohíbe su mutación/borrado. Ambos conviven +/// en el mismo pipeline: si se intenta modificar una traza, este guard aborta el SaveChanges +/// completo antes de tocar la base de datos. +/// +/// +public sealed class AuditAppendOnlyGuardInterceptor : SaveChangesInterceptor +{ + // Mensaje de no-repudio; el marcador {0} se rellena con el estado rechazado (Modified/Deleted). + internal const string TamperMessageFormat = + "No repudio (G-081): la traza de auditoría (AuditRecordRecord) es append-only a nivel de " + + "persistencia. Se rechazó una operación de {0} sobre una traza de auditoría; solo se permite " + + "INSERT (Added). UPDATE/DELETE de auditoría está denegado."; + + public override InterceptionResult SavingChanges( + DbContextEventData eventData, + InterceptionResult result) + { + if (eventData.Context is not null) + EnforceAppendOnly(eventData.Context); + + return base.SavingChanges(eventData, result); + } + + public override ValueTask> SavingChangesAsync( + DbContextEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + if (eventData.Context is not null) + EnforceAppendOnly(eventData.Context); + + return base.SavingChangesAsync(eventData, result, cancellationToken); + } + + /// + /// Recorre las entradas de la traza de auditoría () en el + /// y lanza + /// si alguna intenta MODIFICAR o BORRAR una traza ya + /// escrita. Se tipa contra la entidad concreta (no contra ) para + /// no interferir con el estampado general de auditoría. + /// + public static void EnforceAppendOnly(DbContext context) + { + var tampered = context.ChangeTracker + .Entries() + .FirstOrDefault(entry => entry.State is EntityState.Modified or EntityState.Deleted); + + if (tampered is not null) + { + throw new InvalidOperationException( + string.Format(CultureInfo.InvariantCulture, TamperMessageFormat, tampered.State)); + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Interceptors/OrganizationDbContextInterceptor.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Interceptors/OrganizationDbContextInterceptor.cs index 66a6b152..f5d45962 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Interceptors/OrganizationDbContextInterceptor.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Interceptors/OrganizationDbContextInterceptor.cs @@ -1,14 +1,50 @@ using System.Data.Common; using Microsoft.EntityFrameworkCore.Diagnostics; +using Ums.Application.Common.Interfaces; namespace Ums.Infrastructure.Persistence.Interceptors; /// -/// EF Core interceptor that sets the organization context in the SQL Server session. -/// Application-layer tenant filtering remains the primary isolation mechanism; this context supports database RLS as a failsafe. +/// EF Core connection interceptor that publishes the current organization/tenant id to the +/// PostgreSQL session as the GUC app.current_organization_id, so it can back a +/// database-level Row-Level Security (RLS) failsafe (G-020). +/// +/// +/// The previous implementation set a SQL Server session context (sp_set_session_context). +/// Under PostgreSQL-only persistence (D-008 / ADR-UMS-089) that T-SQL no longer applies, and the +/// interceptor had degraded to a no-op. Tenant isolation is still enforced primarily by the +/// application-layer EF Core global query filters on TenantId (see +/// ); RLS is defense-in-depth on top of them. +/// +/// +/// +/// The GUC is set on every connection open (false = session scope), which is correct under +/// Npgsql pooling because the logical Open() — and therefore this interceptor — fires on +/// every checkout, overwriting any value left by a previous borrower of the physical connection. +/// A organization id (internal admin / system / seeding) is published as an +/// empty string; the RLS policy must treat an empty setting as "no tenant restriction", mirroring +/// the query-filter short-circuit (!OrganizationId.HasValue). +/// +/// +/// +/// Enabling the RLS policies is intentionally NOT done here. It requires a migration that +/// (1) ALTER TABLE … ENABLE ROW LEVEL SECURITY on every tenant-scoped table, (2) creates a +/// policy USING ((SELECT current_setting('app.current_organization_id', true)) = '' OR +/// "TenantId" = (SELECT NULLIF(current_setting('app.current_organization_id', true), '')::uuid)) +/// — la GUC va envuelta en una subconsulta escalar para que PostgreSQL la evalúe una vez por +/// sentencia (InitPlan) y no una vez por fila, y la comparación es uuid contra uuid para no +/// inutilizar IX_*_TenantId (G-173, migración FixRlsTenantComparisonTypeMismatch) —, +/// and — critically — +/// (3) accounts for the fact that a table owner bypasses RLS unless +/// FORCE ROW LEVEL SECURITY is set, and that any DbContext touching those tables without this +/// interceptor (ReadModels, MasterData, raw ADO) would be filtered by a stale/empty GUC. Because RLS +/// is fail-closed, it must be verified on a live multi-tenant database before rollout (G-020). +/// /// public class OrganizationDbContextInterceptor : DbConnectionInterceptor { + private const string Guc = "app.current_organization_id"; + private readonly ITenantContext _tenantContext; public OrganizationDbContextInterceptor(ITenantContext tenantContext) @@ -16,60 +52,48 @@ public OrganizationDbContextInterceptor(ITenantContext tenantContext) _tenantContext = tenantContext; } - /// - /// Sets the SQL Server session context immediately after the connection is opened. - /// Skipped for SQLite since sp_set_session_context is SQL Server-specific. - /// + public override void ConnectionOpened(DbConnection connection, ConnectionEndEventData eventData) + { + ApplyOrganizationContext(connection); + base.ConnectionOpened(connection, eventData); + } + public override async Task ConnectionOpenedAsync( DbConnection connection, ConnectionEndEventData eventData, CancellationToken cancellationToken = default) { - if (connection is Microsoft.Data.Sqlite.SqliteConnection) - { - await base.ConnectionOpenedAsync(connection, eventData, cancellationToken); - return; - } - - if (_tenantContext.OrganizationId.HasValue) - { - using var command = connection.CreateCommand(); - command.CommandText = "EXEC sp_set_session_context @key = N'current_organization_id', @value = @organizationId;"; - - var organizationId = command.CreateParameter(); - organizationId.ParameterName = "@organizationId"; - organizationId.Value = _tenantContext.OrganizationId.Value; - command.Parameters.Add(organizationId); + await ApplyOrganizationContextAsync(connection, cancellationToken).ConfigureAwait(false); + await base.ConnectionOpenedAsync(connection, eventData, cancellationToken).ConfigureAwait(false); + } - await command.ExecuteNonQueryAsync(cancellationToken); - } + // Empty string when there is no tenant restriction (internal admin / system / seeding), + // matching the query-filter short-circuit. set_config(..., false) = session scope; re-applied + // on every open, so pooled connections never leak a previous tenant's value. + private string CurrentOrganization() + => _tenantContext.OrganizationId?.ToString() ?? string.Empty; - await base.ConnectionOpenedAsync(connection, eventData, cancellationToken); + private void ApplyOrganizationContext(DbConnection connection) + { + using var command = connection.CreateCommand(); + command.CommandText = $"SELECT set_config('{Guc}', @org, false)"; + command.Parameters.Add(CreateOrgParameter(command)); + command.ExecuteNonQuery(); } - public override void ConnectionOpened( - DbConnection connection, - ConnectionEndEventData eventData) + private async Task ApplyOrganizationContextAsync(DbConnection connection, CancellationToken cancellationToken) { - if (connection is Microsoft.Data.Sqlite.SqliteConnection) - { - base.ConnectionOpened(connection, eventData); - return; - } - - if (_tenantContext.OrganizationId.HasValue) - { - using var command = connection.CreateCommand(); - command.CommandText = "EXEC sp_set_session_context @key = N'current_organization_id', @value = @organizationId;"; - - var organizationId = command.CreateParameter(); - organizationId.ParameterName = "@organizationId"; - organizationId.Value = _tenantContext.OrganizationId.Value; - command.Parameters.Add(organizationId); - - command.ExecuteNonQuery(); - } + await using var command = connection.CreateCommand(); + command.CommandText = $"SELECT set_config('{Guc}', @org, false)"; + command.Parameters.Add(CreateOrgParameter(command)); + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } - base.ConnectionOpened(connection, eventData); + private DbParameter CreateOrgParameter(DbCommand command) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = "org"; + parameter.Value = CurrentOrganization(); + return parameter; } -} \ No newline at end of file +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260716012015_AddRefreshTokens.Designer.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260716012015_AddRefreshTokens.Designer.cs new file mode 100644 index 00000000..2554f3a2 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260716012015_AddRefreshTokens.Designer.cs @@ -0,0 +1,2809 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Ums.Infrastructure.Persistence; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(UmsPlatformDbContext))] + [Migration("20260716012015_AddRefreshTokens")] + partial class AddRefreshTokens + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ums_platform") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessEnforcementPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EnforcementActionId") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ProfileId") + .HasFilter("\"ProfileId\" IS NOT NULL"); + + b.HasIndex("TenantId", "RoleId") + .HasFilter("\"RoleId\" IS NOT NULL"); + + b.ToTable("AccessEnforcementPolicies", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("DaysRemaining") + .HasColumnType("integer"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Step") + .HasColumnType("integer"); + + b.Property("UserDocumentId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserDocumentId"); + + b.HasIndex("UserDocumentId", "Step") + .IsUnique(); + + b.ToTable("UserDocumentNotifications", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GrantedRoleId") + .HasColumnType("uuid"); + + b.Property("Justification") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RequestedBranchId") + .HasColumnType("uuid"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("RequestedSystemId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetProfileId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetProfileId"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("TargetUserId", "RequestedSystemId", "RequestedBranchId", "StatusId"); + + b.ToTable("ApprovalRequests", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DocumentTypeId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("WorkflowId", "DocumentTypeId") + .IsUnique(); + + b.ToTable("ApprovalRequiredDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TargetUserCategoryId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("ApprovalWorkflows", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.DocumentTypeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("DocumentTypes", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.NotificationRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("NotificationRules", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("FileChecksum") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("FileStoragePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IssueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NotificationStep") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("StatusId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "DocumentTypeId"); + + b.ToTable("UserDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Audit.Entities.AuditRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AffectedEntityId") + .HasColumnType("uuid"); + + b.Property("AffectedEntityType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AuditResultId") + .HasColumnType("integer"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Metadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RootTenantId") + .HasColumnType("uuid"); + + b.Property("SubjectTypeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WhatChanged") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("WhenOccurred") + .HasColumnType("timestamp with time zone"); + + b.Property("WhoActed") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AffectedEntityId"); + + b.HasIndex("EventType"); + + b.HasIndex("RootTenantId"); + + b.HasIndex("WhoActed"); + + b.HasIndex("AffectedEntityId", "AffectedEntityType"); + + b.ToTable("AuditRecords", "audit"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "TargetTypeId", "TargetId", "ActionId") + .IsUnique(); + + b.ToTable("PermissionTemplateItems", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "SystemSuiteId", "Version") + .IsUnique(); + + b.ToTable("PermissionTemplates", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("IsOverride") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("ProfileId", "TemplateId", "ActionId", "TargetId"); + + b.ToTable("ProfilePermissions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId", "BranchId"); + + b.ToTable("Profiles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HierarchyLevel") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ParentRoleId") + .HasColumnType("uuid"); + + b.Property("PromotionOrder") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("ParentRoleId"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("Roles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "ConfigKey", "ScopeId") + .IsUnique(); + + b.ToTable("SystemSuiteAppSettings", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteDomainResources", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteModules", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "ActionCode") + .IsUnique(); + + b.ToTable("SystemSuiteNodeActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ComponenteTecnico") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Criticidad") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Dependencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Evidencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("NodeKindId") + .HasColumnType("integer"); + + b.Property("ParentNodeId") + .HasColumnType("uuid"); + + b.Property("ProductoImpactado") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Responsable") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TrazabilidadSdlc") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ParentNodeId"); + + b.HasIndex("ModuleId", "ParentNodeId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteNodes", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("SystemSuites", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.TemplateAssignmentRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateAssignmentRules", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.AppConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsEncrypted") + .HasColumnType("boolean"); + + b.Property("IsInheritable") + .HasColumnType("boolean"); + + b.Property("IsNonOverridable") + .HasColumnType("boolean"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ModuleId", "Code") + .IsUnique(); + + b.ToTable("AppConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CriteriaType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagCriteria", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Result") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("EvaluatedAtUtc"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagEvaluationLogs", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagTargets") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FlagTypeId") + .HasColumnType("integer"); + + b.Property("LinkedResourceId") + .HasColumnType("uuid"); + + b.Property("LinkedResourceTypeId") + .HasColumnType("integer"); + + b.Property("RolloutPercentage") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("FlagTypeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("SystemSuiteId", "FlagCode") + .IsUnique(); + + b.ToTable("FeatureFlags", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.IdpConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigPayload") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DomainHintsJson") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FallbackToId") + .HasColumnType("uuid"); + + b.Property("ProviderTypeId") + .HasColumnType("integer"); + + b.Property("ResolutionPriority") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SecretRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderTypeId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ResolutionPriority"); + + b.ToTable("IdpConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DataTypeId") + .HasColumnType("integer"); + + b.Property("DefaultValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive"); + + b.HasIndex("ScopeId"); + + b.ToTable("ParameterDefinitions", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterGlobalValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EffectiveValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ParameterDefinitionId") + .IsUnique(); + + b.HasIndex("StatusId"); + + b.ToTable("ParameterGlobalValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterTenantValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ParameterDefinitionId") + .IsUnique(); + + b.ToTable("ParameterTenantValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.RefreshTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("uuid"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RenewalCount") + .HasColumnType("integer"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("RefreshTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("GeofencingMetadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantBranches", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StrategyId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantIdentityProviders", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantParameterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedValues") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultValue") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsSensitive") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ValueTypeId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code", "IsActive") + .IsUnique() + .HasDatabaseName("IX_TenantParameters_TenantId_Code_IsActive") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("TenantParameters", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyReference") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IdpStrategyId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsManagementOwner") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationTypeId") + .HasColumnType("integer"); + + b.Property("ParentTenantId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("IsManagementOwner") + .IsUnique() + .HasDatabaseName("IX_Tenants_SingleManagementOwner") + .HasFilter("\"IsManagementOwner\" = true"); + + b.HasIndex("ParentTenantId"); + + b.ToTable("Tenants", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantSignupRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedTenantId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyReference") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyReference") + .IsUnique(); + + b.HasIndex("ContactEmail"); + + b.HasIndex("StatusId"); + + b.ToTable("TenantSignupRequests", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MethodId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId", "MethodId"); + + b.ToTable("UserAccountMfaEnrollments", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.ToTable("UserAccountPasswordCredentials", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IdentityReference") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IdentityReferenceTypeId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Email") + .IsUnique(); + + b.ToTable("UserAccounts", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserManagementDelegationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedActionsJson") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DelegatedAdminId") + .HasColumnType("uuid"); + + b.Property("DelegatingAdminId") + .HasColumnType("uuid"); + + b.Property("MaxDurationDays") + .HasColumnType("integer"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RevocationReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("uuid"); + + b.Property("ScopeTypeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DelegatedAdminId"); + + b.HasIndex("DelegatingAdminId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("UserManagementDelegations", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", "UserDocument") + .WithMany("Notifications") + .HasForeignKey("UserDocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserDocument"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", null) + .WithMany("RequiredDocuments") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", "Template") + .WithMany("Items") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", "Profile") + .WithMany("Permissions") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("ParentRoleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Actions") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("AppSettings") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("DomainResources") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Modules") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Node") + .WithMany("Actions") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") + .WithMany("Nodes") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Parent") + .WithMany("Children") + .HasForeignKey("ParentNodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Module"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("Criteria") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("EvaluationLogs") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("Branches") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("IdentityProviders") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("MfaEnrollments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("PasswordCredentials") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Navigation("RequiredDocuments"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Navigation("Notifications"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Navigation("Nodes"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("Children"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("AppSettings"); + + b.Navigation("DomainResources"); + + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Navigation("Criteria"); + + b.Navigation("EvaluationLogs"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Navigation("Branches"); + + b.Navigation("IdentityProviders"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Navigation("MfaEnrollments"); + + b.Navigation("PasswordCredentials"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260716012015_AddRefreshTokens.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260716012015_AddRefreshTokens.cs new file mode 100644 index 00000000..f2674385 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260716012015_AddRefreshTokens.cs @@ -0,0 +1,71 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddRefreshTokens : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "RefreshTokens", + schema: "ums_identity", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TenantId = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + FamilyId = table.Column(type: "uuid", nullable: false), + TokenHash = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + IssuedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + ExpiresAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + RenewalCount = table.Column(type: "integer", nullable: false), + ReplacedByTokenId = table.Column(type: "uuid", nullable: true), + RevokedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + RevokedReason = table.Column(type: "character varying(60)", maxLength: 60, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_RefreshTokens", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_ExpiresAtUtc", + schema: "ums_identity", + table: "RefreshTokens", + column: "ExpiresAtUtc"); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_FamilyId", + schema: "ums_identity", + table: "RefreshTokens", + column: "FamilyId"); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_TenantId_UserId", + schema: "ums_identity", + table: "RefreshTokens", + columns: new[] { "TenantId", "UserId" }); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_TokenHash", + schema: "ums_identity", + table: "RefreshTokens", + column: "TokenHash", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "RefreshTokens", + schema: "ums_identity"); + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260716030000_AddIgaTables.Designer.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260716030000_AddIgaTables.Designer.cs new file mode 100644 index 00000000..989ad5f2 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260716030000_AddIgaTables.Designer.cs @@ -0,0 +1,2964 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Ums.Infrastructure.Persistence; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(UmsPlatformDbContext))] + [Migration("20260716030000_AddIgaTables")] + partial class AddIgaTables + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ums_platform") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessEnforcementPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EnforcementActionId") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ProfileId") + .HasFilter("\"ProfileId\" IS NOT NULL"); + + b.HasIndex("TenantId", "RoleId") + .HasFilter("\"RoleId\" IS NOT NULL"); + + b.ToTable("AccessEnforcementPolicies", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("DaysRemaining") + .HasColumnType("integer"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Step") + .HasColumnType("integer"); + + b.Property("UserDocumentId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserDocumentId"); + + b.HasIndex("UserDocumentId", "Step") + .IsUnique(); + + b.ToTable("UserDocumentNotifications", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GrantedRoleId") + .HasColumnType("uuid"); + + b.Property("Justification") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RequestedBranchId") + .HasColumnType("uuid"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("RequestedSystemId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetProfileId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetProfileId"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("TargetUserId", "RequestedSystemId", "RequestedBranchId", "StatusId"); + + b.ToTable("ApprovalRequests", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DocumentTypeId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("WorkflowId", "DocumentTypeId") + .IsUnique(); + + b.ToTable("ApprovalRequiredDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TargetUserCategoryId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("ApprovalWorkflows", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.DocumentTypeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("DocumentTypes", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.NotificationRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("NotificationRules", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("FileChecksum") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("FileStoragePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IssueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NotificationStep") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("StatusId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "DocumentTypeId"); + + b.ToTable("UserDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Audit.Entities.AuditRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AffectedEntityId") + .HasColumnType("uuid"); + + b.Property("AffectedEntityType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AuditResultId") + .HasColumnType("integer"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Metadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RootTenantId") + .HasColumnType("uuid"); + + b.Property("SubjectTypeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WhatChanged") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("WhenOccurred") + .HasColumnType("timestamp with time zone"); + + b.Property("WhoActed") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AffectedEntityId"); + + b.HasIndex("EventType"); + + b.HasIndex("RootTenantId"); + + b.HasIndex("WhoActed"); + + b.HasIndex("AffectedEntityId", "AffectedEntityType"); + + b.ToTable("AuditRecords", "audit"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "TargetTypeId", "TargetId", "ActionId") + .IsUnique(); + + b.ToTable("PermissionTemplateItems", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "SystemSuiteId", "Version") + .IsUnique(); + + b.ToTable("PermissionTemplates", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("IsOverride") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("ProfileId", "TemplateId", "ActionId", "TargetId"); + + b.ToTable("ProfilePermissions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId", "BranchId"); + + b.ToTable("Profiles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HierarchyLevel") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ParentRoleId") + .HasColumnType("uuid"); + + b.Property("PromotionOrder") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("ParentRoleId"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("Roles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "ConfigKey", "ScopeId") + .IsUnique(); + + b.ToTable("SystemSuiteAppSettings", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteDomainResources", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteModules", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "ActionCode") + .IsUnique(); + + b.ToTable("SystemSuiteNodeActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ComponenteTecnico") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Criticidad") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Dependencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Evidencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("NodeKindId") + .HasColumnType("integer"); + + b.Property("ParentNodeId") + .HasColumnType("uuid"); + + b.Property("ProductoImpactado") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Responsable") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TrazabilidadSdlc") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ParentNodeId"); + + b.HasIndex("ModuleId", "ParentNodeId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteNodes", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("SystemSuites", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.TemplateAssignmentRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateAssignmentRules", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.AppConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsEncrypted") + .HasColumnType("boolean"); + + b.Property("IsInheritable") + .HasColumnType("boolean"); + + b.Property("IsNonOverridable") + .HasColumnType("boolean"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ModuleId", "Code") + .IsUnique(); + + b.ToTable("AppConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CriteriaType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagCriteria", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Result") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("EvaluatedAtUtc"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagEvaluationLogs", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagTargets") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FlagTypeId") + .HasColumnType("integer"); + + b.Property("LinkedResourceId") + .HasColumnType("uuid"); + + b.Property("LinkedResourceTypeId") + .HasColumnType("integer"); + + b.Property("RolloutPercentage") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("FlagTypeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("SystemSuiteId", "FlagCode") + .IsUnique(); + + b.ToTable("FeatureFlags", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.IdpConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigPayload") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DomainHintsJson") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FallbackToId") + .HasColumnType("uuid"); + + b.Property("ProviderTypeId") + .HasColumnType("integer"); + + b.Property("ResolutionPriority") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SecretRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderTypeId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ResolutionPriority"); + + b.ToTable("IdpConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DataTypeId") + .HasColumnType("integer"); + + b.Property("DefaultValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive"); + + b.HasIndex("ScopeId"); + + b.ToTable("ParameterDefinitions", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterGlobalValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EffectiveValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ParameterDefinitionId") + .IsUnique(); + + b.HasIndex("StatusId"); + + b.ToTable("ParameterGlobalValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterTenantValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ParameterDefinitionId") + .IsUnique(); + + b.ToTable("ParameterTenantValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.RefreshTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("uuid"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RenewalCount") + .HasColumnType("integer"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("RefreshTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("GeofencingMetadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantBranches", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StrategyId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantIdentityProviders", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantParameterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedValues") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultValue") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsSensitive") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ValueTypeId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code", "IsActive") + .IsUnique() + .HasDatabaseName("IX_TenantParameters_TenantId_Code_IsActive") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("TenantParameters", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyReference") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IdpStrategyId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsManagementOwner") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationTypeId") + .HasColumnType("integer"); + + b.Property("ParentTenantId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("IsManagementOwner") + .IsUnique() + .HasDatabaseName("IX_Tenants_SingleManagementOwner") + .HasFilter("\"IsManagementOwner\" = true"); + + b.HasIndex("ParentTenantId"); + + b.ToTable("Tenants", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantSignupRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedTenantId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyReference") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyReference") + .IsUnique(); + + b.HasIndex("ContactEmail"); + + b.HasIndex("StatusId"); + + b.ToTable("TenantSignupRequests", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MethodId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId", "MethodId"); + + b.ToTable("UserAccountMfaEnrollments", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.ToTable("UserAccountPasswordCredentials", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IdentityReference") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IdentityReferenceTypeId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Email") + .IsUnique(); + + b.ToTable("UserAccounts", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserManagementDelegationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedActionsJson") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DelegatedAdminId") + .HasColumnType("uuid"); + + b.Property("DelegatingAdminId") + .HasColumnType("uuid"); + + b.Property("MaxDurationDays") + .HasColumnType("integer"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RevocationReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("uuid"); + + b.Property("ScopeTypeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DelegatedAdminId"); + + b.HasIndex("DelegatingAdminId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("UserManagementDelegations", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RoleMaturityStatusRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BlockingFactor") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CompletedCertificationsCount") + .HasColumnType("integer"); + + b.Property("CompletedTrainingsCount") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentLevelSince") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentMaturityLevel") + .HasColumnType("integer"); + + b.Property("EligibleForPromotionAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HasNoComplianceIssues") + .HasColumnType("boolean"); + + b.Property("LastReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NextEligibleMaturityLevel") + .HasColumnType("integer"); + + b.Property("PerformanceScore") + .HasColumnType("numeric(4,2)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId") + .IsUnique(); + + b.ToTable("RoleMaturityStatuses", "iga"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RolePromotionRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApproverId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentRoleId") + .HasColumnType("uuid"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ExecutorId") + .HasColumnType("uuid"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("RiskScore") + .HasColumnType("integer"); + + b.Property("SecurityReviewerId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetRoleId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("VerifierId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("RolePromotionRequests", "iga"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", "UserDocument") + .WithMany("Notifications") + .HasForeignKey("UserDocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserDocument"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", null) + .WithMany("RequiredDocuments") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", "Template") + .WithMany("Items") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", "Profile") + .WithMany("Permissions") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("ParentRoleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Actions") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("AppSettings") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("DomainResources") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Modules") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Node") + .WithMany("Actions") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") + .WithMany("Nodes") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Parent") + .WithMany("Children") + .HasForeignKey("ParentNodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Module"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("Criteria") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("EvaluationLogs") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("Branches") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("IdentityProviders") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("MfaEnrollments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("PasswordCredentials") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Navigation("RequiredDocuments"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Navigation("Notifications"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Navigation("Nodes"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("Children"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("AppSettings"); + + b.Navigation("DomainResources"); + + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Navigation("Criteria"); + + b.Navigation("EvaluationLogs"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Navigation("Branches"); + + b.Navigation("IdentityProviders"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Navigation("MfaEnrollments"); + + b.Navigation("PasswordCredentials"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260716030000_AddIgaTables.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260716030000_AddIgaTables.cs new file mode 100644 index 00000000..fb392c2c --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/20260716030000_AddIgaTables.cs @@ -0,0 +1,127 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddIgaTables : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "iga"); + + migrationBuilder.CreateTable( + name: "RoleMaturityStatuses", + schema: "iga", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TenantId = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + RoleId = table.Column(type: "uuid", nullable: false), + CurrentMaturityLevel = table.Column(type: "integer", nullable: false), + NextEligibleMaturityLevel = table.Column(type: "integer", nullable: true), + AssignedAt = table.Column(type: "timestamp with time zone", nullable: false), + CurrentLevelSince = table.Column(type: "timestamp with time zone", nullable: false), + EligibleForPromotionAt = table.Column(type: "timestamp with time zone", nullable: true), + CompletedCertificationsCount = table.Column(type: "integer", nullable: false), + CompletedTrainingsCount = table.Column(type: "integer", nullable: false), + PerformanceScore = table.Column(type: "numeric(4,2)", nullable: false), + HasNoComplianceIssues = table.Column(type: "boolean", nullable: false), + BlockingFactor = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + LastReviewedAt = table.Column(type: "timestamp with time zone", nullable: true), + CreatedBy = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedBy = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + UpdatedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + AuditTimeSpan = table.Column(type: "character varying(100)", maxLength: 100, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_RoleMaturityStatuses", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "RolePromotionRequests", + schema: "iga", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TenantId = table.Column(type: "uuid", nullable: false), + TargetUserId = table.Column(type: "uuid", nullable: false), + RequesterId = table.Column(type: "uuid", nullable: false), + CurrentRoleId = table.Column(type: "uuid", nullable: false), + TargetRoleId = table.Column(type: "uuid", nullable: false), + StatusId = table.Column(type: "integer", nullable: false), + RiskScore = table.Column(type: "integer", nullable: true), + ApproverId = table.Column(type: "uuid", nullable: true), + SecurityReviewerId = table.Column(type: "uuid", nullable: true), + ExecutorId = table.Column(type: "uuid", nullable: true), + VerifierId = table.Column(type: "uuid", nullable: true), + DecisionReason = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true), + CreatedBy = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedBy = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + UpdatedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + AuditTimeSpan = table.Column(type: "character varying(100)", maxLength: 100, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_RolePromotionRequests", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_RoleMaturityStatuses_TenantId", + schema: "iga", + table: "RoleMaturityStatuses", + column: "TenantId"); + + migrationBuilder.CreateIndex( + name: "IX_RoleMaturityStatuses_TenantId_UserId", + schema: "iga", + table: "RoleMaturityStatuses", + columns: new[] { "TenantId", "UserId" }); + + migrationBuilder.CreateIndex( + name: "IX_RoleMaturityStatuses_TenantId_UserId_RoleId", + schema: "iga", + table: "RoleMaturityStatuses", + columns: new[] { "TenantId", "UserId", "RoleId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_RolePromotionRequests_TargetUserId", + schema: "iga", + table: "RolePromotionRequests", + column: "TargetUserId"); + + migrationBuilder.CreateIndex( + name: "IX_RolePromotionRequests_TenantId", + schema: "iga", + table: "RolePromotionRequests", + column: "TenantId"); + + migrationBuilder.CreateIndex( + name: "IX_RolePromotionRequests_TenantId_StatusId", + schema: "iga", + table: "RolePromotionRequests", + columns: new[] { "TenantId", "StatusId" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "RoleMaturityStatuses", + schema: "iga"); + + migrationBuilder.DropTable( + name: "RolePromotionRequests", + schema: "iga"); + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260606225603_InitialPostgresCreate.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260606225603_InitialPostgresCreate.cs index 9a18c471..582601c2 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260606225603_InitialPostgresCreate.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260606225603_InitialPostgresCreate.cs @@ -1853,6 +1853,7 @@ protected override void Up(MigrationBuilder migrationBuilder) } /// + #pragma warning disable S1186 protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropTable( @@ -2027,5 +2028,6 @@ protected override void Down(MigrationBuilder migrationBuilder) name: "SystemSuites", schema: "ums_authorization"); } +#pragma warning restore S1186 } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260607033700_UpdatePostgresMassTransitOutbox.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260607033700_UpdatePostgresMassTransitOutbox.cs index ffca898f..f13dc4b4 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260607033700_UpdatePostgresMassTransitOutbox.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260607033700_UpdatePostgresMassTransitOutbox.cs @@ -38,6 +38,7 @@ protected override void Up(MigrationBuilder migrationBuilder) } /// + #pragma warning disable S1186 protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.AlterColumn( @@ -176,5 +177,6 @@ protected override void Down(MigrationBuilder migrationBuilder) table: "OutboxState", column: "Created"); } +#pragma warning restore S1186 } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260607040459_TestFinal.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260607040459_TestFinal.cs index 6c1bce11..bbcaf749 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260607040459_TestFinal.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260607040459_TestFinal.cs @@ -14,9 +14,11 @@ protected override void Up(MigrationBuilder migrationBuilder) } /// + #pragma warning disable S1186 protected override void Down(MigrationBuilder migrationBuilder) { } +#pragma warning restore S1186 } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260607040745_UpdatePostgresFinal.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260607040745_UpdatePostgresFinal.cs index a4026a8c..c80b9155 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260607040745_UpdatePostgresFinal.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260607040745_UpdatePostgresFinal.cs @@ -14,9 +14,11 @@ protected override void Up(MigrationBuilder migrationBuilder) } /// + #pragma warning disable S1186 protected override void Down(MigrationBuilder migrationBuilder) { } +#pragma warning restore S1186 } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260607041826_UpdatePostgresFinal3.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260607041826_UpdatePostgresFinal3.cs index 8597a787..91dd4924 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260607041826_UpdatePostgresFinal3.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260607041826_UpdatePostgresFinal3.cs @@ -14,9 +14,11 @@ protected override void Up(MigrationBuilder migrationBuilder) } /// + #pragma warning disable S1186 protected override void Down(MigrationBuilder migrationBuilder) { } +#pragma warning restore S1186 } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260607044419_SyncDrift.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260607044419_SyncDrift.cs index fb2c2162..2ca26871 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260607044419_SyncDrift.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260607044419_SyncDrift.cs @@ -14,9 +14,11 @@ protected override void Up(MigrationBuilder migrationBuilder) } /// + #pragma warning disable S1186 protected override void Down(MigrationBuilder migrationBuilder) { } +#pragma warning restore S1186 } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260607044815_AddPgCryptoExtension.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260607044815_AddPgCryptoExtension.cs index e96cf5b1..0a646438 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260607044815_AddPgCryptoExtension.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260607044815_AddPgCryptoExtension.cs @@ -15,10 +15,12 @@ protected override void Up(MigrationBuilder migrationBuilder) } /// + #pragma warning disable S1186 protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.AlterDatabase() .OldAnnotation("Npgsql:PostgresExtension:pgcrypto", ",,"); } +#pragma warning restore S1186 } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260612204741_WidenApprovalWorkflowCode.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260612204741_WidenApprovalWorkflowCode.cs index 909a1194..8db46838 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260612204741_WidenApprovalWorkflowCode.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260612204741_WidenApprovalWorkflowCode.cs @@ -23,6 +23,7 @@ protected override void Up(MigrationBuilder migrationBuilder) } /// + #pragma warning disable S1186 protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.AlterColumn( @@ -36,5 +37,6 @@ protected override void Down(MigrationBuilder migrationBuilder) oldType: "character varying(50)", oldMaxLength: 50); } +#pragma warning restore S1186 } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260612204858_PostgresRowVersionDefaults.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260612204858_PostgresRowVersionDefaults.cs index aa0bbd23..62bb9232 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260612204858_PostgresRowVersionDefaults.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260612204858_PostgresRowVersionDefaults.cs @@ -209,6 +209,7 @@ protected override void Up(MigrationBuilder migrationBuilder) } /// + #pragma warning disable S1186 protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.AlterColumn( @@ -408,5 +409,6 @@ protected override void Down(MigrationBuilder migrationBuilder) oldType: "bytea", oldDefaultValueSql: "gen_random_bytes(8)"); } +#pragma warning restore S1186 } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260714200354_AddSingleManagementOwnerIndex.Designer.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260714200354_AddSingleManagementOwnerIndex.Designer.cs new file mode 100644 index 00000000..e6f7b234 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260714200354_AddSingleManagementOwnerIndex.Designer.cs @@ -0,0 +1,2917 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Ums.Infrastructure.Persistence; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + [DbContext(typeof(UmsPlatformDbContext))] + [Migration("20260714200354_AddSingleManagementOwnerIndex")] + partial class AddSingleManagementOwnerIndex + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ums_platform") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessEnforcementPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EnforcementActionId") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ProfileId") + .HasFilter("\"ProfileId\" IS NOT NULL"); + + b.HasIndex("TenantId", "RoleId") + .HasFilter("\"RoleId\" IS NOT NULL"); + + b.ToTable("AccessEnforcementPolicies", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("DaysRemaining") + .HasColumnType("integer"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Step") + .HasColumnType("integer"); + + b.Property("UserDocumentId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserDocumentId"); + + b.HasIndex("UserDocumentId", "Step") + .IsUnique(); + + b.ToTable("UserDocumentNotifications", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GrantedRoleId") + .HasColumnType("uuid"); + + b.Property("Justification") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RequestedBranchId") + .HasColumnType("uuid"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("RequestedSystemId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetProfileId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetProfileId"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("TargetUserId", "RequestedSystemId", "RequestedBranchId", "StatusId"); + + b.ToTable("ApprovalRequests", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DocumentTypeId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("WorkflowId", "DocumentTypeId") + .IsUnique(); + + b.ToTable("ApprovalRequiredDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TargetUserCategoryId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("ApprovalWorkflows", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.DocumentTypeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("DocumentTypes", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.NotificationRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("NotificationRules", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("FileChecksum") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("FileStoragePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IssueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NotificationStep") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("StatusId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "DocumentTypeId"); + + b.ToTable("UserDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Audit.Entities.AuditRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AffectedEntityId") + .HasColumnType("uuid"); + + b.Property("AffectedEntityType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AuditResultId") + .HasColumnType("integer"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Metadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RootTenantId") + .HasColumnType("uuid"); + + b.Property("SubjectTypeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WhatChanged") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("WhenOccurred") + .HasColumnType("timestamp with time zone"); + + b.Property("WhoActed") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AffectedEntityId"); + + b.HasIndex("EventType"); + + b.HasIndex("RootTenantId"); + + b.HasIndex("WhoActed"); + + b.HasIndex("AffectedEntityId", "AffectedEntityType"); + + b.ToTable("AuditRecords", "audit"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "TargetTypeId", "TargetId", "ActionId") + .IsUnique(); + + b.ToTable("PermissionTemplateItems", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "SystemSuiteId", "Version") + .IsUnique(); + + b.ToTable("PermissionTemplates", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("IsOverride") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("ProfileId", "TemplateId", "ActionId", "TargetId"); + + b.ToTable("ProfilePermissions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId", "BranchId"); + + b.ToTable("Profiles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HierarchyLevel") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ParentRoleId") + .HasColumnType("uuid"); + + b.Property("PromotionOrder") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("ParentRoleId"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("Roles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "ConfigKey", "ScopeId") + .IsUnique(); + + b.ToTable("SystemSuiteAppSettings", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteDomainResources", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteMenuRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ModuleId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteMenus", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteModules", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteOptionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("SubMenuId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SubMenuId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteOptions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("SystemSuites", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteSubMenuRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("MenuId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("MenuId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteSubMenus", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.TemplateAssignmentRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateAssignmentRules", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.AppConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsEncrypted") + .HasColumnType("boolean"); + + b.Property("IsInheritable") + .HasColumnType("boolean"); + + b.Property("IsNonOverridable") + .HasColumnType("boolean"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ModuleId", "Code") + .IsUnique(); + + b.ToTable("AppConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CriteriaType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagCriteria", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Result") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("EvaluatedAtUtc"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagEvaluationLogs", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagTargets") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FlagTypeId") + .HasColumnType("integer"); + + b.Property("LinkedResourceId") + .HasColumnType("uuid"); + + b.Property("LinkedResourceTypeId") + .HasColumnType("integer"); + + b.Property("RolloutPercentage") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("FlagTypeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("SystemSuiteId", "FlagCode") + .IsUnique(); + + b.ToTable("FeatureFlags", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.IdpConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigPayload") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DomainHintsJson") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FallbackToId") + .HasColumnType("uuid"); + + b.Property("ProviderTypeId") + .HasColumnType("integer"); + + b.Property("ResolutionPriority") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SecretRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderTypeId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ResolutionPriority"); + + b.ToTable("IdpConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DataTypeId") + .HasColumnType("integer"); + + b.Property("DefaultValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive"); + + b.HasIndex("ScopeId"); + + b.ToTable("ParameterDefinitions", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterGlobalValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EffectiveValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ParameterDefinitionId") + .IsUnique(); + + b.HasIndex("StatusId"); + + b.ToTable("ParameterGlobalValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterTenantValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ParameterDefinitionId") + .IsUnique(); + + b.ToTable("ParameterTenantValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("GeofencingMetadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantBranches", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBrandingRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BackgroundStyleId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CustomDomain") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("DnsCnameTarget") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("DnsVerificationStatusId") + .HasColumnType("integer"); + + b.Property("FooterText") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("HeadlineText") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Logo") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("LogoFormatId") + .HasColumnType("integer"); + + b.Property("MagicLinkFallbackEnabled") + .HasColumnType("boolean"); + + b.Property("PrimaryButtonLabel") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PrimaryColor") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("SecondaryText") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CustomDomain") + .IsUnique() + .HasFilter("\"CustomDomain\" IS NOT NULL"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("TenantBrandings", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StrategyId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantIdentityProviders", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantParameterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedValues") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultValue") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsSensitive") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ValueTypeId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code", "IsActive") + .IsUnique() + .HasDatabaseName("IX_TenantParameters_TenantId_Code_IsActive") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("TenantParameters", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyReference") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IdpStrategyId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsManagementOwner") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationTypeId") + .HasColumnType("integer"); + + b.Property("ParentTenantId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("IsManagementOwner") + .IsUnique() + .HasDatabaseName("IX_Tenants_SingleManagementOwner") + .HasFilter("\"IsManagementOwner\" = true"); + + b.HasIndex("ParentTenantId"); + + b.ToTable("Tenants", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantSignupRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedTenantId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyReference") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyReference") + .IsUnique(); + + b.HasIndex("ContactEmail"); + + b.HasIndex("StatusId"); + + b.ToTable("TenantSignupRequests", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MethodId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId", "MethodId"); + + b.ToTable("UserAccountMfaEnrollments", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.ToTable("UserAccountPasswordCredentials", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IdentityReference") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IdentityReferenceTypeId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Email") + .IsUnique(); + + b.ToTable("UserAccounts", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserManagementDelegationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedActionsJson") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DelegatedAdminId") + .HasColumnType("uuid"); + + b.Property("DelegatingAdminId") + .HasColumnType("uuid"); + + b.Property("MaxDurationDays") + .HasColumnType("integer"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RevocationReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("uuid"); + + b.Property("ScopeTypeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DelegatedAdminId"); + + b.HasIndex("DelegatingAdminId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("UserManagementDelegations", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", "UserDocument") + .WithMany("Notifications") + .HasForeignKey("UserDocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserDocument"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", null) + .WithMany("RequiredDocuments") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", "Template") + .WithMany("Items") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", "Profile") + .WithMany("Permissions") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("ParentRoleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Actions") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("AppSettings") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("DomainResources") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteMenuRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") + .WithMany("Menus") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Module"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Modules") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteOptionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteSubMenuRecord", "SubMenu") + .WithMany("Options") + .HasForeignKey("SubMenuId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SubMenu"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteSubMenuRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteMenuRecord", "Menu") + .WithMany("SubMenus") + .HasForeignKey("MenuId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Menu"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("Criteria") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("EvaluationLogs") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("Branches") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBrandingRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithOne("Branding") + .HasForeignKey("Ums.Infrastructure.Persistence.Identity.Entities.TenantBrandingRecord", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("IdentityProviders") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("MfaEnrollments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("PasswordCredentials") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Navigation("RequiredDocuments"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Navigation("Notifications"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteMenuRecord", b => + { + b.Navigation("SubMenus"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Navigation("Menus"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("AppSettings"); + + b.Navigation("DomainResources"); + + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteSubMenuRecord", b => + { + b.Navigation("Options"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Navigation("Criteria"); + + b.Navigation("EvaluationLogs"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Navigation("Branches"); + + b.Navigation("Branding"); + + b.Navigation("IdentityProviders"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Navigation("MfaEnrollments"); + + b.Navigation("PasswordCredentials"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260714200354_AddSingleManagementOwnerIndex.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260714200354_AddSingleManagementOwnerIndex.cs new file mode 100644 index 00000000..45464460 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260714200354_AddSingleManagementOwnerIndex.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + public partial class AddSingleManagementOwnerIndex : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_Tenants_SingleManagementOwner", + schema: "ums_identity", + table: "Tenants", + column: "IsManagementOwner", + unique: true, + filter: "\"IsManagementOwner\" = true"); + } + + /// + #pragma warning disable S1186 + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Tenants_SingleManagementOwner", + schema: "ums_identity", + table: "Tenants"); + } +#pragma warning restore S1186 + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260715165202_AddSystemSuiteNodes.Designer.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260715165202_AddSystemSuiteNodes.Designer.cs new file mode 100644 index 00000000..925a41ae --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260715165202_AddSystemSuiteNodes.Designer.cs @@ -0,0 +1,2966 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Ums.Infrastructure.Persistence; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + [DbContext(typeof(UmsPlatformDbContext))] + [Migration("20260715165202_AddSystemSuiteNodes")] + partial class AddSystemSuiteNodes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ums_platform") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessEnforcementPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EnforcementActionId") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ProfileId") + .HasFilter("\"ProfileId\" IS NOT NULL"); + + b.HasIndex("TenantId", "RoleId") + .HasFilter("\"RoleId\" IS NOT NULL"); + + b.ToTable("AccessEnforcementPolicies", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("DaysRemaining") + .HasColumnType("integer"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Step") + .HasColumnType("integer"); + + b.Property("UserDocumentId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserDocumentId"); + + b.HasIndex("UserDocumentId", "Step") + .IsUnique(); + + b.ToTable("UserDocumentNotifications", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GrantedRoleId") + .HasColumnType("uuid"); + + b.Property("Justification") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RequestedBranchId") + .HasColumnType("uuid"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("RequestedSystemId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetProfileId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetProfileId"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("TargetUserId", "RequestedSystemId", "RequestedBranchId", "StatusId"); + + b.ToTable("ApprovalRequests", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DocumentTypeId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("WorkflowId", "DocumentTypeId") + .IsUnique(); + + b.ToTable("ApprovalRequiredDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TargetUserCategoryId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("ApprovalWorkflows", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.DocumentTypeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("DocumentTypes", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.NotificationRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("NotificationRules", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("FileChecksum") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("FileStoragePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IssueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NotificationStep") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("StatusId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "DocumentTypeId"); + + b.ToTable("UserDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Audit.Entities.AuditRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AffectedEntityId") + .HasColumnType("uuid"); + + b.Property("AffectedEntityType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AuditResultId") + .HasColumnType("integer"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Metadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RootTenantId") + .HasColumnType("uuid"); + + b.Property("SubjectTypeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WhatChanged") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("WhenOccurred") + .HasColumnType("timestamp with time zone"); + + b.Property("WhoActed") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AffectedEntityId"); + + b.HasIndex("EventType"); + + b.HasIndex("RootTenantId"); + + b.HasIndex("WhoActed"); + + b.HasIndex("AffectedEntityId", "AffectedEntityType"); + + b.ToTable("AuditRecords", "audit"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "TargetTypeId", "TargetId", "ActionId") + .IsUnique(); + + b.ToTable("PermissionTemplateItems", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "SystemSuiteId", "Version") + .IsUnique(); + + b.ToTable("PermissionTemplates", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("IsOverride") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("ProfileId", "TemplateId", "ActionId", "TargetId"); + + b.ToTable("ProfilePermissions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId", "BranchId"); + + b.ToTable("Profiles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HierarchyLevel") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ParentRoleId") + .HasColumnType("uuid"); + + b.Property("PromotionOrder") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("ParentRoleId"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("Roles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "ConfigKey", "ScopeId") + .IsUnique(); + + b.ToTable("SystemSuiteAppSettings", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteDomainResources", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteMenuRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ModuleId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteMenus", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteModules", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "ActionCode") + .IsUnique(); + + b.ToTable("SystemSuiteNodeActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ComponenteTecnico") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Criticidad") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Dependencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Evidencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("NodeKindId") + .HasColumnType("integer"); + + b.Property("ParentNodeId") + .HasColumnType("uuid"); + + b.Property("ProductoImpactado") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Responsable") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TrazabilidadSdlc") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ParentNodeId"); + + b.HasIndex("ModuleId", "ParentNodeId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteNodes", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteOptionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("SubMenuId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SubMenuId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteOptions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("SystemSuites", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteSubMenuRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("MenuId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("MenuId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteSubMenus", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.TemplateAssignmentRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateAssignmentRules", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.AppConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsEncrypted") + .HasColumnType("boolean"); + + b.Property("IsInheritable") + .HasColumnType("boolean"); + + b.Property("IsNonOverridable") + .HasColumnType("boolean"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ModuleId", "Code") + .IsUnique(); + + b.ToTable("AppConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CriteriaType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagCriteria", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Result") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("EvaluatedAtUtc"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagEvaluationLogs", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagTargets") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FlagTypeId") + .HasColumnType("integer"); + + b.Property("LinkedResourceId") + .HasColumnType("uuid"); + + b.Property("LinkedResourceTypeId") + .HasColumnType("integer"); + + b.Property("RolloutPercentage") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("FlagTypeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("SystemSuiteId", "FlagCode") + .IsUnique(); + + b.ToTable("FeatureFlags", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.IdpConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigPayload") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DomainHintsJson") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FallbackToId") + .HasColumnType("uuid"); + + b.Property("ProviderTypeId") + .HasColumnType("integer"); + + b.Property("ResolutionPriority") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SecretRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderTypeId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ResolutionPriority"); + + b.ToTable("IdpConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DataTypeId") + .HasColumnType("integer"); + + b.Property("DefaultValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive"); + + b.HasIndex("ScopeId"); + + b.ToTable("ParameterDefinitions", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterGlobalValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EffectiveValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ParameterDefinitionId") + .IsUnique(); + + b.HasIndex("StatusId"); + + b.ToTable("ParameterGlobalValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterTenantValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ParameterDefinitionId") + .IsUnique(); + + b.ToTable("ParameterTenantValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("GeofencingMetadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantBranches", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StrategyId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantIdentityProviders", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantParameterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedValues") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultValue") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsSensitive") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ValueTypeId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code", "IsActive") + .IsUnique() + .HasDatabaseName("IX_TenantParameters_TenantId_Code_IsActive") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("TenantParameters", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyReference") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IdpStrategyId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsManagementOwner") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationTypeId") + .HasColumnType("integer"); + + b.Property("ParentTenantId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("IsManagementOwner") + .IsUnique() + .HasDatabaseName("IX_Tenants_SingleManagementOwner") + .HasFilter("\"IsManagementOwner\" = true"); + + b.HasIndex("ParentTenantId"); + + b.ToTable("Tenants", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantSignupRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedTenantId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyReference") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyReference") + .IsUnique(); + + b.HasIndex("ContactEmail"); + + b.HasIndex("StatusId"); + + b.ToTable("TenantSignupRequests", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MethodId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId", "MethodId"); + + b.ToTable("UserAccountMfaEnrollments", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.ToTable("UserAccountPasswordCredentials", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IdentityReference") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IdentityReferenceTypeId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Email") + .IsUnique(); + + b.ToTable("UserAccounts", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserManagementDelegationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedActionsJson") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DelegatedAdminId") + .HasColumnType("uuid"); + + b.Property("DelegatingAdminId") + .HasColumnType("uuid"); + + b.Property("MaxDurationDays") + .HasColumnType("integer"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RevocationReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("uuid"); + + b.Property("ScopeTypeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DelegatedAdminId"); + + b.HasIndex("DelegatingAdminId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("UserManagementDelegations", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", "UserDocument") + .WithMany("Notifications") + .HasForeignKey("UserDocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserDocument"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", null) + .WithMany("RequiredDocuments") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", "Template") + .WithMany("Items") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", "Profile") + .WithMany("Permissions") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("ParentRoleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Actions") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("AppSettings") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("DomainResources") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteMenuRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") + .WithMany("Menus") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Module"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Modules") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Node") + .WithMany("Actions") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") + .WithMany("Nodes") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Parent") + .WithMany("Children") + .HasForeignKey("ParentNodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Module"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteOptionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteSubMenuRecord", "SubMenu") + .WithMany("Options") + .HasForeignKey("SubMenuId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SubMenu"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteSubMenuRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteMenuRecord", "Menu") + .WithMany("SubMenus") + .HasForeignKey("MenuId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Menu"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("Criteria") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("EvaluationLogs") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("Branches") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("IdentityProviders") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("MfaEnrollments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("PasswordCredentials") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Navigation("RequiredDocuments"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Navigation("Notifications"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteMenuRecord", b => + { + b.Navigation("SubMenus"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Navigation("Menus"); + + b.Navigation("Nodes"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("Children"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("AppSettings"); + + b.Navigation("DomainResources"); + + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteSubMenuRecord", b => + { + b.Navigation("Options"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Navigation("Criteria"); + + b.Navigation("EvaluationLogs"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Navigation("Branches"); + + b.Navigation("IdentityProviders"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Navigation("MfaEnrollments"); + + b.Navigation("PasswordCredentials"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260715165202_AddSystemSuiteNodes.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260715165202_AddSystemSuiteNodes.cs new file mode 100644 index 00000000..5a035311 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260715165202_AddSystemSuiteNodes.cs @@ -0,0 +1,116 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + public partial class AddSystemSuiteNodes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "SystemSuiteNodes", + schema: "ums_authorization", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ModuleId = table.Column(type: "uuid", nullable: false), + ParentNodeId = table.Column(type: "uuid", nullable: true), + NodeKindId = table.Column(type: "integer", nullable: false), + Code = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Label = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Description = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: false), + StatusId = table.Column(type: "integer", nullable: false), + SortOrder = table.Column(type: "integer", nullable: false), + Responsable = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + Criticidad = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + ProductoImpactado = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + ComponenteTecnico = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + Dependencias = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true), + Evidencias = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true), + TrazabilidadSdlc = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true), + CreatedBy = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedBy = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + UpdatedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + AuditTimeSpan = table.Column(type: "character varying(100)", maxLength: 100, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SystemSuiteNodes", x => x.Id); + table.ForeignKey( + name: "FK_SystemSuiteNodes_SystemSuiteModules_ModuleId", + column: x => x.ModuleId, + principalSchema: "ums_authorization", + principalTable: "SystemSuiteModules", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_SystemSuiteNodes_SystemSuiteNodes_ParentNodeId", + column: x => x.ParentNodeId, + principalSchema: "ums_authorization", + principalTable: "SystemSuiteNodes", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "SystemSuiteNodeActions", + schema: "ums_authorization", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + NodeId = table.Column(type: "uuid", nullable: false), + ActionCode = table.Column(type: "character varying(100)", maxLength: 100, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SystemSuiteNodeActions", x => x.Id); + table.ForeignKey( + name: "FK_SystemSuiteNodeActions_SystemSuiteNodes_NodeId", + column: x => x.NodeId, + principalSchema: "ums_authorization", + principalTable: "SystemSuiteNodes", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_SystemSuiteNodeActions_NodeId_ActionCode", + schema: "ums_authorization", + table: "SystemSuiteNodeActions", + columns: new[] { "NodeId", "ActionCode" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_SystemSuiteNodes_ModuleId_ParentNodeId_Code", + schema: "ums_authorization", + table: "SystemSuiteNodes", + columns: new[] { "ModuleId", "ParentNodeId", "Code" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_SystemSuiteNodes_ParentNodeId", + schema: "ums_authorization", + table: "SystemSuiteNodes", + column: "ParentNodeId"); + } + + /// + #pragma warning disable S1186 + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "SystemSuiteNodeActions", + schema: "ums_authorization"); + + migrationBuilder.DropTable( + name: "SystemSuiteNodes", + schema: "ums_authorization"); + } +#pragma warning restore S1186 + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260715171139_ProjectHierarchyToNodes.Designer.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260715171139_ProjectHierarchyToNodes.Designer.cs new file mode 100644 index 00000000..345eb8c9 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260715171139_ProjectHierarchyToNodes.Designer.cs @@ -0,0 +1,2966 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Ums.Infrastructure.Persistence; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + [DbContext(typeof(UmsPlatformDbContext))] + [Migration("20260715171139_ProjectHierarchyToNodes")] + partial class ProjectHierarchyToNodes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ums_platform") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessEnforcementPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EnforcementActionId") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ProfileId") + .HasFilter("\"ProfileId\" IS NOT NULL"); + + b.HasIndex("TenantId", "RoleId") + .HasFilter("\"RoleId\" IS NOT NULL"); + + b.ToTable("AccessEnforcementPolicies", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("DaysRemaining") + .HasColumnType("integer"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Step") + .HasColumnType("integer"); + + b.Property("UserDocumentId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserDocumentId"); + + b.HasIndex("UserDocumentId", "Step") + .IsUnique(); + + b.ToTable("UserDocumentNotifications", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GrantedRoleId") + .HasColumnType("uuid"); + + b.Property("Justification") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RequestedBranchId") + .HasColumnType("uuid"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("RequestedSystemId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetProfileId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetProfileId"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("TargetUserId", "RequestedSystemId", "RequestedBranchId", "StatusId"); + + b.ToTable("ApprovalRequests", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DocumentTypeId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("WorkflowId", "DocumentTypeId") + .IsUnique(); + + b.ToTable("ApprovalRequiredDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TargetUserCategoryId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("ApprovalWorkflows", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.DocumentTypeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("DocumentTypes", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.NotificationRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("NotificationRules", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("FileChecksum") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("FileStoragePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IssueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NotificationStep") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("StatusId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "DocumentTypeId"); + + b.ToTable("UserDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Audit.Entities.AuditRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AffectedEntityId") + .HasColumnType("uuid"); + + b.Property("AffectedEntityType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AuditResultId") + .HasColumnType("integer"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Metadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RootTenantId") + .HasColumnType("uuid"); + + b.Property("SubjectTypeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WhatChanged") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("WhenOccurred") + .HasColumnType("timestamp with time zone"); + + b.Property("WhoActed") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AffectedEntityId"); + + b.HasIndex("EventType"); + + b.HasIndex("RootTenantId"); + + b.HasIndex("WhoActed"); + + b.HasIndex("AffectedEntityId", "AffectedEntityType"); + + b.ToTable("AuditRecords", "audit"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "TargetTypeId", "TargetId", "ActionId") + .IsUnique(); + + b.ToTable("PermissionTemplateItems", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "SystemSuiteId", "Version") + .IsUnique(); + + b.ToTable("PermissionTemplates", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("IsOverride") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("ProfileId", "TemplateId", "ActionId", "TargetId"); + + b.ToTable("ProfilePermissions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId", "BranchId"); + + b.ToTable("Profiles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HierarchyLevel") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ParentRoleId") + .HasColumnType("uuid"); + + b.Property("PromotionOrder") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("ParentRoleId"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("Roles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "ConfigKey", "ScopeId") + .IsUnique(); + + b.ToTable("SystemSuiteAppSettings", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteDomainResources", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteMenuRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ModuleId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteMenus", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteModules", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "ActionCode") + .IsUnique(); + + b.ToTable("SystemSuiteNodeActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ComponenteTecnico") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Criticidad") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Dependencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Evidencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("NodeKindId") + .HasColumnType("integer"); + + b.Property("ParentNodeId") + .HasColumnType("uuid"); + + b.Property("ProductoImpactado") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Responsable") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TrazabilidadSdlc") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ParentNodeId"); + + b.HasIndex("ModuleId", "ParentNodeId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteNodes", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteOptionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("SubMenuId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SubMenuId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteOptions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("SystemSuites", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteSubMenuRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("MenuId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("MenuId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteSubMenus", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.TemplateAssignmentRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateAssignmentRules", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.AppConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsEncrypted") + .HasColumnType("boolean"); + + b.Property("IsInheritable") + .HasColumnType("boolean"); + + b.Property("IsNonOverridable") + .HasColumnType("boolean"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ModuleId", "Code") + .IsUnique(); + + b.ToTable("AppConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CriteriaType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagCriteria", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Result") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("EvaluatedAtUtc"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagEvaluationLogs", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagTargets") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FlagTypeId") + .HasColumnType("integer"); + + b.Property("LinkedResourceId") + .HasColumnType("uuid"); + + b.Property("LinkedResourceTypeId") + .HasColumnType("integer"); + + b.Property("RolloutPercentage") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("FlagTypeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("SystemSuiteId", "FlagCode") + .IsUnique(); + + b.ToTable("FeatureFlags", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.IdpConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigPayload") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DomainHintsJson") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FallbackToId") + .HasColumnType("uuid"); + + b.Property("ProviderTypeId") + .HasColumnType("integer"); + + b.Property("ResolutionPriority") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SecretRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderTypeId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ResolutionPriority"); + + b.ToTable("IdpConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DataTypeId") + .HasColumnType("integer"); + + b.Property("DefaultValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive"); + + b.HasIndex("ScopeId"); + + b.ToTable("ParameterDefinitions", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterGlobalValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EffectiveValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ParameterDefinitionId") + .IsUnique(); + + b.HasIndex("StatusId"); + + b.ToTable("ParameterGlobalValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterTenantValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ParameterDefinitionId") + .IsUnique(); + + b.ToTable("ParameterTenantValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("GeofencingMetadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantBranches", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StrategyId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantIdentityProviders", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantParameterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedValues") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultValue") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsSensitive") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ValueTypeId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code", "IsActive") + .IsUnique() + .HasDatabaseName("IX_TenantParameters_TenantId_Code_IsActive") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("TenantParameters", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyReference") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IdpStrategyId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsManagementOwner") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationTypeId") + .HasColumnType("integer"); + + b.Property("ParentTenantId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("IsManagementOwner") + .IsUnique() + .HasDatabaseName("IX_Tenants_SingleManagementOwner") + .HasFilter("\"IsManagementOwner\" = true"); + + b.HasIndex("ParentTenantId"); + + b.ToTable("Tenants", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantSignupRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedTenantId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyReference") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyReference") + .IsUnique(); + + b.HasIndex("ContactEmail"); + + b.HasIndex("StatusId"); + + b.ToTable("TenantSignupRequests", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MethodId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId", "MethodId"); + + b.ToTable("UserAccountMfaEnrollments", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.ToTable("UserAccountPasswordCredentials", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IdentityReference") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IdentityReferenceTypeId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Email") + .IsUnique(); + + b.ToTable("UserAccounts", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserManagementDelegationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedActionsJson") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DelegatedAdminId") + .HasColumnType("uuid"); + + b.Property("DelegatingAdminId") + .HasColumnType("uuid"); + + b.Property("MaxDurationDays") + .HasColumnType("integer"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RevocationReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("uuid"); + + b.Property("ScopeTypeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DelegatedAdminId"); + + b.HasIndex("DelegatingAdminId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("UserManagementDelegations", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", "UserDocument") + .WithMany("Notifications") + .HasForeignKey("UserDocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserDocument"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", null) + .WithMany("RequiredDocuments") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", "Template") + .WithMany("Items") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", "Profile") + .WithMany("Permissions") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("ParentRoleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Actions") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("AppSettings") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("DomainResources") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteMenuRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") + .WithMany("Menus") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Module"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Modules") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Node") + .WithMany("Actions") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") + .WithMany("Nodes") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Parent") + .WithMany("Children") + .HasForeignKey("ParentNodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Module"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteOptionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteSubMenuRecord", "SubMenu") + .WithMany("Options") + .HasForeignKey("SubMenuId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SubMenu"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteSubMenuRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteMenuRecord", "Menu") + .WithMany("SubMenus") + .HasForeignKey("MenuId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Menu"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("Criteria") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("EvaluationLogs") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("Branches") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("IdentityProviders") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("MfaEnrollments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("PasswordCredentials") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Navigation("RequiredDocuments"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Navigation("Notifications"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteMenuRecord", b => + { + b.Navigation("SubMenus"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Navigation("Menus"); + + b.Navigation("Nodes"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("Children"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("AppSettings"); + + b.Navigation("DomainResources"); + + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteSubMenuRecord", b => + { + b.Navigation("Options"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Navigation("Criteria"); + + b.Navigation("EvaluationLogs"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Navigation("Branches"); + + b.Navigation("IdentityProviders"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Navigation("MfaEnrollments"); + + b.Navigation("PasswordCredentials"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260715171139_ProjectHierarchyToNodes.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260715171139_ProjectHierarchyToNodes.cs new file mode 100644 index 00000000..403265b4 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260715171139_ProjectHierarchyToNodes.cs @@ -0,0 +1,82 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + /// Proyección de datos (ADR-0090): copia la jerarquía rígida + /// Menús → Submenús → Opciones al árbol recursivo `SystemSuiteNodes`, + /// reusando los `Id` para que la jerarquía se enlace sola + /// (submenu.MenuId ⇒ nodo-menú; option.SubMenuId ⇒ nodo-submenú) y las + /// funcionalidades de las opciones al puente N:M `SystemSuiteNodeActions`. + /// Idempotente (NOT EXISTS). No borra el modelo rígido (se retira en Fase E). + /// + public partial class ProjectHierarchyToNodes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // 1) Menús → nodos raíz (NodeKindId = 1, sin padre). + migrationBuilder.Sql(@" +INSERT INTO ums_authorization.""SystemSuiteNodes"" + (""Id"", ""ModuleId"", ""ParentNodeId"", ""NodeKindId"", ""Code"", ""Label"", ""Description"", + ""StatusId"", ""SortOrder"", ""CreatedBy"", ""CreatedAtUtc"", ""UpdatedBy"", ""UpdatedAtUtc"", ""AuditTimeSpan"") +SELECT m.""Id"", m.""ModuleId"", NULL, 1, m.""Code"", m.""Label"", m.""Description"", + 1, m.""SortOrder"", m.""CreatedBy"", m.""CreatedAtUtc"", m.""UpdatedBy"", m.""UpdatedAtUtc"", m.""AuditTimeSpan"" +FROM ums_authorization.""SystemSuiteMenus"" m +WHERE NOT EXISTS (SELECT 1 FROM ums_authorization.""SystemSuiteNodes"" n WHERE n.""Id"" = m.""Id"");"); + + // 2) Submenús → nodos (NodeKindId = 2, padre = nodo-menú; módulo del menú). + migrationBuilder.Sql(@" +INSERT INTO ums_authorization.""SystemSuiteNodes"" + (""Id"", ""ModuleId"", ""ParentNodeId"", ""NodeKindId"", ""Code"", ""Label"", ""Description"", + ""StatusId"", ""SortOrder"", ""CreatedBy"", ""CreatedAtUtc"", ""UpdatedBy"", ""UpdatedAtUtc"", ""AuditTimeSpan"") +SELECT sm.""Id"", mn.""ModuleId"", sm.""MenuId"", 2, sm.""Code"", sm.""Label"", sm.""Description"", + 1, sm.""SortOrder"", sm.""CreatedBy"", sm.""CreatedAtUtc"", sm.""UpdatedBy"", sm.""UpdatedAtUtc"", sm.""AuditTimeSpan"" +FROM ums_authorization.""SystemSuiteSubMenus"" sm +JOIN ums_authorization.""SystemSuiteMenus"" mn ON mn.""Id"" = sm.""MenuId"" +WHERE NOT EXISTS (SELECT 1 FROM ums_authorization.""SystemSuiteNodes"" n WHERE n.""Id"" = sm.""Id"");"); + + // 3) Opciones → nodos hoja (NodeKindId = 3, padre = nodo-submenú; módulo del menú). + migrationBuilder.Sql(@" +INSERT INTO ums_authorization.""SystemSuiteNodes"" + (""Id"", ""ModuleId"", ""ParentNodeId"", ""NodeKindId"", ""Code"", ""Label"", ""Description"", + ""StatusId"", ""SortOrder"", ""CreatedBy"", ""CreatedAtUtc"", ""UpdatedBy"", ""UpdatedAtUtc"", ""AuditTimeSpan"") +SELECT o.""Id"", mn.""ModuleId"", o.""SubMenuId"", 3, o.""Code"", o.""Label"", o.""Description"", + 1, o.""SortOrder"", o.""CreatedBy"", o.""CreatedAtUtc"", o.""UpdatedBy"", o.""UpdatedAtUtc"", o.""AuditTimeSpan"" +FROM ums_authorization.""SystemSuiteOptions"" o +JOIN ums_authorization.""SystemSuiteSubMenus"" sm ON sm.""Id"" = o.""SubMenuId"" +JOIN ums_authorization.""SystemSuiteMenus"" mn ON mn.""Id"" = sm.""MenuId"" +WHERE NOT EXISTS (SELECT 1 FROM ums_authorization.""SystemSuiteNodes"" n WHERE n.""Id"" = o.""Id"");"); + + // 4) ActionCode de cada opción → puente N:M. + migrationBuilder.Sql(@" +INSERT INTO ums_authorization.""SystemSuiteNodeActions"" (""Id"", ""NodeId"", ""ActionCode"") +SELECT gen_random_uuid(), o.""Id"", o.""ActionCode"" +FROM ums_authorization.""SystemSuiteOptions"" o +WHERE o.""ActionCode"" IS NOT NULL AND o.""ActionCode"" <> '' +AND NOT EXISTS ( + SELECT 1 FROM ums_authorization.""SystemSuiteNodeActions"" na + WHERE na.""NodeId"" = o.""Id"" AND na.""ActionCode"" = o.""ActionCode"");"); + } + + /// + #pragma warning disable S1186 + protected override void Down(MigrationBuilder migrationBuilder) + { + // Revierte solo los nodos proyectados (cuyo Id proviene del modelo rígido). + migrationBuilder.Sql(@" +DELETE FROM ums_authorization.""SystemSuiteNodeActions"" na +USING ums_authorization.""SystemSuiteOptions"" o +WHERE na.""NodeId"" = o.""Id"";"); + + migrationBuilder.Sql(@" +DELETE FROM ums_authorization.""SystemSuiteNodes"" n +WHERE n.""Id"" IN (SELECT ""Id"" FROM ums_authorization.""SystemSuiteOptions"") + OR n.""Id"" IN (SELECT ""Id"" FROM ums_authorization.""SystemSuiteSubMenus"") + OR n.""Id"" IN (SELECT ""Id"" FROM ums_authorization.""SystemSuiteMenus"");"); + } +#pragma warning restore S1186 + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260715184938_DropSystemSuiteMenusSubMenusOptions.Designer.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260715184938_DropSystemSuiteMenusSubMenusOptions.Designer.cs new file mode 100644 index 00000000..4f45ab80 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260715184938_DropSystemSuiteMenusSubMenusOptions.Designer.cs @@ -0,0 +1,2751 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Ums.Infrastructure.Persistence; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + [DbContext(typeof(UmsPlatformDbContext))] + [Migration("20260715184938_DropSystemSuiteMenusSubMenusOptions")] + partial class DropSystemSuiteMenusSubMenusOptions + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ums_platform") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessEnforcementPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EnforcementActionId") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ProfileId") + .HasFilter("\"ProfileId\" IS NOT NULL"); + + b.HasIndex("TenantId", "RoleId") + .HasFilter("\"RoleId\" IS NOT NULL"); + + b.ToTable("AccessEnforcementPolicies", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("DaysRemaining") + .HasColumnType("integer"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Step") + .HasColumnType("integer"); + + b.Property("UserDocumentId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserDocumentId"); + + b.HasIndex("UserDocumentId", "Step") + .IsUnique(); + + b.ToTable("UserDocumentNotifications", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GrantedRoleId") + .HasColumnType("uuid"); + + b.Property("Justification") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RequestedBranchId") + .HasColumnType("uuid"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("RequestedSystemId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetProfileId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetProfileId"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("TargetUserId", "RequestedSystemId", "RequestedBranchId", "StatusId"); + + b.ToTable("ApprovalRequests", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DocumentTypeId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("WorkflowId", "DocumentTypeId") + .IsUnique(); + + b.ToTable("ApprovalRequiredDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TargetUserCategoryId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("ApprovalWorkflows", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.DocumentTypeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("DocumentTypes", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.NotificationRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("NotificationRules", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("FileChecksum") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("FileStoragePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IssueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NotificationStep") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("StatusId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "DocumentTypeId"); + + b.ToTable("UserDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Audit.Entities.AuditRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AffectedEntityId") + .HasColumnType("uuid"); + + b.Property("AffectedEntityType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AuditResultId") + .HasColumnType("integer"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Metadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RootTenantId") + .HasColumnType("uuid"); + + b.Property("SubjectTypeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WhatChanged") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("WhenOccurred") + .HasColumnType("timestamp with time zone"); + + b.Property("WhoActed") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AffectedEntityId"); + + b.HasIndex("EventType"); + + b.HasIndex("RootTenantId"); + + b.HasIndex("WhoActed"); + + b.HasIndex("AffectedEntityId", "AffectedEntityType"); + + b.ToTable("AuditRecords", "audit"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "TargetTypeId", "TargetId", "ActionId") + .IsUnique(); + + b.ToTable("PermissionTemplateItems", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "SystemSuiteId", "Version") + .IsUnique(); + + b.ToTable("PermissionTemplates", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("IsOverride") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("ProfileId", "TemplateId", "ActionId", "TargetId"); + + b.ToTable("ProfilePermissions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId", "BranchId"); + + b.ToTable("Profiles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HierarchyLevel") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ParentRoleId") + .HasColumnType("uuid"); + + b.Property("PromotionOrder") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("ParentRoleId"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("Roles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "ConfigKey", "ScopeId") + .IsUnique(); + + b.ToTable("SystemSuiteAppSettings", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteDomainResources", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteModules", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "ActionCode") + .IsUnique(); + + b.ToTable("SystemSuiteNodeActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ComponenteTecnico") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Criticidad") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Dependencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Evidencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("NodeKindId") + .HasColumnType("integer"); + + b.Property("ParentNodeId") + .HasColumnType("uuid"); + + b.Property("ProductoImpactado") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Responsable") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TrazabilidadSdlc") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ParentNodeId"); + + b.HasIndex("ModuleId", "ParentNodeId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteNodes", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("SystemSuites", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.TemplateAssignmentRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateAssignmentRules", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.AppConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsEncrypted") + .HasColumnType("boolean"); + + b.Property("IsInheritable") + .HasColumnType("boolean"); + + b.Property("IsNonOverridable") + .HasColumnType("boolean"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ModuleId", "Code") + .IsUnique(); + + b.ToTable("AppConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CriteriaType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagCriteria", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Result") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("EvaluatedAtUtc"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagEvaluationLogs", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagTargets") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FlagTypeId") + .HasColumnType("integer"); + + b.Property("LinkedResourceId") + .HasColumnType("uuid"); + + b.Property("LinkedResourceTypeId") + .HasColumnType("integer"); + + b.Property("RolloutPercentage") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("FlagTypeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("SystemSuiteId", "FlagCode") + .IsUnique(); + + b.ToTable("FeatureFlags", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.IdpConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigPayload") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DomainHintsJson") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FallbackToId") + .HasColumnType("uuid"); + + b.Property("ProviderTypeId") + .HasColumnType("integer"); + + b.Property("ResolutionPriority") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SecretRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderTypeId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ResolutionPriority"); + + b.ToTable("IdpConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DataTypeId") + .HasColumnType("integer"); + + b.Property("DefaultValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive"); + + b.HasIndex("ScopeId"); + + b.ToTable("ParameterDefinitions", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterGlobalValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EffectiveValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ParameterDefinitionId") + .IsUnique(); + + b.HasIndex("StatusId"); + + b.ToTable("ParameterGlobalValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterTenantValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ParameterDefinitionId") + .IsUnique(); + + b.ToTable("ParameterTenantValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("GeofencingMetadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantBranches", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StrategyId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantIdentityProviders", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantParameterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedValues") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultValue") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsSensitive") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ValueTypeId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code", "IsActive") + .IsUnique() + .HasDatabaseName("IX_TenantParameters_TenantId_Code_IsActive") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("TenantParameters", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyReference") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IdpStrategyId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsManagementOwner") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationTypeId") + .HasColumnType("integer"); + + b.Property("ParentTenantId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("IsManagementOwner") + .IsUnique() + .HasDatabaseName("IX_Tenants_SingleManagementOwner") + .HasFilter("\"IsManagementOwner\" = true"); + + b.HasIndex("ParentTenantId"); + + b.ToTable("Tenants", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantSignupRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedTenantId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyReference") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyReference") + .IsUnique(); + + b.HasIndex("ContactEmail"); + + b.HasIndex("StatusId"); + + b.ToTable("TenantSignupRequests", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MethodId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId", "MethodId"); + + b.ToTable("UserAccountMfaEnrollments", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.ToTable("UserAccountPasswordCredentials", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IdentityReference") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IdentityReferenceTypeId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Email") + .IsUnique(); + + b.ToTable("UserAccounts", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserManagementDelegationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedActionsJson") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DelegatedAdminId") + .HasColumnType("uuid"); + + b.Property("DelegatingAdminId") + .HasColumnType("uuid"); + + b.Property("MaxDurationDays") + .HasColumnType("integer"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RevocationReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("uuid"); + + b.Property("ScopeTypeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DelegatedAdminId"); + + b.HasIndex("DelegatingAdminId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("UserManagementDelegations", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", "UserDocument") + .WithMany("Notifications") + .HasForeignKey("UserDocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserDocument"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", null) + .WithMany("RequiredDocuments") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", "Template") + .WithMany("Items") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", "Profile") + .WithMany("Permissions") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("ParentRoleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Actions") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("AppSettings") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("DomainResources") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Modules") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Node") + .WithMany("Actions") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") + .WithMany("Nodes") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Parent") + .WithMany("Children") + .HasForeignKey("ParentNodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Module"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("Criteria") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("EvaluationLogs") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("Branches") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("IdentityProviders") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("MfaEnrollments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("PasswordCredentials") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Navigation("RequiredDocuments"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Navigation("Notifications"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Navigation("Nodes"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("Children"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("AppSettings"); + + b.Navigation("DomainResources"); + + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Navigation("Criteria"); + + b.Navigation("EvaluationLogs"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Navigation("Branches"); + + b.Navigation("IdentityProviders"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Navigation("MfaEnrollments"); + + b.Navigation("PasswordCredentials"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260715184938_DropSystemSuiteMenusSubMenusOptions.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260715184938_DropSystemSuiteMenusSubMenusOptions.cs new file mode 100644 index 00000000..1b16b5a3 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260715184938_DropSystemSuiteMenusSubMenusOptions.cs @@ -0,0 +1,142 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + public partial class DropSystemSuiteMenusSubMenusOptions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "SystemSuiteOptions", + schema: "ums_authorization"); + + migrationBuilder.DropTable( + name: "SystemSuiteSubMenus", + schema: "ums_authorization"); + + migrationBuilder.DropTable( + name: "SystemSuiteMenus", + schema: "ums_authorization"); + } + + /// + #pragma warning disable S1186 + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "SystemSuiteMenus", + schema: "ums_authorization", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ModuleId = table.Column(type: "uuid", nullable: false), + AuditTimeSpan = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Code = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + CreatedBy = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Description = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: false), + Label = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + SortOrder = table.Column(type: "integer", nullable: false), + UpdatedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + UpdatedBy = table.Column(type: "character varying(100)", maxLength: 100, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_SystemSuiteMenus", x => x.Id); + table.ForeignKey( + name: "FK_SystemSuiteMenus_SystemSuiteModules_ModuleId", + column: x => x.ModuleId, + principalSchema: "ums_authorization", + principalTable: "SystemSuiteModules", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "SystemSuiteSubMenus", + schema: "ums_authorization", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + MenuId = table.Column(type: "uuid", nullable: false), + AuditTimeSpan = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Code = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + CreatedBy = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Description = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: false), + Label = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + SortOrder = table.Column(type: "integer", nullable: false), + UpdatedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + UpdatedBy = table.Column(type: "character varying(100)", maxLength: 100, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_SystemSuiteSubMenus", x => x.Id); + table.ForeignKey( + name: "FK_SystemSuiteSubMenus_SystemSuiteMenus_MenuId", + column: x => x.MenuId, + principalSchema: "ums_authorization", + principalTable: "SystemSuiteMenus", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "SystemSuiteOptions", + schema: "ums_authorization", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + SubMenuId = table.Column(type: "uuid", nullable: false), + ActionCode = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + AuditTimeSpan = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Code = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + CreatedBy = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Description = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: false), + Label = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + SortOrder = table.Column(type: "integer", nullable: false), + UpdatedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + UpdatedBy = table.Column(type: "character varying(100)", maxLength: 100, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_SystemSuiteOptions", x => x.Id); + table.ForeignKey( + name: "FK_SystemSuiteOptions_SystemSuiteSubMenus_SubMenuId", + column: x => x.SubMenuId, + principalSchema: "ums_authorization", + principalTable: "SystemSuiteSubMenus", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_SystemSuiteMenus_ModuleId_Code", + schema: "ums_authorization", + table: "SystemSuiteMenus", + columns: new[] { "ModuleId", "Code" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_SystemSuiteOptions_SubMenuId_Code", + schema: "ums_authorization", + table: "SystemSuiteOptions", + columns: new[] { "SubMenuId", "Code" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_SystemSuiteSubMenus_MenuId_Code", + schema: "ums_authorization", + table: "SystemSuiteSubMenus", + columns: new[] { "MenuId", "Code" }, + unique: true); + } +#pragma warning restore S1186 + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260717163004_AddUmsPlatformOutbox.Designer.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260717163004_AddUmsPlatformOutbox.Designer.cs new file mode 100644 index 00000000..0214a1c4 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260717163004_AddUmsPlatformOutbox.Designer.cs @@ -0,0 +1,3146 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Ums.Infrastructure.Persistence; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(UmsPlatformDbContext))] + [Migration("20260717163004_AddUmsPlatformOutbox")] + partial class AddUmsPlatformOutbox + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ums_platform") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessEnforcementPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EnforcementActionId") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ProfileId") + .HasFilter("\"ProfileId\" IS NOT NULL"); + + b.HasIndex("TenantId", "RoleId") + .HasFilter("\"RoleId\" IS NOT NULL"); + + b.ToTable("AccessEnforcementPolicies", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("DaysRemaining") + .HasColumnType("integer"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Step") + .HasColumnType("integer"); + + b.Property("UserDocumentId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserDocumentId"); + + b.HasIndex("UserDocumentId", "Step") + .IsUnique(); + + b.ToTable("UserDocumentNotifications", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GrantedRoleId") + .HasColumnType("uuid"); + + b.Property("Justification") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RequestedBranchId") + .HasColumnType("uuid"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("RequestedSystemId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetProfileId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetProfileId"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("TargetUserId", "RequestedSystemId", "RequestedBranchId", "StatusId"); + + b.ToTable("ApprovalRequests", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DocumentTypeId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("WorkflowId", "DocumentTypeId") + .IsUnique(); + + b.ToTable("ApprovalRequiredDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TargetUserCategoryId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("ApprovalWorkflows", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.DocumentTypeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("DocumentTypes", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.NotificationRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("NotificationRules", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("FileChecksum") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("FileStoragePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IssueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NotificationStep") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("StatusId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "DocumentTypeId"); + + b.ToTable("UserDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Audit.Entities.AuditRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AffectedEntityId") + .HasColumnType("uuid"); + + b.Property("AffectedEntityType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AuditResultId") + .HasColumnType("integer"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Metadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RootTenantId") + .HasColumnType("uuid"); + + b.Property("SubjectTypeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WhatChanged") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("WhenOccurred") + .HasColumnType("timestamp with time zone"); + + b.Property("WhoActed") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AffectedEntityId"); + + b.HasIndex("EventType"); + + b.HasIndex("RootTenantId"); + + b.HasIndex("WhoActed"); + + b.HasIndex("AffectedEntityId", "AffectedEntityType"); + + b.ToTable("AuditRecords", "audit"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "TargetTypeId", "TargetId", "ActionId") + .IsUnique(); + + b.ToTable("PermissionTemplateItems", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "SystemSuiteId", "Version") + .IsUnique(); + + b.ToTable("PermissionTemplates", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("IsOverride") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("ProfileId", "TemplateId", "ActionId", "TargetId"); + + b.ToTable("ProfilePermissions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId", "BranchId"); + + b.ToTable("Profiles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HierarchyLevel") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ParentRoleId") + .HasColumnType("uuid"); + + b.Property("PromotionOrder") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("ParentRoleId"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("Roles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "ConfigKey", "ScopeId") + .IsUnique(); + + b.ToTable("SystemSuiteAppSettings", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteDomainResources", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteModules", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "ActionCode") + .IsUnique(); + + b.ToTable("SystemSuiteNodeActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ComponenteTecnico") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Criticidad") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Dependencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Evidencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("NodeKindId") + .HasColumnType("integer"); + + b.Property("ParentNodeId") + .HasColumnType("uuid"); + + b.Property("ProductoImpactado") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Responsable") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TrazabilidadSdlc") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ParentNodeId"); + + b.HasIndex("ModuleId", "ParentNodeId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteNodes", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("SystemSuites", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.TemplateAssignmentRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateAssignmentRules", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.AppConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsEncrypted") + .HasColumnType("boolean"); + + b.Property("IsInheritable") + .HasColumnType("boolean"); + + b.Property("IsNonOverridable") + .HasColumnType("boolean"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ModuleId", "Code") + .IsUnique(); + + b.ToTable("AppConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CriteriaType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagCriteria", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Result") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("EvaluatedAtUtc"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagEvaluationLogs", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagTargets") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FlagTypeId") + .HasColumnType("integer"); + + b.Property("LinkedResourceId") + .HasColumnType("uuid"); + + b.Property("LinkedResourceTypeId") + .HasColumnType("integer"); + + b.Property("RolloutPercentage") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("FlagTypeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("SystemSuiteId", "FlagCode") + .IsUnique(); + + b.ToTable("FeatureFlags", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.IdpConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigPayload") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DomainHintsJson") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FallbackToId") + .HasColumnType("uuid"); + + b.Property("ProviderTypeId") + .HasColumnType("integer"); + + b.Property("ResolutionPriority") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SecretRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderTypeId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ResolutionPriority"); + + b.ToTable("IdpConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DataTypeId") + .HasColumnType("integer"); + + b.Property("DefaultValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive"); + + b.HasIndex("ScopeId"); + + b.ToTable("ParameterDefinitions", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterGlobalValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EffectiveValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ParameterDefinitionId") + .IsUnique(); + + b.HasIndex("StatusId"); + + b.ToTable("ParameterGlobalValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterTenantValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ParameterDefinitionId") + .IsUnique(); + + b.ToTable("ParameterTenantValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.RefreshTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("uuid"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RenewalCount") + .HasColumnType("integer"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("RefreshTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("GeofencingMetadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantBranches", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StrategyId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantIdentityProviders", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantParameterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedValues") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultValue") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsSensitive") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ValueTypeId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code", "IsActive") + .IsUnique() + .HasDatabaseName("IX_TenantParameters_TenantId_Code_IsActive") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("TenantParameters", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyReference") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IdpStrategyId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsManagementOwner") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationTypeId") + .HasColumnType("integer"); + + b.Property("ParentTenantId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("IsManagementOwner") + .IsUnique() + .HasDatabaseName("IX_Tenants_SingleManagementOwner") + .HasFilter("\"IsManagementOwner\" = true"); + + b.HasIndex("ParentTenantId"); + + b.ToTable("Tenants", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantSignupRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedTenantId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyReference") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyReference") + .IsUnique(); + + b.HasIndex("ContactEmail"); + + b.HasIndex("StatusId"); + + b.ToTable("TenantSignupRequests", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MethodId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId", "MethodId"); + + b.ToTable("UserAccountMfaEnrollments", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.ToTable("UserAccountPasswordCredentials", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IdentityReference") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IdentityReferenceTypeId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Email") + .IsUnique(); + + b.ToTable("UserAccounts", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserManagementDelegationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedActionsJson") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DelegatedAdminId") + .HasColumnType("uuid"); + + b.Property("DelegatingAdminId") + .HasColumnType("uuid"); + + b.Property("MaxDurationDays") + .HasColumnType("integer"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RevocationReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("uuid"); + + b.Property("ScopeTypeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DelegatedAdminId"); + + b.HasIndex("DelegatingAdminId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("UserManagementDelegations", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RoleMaturityStatusRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BlockingFactor") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CompletedCertificationsCount") + .HasColumnType("integer"); + + b.Property("CompletedTrainingsCount") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentLevelSince") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentMaturityLevel") + .HasColumnType("integer"); + + b.Property("EligibleForPromotionAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HasNoComplianceIssues") + .HasColumnType("boolean"); + + b.Property("LastReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NextEligibleMaturityLevel") + .HasColumnType("integer"); + + b.Property("PerformanceScore") + .HasColumnType("numeric(4,2)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId") + .IsUnique(); + + b.ToTable("RoleMaturityStatuses", "iga"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RolePromotionRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApproverId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentRoleId") + .HasColumnType("uuid"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ExecutorId") + .HasColumnType("uuid"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("RiskScore") + .HasColumnType("integer"); + + b.Property("SecurityReviewerId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetRoleId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("VerifierId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("RolePromotionRequests", "iga"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", "UserDocument") + .WithMany("Notifications") + .HasForeignKey("UserDocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserDocument"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", null) + .WithMany("RequiredDocuments") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", "Template") + .WithMany("Items") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", "Profile") + .WithMany("Permissions") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("ParentRoleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Actions") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("AppSettings") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("DomainResources") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Modules") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Node") + .WithMany("Actions") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") + .WithMany("Nodes") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Parent") + .WithMany("Children") + .HasForeignKey("ParentNodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Module"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("Criteria") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("EvaluationLogs") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("Branches") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("IdentityProviders") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("MfaEnrollments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("PasswordCredentials") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Navigation("RequiredDocuments"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Navigation("Notifications"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Navigation("Nodes"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("Children"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("AppSettings"); + + b.Navigation("DomainResources"); + + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Navigation("Criteria"); + + b.Navigation("EvaluationLogs"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Navigation("Branches"); + + b.Navigation("IdentityProviders"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Navigation("MfaEnrollments"); + + b.Navigation("PasswordCredentials"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260717163004_AddUmsPlatformOutbox.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260717163004_AddUmsPlatformOutbox.cs new file mode 100644 index 00000000..c0051455 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260717163004_AddUmsPlatformOutbox.cs @@ -0,0 +1,158 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddUmsPlatformOutbox : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "InboxState", + schema: "ums_platform", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + MessageId = table.Column(type: "uuid", nullable: false), + ConsumerId = table.Column(type: "uuid", nullable: false), + LockId = table.Column(type: "uuid", nullable: false), + RowVersion = table.Column(type: "bytea", nullable: true, defaultValueSql: "gen_random_bytes(8)"), + Received = table.Column(type: "timestamp with time zone", nullable: false), + ReceiveCount = table.Column(type: "integer", nullable: false), + ExpirationTime = table.Column(type: "timestamp with time zone", nullable: true), + Consumed = table.Column(type: "timestamp with time zone", nullable: true), + Delivered = table.Column(type: "timestamp with time zone", nullable: true), + LastSequenceNumber = table.Column(type: "bigint", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_InboxState", x => x.Id); + table.UniqueConstraint("AK_InboxState_MessageId_ConsumerId", x => new { x.MessageId, x.ConsumerId }); + }); + + migrationBuilder.CreateTable( + name: "OutboxState", + schema: "ums_platform", + columns: table => new + { + OutboxId = table.Column(type: "uuid", nullable: false), + LockId = table.Column(type: "uuid", nullable: false), + RowVersion = table.Column(type: "bytea", nullable: true, defaultValueSql: "gen_random_bytes(8)"), + Created = table.Column(type: "timestamp with time zone", nullable: false), + Delivered = table.Column(type: "timestamp with time zone", nullable: true), + LastSequenceNumber = table.Column(type: "bigint", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OutboxState", x => x.OutboxId); + }); + + migrationBuilder.CreateTable( + name: "OutboxMessage", + schema: "ums_platform", + columns: table => new + { + SequenceNumber = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + EnqueueTime = table.Column(type: "timestamp with time zone", nullable: true), + SentTime = table.Column(type: "timestamp with time zone", nullable: false), + Headers = table.Column(type: "text", nullable: true), + Properties = table.Column(type: "text", nullable: true), + InboxMessageId = table.Column(type: "uuid", nullable: true), + InboxConsumerId = table.Column(type: "uuid", nullable: true), + OutboxId = table.Column(type: "uuid", nullable: true), + MessageId = table.Column(type: "uuid", nullable: false), + ContentType = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + MessageType = table.Column(type: "text", nullable: false), + Body = table.Column(type: "text", nullable: false), + ConversationId = table.Column(type: "uuid", nullable: true), + CorrelationId = table.Column(type: "uuid", nullable: true), + InitiatorId = table.Column(type: "uuid", nullable: true), + RequestId = table.Column(type: "uuid", nullable: true), + SourceAddress = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + DestinationAddress = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + ResponseAddress = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + FaultAddress = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + ExpirationTime = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OutboxMessage", x => x.SequenceNumber); + table.ForeignKey( + name: "FK_OutboxMessage_InboxState_InboxMessageId_InboxConsumerId", + columns: x => new { x.InboxMessageId, x.InboxConsumerId }, + principalSchema: "ums_platform", + principalTable: "InboxState", + principalColumns: new[] { "MessageId", "ConsumerId" }); + table.ForeignKey( + name: "FK_OutboxMessage_OutboxState_OutboxId", + column: x => x.OutboxId, + principalSchema: "ums_platform", + principalTable: "OutboxState", + principalColumn: "OutboxId"); + }); + + migrationBuilder.CreateIndex( + name: "IX_InboxState_Delivered", + schema: "ums_platform", + table: "InboxState", + column: "Delivered"); + + migrationBuilder.CreateIndex( + name: "IX_OutboxMessage_EnqueueTime", + schema: "ums_platform", + table: "OutboxMessage", + column: "EnqueueTime"); + + migrationBuilder.CreateIndex( + name: "IX_OutboxMessage_ExpirationTime", + schema: "ums_platform", + table: "OutboxMessage", + column: "ExpirationTime"); + + migrationBuilder.CreateIndex( + name: "IX_OutboxMessage_InboxMessageId_InboxConsumerId_SequenceNumber", + schema: "ums_platform", + table: "OutboxMessage", + columns: new[] { "InboxMessageId", "InboxConsumerId", "SequenceNumber" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_OutboxMessage_OutboxId_SequenceNumber", + schema: "ums_platform", + table: "OutboxMessage", + columns: new[] { "OutboxId", "SequenceNumber" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_OutboxState_Created", + schema: "ums_platform", + table: "OutboxState", + column: "Created"); + } + + /// + #pragma warning disable S1186 + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "OutboxMessage", + schema: "ums_platform"); + + migrationBuilder.DropTable( + name: "InboxState", + schema: "ums_platform"); + + migrationBuilder.DropTable( + name: "OutboxState", + schema: "ums_platform"); + } +#pragma warning restore S1186 + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260720152552_EnableRowLevelSecurity.Designer.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260720152552_EnableRowLevelSecurity.Designer.cs new file mode 100644 index 00000000..08bae661 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260720152552_EnableRowLevelSecurity.Designer.cs @@ -0,0 +1,3146 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Ums.Infrastructure.Persistence; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(UmsPlatformDbContext))] + [Migration("20260720152552_EnableRowLevelSecurity")] + partial class EnableRowLevelSecurity + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ums_platform") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessEnforcementPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EnforcementActionId") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ProfileId") + .HasFilter("\"ProfileId\" IS NOT NULL"); + + b.HasIndex("TenantId", "RoleId") + .HasFilter("\"RoleId\" IS NOT NULL"); + + b.ToTable("AccessEnforcementPolicies", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("DaysRemaining") + .HasColumnType("integer"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Step") + .HasColumnType("integer"); + + b.Property("UserDocumentId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserDocumentId"); + + b.HasIndex("UserDocumentId", "Step") + .IsUnique(); + + b.ToTable("UserDocumentNotifications", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GrantedRoleId") + .HasColumnType("uuid"); + + b.Property("Justification") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RequestedBranchId") + .HasColumnType("uuid"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("RequestedSystemId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetProfileId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetProfileId"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("TargetUserId", "RequestedSystemId", "RequestedBranchId", "StatusId"); + + b.ToTable("ApprovalRequests", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DocumentTypeId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("WorkflowId", "DocumentTypeId") + .IsUnique(); + + b.ToTable("ApprovalRequiredDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TargetUserCategoryId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("ApprovalWorkflows", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.DocumentTypeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("DocumentTypes", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.NotificationRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("NotificationRules", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("FileChecksum") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("FileStoragePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IssueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NotificationStep") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("StatusId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "DocumentTypeId"); + + b.ToTable("UserDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Audit.Entities.AuditRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AffectedEntityId") + .HasColumnType("uuid"); + + b.Property("AffectedEntityType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AuditResultId") + .HasColumnType("integer"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Metadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RootTenantId") + .HasColumnType("uuid"); + + b.Property("SubjectTypeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WhatChanged") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("WhenOccurred") + .HasColumnType("timestamp with time zone"); + + b.Property("WhoActed") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AffectedEntityId"); + + b.HasIndex("EventType"); + + b.HasIndex("RootTenantId"); + + b.HasIndex("WhoActed"); + + b.HasIndex("AffectedEntityId", "AffectedEntityType"); + + b.ToTable("AuditRecords", "audit"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "TargetTypeId", "TargetId", "ActionId") + .IsUnique(); + + b.ToTable("PermissionTemplateItems", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "SystemSuiteId", "Version") + .IsUnique(); + + b.ToTable("PermissionTemplates", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("IsOverride") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("ProfileId", "TemplateId", "ActionId", "TargetId"); + + b.ToTable("ProfilePermissions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId", "BranchId"); + + b.ToTable("Profiles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HierarchyLevel") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ParentRoleId") + .HasColumnType("uuid"); + + b.Property("PromotionOrder") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("ParentRoleId"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("Roles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "ConfigKey", "ScopeId") + .IsUnique(); + + b.ToTable("SystemSuiteAppSettings", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteDomainResources", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteModules", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "ActionCode") + .IsUnique(); + + b.ToTable("SystemSuiteNodeActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ComponenteTecnico") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Criticidad") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Dependencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Evidencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("NodeKindId") + .HasColumnType("integer"); + + b.Property("ParentNodeId") + .HasColumnType("uuid"); + + b.Property("ProductoImpactado") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Responsable") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TrazabilidadSdlc") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ParentNodeId"); + + b.HasIndex("ModuleId", "ParentNodeId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteNodes", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("SystemSuites", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.TemplateAssignmentRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateAssignmentRules", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.AppConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsEncrypted") + .HasColumnType("boolean"); + + b.Property("IsInheritable") + .HasColumnType("boolean"); + + b.Property("IsNonOverridable") + .HasColumnType("boolean"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ModuleId", "Code") + .IsUnique(); + + b.ToTable("AppConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CriteriaType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagCriteria", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Result") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("EvaluatedAtUtc"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagEvaluationLogs", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagTargets") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FlagTypeId") + .HasColumnType("integer"); + + b.Property("LinkedResourceId") + .HasColumnType("uuid"); + + b.Property("LinkedResourceTypeId") + .HasColumnType("integer"); + + b.Property("RolloutPercentage") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("FlagTypeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("SystemSuiteId", "FlagCode") + .IsUnique(); + + b.ToTable("FeatureFlags", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.IdpConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigPayload") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DomainHintsJson") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FallbackToId") + .HasColumnType("uuid"); + + b.Property("ProviderTypeId") + .HasColumnType("integer"); + + b.Property("ResolutionPriority") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SecretRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderTypeId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ResolutionPriority"); + + b.ToTable("IdpConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DataTypeId") + .HasColumnType("integer"); + + b.Property("DefaultValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive"); + + b.HasIndex("ScopeId"); + + b.ToTable("ParameterDefinitions", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterGlobalValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EffectiveValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ParameterDefinitionId") + .IsUnique(); + + b.HasIndex("StatusId"); + + b.ToTable("ParameterGlobalValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterTenantValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ParameterDefinitionId") + .IsUnique(); + + b.ToTable("ParameterTenantValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.RefreshTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("uuid"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RenewalCount") + .HasColumnType("integer"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("RefreshTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("GeofencingMetadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantBranches", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StrategyId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantIdentityProviders", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantParameterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedValues") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultValue") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsSensitive") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ValueTypeId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code", "IsActive") + .IsUnique() + .HasDatabaseName("IX_TenantParameters_TenantId_Code_IsActive") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("TenantParameters", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyReference") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IdpStrategyId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsManagementOwner") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationTypeId") + .HasColumnType("integer"); + + b.Property("ParentTenantId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("IsManagementOwner") + .IsUnique() + .HasDatabaseName("IX_Tenants_SingleManagementOwner") + .HasFilter("\"IsManagementOwner\" = true"); + + b.HasIndex("ParentTenantId"); + + b.ToTable("Tenants", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantSignupRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedTenantId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyReference") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyReference") + .IsUnique(); + + b.HasIndex("ContactEmail"); + + b.HasIndex("StatusId"); + + b.ToTable("TenantSignupRequests", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MethodId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId", "MethodId"); + + b.ToTable("UserAccountMfaEnrollments", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.ToTable("UserAccountPasswordCredentials", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IdentityReference") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IdentityReferenceTypeId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Email") + .IsUnique(); + + b.ToTable("UserAccounts", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserManagementDelegationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedActionsJson") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DelegatedAdminId") + .HasColumnType("uuid"); + + b.Property("DelegatingAdminId") + .HasColumnType("uuid"); + + b.Property("MaxDurationDays") + .HasColumnType("integer"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RevocationReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("uuid"); + + b.Property("ScopeTypeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DelegatedAdminId"); + + b.HasIndex("DelegatingAdminId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("UserManagementDelegations", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RoleMaturityStatusRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BlockingFactor") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CompletedCertificationsCount") + .HasColumnType("integer"); + + b.Property("CompletedTrainingsCount") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentLevelSince") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentMaturityLevel") + .HasColumnType("integer"); + + b.Property("EligibleForPromotionAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HasNoComplianceIssues") + .HasColumnType("boolean"); + + b.Property("LastReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NextEligibleMaturityLevel") + .HasColumnType("integer"); + + b.Property("PerformanceScore") + .HasColumnType("numeric(4,2)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId") + .IsUnique(); + + b.ToTable("RoleMaturityStatuses", "iga"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RolePromotionRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApproverId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentRoleId") + .HasColumnType("uuid"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ExecutorId") + .HasColumnType("uuid"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("RiskScore") + .HasColumnType("integer"); + + b.Property("SecurityReviewerId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetRoleId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("VerifierId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("RolePromotionRequests", "iga"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", "UserDocument") + .WithMany("Notifications") + .HasForeignKey("UserDocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserDocument"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", null) + .WithMany("RequiredDocuments") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", "Template") + .WithMany("Items") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", "Profile") + .WithMany("Permissions") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("ParentRoleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Actions") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("AppSettings") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("DomainResources") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Modules") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Node") + .WithMany("Actions") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") + .WithMany("Nodes") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Parent") + .WithMany("Children") + .HasForeignKey("ParentNodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Module"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("Criteria") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("EvaluationLogs") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("Branches") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("IdentityProviders") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("MfaEnrollments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("PasswordCredentials") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Navigation("RequiredDocuments"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Navigation("Notifications"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Navigation("Nodes"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("Children"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("AppSettings"); + + b.Navigation("DomainResources"); + + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Navigation("Criteria"); + + b.Navigation("EvaluationLogs"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Navigation("Branches"); + + b.Navigation("IdentityProviders"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Navigation("MfaEnrollments"); + + b.Navigation("PasswordCredentials"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260720152552_EnableRowLevelSecurity.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260720152552_EnableRowLevelSecurity.cs new file mode 100644 index 00000000..d82db407 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260720152552_EnableRowLevelSecurity.cs @@ -0,0 +1,86 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations +{ + /// + public partial class EnableRowLevelSecurity : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + var tables = new (string Schema, string TableName, bool IsGlobalNullable)[] + { + ("ums_identity", "TenantBranches", false), + ("ums_identity", "TenantParameters", false), + ("ums_identity", "TenantIdentityProviders", false), + ("ums_identity", "UserAccounts", false), + ("ums_authorization", "Profiles", false), + ("ums_authorization", "Roles", false), + ("ums_authorization", "SystemSuites", false), + ("ums_authorization", "PermissionTemplates", false), + ("ums_identity", "UserManagementDelegations", false), + ("ums_configuration", "IdpConfigurations", false), + ("ums_configuration", "AppConfigurations", true), + ("ums_configuration", "ParameterTenantValues", false), + ("approvals", "ApprovalWorkflows", false), + ("approvals", "NotificationRules", false), + ("approvals", "DocumentTypes", false), + ("approvals", "AccessEnforcementPolicies", false), + ("iga", "RoleMaturityStatuses", false), + ("iga", "RolePromotionRequests", false) + }; + + foreach (var t in tables) + { + migrationBuilder.Sql($@"ALTER TABLE ""{t.Schema}"".""{t.TableName}"" ENABLE ROW LEVEL SECURITY;"); + migrationBuilder.Sql($@"ALTER TABLE ""{t.Schema}"".""{t.TableName}"" FORCE ROW LEVEL SECURITY;"); + + string policyUsing = t.IsGlobalNullable + ? @"current_setting('app.current_organization_id', true) = '' OR ""TenantId"" IS NULL OR ""TenantId""::text = current_setting('app.current_organization_id', true)" + : @"current_setting('app.current_organization_id', true) = '' OR ""TenantId""::text = current_setting('app.current_organization_id', true)"; + + migrationBuilder.Sql($@" + CREATE POLICY tenant_isolation_policy ON ""{t.Schema}"".""{t.TableName}"" + FOR ALL + USING ({policyUsing}); + "); + } + } + + /// + #pragma warning disable S1186 + protected override void Down(MigrationBuilder migrationBuilder) + { + var tables = new (string Schema, string TableName)[] + { + ("ums_identity", "TenantBranches"), + ("ums_identity", "TenantParameters"), + ("ums_identity", "TenantIdentityProviders"), + ("ums_identity", "UserAccounts"), + ("ums_authorization", "Profiles"), + ("ums_authorization", "Roles"), + ("ums_authorization", "SystemSuites"), + ("ums_authorization", "PermissionTemplates"), + ("ums_identity", "UserManagementDelegations"), + ("ums_configuration", "IdpConfigurations"), + ("ums_configuration", "AppConfigurations"), + ("ums_configuration", "ParameterTenantValues"), + ("approvals", "ApprovalWorkflows"), + ("approvals", "NotificationRules"), + ("approvals", "DocumentTypes"), + ("approvals", "AccessEnforcementPolicies"), + ("iga", "RoleMaturityStatuses"), + ("iga", "RolePromotionRequests") + }; + + foreach (var t in tables) + { + migrationBuilder.Sql($@"DROP POLICY IF EXISTS tenant_isolation_policy ON ""{t.Schema}"".""{t.TableName}"";"); + migrationBuilder.Sql($@"ALTER TABLE ""{t.Schema}"".""{t.TableName}"" DISABLE ROW LEVEL SECURITY;"); + } + } +#pragma warning restore S1186 + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260720154635_FixFeatureFlagUniqueIndex.Designer.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260720154635_FixFeatureFlagUniqueIndex.Designer.cs new file mode 100644 index 00000000..cc7f31d6 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260720154635_FixFeatureFlagUniqueIndex.Designer.cs @@ -0,0 +1,3147 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Ums.Infrastructure.Persistence; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + [DbContext(typeof(UmsPlatformDbContext))] + [Migration("20260720154635_FixFeatureFlagUniqueIndex")] + partial class FixFeatureFlagUniqueIndex + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ums_platform") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessEnforcementPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EnforcementActionId") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ProfileId") + .HasFilter("\"ProfileId\" IS NOT NULL"); + + b.HasIndex("TenantId", "RoleId") + .HasFilter("\"RoleId\" IS NOT NULL"); + + b.ToTable("AccessEnforcementPolicies", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("DaysRemaining") + .HasColumnType("integer"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Step") + .HasColumnType("integer"); + + b.Property("UserDocumentId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserDocumentId"); + + b.HasIndex("UserDocumentId", "Step") + .IsUnique(); + + b.ToTable("UserDocumentNotifications", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GrantedRoleId") + .HasColumnType("uuid"); + + b.Property("Justification") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RequestedBranchId") + .HasColumnType("uuid"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("RequestedSystemId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetProfileId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetProfileId"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("TargetUserId", "RequestedSystemId", "RequestedBranchId", "StatusId"); + + b.ToTable("ApprovalRequests", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DocumentTypeId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("WorkflowId", "DocumentTypeId") + .IsUnique(); + + b.ToTable("ApprovalRequiredDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TargetUserCategoryId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("ApprovalWorkflows", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.DocumentTypeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("DocumentTypes", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.NotificationRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("NotificationRules", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("FileChecksum") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("FileStoragePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IssueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NotificationStep") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("StatusId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "DocumentTypeId"); + + b.ToTable("UserDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Audit.Entities.AuditRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AffectedEntityId") + .HasColumnType("uuid"); + + b.Property("AffectedEntityType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AuditResultId") + .HasColumnType("integer"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Metadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RootTenantId") + .HasColumnType("uuid"); + + b.Property("SubjectTypeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WhatChanged") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("WhenOccurred") + .HasColumnType("timestamp with time zone"); + + b.Property("WhoActed") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AffectedEntityId"); + + b.HasIndex("EventType"); + + b.HasIndex("RootTenantId"); + + b.HasIndex("WhoActed"); + + b.HasIndex("AffectedEntityId", "AffectedEntityType"); + + b.ToTable("AuditRecords", "audit"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "TargetTypeId", "TargetId", "ActionId") + .IsUnique(); + + b.ToTable("PermissionTemplateItems", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "SystemSuiteId", "Version") + .IsUnique(); + + b.ToTable("PermissionTemplates", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("IsOverride") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("ProfileId", "TemplateId", "ActionId", "TargetId"); + + b.ToTable("ProfilePermissions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId", "BranchId"); + + b.ToTable("Profiles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HierarchyLevel") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ParentRoleId") + .HasColumnType("uuid"); + + b.Property("PromotionOrder") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("ParentRoleId"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("Roles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "ConfigKey", "ScopeId") + .IsUnique(); + + b.ToTable("SystemSuiteAppSettings", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteDomainResources", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteModules", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "ActionCode") + .IsUnique(); + + b.ToTable("SystemSuiteNodeActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ComponenteTecnico") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Criticidad") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Dependencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Evidencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("NodeKindId") + .HasColumnType("integer"); + + b.Property("ParentNodeId") + .HasColumnType("uuid"); + + b.Property("ProductoImpactado") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Responsable") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TrazabilidadSdlc") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ParentNodeId"); + + b.HasIndex("ModuleId", "ParentNodeId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteNodes", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("SystemSuites", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.TemplateAssignmentRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateAssignmentRules", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.AppConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsEncrypted") + .HasColumnType("boolean"); + + b.Property("IsInheritable") + .HasColumnType("boolean"); + + b.Property("IsNonOverridable") + .HasColumnType("boolean"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ModuleId", "Code") + .IsUnique(); + + b.ToTable("AppConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CriteriaType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagCriteria", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Result") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("EvaluatedAtUtc"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagEvaluationLogs", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagTargets") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FlagTypeId") + .HasColumnType("integer"); + + b.Property("LinkedResourceId") + .HasColumnType("uuid"); + + b.Property("LinkedResourceTypeId") + .HasColumnType("integer"); + + b.Property("RolloutPercentage") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("FlagTypeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("SystemSuiteId", "FlagCode") + .IsUnique() + .HasFilter("\"StatusId\" != 3"); + + b.ToTable("FeatureFlags", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.IdpConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigPayload") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DomainHintsJson") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FallbackToId") + .HasColumnType("uuid"); + + b.Property("ProviderTypeId") + .HasColumnType("integer"); + + b.Property("ResolutionPriority") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SecretRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderTypeId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ResolutionPriority"); + + b.ToTable("IdpConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DataTypeId") + .HasColumnType("integer"); + + b.Property("DefaultValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive"); + + b.HasIndex("ScopeId"); + + b.ToTable("ParameterDefinitions", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterGlobalValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EffectiveValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ParameterDefinitionId") + .IsUnique(); + + b.HasIndex("StatusId"); + + b.ToTable("ParameterGlobalValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterTenantValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ParameterDefinitionId") + .IsUnique(); + + b.ToTable("ParameterTenantValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.RefreshTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("uuid"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RenewalCount") + .HasColumnType("integer"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("RefreshTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("GeofencingMetadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantBranches", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StrategyId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantIdentityProviders", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantParameterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedValues") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultValue") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsSensitive") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ValueTypeId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code", "IsActive") + .IsUnique() + .HasDatabaseName("IX_TenantParameters_TenantId_Code_IsActive") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("TenantParameters", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyReference") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IdpStrategyId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsManagementOwner") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationTypeId") + .HasColumnType("integer"); + + b.Property("ParentTenantId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("IsManagementOwner") + .IsUnique() + .HasDatabaseName("IX_Tenants_SingleManagementOwner") + .HasFilter("\"IsManagementOwner\" = true"); + + b.HasIndex("ParentTenantId"); + + b.ToTable("Tenants", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantSignupRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedTenantId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyReference") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyReference") + .IsUnique(); + + b.HasIndex("ContactEmail"); + + b.HasIndex("StatusId"); + + b.ToTable("TenantSignupRequests", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MethodId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId", "MethodId"); + + b.ToTable("UserAccountMfaEnrollments", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.ToTable("UserAccountPasswordCredentials", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IdentityReference") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IdentityReferenceTypeId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Email") + .IsUnique(); + + b.ToTable("UserAccounts", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserManagementDelegationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedActionsJson") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DelegatedAdminId") + .HasColumnType("uuid"); + + b.Property("DelegatingAdminId") + .HasColumnType("uuid"); + + b.Property("MaxDurationDays") + .HasColumnType("integer"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RevocationReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("uuid"); + + b.Property("ScopeTypeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DelegatedAdminId"); + + b.HasIndex("DelegatingAdminId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("UserManagementDelegations", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RoleMaturityStatusRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BlockingFactor") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CompletedCertificationsCount") + .HasColumnType("integer"); + + b.Property("CompletedTrainingsCount") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentLevelSince") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentMaturityLevel") + .HasColumnType("integer"); + + b.Property("EligibleForPromotionAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HasNoComplianceIssues") + .HasColumnType("boolean"); + + b.Property("LastReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NextEligibleMaturityLevel") + .HasColumnType("integer"); + + b.Property("PerformanceScore") + .HasColumnType("numeric(4,2)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId") + .IsUnique(); + + b.ToTable("RoleMaturityStatuses", "iga"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RolePromotionRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApproverId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentRoleId") + .HasColumnType("uuid"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ExecutorId") + .HasColumnType("uuid"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("RiskScore") + .HasColumnType("integer"); + + b.Property("SecurityReviewerId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetRoleId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("VerifierId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("RolePromotionRequests", "iga"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", "UserDocument") + .WithMany("Notifications") + .HasForeignKey("UserDocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserDocument"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", null) + .WithMany("RequiredDocuments") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", "Template") + .WithMany("Items") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", "Profile") + .WithMany("Permissions") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("ParentRoleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Actions") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("AppSettings") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("DomainResources") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Modules") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Node") + .WithMany("Actions") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") + .WithMany("Nodes") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Parent") + .WithMany("Children") + .HasForeignKey("ParentNodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Module"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("Criteria") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("EvaluationLogs") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("Branches") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("IdentityProviders") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("MfaEnrollments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("PasswordCredentials") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Navigation("RequiredDocuments"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Navigation("Notifications"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Navigation("Nodes"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("Children"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("AppSettings"); + + b.Navigation("DomainResources"); + + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Navigation("Criteria"); + + b.Navigation("EvaluationLogs"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Navigation("Branches"); + + b.Navigation("IdentityProviders"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Navigation("MfaEnrollments"); + + b.Navigation("PasswordCredentials"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260720154635_FixFeatureFlagUniqueIndex.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260720154635_FixFeatureFlagUniqueIndex.cs new file mode 100644 index 00000000..25d6dc6c --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260720154635_FixFeatureFlagUniqueIndex.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + public partial class FixFeatureFlagUniqueIndex : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_FeatureFlags_SystemSuiteId_FlagCode", + schema: "ums_configuration", + table: "FeatureFlags"); + + migrationBuilder.CreateIndex( + name: "IX_FeatureFlags_SystemSuiteId_FlagCode", + schema: "ums_configuration", + table: "FeatureFlags", + columns: new[] { "SystemSuiteId", "FlagCode" }, + unique: true, + filter: "\"StatusId\" != 3"); + } + + /// + #pragma warning disable S1186 + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_FeatureFlags_SystemSuiteId_FlagCode", + schema: "ums_configuration", + table: "FeatureFlags"); + + migrationBuilder.CreateIndex( + name: "IX_FeatureFlags_SystemSuiteId_FlagCode", + schema: "ums_configuration", + table: "FeatureFlags", + columns: new[] { "SystemSuiteId", "FlagCode" }, + unique: true); + } +#pragma warning restore S1186 + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260721154054_AddAccountLockout.Designer.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260721154054_AddAccountLockout.Designer.cs new file mode 100644 index 00000000..e7b400bd --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260721154054_AddAccountLockout.Designer.cs @@ -0,0 +1,3155 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Ums.Infrastructure.Persistence; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + [DbContext(typeof(UmsPlatformDbContext))] + [Migration("20260721154054_AddAccountLockout")] + partial class AddAccountLockout + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ums_platform") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessEnforcementPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EnforcementActionId") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ProfileId") + .HasFilter("\"ProfileId\" IS NOT NULL"); + + b.HasIndex("TenantId", "RoleId") + .HasFilter("\"RoleId\" IS NOT NULL"); + + b.ToTable("AccessEnforcementPolicies", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("DaysRemaining") + .HasColumnType("integer"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Step") + .HasColumnType("integer"); + + b.Property("UserDocumentId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserDocumentId"); + + b.HasIndex("UserDocumentId", "Step") + .IsUnique(); + + b.ToTable("UserDocumentNotifications", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GrantedRoleId") + .HasColumnType("uuid"); + + b.Property("Justification") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RequestedBranchId") + .HasColumnType("uuid"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("RequestedSystemId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetProfileId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetProfileId"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("TargetUserId", "RequestedSystemId", "RequestedBranchId", "StatusId"); + + b.ToTable("ApprovalRequests", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DocumentTypeId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("WorkflowId", "DocumentTypeId") + .IsUnique(); + + b.ToTable("ApprovalRequiredDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TargetUserCategoryId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("ApprovalWorkflows", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.DocumentTypeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("DocumentTypes", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.NotificationRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("NotificationRules", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("FileChecksum") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("FileStoragePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IssueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NotificationStep") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("StatusId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "DocumentTypeId"); + + b.ToTable("UserDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Audit.Entities.AuditRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AffectedEntityId") + .HasColumnType("uuid"); + + b.Property("AffectedEntityType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AuditResultId") + .HasColumnType("integer"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Metadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RootTenantId") + .HasColumnType("uuid"); + + b.Property("SubjectTypeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WhatChanged") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("WhenOccurred") + .HasColumnType("timestamp with time zone"); + + b.Property("WhoActed") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AffectedEntityId"); + + b.HasIndex("EventType"); + + b.HasIndex("RootTenantId"); + + b.HasIndex("WhoActed"); + + b.HasIndex("AffectedEntityId", "AffectedEntityType"); + + b.ToTable("AuditRecords", "audit"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "TargetTypeId", "TargetId", "ActionId") + .IsUnique(); + + b.ToTable("PermissionTemplateItems", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "SystemSuiteId", "Version") + .IsUnique(); + + b.ToTable("PermissionTemplates", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("IsOverride") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("ProfileId", "TemplateId", "ActionId", "TargetId"); + + b.ToTable("ProfilePermissions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId", "BranchId"); + + b.ToTable("Profiles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HierarchyLevel") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ParentRoleId") + .HasColumnType("uuid"); + + b.Property("PromotionOrder") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("ParentRoleId"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("Roles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "ConfigKey", "ScopeId") + .IsUnique(); + + b.ToTable("SystemSuiteAppSettings", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteDomainResources", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteModules", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "ActionCode") + .IsUnique(); + + b.ToTable("SystemSuiteNodeActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ComponenteTecnico") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Criticidad") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Dependencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Evidencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("NodeKindId") + .HasColumnType("integer"); + + b.Property("ParentNodeId") + .HasColumnType("uuid"); + + b.Property("ProductoImpactado") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Responsable") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TrazabilidadSdlc") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ParentNodeId"); + + b.HasIndex("ModuleId", "ParentNodeId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteNodes", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("SystemSuites", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.TemplateAssignmentRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateAssignmentRules", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.AppConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsEncrypted") + .HasColumnType("boolean"); + + b.Property("IsInheritable") + .HasColumnType("boolean"); + + b.Property("IsNonOverridable") + .HasColumnType("boolean"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ModuleId", "Code") + .IsUnique(); + + b.ToTable("AppConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CriteriaType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagCriteria", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Result") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("EvaluatedAtUtc"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagEvaluationLogs", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagTargets") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FlagTypeId") + .HasColumnType("integer"); + + b.Property("LinkedResourceId") + .HasColumnType("uuid"); + + b.Property("LinkedResourceTypeId") + .HasColumnType("integer"); + + b.Property("RolloutPercentage") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("FlagTypeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("SystemSuiteId", "FlagCode") + .IsUnique() + .HasFilter("\"StatusId\" != 3"); + + b.ToTable("FeatureFlags", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.IdpConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigPayload") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DomainHintsJson") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FallbackToId") + .HasColumnType("uuid"); + + b.Property("ProviderTypeId") + .HasColumnType("integer"); + + b.Property("ResolutionPriority") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SecretRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderTypeId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ResolutionPriority"); + + b.ToTable("IdpConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DataTypeId") + .HasColumnType("integer"); + + b.Property("DefaultValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive"); + + b.HasIndex("ScopeId"); + + b.ToTable("ParameterDefinitions", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterGlobalValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EffectiveValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ParameterDefinitionId") + .IsUnique(); + + b.HasIndex("StatusId"); + + b.ToTable("ParameterGlobalValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterTenantValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ParameterDefinitionId") + .IsUnique(); + + b.ToTable("ParameterTenantValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.RefreshTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("uuid"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RenewalCount") + .HasColumnType("integer"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("RefreshTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("GeofencingMetadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantBranches", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StrategyId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantIdentityProviders", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantParameterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedValues") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultValue") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsSensitive") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ValueTypeId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code", "IsActive") + .IsUnique() + .HasDatabaseName("IX_TenantParameters_TenantId_Code_IsActive") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("TenantParameters", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyReference") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IdpStrategyId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsManagementOwner") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationTypeId") + .HasColumnType("integer"); + + b.Property("ParentTenantId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("IsManagementOwner") + .IsUnique() + .HasDatabaseName("IX_Tenants_SingleManagementOwner") + .HasFilter("\"IsManagementOwner\" = true"); + + b.HasIndex("ParentTenantId"); + + b.ToTable("Tenants", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantSignupRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedTenantId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyReference") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyReference") + .IsUnique(); + + b.HasIndex("ContactEmail"); + + b.HasIndex("StatusId"); + + b.ToTable("TenantSignupRequests", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MethodId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId", "MethodId"); + + b.ToTable("UserAccountMfaEnrollments", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.ToTable("UserAccountPasswordCredentials", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FailedLoginAttempts") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("IdentityReference") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IdentityReferenceTypeId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LockedUntilUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Email") + .IsUnique(); + + b.ToTable("UserAccounts", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserManagementDelegationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedActionsJson") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DelegatedAdminId") + .HasColumnType("uuid"); + + b.Property("DelegatingAdminId") + .HasColumnType("uuid"); + + b.Property("MaxDurationDays") + .HasColumnType("integer"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RevocationReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("uuid"); + + b.Property("ScopeTypeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DelegatedAdminId"); + + b.HasIndex("DelegatingAdminId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("UserManagementDelegations", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RoleMaturityStatusRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BlockingFactor") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CompletedCertificationsCount") + .HasColumnType("integer"); + + b.Property("CompletedTrainingsCount") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentLevelSince") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentMaturityLevel") + .HasColumnType("integer"); + + b.Property("EligibleForPromotionAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HasNoComplianceIssues") + .HasColumnType("boolean"); + + b.Property("LastReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NextEligibleMaturityLevel") + .HasColumnType("integer"); + + b.Property("PerformanceScore") + .HasColumnType("numeric(4,2)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId") + .IsUnique(); + + b.ToTable("RoleMaturityStatuses", "iga"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RolePromotionRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApproverId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentRoleId") + .HasColumnType("uuid"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ExecutorId") + .HasColumnType("uuid"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("RiskScore") + .HasColumnType("integer"); + + b.Property("SecurityReviewerId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetRoleId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("VerifierId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("RolePromotionRequests", "iga"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", "UserDocument") + .WithMany("Notifications") + .HasForeignKey("UserDocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserDocument"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", null) + .WithMany("RequiredDocuments") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", "Template") + .WithMany("Items") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", "Profile") + .WithMany("Permissions") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("ParentRoleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Actions") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("AppSettings") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("DomainResources") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Modules") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Node") + .WithMany("Actions") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") + .WithMany("Nodes") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Parent") + .WithMany("Children") + .HasForeignKey("ParentNodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Module"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("Criteria") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("EvaluationLogs") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("Branches") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("IdentityProviders") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("MfaEnrollments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("PasswordCredentials") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Navigation("RequiredDocuments"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Navigation("Notifications"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Navigation("Nodes"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("Children"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("AppSettings"); + + b.Navigation("DomainResources"); + + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Navigation("Criteria"); + + b.Navigation("EvaluationLogs"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Navigation("Branches"); + + b.Navigation("IdentityProviders"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Navigation("MfaEnrollments"); + + b.Navigation("PasswordCredentials"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260721154054_AddAccountLockout.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260721154054_AddAccountLockout.cs new file mode 100644 index 00000000..20978833 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260721154054_AddAccountLockout.cs @@ -0,0 +1,44 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + public partial class AddAccountLockout : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "FailedLoginAttempts", + schema: "ums_identity", + table: "UserAccounts", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "LockedUntilUtc", + schema: "ums_identity", + table: "UserAccounts", + type: "timestamp with time zone", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "FailedLoginAttempts", + schema: "ums_identity", + table: "UserAccounts"); + + migrationBuilder.DropColumn( + name: "LockedUntilUtc", + schema: "ums_identity", + table: "UserAccounts"); + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260722012535_AddTenantDefaultSystemSuite.Designer.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260722012535_AddTenantDefaultSystemSuite.Designer.cs new file mode 100644 index 00000000..648c2fa9 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260722012535_AddTenantDefaultSystemSuite.Designer.cs @@ -0,0 +1,3158 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Ums.Infrastructure.Persistence; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + [DbContext(typeof(UmsPlatformDbContext))] + [Migration("20260722012535_AddTenantDefaultSystemSuite")] + partial class AddTenantDefaultSystemSuite + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ums_platform") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessEnforcementPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EnforcementActionId") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ProfileId") + .HasFilter("\"ProfileId\" IS NOT NULL"); + + b.HasIndex("TenantId", "RoleId") + .HasFilter("\"RoleId\" IS NOT NULL"); + + b.ToTable("AccessEnforcementPolicies", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("DaysRemaining") + .HasColumnType("integer"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Step") + .HasColumnType("integer"); + + b.Property("UserDocumentId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserDocumentId"); + + b.HasIndex("UserDocumentId", "Step") + .IsUnique(); + + b.ToTable("UserDocumentNotifications", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GrantedRoleId") + .HasColumnType("uuid"); + + b.Property("Justification") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RequestedBranchId") + .HasColumnType("uuid"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("RequestedSystemId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetProfileId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetProfileId"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("TargetUserId", "RequestedSystemId", "RequestedBranchId", "StatusId"); + + b.ToTable("ApprovalRequests", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DocumentTypeId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("WorkflowId", "DocumentTypeId") + .IsUnique(); + + b.ToTable("ApprovalRequiredDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TargetUserCategoryId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("ApprovalWorkflows", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.DocumentTypeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("DocumentTypes", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.NotificationRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("NotificationRules", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("FileChecksum") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("FileStoragePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IssueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NotificationStep") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("StatusId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "DocumentTypeId"); + + b.ToTable("UserDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Audit.Entities.AuditRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AffectedEntityId") + .HasColumnType("uuid"); + + b.Property("AffectedEntityType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AuditResultId") + .HasColumnType("integer"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Metadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RootTenantId") + .HasColumnType("uuid"); + + b.Property("SubjectTypeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WhatChanged") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("WhenOccurred") + .HasColumnType("timestamp with time zone"); + + b.Property("WhoActed") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AffectedEntityId"); + + b.HasIndex("EventType"); + + b.HasIndex("RootTenantId"); + + b.HasIndex("WhoActed"); + + b.HasIndex("AffectedEntityId", "AffectedEntityType"); + + b.ToTable("AuditRecords", "audit"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "TargetTypeId", "TargetId", "ActionId") + .IsUnique(); + + b.ToTable("PermissionTemplateItems", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "SystemSuiteId", "Version") + .IsUnique(); + + b.ToTable("PermissionTemplates", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("IsOverride") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("ProfileId", "TemplateId", "ActionId", "TargetId"); + + b.ToTable("ProfilePermissions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId", "BranchId"); + + b.ToTable("Profiles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HierarchyLevel") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ParentRoleId") + .HasColumnType("uuid"); + + b.Property("PromotionOrder") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("ParentRoleId"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("Roles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "ConfigKey", "ScopeId") + .IsUnique(); + + b.ToTable("SystemSuiteAppSettings", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteDomainResources", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteModules", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "ActionCode") + .IsUnique(); + + b.ToTable("SystemSuiteNodeActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ComponenteTecnico") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Criticidad") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Dependencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Evidencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("NodeKindId") + .HasColumnType("integer"); + + b.Property("ParentNodeId") + .HasColumnType("uuid"); + + b.Property("ProductoImpactado") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Responsable") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TrazabilidadSdlc") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ParentNodeId"); + + b.HasIndex("ModuleId", "ParentNodeId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteNodes", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("SystemSuites", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.TemplateAssignmentRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateAssignmentRules", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.AppConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsEncrypted") + .HasColumnType("boolean"); + + b.Property("IsInheritable") + .HasColumnType("boolean"); + + b.Property("IsNonOverridable") + .HasColumnType("boolean"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ModuleId", "Code") + .IsUnique(); + + b.ToTable("AppConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CriteriaType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagCriteria", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Result") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("EvaluatedAtUtc"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagEvaluationLogs", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagTargets") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FlagTypeId") + .HasColumnType("integer"); + + b.Property("LinkedResourceId") + .HasColumnType("uuid"); + + b.Property("LinkedResourceTypeId") + .HasColumnType("integer"); + + b.Property("RolloutPercentage") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("FlagTypeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("SystemSuiteId", "FlagCode") + .IsUnique() + .HasFilter("\"StatusId\" != 3"); + + b.ToTable("FeatureFlags", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.IdpConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigPayload") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DomainHintsJson") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FallbackToId") + .HasColumnType("uuid"); + + b.Property("ProviderTypeId") + .HasColumnType("integer"); + + b.Property("ResolutionPriority") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SecretRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderTypeId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ResolutionPriority"); + + b.ToTable("IdpConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DataTypeId") + .HasColumnType("integer"); + + b.Property("DefaultValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive"); + + b.HasIndex("ScopeId"); + + b.ToTable("ParameterDefinitions", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterGlobalValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EffectiveValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ParameterDefinitionId") + .IsUnique(); + + b.HasIndex("StatusId"); + + b.ToTable("ParameterGlobalValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterTenantValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ParameterDefinitionId") + .IsUnique(); + + b.ToTable("ParameterTenantValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.RefreshTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("uuid"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RenewalCount") + .HasColumnType("integer"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("RefreshTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("GeofencingMetadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantBranches", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StrategyId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantIdentityProviders", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantParameterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedValues") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultValue") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsSensitive") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ValueTypeId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code", "IsActive") + .IsUnique() + .HasDatabaseName("IX_TenantParameters_TenantId_Code_IsActive") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("TenantParameters", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyReference") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultSystemSuiteId") + .HasColumnType("uuid"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IdpStrategyId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsManagementOwner") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationTypeId") + .HasColumnType("integer"); + + b.Property("ParentTenantId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("IsManagementOwner") + .IsUnique() + .HasDatabaseName("IX_Tenants_SingleManagementOwner") + .HasFilter("\"IsManagementOwner\" = true"); + + b.HasIndex("ParentTenantId"); + + b.ToTable("Tenants", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantSignupRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedTenantId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyReference") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyReference") + .IsUnique(); + + b.HasIndex("ContactEmail"); + + b.HasIndex("StatusId"); + + b.ToTable("TenantSignupRequests", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MethodId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId", "MethodId"); + + b.ToTable("UserAccountMfaEnrollments", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.ToTable("UserAccountPasswordCredentials", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FailedLoginAttempts") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("IdentityReference") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IdentityReferenceTypeId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LockedUntilUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Email") + .IsUnique(); + + b.ToTable("UserAccounts", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserManagementDelegationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedActionsJson") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DelegatedAdminId") + .HasColumnType("uuid"); + + b.Property("DelegatingAdminId") + .HasColumnType("uuid"); + + b.Property("MaxDurationDays") + .HasColumnType("integer"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RevocationReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("uuid"); + + b.Property("ScopeTypeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DelegatedAdminId"); + + b.HasIndex("DelegatingAdminId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("UserManagementDelegations", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RoleMaturityStatusRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BlockingFactor") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CompletedCertificationsCount") + .HasColumnType("integer"); + + b.Property("CompletedTrainingsCount") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentLevelSince") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentMaturityLevel") + .HasColumnType("integer"); + + b.Property("EligibleForPromotionAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HasNoComplianceIssues") + .HasColumnType("boolean"); + + b.Property("LastReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NextEligibleMaturityLevel") + .HasColumnType("integer"); + + b.Property("PerformanceScore") + .HasColumnType("numeric(4,2)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId") + .IsUnique(); + + b.ToTable("RoleMaturityStatuses", "iga"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RolePromotionRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApproverId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentRoleId") + .HasColumnType("uuid"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ExecutorId") + .HasColumnType("uuid"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("RiskScore") + .HasColumnType("integer"); + + b.Property("SecurityReviewerId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetRoleId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("VerifierId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("RolePromotionRequests", "iga"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", "UserDocument") + .WithMany("Notifications") + .HasForeignKey("UserDocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserDocument"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", null) + .WithMany("RequiredDocuments") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", "Template") + .WithMany("Items") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", "Profile") + .WithMany("Permissions") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("ParentRoleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Actions") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("AppSettings") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("DomainResources") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Modules") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Node") + .WithMany("Actions") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") + .WithMany("Nodes") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Parent") + .WithMany("Children") + .HasForeignKey("ParentNodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Module"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("Criteria") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("EvaluationLogs") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("Branches") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("IdentityProviders") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("MfaEnrollments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("PasswordCredentials") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Navigation("RequiredDocuments"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Navigation("Notifications"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Navigation("Nodes"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("Children"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("AppSettings"); + + b.Navigation("DomainResources"); + + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Navigation("Criteria"); + + b.Navigation("EvaluationLogs"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Navigation("Branches"); + + b.Navigation("IdentityProviders"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Navigation("MfaEnrollments"); + + b.Navigation("PasswordCredentials"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260722012535_AddTenantDefaultSystemSuite.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260722012535_AddTenantDefaultSystemSuite.cs new file mode 100644 index 00000000..3f6e6f9e --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260722012535_AddTenantDefaultSystemSuite.cs @@ -0,0 +1,31 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + public partial class AddTenantDefaultSystemSuite : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DefaultSystemSuiteId", + schema: "ums_identity", + table: "Tenants", + type: "uuid", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DefaultSystemSuiteId", + schema: "ums_identity", + table: "Tenants"); + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260722191208_AddGracePeriodToEnforcementPolicy.Designer.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260722191208_AddGracePeriodToEnforcementPolicy.Designer.cs new file mode 100644 index 00000000..8d0f86a1 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260722191208_AddGracePeriodToEnforcementPolicy.Designer.cs @@ -0,0 +1,3161 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Ums.Infrastructure.Persistence; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + [DbContext(typeof(UmsPlatformDbContext))] + [Migration("20260722191208_AddGracePeriodToEnforcementPolicy")] + partial class AddGracePeriodToEnforcementPolicy + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ums_platform") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessEnforcementPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EnforcementActionId") + .HasColumnType("integer"); + + b.Property("GracePeriodDays") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ProfileId") + .HasFilter("\"ProfileId\" IS NOT NULL"); + + b.HasIndex("TenantId", "RoleId") + .HasFilter("\"RoleId\" IS NOT NULL"); + + b.ToTable("AccessEnforcementPolicies", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("DaysRemaining") + .HasColumnType("integer"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Step") + .HasColumnType("integer"); + + b.Property("UserDocumentId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserDocumentId"); + + b.HasIndex("UserDocumentId", "Step") + .IsUnique(); + + b.ToTable("UserDocumentNotifications", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GrantedRoleId") + .HasColumnType("uuid"); + + b.Property("Justification") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RequestedBranchId") + .HasColumnType("uuid"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("RequestedSystemId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetProfileId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetProfileId"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("TargetUserId", "RequestedSystemId", "RequestedBranchId", "StatusId"); + + b.ToTable("ApprovalRequests", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DocumentTypeId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("WorkflowId", "DocumentTypeId") + .IsUnique(); + + b.ToTable("ApprovalRequiredDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TargetUserCategoryId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("ApprovalWorkflows", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.DocumentTypeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("DocumentTypes", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.NotificationRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("NotificationRules", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("FileChecksum") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("FileStoragePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IssueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NotificationStep") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("StatusId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "DocumentTypeId"); + + b.ToTable("UserDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Audit.Entities.AuditRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AffectedEntityId") + .HasColumnType("uuid"); + + b.Property("AffectedEntityType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AuditResultId") + .HasColumnType("integer"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Metadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RootTenantId") + .HasColumnType("uuid"); + + b.Property("SubjectTypeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WhatChanged") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("WhenOccurred") + .HasColumnType("timestamp with time zone"); + + b.Property("WhoActed") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AffectedEntityId"); + + b.HasIndex("EventType"); + + b.HasIndex("RootTenantId"); + + b.HasIndex("WhoActed"); + + b.HasIndex("AffectedEntityId", "AffectedEntityType"); + + b.ToTable("AuditRecords", "audit"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "TargetTypeId", "TargetId", "ActionId") + .IsUnique(); + + b.ToTable("PermissionTemplateItems", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "SystemSuiteId", "Version") + .IsUnique(); + + b.ToTable("PermissionTemplates", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("IsOverride") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("ProfileId", "TemplateId", "ActionId", "TargetId"); + + b.ToTable("ProfilePermissions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId", "BranchId"); + + b.ToTable("Profiles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HierarchyLevel") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ParentRoleId") + .HasColumnType("uuid"); + + b.Property("PromotionOrder") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("ParentRoleId"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("Roles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "ConfigKey", "ScopeId") + .IsUnique(); + + b.ToTable("SystemSuiteAppSettings", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteDomainResources", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteModules", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "ActionCode") + .IsUnique(); + + b.ToTable("SystemSuiteNodeActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ComponenteTecnico") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Criticidad") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Dependencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Evidencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("NodeKindId") + .HasColumnType("integer"); + + b.Property("ParentNodeId") + .HasColumnType("uuid"); + + b.Property("ProductoImpactado") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Responsable") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TrazabilidadSdlc") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ParentNodeId"); + + b.HasIndex("ModuleId", "ParentNodeId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteNodes", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("SystemSuites", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.TemplateAssignmentRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateAssignmentRules", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.AppConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsEncrypted") + .HasColumnType("boolean"); + + b.Property("IsInheritable") + .HasColumnType("boolean"); + + b.Property("IsNonOverridable") + .HasColumnType("boolean"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ModuleId", "Code") + .IsUnique(); + + b.ToTable("AppConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CriteriaType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagCriteria", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Result") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("EvaluatedAtUtc"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagEvaluationLogs", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagTargets") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FlagTypeId") + .HasColumnType("integer"); + + b.Property("LinkedResourceId") + .HasColumnType("uuid"); + + b.Property("LinkedResourceTypeId") + .HasColumnType("integer"); + + b.Property("RolloutPercentage") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("FlagTypeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("SystemSuiteId", "FlagCode") + .IsUnique() + .HasFilter("\"StatusId\" != 3"); + + b.ToTable("FeatureFlags", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.IdpConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigPayload") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DomainHintsJson") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FallbackToId") + .HasColumnType("uuid"); + + b.Property("ProviderTypeId") + .HasColumnType("integer"); + + b.Property("ResolutionPriority") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SecretRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderTypeId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ResolutionPriority"); + + b.ToTable("IdpConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DataTypeId") + .HasColumnType("integer"); + + b.Property("DefaultValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive"); + + b.HasIndex("ScopeId"); + + b.ToTable("ParameterDefinitions", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterGlobalValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EffectiveValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ParameterDefinitionId") + .IsUnique(); + + b.HasIndex("StatusId"); + + b.ToTable("ParameterGlobalValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterTenantValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ParameterDefinitionId") + .IsUnique(); + + b.ToTable("ParameterTenantValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.RefreshTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("uuid"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RenewalCount") + .HasColumnType("integer"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("RefreshTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("GeofencingMetadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantBranches", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StrategyId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantIdentityProviders", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantParameterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedValues") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultValue") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsSensitive") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ValueTypeId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code", "IsActive") + .IsUnique() + .HasDatabaseName("IX_TenantParameters_TenantId_Code_IsActive") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("TenantParameters", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyReference") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultSystemSuiteId") + .HasColumnType("uuid"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IdpStrategyId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsManagementOwner") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationTypeId") + .HasColumnType("integer"); + + b.Property("ParentTenantId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("IsManagementOwner") + .IsUnique() + .HasDatabaseName("IX_Tenants_SingleManagementOwner") + .HasFilter("\"IsManagementOwner\" = true"); + + b.HasIndex("ParentTenantId"); + + b.ToTable("Tenants", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantSignupRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedTenantId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyReference") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyReference") + .IsUnique(); + + b.HasIndex("ContactEmail"); + + b.HasIndex("StatusId"); + + b.ToTable("TenantSignupRequests", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MethodId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId", "MethodId"); + + b.ToTable("UserAccountMfaEnrollments", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.ToTable("UserAccountPasswordCredentials", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FailedLoginAttempts") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("IdentityReference") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IdentityReferenceTypeId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LockedUntilUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Email") + .IsUnique(); + + b.ToTable("UserAccounts", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserManagementDelegationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedActionsJson") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DelegatedAdminId") + .HasColumnType("uuid"); + + b.Property("DelegatingAdminId") + .HasColumnType("uuid"); + + b.Property("MaxDurationDays") + .HasColumnType("integer"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RevocationReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("uuid"); + + b.Property("ScopeTypeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DelegatedAdminId"); + + b.HasIndex("DelegatingAdminId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("UserManagementDelegations", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RoleMaturityStatusRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BlockingFactor") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CompletedCertificationsCount") + .HasColumnType("integer"); + + b.Property("CompletedTrainingsCount") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentLevelSince") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentMaturityLevel") + .HasColumnType("integer"); + + b.Property("EligibleForPromotionAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HasNoComplianceIssues") + .HasColumnType("boolean"); + + b.Property("LastReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NextEligibleMaturityLevel") + .HasColumnType("integer"); + + b.Property("PerformanceScore") + .HasColumnType("numeric(4,2)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId") + .IsUnique(); + + b.ToTable("RoleMaturityStatuses", "iga"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RolePromotionRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApproverId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentRoleId") + .HasColumnType("uuid"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ExecutorId") + .HasColumnType("uuid"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("RiskScore") + .HasColumnType("integer"); + + b.Property("SecurityReviewerId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetRoleId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("VerifierId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("RolePromotionRequests", "iga"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", "UserDocument") + .WithMany("Notifications") + .HasForeignKey("UserDocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserDocument"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", null) + .WithMany("RequiredDocuments") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", "Template") + .WithMany("Items") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", "Profile") + .WithMany("Permissions") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("ParentRoleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Actions") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("AppSettings") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("DomainResources") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Modules") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Node") + .WithMany("Actions") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") + .WithMany("Nodes") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Parent") + .WithMany("Children") + .HasForeignKey("ParentNodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Module"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("Criteria") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("EvaluationLogs") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("Branches") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("IdentityProviders") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("MfaEnrollments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("PasswordCredentials") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Navigation("RequiredDocuments"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Navigation("Notifications"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Navigation("Nodes"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("Children"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("AppSettings"); + + b.Navigation("DomainResources"); + + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Navigation("Criteria"); + + b.Navigation("EvaluationLogs"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Navigation("Branches"); + + b.Navigation("IdentityProviders"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Navigation("MfaEnrollments"); + + b.Navigation("PasswordCredentials"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260722191208_AddGracePeriodToEnforcementPolicy.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260722191208_AddGracePeriodToEnforcementPolicy.cs new file mode 100644 index 00000000..f5376a6f --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260722191208_AddGracePeriodToEnforcementPolicy.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + public partial class AddGracePeriodToEnforcementPolicy : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "GracePeriodDays", + schema: "approvals", + table: "AccessEnforcementPolicies", + type: "integer", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "GracePeriodDays", + schema: "approvals", + table: "AccessEnforcementPolicies"); + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260801214202_AddAuthorizationLookupIndexes.Designer.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260801214202_AddAuthorizationLookupIndexes.Designer.cs new file mode 100644 index 00000000..276f9b37 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260801214202_AddAuthorizationLookupIndexes.Designer.cs @@ -0,0 +1,3173 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Ums.Infrastructure.Persistence; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + [DbContext(typeof(UmsPlatformDbContext))] + [Migration("20260801214202_AddAuthorizationLookupIndexes")] + partial class AddAuthorizationLookupIndexes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ums_platform") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessEnforcementPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EnforcementActionId") + .HasColumnType("integer"); + + b.Property("GracePeriodDays") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ProfileId") + .HasFilter("\"ProfileId\" IS NOT NULL"); + + b.HasIndex("TenantId", "RoleId") + .HasFilter("\"RoleId\" IS NOT NULL"); + + b.ToTable("AccessEnforcementPolicies", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("DaysRemaining") + .HasColumnType("integer"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Step") + .HasColumnType("integer"); + + b.Property("UserDocumentId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserDocumentId"); + + b.HasIndex("UserDocumentId", "Step") + .IsUnique(); + + b.ToTable("UserDocumentNotifications", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GrantedRoleId") + .HasColumnType("uuid"); + + b.Property("Justification") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RequestedBranchId") + .HasColumnType("uuid"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("RequestedSystemId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetProfileId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetProfileId"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("TargetUserId", "RequestedSystemId", "RequestedBranchId", "StatusId"); + + b.ToTable("ApprovalRequests", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DocumentTypeId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("WorkflowId", "DocumentTypeId") + .IsUnique(); + + b.ToTable("ApprovalRequiredDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TargetUserCategoryId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("ApprovalWorkflows", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.DocumentTypeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("DocumentTypes", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.NotificationRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("NotificationRules", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("FileChecksum") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("FileStoragePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IssueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NotificationStep") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("StatusId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "DocumentTypeId"); + + b.ToTable("UserDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Audit.Entities.AuditRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AffectedEntityId") + .HasColumnType("uuid"); + + b.Property("AffectedEntityType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AuditResultId") + .HasColumnType("integer"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Metadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RootTenantId") + .HasColumnType("uuid"); + + b.Property("SubjectTypeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WhatChanged") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("WhenOccurred") + .HasColumnType("timestamp with time zone"); + + b.Property("WhoActed") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AffectedEntityId"); + + b.HasIndex("EventType"); + + b.HasIndex("RootTenantId"); + + b.HasIndex("WhoActed"); + + b.HasIndex("AffectedEntityId", "AffectedEntityType"); + + b.ToTable("AuditRecords", "audit"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TargetId", "IsActive") + .HasDatabaseName("IX_PermissionTemplateItems_TargetId_IsActive"); + + b.HasIndex("TemplateId", "TargetTypeId", "TargetId", "ActionId") + .IsUnique(); + + b.ToTable("PermissionTemplateItems", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "SystemSuiteId", "Version") + .IsUnique(); + + b.ToTable("PermissionTemplates", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("IsOverride") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("TemplateId") + .HasDatabaseName("IX_ProfilePermissions_TemplateId"); + + b.HasIndex("ProfileId", "TemplateId", "ActionId", "TargetId"); + + b.ToTable("ProfilePermissions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId", "RoleId", "BranchId"); + + b.HasIndex(new[] { "UserId" }, "IX_Profiles_UserId"); + + b.HasIndex(new[] { "UserId" }, "IX_Profiles_UserId_Active") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("Profiles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HierarchyLevel") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ParentRoleId") + .HasColumnType("uuid"); + + b.Property("PromotionOrder") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("ParentRoleId"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("Roles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "ConfigKey", "ScopeId") + .IsUnique(); + + b.ToTable("SystemSuiteAppSettings", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ModuleId") + .HasDatabaseName("IX_SystemSuiteDomainResources_ModuleId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteDomainResources", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteModules", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "ActionCode") + .IsUnique(); + + b.ToTable("SystemSuiteNodeActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ComponenteTecnico") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Criticidad") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Dependencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Evidencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("NodeKindId") + .HasColumnType("integer"); + + b.Property("ParentNodeId") + .HasColumnType("uuid"); + + b.Property("ProductoImpactado") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Responsable") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TrazabilidadSdlc") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ParentNodeId"); + + b.HasIndex("ModuleId", "ParentNodeId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteNodes", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("SystemSuites", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.TemplateAssignmentRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateAssignmentRules", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.AppConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsEncrypted") + .HasColumnType("boolean"); + + b.Property("IsInheritable") + .HasColumnType("boolean"); + + b.Property("IsNonOverridable") + .HasColumnType("boolean"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ModuleId", "Code") + .IsUnique(); + + b.ToTable("AppConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CriteriaType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagCriteria", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Result") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("EvaluatedAtUtc"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagEvaluationLogs", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagTargets") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FlagTypeId") + .HasColumnType("integer"); + + b.Property("LinkedResourceId") + .HasColumnType("uuid"); + + b.Property("LinkedResourceTypeId") + .HasColumnType("integer"); + + b.Property("RolloutPercentage") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("FlagTypeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("SystemSuiteId", "FlagCode") + .IsUnique() + .HasFilter("\"StatusId\" != 3"); + + b.ToTable("FeatureFlags", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.IdpConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigPayload") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DomainHintsJson") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FallbackToId") + .HasColumnType("uuid"); + + b.Property("ProviderTypeId") + .HasColumnType("integer"); + + b.Property("ResolutionPriority") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SecretRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderTypeId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ResolutionPriority"); + + b.ToTable("IdpConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DataTypeId") + .HasColumnType("integer"); + + b.Property("DefaultValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive"); + + b.HasIndex("ScopeId"); + + b.ToTable("ParameterDefinitions", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterGlobalValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EffectiveValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ParameterDefinitionId") + .IsUnique(); + + b.HasIndex("StatusId"); + + b.ToTable("ParameterGlobalValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterTenantValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ParameterDefinitionId") + .IsUnique(); + + b.ToTable("ParameterTenantValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.RefreshTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("uuid"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RenewalCount") + .HasColumnType("integer"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("RefreshTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("GeofencingMetadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantBranches", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StrategyId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantIdentityProviders", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantParameterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedValues") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultValue") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsSensitive") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ValueTypeId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code", "IsActive") + .IsUnique() + .HasDatabaseName("IX_TenantParameters_TenantId_Code_IsActive") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("TenantParameters", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyReference") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultSystemSuiteId") + .HasColumnType("uuid"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IdpStrategyId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsManagementOwner") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationTypeId") + .HasColumnType("integer"); + + b.Property("ParentTenantId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("IsManagementOwner") + .IsUnique() + .HasDatabaseName("IX_Tenants_SingleManagementOwner") + .HasFilter("\"IsManagementOwner\" = true"); + + b.HasIndex("ParentTenantId"); + + b.ToTable("Tenants", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantSignupRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedTenantId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyReference") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyReference") + .IsUnique(); + + b.HasIndex("ContactEmail"); + + b.HasIndex("StatusId"); + + b.ToTable("TenantSignupRequests", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MethodId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId", "MethodId"); + + b.ToTable("UserAccountMfaEnrollments", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.ToTable("UserAccountPasswordCredentials", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FailedLoginAttempts") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("IdentityReference") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IdentityReferenceTypeId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LockedUntilUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Email") + .IsUnique(); + + b.ToTable("UserAccounts", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserManagementDelegationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedActionsJson") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DelegatedAdminId") + .HasColumnType("uuid"); + + b.Property("DelegatingAdminId") + .HasColumnType("uuid"); + + b.Property("MaxDurationDays") + .HasColumnType("integer"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RevocationReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("uuid"); + + b.Property("ScopeTypeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DelegatedAdminId"); + + b.HasIndex("DelegatingAdminId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("UserManagementDelegations", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RoleMaturityStatusRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BlockingFactor") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CompletedCertificationsCount") + .HasColumnType("integer"); + + b.Property("CompletedTrainingsCount") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentLevelSince") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentMaturityLevel") + .HasColumnType("integer"); + + b.Property("EligibleForPromotionAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HasNoComplianceIssues") + .HasColumnType("boolean"); + + b.Property("LastReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NextEligibleMaturityLevel") + .HasColumnType("integer"); + + b.Property("PerformanceScore") + .HasColumnType("numeric(4,2)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId") + .IsUnique(); + + b.ToTable("RoleMaturityStatuses", "iga"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RolePromotionRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApproverId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentRoleId") + .HasColumnType("uuid"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ExecutorId") + .HasColumnType("uuid"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("RiskScore") + .HasColumnType("integer"); + + b.Property("SecurityReviewerId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetRoleId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("VerifierId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("RolePromotionRequests", "iga"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", "UserDocument") + .WithMany("Notifications") + .HasForeignKey("UserDocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserDocument"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", null) + .WithMany("RequiredDocuments") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", "Template") + .WithMany("Items") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", "Profile") + .WithMany("Permissions") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("ParentRoleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Actions") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("AppSettings") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("DomainResources") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Modules") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Node") + .WithMany("Actions") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") + .WithMany("Nodes") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Parent") + .WithMany("Children") + .HasForeignKey("ParentNodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Module"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("Criteria") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("EvaluationLogs") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("Branches") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("IdentityProviders") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("MfaEnrollments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("PasswordCredentials") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Navigation("RequiredDocuments"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Navigation("Notifications"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Navigation("Nodes"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("Children"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("AppSettings"); + + b.Navigation("DomainResources"); + + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Navigation("Criteria"); + + b.Navigation("EvaluationLogs"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Navigation("Branches"); + + b.Navigation("IdentityProviders"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Navigation("MfaEnrollments"); + + b.Navigation("PasswordCredentials"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260801214202_AddAuthorizationLookupIndexes.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260801214202_AddAuthorizationLookupIndexes.cs new file mode 100644 index 00000000..75696d36 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260801214202_AddAuthorizationLookupIndexes.cs @@ -0,0 +1,63 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + public partial class AddAuthorizationLookupIndexes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_SystemSuiteDomainResources_ModuleId", + schema: "ums_authorization", + table: "SystemSuiteDomainResources", + column: "ModuleId"); + + migrationBuilder.CreateIndex( + name: "IX_Profiles_UserId_Active", + schema: "ums_authorization", + table: "Profiles", + column: "UserId", + filter: "\"IsActive\" = true"); + + migrationBuilder.CreateIndex( + name: "IX_ProfilePermissions_TemplateId", + schema: "ums_authorization", + table: "ProfilePermissions", + column: "TemplateId"); + + migrationBuilder.CreateIndex( + name: "IX_PermissionTemplateItems_TargetId_IsActive", + schema: "ums_authorization", + table: "PermissionTemplateItems", + columns: new[] { "TargetId", "IsActive" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_SystemSuiteDomainResources_ModuleId", + schema: "ums_authorization", + table: "SystemSuiteDomainResources"); + + migrationBuilder.DropIndex( + name: "IX_Profiles_UserId_Active", + schema: "ums_authorization", + table: "Profiles"); + + migrationBuilder.DropIndex( + name: "IX_ProfilePermissions_TemplateId", + schema: "ums_authorization", + table: "ProfilePermissions"); + + migrationBuilder.DropIndex( + name: "IX_PermissionTemplateItems_TargetId_IsActive", + schema: "ums_authorization", + table: "PermissionTemplateItems"); + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802002315_AddAppSettingClientVisibility.Designer.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802002315_AddAppSettingClientVisibility.Designer.cs new file mode 100644 index 00000000..500fd3db --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802002315_AddAppSettingClientVisibility.Designer.cs @@ -0,0 +1,3178 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Ums.Infrastructure.Persistence; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + [DbContext(typeof(UmsPlatformDbContext))] + [Migration("20260802002315_AddAppSettingClientVisibility")] + partial class AddAppSettingClientVisibility + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ums_platform") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessEnforcementPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EnforcementActionId") + .HasColumnType("integer"); + + b.Property("GracePeriodDays") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ProfileId") + .HasFilter("\"ProfileId\" IS NOT NULL"); + + b.HasIndex("TenantId", "RoleId") + .HasFilter("\"RoleId\" IS NOT NULL"); + + b.ToTable("AccessEnforcementPolicies", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("DaysRemaining") + .HasColumnType("integer"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Step") + .HasColumnType("integer"); + + b.Property("UserDocumentId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserDocumentId"); + + b.HasIndex("UserDocumentId", "Step") + .IsUnique(); + + b.ToTable("UserDocumentNotifications", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GrantedRoleId") + .HasColumnType("uuid"); + + b.Property("Justification") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RequestedBranchId") + .HasColumnType("uuid"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("RequestedSystemId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetProfileId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetProfileId"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("TargetUserId", "RequestedSystemId", "RequestedBranchId", "StatusId"); + + b.ToTable("ApprovalRequests", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DocumentTypeId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("WorkflowId", "DocumentTypeId") + .IsUnique(); + + b.ToTable("ApprovalRequiredDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TargetUserCategoryId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("ApprovalWorkflows", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.DocumentTypeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("DocumentTypes", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.NotificationRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("NotificationRules", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("FileChecksum") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("FileStoragePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IssueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NotificationStep") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("StatusId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "DocumentTypeId"); + + b.ToTable("UserDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Audit.Entities.AuditRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AffectedEntityId") + .HasColumnType("uuid"); + + b.Property("AffectedEntityType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AuditResultId") + .HasColumnType("integer"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Metadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RootTenantId") + .HasColumnType("uuid"); + + b.Property("SubjectTypeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WhatChanged") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("WhenOccurred") + .HasColumnType("timestamp with time zone"); + + b.Property("WhoActed") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AffectedEntityId"); + + b.HasIndex("EventType"); + + b.HasIndex("RootTenantId"); + + b.HasIndex("WhoActed"); + + b.HasIndex("AffectedEntityId", "AffectedEntityType"); + + b.ToTable("AuditRecords", "audit"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TargetId", "IsActive") + .HasDatabaseName("IX_PermissionTemplateItems_TargetId_IsActive"); + + b.HasIndex("TemplateId", "TargetTypeId", "TargetId", "ActionId") + .IsUnique(); + + b.ToTable("PermissionTemplateItems", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "SystemSuiteId", "Version") + .IsUnique(); + + b.ToTable("PermissionTemplates", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("IsOverride") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("TemplateId") + .HasDatabaseName("IX_ProfilePermissions_TemplateId"); + + b.HasIndex("ProfileId", "TemplateId", "ActionId", "TargetId"); + + b.ToTable("ProfilePermissions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId", "RoleId", "BranchId"); + + b.HasIndex(new[] { "UserId" }, "IX_Profiles_UserId"); + + b.HasIndex(new[] { "UserId" }, "IX_Profiles_UserId_Active") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("Profiles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HierarchyLevel") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ParentRoleId") + .HasColumnType("uuid"); + + b.Property("PromotionOrder") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("ParentRoleId"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("Roles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsClientVisible") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "ConfigKey", "ScopeId") + .IsUnique(); + + b.ToTable("SystemSuiteAppSettings", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ModuleId") + .HasDatabaseName("IX_SystemSuiteDomainResources_ModuleId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteDomainResources", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteModules", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "ActionCode") + .IsUnique(); + + b.ToTable("SystemSuiteNodeActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ComponenteTecnico") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Criticidad") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Dependencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Evidencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("NodeKindId") + .HasColumnType("integer"); + + b.Property("ParentNodeId") + .HasColumnType("uuid"); + + b.Property("ProductoImpactado") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Responsable") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TrazabilidadSdlc") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ParentNodeId"); + + b.HasIndex("ModuleId", "ParentNodeId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteNodes", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("SystemSuites", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.TemplateAssignmentRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateAssignmentRules", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.AppConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsEncrypted") + .HasColumnType("boolean"); + + b.Property("IsInheritable") + .HasColumnType("boolean"); + + b.Property("IsNonOverridable") + .HasColumnType("boolean"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ModuleId", "Code") + .IsUnique(); + + b.ToTable("AppConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CriteriaType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagCriteria", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Result") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("EvaluatedAtUtc"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagEvaluationLogs", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagTargets") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FlagTypeId") + .HasColumnType("integer"); + + b.Property("LinkedResourceId") + .HasColumnType("uuid"); + + b.Property("LinkedResourceTypeId") + .HasColumnType("integer"); + + b.Property("RolloutPercentage") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("FlagTypeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("SystemSuiteId", "FlagCode") + .IsUnique() + .HasFilter("\"StatusId\" != 3"); + + b.ToTable("FeatureFlags", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.IdpConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigPayload") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DomainHintsJson") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FallbackToId") + .HasColumnType("uuid"); + + b.Property("ProviderTypeId") + .HasColumnType("integer"); + + b.Property("ResolutionPriority") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SecretRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderTypeId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ResolutionPriority"); + + b.ToTable("IdpConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DataTypeId") + .HasColumnType("integer"); + + b.Property("DefaultValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive"); + + b.HasIndex("ScopeId"); + + b.ToTable("ParameterDefinitions", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterGlobalValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EffectiveValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ParameterDefinitionId") + .IsUnique(); + + b.HasIndex("StatusId"); + + b.ToTable("ParameterGlobalValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterTenantValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ParameterDefinitionId") + .IsUnique(); + + b.ToTable("ParameterTenantValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.RefreshTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("uuid"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RenewalCount") + .HasColumnType("integer"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("RefreshTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("GeofencingMetadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantBranches", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StrategyId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantIdentityProviders", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantParameterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedValues") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultValue") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsSensitive") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ValueTypeId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code", "IsActive") + .IsUnique() + .HasDatabaseName("IX_TenantParameters_TenantId_Code_IsActive") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("TenantParameters", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyReference") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultSystemSuiteId") + .HasColumnType("uuid"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IdpStrategyId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsManagementOwner") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationTypeId") + .HasColumnType("integer"); + + b.Property("ParentTenantId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("IsManagementOwner") + .IsUnique() + .HasDatabaseName("IX_Tenants_SingleManagementOwner") + .HasFilter("\"IsManagementOwner\" = true"); + + b.HasIndex("ParentTenantId"); + + b.ToTable("Tenants", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantSignupRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedTenantId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyReference") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyReference") + .IsUnique(); + + b.HasIndex("ContactEmail"); + + b.HasIndex("StatusId"); + + b.ToTable("TenantSignupRequests", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MethodId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId", "MethodId"); + + b.ToTable("UserAccountMfaEnrollments", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.ToTable("UserAccountPasswordCredentials", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FailedLoginAttempts") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("IdentityReference") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IdentityReferenceTypeId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LockedUntilUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Email") + .IsUnique(); + + b.ToTable("UserAccounts", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserManagementDelegationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedActionsJson") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DelegatedAdminId") + .HasColumnType("uuid"); + + b.Property("DelegatingAdminId") + .HasColumnType("uuid"); + + b.Property("MaxDurationDays") + .HasColumnType("integer"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RevocationReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("uuid"); + + b.Property("ScopeTypeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DelegatedAdminId"); + + b.HasIndex("DelegatingAdminId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("UserManagementDelegations", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RoleMaturityStatusRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BlockingFactor") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CompletedCertificationsCount") + .HasColumnType("integer"); + + b.Property("CompletedTrainingsCount") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentLevelSince") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentMaturityLevel") + .HasColumnType("integer"); + + b.Property("EligibleForPromotionAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HasNoComplianceIssues") + .HasColumnType("boolean"); + + b.Property("LastReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NextEligibleMaturityLevel") + .HasColumnType("integer"); + + b.Property("PerformanceScore") + .HasColumnType("numeric(4,2)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId") + .IsUnique(); + + b.ToTable("RoleMaturityStatuses", "iga"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RolePromotionRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApproverId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentRoleId") + .HasColumnType("uuid"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ExecutorId") + .HasColumnType("uuid"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("RiskScore") + .HasColumnType("integer"); + + b.Property("SecurityReviewerId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetRoleId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("VerifierId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("RolePromotionRequests", "iga"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", "UserDocument") + .WithMany("Notifications") + .HasForeignKey("UserDocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserDocument"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", null) + .WithMany("RequiredDocuments") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", "Template") + .WithMany("Items") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", "Profile") + .WithMany("Permissions") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("ParentRoleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Actions") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("AppSettings") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("DomainResources") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Modules") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Node") + .WithMany("Actions") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") + .WithMany("Nodes") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Parent") + .WithMany("Children") + .HasForeignKey("ParentNodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Module"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("Criteria") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("EvaluationLogs") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("Branches") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("IdentityProviders") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("MfaEnrollments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("PasswordCredentials") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Navigation("RequiredDocuments"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Navigation("Notifications"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Navigation("Nodes"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("Children"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("AppSettings"); + + b.Navigation("DomainResources"); + + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Navigation("Criteria"); + + b.Navigation("EvaluationLogs"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Navigation("Branches"); + + b.Navigation("IdentityProviders"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Navigation("MfaEnrollments"); + + b.Navigation("PasswordCredentials"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802002315_AddAppSettingClientVisibility.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802002315_AddAppSettingClientVisibility.cs new file mode 100644 index 00000000..b6890173 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802002315_AddAppSettingClientVisibility.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + public partial class AddAppSettingClientVisibility : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsClientVisible", + schema: "ums_authorization", + table: "SystemSuiteAppSettings", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsClientVisible", + schema: "ums_authorization", + table: "SystemSuiteAppSettings"); + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802004743_AddMenuNodePresentation.Designer.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802004743_AddMenuNodePresentation.Designer.cs new file mode 100644 index 00000000..3cee327f --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802004743_AddMenuNodePresentation.Designer.cs @@ -0,0 +1,3186 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Ums.Infrastructure.Persistence; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + [DbContext(typeof(UmsPlatformDbContext))] + [Migration("20260802004743_AddMenuNodePresentation")] + partial class AddMenuNodePresentation + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ums_platform") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessEnforcementPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EnforcementActionId") + .HasColumnType("integer"); + + b.Property("GracePeriodDays") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ProfileId") + .HasFilter("\"ProfileId\" IS NOT NULL"); + + b.HasIndex("TenantId", "RoleId") + .HasFilter("\"RoleId\" IS NOT NULL"); + + b.ToTable("AccessEnforcementPolicies", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("DaysRemaining") + .HasColumnType("integer"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Step") + .HasColumnType("integer"); + + b.Property("UserDocumentId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserDocumentId"); + + b.HasIndex("UserDocumentId", "Step") + .IsUnique(); + + b.ToTable("UserDocumentNotifications", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GrantedRoleId") + .HasColumnType("uuid"); + + b.Property("Justification") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RequestedBranchId") + .HasColumnType("uuid"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("RequestedSystemId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetProfileId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetProfileId"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("TargetUserId", "RequestedSystemId", "RequestedBranchId", "StatusId"); + + b.ToTable("ApprovalRequests", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DocumentTypeId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("WorkflowId", "DocumentTypeId") + .IsUnique(); + + b.ToTable("ApprovalRequiredDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TargetUserCategoryId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("ApprovalWorkflows", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.DocumentTypeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("DocumentTypes", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.NotificationRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("NotificationRules", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("FileChecksum") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("FileStoragePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IssueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NotificationStep") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("StatusId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "DocumentTypeId"); + + b.ToTable("UserDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Audit.Entities.AuditRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AffectedEntityId") + .HasColumnType("uuid"); + + b.Property("AffectedEntityType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AuditResultId") + .HasColumnType("integer"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Metadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RootTenantId") + .HasColumnType("uuid"); + + b.Property("SubjectTypeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WhatChanged") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("WhenOccurred") + .HasColumnType("timestamp with time zone"); + + b.Property("WhoActed") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AffectedEntityId"); + + b.HasIndex("EventType"); + + b.HasIndex("RootTenantId"); + + b.HasIndex("WhoActed"); + + b.HasIndex("AffectedEntityId", "AffectedEntityType"); + + b.ToTable("AuditRecords", "audit"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TargetId", "IsActive") + .HasDatabaseName("IX_PermissionTemplateItems_TargetId_IsActive"); + + b.HasIndex("TemplateId", "TargetTypeId", "TargetId", "ActionId") + .IsUnique(); + + b.ToTable("PermissionTemplateItems", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "SystemSuiteId", "Version") + .IsUnique(); + + b.ToTable("PermissionTemplates", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("IsOverride") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("TemplateId") + .HasDatabaseName("IX_ProfilePermissions_TemplateId"); + + b.HasIndex("ProfileId", "TemplateId", "ActionId", "TargetId"); + + b.ToTable("ProfilePermissions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId", "RoleId", "BranchId"); + + b.HasIndex(new[] { "UserId" }, "IX_Profiles_UserId"); + + b.HasIndex(new[] { "UserId" }, "IX_Profiles_UserId_Active") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("Profiles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HierarchyLevel") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ParentRoleId") + .HasColumnType("uuid"); + + b.Property("PromotionOrder") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("ParentRoleId"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("Roles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsClientVisible") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "ConfigKey", "ScopeId") + .IsUnique(); + + b.ToTable("SystemSuiteAppSettings", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ModuleId") + .HasDatabaseName("IX_SystemSuiteDomainResources_ModuleId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteDomainResources", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteModules", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "ActionCode") + .IsUnique(); + + b.ToTable("SystemSuiteNodeActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ComponenteTecnico") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Criticidad") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Dependencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Evidencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Icon") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("NodeKindId") + .HasColumnType("integer"); + + b.Property("ParentNodeId") + .HasColumnType("uuid"); + + b.Property("ProductoImpactado") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Responsable") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Route") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TrazabilidadSdlc") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ParentNodeId"); + + b.HasIndex("ModuleId", "ParentNodeId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteNodes", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("SystemSuites", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.TemplateAssignmentRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateAssignmentRules", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.AppConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsEncrypted") + .HasColumnType("boolean"); + + b.Property("IsInheritable") + .HasColumnType("boolean"); + + b.Property("IsNonOverridable") + .HasColumnType("boolean"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ModuleId", "Code") + .IsUnique(); + + b.ToTable("AppConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CriteriaType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagCriteria", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Result") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("EvaluatedAtUtc"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagEvaluationLogs", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagTargets") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FlagTypeId") + .HasColumnType("integer"); + + b.Property("LinkedResourceId") + .HasColumnType("uuid"); + + b.Property("LinkedResourceTypeId") + .HasColumnType("integer"); + + b.Property("RolloutPercentage") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("FlagTypeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("SystemSuiteId", "FlagCode") + .IsUnique() + .HasFilter("\"StatusId\" != 3"); + + b.ToTable("FeatureFlags", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.IdpConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigPayload") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DomainHintsJson") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FallbackToId") + .HasColumnType("uuid"); + + b.Property("ProviderTypeId") + .HasColumnType("integer"); + + b.Property("ResolutionPriority") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SecretRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderTypeId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ResolutionPriority"); + + b.ToTable("IdpConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DataTypeId") + .HasColumnType("integer"); + + b.Property("DefaultValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive"); + + b.HasIndex("ScopeId"); + + b.ToTable("ParameterDefinitions", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterGlobalValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EffectiveValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ParameterDefinitionId") + .IsUnique(); + + b.HasIndex("StatusId"); + + b.ToTable("ParameterGlobalValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterTenantValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ParameterDefinitionId") + .IsUnique(); + + b.ToTable("ParameterTenantValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.RefreshTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("uuid"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RenewalCount") + .HasColumnType("integer"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("RefreshTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("GeofencingMetadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantBranches", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StrategyId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantIdentityProviders", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantParameterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedValues") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultValue") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsSensitive") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ValueTypeId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code", "IsActive") + .IsUnique() + .HasDatabaseName("IX_TenantParameters_TenantId_Code_IsActive") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("TenantParameters", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyReference") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultSystemSuiteId") + .HasColumnType("uuid"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IdpStrategyId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsManagementOwner") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationTypeId") + .HasColumnType("integer"); + + b.Property("ParentTenantId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("IsManagementOwner") + .IsUnique() + .HasDatabaseName("IX_Tenants_SingleManagementOwner") + .HasFilter("\"IsManagementOwner\" = true"); + + b.HasIndex("ParentTenantId"); + + b.ToTable("Tenants", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantSignupRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedTenantId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyReference") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyReference") + .IsUnique(); + + b.HasIndex("ContactEmail"); + + b.HasIndex("StatusId"); + + b.ToTable("TenantSignupRequests", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MethodId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId", "MethodId"); + + b.ToTable("UserAccountMfaEnrollments", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.ToTable("UserAccountPasswordCredentials", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FailedLoginAttempts") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("IdentityReference") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IdentityReferenceTypeId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LockedUntilUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Email") + .IsUnique(); + + b.ToTable("UserAccounts", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserManagementDelegationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedActionsJson") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DelegatedAdminId") + .HasColumnType("uuid"); + + b.Property("DelegatingAdminId") + .HasColumnType("uuid"); + + b.Property("MaxDurationDays") + .HasColumnType("integer"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RevocationReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("uuid"); + + b.Property("ScopeTypeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DelegatedAdminId"); + + b.HasIndex("DelegatingAdminId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("UserManagementDelegations", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RoleMaturityStatusRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BlockingFactor") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CompletedCertificationsCount") + .HasColumnType("integer"); + + b.Property("CompletedTrainingsCount") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentLevelSince") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentMaturityLevel") + .HasColumnType("integer"); + + b.Property("EligibleForPromotionAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HasNoComplianceIssues") + .HasColumnType("boolean"); + + b.Property("LastReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NextEligibleMaturityLevel") + .HasColumnType("integer"); + + b.Property("PerformanceScore") + .HasColumnType("numeric(4,2)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId") + .IsUnique(); + + b.ToTable("RoleMaturityStatuses", "iga"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RolePromotionRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApproverId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentRoleId") + .HasColumnType("uuid"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ExecutorId") + .HasColumnType("uuid"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("RiskScore") + .HasColumnType("integer"); + + b.Property("SecurityReviewerId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetRoleId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("VerifierId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("RolePromotionRequests", "iga"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", "UserDocument") + .WithMany("Notifications") + .HasForeignKey("UserDocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserDocument"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", null) + .WithMany("RequiredDocuments") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", "Template") + .WithMany("Items") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", "Profile") + .WithMany("Permissions") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("ParentRoleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Actions") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("AppSettings") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("DomainResources") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Modules") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Node") + .WithMany("Actions") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") + .WithMany("Nodes") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Parent") + .WithMany("Children") + .HasForeignKey("ParentNodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Module"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("Criteria") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("EvaluationLogs") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("Branches") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("IdentityProviders") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("MfaEnrollments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("PasswordCredentials") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Navigation("RequiredDocuments"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Navigation("Notifications"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Navigation("Nodes"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("Children"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("AppSettings"); + + b.Navigation("DomainResources"); + + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Navigation("Criteria"); + + b.Navigation("EvaluationLogs"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Navigation("Branches"); + + b.Navigation("IdentityProviders"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Navigation("MfaEnrollments"); + + b.Navigation("PasswordCredentials"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802004743_AddMenuNodePresentation.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802004743_AddMenuNodePresentation.cs new file mode 100644 index 00000000..540e1417 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802004743_AddMenuNodePresentation.cs @@ -0,0 +1,44 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + public partial class AddMenuNodePresentation : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Icon", + schema: "ums_authorization", + table: "SystemSuiteNodes", + type: "character varying(64)", + maxLength: 64, + nullable: true); + + migrationBuilder.AddColumn( + name: "Route", + schema: "ums_authorization", + table: "SystemSuiteNodes", + type: "character varying(400)", + maxLength: 400, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Icon", + schema: "ums_authorization", + table: "SystemSuiteNodes"); + + migrationBuilder.DropColumn( + name: "Route", + schema: "ums_authorization", + table: "SystemSuiteNodes"); + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802014735_AddModuleIcon.Designer.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802014735_AddModuleIcon.Designer.cs new file mode 100644 index 00000000..d536215c --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802014735_AddModuleIcon.Designer.cs @@ -0,0 +1,3190 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Ums.Infrastructure.Persistence; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + [DbContext(typeof(UmsPlatformDbContext))] + [Migration("20260802014735_AddModuleIcon")] + partial class AddModuleIcon + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ums_platform") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessEnforcementPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EnforcementActionId") + .HasColumnType("integer"); + + b.Property("GracePeriodDays") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ProfileId") + .HasFilter("\"ProfileId\" IS NOT NULL"); + + b.HasIndex("TenantId", "RoleId") + .HasFilter("\"RoleId\" IS NOT NULL"); + + b.ToTable("AccessEnforcementPolicies", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("DaysRemaining") + .HasColumnType("integer"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Step") + .HasColumnType("integer"); + + b.Property("UserDocumentId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserDocumentId"); + + b.HasIndex("UserDocumentId", "Step") + .IsUnique(); + + b.ToTable("UserDocumentNotifications", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GrantedRoleId") + .HasColumnType("uuid"); + + b.Property("Justification") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RequestedBranchId") + .HasColumnType("uuid"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("RequestedSystemId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetProfileId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetProfileId"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("TargetUserId", "RequestedSystemId", "RequestedBranchId", "StatusId"); + + b.ToTable("ApprovalRequests", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DocumentTypeId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("WorkflowId", "DocumentTypeId") + .IsUnique(); + + b.ToTable("ApprovalRequiredDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TargetUserCategoryId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("ApprovalWorkflows", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.DocumentTypeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("DocumentTypes", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.NotificationRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("NotificationRules", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("FileChecksum") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("FileStoragePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IssueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NotificationStep") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("StatusId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "DocumentTypeId"); + + b.ToTable("UserDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Audit.Entities.AuditRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AffectedEntityId") + .HasColumnType("uuid"); + + b.Property("AffectedEntityType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AuditResultId") + .HasColumnType("integer"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Metadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RootTenantId") + .HasColumnType("uuid"); + + b.Property("SubjectTypeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WhatChanged") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("WhenOccurred") + .HasColumnType("timestamp with time zone"); + + b.Property("WhoActed") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AffectedEntityId"); + + b.HasIndex("EventType"); + + b.HasIndex("RootTenantId"); + + b.HasIndex("WhoActed"); + + b.HasIndex("AffectedEntityId", "AffectedEntityType"); + + b.ToTable("AuditRecords", "audit"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TargetId", "IsActive") + .HasDatabaseName("IX_PermissionTemplateItems_TargetId_IsActive"); + + b.HasIndex("TemplateId", "TargetTypeId", "TargetId", "ActionId") + .IsUnique(); + + b.ToTable("PermissionTemplateItems", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "SystemSuiteId", "Version") + .IsUnique(); + + b.ToTable("PermissionTemplates", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("IsOverride") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("TemplateId") + .HasDatabaseName("IX_ProfilePermissions_TemplateId"); + + b.HasIndex("ProfileId", "TemplateId", "ActionId", "TargetId"); + + b.ToTable("ProfilePermissions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId", "RoleId", "BranchId"); + + b.HasIndex(new[] { "UserId" }, "IX_Profiles_UserId"); + + b.HasIndex(new[] { "UserId" }, "IX_Profiles_UserId_Active") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("Profiles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HierarchyLevel") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ParentRoleId") + .HasColumnType("uuid"); + + b.Property("PromotionOrder") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("ParentRoleId"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("Roles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsClientVisible") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "ConfigKey", "ScopeId") + .IsUnique(); + + b.ToTable("SystemSuiteAppSettings", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ModuleId") + .HasDatabaseName("IX_SystemSuiteDomainResources_ModuleId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteDomainResources", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Icon") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteModules", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "ActionCode") + .IsUnique(); + + b.ToTable("SystemSuiteNodeActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ComponenteTecnico") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Criticidad") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Dependencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Evidencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Icon") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("NodeKindId") + .HasColumnType("integer"); + + b.Property("ParentNodeId") + .HasColumnType("uuid"); + + b.Property("ProductoImpactado") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Responsable") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Route") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TrazabilidadSdlc") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ParentNodeId"); + + b.HasIndex("ModuleId", "ParentNodeId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteNodes", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("SystemSuites", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.TemplateAssignmentRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateAssignmentRules", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.AppConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsEncrypted") + .HasColumnType("boolean"); + + b.Property("IsInheritable") + .HasColumnType("boolean"); + + b.Property("IsNonOverridable") + .HasColumnType("boolean"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ModuleId", "Code") + .IsUnique(); + + b.ToTable("AppConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CriteriaType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagCriteria", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Result") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("EvaluatedAtUtc"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagEvaluationLogs", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagTargets") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FlagTypeId") + .HasColumnType("integer"); + + b.Property("LinkedResourceId") + .HasColumnType("uuid"); + + b.Property("LinkedResourceTypeId") + .HasColumnType("integer"); + + b.Property("RolloutPercentage") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("FlagTypeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("SystemSuiteId", "FlagCode") + .IsUnique() + .HasFilter("\"StatusId\" != 3"); + + b.ToTable("FeatureFlags", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.IdpConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigPayload") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DomainHintsJson") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FallbackToId") + .HasColumnType("uuid"); + + b.Property("ProviderTypeId") + .HasColumnType("integer"); + + b.Property("ResolutionPriority") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SecretRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderTypeId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ResolutionPriority"); + + b.ToTable("IdpConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DataTypeId") + .HasColumnType("integer"); + + b.Property("DefaultValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive"); + + b.HasIndex("ScopeId"); + + b.ToTable("ParameterDefinitions", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterGlobalValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EffectiveValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ParameterDefinitionId") + .IsUnique(); + + b.HasIndex("StatusId"); + + b.ToTable("ParameterGlobalValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterTenantValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ParameterDefinitionId") + .IsUnique(); + + b.ToTable("ParameterTenantValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.RefreshTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("uuid"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RenewalCount") + .HasColumnType("integer"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("RefreshTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("GeofencingMetadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantBranches", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StrategyId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantIdentityProviders", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantParameterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedValues") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultValue") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsSensitive") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ValueTypeId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code", "IsActive") + .IsUnique() + .HasDatabaseName("IX_TenantParameters_TenantId_Code_IsActive") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("TenantParameters", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyReference") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultSystemSuiteId") + .HasColumnType("uuid"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IdpStrategyId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsManagementOwner") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationTypeId") + .HasColumnType("integer"); + + b.Property("ParentTenantId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("IsManagementOwner") + .IsUnique() + .HasDatabaseName("IX_Tenants_SingleManagementOwner") + .HasFilter("\"IsManagementOwner\" = true"); + + b.HasIndex("ParentTenantId"); + + b.ToTable("Tenants", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantSignupRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedTenantId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyReference") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyReference") + .IsUnique(); + + b.HasIndex("ContactEmail"); + + b.HasIndex("StatusId"); + + b.ToTable("TenantSignupRequests", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MethodId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId", "MethodId"); + + b.ToTable("UserAccountMfaEnrollments", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.ToTable("UserAccountPasswordCredentials", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FailedLoginAttempts") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("IdentityReference") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IdentityReferenceTypeId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LockedUntilUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Email") + .IsUnique(); + + b.ToTable("UserAccounts", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserManagementDelegationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedActionsJson") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DelegatedAdminId") + .HasColumnType("uuid"); + + b.Property("DelegatingAdminId") + .HasColumnType("uuid"); + + b.Property("MaxDurationDays") + .HasColumnType("integer"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RevocationReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("uuid"); + + b.Property("ScopeTypeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DelegatedAdminId"); + + b.HasIndex("DelegatingAdminId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("UserManagementDelegations", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RoleMaturityStatusRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BlockingFactor") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CompletedCertificationsCount") + .HasColumnType("integer"); + + b.Property("CompletedTrainingsCount") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentLevelSince") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentMaturityLevel") + .HasColumnType("integer"); + + b.Property("EligibleForPromotionAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HasNoComplianceIssues") + .HasColumnType("boolean"); + + b.Property("LastReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NextEligibleMaturityLevel") + .HasColumnType("integer"); + + b.Property("PerformanceScore") + .HasColumnType("numeric(4,2)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId") + .IsUnique(); + + b.ToTable("RoleMaturityStatuses", "iga"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RolePromotionRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApproverId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentRoleId") + .HasColumnType("uuid"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ExecutorId") + .HasColumnType("uuid"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("RiskScore") + .HasColumnType("integer"); + + b.Property("SecurityReviewerId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetRoleId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("VerifierId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("RolePromotionRequests", "iga"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", "UserDocument") + .WithMany("Notifications") + .HasForeignKey("UserDocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserDocument"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", null) + .WithMany("RequiredDocuments") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", "Template") + .WithMany("Items") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", "Profile") + .WithMany("Permissions") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("ParentRoleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Actions") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("AppSettings") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("DomainResources") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Modules") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Node") + .WithMany("Actions") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") + .WithMany("Nodes") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Parent") + .WithMany("Children") + .HasForeignKey("ParentNodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Module"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("Criteria") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("EvaluationLogs") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("Branches") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("IdentityProviders") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("MfaEnrollments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("PasswordCredentials") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Navigation("RequiredDocuments"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Navigation("Notifications"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Navigation("Nodes"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("Children"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("AppSettings"); + + b.Navigation("DomainResources"); + + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Navigation("Criteria"); + + b.Navigation("EvaluationLogs"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Navigation("Branches"); + + b.Navigation("IdentityProviders"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Navigation("MfaEnrollments"); + + b.Navigation("PasswordCredentials"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802014735_AddModuleIcon.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802014735_AddModuleIcon.cs new file mode 100644 index 00000000..50ab96af --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802014735_AddModuleIcon.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + public partial class AddModuleIcon : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Icon", + schema: "ums_authorization", + table: "SystemSuiteModules", + type: "character varying(64)", + maxLength: 64, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Icon", + schema: "ums_authorization", + table: "SystemSuiteModules"); + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802060255_AddPasswordResetTokens.Designer.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802060255_AddPasswordResetTokens.Designer.cs new file mode 100644 index 00000000..8ce2d904 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802060255_AddPasswordResetTokens.Designer.cs @@ -0,0 +1,3237 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Ums.Infrastructure.Persistence; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + [DbContext(typeof(UmsPlatformDbContext))] + [Migration("20260802060255_AddPasswordResetTokens")] + partial class AddPasswordResetTokens + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ums_platform") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessEnforcementPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EnforcementActionId") + .HasColumnType("integer"); + + b.Property("GracePeriodDays") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ProfileId") + .HasFilter("\"ProfileId\" IS NOT NULL"); + + b.HasIndex("TenantId", "RoleId") + .HasFilter("\"RoleId\" IS NOT NULL"); + + b.ToTable("AccessEnforcementPolicies", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("DaysRemaining") + .HasColumnType("integer"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Step") + .HasColumnType("integer"); + + b.Property("UserDocumentId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserDocumentId"); + + b.HasIndex("UserDocumentId", "Step") + .IsUnique(); + + b.ToTable("UserDocumentNotifications", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GrantedRoleId") + .HasColumnType("uuid"); + + b.Property("Justification") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RequestedBranchId") + .HasColumnType("uuid"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("RequestedSystemId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetProfileId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetProfileId"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("TargetUserId", "RequestedSystemId", "RequestedBranchId", "StatusId"); + + b.ToTable("ApprovalRequests", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DocumentTypeId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("WorkflowId", "DocumentTypeId") + .IsUnique(); + + b.ToTable("ApprovalRequiredDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TargetUserCategoryId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("ApprovalWorkflows", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.DocumentTypeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("DocumentTypes", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.NotificationRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("NotificationRules", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("FileChecksum") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("FileStoragePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IssueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NotificationStep") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("StatusId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "DocumentTypeId"); + + b.ToTable("UserDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Audit.Entities.AuditRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AffectedEntityId") + .HasColumnType("uuid"); + + b.Property("AffectedEntityType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AuditResultId") + .HasColumnType("integer"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Metadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RootTenantId") + .HasColumnType("uuid"); + + b.Property("SubjectTypeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WhatChanged") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("WhenOccurred") + .HasColumnType("timestamp with time zone"); + + b.Property("WhoActed") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AffectedEntityId"); + + b.HasIndex("EventType"); + + b.HasIndex("RootTenantId"); + + b.HasIndex("WhoActed"); + + b.HasIndex("AffectedEntityId", "AffectedEntityType"); + + b.ToTable("AuditRecords", "audit"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TargetId", "IsActive") + .HasDatabaseName("IX_PermissionTemplateItems_TargetId_IsActive"); + + b.HasIndex("TemplateId", "TargetTypeId", "TargetId", "ActionId") + .IsUnique(); + + b.ToTable("PermissionTemplateItems", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "SystemSuiteId", "Version") + .IsUnique(); + + b.ToTable("PermissionTemplates", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("IsOverride") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("TemplateId") + .HasDatabaseName("IX_ProfilePermissions_TemplateId"); + + b.HasIndex("ProfileId", "TemplateId", "ActionId", "TargetId"); + + b.ToTable("ProfilePermissions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId", "RoleId", "BranchId"); + + b.HasIndex(new[] { "UserId" }, "IX_Profiles_UserId"); + + b.HasIndex(new[] { "UserId" }, "IX_Profiles_UserId_Active") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("Profiles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HierarchyLevel") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ParentRoleId") + .HasColumnType("uuid"); + + b.Property("PromotionOrder") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("ParentRoleId"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("Roles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsClientVisible") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "ConfigKey", "ScopeId") + .IsUnique(); + + b.ToTable("SystemSuiteAppSettings", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ModuleId") + .HasDatabaseName("IX_SystemSuiteDomainResources_ModuleId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteDomainResources", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Icon") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteModules", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "ActionCode") + .IsUnique(); + + b.ToTable("SystemSuiteNodeActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ComponenteTecnico") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Criticidad") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Dependencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Evidencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Icon") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("NodeKindId") + .HasColumnType("integer"); + + b.Property("ParentNodeId") + .HasColumnType("uuid"); + + b.Property("ProductoImpactado") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Responsable") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Route") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TrazabilidadSdlc") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ParentNodeId"); + + b.HasIndex("ModuleId", "ParentNodeId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteNodes", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("SystemSuites", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.TemplateAssignmentRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateAssignmentRules", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.AppConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsEncrypted") + .HasColumnType("boolean"); + + b.Property("IsInheritable") + .HasColumnType("boolean"); + + b.Property("IsNonOverridable") + .HasColumnType("boolean"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ModuleId", "Code") + .IsUnique(); + + b.ToTable("AppConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CriteriaType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagCriteria", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Result") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("EvaluatedAtUtc"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagEvaluationLogs", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagTargets") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FlagTypeId") + .HasColumnType("integer"); + + b.Property("LinkedResourceId") + .HasColumnType("uuid"); + + b.Property("LinkedResourceTypeId") + .HasColumnType("integer"); + + b.Property("RolloutPercentage") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("FlagTypeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("SystemSuiteId", "FlagCode") + .IsUnique() + .HasFilter("\"StatusId\" != 3"); + + b.ToTable("FeatureFlags", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.IdpConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigPayload") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DomainHintsJson") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FallbackToId") + .HasColumnType("uuid"); + + b.Property("ProviderTypeId") + .HasColumnType("integer"); + + b.Property("ResolutionPriority") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SecretRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderTypeId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ResolutionPriority"); + + b.ToTable("IdpConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DataTypeId") + .HasColumnType("integer"); + + b.Property("DefaultValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive"); + + b.HasIndex("ScopeId"); + + b.ToTable("ParameterDefinitions", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterGlobalValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EffectiveValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ParameterDefinitionId") + .IsUnique(); + + b.HasIndex("StatusId"); + + b.ToTable("ParameterGlobalValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterTenantValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ParameterDefinitionId") + .IsUnique(); + + b.ToTable("ParameterTenantValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.PasswordResetTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsumedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("InvalidatedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("PasswordResetTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.RefreshTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("uuid"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RenewalCount") + .HasColumnType("integer"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("RefreshTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("GeofencingMetadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantBranches", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StrategyId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantIdentityProviders", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantParameterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedValues") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultValue") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsSensitive") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ValueTypeId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code", "IsActive") + .IsUnique() + .HasDatabaseName("IX_TenantParameters_TenantId_Code_IsActive") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("TenantParameters", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyReference") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultSystemSuiteId") + .HasColumnType("uuid"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IdpStrategyId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsManagementOwner") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationTypeId") + .HasColumnType("integer"); + + b.Property("ParentTenantId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("IsManagementOwner") + .IsUnique() + .HasDatabaseName("IX_Tenants_SingleManagementOwner") + .HasFilter("\"IsManagementOwner\" = true"); + + b.HasIndex("ParentTenantId"); + + b.ToTable("Tenants", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantSignupRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedTenantId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyReference") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyReference") + .IsUnique(); + + b.HasIndex("ContactEmail"); + + b.HasIndex("StatusId"); + + b.ToTable("TenantSignupRequests", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MethodId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId", "MethodId"); + + b.ToTable("UserAccountMfaEnrollments", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.ToTable("UserAccountPasswordCredentials", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FailedLoginAttempts") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("IdentityReference") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IdentityReferenceTypeId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LockedUntilUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Email") + .IsUnique(); + + b.ToTable("UserAccounts", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserManagementDelegationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedActionsJson") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DelegatedAdminId") + .HasColumnType("uuid"); + + b.Property("DelegatingAdminId") + .HasColumnType("uuid"); + + b.Property("MaxDurationDays") + .HasColumnType("integer"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RevocationReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("uuid"); + + b.Property("ScopeTypeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DelegatedAdminId"); + + b.HasIndex("DelegatingAdminId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("UserManagementDelegations", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RoleMaturityStatusRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BlockingFactor") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CompletedCertificationsCount") + .HasColumnType("integer"); + + b.Property("CompletedTrainingsCount") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentLevelSince") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentMaturityLevel") + .HasColumnType("integer"); + + b.Property("EligibleForPromotionAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HasNoComplianceIssues") + .HasColumnType("boolean"); + + b.Property("LastReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NextEligibleMaturityLevel") + .HasColumnType("integer"); + + b.Property("PerformanceScore") + .HasColumnType("numeric(4,2)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId") + .IsUnique(); + + b.ToTable("RoleMaturityStatuses", "iga"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RolePromotionRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApproverId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentRoleId") + .HasColumnType("uuid"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ExecutorId") + .HasColumnType("uuid"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("RiskScore") + .HasColumnType("integer"); + + b.Property("SecurityReviewerId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetRoleId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("VerifierId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("RolePromotionRequests", "iga"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", "UserDocument") + .WithMany("Notifications") + .HasForeignKey("UserDocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserDocument"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", null) + .WithMany("RequiredDocuments") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", "Template") + .WithMany("Items") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", "Profile") + .WithMany("Permissions") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("ParentRoleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Actions") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("AppSettings") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("DomainResources") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Modules") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Node") + .WithMany("Actions") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") + .WithMany("Nodes") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Parent") + .WithMany("Children") + .HasForeignKey("ParentNodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Module"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("Criteria") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("EvaluationLogs") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("Branches") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("IdentityProviders") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("MfaEnrollments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("PasswordCredentials") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Navigation("RequiredDocuments"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Navigation("Notifications"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Navigation("Nodes"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("Children"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("AppSettings"); + + b.Navigation("DomainResources"); + + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Navigation("Criteria"); + + b.Navigation("EvaluationLogs"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Navigation("Branches"); + + b.Navigation("IdentityProviders"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Navigation("MfaEnrollments"); + + b.Navigation("PasswordCredentials"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802060255_AddPasswordResetTokens.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802060255_AddPasswordResetTokens.cs new file mode 100644 index 00000000..f21dcb29 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260802060255_AddPasswordResetTokens.cs @@ -0,0 +1,62 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + public partial class AddPasswordResetTokens : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PasswordResetTokens", + schema: "ums_identity", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TenantId = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + TokenHash = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + IssuedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + ExpiresAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + ConsumedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + InvalidatedReason = table.Column(type: "character varying(60)", maxLength: 60, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_PasswordResetTokens", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_PasswordResetTokens_ExpiresAtUtc", + schema: "ums_identity", + table: "PasswordResetTokens", + column: "ExpiresAtUtc"); + + migrationBuilder.CreateIndex( + name: "IX_PasswordResetTokens_TenantId_UserId", + schema: "ums_identity", + table: "PasswordResetTokens", + columns: new[] { "TenantId", "UserId" }); + + migrationBuilder.CreateIndex( + name: "IX_PasswordResetTokens_TokenHash", + schema: "ums_identity", + table: "PasswordResetTokens", + column: "TokenHash", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PasswordResetTokens", + schema: "ums_identity"); + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804135513_FixRlsTenantComparisonTypeMismatch.Designer.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804135513_FixRlsTenantComparisonTypeMismatch.Designer.cs new file mode 100644 index 00000000..8e86ceb4 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804135513_FixRlsTenantComparisonTypeMismatch.Designer.cs @@ -0,0 +1,3237 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Ums.Infrastructure.Persistence; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + [DbContext(typeof(UmsPlatformDbContext))] + [Migration("20260804135513_FixRlsTenantComparisonTypeMismatch")] + partial class FixRlsTenantComparisonTypeMismatch + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ums_platform") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessEnforcementPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EnforcementActionId") + .HasColumnType("integer"); + + b.Property("GracePeriodDays") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ProfileId") + .HasFilter("\"ProfileId\" IS NOT NULL"); + + b.HasIndex("TenantId", "RoleId") + .HasFilter("\"RoleId\" IS NOT NULL"); + + b.ToTable("AccessEnforcementPolicies", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("DaysRemaining") + .HasColumnType("integer"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Step") + .HasColumnType("integer"); + + b.Property("UserDocumentId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserDocumentId"); + + b.HasIndex("UserDocumentId", "Step") + .IsUnique(); + + b.ToTable("UserDocumentNotifications", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GrantedRoleId") + .HasColumnType("uuid"); + + b.Property("Justification") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RequestedBranchId") + .HasColumnType("uuid"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("RequestedSystemId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetProfileId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetProfileId"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("TargetUserId", "RequestedSystemId", "RequestedBranchId", "StatusId"); + + b.ToTable("ApprovalRequests", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DocumentTypeId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("WorkflowId", "DocumentTypeId") + .IsUnique(); + + b.ToTable("ApprovalRequiredDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TargetUserCategoryId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("ApprovalWorkflows", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.DocumentTypeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("DocumentTypes", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.NotificationRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("NotificationRules", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("FileChecksum") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("FileStoragePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IssueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NotificationStep") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("StatusId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "DocumentTypeId"); + + b.ToTable("UserDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Audit.Entities.AuditRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AffectedEntityId") + .HasColumnType("uuid"); + + b.Property("AffectedEntityType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AuditResultId") + .HasColumnType("integer"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Metadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RootTenantId") + .HasColumnType("uuid"); + + b.Property("SubjectTypeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WhatChanged") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("WhenOccurred") + .HasColumnType("timestamp with time zone"); + + b.Property("WhoActed") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AffectedEntityId"); + + b.HasIndex("EventType"); + + b.HasIndex("RootTenantId"); + + b.HasIndex("WhoActed"); + + b.HasIndex("AffectedEntityId", "AffectedEntityType"); + + b.ToTable("AuditRecords", "audit"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TargetId", "IsActive") + .HasDatabaseName("IX_PermissionTemplateItems_TargetId_IsActive"); + + b.HasIndex("TemplateId", "TargetTypeId", "TargetId", "ActionId") + .IsUnique(); + + b.ToTable("PermissionTemplateItems", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "SystemSuiteId", "Version") + .IsUnique(); + + b.ToTable("PermissionTemplates", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("IsOverride") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("TemplateId") + .HasDatabaseName("IX_ProfilePermissions_TemplateId"); + + b.HasIndex("ProfileId", "TemplateId", "ActionId", "TargetId"); + + b.ToTable("ProfilePermissions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId", "RoleId", "BranchId"); + + b.HasIndex(new[] { "UserId" }, "IX_Profiles_UserId"); + + b.HasIndex(new[] { "UserId" }, "IX_Profiles_UserId_Active") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("Profiles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HierarchyLevel") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ParentRoleId") + .HasColumnType("uuid"); + + b.Property("PromotionOrder") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("ParentRoleId"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("Roles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsClientVisible") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "ConfigKey", "ScopeId") + .IsUnique(); + + b.ToTable("SystemSuiteAppSettings", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ModuleId") + .HasDatabaseName("IX_SystemSuiteDomainResources_ModuleId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteDomainResources", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Icon") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteModules", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "ActionCode") + .IsUnique(); + + b.ToTable("SystemSuiteNodeActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ComponenteTecnico") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Criticidad") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Dependencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Evidencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Icon") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("NodeKindId") + .HasColumnType("integer"); + + b.Property("ParentNodeId") + .HasColumnType("uuid"); + + b.Property("ProductoImpactado") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Responsable") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Route") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TrazabilidadSdlc") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ParentNodeId"); + + b.HasIndex("ModuleId", "ParentNodeId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteNodes", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("SystemSuites", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.TemplateAssignmentRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateAssignmentRules", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.AppConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsEncrypted") + .HasColumnType("boolean"); + + b.Property("IsInheritable") + .HasColumnType("boolean"); + + b.Property("IsNonOverridable") + .HasColumnType("boolean"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ModuleId", "Code") + .IsUnique(); + + b.ToTable("AppConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CriteriaType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagCriteria", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Result") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("EvaluatedAtUtc"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagEvaluationLogs", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagTargets") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FlagTypeId") + .HasColumnType("integer"); + + b.Property("LinkedResourceId") + .HasColumnType("uuid"); + + b.Property("LinkedResourceTypeId") + .HasColumnType("integer"); + + b.Property("RolloutPercentage") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("FlagTypeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("SystemSuiteId", "FlagCode") + .IsUnique() + .HasFilter("\"StatusId\" != 3"); + + b.ToTable("FeatureFlags", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.IdpConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigPayload") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DomainHintsJson") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FallbackToId") + .HasColumnType("uuid"); + + b.Property("ProviderTypeId") + .HasColumnType("integer"); + + b.Property("ResolutionPriority") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SecretRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderTypeId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ResolutionPriority"); + + b.ToTable("IdpConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DataTypeId") + .HasColumnType("integer"); + + b.Property("DefaultValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive"); + + b.HasIndex("ScopeId"); + + b.ToTable("ParameterDefinitions", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterGlobalValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EffectiveValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ParameterDefinitionId") + .IsUnique(); + + b.HasIndex("StatusId"); + + b.ToTable("ParameterGlobalValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterTenantValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ParameterDefinitionId") + .IsUnique(); + + b.ToTable("ParameterTenantValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.PasswordResetTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsumedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("InvalidatedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("PasswordResetTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.RefreshTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("uuid"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RenewalCount") + .HasColumnType("integer"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("RefreshTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("GeofencingMetadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantBranches", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StrategyId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantIdentityProviders", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantParameterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedValues") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultValue") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsSensitive") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ValueTypeId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code", "IsActive") + .IsUnique() + .HasDatabaseName("IX_TenantParameters_TenantId_Code_IsActive") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("TenantParameters", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyReference") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultSystemSuiteId") + .HasColumnType("uuid"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IdpStrategyId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsManagementOwner") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationTypeId") + .HasColumnType("integer"); + + b.Property("ParentTenantId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("IsManagementOwner") + .IsUnique() + .HasDatabaseName("IX_Tenants_SingleManagementOwner") + .HasFilter("\"IsManagementOwner\" = true"); + + b.HasIndex("ParentTenantId"); + + b.ToTable("Tenants", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantSignupRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedTenantId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyReference") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyReference") + .IsUnique(); + + b.HasIndex("ContactEmail"); + + b.HasIndex("StatusId"); + + b.ToTable("TenantSignupRequests", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MethodId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId", "MethodId"); + + b.ToTable("UserAccountMfaEnrollments", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.ToTable("UserAccountPasswordCredentials", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FailedLoginAttempts") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("IdentityReference") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IdentityReferenceTypeId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LockedUntilUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Email") + .IsUnique(); + + b.ToTable("UserAccounts", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserManagementDelegationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedActionsJson") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DelegatedAdminId") + .HasColumnType("uuid"); + + b.Property("DelegatingAdminId") + .HasColumnType("uuid"); + + b.Property("MaxDurationDays") + .HasColumnType("integer"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RevocationReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("uuid"); + + b.Property("ScopeTypeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DelegatedAdminId"); + + b.HasIndex("DelegatingAdminId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("UserManagementDelegations", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RoleMaturityStatusRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BlockingFactor") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CompletedCertificationsCount") + .HasColumnType("integer"); + + b.Property("CompletedTrainingsCount") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentLevelSince") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentMaturityLevel") + .HasColumnType("integer"); + + b.Property("EligibleForPromotionAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HasNoComplianceIssues") + .HasColumnType("boolean"); + + b.Property("LastReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NextEligibleMaturityLevel") + .HasColumnType("integer"); + + b.Property("PerformanceScore") + .HasColumnType("numeric(4,2)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId") + .IsUnique(); + + b.ToTable("RoleMaturityStatuses", "iga"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RolePromotionRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApproverId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentRoleId") + .HasColumnType("uuid"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ExecutorId") + .HasColumnType("uuid"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("RiskScore") + .HasColumnType("integer"); + + b.Property("SecurityReviewerId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetRoleId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("VerifierId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("RolePromotionRequests", "iga"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", "UserDocument") + .WithMany("Notifications") + .HasForeignKey("UserDocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserDocument"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", null) + .WithMany("RequiredDocuments") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", "Template") + .WithMany("Items") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", "Profile") + .WithMany("Permissions") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("ParentRoleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Actions") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("AppSettings") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("DomainResources") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Modules") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Node") + .WithMany("Actions") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") + .WithMany("Nodes") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Parent") + .WithMany("Children") + .HasForeignKey("ParentNodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Module"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("Criteria") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("EvaluationLogs") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("Branches") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("IdentityProviders") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("MfaEnrollments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("PasswordCredentials") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Navigation("RequiredDocuments"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Navigation("Notifications"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Navigation("Nodes"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("Children"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("AppSettings"); + + b.Navigation("DomainResources"); + + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Navigation("Criteria"); + + b.Navigation("EvaluationLogs"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Navigation("Branches"); + + b.Navigation("IdentityProviders"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Navigation("MfaEnrollments"); + + b.Navigation("PasswordCredentials"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804135513_FixRlsTenantComparisonTypeMismatch.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804135513_FixRlsTenantComparisonTypeMismatch.cs new file mode 100644 index 00000000..a7797f1a --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804135513_FixRlsTenantComparisonTypeMismatch.cs @@ -0,0 +1,144 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + /// G-173: reescribe el predicado de la politica tenant_isolation_policy creada en + /// 20260720152552_EnableRowLevelSecurity para que la comparacion por inquilino sea + /// del mismo tipo por ambos lados y deje de costar una llamada por fila. + /// + /// Que estaba mal. El predicado original era + /// current_setting('app.current_organization_id', true) = '' OR "TenantId"::text = current_setting(...). + /// Dos defectos independientes: + /// + /// + /// "TenantId"::text compara uuid convertido a texto contra texto. El indice + /// IX_*_TenantId esta construido sobre el uuid, no sobre su representacion textual, + /// asi que el predicado nunca puede resolverse por indice; ademas obliga a materializar + /// una cadena de 36 bytes por cada fila evaluada. + /// + /// + /// current_setting es STABLE, no IMMUTABLE: PostgreSQL no la pliega en tiempo de + /// planificacion y la invoca una vez por fila. Con el OR por delante, el + /// coste se paga sobre todas las filas de la tabla, no sobre las del inquilino. + /// + /// + /// + /// Que hace este arreglo. Envuelve cada lectura de la GUC en una subconsulta + /// escalar sin correlacion. PostgreSQL la convierte en un InitPlan y la evalua + /// una sola vez por sentencia, dejando en el filtro una simple comparacion contra un + /// parametro de ejecucion; y compara uuid = uuid, sin conversion. Medido sobre 600.000 + /// filas y 300 inquilinos, el plan de la ruta caliente pasa de + /// Filter: (current_setting(...) = '' OR ("TenantId")::text = current_setting(...)) + /// a Filter: (($0 = ''::text) OR ("TenantId" = $1)): mismo Index Cond sobre + /// IX_*_TenantId, entre 3x y 5x menos tiempo de ejecucion. + /// + /// Por que se conserva el OR. La compuerta "GUC vacia = sin restriccion" + /// es contractual (admin interno, sistema y siembra; ver OrganizationDbContextInterceptor) + /// y no puede expresarse como igualdad. Se probo sustituirla por un rango conjuntivo + /// ("TenantId" BETWEEN $0 AND $1), que si convierte el propio predicado RLS en + /// Index Cond; se descarto porque el planificador no conoce los parametros y elige ese + /// indice tambien en modo bypass, donde debe leer la tabla entera: 129.354 buffers frente a + /// 7.999 del recorrido secuencial. Se prefiere no degradar siembra ni migraciones. + /// + /// Semantica preservada, bit a bit. En particular el caso fail-closed: + /// si la GUC no se fijo nunca en la sesion, current_setting(..., true) devuelve NULL, + /// NULL = '' es NULL y la fila queda oculta. Por eso la compuerta compara el texto + /// crudo contra '' y NO usa NULLIF(...) IS NULL: esa variante, aparentemente + /// equivalente, trata la GUC ausente como "sin restriccion" y abre la tabla completa + /// (verificado: 600.000 filas visibles en lugar de 0). + /// + /// Efecto colateral positivo: al evaluarse en el lider y propagarse como parametro + /// (Params Evaluated), los planes paralelos dejan de depender de que cada worker + /// reciba la GUC, un caso en el que la politica anterior devolvia recuentos incompletos. + /// + /// Referencia: ADR-0111 (arquitectura de datos PostgreSQL) y ADR-0010 (el aislamiento + /// vive en RBAC/ABAC de aplicacion; la RLS es defensa en profundidad, no el control primario). + /// + public partial class FixRlsTenantComparisonTypeMismatch : Migration + { + // Mismo conjunto de tablas que 20260720152552_EnableRowLevelSecurity: si esa lista cambia, + // esta debe cambiar con ella. El flag indica TenantId anulable (filas globales). + private static readonly (string Schema, string TableName, bool IsGlobalNullable)[] Tables = + [ + ("ums_identity", "TenantBranches", false), + ("ums_identity", "TenantParameters", false), + ("ums_identity", "TenantIdentityProviders", false), + ("ums_identity", "UserAccounts", false), + ("ums_authorization", "Profiles", false), + ("ums_authorization", "Roles", false), + ("ums_authorization", "SystemSuites", false), + ("ums_authorization", "PermissionTemplates", false), + ("ums_identity", "UserManagementDelegations", false), + ("ums_configuration", "IdpConfigurations", false), + ("ums_configuration", "AppConfigurations", true), + ("ums_configuration", "ParameterTenantValues", false), + ("approvals", "ApprovalWorkflows", false), + ("approvals", "NotificationRules", false), + ("approvals", "DocumentTypes", false), + ("approvals", "AccessEnforcementPolicies", false), + ("iga", "RoleMaturityStatuses", false), + ("iga", "RolePromotionRequests", false) + ]; + + // Subconsulta escalar sin correlacion: PostgreSQL la resuelve como InitPlan, una vez por + // sentencia, en lugar de invocar current_setting() una vez por fila. + private const string GucRaw = + "(SELECT current_setting('app.current_organization_id', true))"; + + // NULLIF(...,'') distingue "sin restriccion" (cadena vacia) de un identificador real; el + // cast a uuid se aplica al PARAMETRO, no a la columna, que es lo que mantiene el indice usable. + private const string GucAsUuid = + "(SELECT NULLIF(current_setting('app.current_organization_id', true), '')::uuid)"; + + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + foreach (var t in Tables) + { + // El predicado nuevo. El orden de los terminos importa poco al planificador, pero + // deja primero la compuerta para que el modo bypass corte sin tocar el InitPlan uuid. + var policyUsing = t.IsGlobalNullable + ? $@"{GucRaw} = '' OR ""TenantId"" IS NULL OR ""TenantId"" = {GucAsUuid}" + : $@"{GucRaw} = '' OR ""TenantId"" = {GucAsUuid}"; + + // DROP + CREATE en lugar de ALTER POLICY: la migracion corre dentro de una + // transaccion, asi que no existe ventana en la que la tabla quede sin politica + // (con FORCE ROW LEVEL SECURITY eso equivaldria a denegar cualquier lectura). + migrationBuilder.Sql( + $@"DROP POLICY IF EXISTS tenant_isolation_policy ON ""{t.Schema}"".""{t.TableName}"";"); + + migrationBuilder.Sql($@" + CREATE POLICY tenant_isolation_policy ON ""{t.Schema}"".""{t.TableName}"" + FOR ALL + USING ({policyUsing}); + "); + } + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // Restaura literalmente el predicado de 20260720152552_EnableRowLevelSecurity, + // incluida la conversion a texto: el Down debe devolver la base al estado anterior, + // no a uno "mejor". + foreach (var t in Tables) + { + var policyUsing = t.IsGlobalNullable + ? @"current_setting('app.current_organization_id', true) = '' OR ""TenantId"" IS NULL OR ""TenantId""::text = current_setting('app.current_organization_id', true)" + : @"current_setting('app.current_organization_id', true) = '' OR ""TenantId""::text = current_setting('app.current_organization_id', true)"; + + migrationBuilder.Sql( + $@"DROP POLICY IF EXISTS tenant_isolation_policy ON ""{t.Schema}"".""{t.TableName}"";"); + + migrationBuilder.Sql($@" + CREATE POLICY tenant_isolation_policy ON ""{t.Schema}"".""{t.TableName}"" + FOR ALL + USING ({policyUsing}); + "); + } + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804161744_AddParameterDefinitionSoftDelete.Designer.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804161744_AddParameterDefinitionSoftDelete.Designer.cs new file mode 100644 index 00000000..fe0fd00a --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804161744_AddParameterDefinitionSoftDelete.Designer.cs @@ -0,0 +1,3249 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Ums.Infrastructure.Persistence; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + [DbContext(typeof(UmsPlatformDbContext))] + [Migration("20260804161744_AddParameterDefinitionSoftDelete")] + partial class AddParameterDefinitionSoftDelete + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ums_platform") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessEnforcementPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EnforcementActionId") + .HasColumnType("integer"); + + b.Property("GracePeriodDays") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ProfileId") + .HasFilter("\"ProfileId\" IS NOT NULL"); + + b.HasIndex("TenantId", "RoleId") + .HasFilter("\"RoleId\" IS NOT NULL"); + + b.ToTable("AccessEnforcementPolicies", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("DaysRemaining") + .HasColumnType("integer"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Step") + .HasColumnType("integer"); + + b.Property("UserDocumentId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserDocumentId"); + + b.HasIndex("UserDocumentId", "Step") + .IsUnique(); + + b.ToTable("UserDocumentNotifications", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GrantedRoleId") + .HasColumnType("uuid"); + + b.Property("Justification") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RequestedBranchId") + .HasColumnType("uuid"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("RequestedSystemId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetProfileId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetProfileId"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("TargetUserId", "RequestedSystemId", "RequestedBranchId", "StatusId"); + + b.ToTable("ApprovalRequests", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DocumentTypeId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("WorkflowId", "DocumentTypeId") + .IsUnique(); + + b.ToTable("ApprovalRequiredDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TargetUserCategoryId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("ApprovalWorkflows", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.DocumentTypeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("DocumentTypes", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.NotificationRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("NotificationRules", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("FileChecksum") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("FileStoragePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IssueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NotificationStep") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("StatusId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "DocumentTypeId"); + + b.ToTable("UserDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Audit.Entities.AuditRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AffectedEntityId") + .HasColumnType("uuid"); + + b.Property("AffectedEntityType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AuditResultId") + .HasColumnType("integer"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Metadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RootTenantId") + .HasColumnType("uuid"); + + b.Property("SubjectTypeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WhatChanged") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("WhenOccurred") + .HasColumnType("timestamp with time zone"); + + b.Property("WhoActed") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AffectedEntityId"); + + b.HasIndex("EventType"); + + b.HasIndex("RootTenantId"); + + b.HasIndex("WhoActed"); + + b.HasIndex("AffectedEntityId", "AffectedEntityType"); + + b.ToTable("AuditRecords", "audit"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TargetId", "IsActive") + .HasDatabaseName("IX_PermissionTemplateItems_TargetId_IsActive"); + + b.HasIndex("TemplateId", "TargetTypeId", "TargetId", "ActionId") + .IsUnique(); + + b.ToTable("PermissionTemplateItems", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "SystemSuiteId", "Version") + .IsUnique(); + + b.ToTable("PermissionTemplates", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("IsOverride") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("TemplateId") + .HasDatabaseName("IX_ProfilePermissions_TemplateId"); + + b.HasIndex("ProfileId", "TemplateId", "ActionId", "TargetId"); + + b.ToTable("ProfilePermissions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId", "RoleId", "BranchId"); + + b.HasIndex(new[] { "UserId" }, "IX_Profiles_UserId"); + + b.HasIndex(new[] { "UserId" }, "IX_Profiles_UserId_Active") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("Profiles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HierarchyLevel") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ParentRoleId") + .HasColumnType("uuid"); + + b.Property("PromotionOrder") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("ParentRoleId"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("Roles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsClientVisible") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "ConfigKey", "ScopeId") + .IsUnique(); + + b.ToTable("SystemSuiteAppSettings", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ModuleId") + .HasDatabaseName("IX_SystemSuiteDomainResources_ModuleId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteDomainResources", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Icon") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteModules", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "ActionCode") + .IsUnique(); + + b.ToTable("SystemSuiteNodeActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ComponenteTecnico") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Criticidad") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Dependencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Evidencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Icon") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("NodeKindId") + .HasColumnType("integer"); + + b.Property("ParentNodeId") + .HasColumnType("uuid"); + + b.Property("ProductoImpactado") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Responsable") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Route") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TrazabilidadSdlc") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ParentNodeId"); + + b.HasIndex("ModuleId", "ParentNodeId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteNodes", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("SystemSuites", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.TemplateAssignmentRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateAssignmentRules", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.AppConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsEncrypted") + .HasColumnType("boolean"); + + b.Property("IsInheritable") + .HasColumnType("boolean"); + + b.Property("IsNonOverridable") + .HasColumnType("boolean"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ModuleId", "Code") + .IsUnique(); + + b.ToTable("AppConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CriteriaType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagCriteria", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Result") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("EvaluatedAtUtc"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagEvaluationLogs", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagTargets") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FlagTypeId") + .HasColumnType("integer"); + + b.Property("LinkedResourceId") + .HasColumnType("uuid"); + + b.Property("LinkedResourceTypeId") + .HasColumnType("integer"); + + b.Property("RolloutPercentage") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("FlagTypeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("SystemSuiteId", "FlagCode") + .IsUnique() + .HasFilter("\"StatusId\" != 3"); + + b.ToTable("FeatureFlags", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.IdpConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigPayload") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DomainHintsJson") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FallbackToId") + .HasColumnType("uuid"); + + b.Property("ProviderTypeId") + .HasColumnType("integer"); + + b.Property("ResolutionPriority") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SecretRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderTypeId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ResolutionPriority"); + + b.ToTable("IdpConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DataTypeId") + .HasColumnType("integer"); + + b.Property("DefaultValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive"); + + b.HasIndex("IsDeleted"); + + b.HasIndex("ScopeId"); + + b.ToTable("ParameterDefinitions", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterGlobalValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EffectiveValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ParameterDefinitionId") + .IsUnique(); + + b.HasIndex("StatusId"); + + b.ToTable("ParameterGlobalValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterTenantValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ParameterDefinitionId") + .IsUnique(); + + b.ToTable("ParameterTenantValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.PasswordResetTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsumedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("InvalidatedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("PasswordResetTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.RefreshTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("uuid"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RenewalCount") + .HasColumnType("integer"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("RefreshTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("GeofencingMetadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantBranches", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StrategyId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantIdentityProviders", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantParameterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedValues") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultValue") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsSensitive") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ValueTypeId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code", "IsActive") + .IsUnique() + .HasDatabaseName("IX_TenantParameters_TenantId_Code_IsActive") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("TenantParameters", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyReference") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultSystemSuiteId") + .HasColumnType("uuid"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IdpStrategyId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsManagementOwner") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationTypeId") + .HasColumnType("integer"); + + b.Property("ParentTenantId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("IsManagementOwner") + .IsUnique() + .HasDatabaseName("IX_Tenants_SingleManagementOwner") + .HasFilter("\"IsManagementOwner\" = true"); + + b.HasIndex("ParentTenantId"); + + b.ToTable("Tenants", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantSignupRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedTenantId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyReference") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyReference") + .IsUnique(); + + b.HasIndex("ContactEmail"); + + b.HasIndex("StatusId"); + + b.ToTable("TenantSignupRequests", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MethodId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId", "MethodId"); + + b.ToTable("UserAccountMfaEnrollments", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.ToTable("UserAccountPasswordCredentials", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FailedLoginAttempts") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("IdentityReference") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IdentityReferenceTypeId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LockedUntilUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Email") + .IsUnique(); + + b.ToTable("UserAccounts", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserManagementDelegationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedActionsJson") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DelegatedAdminId") + .HasColumnType("uuid"); + + b.Property("DelegatingAdminId") + .HasColumnType("uuid"); + + b.Property("MaxDurationDays") + .HasColumnType("integer"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RevocationReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("uuid"); + + b.Property("ScopeTypeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DelegatedAdminId"); + + b.HasIndex("DelegatingAdminId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("UserManagementDelegations", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RoleMaturityStatusRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BlockingFactor") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CompletedCertificationsCount") + .HasColumnType("integer"); + + b.Property("CompletedTrainingsCount") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentLevelSince") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentMaturityLevel") + .HasColumnType("integer"); + + b.Property("EligibleForPromotionAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HasNoComplianceIssues") + .HasColumnType("boolean"); + + b.Property("LastReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NextEligibleMaturityLevel") + .HasColumnType("integer"); + + b.Property("PerformanceScore") + .HasColumnType("numeric(4,2)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId") + .IsUnique(); + + b.ToTable("RoleMaturityStatuses", "iga"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RolePromotionRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApproverId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentRoleId") + .HasColumnType("uuid"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ExecutorId") + .HasColumnType("uuid"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("RiskScore") + .HasColumnType("integer"); + + b.Property("SecurityReviewerId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetRoleId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("VerifierId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("RolePromotionRequests", "iga"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", "UserDocument") + .WithMany("Notifications") + .HasForeignKey("UserDocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserDocument"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", null) + .WithMany("RequiredDocuments") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", "Template") + .WithMany("Items") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", "Profile") + .WithMany("Permissions") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("ParentRoleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Actions") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("AppSettings") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("DomainResources") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Modules") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Node") + .WithMany("Actions") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") + .WithMany("Nodes") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Parent") + .WithMany("Children") + .HasForeignKey("ParentNodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Module"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("Criteria") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("EvaluationLogs") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("Branches") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("IdentityProviders") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("MfaEnrollments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("PasswordCredentials") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Navigation("RequiredDocuments"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Navigation("Notifications"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Navigation("Nodes"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("Children"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("AppSettings"); + + b.Navigation("DomainResources"); + + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Navigation("Criteria"); + + b.Navigation("EvaluationLogs"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Navigation("Branches"); + + b.Navigation("IdentityProviders"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Navigation("MfaEnrollments"); + + b.Navigation("PasswordCredentials"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804161744_AddParameterDefinitionSoftDelete.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804161744_AddParameterDefinitionSoftDelete.cs new file mode 100644 index 00000000..0081fee7 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804161744_AddParameterDefinitionSoftDelete.cs @@ -0,0 +1,68 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + public partial class AddParameterDefinitionSoftDelete : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DeletedAtUtc", + schema: "ums_configuration", + table: "ParameterDefinitions", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "DeletedBy", + schema: "ums_configuration", + table: "ParameterDefinitions", + type: "character varying(100)", + maxLength: 100, + nullable: true); + + migrationBuilder.AddColumn( + name: "IsDeleted", + schema: "ums_configuration", + table: "ParameterDefinitions", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.CreateIndex( + name: "IX_ParameterDefinitions_IsDeleted", + schema: "ums_configuration", + table: "ParameterDefinitions", + column: "IsDeleted"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_ParameterDefinitions_IsDeleted", + schema: "ums_configuration", + table: "ParameterDefinitions"); + + migrationBuilder.DropColumn( + name: "DeletedAtUtc", + schema: "ums_configuration", + table: "ParameterDefinitions"); + + migrationBuilder.DropColumn( + name: "DeletedBy", + schema: "ums_configuration", + table: "ParameterDefinitions"); + + migrationBuilder.DropColumn( + name: "IsDeleted", + schema: "ums_configuration", + table: "ParameterDefinitions"); + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804164227_AddTenantParameterSoftDelete.Designer.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804164227_AddTenantParameterSoftDelete.Designer.cs new file mode 100644 index 00000000..2160533f --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804164227_AddTenantParameterSoftDelete.Designer.cs @@ -0,0 +1,3245 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Ums.Infrastructure.Persistence; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + [DbContext(typeof(UmsPlatformDbContext))] + [Migration("20260804164227_AddTenantParameterSoftDelete")] + partial class AddTenantParameterSoftDelete + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ums_platform") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessEnforcementPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EnforcementActionId") + .HasColumnType("integer"); + + b.Property("GracePeriodDays") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ProfileId") + .HasFilter("\"ProfileId\" IS NOT NULL"); + + b.HasIndex("TenantId", "RoleId") + .HasFilter("\"RoleId\" IS NOT NULL"); + + b.ToTable("AccessEnforcementPolicies", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("DaysRemaining") + .HasColumnType("integer"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Step") + .HasColumnType("integer"); + + b.Property("UserDocumentId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserDocumentId"); + + b.HasIndex("UserDocumentId", "Step") + .IsUnique(); + + b.ToTable("UserDocumentNotifications", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GrantedRoleId") + .HasColumnType("uuid"); + + b.Property("Justification") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RequestedBranchId") + .HasColumnType("uuid"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("RequestedSystemId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetProfileId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetProfileId"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("TargetUserId", "RequestedSystemId", "RequestedBranchId", "StatusId"); + + b.ToTable("ApprovalRequests", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DocumentTypeId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("WorkflowId", "DocumentTypeId") + .IsUnique(); + + b.ToTable("ApprovalRequiredDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TargetUserCategoryId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("ApprovalWorkflows", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.DocumentTypeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("DocumentTypes", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.NotificationRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("NotificationRules", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("FileChecksum") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("FileStoragePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IssueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NotificationStep") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("StatusId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "DocumentTypeId"); + + b.ToTable("UserDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Audit.Entities.AuditRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AffectedEntityId") + .HasColumnType("uuid"); + + b.Property("AffectedEntityType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AuditResultId") + .HasColumnType("integer"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Metadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RootTenantId") + .HasColumnType("uuid"); + + b.Property("SubjectTypeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WhatChanged") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("WhenOccurred") + .HasColumnType("timestamp with time zone"); + + b.Property("WhoActed") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AffectedEntityId"); + + b.HasIndex("EventType"); + + b.HasIndex("RootTenantId"); + + b.HasIndex("WhoActed"); + + b.HasIndex("AffectedEntityId", "AffectedEntityType"); + + b.ToTable("AuditRecords", "audit"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TargetId", "IsActive") + .HasDatabaseName("IX_PermissionTemplateItems_TargetId_IsActive"); + + b.HasIndex("TemplateId", "TargetTypeId", "TargetId", "ActionId") + .IsUnique(); + + b.ToTable("PermissionTemplateItems", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "SystemSuiteId", "Version") + .IsUnique(); + + b.ToTable("PermissionTemplates", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("IsOverride") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("TemplateId") + .HasDatabaseName("IX_ProfilePermissions_TemplateId"); + + b.HasIndex("ProfileId", "TemplateId", "ActionId", "TargetId"); + + b.ToTable("ProfilePermissions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId", "RoleId", "BranchId"); + + b.HasIndex(new[] { "UserId" }, "IX_Profiles_UserId"); + + b.HasIndex(new[] { "UserId" }, "IX_Profiles_UserId_Active") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("Profiles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HierarchyLevel") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ParentRoleId") + .HasColumnType("uuid"); + + b.Property("PromotionOrder") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("ParentRoleId"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("Roles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsClientVisible") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "ConfigKey", "ScopeId") + .IsUnique(); + + b.ToTable("SystemSuiteAppSettings", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ModuleId") + .HasDatabaseName("IX_SystemSuiteDomainResources_ModuleId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteDomainResources", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Icon") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteModules", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "ActionCode") + .IsUnique(); + + b.ToTable("SystemSuiteNodeActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ComponenteTecnico") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Criticidad") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Dependencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Evidencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Icon") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("NodeKindId") + .HasColumnType("integer"); + + b.Property("ParentNodeId") + .HasColumnType("uuid"); + + b.Property("ProductoImpactado") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Responsable") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Route") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TrazabilidadSdlc") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ParentNodeId"); + + b.HasIndex("ModuleId", "ParentNodeId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteNodes", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("SystemSuites", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.TemplateAssignmentRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateAssignmentRules", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.AppConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsEncrypted") + .HasColumnType("boolean"); + + b.Property("IsInheritable") + .HasColumnType("boolean"); + + b.Property("IsNonOverridable") + .HasColumnType("boolean"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ModuleId", "Code") + .IsUnique(); + + b.ToTable("AppConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CriteriaType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagCriteria", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Result") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("EvaluatedAtUtc"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagEvaluationLogs", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagTargets") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FlagTypeId") + .HasColumnType("integer"); + + b.Property("LinkedResourceId") + .HasColumnType("uuid"); + + b.Property("LinkedResourceTypeId") + .HasColumnType("integer"); + + b.Property("RolloutPercentage") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("FlagTypeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("SystemSuiteId", "FlagCode") + .IsUnique() + .HasFilter("\"StatusId\" != 3"); + + b.ToTable("FeatureFlags", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.IdpConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigPayload") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DomainHintsJson") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FallbackToId") + .HasColumnType("uuid"); + + b.Property("ProviderTypeId") + .HasColumnType("integer"); + + b.Property("ResolutionPriority") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SecretRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderTypeId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ResolutionPriority"); + + b.ToTable("IdpConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DataTypeId") + .HasColumnType("integer"); + + b.Property("DefaultValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive"); + + b.HasIndex("ScopeId"); + + b.ToTable("ParameterDefinitions", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterGlobalValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EffectiveValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ParameterDefinitionId") + .IsUnique(); + + b.HasIndex("StatusId"); + + b.ToTable("ParameterGlobalValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterTenantValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ParameterDefinitionId") + .IsUnique(); + + b.ToTable("ParameterTenantValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.PasswordResetTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsumedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("InvalidatedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("PasswordResetTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.RefreshTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("uuid"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RenewalCount") + .HasColumnType("integer"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("RefreshTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("GeofencingMetadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantBranches", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StrategyId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantIdentityProviders", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantParameterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedValues") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultValue") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsSensitive") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ValueTypeId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId", "Code", "IsActive") + .IsUnique() + .HasDatabaseName("IX_TenantParameters_TenantId_Code_IsActive") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("TenantParameters", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyReference") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultSystemSuiteId") + .HasColumnType("uuid"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IdpStrategyId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsManagementOwner") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationTypeId") + .HasColumnType("integer"); + + b.Property("ParentTenantId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("IsManagementOwner") + .IsUnique() + .HasDatabaseName("IX_Tenants_SingleManagementOwner") + .HasFilter("\"IsManagementOwner\" = true"); + + b.HasIndex("ParentTenantId"); + + b.ToTable("Tenants", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantSignupRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedTenantId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyReference") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyReference") + .IsUnique(); + + b.HasIndex("ContactEmail"); + + b.HasIndex("StatusId"); + + b.ToTable("TenantSignupRequests", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MethodId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId", "MethodId"); + + b.ToTable("UserAccountMfaEnrollments", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.ToTable("UserAccountPasswordCredentials", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FailedLoginAttempts") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("IdentityReference") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IdentityReferenceTypeId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LockedUntilUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Email") + .IsUnique(); + + b.ToTable("UserAccounts", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserManagementDelegationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedActionsJson") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DelegatedAdminId") + .HasColumnType("uuid"); + + b.Property("DelegatingAdminId") + .HasColumnType("uuid"); + + b.Property("MaxDurationDays") + .HasColumnType("integer"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RevocationReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("uuid"); + + b.Property("ScopeTypeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DelegatedAdminId"); + + b.HasIndex("DelegatingAdminId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("UserManagementDelegations", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RoleMaturityStatusRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BlockingFactor") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CompletedCertificationsCount") + .HasColumnType("integer"); + + b.Property("CompletedTrainingsCount") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentLevelSince") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentMaturityLevel") + .HasColumnType("integer"); + + b.Property("EligibleForPromotionAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HasNoComplianceIssues") + .HasColumnType("boolean"); + + b.Property("LastReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NextEligibleMaturityLevel") + .HasColumnType("integer"); + + b.Property("PerformanceScore") + .HasColumnType("numeric(4,2)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId") + .IsUnique(); + + b.ToTable("RoleMaturityStatuses", "iga"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RolePromotionRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApproverId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentRoleId") + .HasColumnType("uuid"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ExecutorId") + .HasColumnType("uuid"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("RiskScore") + .HasColumnType("integer"); + + b.Property("SecurityReviewerId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetRoleId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("VerifierId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("RolePromotionRequests", "iga"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", "UserDocument") + .WithMany("Notifications") + .HasForeignKey("UserDocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserDocument"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", null) + .WithMany("RequiredDocuments") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", "Template") + .WithMany("Items") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", "Profile") + .WithMany("Permissions") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("ParentRoleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Actions") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("AppSettings") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("DomainResources") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Modules") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Node") + .WithMany("Actions") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") + .WithMany("Nodes") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Parent") + .WithMany("Children") + .HasForeignKey("ParentNodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Module"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("Criteria") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("EvaluationLogs") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("Branches") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("IdentityProviders") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("MfaEnrollments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("PasswordCredentials") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Navigation("RequiredDocuments"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Navigation("Notifications"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Navigation("Nodes"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("Children"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("AppSettings"); + + b.Navigation("DomainResources"); + + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Navigation("Criteria"); + + b.Navigation("EvaluationLogs"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Navigation("Branches"); + + b.Navigation("IdentityProviders"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Navigation("MfaEnrollments"); + + b.Navigation("PasswordCredentials"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804164227_AddTenantParameterSoftDelete.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804164227_AddTenantParameterSoftDelete.cs new file mode 100644 index 00000000..fb8f4c5e --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804164227_AddTenantParameterSoftDelete.cs @@ -0,0 +1,51 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + /// Borrado LÓGICO de parámetros de inquilino. El repositorio hacía DELETE físico y la + /// configuración histórica del inquilino se perdía sin remedio; la política del propietario es que + /// solo exista borrado lógico porque el negocio consulta datos antiguos. + /// + /// La columna nace con DEFAULT false, así que ninguna fila existente cambia de semántica: todo lo + /// que hay hoy está vivo. El índice parcial cubre el predicado que el filtro global añade a TODA + /// consulta de parámetros, igual que en Tenants y UserAccounts. + /// + public partial class AddTenantParameterSoftDelete : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsDeleted", + schema: "ums_identity", + table: "TenantParameters", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.CreateIndex( + name: "IX_TenantParameters_IsDeleted", + schema: "ums_identity", + table: "TenantParameters", + column: "IsDeleted", + filter: "\"IsDeleted\" = false"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_TenantParameters_IsDeleted", + schema: "ums_identity", + table: "TenantParameters"); + + migrationBuilder.DropColumn( + name: "IsDeleted", + schema: "ums_identity", + table: "TenantParameters"); + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804194508_AddBranchClosureAndLifecycleLog.Designer.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804194508_AddBranchClosureAndLifecycleLog.Designer.cs new file mode 100644 index 00000000..4cdbe3ea --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804194508_AddBranchClosureAndLifecycleLog.Designer.cs @@ -0,0 +1,3333 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Ums.Infrastructure.Persistence; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + [DbContext(typeof(UmsPlatformDbContext))] + [Migration("20260804194508_AddBranchClosureAndLifecycleLog")] + partial class AddBranchClosureAndLifecycleLog + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ums_platform") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessEnforcementPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EnforcementActionId") + .HasColumnType("integer"); + + b.Property("GracePeriodDays") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ProfileId") + .HasFilter("\"ProfileId\" IS NOT NULL"); + + b.HasIndex("TenantId", "RoleId") + .HasFilter("\"RoleId\" IS NOT NULL"); + + b.ToTable("AccessEnforcementPolicies", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("DaysRemaining") + .HasColumnType("integer"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Step") + .HasColumnType("integer"); + + b.Property("UserDocumentId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserDocumentId"); + + b.HasIndex("UserDocumentId", "Step") + .IsUnique(); + + b.ToTable("UserDocumentNotifications", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GrantedRoleId") + .HasColumnType("uuid"); + + b.Property("Justification") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RequestedBranchId") + .HasColumnType("uuid"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("RequestedSystemId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetProfileId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetProfileId"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("TargetUserId", "RequestedSystemId", "RequestedBranchId", "StatusId"); + + b.ToTable("ApprovalRequests", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DocumentTypeId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("WorkflowId", "DocumentTypeId") + .IsUnique(); + + b.ToTable("ApprovalRequiredDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TargetUserCategoryId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("ApprovalWorkflows", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.DocumentTypeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("DocumentTypes", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.NotificationRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("NotificationRules", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("FileChecksum") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("FileStoragePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IssueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NotificationStep") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("StatusId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "DocumentTypeId"); + + b.ToTable("UserDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Audit.Entities.AuditRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AffectedEntityId") + .HasColumnType("uuid"); + + b.Property("AffectedEntityType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AuditResultId") + .HasColumnType("integer"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Metadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RootTenantId") + .HasColumnType("uuid"); + + b.Property("SubjectTypeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WhatChanged") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("WhenOccurred") + .HasColumnType("timestamp with time zone"); + + b.Property("WhoActed") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AffectedEntityId"); + + b.HasIndex("EventType"); + + b.HasIndex("RootTenantId"); + + b.HasIndex("WhoActed"); + + b.HasIndex("AffectedEntityId", "AffectedEntityType"); + + b.ToTable("AuditRecords", "audit"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TargetId", "IsActive") + .HasDatabaseName("IX_PermissionTemplateItems_TargetId_IsActive"); + + b.HasIndex("TemplateId", "TargetTypeId", "TargetId", "ActionId") + .IsUnique(); + + b.ToTable("PermissionTemplateItems", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "SystemSuiteId", "Version") + .IsUnique(); + + b.ToTable("PermissionTemplates", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("IsOverride") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("TemplateId") + .HasDatabaseName("IX_ProfilePermissions_TemplateId"); + + b.HasIndex("ProfileId", "TemplateId", "ActionId", "TargetId"); + + b.ToTable("ProfilePermissions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId", "RoleId", "BranchId"); + + b.HasIndex(new[] { "UserId" }, "IX_Profiles_UserId"); + + b.HasIndex(new[] { "UserId" }, "IX_Profiles_UserId_Active") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("Profiles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HierarchyLevel") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ParentRoleId") + .HasColumnType("uuid"); + + b.Property("PromotionOrder") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("ParentRoleId"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("Roles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsClientVisible") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "ConfigKey", "ScopeId") + .IsUnique(); + + b.ToTable("SystemSuiteAppSettings", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ModuleId") + .HasDatabaseName("IX_SystemSuiteDomainResources_ModuleId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteDomainResources", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Icon") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteModules", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "ActionCode") + .IsUnique(); + + b.ToTable("SystemSuiteNodeActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ComponenteTecnico") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Criticidad") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Dependencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Evidencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Icon") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("NodeKindId") + .HasColumnType("integer"); + + b.Property("ParentNodeId") + .HasColumnType("uuid"); + + b.Property("ProductoImpactado") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Responsable") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Route") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TrazabilidadSdlc") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ParentNodeId"); + + b.HasIndex("ModuleId", "ParentNodeId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteNodes", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("SystemSuites", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.TemplateAssignmentRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateAssignmentRules", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.AppConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsEncrypted") + .HasColumnType("boolean"); + + b.Property("IsInheritable") + .HasColumnType("boolean"); + + b.Property("IsNonOverridable") + .HasColumnType("boolean"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ModuleId", "Code") + .IsUnique(); + + b.ToTable("AppConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CriteriaType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagCriteria", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Result") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("EvaluatedAtUtc"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagEvaluationLogs", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagTargets") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FlagTypeId") + .HasColumnType("integer"); + + b.Property("LinkedResourceId") + .HasColumnType("uuid"); + + b.Property("LinkedResourceTypeId") + .HasColumnType("integer"); + + b.Property("RolloutPercentage") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("FlagTypeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("SystemSuiteId", "FlagCode") + .IsUnique() + .HasFilter("\"StatusId\" != 3"); + + b.ToTable("FeatureFlags", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.IdpConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigPayload") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DomainHintsJson") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FallbackToId") + .HasColumnType("uuid"); + + b.Property("ProviderTypeId") + .HasColumnType("integer"); + + b.Property("ResolutionPriority") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SecretRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderTypeId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ResolutionPriority"); + + b.ToTable("IdpConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DataTypeId") + .HasColumnType("integer"); + + b.Property("DefaultValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive"); + + b.HasIndex("IsDeleted"); + + b.HasIndex("ScopeId"); + + b.ToTable("ParameterDefinitions", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterGlobalValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EffectiveValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ParameterDefinitionId") + .IsUnique(); + + b.HasIndex("StatusId"); + + b.ToTable("ParameterGlobalValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterTenantValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ParameterDefinitionId") + .IsUnique(); + + b.ToTable("ParameterTenantValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.PasswordResetTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsumedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("InvalidatedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("PasswordResetTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.RefreshTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("uuid"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RenewalCount") + .HasColumnType("integer"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("RefreshTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchLifecycleEntryRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActorId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("EpisodeId") + .HasColumnType("integer"); + + b.Property("GeofencingSnapshot") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("NameSnapshot") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Reason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("BranchId", "OccurredAtUtc"); + + b.ToTable("TenantBranchLifecycleEntries", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ClosedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ClosedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("GeofencingMetadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsClosed") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("IsClosed") + .HasFilter("\"IsClosed\" = false"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantBranches", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StrategyId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantIdentityProviders", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantParameterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedValues") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultValue") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsSensitive") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ValueTypeId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId", "Code", "IsActive") + .IsUnique() + .HasDatabaseName("IX_TenantParameters_TenantId_Code_IsActive") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("TenantParameters", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyReference") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultSystemSuiteId") + .HasColumnType("uuid"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IdpStrategyId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsManagementOwner") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationTypeId") + .HasColumnType("integer"); + + b.Property("ParentTenantId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("IsManagementOwner") + .IsUnique() + .HasDatabaseName("IX_Tenants_SingleManagementOwner") + .HasFilter("\"IsManagementOwner\" = true"); + + b.HasIndex("ParentTenantId"); + + b.ToTable("Tenants", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantSignupRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedTenantId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyReference") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyReference") + .IsUnique(); + + b.HasIndex("ContactEmail"); + + b.HasIndex("StatusId"); + + b.ToTable("TenantSignupRequests", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MethodId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId", "MethodId"); + + b.ToTable("UserAccountMfaEnrollments", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.ToTable("UserAccountPasswordCredentials", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FailedLoginAttempts") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("IdentityReference") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IdentityReferenceTypeId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LockedUntilUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Email") + .IsUnique(); + + b.ToTable("UserAccounts", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserManagementDelegationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedActionsJson") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DelegatedAdminId") + .HasColumnType("uuid"); + + b.Property("DelegatingAdminId") + .HasColumnType("uuid"); + + b.Property("MaxDurationDays") + .HasColumnType("integer"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RevocationReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("uuid"); + + b.Property("ScopeTypeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DelegatedAdminId"); + + b.HasIndex("DelegatingAdminId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("UserManagementDelegations", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RoleMaturityStatusRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BlockingFactor") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CompletedCertificationsCount") + .HasColumnType("integer"); + + b.Property("CompletedTrainingsCount") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentLevelSince") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentMaturityLevel") + .HasColumnType("integer"); + + b.Property("EligibleForPromotionAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HasNoComplianceIssues") + .HasColumnType("boolean"); + + b.Property("LastReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NextEligibleMaturityLevel") + .HasColumnType("integer"); + + b.Property("PerformanceScore") + .HasColumnType("numeric(4,2)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId") + .IsUnique(); + + b.ToTable("RoleMaturityStatuses", "iga"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RolePromotionRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApproverId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentRoleId") + .HasColumnType("uuid"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ExecutorId") + .HasColumnType("uuid"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("RiskScore") + .HasColumnType("integer"); + + b.Property("SecurityReviewerId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetRoleId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("VerifierId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("RolePromotionRequests", "iga"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", "UserDocument") + .WithMany("Notifications") + .HasForeignKey("UserDocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserDocument"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", null) + .WithMany("RequiredDocuments") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", "Template") + .WithMany("Items") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", "Profile") + .WithMany("Permissions") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("ParentRoleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Actions") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("AppSettings") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("DomainResources") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Modules") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Node") + .WithMany("Actions") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") + .WithMany("Nodes") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Parent") + .WithMany("Children") + .HasForeignKey("ParentNodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Module"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("Criteria") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("EvaluationLogs") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchLifecycleEntryRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", "Branch") + .WithMany("LifecycleEntries") + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Branch"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("Branches") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("IdentityProviders") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("MfaEnrollments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("PasswordCredentials") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Navigation("RequiredDocuments"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Navigation("Notifications"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Navigation("Nodes"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("Children"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("AppSettings"); + + b.Navigation("DomainResources"); + + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Navigation("Criteria"); + + b.Navigation("EvaluationLogs"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.Navigation("LifecycleEntries"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Navigation("Branches"); + + b.Navigation("IdentityProviders"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Navigation("MfaEnrollments"); + + b.Navigation("PasswordCredentials"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804194508_AddBranchClosureAndLifecycleLog.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804194508_AddBranchClosureAndLifecycleLog.cs new file mode 100644 index 00000000..e9a82f4b --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804194508_AddBranchClosureAndLifecycleLog.cs @@ -0,0 +1,179 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + /// ADR-0164 aplicado a las SUCURSALES. Dos cosas a la vez, porque son la misma decisión: + /// + /// 1. Cierre definitivo en lugar de borrado físico. Tenant.RemoveBranch quitaba + /// la sucursal de la colección y el reconciliador de EF lo traducía en un DELETE real. Como + /// ni Profiles.BranchId ni UserAccounts.BranchId tienen clave ajena contra esta + /// tabla, la base no decía nada y las filas quedaban huérfanas en silencio; y un despacho de 2024 + /// se quedaba sin la sucursal que lo explicaba. Ahora hay IsClosed / ClosedAtUtc / + /// ClosedBy: la fila permanece. La columna nace con DEFAULT false, así que ninguna + /// fila existente cambia de semántica — todo lo que hay hoy sigue vivo. + /// + /// Lo que esta migración NO hace, a propósito: no toca + /// IX_TenantBranches_TenantId_Code. El índice único sigue SIN filtrar por el estado de + /// cierre (§2.3), que es lo que mantiene ocupado el código de una sucursal cerrada y evita que dos + /// sucursales distintas compartan código dentro del mismo inquilino. Es una omisión deliberada, + /// no un olvido. + /// + /// 2. Bitácora de episodios. TenantBranchLifecycleEntries registra cada + /// apertura, baja, reapertura y cierre con su fecha, su autor y la foto (nombre y geocerca) de la + /// época. Sin ella, un bool IsActive y una sola marca de auditoría hacen indistinguibles + /// dos épocas de la misma sucursal, y una auditoría de 2028 sobre un despacho de 2024 vería la + /// dirección y el responsable de hoy. + /// + /// Relleno del histórico. Se siembra UN asiento de apertura por cada sucursal + /// existente, tomado de su propia auditoría de creación, para que la bitácora no empiece a media + /// historia. NO se inventan bajas ni reaperturas de las sucursales hoy inactivas: se sabe que + /// están inactivas, no cuándo ni quién las desactivó, y un asiento inventado sería peor que la + /// ausencia. Ese hueco se cierra solo con el uso. + /// + public partial class AddBranchClosureAndLifecycleLog : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ClosedAtUtc", + schema: "ums_identity", + table: "TenantBranches", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "ClosedBy", + schema: "ums_identity", + table: "TenantBranches", + type: "character varying(100)", + maxLength: 100, + nullable: true); + + migrationBuilder.AddColumn( + name: "IsClosed", + schema: "ums_identity", + table: "TenantBranches", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.CreateTable( + name: "TenantBranchLifecycleEntries", + schema: "ums_identity", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TenantId = table.Column(type: "uuid", nullable: false), + BranchId = table.Column(type: "uuid", nullable: false), + EpisodeId = table.Column(type: "integer", nullable: false), + OccurredAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + ActorId = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + NameSnapshot = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + GeofencingSnapshot = table.Column(type: "character varying(4000)", maxLength: 4000, nullable: true), + Reason = table.Column(type: "character varying(500)", maxLength: 500, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_TenantBranchLifecycleEntries", x => x.Id); + table.ForeignKey( + name: "FK_TenantBranchLifecycleEntries_TenantBranches_BranchId", + column: x => x.BranchId, + principalSchema: "ums_identity", + principalTable: "TenantBranches", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_TenantBranches_IsClosed", + schema: "ums_identity", + table: "TenantBranches", + column: "IsClosed", + filter: "\"IsClosed\" = false"); + + migrationBuilder.CreateIndex( + name: "IX_TenantBranchLifecycleEntries_BranchId_OccurredAtUtc", + schema: "ums_identity", + table: "TenantBranchLifecycleEntries", + columns: new[] { "BranchId", "OccurredAtUtc" }); + + migrationBuilder.CreateIndex( + name: "IX_TenantBranchLifecycleEntries_TenantId", + schema: "ums_identity", + table: "TenantBranchLifecycleEntries", + column: "TenantId"); + + // ── Relleno del histórico ──────────────────────────────────────────────────────────── + // Va ANTES de activar la RLS: la política es fail-closed —con la GUC de inquilino sin + // fijar, `current_setting(...,true)` devuelve NULL y el predicado no se cumple— y una + // migración no tiene inquilino de contexto, así que un INSERT posterior sería rechazado. + // EpisodeId = 1 es BranchLifecycleEpisode.Opened. + migrationBuilder.Sql(@" + INSERT INTO ums_identity.""TenantBranchLifecycleEntries"" + (""Id"", ""TenantId"", ""BranchId"", ""EpisodeId"", ""OccurredAtUtc"", + ""ActorId"", ""NameSnapshot"", ""GeofencingSnapshot"", ""Reason"") + SELECT + gen_random_uuid(), + b.""TenantId"", + b.""Id"", + 1, + b.""CreatedAtUtc"", + b.""CreatedBy"", + b.""Name"", + b.""GeofencingMetadata"", + 'Asiento de apertura reconstruido desde la auditoría de creación al estrenar la bitácora.' + FROM ums_identity.""TenantBranches"" b; + "); + + // ── Aislamiento por inquilino ──────────────────────────────────────────────────────── + // Mismo tratamiento que el resto de tablas acotadas por inquilino: ENABLE + FORCE y la + // política con el predicado ya optimizado de 20260804135513 (InitPlan por sentencia y + // comparación uuid = uuid, sin conversión a texto, para no perder el índice). + migrationBuilder.Sql(@"ALTER TABLE ums_identity.""TenantBranchLifecycleEntries"" ENABLE ROW LEVEL SECURITY;"); + migrationBuilder.Sql(@"ALTER TABLE ums_identity.""TenantBranchLifecycleEntries"" FORCE ROW LEVEL SECURITY;"); + migrationBuilder.Sql(@" + CREATE POLICY tenant_isolation_policy ON ums_identity.""TenantBranchLifecycleEntries"" + FOR ALL + USING ( + (SELECT current_setting('app.current_organization_id', true)) = '' + OR ""TenantId"" = (SELECT NULLIF(current_setting('app.current_organization_id', true), '')::uuid) + ); + "); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(@"DROP POLICY IF EXISTS tenant_isolation_policy ON ums_identity.""TenantBranchLifecycleEntries"";"); + + migrationBuilder.DropTable( + name: "TenantBranchLifecycleEntries", + schema: "ums_identity"); + + migrationBuilder.DropIndex( + name: "IX_TenantBranches_IsClosed", + schema: "ums_identity", + table: "TenantBranches"); + + migrationBuilder.DropColumn( + name: "ClosedAtUtc", + schema: "ums_identity", + table: "TenantBranches"); + + migrationBuilder.DropColumn( + name: "ClosedBy", + schema: "ums_identity", + table: "TenantBranches"); + + migrationBuilder.DropColumn( + name: "IsClosed", + schema: "ums_identity", + table: "TenantBranches"); + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804225858_LiberarRanuraDeConfiguracionAlBorrar.Designer.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804225858_LiberarRanuraDeConfiguracionAlBorrar.Designer.cs new file mode 100644 index 00000000..cd72f879 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804225858_LiberarRanuraDeConfiguracionAlBorrar.Designer.cs @@ -0,0 +1,3337 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Ums.Infrastructure.Persistence; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + [DbContext(typeof(UmsPlatformDbContext))] + [Migration("20260804225858_LiberarRanuraDeConfiguracionAlBorrar")] + partial class LiberarRanuraDeConfiguracionAlBorrar + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ums_platform") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessEnforcementPolicyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EnforcementActionId") + .HasColumnType("integer"); + + b.Property("GracePeriodDays") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ProfileId") + .HasFilter("\"ProfileId\" IS NOT NULL"); + + b.HasIndex("TenantId", "RoleId") + .HasFilter("\"RoleId\" IS NOT NULL"); + + b.ToTable("AccessEnforcementPolicies", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("DaysRemaining") + .HasColumnType("integer"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Step") + .HasColumnType("integer"); + + b.Property("UserDocumentId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserDocumentId"); + + b.HasIndex("UserDocumentId", "Step") + .IsUnique(); + + b.ToTable("UserDocumentNotifications", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GrantedRoleId") + .HasColumnType("uuid"); + + b.Property("Justification") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RequestedBranchId") + .HasColumnType("uuid"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("RequestedSystemId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetProfileId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetProfileId"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("TargetUserId", "RequestedSystemId", "RequestedBranchId", "StatusId"); + + b.ToTable("ApprovalRequests", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DocumentTypeId"); + + b.HasIndex("WorkflowId"); + + b.HasIndex("WorkflowId", "DocumentTypeId") + .IsUnique(); + + b.ToTable("ApprovalRequiredDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TargetUserCategoryId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("ApprovalWorkflows", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.DocumentTypeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("DocumentTypes", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.NotificationRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("NotificationRules", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CriticityId") + .HasColumnType("integer"); + + b.Property("DocumentTypeId") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("FileChecksum") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("FileStoragePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IssueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NotificationStep") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("StatusId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "DocumentTypeId"); + + b.ToTable("UserDocuments", "approvals"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Audit.Entities.AuditRecordRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AffectedEntityId") + .HasColumnType("uuid"); + + b.Property("AffectedEntityType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AuditResultId") + .HasColumnType("integer"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Metadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RootTenantId") + .HasColumnType("uuid"); + + b.Property("SubjectTypeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WhatChanged") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("WhenOccurred") + .HasColumnType("timestamp with time zone"); + + b.Property("WhoActed") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AffectedEntityId"); + + b.HasIndex("EventType"); + + b.HasIndex("RootTenantId"); + + b.HasIndex("WhoActed"); + + b.HasIndex("AffectedEntityId", "AffectedEntityType"); + + b.ToTable("AuditRecords", "audit"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TargetId", "IsActive") + .HasDatabaseName("IX_PermissionTemplateItems_TargetId_IsActive"); + + b.HasIndex("TemplateId", "TargetTypeId", "TargetId", "ActionId") + .IsUnique(); + + b.ToTable("PermissionTemplateItems", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "SystemSuiteId", "Version") + .IsUnique(); + + b.ToTable("PermissionTemplates", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAllowed") + .HasColumnType("boolean"); + + b.Property("IsDenied") + .HasColumnType("boolean"); + + b.Property("IsOverride") + .HasColumnType("boolean"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetTypeId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("TemplateId") + .HasDatabaseName("IX_ProfilePermissions_TemplateId"); + + b.HasIndex("ProfileId", "TemplateId", "ActionId", "TargetId"); + + b.ToTable("ProfilePermissions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId", "RoleId", "BranchId"); + + b.HasIndex(new[] { "UserId" }, "IX_Profiles_UserId"); + + b.HasIndex(new[] { "UserId" }, "IX_Profiles_UserId_Active") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("Profiles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HierarchyLevel") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ParentRoleId") + .HasColumnType("uuid"); + + b.Property("PromotionOrder") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("ParentRoleId"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("Roles", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsClientVisible") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "ConfigKey", "ScopeId") + .IsUnique(); + + b.ToTable("SystemSuiteAppSettings", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ModuleId") + .HasDatabaseName("IX_SystemSuiteDomainResources_ModuleId"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteDomainResources", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Icon") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("SystemSuiteId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteModules", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "ActionCode") + .IsUnique(); + + b.ToTable("SystemSuiteNodeActions", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ComponenteTecnico") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Criticidad") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Dependencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Evidencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Icon") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("NodeKindId") + .HasColumnType("integer"); + + b.Property("ParentNodeId") + .HasColumnType("uuid"); + + b.Property("ProductoImpactado") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Responsable") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Route") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TrazabilidadSdlc") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ParentNodeId"); + + b.HasIndex("ModuleId", "ParentNodeId", "Code") + .IsUnique(); + + b.ToTable("SystemSuiteNodes", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("SystemSuites", "ums_authorization"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.TemplateAssignmentRuleRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateAssignmentRules", "ums_platform"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.AppConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsEncrypted") + .HasColumnType("boolean"); + + b.Property("IsInheritable") + .HasColumnType("boolean"); + + b.Property("IsNonOverridable") + .HasColumnType("boolean"); + + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ModuleId", "Code") + .IsUnique() + .HasFilter("\"StatusId\" != 4"); + + b.ToTable("AppConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CriteriaType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagCriteria", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("EvaluatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EvaluatedBy") + .HasColumnType("uuid"); + + b.Property("FeatureFlagId") + .HasColumnType("uuid"); + + b.Property("Result") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("EvaluatedAtUtc"); + + b.HasIndex("FeatureFlagId"); + + b.ToTable("FeatureFlagEvaluationLogs", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FlagTargets") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FlagTypeId") + .HasColumnType("integer"); + + b.Property("LinkedResourceId") + .HasColumnType("uuid"); + + b.Property("LinkedResourceTypeId") + .HasColumnType("integer"); + + b.Property("RolloutPercentage") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("FlagTypeId"); + + b.HasIndex("StatusId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("SystemSuiteId", "FlagCode") + .IsUnique() + .HasFilter("\"StatusId\" != 3"); + + b.ToTable("FeatureFlags", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.IdpConfigurationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfigPayload") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DomainHintsJson") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FallbackToId") + .HasColumnType("uuid"); + + b.Property("ProviderTypeId") + .HasColumnType("integer"); + + b.Property("ResolutionPriority") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("SecretRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderTypeId"); + + b.HasIndex("SystemSuiteId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "SystemSuiteId", "ResolutionPriority"); + + b.ToTable("IdpConfigurations", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DataTypeId") + .HasColumnType("integer"); + + b.Property("DefaultValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsMandatory") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique() + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("IsActive"); + + b.HasIndex("IsDeleted"); + + b.HasIndex("ScopeId"); + + b.ToTable("ParameterDefinitions", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterGlobalValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EffectiveValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ParameterDefinitionId") + .IsUnique() + .HasFilter("\"StatusId\" != 4"); + + b.HasIndex("StatusId"); + + b.ToTable("ParameterGlobalValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.ParameterTenantValueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ParameterDefinitionId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("StatusId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ParameterDefinitionId") + .IsUnique() + .HasFilter("\"StatusId\" != 4"); + + b.ToTable("ParameterTenantValues", "ums_configuration"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.PasswordResetTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsumedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("InvalidatedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("PasswordResetTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.RefreshTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("uuid"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RenewalCount") + .HasColumnType("integer"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("RefreshTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchLifecycleEntryRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActorId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("EpisodeId") + .HasColumnType("integer"); + + b.Property("GeofencingSnapshot") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("NameSnapshot") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Reason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("BranchId", "OccurredAtUtc"); + + b.ToTable("TenantBranchLifecycleEntries", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ClosedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ClosedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("GeofencingMetadata") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsClosed") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("IsClosed") + .HasFilter("\"IsClosed\" = false"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantBranches", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StrategyId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("TenantIdentityProviders", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantParameterRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedValues") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultValue") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsSensitive") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ValueTypeId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId", "Code", "IsActive") + .IsUnique() + .HasDatabaseName("IX_TenantParameters_TenantId_Code_IsActive") + .HasFilter("\"IsActive\" = true"); + + b.ToTable("TenantParameters", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyReference") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultSystemSuiteId") + .HasColumnType("uuid"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IdpStrategyId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsManagementOwner") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationTypeId") + .HasColumnType("integer"); + + b.Property("ParentTenantId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("IsManagementOwner") + .IsUnique() + .HasDatabaseName("IX_Tenants_SingleManagementOwner") + .HasFilter("\"IsManagementOwner\" = true"); + + b.HasIndex("ParentTenantId"); + + b.ToTable("Tenants", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantSignupRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedTenantId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyReference") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyReference") + .IsUnique(); + + b.HasIndex("ContactEmail"); + + b.HasIndex("StatusId"); + + b.ToTable("TenantSignupRequests", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MethodId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId", "MethodId"); + + b.ToTable("UserAccountMfaEnrollments", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.ToTable("UserAccountPasswordCredentials", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FailedLoginAttempts") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("IdentityReference") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IdentityReferenceTypeId") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LockedUntilUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Email") + .IsUnique(); + + b.ToTable("UserAccounts", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserManagementDelegationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedActionsJson") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DelegatedAdminId") + .HasColumnType("uuid"); + + b.Property("DelegatingAdminId") + .HasColumnType("uuid"); + + b.Property("MaxDurationDays") + .HasColumnType("integer"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RevocationReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.Property("ScopeId") + .HasColumnType("uuid"); + + b.Property("ScopeTypeId") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DelegatedAdminId"); + + b.HasIndex("DelegatingAdminId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("UserManagementDelegations", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RoleMaturityStatusRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BlockingFactor") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CompletedCertificationsCount") + .HasColumnType("integer"); + + b.Property("CompletedTrainingsCount") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentLevelSince") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentMaturityLevel") + .HasColumnType("integer"); + + b.Property("EligibleForPromotionAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HasNoComplianceIssues") + .HasColumnType("boolean"); + + b.Property("LastReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NextEligibleMaturityLevel") + .HasColumnType("integer"); + + b.Property("PerformanceScore") + .HasColumnType("numeric(4,2)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId") + .IsUnique(); + + b.ToTable("RoleMaturityStatuses", "iga"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RolePromotionRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApproverId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentRoleId") + .HasColumnType("uuid"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ExecutorId") + .HasColumnType("uuid"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("RiskScore") + .HasColumnType("integer"); + + b.Property("SecurityReviewerId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetRoleId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("VerifierId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("RolePromotionRequests", "iga"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", "UserDocument") + .WithMany("Notifications") + .HasForeignKey("UserDocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserDocument"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalRequiredDocumentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", null) + .WithMany("RequiredDocuments") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateItemRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", "Template") + .WithMany("Items") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfilePermissionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", "Profile") + .WithMany("Permissions") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.RoleRecord", null) + .WithMany() + .HasForeignKey("ParentRoleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", null) + .WithMany() + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Actions") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteAppSettingRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("AppSettings") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteDomainResourceRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("DomainResources") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") + .WithMany("Modules") + .HasForeignKey("SystemSuiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SystemSuite"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Node") + .WithMany("Actions") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") + .WithMany("Nodes") + .HasForeignKey("ModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Parent") + .WithMany("Children") + .HasForeignKey("ParentNodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Module"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("Criteria") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagEvaluationLogRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", "FeatureFlag") + .WithMany("EvaluationLogs") + .HasForeignKey("FeatureFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeatureFlag"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchLifecycleEntryRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", "Branch") + .WithMany("LifecycleEntries") + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Branch"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("Branches") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") + .WithMany("IdentityProviders") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountMfaEnrollmentRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("MfaEnrollments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountPasswordCredentialRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", "UserAccount") + .WithMany("PasswordCredentials") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.ApprovalWorkflowRecord", b => + { + b.Navigation("RequiredDocuments"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", b => + { + b.Navigation("Notifications"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.PermissionTemplateRecord", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.ProfileRecord", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + { + b.Navigation("Nodes"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("Children"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => + { + b.Navigation("Actions"); + + b.Navigation("AppSettings"); + + b.Navigation("DomainResources"); + + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => + { + b.Navigation("Criteria"); + + b.Navigation("EvaluationLogs"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.Navigation("LifecycleEntries"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => + { + b.Navigation("Branches"); + + b.Navigation("IdentityProviders"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.UserAccountRecord", b => + { + b.Navigation("MfaEnrollments"); + + b.Navigation("PasswordCredentials"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804225858_LiberarRanuraDeConfiguracionAlBorrar.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804225858_LiberarRanuraDeConfiguracionAlBorrar.cs new file mode 100644 index 00000000..a1a79fd1 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/20260804225858_LiberarRanuraDeConfiguracionAlBorrar.cs @@ -0,0 +1,136 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + /// El borrado lógico LIBERA la ranura de configuración: los cuatro índices únicos del esquema + /// `ums_configuration` pasan a ser PARCIALES y dejan fuera lo eliminado. + /// + /// Por qué solo aquí. ADR-0164 §2.3 fijó que la clave natural queda ocupada para siempre, y para + /// una sucursal o un sistema es correcto: su código lo inventa quien opera —cierras LIMA-01 y + /// abres LIMA-05— y reutilizarlo volvería ambigua una consulta histórica. El código de una + /// configuración es otra cosa: viene de un catálogo cerrado. `MFA_REQUIRED_FOR_ADMIN` es *el* + /// nombre de ese parámetro, no uno que se elija. Dejar esa ranura ocupada equivalía a que borrar + /// la configuración global de un parámetro impidiera volver a configurarlo NUNCA. El propietario + /// del producto acotó la regla el 2026-08-04; sucursales y `SystemSuite` NO cambian. + /// + /// Qué NO cambia: la fila eliminada sigue en la tabla. Esto no reintroduce el borrado físico —el + /// repositorio sigue sin exponerlo— y todo lo demás de ADR-0164 continúa vigente. + /// + /// Sobre la reversión: `Down` restaura los índices totales y solo puede aplicarse si en ese + /// momento no conviven una fila viva y una lápida con la misma clave. Es la consecuencia + /// esperable de revertir una regla que ya se usó; el dato no se pierde, la reversión se rechaza. + /// + public partial class LiberarRanuraDeConfiguracionAlBorrar : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_ParameterTenantValues_TenantId_ParameterDefinitionId", + schema: "ums_configuration", + table: "ParameterTenantValues"); + + migrationBuilder.DropIndex( + name: "IX_ParameterGlobalValues_ParameterDefinitionId", + schema: "ums_configuration", + table: "ParameterGlobalValues"); + + migrationBuilder.DropIndex( + name: "IX_ParameterDefinitions_Code", + schema: "ums_configuration", + table: "ParameterDefinitions"); + + migrationBuilder.DropIndex( + name: "IX_AppConfigurations_TenantId_SystemSuiteId_ModuleId_Code", + schema: "ums_configuration", + table: "AppConfigurations"); + + migrationBuilder.CreateIndex( + name: "IX_ParameterTenantValues_TenantId_ParameterDefinitionId", + schema: "ums_configuration", + table: "ParameterTenantValues", + columns: new[] { "TenantId", "ParameterDefinitionId" }, + unique: true, + filter: "\"StatusId\" != 4"); + + migrationBuilder.CreateIndex( + name: "IX_ParameterGlobalValues_ParameterDefinitionId", + schema: "ums_configuration", + table: "ParameterGlobalValues", + column: "ParameterDefinitionId", + unique: true, + filter: "\"StatusId\" != 4"); + + migrationBuilder.CreateIndex( + name: "IX_ParameterDefinitions_Code", + schema: "ums_configuration", + table: "ParameterDefinitions", + column: "Code", + unique: true, + filter: "\"IsDeleted\" = false"); + + migrationBuilder.CreateIndex( + name: "IX_AppConfigurations_TenantId_SystemSuiteId_ModuleId_Code", + schema: "ums_configuration", + table: "AppConfigurations", + columns: new[] { "TenantId", "SystemSuiteId", "ModuleId", "Code" }, + unique: true, + filter: "\"StatusId\" != 4"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_ParameterTenantValues_TenantId_ParameterDefinitionId", + schema: "ums_configuration", + table: "ParameterTenantValues"); + + migrationBuilder.DropIndex( + name: "IX_ParameterGlobalValues_ParameterDefinitionId", + schema: "ums_configuration", + table: "ParameterGlobalValues"); + + migrationBuilder.DropIndex( + name: "IX_ParameterDefinitions_Code", + schema: "ums_configuration", + table: "ParameterDefinitions"); + + migrationBuilder.DropIndex( + name: "IX_AppConfigurations_TenantId_SystemSuiteId_ModuleId_Code", + schema: "ums_configuration", + table: "AppConfigurations"); + + migrationBuilder.CreateIndex( + name: "IX_ParameterTenantValues_TenantId_ParameterDefinitionId", + schema: "ums_configuration", + table: "ParameterTenantValues", + columns: new[] { "TenantId", "ParameterDefinitionId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ParameterGlobalValues_ParameterDefinitionId", + schema: "ums_configuration", + table: "ParameterGlobalValues", + column: "ParameterDefinitionId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ParameterDefinitions_Code", + schema: "ums_configuration", + table: "ParameterDefinitions", + column: "Code", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_AppConfigurations_TenantId_SystemSuiteId_ModuleId_Code", + schema: "ums_configuration", + table: "AppConfigurations", + columns: new[] { "TenantId", "SystemSuiteId", "ModuleId", "Code" }, + unique: true); + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/UmsPlatformDbContextModelSnapshot.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/UmsPlatformDbContextModelSnapshot.cs index ba2b35d4..1d0ab6e9 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/UmsPlatformDbContextModelSnapshot.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Migrations/PostgreSql/UmsPlatformDbContextModelSnapshot.cs @@ -8,7 +8,7 @@ #nullable disable -namespace Ums.Infrastructure.Persistence.Migrations.PostgreSql +namespace Ums.Infrastructure.Persistence.Migrations { [DbContext(typeof(UmsPlatformDbContext))] partial class UmsPlatformDbContextModelSnapshot : ModelSnapshot @@ -24,6 +24,176 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage", "ums_platform"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bytea") + .HasDefaultValueSql("gen_random_bytes(8)"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState", "ums_platform"); + }); + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessEnforcementPolicyRecord", b => { b.Property("Id") @@ -46,6 +216,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("EnforcementActionId") .HasColumnType("integer"); + b.Property("GracePeriodDays") + .HasColumnType("integer"); + b.Property("IsActive") .HasColumnType("boolean"); @@ -635,6 +808,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("TargetId", "IsActive") + .HasDatabaseName("IX_PermissionTemplateItems_TargetId_IsActive"); + b.HasIndex("TemplateId", "TargetTypeId", "TargetId", "ActionId") .IsUnique(); @@ -762,6 +938,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("ProfileId"); + b.HasIndex("TemplateId") + .HasDatabaseName("IX_ProfilePermissions_TemplateId"); + b.HasIndex("ProfileId", "TemplateId", "ActionId", "TargetId"); b.ToTable("ProfilePermissions", "ums_authorization"); @@ -822,10 +1001,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("TenantId"); - b.HasIndex("UserId"); - b.HasIndex("TenantId", "UserId", "RoleId", "BranchId"); + b.HasIndex(new[] { "UserId" }, "IX_Profiles_UserId"); + + b.HasIndex(new[] { "UserId" }, "IX_Profiles_UserId_Active") + .HasFilter("\"IsActive\" = true"); + b.ToTable("Profiles", "ums_authorization"); }); @@ -978,6 +1160,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(4000) .HasColumnType("character varying(4000)"); + b.Property("IsClientVisible") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + b.Property("ScopeId") .HasColumnType("integer"); @@ -1049,13 +1236,16 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("ModuleId") + .HasDatabaseName("IX_SystemSuiteDomainResources_ModuleId"); + b.HasIndex("SystemSuiteId", "Code") .IsUnique(); b.ToTable("SystemSuiteDomainResources", "ums_authorization"); }); - modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteMenuRecord", b => + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -1084,17 +1274,24 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(1000) .HasColumnType("character varying(1000)"); - b.Property("Label") + b.Property("Icon") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") .IsRequired() .HasMaxLength(200) .HasColumnType("character varying(200)"); - b.Property("ModuleId") - .HasColumnType("uuid"); - b.Property("SortOrder") .HasColumnType("integer"); + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("SystemSuiteId") + .HasColumnType("uuid"); + b.Property("UpdatedAtUtc") .HasColumnType("timestamp with time zone"); @@ -1104,81 +1301,40 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.HasIndex("ModuleId", "Code") + b.HasIndex("SystemSuiteId", "Code") .IsUnique(); - b.ToTable("SystemSuiteMenus", "ums_authorization"); + b.ToTable("SystemSuiteModules", "ums_authorization"); }); - modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("uuid"); - b.Property("AuditTimeSpan") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("Code") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("CreatedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") + b.Property("ActionCode") .IsRequired() .HasMaxLength(100) .HasColumnType("character varying(100)"); - b.Property("Description") - .IsRequired() - .HasMaxLength(1000) - .HasColumnType("character varying(1000)"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("SortOrder") - .HasColumnType("integer"); - - b.Property("StatusId") - .HasColumnType("integer"); - - b.Property("SystemSuiteId") + b.Property("NodeId") .HasColumnType("uuid"); - b.Property("UpdatedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("UpdatedBy") - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - b.HasKey("Id"); - b.HasIndex("SystemSuiteId", "Code") + b.HasIndex("NodeId", "ActionCode") .IsUnique(); - b.ToTable("SystemSuiteModules", "ums_authorization"); + b.ToTable("SystemSuiteNodeActions", "ums_authorization"); }); - modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteOptionRecord", b => + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("uuid"); - b.Property("ActionCode") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - b.Property("AuditTimeSpan") .IsRequired() .HasMaxLength(100) @@ -1189,6 +1345,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(100) .HasColumnType("character varying(100)"); + b.Property("ComponenteTecnico") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + b.Property("CreatedAtUtc") .HasColumnType("timestamp with time zone"); @@ -1197,22 +1357,63 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(100) .HasColumnType("character varying(100)"); + b.Property("Criticidad") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Dependencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + b.Property("Description") .IsRequired() .HasMaxLength(1000) .HasColumnType("character varying(1000)"); + b.Property("Evidencias") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Icon") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + b.Property("Label") .IsRequired() .HasMaxLength(200) .HasColumnType("character varying(200)"); - b.Property("SortOrder") + b.Property("ModuleId") + .HasColumnType("uuid"); + + b.Property("NodeKindId") .HasColumnType("integer"); - b.Property("SubMenuId") + b.Property("ParentNodeId") .HasColumnType("uuid"); + b.Property("ProductoImpactado") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Responsable") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Route") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TrazabilidadSdlc") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + b.Property("UpdatedAtUtc") .HasColumnType("timestamp with time zone"); @@ -1222,10 +1423,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.HasIndex("SubMenuId", "Code") + b.HasIndex("ParentNodeId"); + + b.HasIndex("ModuleId", "ParentNodeId", "Code") .IsUnique(); - b.ToTable("SystemSuiteOptions", "ums_authorization"); + b.ToTable("SystemSuiteNodes", "ums_authorization"); }); modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => @@ -1292,7 +1495,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("SystemSuites", "ums_authorization"); }); - modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteSubMenuRecord", b => + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.TemplateAssignmentRuleRecord", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -1300,97 +1503,42 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("AuditTimeSpan") .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("Code") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); + .HasColumnType("text"); b.Property("CreatedAtUtc") .HasColumnType("timestamp with time zone"); b.Property("CreatedBy") .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("Description") - .IsRequired() - .HasMaxLength(1000) - .HasColumnType("character varying(1000)"); + .HasColumnType("text"); - b.Property("Label") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); + b.Property("Priority") + .HasColumnType("integer"); - b.Property("MenuId") + b.Property("RoleId") .HasColumnType("uuid"); - b.Property("SortOrder") + b.Property("StatusId") .HasColumnType("integer"); + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + b.Property("UpdatedAtUtc") .HasColumnType("timestamp with time zone"); b.Property("UpdatedBy") - .HasMaxLength(100) - .HasColumnType("character varying(100)"); + .HasColumnType("text"); b.HasKey("Id"); - b.HasIndex("MenuId", "Code") - .IsUnique(); - - b.ToTable("SystemSuiteSubMenus", "ums_authorization"); + b.ToTable("TemplateAssignmentRules", "ums_platform"); }); - modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.TemplateAssignmentRuleRecord", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("AuditTimeSpan") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .IsRequired() - .HasColumnType("text"); - - b.Property("Priority") - .HasColumnType("integer"); - - b.Property("RoleId") - .HasColumnType("uuid"); - - b.Property("StatusId") - .HasColumnType("integer"); - - b.Property("TemplateId") - .HasColumnType("uuid"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("UpdatedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("UpdatedBy") - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("TemplateAssignmentRules", "ums_platform"); - }); - - modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.AppConfigurationRecord", b => + modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.AppConfigurationRecord", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -1474,7 +1622,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("StatusId"); b.HasIndex("TenantId", "SystemSuiteId", "ModuleId", "Code") - .IsUnique(); + .IsUnique() + .HasFilter("\"StatusId\" != 4"); b.ToTable("AppConfigurations", "ums_configuration"); }); @@ -1618,7 +1767,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("SystemSuiteId"); b.HasIndex("SystemSuiteId", "FlagCode") - .IsUnique(); + .IsUnique() + .HasFilter("\"StatusId\" != 3"); b.ToTable("FeatureFlags", "ums_configuration"); }); @@ -1737,6 +1887,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(4000) .HasColumnType("character varying(4000)"); + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + b.Property("Description") .IsRequired() .HasMaxLength(1000) @@ -1748,6 +1905,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("IsActive") .HasColumnType("boolean"); + b.Property("IsDeleted") + .HasColumnType("boolean"); + b.Property("IsMandatory") .HasColumnType("boolean"); @@ -1774,10 +1934,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); b.HasIndex("Code") - .IsUnique(); + .IsUnique() + .HasFilter("\"IsDeleted\" = false"); b.HasIndex("IsActive"); + b.HasIndex("IsDeleted"); + b.HasIndex("ScopeId"); b.ToTable("ParameterDefinitions", "ums_configuration"); @@ -1828,7 +1991,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); b.HasIndex("ParameterDefinitionId") - .IsUnique(); + .IsUnique() + .HasFilter("\"StatusId\" != 4"); b.HasIndex("StatusId"); @@ -1887,11 +2051,162 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("TenantId"); b.HasIndex("TenantId", "ParameterDefinitionId") - .IsUnique(); + .IsUnique() + .HasFilter("\"StatusId\" != 4"); b.ToTable("ParameterTenantValues", "ums_configuration"); }); + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.PasswordResetTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsumedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("InvalidatedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("PasswordResetTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.RefreshTokenRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("uuid"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RenewalCount") + .HasColumnType("integer"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("RefreshTokens", "ums_identity"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchLifecycleEntryRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActorId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BranchId") + .HasColumnType("uuid"); + + b.Property("EpisodeId") + .HasColumnType("integer"); + + b.Property("GeofencingSnapshot") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("NameSnapshot") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Reason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("BranchId", "OccurredAtUtc"); + + b.ToTable("TenantBranchLifecycleEntries", "ums_identity"); + }); + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => { b.Property("Id") @@ -1903,6 +2218,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(100) .HasColumnType("character varying(100)"); + b.Property("ClosedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ClosedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + b.Property("Code") .IsRequired() .HasMaxLength(100) @@ -1923,6 +2245,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("IsActive") .HasColumnType("boolean"); + b.Property("IsClosed") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + b.Property("Name") .IsRequired() .HasMaxLength(200) @@ -1940,6 +2267,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("IsClosed") + .HasFilter("\"IsClosed\" = false"); + b.HasIndex("TenantId", "Code") .IsUnique(); @@ -1954,8 +2284,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("AuditTimeSpan") .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); + .HasColumnType("text"); b.Property("BackgroundStyleId") .HasColumnType("integer"); @@ -1965,35 +2294,29 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CreatedBy") .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); + .HasColumnType("text"); b.Property("CustomDomain") - .HasMaxLength(255) - .HasColumnType("character varying(255)"); + .HasColumnType("text"); b.Property("DnsCnameTarget") .IsRequired() - .HasMaxLength(255) - .HasColumnType("character varying(255)"); + .HasColumnType("text"); b.Property("DnsVerificationStatusId") .HasColumnType("integer"); b.Property("FooterText") .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); + .HasColumnType("text"); b.Property("HeadlineText") .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); + .HasColumnType("text"); b.Property("Logo") .IsRequired() - .HasMaxLength(4000) - .HasColumnType("character varying(4000)"); + .HasColumnType("text"); b.Property("LogoFormatId") .HasColumnType("integer"); @@ -2003,18 +2326,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("PrimaryButtonLabel") .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); + .HasColumnType("text"); b.Property("PrimaryColor") .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); + .HasColumnType("text"); b.Property("SecondaryText") .IsRequired() - .HasMaxLength(500) - .HasColumnType("character varying(500)"); + .HasColumnType("text"); b.Property("TenantId") .HasColumnType("uuid"); @@ -2023,19 +2343,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("timestamp with time zone"); b.Property("UpdatedBy") - .HasMaxLength(100) - .HasColumnType("character varying(100)"); + .HasColumnType("text"); b.HasKey("Id"); - b.HasIndex("CustomDomain") - .IsUnique() - .HasFilter("\"CustomDomain\" IS NOT NULL"); - b.HasIndex("TenantId") .IsUnique(); - b.ToTable("TenantBrandings", "ums_identity"); + b.ToTable("TenantBrandingRecord", "ums_platform"); }); modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantIdentityProviderRecord", b => @@ -2139,6 +2454,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("IsActive") .HasColumnType("boolean"); + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + b.Property("IsSensitive") .HasColumnType("boolean"); @@ -2168,6 +2488,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + b.HasIndex("TenantId", "Code", "IsActive") .IsUnique() .HasDatabaseName("IX_TenantParameters_TenantId_Code_IsActive") @@ -2204,6 +2527,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(100) .HasColumnType("character varying(100)"); + b.Property("DefaultSystemSuiteId") + .HasColumnType("uuid"); + b.Property("DeletedAtUtc") .HasColumnType("timestamp with time zone"); @@ -2260,6 +2586,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("IsDeleted") .HasFilter("\"IsDeleted\" = false"); + b.HasIndex("IsManagementOwner") + .IsUnique() + .HasDatabaseName("IX_Tenants_SingleManagementOwner") + .HasFilter("\"IsManagementOwner\" = true"); + b.HasIndex("ParentTenantId"); b.ToTable("Tenants", "ums_identity"); @@ -2469,6 +2800,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ExpiresAtUtc") .HasColumnType("timestamp with time zone"); + b.Property("FailedLoginAttempts") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + b.Property("IdentityReference") .HasMaxLength(255) .HasColumnType("character varying(255)"); @@ -2481,6 +2817,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("boolean") .HasDefaultValue(false); + b.Property("LockedUntilUtc") + .HasColumnType("timestamp with time zone"); + b.Property("RowVersion") .IsConcurrencyToken() .IsRequired() @@ -2610,6 +2949,173 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("UserManagementDelegations", "ums_identity"); }); + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RoleMaturityStatusRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BlockingFactor") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CompletedCertificationsCount") + .HasColumnType("integer"); + + b.Property("CompletedTrainingsCount") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentLevelSince") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentMaturityLevel") + .HasColumnType("integer"); + + b.Property("EligibleForPromotionAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HasNoComplianceIssues") + .HasColumnType("boolean"); + + b.Property("LastReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NextEligibleMaturityLevel") + .HasColumnType("integer"); + + b.Property("PerformanceScore") + .HasColumnType("numeric(4,2)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId") + .IsUnique(); + + b.ToTable("RoleMaturityStatuses", "iga"); + }); + + modelBuilder.Entity("Ums.Infrastructure.Persistence.Iga.Entities.RolePromotionRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApproverId") + .HasColumnType("uuid"); + + b.Property("AuditTimeSpan") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CurrentRoleId") + .HasColumnType("uuid"); + + b.Property("DecisionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ExecutorId") + .HasColumnType("uuid"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("RiskScore") + .HasColumnType("integer"); + + b.Property("SecurityReviewerId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("integer"); + + b.Property("TargetRoleId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("VerifierId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TargetUserId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "StatusId"); + + b.ToTable("RolePromotionRequests", "iga"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + modelBuilder.Entity("Ums.Infrastructure.Persistence.Approvals.Entities.AccessNotificationRecord", b => { b.HasOne("Ums.Infrastructure.Persistence.Approvals.Entities.UserDocumentRecord", "UserDocument") @@ -2714,17 +3220,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("SystemSuite"); }); - modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteMenuRecord", b => - { - b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") - .WithMany("Menus") - .HasForeignKey("ModuleId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Module"); - }); - modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => { b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", "SystemSuite") @@ -2736,26 +3231,33 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("SystemSuite"); }); - modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteOptionRecord", b => + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeActionRecord", b => { - b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteSubMenuRecord", "SubMenu") - .WithMany("Options") - .HasForeignKey("SubMenuId") + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Node") + .WithMany("Actions") + .HasForeignKey("NodeId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.Navigation("SubMenu"); + b.Navigation("Node"); }); - modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteSubMenuRecord", b => + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => { - b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteMenuRecord", "Menu") - .WithMany("SubMenus") - .HasForeignKey("MenuId") + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", "Module") + .WithMany("Nodes") + .HasForeignKey("ModuleId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.Navigation("Menu"); + b.HasOne("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", "Parent") + .WithMany("Children") + .HasForeignKey("ParentNodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Module"); + + b.Navigation("Parent"); }); modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagCriteriaRecord", b => @@ -2780,6 +3282,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("FeatureFlag"); }); + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchLifecycleEntryRecord", b => + { + b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", "Branch") + .WithMany("LifecycleEntries") + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Branch"); + }); + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => { b.HasOne("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", "Tenant") @@ -2855,14 +3368,16 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Permissions"); }); - modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteMenuRecord", b => + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => { - b.Navigation("SubMenus"); + b.Navigation("Nodes"); }); - modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteModuleRecord", b => + modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteNodeRecord", b => { - b.Navigation("Menus"); + b.Navigation("Actions"); + + b.Navigation("Children"); }); modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteRecord", b => @@ -2876,11 +3391,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Modules"); }); - modelBuilder.Entity("Ums.Infrastructure.Persistence.Authorization.Entities.SystemSuiteSubMenuRecord", b => - { - b.Navigation("Options"); - }); - modelBuilder.Entity("Ums.Infrastructure.Persistence.Configuration.Entities.FeatureFlagRecord", b => { b.Navigation("Criteria"); @@ -2888,6 +3398,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("EvaluationLogs"); }); + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantBranchRecord", b => + { + b.Navigation("LifecycleEntries"); + }); + modelBuilder.Entity("Ums.Infrastructure.Persistence.Identity.Entities.TenantRecord", b => { b.Navigation("Branches"); diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Options/PersistenceOptions.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Options/PersistenceOptions.cs index 68e4935e..0bc76041 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Options/PersistenceOptions.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Options/PersistenceOptions.cs @@ -8,24 +8,61 @@ public sealed class PersistenceOptions public AggregateStoreMode AggregateStoreMode { get; init; } = AggregateStoreMode.InMemory; - public bool UseSqliteIdentityStores { get; init; } = false; public bool UsePostgreSqlIdentityStores { get; init; } = false; - public bool UseSqliteAuthorizationStores { get; init; } = false; public bool UsePostgreSqlAuthorizationStores { get; init; } = false; - public bool UseSqliteConfigurationStores { get; init; } = false; public bool UsePostgreSqlConfigurationStores { get; init; } = false; - public bool UseSqliteApprovalsStores { get; init; } = false; public bool UsePostgreSqlApprovalsStores { get; init; } = false; - public bool UseSqliteIgaStores { get; init; } = false; public bool UsePostgreSqlIgaStores { get; init; } = false; + /// + /// G-127: cuando es true, siembra el dataset determinista FS-25 (datos de referencia + /// —roles, plantillas de permiso, suites, catálogos— + datos demo —tenants, usuarios, + /// aprobaciones—) al arrancar. Está DESACOPLADO del entorno: habilita el stage UAT (usuarios + /// reales con datos visibles al ingresar), no solo Development. Nunca se siembra en Production + /// (guarda de defensa en profundidad en InitializeUmsPlatformAsync). La siembra es + /// idempotente (guarda por tenant ancla + seeders idempotentes), segura ante reinicios. + /// public bool SeedDevData { get; init; } = true; + /// + /// Fuerza la siembra aunque el inquilino ancla ya exista. + /// + /// POR QUÉ HACE FALTA. La guarda de idempotencia de SeedAllAsync omite toda + /// la siembra en cuanto detecta el inquilino ancla, para que un reinicio de pod no re-ejecute + /// los siete sembradores. El efecto secundario es que el conjunto sembrado queda congelado en + /// lo que hubiera el primer día: si más tarde se añade un sistema, un rol o una plantilla al + /// catálogo, ninguna base existente lo recibe jamás. Así se quedó BEYONDNET sin la suite UMS. + /// + /// Hasta ahora la única salida era un reset que dropea el esquema, lo cual también + /// borra lo que NO siembra el código —el Tablero SDLC se carga por API— y obliga a rehacerlo. + /// Esta bandera permite converger sin perder nada. + /// + /// Es de un solo uso deliberado: se enciende para un despliegue, se comprueba el + /// resultado y se apaga. Dejarla encendida devolvería el coste que la guarda evita —los siete + /// sembradores en cada arranque de cada réplica— y convertiría en rutina algo que conviene + /// mirar mientras pasa. + /// + public bool ForceReseed { get; init; } = false; + public bool EnableOutbox { get; init; } = true; public bool InitializePlatformStoreOnStartup { get; init; } = false; + + /// + /// Cuando es true, el proceso migra, siembra y termina sin levantar el servidor. + /// + /// Existe para sacar la migración del arranque de cada réplica (G-169). Con una sola + /// réplica no se nota; con dos o más, las dos arrancan a la vez y las dos intentan migrar la + /// misma base. EF Core toma un bloqueo de aviso, así que no corrompe nada, pero la segunda + /// espera a la primera y puede agotar su sonda de vivacidad antes de servir una sola + /// petición. + /// + /// Con esto, el chart lo ejecuta UNA vez como Job previo al despliegue y las réplicas + /// arrancan contra una base ya migrada. + /// + public bool MigrateAndExit { get; init; } = false; } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Options/PersistenceProvider.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Options/PersistenceProvider.cs index 0216fe9a..69cfdfe8 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Options/PersistenceProvider.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Options/PersistenceProvider.cs @@ -3,6 +3,5 @@ namespace Ums.Infrastructure.Persistence.Options; public enum PersistenceProvider { InMemory = 0, - Sqlite = 2, PostgreSql = 3, } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/PostgreSqlSchemaBootstrapper.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/PostgreSqlSchemaBootstrapper.cs deleted file mode 100644 index 785dad83..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/PostgreSqlSchemaBootstrapper.cs +++ /dev/null @@ -1,73 +0,0 @@ -using System.Reflection; -using System.Text.RegularExpressions; -using Microsoft.EntityFrameworkCore; - -namespace Ums.Infrastructure.Persistence; - -public static partial class PostgreSqlSchemaBootstrapper -{ - private static readonly string[] ScriptOrder = - [ - "20260521_sqlserver_platform_outbox.sql", - "20260521_sqlserver_identity_aggregates.sql", - "20260521_sqlserver_authorization_profiles.sql", - "20260522_sqlserver_identity_delegations.sql", - "20260524_sqlserver_authorization_advanced.sql", - "20260523_sqlserver_configuration_aggregates.sql", - "20260523_sqlserver_audit_records.sql", - "20260523_sqlserver_approvals.sql", - "20260523_soft_delete_gdpr.sql", // REC-16 - "20260523_outbox_dispatch_lease.sql", // HARDENING-01 - "20260524_sqlserver_approvals_ep07_tables.sql", // EP-07: DocumentType, UserDocument, AEP - ]; - - /// - /// HARDENING-05: Runs all pending migration scripts under a SQL Server distributed lock - /// (sp_getapplock) so that concurrent pod startups do not run migrations simultaneously. - /// - /// Lock semantics: - /// - Mode = Exclusive → only one session holds the lock at a time. - /// - Timeout = 60 000 ms → pods wait up to 60 s for the lock; they do not crash on startup. - /// - The lock is released automatically when the connection is returned to the pool (end of scope). - /// - All migration SQL scripts are idempotent (IF NOT EXISTS guards) so a second pod that - /// acquires the lock after the first finishes will simply execute no-ops. - /// - public static async Task InitializeAsync( - UmsPlatformDbContext dbContext, - IDistributedLockProvider lockProvider, - CancellationToken cancellationToken = default) - { - await dbContext.Database.EnsureCreatedAsync(cancellationToken); - - // Open connection explicitly so the session spans the lock acquisition and release - await dbContext.Database.OpenConnectionAsync(cancellationToken); - try - { - await using var lockScope = await lockProvider.AcquireLockAsync( - dbContext, - "ums_schema_migrations", - TimeSpan.FromSeconds(60), - cancellationToken); - - var assembly = typeof(PostgreSqlSchemaBootstrapper).Assembly; - - // In PostgreSQL, EF Core Migrations and OnModelCreating fully define the schemas and tables, - // so we do not execute the SQL Server-specific .sql scripts. - } - finally - { - await dbContext.Database.CloseConnectionAsync(); - } - } - - private static IEnumerable SplitBatches(string sql) - { - return GoBatchRegex() - .Split(sql) - .Select(batch => batch.Trim()) - .Where(batch => !string.IsNullOrWhiteSpace(batch)); - } - - [GeneratedRegex(@"^\s*GO\s*$(\r?\n)?", RegexOptions.Multiline | RegexOptions.IgnoreCase)] - private static partial Regex GoBatchRegex(); -} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Reflection/ApprovalsAggregateFactory.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Reflection/ApprovalsAggregateFactory.cs index 06adfd17..684a398d 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Reflection/ApprovalsAggregateFactory.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Reflection/ApprovalsAggregateFactory.cs @@ -29,6 +29,11 @@ namespace Ums.Infrastructure.Persistence.Reflection; internal static class ApprovalsAggregateFactory { + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Security Hotspot", "S3011:Reflection should not be used to increase accessibility of classes, methods, or fields", + Justification = "ADR-UMS-099: reflexión sancionada para rehidratación de agregados. Reconstruir un agregado " + + "ya validado (ADR-UMS-069) desde persistencia sin exponer setters públicos preserva la pureza " + + "del dominio; las invariantes se cumplieron al crearse.")] private static readonly BindingFlags InstanceFlags = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public; public static ApprovalWorkflowAggregate RehydrateWorkflow( @@ -204,6 +209,7 @@ public static AccessEnforcementPolicyAggregate RehydrateAccessEnforcementPolicy( record.RoleId.HasValue ? RoleId.Load(record.RoleId.Value) : null, DomainEnumerationMapper.FromValue(record.EnforcementActionId), record.IsActive, + record.GracePeriodDays, ActorId.Create(record.CreatedBy)); SetAudit(props, record.CreatedBy, record.CreatedAtUtc, record.UpdatedBy, record.UpdatedAtUtc, record.AuditTimeSpan); diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Reflection/AuditAggregateFactory.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Reflection/AuditAggregateFactory.cs index a56e3a13..ac56a350 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Reflection/AuditAggregateFactory.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Reflection/AuditAggregateFactory.cs @@ -12,6 +12,11 @@ namespace Ums.Infrastructure.Persistence.Reflection; internal static class AuditAggregateFactory { + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Security Hotspot", "S3011:Reflection should not be used to increase accessibility of classes, methods, or fields", + Justification = "ADR-UMS-099: reflexión sancionada para rehidratación de agregados. Reconstruir un agregado " + + "ya validado (ADR-UMS-069) desde persistencia sin exponer setters públicos preserva la pureza " + + "del dominio; las invariantes se cumplieron al crearse.")] private static readonly BindingFlags InstanceFlags = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public; public static AuditRecordAggregate RehydrateAuditRecord(AuditRecordRecord record) diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Reflection/AuthorizationAggregateFactory.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Reflection/AuthorizationAggregateFactory.cs index 5dbee18d..3c05e546 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Reflection/AuthorizationAggregateFactory.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Reflection/AuthorizationAggregateFactory.cs @@ -1,11 +1,10 @@ +using System.Collections.Concurrent; using System.Reflection; using Ums.Domain.Authorization.Profile; using Ums.Domain.Authorization.Profile.ProfilePermission; using Ums.Domain.Authorization.SystemSuite.AppSetting; using Ums.Domain.Authorization.SystemSuite.Module; -using Ums.Domain.Authorization.SystemSuite.Menu; -using Ums.Domain.Authorization.SystemSuite.SubMenu; -using Ums.Domain.Authorization.SystemSuite.Option; +using Ums.Domain.Authorization.SystemSuite.MenuNode; using Ums.Domain.Authorization.SystemSuite.Action; using Ums.Domain.Authorization.SystemSuite.DomainResource; using Ums.Domain.Authorization.Template; @@ -26,15 +25,18 @@ namespace Ums.Infrastructure.Persistence.Reflection; using SystemSuiteAggregate = Ums.Domain.Authorization.SystemSuite.SystemSuite; using AssignmentRuleAggregate = Ums.Domain.Authorization.AssignmentRule.TemplateAssignmentRule; using ModuleEntity = Ums.Domain.Authorization.SystemSuite.Module.Module; -using MenuEntity = Ums.Domain.Authorization.SystemSuite.Menu.Menu; -using SubMenuEntity = Ums.Domain.Authorization.SystemSuite.SubMenu.SubMenu; -using OptionEntity = Ums.Domain.Authorization.SystemSuite.Option.Option; +using MenuNodeEntity = Ums.Domain.Authorization.SystemSuite.MenuNode.MenuNode; using ActionEntity = Ums.Domain.Authorization.SystemSuite.Action.Action; using DomainResourceEntity = Ums.Domain.Authorization.SystemSuite.DomainResource.DomainResource; using PermissionTemplateItemEntity = Ums.Domain.Authorization.Template.PermissionTemplateItem.PermissionTemplateItem; internal static class AuthorizationAggregateFactory { + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Security Hotspot", "S3011:Reflection should not be used to increase accessibility of classes, methods, or fields", + Justification = "ADR-UMS-099: reflexión sancionada para rehidratación de agregados. Reconstruir un agregado " + + "ya validado (ADR-UMS-069) desde persistencia sin exponer setters públicos preserva la pureza " + + "del dominio; las invariantes se cumplieron al crearse.")] private static readonly BindingFlags InstanceFlags = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public; public static ProfileAggregate RehydrateProfile( @@ -214,70 +216,72 @@ private static ModuleEntity RehydrateModule(SystemSuiteModuleRecord record) Description.Create(record.Description), DomainEnumerationMapper.FromValue(record.StatusId), record.SortOrder, - ActorId.Create(record.CreatedBy)); + ActorId.Create(record.CreatedBy), + record.Icon); SetAudit(props, record.CreatedBy, record.CreatedAtUtc, record.UpdatedBy, record.UpdatedAtUtc, record.AuditTimeSpan); var module = Construct(props); - var menus = record.Menus.OrderBy(x => x.SortOrder).Select(RehydrateMenu).ToList(); - SetField(module, "_menus", menus); + + // Árbol recursivo (ADR-0090): los nodos llegan planos (todos con el mismo + // ModuleId); se agrupan por padre y se reconstruye el árbol en memoria. + var allNodes = record.Nodes.ToList(); + var childrenByParent = allNodes + .Where(n => n.ParentNodeId.HasValue) + .GroupBy(n => n.ParentNodeId!.Value) + .ToDictionary(g => g.Key, g => (IReadOnlyList)g.OrderBy(x => x.SortOrder).ToList()); + var roots = allNodes + .Where(n => !n.ParentNodeId.HasValue) + .OrderBy(n => n.SortOrder) + .Select(r => RehydrateNode(r, childrenByParent)) + .ToList(); + SetField(module, "_nodes", roots); + module.BrokenRules.Clear(); return module; } - private static MenuEntity RehydrateMenu(SystemSuiteMenuRecord record) + private static MenuNodeEntity RehydrateNode( + SystemSuiteNodeRecord record, + IReadOnlyDictionary> childrenByParent) { - var props = new MenuProps( + var metadata = MenuNodeMetadata.Create( + record.Responsable, + record.Criticidad, + record.ProductoImpactado, + record.ComponenteTecnico, + record.Dependencias, + record.Evidencias, + record.TrazabilidadSdlc); + + var props = new MenuNodeProps( IdValueObject.Load(record.Id), ModuleId.Load(record.ModuleId), + record.ParentNodeId.HasValue ? IdValueObject.Load(record.ParentNodeId.Value) : null, + (NodeKind)record.NodeKindId, Code.Create(record.Code), Name.Create(record.Label), Description.Create(record.Description), + DomainEnumerationMapper.FromValue(record.StatusId), record.SortOrder, - ActorId.Create(record.CreatedBy)); + metadata, + ActorId.Create(record.CreatedBy), + MenuNodePresentation.Create(record.Icon, record.Route)); SetAudit(props, record.CreatedBy, record.CreatedAtUtc, record.UpdatedBy, record.UpdatedAtUtc, record.AuditTimeSpan); - var menu = Construct(props); - var subMenus = record.SubMenus.OrderBy(x => x.SortOrder).Select(RehydrateSubMenu).ToList(); - SetField(menu, "_subMenus", subMenus); - menu.BrokenRules.Clear(); - return menu; - } - - private static SubMenuEntity RehydrateSubMenu(SystemSuiteSubMenuRecord record) - { - var props = new SubMenuProps( - IdValueObject.Load(record.Id), - MenuId.Load(record.MenuId), - Code.Create(record.Code), - Name.Create(record.Label), - Description.Create(record.Description), - record.SortOrder, - ActorId.Create(record.CreatedBy)); + var node = Construct(props); - SetAudit(props, record.CreatedBy, record.CreatedAtUtc, record.UpdatedBy, record.UpdatedAtUtc, record.AuditTimeSpan); - var subMenu = Construct(props); - var options = record.Options.OrderBy(x => x.SortOrder).Select(RehydrateOption).ToList(); - SetField(subMenu, "_options", options); - subMenu.BrokenRules.Clear(); - return subMenu; - } + var children = childrenByParent.TryGetValue(record.Id, out var childRecords) + ? childRecords.Select(c => RehydrateNode(c, childrenByParent)).ToList() + : new List(); + SetField(node, "_children", children); - private static OptionEntity RehydrateOption(SystemSuiteOptionRecord record) - { - var props = new OptionProps( - IdValueObject.Load(record.Id), - SubMenuId.Load(record.SubMenuId), - Code.Create(record.Code), - Name.Create(record.Label), - Description.Create(record.Description), - ActionCode.Create(record.ActionCode), - record.SortOrder, - ActorId.Create(record.CreatedBy)); + var actionCodes = record.Actions + .Select(a => ActionCode.Create(a.ActionCode)) + .ToList(); + SetField(node, "_actionCodes", actionCodes); - SetAudit(props, record.CreatedBy, record.CreatedAtUtc, record.UpdatedBy, record.UpdatedAtUtc, record.AuditTimeSpan); - var option = Construct(props); - option.BrokenRules.Clear(); - return option; + node.BrokenRules.Clear(); + return node; } private static ActionEntity RehydrateAction(SystemSuiteActionRecord record) @@ -322,7 +326,10 @@ private static AppSetting RehydrateAppSetting(SystemSuiteAppSettingRecord record ?? throw new InvalidOperationException("AppSetting.Create factory not found."); var result = (Result)method.Invoke(null, - [ConfigurationKey.Create(record.ConfigKey), ConfigurationValue.Create(record.ConfigValue), DomainEnumerationMapper.FromValue(record.ScopeId)])!; + [ConfigurationKey.Create(record.ConfigKey), + ConfigurationValue.Create(record.ConfigValue), + DomainEnumerationMapper.FromValue(record.ScopeId), + record.IsClientVisible])!; if (result.IsFailure) { @@ -351,28 +358,44 @@ private static PermissionTemplateItemEntity RehydrateTemplateItem(PermissionTemp return item; } + // ── Memoización de metadatos de reflexión (R-12) ───────────────────────── + // + // Rehidratar el grafo de una suite instancia del orden de 500 entidades por login, y cada + // una resolvía de nuevo su constructor, su campo o su propiedad por reflexión. Buscar + // metadatos es la parte cara de la reflexión; invocar, una vez resueltos, es barato. Los + // metadatos de un tipo no cambian en tiempo de ejecución, así que se cachean para siempre. + // `ConcurrentDictionary` porque la rehidratación ocurre en peticiones concurrentes. + + private static readonly ConcurrentDictionary<(Type Entity, Type Props), ConstructorInfo> CtorCache = new(); + private static readonly ConcurrentDictionary<(Type Target, string Field), FieldInfo> FieldCache = new(); + private static readonly ConcurrentDictionary AuditCache = new(); + private static readonly ConcurrentDictionary StringCtorCache = new(); + private static TEntity Construct(TProps props) where TEntity : class where TProps : class { - var ctor = typeof(TEntity).GetConstructor(InstanceFlags, null, [typeof(TProps)], null) - ?? throw new InvalidOperationException($"Constructor for {typeof(TEntity).Name} not found."); + var ctor = CtorCache.GetOrAdd((typeof(TEntity), typeof(TProps)), static llave => + llave.Entity.GetConstructor(InstanceFlags, null, [llave.Props], null) + ?? throw new InvalidOperationException($"Constructor for {llave.Entity.Name} not found.")); return (TEntity)ctor.Invoke([props]); } private static void SetField(object target, string fieldName, TTarget value) { - var field = target.GetType().GetField(fieldName, InstanceFlags) - ?? throw new InvalidOperationException($"Field {fieldName} not found on {target.GetType().Name}."); + var field = FieldCache.GetOrAdd((target.GetType(), fieldName), static llave => + llave.Target.GetField(llave.Field, InstanceFlags) + ?? throw new InvalidOperationException($"Field {llave.Field} not found on {llave.Target.Name}.")); field.SetValue(target, value); } private static void SetAudit(object props, string createdBy, DateTime createdAtUtc, string? updatedBy, DateTime? updatedAtUtc, string auditTimeSpan) { - var property = props.GetType().GetProperty("Audit", InstanceFlags) - ?? throw new InvalidOperationException($"Audit property not found on {props.GetType().Name}."); + var property = AuditCache.GetOrAdd(props.GetType(), static tipo => + tipo.GetProperty("Audit", InstanceFlags) + ?? throw new InvalidOperationException($"Audit property not found on {tipo.Name}.")); property.SetValue(props, AuditValueObject.Load(new AuditProps { @@ -387,8 +410,9 @@ private static void SetAudit(object props, string createdBy, DateTime createdAtU private static TValueObject ConstructStringValueObject(string value) where TValueObject : class { - var ctor = typeof(TValueObject).GetConstructor(InstanceFlags, null, [typeof(string)], null) - ?? throw new InvalidOperationException($"String constructor for {typeof(TValueObject).Name} not found."); + var ctor = StringCtorCache.GetOrAdd(typeof(TValueObject), static tipo => + tipo.GetConstructor(InstanceFlags, null, [typeof(string)], null) + ?? throw new InvalidOperationException($"String constructor for {tipo.Name} not found.")); return (TValueObject)ctor.Invoke([value]); } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Reflection/ConfigurationAggregateFactory.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Reflection/ConfigurationAggregateFactory.cs index 7f939621..bde208e1 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Reflection/ConfigurationAggregateFactory.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Reflection/ConfigurationAggregateFactory.cs @@ -22,6 +22,11 @@ namespace Ums.Infrastructure.Persistence.Reflection; internal static class ConfigurationAggregateFactory { + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Security Hotspot", "S3011:Reflection should not be used to increase accessibility of classes, methods, or fields", + Justification = "ADR-UMS-099: reflexión sancionada para rehidratación de agregados. Reconstruir un agregado " + + "ya validado (ADR-UMS-069) desde persistencia sin exponer setters públicos preserva la pureza " + + "del dominio; las invariantes se cumplieron al crearse.")] private static readonly BindingFlags InstanceFlags = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public; private static readonly Type AppConfigurationIdType = Type.GetType("Ums.Domain.Kernel.ValueObjects.AppConfigurationId, Ums.Domain")!; @@ -188,12 +193,13 @@ public static ParameterDefinition RehydrateParameterDefinition(ParameterDefiniti }); // Use the private rehydration constructor via reflection. + // (13 parámetros desde que la definición lleva su marca de borrado lógico `IsDeleted`.) var propsCtor = typeof(ParameterDefinitionProps) .GetConstructors(InstanceFlags) .Single(c => { var ps = c.GetParameters(); - return ps.Length == 12 && ps[10].ParameterType == typeof(string); // version param + return ps.Length == 13 && ps[11].ParameterType == typeof(string); // version param }); var props = (ParameterDefinitionProps)propsCtor.Invoke([ @@ -205,6 +211,7 @@ public static ParameterDefinition RehydrateParameterDefinition(ParameterDefiniti DefaultValue.Create(r.DefaultValue), ParameterScope.FromValue(r.ScopeId), r.IsActive, r.IsMandatory, r.DisplayOrder, + r.IsDeleted, r.Version, audit, ]); diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Reflection/IdentityAggregateFactory.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Reflection/IdentityAggregateFactory.cs index 1daa81a3..28febebe 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Reflection/IdentityAggregateFactory.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Reflection/IdentityAggregateFactory.cs @@ -2,7 +2,6 @@ using System.Text.Json; using Ums.Domain.Enums; using Ums.Domain.Identity.Tenant; -using Ums.Domain.Identity.Tenant.Branding; using Ums.Domain.Identity.Tenant.Branch; using Ums.Domain.Identity.Tenant.IdentityProvider; using Ums.Domain.Identity.Tenant.TenantParameter; @@ -23,13 +22,17 @@ namespace Ums.Infrastructure.Persistence.Reflection; internal static class IdentityAggregateFactory { + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Security Hotspot", "S3011:Reflection should not be used to increase accessibility of classes, methods, or fields", + Justification = "ADR-UMS-099: reflexión sancionada para rehidratación de agregados. Reconstruir un agregado " + + "ya validado (ADR-UMS-069) desde persistencia sin exponer setters públicos preserva la pureza " + + "del dominio; las invariantes se cumplieron al crearse.")] private static readonly BindingFlags InstanceFlags = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public; public static TenantAggregate RehydrateTenant( TenantRecord tenantRecord, IReadOnlyCollection branchRecords, - IReadOnlyCollection providerRecords, - TenantBrandingRecord? brandingRecord) + IReadOnlyCollection providerRecords) { var audit = AuditValueObject.Load(new AuditProps { @@ -49,6 +52,8 @@ public static TenantAggregate RehydrateTenant( string.IsNullOrWhiteSpace(tenantRecord.CompanyReference) ? null : CompanyReference.Create(tenantRecord.CompanyReference), tenantRecord.ParentTenantId.HasValue ? TenantId.Load(tenantRecord.ParentTenantId.Value) : null, tenantRecord.IsManagementOwner, + // FR-042 (ADR-UMS-097 §2.2): suite por defecto del inquilino (nullable/retrocompatible). + tenantRecord.DefaultSystemSuiteId.HasValue ? SystemSuiteId.Load(tenantRecord.DefaultSystemSuiteId.Value) : null, DomainEnumerationMapper.FromValue(tenantRecord.StatusId), audit); @@ -56,11 +61,9 @@ public static TenantAggregate RehydrateTenant( var branches = branchRecords.Select(RehydrateBranch).ToList(); var providers = providerRecords.Select(RehydrateIdentityProvider).ToList(); - var branding = brandingRecord is null ? null : RehydrateBranding(brandingRecord); SetField(tenant, "_branches", branches); SetField(tenant, "_identityProviders", providers); - SetField(tenant, "_branding", branding); tenant.DomainEvents.MarkChangesAsCommitted(); tenant.BrokenRules.Clear(); @@ -85,6 +88,11 @@ public static UserAccountAggregate RehydrateUserAccount( ? (DateTimeOffset?)new DateTimeOffset(record.ExpiresAtUtc.Value, TimeSpan.Zero) : null; + // ADR-UMS-095: rehidratación del estado de bloqueo temporal. + var lockedUntil = record.LockedUntilUtc.HasValue + ? (DateTimeOffset?)new DateTimeOffset(record.LockedUntilUtc.Value, TimeSpan.Zero) + : null; + var props = new UserAccountProps( UserAccountId.Load(record.Id), TenantId.Load(record.TenantId), @@ -96,7 +104,9 @@ public static UserAccountAggregate RehydrateUserAccount( record.IdentityReferenceTypeId.HasValue ? DomainEnumerationMapper.FromValue(record.IdentityReferenceTypeId.Value) : null, audit, string.IsNullOrWhiteSpace(record.DisplayName) ? null : Name.Create(record.DisplayName), - expiresAt); + expiresAt, + record.FailedLoginAttempts, + lockedUntil); var account = Construct(props); @@ -174,7 +184,8 @@ public static TenantParameterAggregate RehydrateTenantParameter(TenantParameterR UpdatedBy = record.UpdatedBy, UpdatedAt = record.UpdatedAtUtc, TimeSpan = record.AuditTimeSpan - })); + }), + record.IsDeleted); var parameter = Construct(props); parameter.DomainEvents.MarkChangesAsCommitted(); @@ -194,6 +205,13 @@ private static Branch RehydrateBranch(TenantBranchRecord record) ActorId.Create(record.CreatedBy)); props.IsActive = record.IsActive; + // ADR-0164: el cierre definitivo se rehidrata como estado; la BITÁCORA no, a propósito. + // Ninguna invariante del agregado depende de la historia, y `Branch.Create` —que sí anota el + // episodio de apertura— no interviene aquí: la rehidratación usa el constructor privado, así + // que una relectura del inquilino no puede inventar asientos que nunca ocurrieron. + props.IsClosed = record.IsClosed; + props.ClosedAtUtc = record.ClosedAtUtc; + props.ClosedBy = record.ClosedBy; SetAudit(props, record.CreatedBy, record.CreatedAtUtc, record.UpdatedBy, record.UpdatedAtUtc, record.AuditTimeSpan); return Construct(props); @@ -216,37 +234,6 @@ private static IdentityProvider RehydrateIdentityProvider(TenantIdentityProvider return Construct(props); } - private static Branding RehydrateBranding(TenantBrandingRecord record) - { - var audit = AuditValueObject.Load(new AuditProps - { - CreatedBy = record.CreatedBy, - CreatedAt = record.CreatedAtUtc, - UpdatedBy = record.UpdatedBy, - UpdatedAt = record.UpdatedAtUtc, - TimeSpan = record.AuditTimeSpan - }); - - var props = new BrandingProps( - BrandingId.Load(record.Id), - TenantId.Load(record.TenantId), - Logo.Create(record.Logo), - DomainEnumerationMapper.FromValue(record.LogoFormatId), - HexColor.Create(record.PrimaryColor), - DomainEnumerationMapper.FromValue(record.BackgroundStyleId), - LoginText.Create(record.HeadlineText), - LoginText.Create(record.SecondaryText), - LoginText.Create(record.PrimaryButtonLabel), - LoginText.Create(record.FooterText), - string.IsNullOrWhiteSpace(record.CustomDomain) ? null : CustomDomain.Create(record.CustomDomain), - DomainEnumerationMapper.FromValue(record.DnsVerificationStatusId), - DnsCnameTarget.Create(), - record.MagicLinkFallbackEnabled, - audit); - - return Construct(props); - } - private static MfaEnrollment RehydrateEnrollment(UserAccountMfaEnrollmentRecord record) { var audit = AuditValueObject.Load(new AuditProps diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Reflection/IgaAggregateFactory.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Reflection/IgaAggregateFactory.cs new file mode 100644 index 00000000..29817d16 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Reflection/IgaAggregateFactory.cs @@ -0,0 +1,99 @@ +using System; +using System.Reflection; +using Ums.Domain.Enums; +using Ums.Domain.IGA.RoleMaturityStatus; +using Ums.Domain.IGA.RolePromotionRequest; +using Ums.Domain.Kernel.ValueObjects; +using Ums.Infrastructure.Persistence.Iga.Entities; +using BeyondNetCode.Shell.Ddd.ValueObjects.Audit; + +namespace Ums.Infrastructure.Persistence.Reflection; + +using RoleMaturityStatusAggregate = Ums.Domain.IGA.RoleMaturityStatus.RoleMaturityStatus; +using RolePromotionRequestAggregate = Ums.Domain.IGA.RolePromotionRequest.RolePromotionRequest; + +/// +/// Rehidrata los agregados del contexto acotado IGA (ADR-UMS-093) desde sus registros de persistencia. +/// Invoca el constructor de rehidratación privado por reflexión (mismo patrón que +/// ) y marca los eventos como confirmados para que la carga +/// no vuelva a emitir el evento de creación ni deje reglas rotas colgando. +/// +internal static class IgaAggregateFactory +{ + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Security Hotspot", "S3011:Reflection should not be used to increase accessibility of classes, methods, or fields", + Justification = "ADR-UMS-099: reflexión sancionada para rehidratación de agregados. Reconstruir un agregado " + + "ya validado (ADR-UMS-069) desde persistencia sin exponer setters públicos preserva la pureza " + + "del dominio; las invariantes se cumplieron al crearse.")] + private static readonly BindingFlags InstanceFlags = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public; + + public static RoleMaturityStatusAggregate RehydrateRoleMaturityStatus(RoleMaturityStatusRecord record) + { + var props = new RoleMaturityStatusProps( + RoleMaturityStatusId.Load(record.Id), + TenantId.Load(record.TenantId), + UserId.Load(record.UserId), + RoleId.Load(record.RoleId), + (RoleMaturityLevel)record.CurrentMaturityLevel, + record.NextEligibleMaturityLevel.HasValue ? (RoleMaturityLevel)record.NextEligibleMaturityLevel.Value : null, + record.AssignedAt, + record.CurrentLevelSince, + record.EligibleForPromotionAt, + record.CompletedCertificationsCount, + record.CompletedTrainingsCount, + record.PerformanceScore, + record.HasNoComplianceIssues, + string.IsNullOrEmpty(record.BlockingFactor) ? null : TextValueObject.Create(record.BlockingFactor), + record.LastReviewedAt, + BuildAudit(record.CreatedBy, record.CreatedAtUtc, record.UpdatedBy, record.UpdatedAtUtc, record.AuditTimeSpan)); + + var aggregate = Construct(props); + aggregate.DomainEvents.MarkChangesAsCommitted(); + aggregate.BrokenRules.Clear(); + return aggregate; + } + + public static RolePromotionRequestAggregate RehydrateRolePromotionRequest(RolePromotionRequestRecord record) + { + var props = new RolePromotionRequestProps( + RolePromotionRequestId.Load(record.Id), + TenantId.Load(record.TenantId), + UserId.Load(record.TargetUserId), + UserId.Load(record.RequesterId), + RoleId.Load(record.CurrentRoleId), + RoleId.Load(record.TargetRoleId), + DomainEnumerationMapper.FromValue(record.StatusId), + record.RiskScore.HasValue ? RiskScore.Load(record.RiskScore.Value) : null, + record.ApproverId.HasValue ? UserId.Load(record.ApproverId.Value) : null, + record.SecurityReviewerId.HasValue ? UserId.Load(record.SecurityReviewerId.Value) : null, + record.ExecutorId.HasValue ? UserId.Load(record.ExecutorId.Value) : null, + record.VerifierId.HasValue ? UserId.Load(record.VerifierId.Value) : null, + record.DecisionReason, + BuildAudit(record.CreatedBy, record.CreatedAtUtc, record.UpdatedBy, record.UpdatedAtUtc, record.AuditTimeSpan)); + + var aggregate = Construct(props); + aggregate.DomainEvents.MarkChangesAsCommitted(); + aggregate.BrokenRules.Clear(); + return aggregate; + } + + private static AuditValueObject BuildAudit(string createdBy, DateTime createdAtUtc, string? updatedBy, DateTime? updatedAtUtc, string auditTimeSpan) + => AuditValueObject.Load(new AuditProps + { + CreatedBy = createdBy, + CreatedAt = createdAtUtc, + UpdatedBy = updatedBy, + UpdatedAt = updatedAtUtc, + TimeSpan = auditTimeSpan, + }); + + private static TEntity Construct(TProps props) + where TEntity : class + where TProps : class + { + var ctor = typeof(TEntity).GetConstructor(InstanceFlags, null, [typeof(TProps)], null) + ?? throw new InvalidOperationException($"Constructor for {typeof(TEntity).Name} not found."); + + return (TEntity)ctor.Invoke([props]); + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/ApprovalsDevDataSeeder.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/ApprovalsDevDataSeeder.cs index a133fe02..79a2a69f 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/ApprovalsDevDataSeeder.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/ApprovalsDevDataSeeder.cs @@ -46,6 +46,7 @@ public static async Task SeedAsync(IServiceProvider serviceProvider, Cancellatio var actor = ActorId.Create(CoreDevDataSeeder.SystemActorId); var ransaTenantId = TenantId.Load(Guid.Parse(CoreDevDataSeeder.RansaTenantId)); var internalAdminTenantId = TenantId.Load(Guid.Parse(CoreDevDataSeeder.InternalAdminTenantId)); + var beyondNetTenantId = TenantId.Load(Guid.Parse(CoreDevDataSeeder.BeyondNetTenantId)); var adminUserId = UserId.Load(Guid.Parse(CoreDevDataSeeder.RansaAdminUserId)); var internalAdminUserId = UserId.Load(Guid.Parse(CoreDevDataSeeder.SuperAdminUserId)); @@ -118,6 +119,20 @@ public static async Task SeedAsync(IServiceProvider serviceProvider, Cancellatio } } + // BEYONDNET tenant workflows (FS-25 §4.5 / criterio 13): ALTA_CLIENTE y ACCESO_EXPEDIENTE + var beyondNetWorkflows = BuildBeyondNetSeedWorkflows(beyondNetTenantId, actor); + if (inMemoryWfRepository is not null) + foreach (var wf in beyondNetWorkflows) inMemoryWfRepository.Seed(wf); + else if (wfRepository is not null) + { + var existing = await wfRepository.GetByTenantIdAsync(beyondNetTenantId.GetValue(), cancellationToken); + if (existing.Count == 0) + { + foreach (var wf in beyondNetWorkflows) await wfRepository.AddAsync(wf, cancellationToken); + await wfRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + } + } + // User Documents var userDocs = BuildSeedUserDocs(adminUserId, docTypes, actor); if (inMemoryUserDocRepository is not null) @@ -179,6 +194,38 @@ private static IReadOnlyList BuildInternalAdminSeedWo return workflow.IsSuccess ? new[] { workflow.Value } : Array.Empty(); } + // BEYONDNET tenant approval flows (FS-25 §4.5): alta de empresa cliente y acceso externo a expediente. + private static IReadOnlyList BuildBeyondNetSeedWorkflows(TenantId tenantId, ActorId actor) + { + var results = new List(); + + var altaCliente = ApprovalWorkflowAggregate.Create( + tenantId, + Code.Create("ALTA_CLIENTE"), + Name.Create("Alta de Empresa Cliente"), + Description.Create("Aprobación del alta de una empresa cliente en el operador BEYONDNET"), + UserCategory.External, + true, + null, + actor, + requiredDocumentCount: 1); + if (altaCliente.IsSuccess) results.Add(altaCliente.Value); + + var accesoExpediente = ApprovalWorkflowAggregate.Create( + tenantId, + Code.Create("ACCESO_EXPEDIENTE"), + Name.Create("Acceso a Expediente"), + Description.Create("Aprobación de acceso externo a un expediente del operador BEYONDNET"), + UserCategory.External, + true, + null, + actor, + requiredDocumentCount: 1); + if (accesoExpediente.IsSuccess) results.Add(accesoExpediente.Value); + + return results; + } + private static IReadOnlyList BuildInternalAdminSeedRequests( UserId requesterId, IReadOnlyList workflows, @@ -262,6 +309,15 @@ private static IReadOnlyList BuildSeedWorkflows(Tenan /// ApprovalsAggregateFactory.RehydrateWorkflow) so that integration tests can reference /// workflows by well-known IDs without relying on dynamic GUIDs. /// + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Major Code Smell", "S1172:Unused method parameters should be removed", + Justification = "'requiredDocumentCount' expresa la intención de datos-semilla (MANUAL_REVIEW exige 1 doc); " + + "ApprovalWorkflowProps aún no lo modela, se conserva para no perder la intención (ver G-016).")] + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Security Hotspot", "S3011:Reflection should not be used to increase accessibility of classes, methods, or fields", + Justification = "ADR-UMS-099: reflexión sancionada en seeder de desarrollo/UAT. Instancia el agregado por su " + + "constructor privado para fijar un ID bien conocido; no se ejecuta en producción " + + "(SeedDevData && !IsProduction).")] private static ApprovalWorkflowAggregate CreateWorkflowWithFixedId( Guid fixedId, TenantId tenantId, diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/AuditDevDataSeeder.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/AuditDevDataSeeder.cs index e40c993c..008217c4 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/AuditDevDataSeeder.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/AuditDevDataSeeder.cs @@ -11,7 +11,6 @@ public static async Task SeedAsync(IServiceProvider serviceProvider, Cancellatio var auditRepository = serviceProvider.GetService(); var inMemoryAuditRepository = serviceProvider.GetService(); - var actor = ActorId.Create(CoreDevDataSeeder.SystemActorId); var ransaTenantId = TenantId.Load(Guid.Parse(CoreDevDataSeeder.RansaTenantId)); var recordResult = AuditRecord.Record( diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/AuthorizationDevDataSeeder.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/AuthorizationDevDataSeeder.cs index d1214f50..f4f64c7a 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/AuthorizationDevDataSeeder.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/AuthorizationDevDataSeeder.cs @@ -1,3 +1,4 @@ +#pragma warning disable S1144 namespace Ums.Infrastructure.Persistence.Seeders; using System.Reflection; @@ -7,6 +8,7 @@ namespace Ums.Infrastructure.Persistence.Seeders; using Ums.Domain.Authorization.Role; using Ums.Domain.Authorization.SystemSuite; using Ums.Domain.Authorization.SystemSuite.DomainResource; +using Ums.Domain.Authorization.SystemSuite.MenuNode; using Ums.Domain.Authorization.Template; using Ums.Domain.Kernel.ValueObjects; using Ums.Infrastructure.Persistence.Authorization.Entities; @@ -15,6 +17,8 @@ namespace Ums.Infrastructure.Persistence.Seeders; using SystemSuiteAggregate = Ums.Domain.Authorization.SystemSuite.SystemSuite; using PermissionTemplateAggregate = Ums.Domain.Authorization.Template.PermissionTemplate; using RoleAggregate = Ums.Domain.Authorization.Role.Role; +using ModuleEntity = Ums.Domain.Authorization.SystemSuite.Module.Module; +using MenuNodeEntity = Ums.Domain.Authorization.SystemSuite.MenuNode.MenuNode; using Ums.Domain.Enums; public static class AuthorizationDevDataSeeder @@ -42,8 +46,11 @@ public static async Task SeedAsync(IServiceProvider serviceProvider, Cancellatio TenantId.Load(Guid.Parse("c9b736b4-6a84-48f8-b34d-176bc5a6d542")), // NEPTUNIA TenantId.Load(Guid.Parse("a3f5b9d2-7c3d-4c8e-a9b0-123456789abc")), // APM_CALLAO TenantId.Load(Guid.Parse("9e8d7c6b-5a4f-3e2d-1c0b-9876543210fe")), // PAITA_PORT - TenantId.Load(Guid.Parse("5f4e3d2c-1b0a-9f8e-7d6c-543210987654")), // UNIMAR + TenantId.Load(Guid.Parse(CoreDevDataSeeder.BeyondNetTenantId)), // BEYONDNET TenantId.Load(Guid.Parse("f3e2d1c0-b9a8-7f6e-5d4c-321098765432")), // INTRADEVCO + TenantId.Load(Guid.Parse(CoreDevDataSeeder.ComexAndinaTenantId)), // COMEX_ANDINA (BEYONDNET client) + TenantId.Load(Guid.Parse(CoreDevDataSeeder.AgronorteTenantId)), // AGRONORTE (BEYONDNET client) + TenantId.Load(Guid.Parse(CoreDevDataSeeder.ImpoAndinaSubTenantId)), // IMPO_ANDINA_SUB (cliente de COMEX_ANDINA) }; foreach (var tenantId in allTenantIds) @@ -56,17 +63,28 @@ public static async Task SeedAsync(IServiceProvider serviceProvider, Cancellatio } else if (suiteRepository is not null) { + // La guarda es POR SUITE, no por inquilino. + // + // Antes la condición era «el inquilino no tiene ninguna suite»: todo o nada. + // Bastaba que tuviera UNA de cualquier otro sembrador para que este no añadiera + // ninguna de las suyas, ni entonces ni nunca. Así se quedó BEYONDNET sin la suite + // UMS —tenía las siete de su catálogo, luego la cuenta no era cero, luego nada + // que hacer— mientras los seis inquilinos genéricos sí la recibían. Un sembrador + // que solo actúa sobre una base vacía no siembra: inicializa una vez y después + // miente. var existing = await suiteRepository.GetByTenantIdAsync(tenantId.GetValue(), cancellationToken); - if (existing.Count == 0) + var yaPresentes = existing.Select(s => s.Code.GetValue()).ToHashSet(StringComparer.OrdinalIgnoreCase); + var faltantes = suites.Where(s => !yaPresentes.Contains(s.Code.GetValue())).ToList(); + + if (faltantes.Count > 0) { - foreach (var suite in suites) await suiteRepository.AddAsync(suite, cancellationToken); + foreach (var suite in faltantes) await suiteRepository.AddAsync(suite, cancellationToken); await suiteRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + existing = await suiteRepository.GetByTenantIdAsync(tenantId.GetValue(), cancellationToken); } - else - { - await EnsureDomainResourcesAsync(existing, tenantId, actor, suiteRepository, cancellationToken); - suites = existing; // use persisted suites so IDs match for roles/templates - } + + await EnsureDomainResourcesAsync(existing, actor, suiteRepository, cancellationToken); + suites = existing; // se usan las persistidas para que los ids casen con roles y plantillas } // Seed Roles @@ -77,16 +95,20 @@ public static async Task SeedAsync(IServiceProvider serviceProvider, Cancellatio } else if (roleRepository is not null) { + // Igual que arriba: por código de rol, no por inquilino. Los roles de una suite + // recién añadida no llegarían nunca si basta con que el inquilino tenga otros. var existing = await roleRepository.GetByTenantIdAsync(tenantId.GetValue(), cancellationToken); - if (existing.Count == 0) + var yaPresentes = existing.Select(r => r.Code.GetValue()).ToHashSet(StringComparer.OrdinalIgnoreCase); + var faltantes = roles.Where(r => !yaPresentes.Contains(r.Code.GetValue())).ToList(); + + if (faltantes.Count > 0) { - foreach (var role in roles) await roleRepository.AddAsync(role, cancellationToken); + foreach (var role in faltantes) await roleRepository.AddAsync(role, cancellationToken); await roleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + existing = await roleRepository.GetByTenantIdAsync(tenantId.GetValue(), cancellationToken); } - else - { - roles = existing; - } + + roles = existing; } // Seed PermissionTemplates @@ -97,16 +119,20 @@ public static async Task SeedAsync(IServiceProvider serviceProvider, Cancellatio } else if (templateRepository is not null) { + // Por ROL: una plantilla pertenece a un rol, así que un rol nuevo necesita la suya + // aunque el inquilino ya tenga plantillas de otros roles. var existing = await templateRepository.GetByTenantIdAsync(tenantId.GetValue(), cancellationToken); - if (existing.Count == 0) + var rolesConPlantilla = existing.Select(p => p.RoleId.GetValue()).ToHashSet(); + var faltantes = templates.Where(p => !rolesConPlantilla.Contains(p.RoleId.GetValue())).ToList(); + + if (faltantes.Count > 0) { - foreach (var template in templates) await templateRepository.AddAsync(template, cancellationToken); + foreach (var template in faltantes) await templateRepository.AddAsync(template, cancellationToken); await templateRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + existing = await templateRepository.GetByTenantIdAsync(tenantId.GetValue(), cancellationToken); } - else - { - templates = existing; - } + + templates = existing; } // Seed Profiles @@ -117,13 +143,24 @@ public static async Task SeedAsync(IServiceProvider serviceProvider, Cancellatio } else if (profileRepository is not null) { + // Por par (usuario, rol): es la clave natural de un perfil. Sin esto, el usuario + // que ya tiene perfil en otra suite jamás recibiría el de una suite nueva —que es + // justo lo que hacía falta para que quien administra UMS pudiera entrar a UMS. var existing = await profileRepository.GetByTenantIdAsync(tenantId.GetValue(), cancellationToken); - if (existing.Count == 0) + var yaAsignados = existing + .Select(p => (Usuario: p.UserId.GetValue(), Rol: p.RoleId.GetValue())) + .ToHashSet(); + var faltantes = profiles + .Where(p => !yaAsignados.Contains((p.UserId.GetValue(), p.RoleId.GetValue()))) + .ToList(); + + if (faltantes.Count > 0) { - foreach (var profile in profiles) await profileRepository.AddAsync(profile, cancellationToken); + foreach (var profile in faltantes) await profileRepository.AddAsync(profile, cancellationToken); await profileRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); } - else if (tenantId.GetValue() == Guid.Parse(CoreDevDataSeeder.InternalAdminTenantId)) + + if (tenantId.GetValue() == Guid.Parse(CoreDevDataSeeder.InternalAdminTenantId)) { await EnsureInternalAdminProfileAsync(profileRepository, roleRepository, templateRepository, cancellationToken); } @@ -131,23 +168,748 @@ public static async Task SeedAsync(IServiceProvider serviceProvider, Cancellatio } } + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Security Hotspot", "S3011:Reflection should not be used to increase accessibility of classes, methods, or fields", + Justification = "ADR-UMS-099: reflexión sancionada en seeder de desarrollo/UAT. La siembra determinista con " + + "IDs bien conocidos exige fijar identidades sobre miembros no públicos; no se ejecuta en " + + "producción (SeedDevData && !IsProduction).")] private static readonly BindingFlags PrivateInstanceFlags = BindingFlags.Instance | BindingFlags.NonPublic; - private static void SetRoleId(RoleAggregate role, Guid id) + // ── FS-25 dispatch: BEYONDNET gets the 6 domain suites/roles/templates/profiles; its + // client tenants get a Portal-only subset; every other tenant keeps the generic set. + private static readonly Guid[] BeyondNetClientTenantIds = + { + Guid.Parse(CoreDevDataSeeder.ComexAndinaTenantId), + Guid.Parse(CoreDevDataSeeder.AgronorteTenantId), + Guid.Parse(CoreDevDataSeeder.FrupiuraTenantId), + Guid.Parse(CoreDevDataSeeder.ImpoAndinaSubTenantId), // cliente de mi cliente — mismo trato acotado + }; + + private static bool IsBeyondNet(TenantId tenantId) => tenantId.GetValue() == Guid.Parse(CoreDevDataSeeder.BeyondNetTenantId); + + private static bool IsBeyondNetClient(TenantId tenantId) => Array.IndexOf(BeyondNetClientTenantIds, tenantId.GetValue()) >= 0; + + private static IReadOnlyList BuildSeedSystemSuites(TenantId tenantId, ActorId actor) { - var propsField = typeof(RoleAggregate).GetField("_props", PrivateInstanceFlags); - var props = propsField?.GetValue(role) as RoleProps; - var idProperty = typeof(RoleProps).GetProperty("Id", PrivateInstanceFlags); - idProperty?.SetValue(props, RoleId.Load(id)); + if (IsBeyondNet(tenantId)) + { + return BuildBeyondNetSystemSuites(tenantId, actor); + } + + if (IsBeyondNetClient(tenantId)) + { + var portal = BuildPortalClienteSuite(tenantId, actor); + return portal is null ? Array.Empty() : new[] { portal }; + } + + return BuildGenericSystemSuites(tenantId, actor); } private static IReadOnlyList BuildSeedRoles(TenantId tenantId, IReadOnlyList suites, ActorId actor) + { + if (IsBeyondNet(tenantId)) + { + return BuildBeyondNetRoles(tenantId, suites, actor); + } + + if (IsBeyondNetClient(tenantId)) + { + return BuildClienteExternoRoles(tenantId, suites, actor); + } + + return BuildGenericRoles(tenantId, suites, actor); + } + + private static IReadOnlyList BuildSeedPermissionTemplates(TenantId tenantId, IReadOnlyList suites, IReadOnlyList roles, ActorId actor) + { + if (IsBeyondNet(tenantId) || IsBeyondNetClient(tenantId)) + { + return BuildRoleAnchoredPermissionTemplates(tenantId, suites, roles, actor); + } + + return BuildGenericPermissionTemplates(tenantId, suites, roles, actor); + } + + private static IReadOnlyList BuildSeedProfiles(TenantId tenantId, IReadOnlyList roles, IReadOnlyList templates, ActorId actor) + { + if (IsBeyondNet(tenantId)) + { + return BuildBeyondNetProfiles(tenantId, roles, templates, actor); + } + + if (IsBeyondNetClient(tenantId)) + { + return BuildClienteProfiles(tenantId, roles, templates, actor); + } + + return BuildGenericProfiles(tenantId, roles, templates, actor); + } + + // ── FS-25 suite tree builder (module → menu → submenu → options) ───────────── + private sealed record OptionSpec(string Code, string Name, string Action); + private sealed record MenuSpec(string Code, string Name, OptionSpec[] Options); + private sealed record ModuleSpec(string Code, string Name, MenuSpec[] Menus); + private sealed record SuiteSpec(string Code, string Name, string Description, ModuleSpec[] Modules); + + private static readonly (string Code, string Name)[] StandardSuiteActions = + { + ("VIEW", "Ver"), + ("CREATE", "Crear"), + ("READ", "Leer"), + ("UPDATE", "Actualizar"), + ("DELETE", "Eliminar"), + ("APPROVE", "Aprobar"), + ("SEARCH", "Buscar"), + }; + + // A menu with the two standard options (view + manage). Option codes stay unique + // per suite because menu codes are unique per suite. + private static MenuSpec Menu(string code, string name) => new(code, name, new[] + { + new OptionSpec("VIEW_" + code, "Ver " + name, "VIEW"), + new OptionSpec("MANAGE_" + code, "Gestionar " + name, "UPDATE"), + }); + + private static SuiteSpec PortalClienteSuiteSpec() => new( + "PORTAL_CLIENTE", "Portal del Cliente", "Portal de consulta acotado para clientes externos", + new[] + { + new ModuleSpec("QUERY", "Consultas", new[] { Menu("FILE_STATUS", "Estado de Expediente"), Menu("DOCUMENTS", "Documentos") }), + new ModuleSpec("NOTIF", "Notificaciones", new[] { Menu("NOTICES", "Avisos") }), + }); + + private static IReadOnlyList BeyondNetSuiteSpecs() => new[] + { + new SuiteSpec("TMS", "Transporte", "Sistema de gestión de transporte", new[] + { + new ModuleSpec("PLAN", "Planificación", new[] { Menu("TORDERS", "Órdenes de Transporte"), Menu("ROUTES", "Rutas") }), + new ModuleSpec("FLEET", "Flota", new[] { Menu("VEHICLES", "Vehículos"), Menu("DRIVERS", "Conductores") }), + new ModuleSpec("TRACK", "Seguimiento", new[] { Menu("MONITOR", "Monitoreo"), Menu("MILESTONES", "Hitos") }), + }), + new SuiteSpec("WMS", "Almacén", "Sistema de gestión de almacén", new[] + { + new ModuleSpec("INV", "Inventario", new[] { Menu("STOCK", "Stock"), Menu("OPS", "Operaciones") }), + new ModuleSpec("RCV", "Recepción y Despacho", new[] { Menu("RECEIPTS", "Recepciones"), Menu("DISPATCHES", "Despachos") }), + new ModuleSpec("REPORTS", "Reportes", new[] { Menu("INV_REPORTS", "Reportes de Inventario"), Menu("IO", "Importar / Exportar") }), + }), + new SuiteSpec("SIL", "Sistema Integral Logístico", "Expedientes, costos y trazabilidad", new[] + { + new ModuleSpec("FILES", "Expedientes", new[] { Menu("FILES_IMPO", "Expedientes Impo"), Menu("FILES_EXPO", "Expedientes Expo") }), + new ModuleSpec("COST", "Costos", new[] { Menu("COST_SETTLE", "Liquidación de Costos") }), + new ModuleSpec("TRACE", "Trazabilidad", new[] { Menu("TIMELINE", "Línea de Tiempo") }), + }), + new SuiteSpec("ADUANAS", "Aduanas", "Declaraciones, canales y agentes de aduana", new[] + { + new ModuleSpec("DECL", "Declaraciones", new[] { Menu("DUA_IMPO", "DUA Importación"), Menu("DAM_EXPO", "DAM Exportación") }), + new ModuleSpec("CHANNEL", "Canales", new[] { Menu("CH_ROJO", "Canal Rojo"), Menu("CH_NARANJA", "Canal Naranja"), Menu("CH_VERDE", "Canal Verde") }), + new ModuleSpec("AGENT", "Agentes", new[] { Menu("CUSTOMS_AGENTS", "Agentes de Aduana"), Menu("POWERS", "Poderes") }), + }), + PortalClienteSuiteSpec(), + new SuiteSpec("FACTURACION", "Facturación", "Facturación, liquidación y cobranzas", new[] + { + new ModuleSpec("BILL", "Facturación", new[] { Menu("INVOICES", "Facturas"), Menu("CREDIT_NOTES", "Notas de Crédito") }), + new ModuleSpec("SETTLE", "Liquidación", new[] { Menu("SETTLEMENTS", "Liquidaciones") }), + new ModuleSpec("COLLECT", "Cobranzas", new[] { Menu("ACCOUNT_STATE", "Estado de Cuenta") }), + }), + }; + + /// + /// Ajustes VISIBLES para el cliente: branding, tema, disposición e idioma. + /// + /// Son los que permiten al frontend inicializar la aplicación sin llamadas adicionales + /// (G-178). Van marcados uno a uno con `isClientVisible: true` a propósito: el default es no + /// publicar, porque esta misma bolsa alberga ajustes operativos que no deben salir del + /// servidor. + /// + /// La convención de clave es `ESPACIO_RESTO`, que el grafo proyecta como + /// `settings.espacio.resto`. + /// + private static void SembrarAjustesDeCliente(SystemSuiteAggregate suite, SuiteSpec spec, ActorId actor) + { + void Visible(string clave, string valor) => + suite.AddAppSetting( + ConfigurationKey.Create(clave), + ConfigurationValue.Create(valor), + ConfigurationScope.Global, + actor, + isClientVisible: true); + + Visible("BRAND_DISPLAY_NAME", spec.Name); + Visible("BRAND_SHORT_NAME", spec.Code); + // Descriptor bajo el nombre comercial. Es la descripción del sistema, no una frase de + // marketing: quien lee la barra quiere saber en qué sistema está, no que se lo vendan. + Visible("BRAND_TAGLINE", spec.Description); + Visible("BRAND_LOGO_URL", $"/branding/{spec.Code.ToLowerInvariant()}/logo.svg"); + Visible("BRAND_ICON_URL", $"/branding/{spec.Code.ToLowerInvariant()}/icon.svg"); + + // Azul corporativo de BeyondNet; el acento y la superficie derivan de él. + Visible("THEME_PRIMARY", "#0f3e67"); + Visible("THEME_ACCENT", "#27ae60"); + Visible("THEME_MODE", "system"); + + // Disposición del shell. El cliente entiende `nav-rail` y `nav-rail-compact`; cualquier + // otro valor cae en la primera en vez de dejar la pantalla sin navegación. + Visible("UI_LAYOUT", "nav-rail"); + Visible("UI_HOME_ROUTE", "/"); + Visible("UI_DENSITY", "comfortable"); + + Visible("LOCALE_LANGUAGE", "es-PE"); + Visible("LOCALE_TIMEZONE", "America/Lima"); + Visible("LOCALE_CURRENCY", "PEN"); + + // Contraejemplo deliberado: un ajuste operativo del mismo sistema que NO se publica. + // Si alguien vuelca la bolsa entera por comodidad, esta clave aparecería en el cable. + suite.AddAppSetting( + ConfigurationKey.Create("OPS_HEALTHCHECK_INTERVAL_S"), + ConfigurationValue.Create("30"), + ConfigurationScope.Global, + actor); + } + + private static SystemSuiteAggregate? BuildSuiteFromSpec(TenantId tenantId, ActorId actor, SuiteSpec spec) + { + var result = SystemSuiteAggregate.Create(tenantId, Code.Create(spec.Code), Name.Create(spec.Name), Description.Create(spec.Description), actor); + if (result.IsFailure) + { + return null; + } + + var suite = result.Value; + + foreach (var action in StandardSuiteActions) + { + suite.RegisterAction(ActionCode.Create(action.Code), Name.Create(action.Name), actor); + } + + SembrarAjustesDeCliente(suite, spec, actor); + + var moduleOrder = 1; + foreach (var moduleSpec in spec.Modules) + { + var moduleResult = suite.AddModule(Code.Create(moduleSpec.Code), Name.Create(moduleSpec.Name), Description.Create(moduleSpec.Name), moduleOrder++, actor, IconoDeModulo(moduleSpec.Code)); + if (moduleResult.IsFailure) + { + continue; + } + + var module = suite.Modules.First(m => m.Code.GetValue() == moduleSpec.Code); + suite.ActivateModule(module.Props.Id, actor); + + var menuOrder = 1; + foreach (var menuSpec in moduleSpec.Menus) + { + var subMenuCode = menuSpec.Code + "_LIST"; + SeedNavMenu( + suite, module, menuOrder++, + menuSpec.Code, menuSpec.Name, + subMenuCode, menuSpec.Name, + menuSpec.Options.Select(o => (o.Code, o.Name, o.Action)).ToArray(), + actor); + } + } + + return suite; + } + + /// + /// Siembra un menú de navegación como árbol de nodos (ADR-0090): un nodo raíz + /// , un submenú y + /// las opciones hoja con su funcionalidad N:M. + /// Reemplaza la antigua cadena rígida Menú/Submenú/Opción. + /// + private static void SeedNavMenu( + SystemSuiteAggregate suite, + ModuleEntity module, + int menuOrder, + string menuCode, + string menuName, + string subCode, + string subName, + (string Code, string Name, string Action)[] options, + ActorId actor, + string? rutaMenu = null) + { + // Icono en el menú —es el nivel que el usuario ve en la barra lateral— y ruta en la + // opción, que es la que navega. Un submenú solo agrupa: ni icono ni ruta. + suite.AddModuleRootNode(module.Props.Id, NodeKind.Menu, Code.Create(menuCode), Name.Create(menuName), Description.Create(menuName), menuOrder, actor, + presentation: MenuNodePresentation.Create(IconoDeMenu(menuCode), rutaMenu)); + var menuNode = module.Nodes.First(n => n.Code.GetValue() == menuCode); + + suite.AddModuleChildNode(module.Props.Id, menuNode.GetId(), NodeKind.SubMenu, Code.Create(subCode), Name.Create(subName), Description.Create(subName), 1, actor); + var subNode = menuNode.Children.First(n => n.Code.GetValue() == subCode); + + var optionOrder = 1; + foreach (var opt in options) + { + // Con ruta real en el menú, las opciones son PERMISOS sobre esa pantalla, no destinos + // distintos: darles una ruta inventada llenaría el menú de enlaces rotos. + var ruta = rutaMenu is null + ? $"/{module.Code.GetValue().ToLowerInvariant()}/{opt.Code.ToLowerInvariant().Replace('_', '-')}" + : null; + suite.AddModuleChildNode(module.Props.Id, subNode.GetId(), NodeKind.Option, Code.Create(opt.Code), Name.Create(opt.Name), Description.Create(opt.Name), optionOrder++, actor, + presentation: MenuNodePresentation.Create(null, ruta)); + var optNode = subNode.Children.First(n => n.Code.GetValue() == opt.Code); + suite.LinkModuleNodeAction(module.Props.Id, optNode.GetId(), ActionCode.Create(opt.Action), actor); + } + } + + /// + /// Icono del módulo por convención sobre su código, con el mismo criterio que el del menú: un + /// IDENTIFICADOR que el cliente resuelve, nunca un recurso. + /// + private static string IconoDeModulo(string moduleCode) => moduleCode switch + { + // Portal de administración de UMS. + "IDM" => "shield-check", + "AUTH" => "shield-check", + "SYS" => "settings", + // Sistemas satélite del grupo. + "INV" or "RCV" => "package", + "REPORTS" => "file-text", + "PLAN" or "FLEET" => "truck", + "TRACK" or "TRACE" => "activity", + "FILES" => "folder-kanban", + "DECL" or "CHANNEL" => "file-text", + "AGENT" => "users", + "BILL" or "SETTLE" or "COLLECT" or "COST" => "receipt", + "QUERY" => "inbox", + "NOTIF" => "bell", + _ => "layout-grid", + }; + + /// + /// Icono del menú por convención sobre su código. + /// + /// Es un IDENTIFICADOR, no un recurso: el catálogo gráfico lo resuelve el cliente. Guardar + /// aquí una URL o un SVG ataría el servidor a la biblioteca de iconos de un frontend concreto, + /// y cambiarla obligaría a migrar datos. + /// + private static string IconoDeMenu(string menuCode) => menuCode switch + { + "SATELITES" or "TORDERS" or "VEHICLES" => "truck", + "STOCK" or "INV_REPORTS" or "RECEIPTS" or "DISPATCHES" => "package", + "FILES_IMPO" or "FILES_EXPO" or "DUA_IMPO" or "DAM_EXPO" => "file-text", + "INVOICES" or "CREDIT_NOTES" or "SETTLEMENTS" or "ACCOUNT_STATE" => "receipt", + "MONITOR" or "MILESTONES" or "TIMELINE" => "activity", + "CUSTOMS_AGENTS" or "POWERS" or "DRIVERS" => "users", + "NOTICES" => "bell", + // Portal de administración de UMS: los identificadores casan con el registro del shell. + "TENANTS" => "building", + "USERS" => "users", + "DELEGATIONS" => "git-merge", + "SYSTEM_SUITES" => "cpu", + "PERMISSION_TEMPLATES" or "PROFILES" => "shield-check", + "FEATURE_FLAGS" => "flag", + "APP_CONFIG" or "PARAM_CATALOG" => "settings", + _ => "layout-grid", + }; + + /// Añade un nodo (raíz si es nulo) y lo devuelve. + private static MenuNodeEntity AddNode( + SystemSuiteAggregate suite, + ModuleEntity module, + MenuNodeEntity? parent, + NodeKind kind, + string code, + string name, + int order, + ActorId actor) + { + if (parent is null) + suite.AddModuleRootNode(module.Props.Id, kind, Code.Create(code), Name.Create(name), Description.Create(name), order, actor); + else + suite.AddModuleChildNode(module.Props.Id, parent.GetId(), kind, Code.Create(code), Name.Create(name), Description.Create(name), order, actor); + + return FindNodeByCode(module, code)!; + } + + /// Busca un nodo por código en todo el árbol recursivo del módulo (ADR-0090). + private static MenuNodeEntity? FindNodeByCode(ModuleEntity module, string code) + { + static MenuNodeEntity? Search(IEnumerable nodes, string code) + { + foreach (var n in nodes) + { + if (n.Code.GetValue() == code) return n; + var found = Search(n.Children, code); + if (found is not null) return found; + } + return null; + } + return Search(module.Nodes, code); + } + + /// Enumera todos los nodos del árbol del módulo en preorden. + private static IEnumerable EnumerateNodes(ModuleEntity module) + { + static IEnumerable Walk(IEnumerable nodes) + { + foreach (var n in nodes) + { + yield return n; + foreach (var c in Walk(n.Children)) + yield return c; + } + } + return Walk(module.Nodes); + } + + private static IReadOnlyList BuildBeyondNetSystemSuites(TenantId tenantId, ActorId actor) + { + var suites = BeyondNetSuiteSpecs() + .Select(spec => BuildSuiteFromSpec(tenantId, actor, spec)) + .Where(suite => suite is not null) + .Select(suite => suite!) + .ToList(); + + // BEYONDNET opera el sistema de identidad de la suite y era el unico inquilino que NO lo tenia + // en su catalogo: quien lo administra no podia entrar a el. Se toma la MISMA suite que + // reciben los demas —no una version propia— porque un sistema debe verse igual desde + // cualquier inquilino, o el grafo deja de ser comparable entre ellos. + var ums = BuildUmsCoreSuite(tenantId, actor); + if (ums is not null) + { + suites.Add(ums); + } + + return suites; + } + + private static SystemSuiteAggregate? BuildPortalClienteSuite(TenantId tenantId, ActorId actor) + => BuildSuiteFromSpec(tenantId, actor, PortalClienteSuiteSpec()); + + // ── FS-25 §4.3 roles ───────────────────────────────────────────────────────── + private static IReadOnlyList BuildBeyondNetRoles(TenantId tenantId, IReadOnlyList suites, ActorId actor) + { + var roles = new List(); + + void Add(string code, string name, string description, string suiteCode) + { + var suite = suites.FirstOrDefault(s => s.Code.GetValue() == suiteCode); + if (suite is null) + { + return; + } + + var result = RoleAggregate.Create(tenantId, suite.GetId(), Code.Create(code), Name.Create(name), Description.Create(description), null, 0, 0, actor); + if (result.IsSuccess) + { + roles.Add(result.Value); + } + } + + Add("AGENTE_ADUANAS", "Agente de Aduanas", "Gestiona declaraciones y trámites aduaneros", "ADUANAS"); + Add("DESPACHADOR", "Despachador", "Ejecuta despachos aduaneros y de expedientes", "ADUANAS"); + Add("JEFE_ALMACEN", "Jefe de Almacén", "Dirige las operaciones de almacén", "WMS"); + Add("OPERARIO_ALMACEN", "Operario de Almacén", "Ejecuta operaciones físicas de almacén", "WMS"); + Add("COORD_TRANSPORTE", "Coordinador de Transporte", "Coordina la planificación y la flota de transporte", "TMS"); + Add("EJECUTIVO_CUENTA", "Ejecutivo de Cuenta", "Atiende la relación comercial con el cliente", "SIL"); + Add("ANALISTA_DOC", "Analista Documentario", "Gestiona la documentación de los expedientes", "SIL"); + Add("CLIENTE_EXTERNO", "Cliente Externo", "Acceso acotado de solo lectura al Portal del Cliente", "PORTAL_CLIENTE"); + Add("AUDITOR", "Auditor", "Acceso transversal de solo lectura", "SIL"); + Add("ADMINISTRADOR", "Administrador", "Acceso administrativo total del operador", "SIL"); + + // Administración del propio UMS. Se separa en tres y no en uno porque quien da de alta a + // un operario de almacén no es quien decide qué permisos concede un rol: la primera es + // tarea diaria de mesa de ayuda, la segunda cambia el modelo de acceso de toda la suite. + // Un único rol «administrador de UMS» obligaría a conceder lo segundo para poder hacer lo + // primero. + Add("ADMIN_UMS", "Administrador de UMS", "Gobierna identidad, autorización y configuración de la suite", "UMS"); + Add("MESA_ACCESOS", "Mesa de Accesos", "Da de alta usuarios y asigna perfiles; no altera roles ni plantillas", "UMS"); + Add("AUDITOR_ACCESOS", "Auditor de Accesos", "Consulta quién pudo hacer qué, sin poder cambiarlo", "UMS"); + + return roles; + } + + private static IReadOnlyList BuildClienteExternoRoles(TenantId tenantId, IReadOnlyList suites, ActorId actor) + { + var roles = new List(); + var portal = suites.FirstOrDefault(s => s.Code.GetValue() == "PORTAL_CLIENTE"); + if (portal is null) + { + return roles; + } + + var result = RoleAggregate.Create(tenantId, portal.GetId(), Code.Create("CLIENTE_EXTERNO"), Name.Create("Cliente Externo"), Description.Create("Acceso acotado de solo lectura al Portal del Cliente"), null, 0, 0, actor); + if (result.IsSuccess) + { + roles.Add(result.Value); + } + + return roles; + } + + // ── FS-25 permission templates — one published template per role, anchored to + // the role's suite; a role's template never references another suite. ──────── + private static IReadOnlyList BuildRoleAnchoredPermissionTemplates(TenantId tenantId, IReadOnlyList suites, IReadOnlyList roles, ActorId actor) + { + var templates = new List(); + + foreach (var role in roles) + { + var suite = suites.FirstOrDefault(s => s.GetId().GetValue() == role.SystemSuiteId.GetValue()); + if (suite is null) + { + continue; + } + + var templateResult = PermissionTemplateAggregate.Create(tenantId, role.GetId(), suite.GetId(), actor); + if (templateResult.IsFailure) + { + continue; + } + + var template = templateResult.Value; + AddFullSuiteNavigation(template, suite, actor, ModulosVisiblesPara(role.Code.GetValue())); + template.Publish(actor); + templates.Add(template); + } + + return templates; + } + + /// + /// Módulos que ve un rol, o null para «todos». + /// + /// Por omisión cada rol recibe la navegación completa de su suite, que es lo que hace + /// falta para que la siembra sea navegable de inmediato. Pero en UMS eso dejaba a los tres + /// roles viendo exactamente lo mismo —24 alcances cada uno—, y tres roles indistinguibles no + /// permiten comprobar lo único que un sistema de permisos tiene que demostrar: que la interfaz + /// cambia según quién entra. Aquí la diferencia es el dato, no un adorno. + /// + /// El corte es por módulo y no por acción porque es el eje que el grafo proyecta al + /// frontend: quien no tiene el módulo no ve su menú. Un corte más fino exigiría modelar + /// acciones por nodo, que es otra decisión. + /// + private static string[]? ModulosVisiblesPara(string roleCode) => roleCode switch + { + // Gobierna la identidad de la suite: identidad, autorización y configuración. + "ADMIN_UMS" => null, + // Da de alta usuarios y asigna perfiles. No toca la configuración del sistema: cambiar un + // parámetro global no es tarea de mesa de ayuda. + "MESA_ACCESOS" => new[] { "IDM", "AUTH" }, + // Consulta quién pudo hacer qué. Vive en el modelo de acceso —roles, perfiles, plantillas— + // y no necesita el padrón de usuarios para responder esa pregunta. + "AUDITOR_ACCESOS" => new[] { "AUTH" }, + _ => null, + }; + + private static void AddFullSuiteNavigation( + PermissionTemplateAggregate template, + SystemSuiteAggregate suite, + ActorId actor, + string[]? modulosVisibles = null) + { + template.AddItem(ExclusiveArcTarget.SystemSuite, suite.GetId(), ActionId.Create(), true, false, actor); + foreach (var module in suite.Modules) + { + if (modulosVisibles is not null + && !modulosVisibles.Contains(module.Code.GetValue(), StringComparer.OrdinalIgnoreCase)) + { + continue; + } + + template.AddItem(ExclusiveArcTarget.Module, module.Props.Id, ActionId.Create(), true, false, actor); + foreach (var root in module.Nodes) + { + AddNodeNavigation(template, root, suite, actor); + } + } + } + + // Recorre el árbol de nodos (ADR-0090) mapeando el rol del nodo al destino + // del permiso: Menú → Submodule; Submenú/Opción → Option. + private static void AddNodeNavigation(PermissionTemplateAggregate template, MenuNodeEntity node, SystemSuiteAggregate suite, ActorId actor) + { + if (node.Kind == NodeKind.Option) + { + // Opción hoja: el grafo evalúa el efecto aquí casando (optionId, actionId). + // Debe usar el ActionId REAL de cada acción vinculada (N:M, ADR-0090); un + // ActionId aleatorio nunca casaría y todo resolvería NotGranted (G-038). + AddOptionActionItems(template, node, suite, actor); + } + else + { + // Nodo estructural (Menú → Submodule; Submenú → Option): concede la + // navegación al nodo. El grafo no evalúa efecto a este nivel, por lo que + // el ActionId es indiferente. + var target = node.Kind == NodeKind.Menu ? ExclusiveArcTarget.Submodule : ExclusiveArcTarget.Option; + template.AddItem(target, node.GetId(), ActionId.Create(), true, false, actor); + } + + foreach (var child in node.Children) + { + AddNodeNavigation(template, child, suite, actor); + } + } + + // Añade una fila de permiso de opción por cada acción REAL vinculada al nodo + // (N:M, ADR-0090), usando el ActionId de la Action registrada en la suite. Es + // la clave del concesión efectiva: AuthorizationGraphBuilderService casa + // (optionId, actionId real) — con un ActionId aleatorio todo sería NotGranted (G-038). + private static void AddOptionActionItems( + PermissionTemplateAggregate template, + MenuNodeEntity optionNode, + SystemSuiteAggregate suite, + ActorId actor, + bool isAllowed = true, + bool isDenied = false) + { + foreach (var actionCode in optionNode.ActionCodes) + { + var action = suite.Actions.FirstOrDefault(a => a.Code.GetValue() == actionCode.GetValue()); + if (action is null) + { + continue; + } + + template.AddItem(ExclusiveArcTarget.Option, optionNode.GetId(), action.GetId(), isAllowed, isDenied, actor); + } + } + + // ── FS-25 §4.4 profiles — one per internal user, linked by deterministic GUID. + // Each profile groups the conceptual "perfil" roles via their published templates. + private static IReadOnlyList BuildBeyondNetProfiles(TenantId tenantId, IReadOnlyList roles, IReadOnlyList templates, ActorId actor) + { + var profiles = new List(); + + var baseBytes = tenantId.GetValue().ToByteArray(); + Guid UserGuid(byte idx) + { + var b = (byte[])baseBytes.Clone(); + b[0] = idx; + return new Guid(b); + } + + var roleByCode = roles + .GroupBy(r => r.Code.GetValue()) + .ToDictionary(g => g.Key, g => g.First()); + var templateByRoleId = templates + .GroupBy(t => t.RoleId.GetValue()) + .ToDictionary(g => g.Key, g => g.First()); + + // (user index, primary role, roles grouped by the conceptual profile) — FS-25 §4.3/§4.4. + var opAduanas = new[] { "AGENTE_ADUANAS", "DESPACHADOR", "ANALISTA_DOC" }; + var almacen = new[] { "JEFE_ALMACEN", "OPERARIO_ALMACEN" }; + var admin = new[] { "ADMINISTRADOR", "AUDITOR" }; + + // Los tres perfiles de UMS son ESCALONADOS y no equivalentes: quien gobierna ve lo de la + // mesa y lo del auditor; la mesa ve lo del auditor; el auditor solo lo suyo. Es lo que + // hace comparable el grafo entre ellos — si los tres cargaran las mismas plantillas, la + // prueba de que la interfaz cambia por rol no probaría nada. + var umsAuditoria = new[] { "AUDITOR_ACCESOS" }; + var umsMesa = new[] { "MESA_ACCESOS", "AUDITOR_ACCESOS" }; + var umsGobierno = new[] { "ADMIN_UMS", "MESA_ACCESOS", "AUDITOR_ACCESOS" }; + var mapping = new (byte Index, string RoleCode, string[] TemplateRoleCodes)[] + { + (1, "ADMINISTRADOR", admin), // admin.callao — PERF_ADMIN (admin por sucursal) + (2, "AGENTE_ADUANAS", opAduanas), // agente.aduanas.callao — PERF_OP_ADUANAS + (3, "DESPACHADOR", opAduanas), // despachador.callao — PERF_OP_ADUANAS + (4, "JEFE_ALMACEN", almacen), // jefe.almacen.callao — PERF_ALMACEN + (5, "COORD_TRANSPORTE", new[] { "COORD_TRANSPORTE" }), // coordinador.transporte.callao — PERF_TRANSPORTE + (6, "EJECUTIVO_CUENTA", new[] { "EJECUTIVO_CUENTA" }), // ejecutivo.cuenta.callao — PERF_COMERCIAL + (7, "ANALISTA_DOC", opAduanas), // analista.doc.callao — PERF_OP_ADUANAS + (8, "AUDITOR", admin), // auditor.callao — PERF_ADMIN + (CoreDevDataSeeder.BeyondNetJefeAlmacenPaitaUserIndex, "JEFE_ALMACEN", almacen), // jefe.almacen.paita — criterio 7 + (10, "OPERARIO_ALMACEN", almacen), // operario.almacen.paita — PERF_ALMACEN + (11, "AGENTE_ADUANAS", opAduanas), // agente.aduanas.paita — PERF_OP_ADUANAS + (12, "EJECUTIVO_CUENTA", new[] { "EJECUTIVO_CUENTA" }), // ejecutivo.cuenta.paita — PERF_COMERCIAL + (CoreDevDataSeeder.BeyondNetRootAdminUserIndex, "ADMINISTRADOR", admin), // admin@beyondnet.com.pe — root BEYONDNET transversal + + // Perfiles sobre el propio UMS. Van como entradas APARTE y no añadiendo el rol a las + // de arriba: administrar la suite logística y administrar la identidad de la suite son + // dos responsabilidades distintas, y separarlas en dos perfiles del mismo usuario es lo + // que permite comprobar el cambio de perfil —la interfaz debe cambiar al conmutar—. + (CoreDevDataSeeder.BeyondNetRootAdminUserIndex, "ADMIN_UMS", umsGobierno), // admin@beyondnet.com.pe + (1, "MESA_ACCESOS", umsMesa), // admin.callao + (8, "AUDITOR_ACCESOS", umsAuditoria), // auditor.callao + }; + + foreach (var entry in mapping) + { + if (!roleByCode.TryGetValue(entry.RoleCode, out var primaryRole)) + { + continue; + } + + var profileResult = ProfileAggregate.Create(tenantId, UserId.Load(UserGuid(entry.Index)), primaryRole.GetId(), null, actor); + if (profileResult.IsFailure) + { + continue; + } + + var profile = profileResult.Value; + foreach (var templateRoleCode in entry.TemplateRoleCodes) + { + if (roleByCode.TryGetValue(templateRoleCode, out var templateRole) + && templateByRoleId.TryGetValue(templateRole.GetId().GetValue(), out var template)) + { + profile.AssignTemplate(template, actor); + } + } + + profiles.Add(profile); + } + + return profiles; + } + + private static IReadOnlyList BuildClienteProfiles(TenantId tenantId, IReadOnlyList roles, IReadOnlyList templates, ActorId actor) + { + var profiles = new List(); + + var role = roles.FirstOrDefault(r => r.Code.GetValue() == "CLIENTE_EXTERNO"); + if (role is null) + { + return profiles; + } + + var template = templates.FirstOrDefault(t => t.RoleId.GetValue() == role.GetId().GetValue()); + + var baseBytes = tenantId.GetValue().ToByteArray(); + baseBytes[0] = 1; // single external user per client tenant + var userId = UserId.Load(new Guid(baseBytes)); + + var profileResult = ProfileAggregate.Create(tenantId, userId, role.GetId(), null, actor); + if (profileResult.IsSuccess) + { + if (template is not null) + { + profileResult.Value.AssignTemplate(template, actor); + } + + profiles.Add(profileResult.Value); + } + + return profiles; + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Security Hotspot", "S3011:Reflection should not be used to increase accessibility of classes, methods, or fields", + Justification = "ADR-UMS-099: reflexión sancionada en seeder de desarrollo/UAT. Fija el ID de rol semilla " + + "(RiskScoreCalculator lo resuelve por id) sobre la propiedad Id de setter privado; no se " + + "ejecuta en producción (SeedDevData && !IsProduction).")] + private static void SetRoleId(RoleAggregate role, Guid id) + { + // G-058: SetRoleId nunca fijaba el id — doble fallo enmascarado. (1) `_props` vive en la base + // AggregateRoot<> y `GetField` no busca en clases base → el campo devolvía null; (2) RoleProps.Id + // es una propiedad PÚBLICA (setter privado) y buscarla solo con NonPublic devolvía null. El + // SetValue nunca corría (no-op silencioso), así que los roles demo (DemoAdminRoleId/ + // DemoOperatorRoleId) conservaban su GUID aleatorio y el RiskScoreCalculator no los resolvía por + // id → `submit` IGA fallaba con 400. Fix: usar el getter PÚBLICO Props (RoleProps es clase, misma + // instancia) y buscar la propiedad Id incluyendo Public. + var idProperty = typeof(RoleProps).GetProperty( + "Id", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + idProperty?.SetValue(role.Props, RoleId.Load(id)); + } + + private static IReadOnlyList BuildGenericRoles(TenantId tenantId, IReadOnlyList suites, ActorId actor) { var roles = new List(); if (suites.Count == 0) return roles; // ── UMS Suite Roles ────────────────────────────────────────────────────── - var adminRoleResult = RoleAggregate.Create(tenantId, suites[0].GetId(), Code.Create("ADMIN"), Name.Create("System Administrator"), Description.Create("Full administrative access"), null, 0, 0, actor); + var adminRoleResult = RoleAggregate.Create(tenantId, suites[0].GetId(), Code.Create("ADMIN"), Name.Create("Administrador del Sistema"), Description.Create("Acceso administrativo completo"), null, 0, 0, actor); if (adminRoleResult.IsSuccess) { var role = adminRoleResult.Value; @@ -156,22 +918,22 @@ private static IReadOnlyList BuildSeedRoles(TenantId tenantId, IR roles.Add(role); } - var supervisorRoleResult = RoleAggregate.Create(tenantId, suites[0].GetId(), Code.Create("SUPERVISOR"), Name.Create("Core Supervisor"), Description.Create("Supervises core operations"), null, 0, 0, actor); + var supervisorRoleResult = RoleAggregate.Create(tenantId, suites[0].GetId(), Code.Create("SUPERVISOR"), Name.Create("Supervisor"), Description.Create("Supervisa las operaciones principales"), null, 0, 0, actor); if (supervisorRoleResult.IsSuccess) roles.Add(supervisorRoleResult.Value); - var auditorRoleResult = RoleAggregate.Create(tenantId, suites[0].GetId(), Code.Create("AUDITOR"), Name.Create("Compliance Auditor"), Description.Create("Read-only access for audits"), null, 0, 0, actor); + var auditorRoleResult = RoleAggregate.Create(tenantId, suites[0].GetId(), Code.Create("AUDITOR"), Name.Create("Auditor de Cumplimiento"), Description.Create("Acceso de solo lectura para auditorías"), null, 0, 0, actor); if (auditorRoleResult.IsSuccess) roles.Add(auditorRoleResult.Value); - var readonlyRoleResult = RoleAggregate.Create(tenantId, suites[0].GetId(), Code.Create("READONLY"), Name.Create("Read Only Viewer"), Description.Create("View-only access, no modifications"), null, 0, 0, actor); + var readonlyRoleResult = RoleAggregate.Create(tenantId, suites[0].GetId(), Code.Create("READONLY"), Name.Create("Solo Lectura"), Description.Create("Acceso de solo visualización, sin modificaciones"), null, 0, 0, actor); if (readonlyRoleResult.IsSuccess) roles.Add(readonlyRoleResult.Value); - var dataEntryRoleResult = RoleAggregate.Create(tenantId, suites[0].GetId(), Code.Create("DATA_ENTRY"), Name.Create("Data Entry Clerk"), Description.Create("Create and update records only"), null, 0, 0, actor); + var dataEntryRoleResult = RoleAggregate.Create(tenantId, suites[0].GetId(), Code.Create("DATA_ENTRY"), Name.Create("Digitador"), Description.Create("Solo crear y actualizar registros"), null, 0, 0, actor); if (dataEntryRoleResult.IsSuccess) roles.Add(dataEntryRoleResult.Value); // ── WMS Suite Roles ────────────────────────────────────────────── if (suites.Count > 1) { - var operatorRoleResult = RoleAggregate.Create(tenantId, suites[1].GetId(), Code.Create("OPERATOR"), Name.Create("Warehouse Operator"), Description.Create("Standard warehouse operations"), null, 0, 0, actor); + var operatorRoleResult = RoleAggregate.Create(tenantId, suites[1].GetId(), Code.Create("OPERATOR"), Name.Create("Operario de Almacén"), Description.Create("Operaciones estándar de almacén"), null, 0, 0, actor); if (operatorRoleResult.IsSuccess) { var role = operatorRoleResult.Value; @@ -180,31 +942,43 @@ private static IReadOnlyList BuildSeedRoles(TenantId tenantId, IR roles.Add(role); } - var inspectorRoleResult = RoleAggregate.Create(tenantId, suites[1].GetId(), Code.Create("INSPECTOR"), Name.Create("Quality Inspector"), Description.Create("Quality control and inspections"), null, 0, 0, actor); + var inspectorRoleResult = RoleAggregate.Create(tenantId, suites[1].GetId(), Code.Create("INSPECTOR"), Name.Create("Inspector de Calidad"), Description.Create("Control de calidad e inspecciones"), null, 0, 0, actor); if (inspectorRoleResult.IsSuccess) roles.Add(inspectorRoleResult.Value); - var managerRoleResult = RoleAggregate.Create(tenantId, suites[1].GetId(), Code.Create("WMS_MANAGER"), Name.Create("Warehouse Manager"), Description.Create("Manages all warehouse operations"), null, 0, 0, actor); + var managerRoleResult = RoleAggregate.Create(tenantId, suites[1].GetId(), Code.Create("WMS_MANAGER"), Name.Create("Jefe de Almacén"), Description.Create("Gestiona todas las operaciones de almacén"), null, 0, 0, actor); if (managerRoleResult.IsSuccess) roles.Add(managerRoleResult.Value); - var dispatcherRoleResult = RoleAggregate.Create(tenantId, suites[1].GetId(), Code.Create("DISPATCHER"), Name.Create("Dispatch Coordinator"), Description.Create("Manages stock transfers and dispatches"), null, 0, 0, actor); + var dispatcherRoleResult = RoleAggregate.Create(tenantId, suites[1].GetId(), Code.Create("DISPATCHER"), Name.Create("Coordinador de Despacho"), Description.Create("Gestiona transferencias y despachos de stock"), null, 0, 0, actor); if (dispatcherRoleResult.IsSuccess) roles.Add(dispatcherRoleResult.Value); - var reporterRoleResult = RoleAggregate.Create(tenantId, suites[1].GetId(), Code.Create("REPORTER"), Name.Create("Report Analyst"), Description.Create("Generates and exports warehouse reports"), null, 0, 0, actor); + var reporterRoleResult = RoleAggregate.Create(tenantId, suites[1].GetId(), Code.Create("REPORTER"), Name.Create("Analista de Reportes"), Description.Create("Genera y exporta reportes de almacén"), null, 0, 0, actor); if (reporterRoleResult.IsSuccess) roles.Add(reporterRoleResult.Value); } return roles; } - private static IReadOnlyList BuildSeedSystemSuites(TenantId tenantId, ActorId actor) + /// + /// La suite UMS, identica para TODOS los inquilinos. + /// + /// Estaba embebida en BuildGenericSystemSuites, asi que BEYONDNET —que va por su + /// catalogo propio— no podia recibirla sin duplicarla. Se intento con una spec declarativa y + /// salio peor: genero nodos con rutas inventadas (/idm/manage-branches) que el shell + /// tomo como navegacion real, porque el grafo CONSTRUYE el menu (G-181) y solo cae a la + /// configuracion estatica cuando no hay grafo. El resultado fue una barra de navegacion que no + /// llevaba a ninguna pantalla existente. + /// + /// De ahi que se extraiga en vez de reescribirse: un mismo sistema debe verse igual desde + /// cualquier inquilino, y la unica forma de garantizarlo es que salga del mismo sitio. + /// + private static SystemSuiteAggregate? BuildUmsCoreSuite(TenantId tenantId, ActorId actor) { var suites = new List(); - var coreResult = SystemSuiteAggregate.Create( tenantId, Code.Create("UMS"), - Name.Create("User Management System"), - Description.Create("Core UMS functionality"), + Name.Create("Sistema de Gestión de Usuarios"), + Description.Create("Funcionalidad principal del UMS"), actor); if (coreResult.IsSuccess) @@ -212,16 +986,16 @@ private static IReadOnlyList BuildSeedSystemSuites(TenantI var suite = coreResult.Value; // Register standard actions - suite.RegisterAction(ActionCode.Create("VIEW"), Name.Create("View Logistics Core"), actor); - suite.RegisterAction(ActionCode.Create("MANAGE"), Name.Create("Manage Logistics Core"), actor); - suite.RegisterAction(ActionCode.Create("APPROVE"), Name.Create("Approve Operations"), actor); + suite.RegisterAction(ActionCode.Create("VIEW"), Name.Create("Ver Núcleo Logístico"), actor); + suite.RegisterAction(ActionCode.Create("MANAGE"), Name.Create("Gestionar Núcleo Logístico"), actor); + suite.RegisterAction(ActionCode.Create("APPROVE"), Name.Create("Aprobar Operaciones"), actor); // Register standard domain actions - suite.RegisterAction(ActionCode.Create("CREATE"), Name.Create("Create Record"), actor); - suite.RegisterAction(ActionCode.Create("READ"), Name.Create("Read Record"), actor); - suite.RegisterAction(ActionCode.Create("UPDATE"), Name.Create("Update Record"), actor); - suite.RegisterAction(ActionCode.Create("DELETE"), Name.Create("Delete Record"), actor); - suite.RegisterAction(ActionCode.Create("SEARCH"), Name.Create("Search Records"), actor); + suite.RegisterAction(ActionCode.Create("CREATE"), Name.Create("Crear Registro"), actor); + suite.RegisterAction(ActionCode.Create("READ"), Name.Create("Leer Registro"), actor); + suite.RegisterAction(ActionCode.Create("UPDATE"), Name.Create("Actualizar Registro"), actor); + suite.RegisterAction(ActionCode.Create("DELETE"), Name.Create("Eliminar Registro"), actor); + suite.RegisterAction(ActionCode.Create("SEARCH"), Name.Create("Buscar Registros"), actor); // Add app settings suite.AddAppSetting( @@ -236,90 +1010,155 @@ private static IReadOnlyList BuildSeedSystemSuites(TenantI actor); // Add IDM module - var modIdm = suite.AddModule(Code.Create("IDM"), Name.Create("Identity & Access"), Description.Create("Tenants, users, and delegations management"), 1, actor); + var modIdm = suite.AddModule(Code.Create("IDM"), Name.Create("Identidad y Accesos"), Description.Create("Gestión de empresas, usuarios y delegaciones"), 1, actor, IconoDeModulo("IDM")); if (modIdm.IsSuccess) { var module = suite.Modules.First(m => m.Code.GetValue() == "IDM"); suite.ActivateModule(module.Props.Id, actor); - module.AddMenu(Code.Create("TENANTS"), Name.Create("Tenants"), Description.Create("Manage tenants"), 1, actor); - var menuTenants = module.Menus.First(m => m.Code.GetValue() == "TENANTS"); - menuTenants.AddSubMenu(Code.Create("TENANTS_LIST"), Name.Create("Tenants List"), Description.Create("Tenants List"), 1, actor); - var subMenuTenants = menuTenants.SubMenus.First(); - subMenuTenants.AddOption(Code.Create("VIEW_TENANTS"), Name.Create("View Tenants"), Description.Create("View Tenants"), ActionCode.Create("VIEW"), 1, actor); - subMenuTenants.AddOption(Code.Create("MANAGE_TENANTS"), Name.Create("Manage Tenants"), Description.Create("Manage Tenants"), ActionCode.Create("MANAGE"), 2, actor); - - module.AddMenu(Code.Create("USERS"), Name.Create("Users"), Description.Create("Manage users"), 2, actor); - var menuUsers = module.Menus.First(m => m.Code.GetValue() == "USERS"); - menuUsers.AddSubMenu(Code.Create("USERS_LIST"), Name.Create("Users List"), Description.Create("Users List"), 1, actor); - var subMenuUsers = menuUsers.SubMenus.First(); - subMenuUsers.AddOption(Code.Create("VIEW_USERS"), Name.Create("View Users"), Description.Create("View Users"), ActionCode.Create("VIEW"), 1, actor); - subMenuUsers.AddOption(Code.Create("MANAGE_USERS"), Name.Create("Manage Users"), Description.Create("Manage Users"), ActionCode.Create("MANAGE"), 2, actor); - - module.AddMenu(Code.Create("DELEGATIONS"), Name.Create("Delegations"), Description.Create("Manage delegations"), 3, actor); - var menuDelegations = module.Menus.First(m => m.Code.GetValue() == "DELEGATIONS"); - menuDelegations.AddSubMenu(Code.Create("DELEGATIONS_LIST"), Name.Create("Delegations List"), Description.Create("Delegations List"), 1, actor); - var subMenuDelegations = menuDelegations.SubMenus.First(); - subMenuDelegations.AddOption(Code.Create("VIEW_DELEGATIONS"), Name.Create("View Delegations"), Description.Create("View Delegations"), ActionCode.Create("VIEW"), 1, actor); - subMenuDelegations.AddOption(Code.Create("MANAGE_DELEGATIONS"), Name.Create("Manage Delegations"), Description.Create("Manage Delegations"), ActionCode.Create("MANAGE"), 2, actor); + SeedNavMenu(suite, module, 1, "TENANTS", "Empresas", "TENANTS_LIST", "Lista de Empresas", + new[] { ("VIEW_TENANTS", "Ver Empresas", "VIEW"), ("MANAGE_TENANTS", "Gestionar Empresas", "MANAGE") }, actor, "/tenants"); + SeedNavMenu(suite, module, 2, "USERS", "Usuarios", "USERS_LIST", "Lista de Usuarios", + new[] { ("VIEW_USERS", "Ver Usuarios", "VIEW"), ("MANAGE_USERS", "Gestionar Usuarios", "MANAGE") }, actor, "/users"); + SeedNavMenu(suite, module, 3, "DELEGATIONS", "Delegaciones", "DELEGATIONS_LIST", "Lista de Delegaciones", + new[] { ("VIEW_DELEGATIONS", "Ver Delegaciones", "VIEW"), ("MANAGE_DELEGATIONS", "Gestionar Delegaciones", "MANAGE") }, actor, "/delegations"); } // Add AUTH module - var modAuth = suite.AddModule(Code.Create("AUTH"), Name.Create("Authorization"), Description.Create("Profiles, templates and suites"), 2, actor); + var modAuth = suite.AddModule(Code.Create("AUTH"), Name.Create("Autorización"), Description.Create("Perfiles, plantillas y sistemas"), 2, actor, IconoDeModulo("AUTH")); if (modAuth.IsSuccess) { var module = suite.Modules.First(m => m.Code.GetValue() == "AUTH"); suite.ActivateModule(module.Props.Id, actor); - module.AddMenu(Code.Create("SYSTEM_SUITES"), Name.Create("System Suites"), Description.Create("Manage System Suites"), 1, actor); - var menuSuites = module.Menus.First(m => m.Code.GetValue() == "SYSTEM_SUITES"); - menuSuites.AddSubMenu(Code.Create("SUITES_LIST"), Name.Create("Suites List"), Description.Create("Suites List"), 1, actor); - var subMenuSuites = menuSuites.SubMenus.First(); - subMenuSuites.AddOption(Code.Create("VIEW_SUITES"), Name.Create("View Suites"), Description.Create("View Suites"), ActionCode.Create("VIEW"), 1, actor); - subMenuSuites.AddOption(Code.Create("MANAGE_SUITES"), Name.Create("Manage Suites"), Description.Create("Manage Suites"), ActionCode.Create("MANAGE"), 2, actor); - - module.AddMenu(Code.Create("PERMISSION_TEMPLATES"), Name.Create("Permission Templates"), Description.Create("Manage Templates"), 2, actor); - var menuTemplates = module.Menus.First(m => m.Code.GetValue() == "PERMISSION_TEMPLATES"); - menuTemplates.AddSubMenu(Code.Create("TEMPLATES_LIST"), Name.Create("Templates List"), Description.Create("Templates List"), 1, actor); - var subMenuTemplates = menuTemplates.SubMenus.First(); - subMenuTemplates.AddOption(Code.Create("VIEW_TEMPLATES"), Name.Create("View Templates"), Description.Create("View Templates"), ActionCode.Create("VIEW"), 1, actor); - subMenuTemplates.AddOption(Code.Create("MANAGE_TEMPLATES"), Name.Create("Manage Templates"), Description.Create("Manage Templates"), ActionCode.Create("MANAGE"), 2, actor); - - module.AddMenu(Code.Create("PROFILES"), Name.Create("Profiles"), Description.Create("Manage Profiles"), 3, actor); - var menuProfiles = module.Menus.First(m => m.Code.GetValue() == "PROFILES"); - menuProfiles.AddSubMenu(Code.Create("PROFILES_LIST"), Name.Create("Profiles List"), Description.Create("Profiles List"), 1, actor); - var subMenuProfiles = menuProfiles.SubMenus.First(); - subMenuProfiles.AddOption(Code.Create("VIEW_PROFILES"), Name.Create("View Profiles"), Description.Create("View Profiles"), ActionCode.Create("VIEW"), 1, actor); - subMenuProfiles.AddOption(Code.Create("MANAGE_PROFILES"), Name.Create("Manage Profiles"), Description.Create("Manage Profiles"), ActionCode.Create("MANAGE"), 2, actor); + SeedNavMenu(suite, module, 1, "SYSTEM_SUITES", "Sistemas", "SUITES_LIST", "Lista de Sistemas", + new[] { ("VIEW_SUITES", "Ver Sistemas", "VIEW"), ("MANAGE_SUITES", "Gestionar Sistemas", "MANAGE") }, actor, "/system-suites"); + SeedNavMenu(suite, module, 2, "PERMISSION_TEMPLATES", "Plantillas de Permisos", "TEMPLATES_LIST", "Lista de Plantillas", + new[] { ("VIEW_TEMPLATES", "Ver Plantillas", "VIEW"), ("MANAGE_TEMPLATES", "Gestionar Plantillas", "MANAGE") }, actor, "/permission-templates"); + SeedNavMenu(suite, module, 3, "PROFILES", "Perfiles", "PROFILES_LIST", "Lista de Perfiles", + new[] { ("VIEW_PROFILES", "Ver Perfiles", "VIEW"), ("MANAGE_PROFILES", "Gestionar Perfiles", "MANAGE") }, actor, "/profiles"); } // Add SYS module - var modSys = suite.AddModule(Code.Create("SYS"), Name.Create("System Configuration"), Description.Create("Global properties, settings and flags"), 3, actor); + var modSys = suite.AddModule(Code.Create("SYS"), Name.Create("Configuración del Sistema"), Description.Create("Propiedades globales, ajustes y banderas"), 3, actor, IconoDeModulo("SYS")); if (modSys.IsSuccess) { var module = suite.Modules.First(m => m.Code.GetValue() == "SYS"); suite.ActivateModule(module.Props.Id, actor); - module.AddMenu(Code.Create("FEATURE_FLAGS"), Name.Create("Feature Flags"), Description.Create("Manage Flags"), 1, actor); - var menuFlags = module.Menus.First(m => m.Code.GetValue() == "FEATURE_FLAGS"); - menuFlags.AddSubMenu(Code.Create("FLAGS_LIST"), Name.Create("Flags List"), Description.Create("Flags List"), 1, actor); - var subMenuFlags = menuFlags.SubMenus.First(); - subMenuFlags.AddOption(Code.Create("VIEW_FLAGS"), Name.Create("View Flags"), Description.Create("View Flags"), ActionCode.Create("VIEW"), 1, actor); - subMenuFlags.AddOption(Code.Create("MANAGE_FLAGS"), Name.Create("Manage Flags"), Description.Create("Manage Flags"), ActionCode.Create("MANAGE"), 2, actor); + SeedNavMenu(suite, module, 1, "FEATURE_FLAGS", "Banderas de Función", "FLAGS_LIST", "Lista de Banderas", + new[] { ("VIEW_FLAGS", "Ver Banderas", "VIEW"), ("MANAGE_FLAGS", "Gestionar Banderas", "MANAGE") }, actor, "/feature-flags"); + SeedNavMenu(suite, module, 2, "APP_CONFIG", "Configuraciones", "CONFIG_LIST", "Lista de Configuraciones", + new[] { ("VIEW_CONFIG", "Ver Configuraciones", "VIEW"), ("MANAGE_CONFIG", "Gestionar Configuraciones", "MANAGE") }, actor, "/app-configurations"); + SeedNavMenu(suite, module, 3, "PARAM_CATALOG", "Catálogo de Parámetros", "PARAM_LIST", "Lista de Parámetros", + new[] { ("VIEW_PARAMS", "Ver Parámetros", "VIEW"), ("MANAGE_PARAMS", "Gestionar Parámetros", "MANAGE") }, actor, "/parameter-catalog"); + } + + // Add domain resources linked to modules + var idmMod = suite.Modules.First(m => m.Code.GetValue() == "IDM"); + var authMod = suite.Modules.First(m => m.Code.GetValue() == "AUTH"); + var sysMod = suite.Modules.First(m => m.Code.GetValue() == "SYS"); + + suite.AddDomainResource(idmMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("TENANT"), Name.Create("Agregado de Empresa"), Description.Create("Raíz del agregado de empresa"), actor); + suite.AddDomainResource(idmMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("USER"), Name.Create("Agregado de Usuario"), Description.Create("Raíz del agregado de usuario"), actor); + suite.AddDomainResource(idmMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("DELEGATION"), Name.Create("Agregado de Delegación"), Description.Create("Raíz del agregado de delegación"), actor); + + suite.AddDomainResource(authMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("SYSTEM_SUITE"), Name.Create("Agregado de Sistema"), Description.Create("Raíz del agregado de sistema"), actor); + suite.AddDomainResource(authMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("PERMISSION_TEMPLATE"), Name.Create("Agregado de Plantilla"), Description.Create("Raíz del agregado de plantilla"), actor); + suite.AddDomainResource(authMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("PROFILE"), Name.Create("Agregado de Perfil"), Description.Create("Raíz del agregado de perfil"), actor); + + suite.AddDomainResource(sysMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("FEATURE_FLAG"), Name.Create("Agregado de Bandera de Función"), Description.Create("Raíz del agregado de bandera de función"), actor); + suite.AddDomainResource(sysMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("APP_CONFIG"), Name.Create("Agregado de Configuración"), Description.Create("Raíz del agregado de configuración"), actor); + suite.AddDomainResource(sysMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("PARAMETER"), Name.Create("Agregado de Parámetro"), Description.Create("Raíz del agregado de parámetro"), actor); + + suites.Add(suite); + } + return suites.Count > 0 ? suites[0] : null; + } + + + private static IReadOnlyList BuildGenericSystemSuites(TenantId tenantId, ActorId actor) + { + var suites = new List(); + + var coreResult = SystemSuiteAggregate.Create( + tenantId, + Code.Create("UMS"), + Name.Create("Sistema de Gestión de Usuarios"), + Description.Create("Funcionalidad principal del UMS"), + actor); + + if (coreResult.IsSuccess) + { + var suite = coreResult.Value; + + // Register standard actions + suite.RegisterAction(ActionCode.Create("VIEW"), Name.Create("Ver Núcleo Logístico"), actor); + suite.RegisterAction(ActionCode.Create("MANAGE"), Name.Create("Gestionar Núcleo Logístico"), actor); + suite.RegisterAction(ActionCode.Create("APPROVE"), Name.Create("Aprobar Operaciones"), actor); + + // Register standard domain actions + suite.RegisterAction(ActionCode.Create("CREATE"), Name.Create("Crear Registro"), actor); + suite.RegisterAction(ActionCode.Create("READ"), Name.Create("Leer Registro"), actor); + suite.RegisterAction(ActionCode.Create("UPDATE"), Name.Create("Actualizar Registro"), actor); + suite.RegisterAction(ActionCode.Create("DELETE"), Name.Create("Eliminar Registro"), actor); + suite.RegisterAction(ActionCode.Create("SEARCH"), Name.Create("Buscar Registros"), actor); + + // Add app settings + suite.AddAppSetting( + ConfigurationKey.Create("SessionTimeout"), + ConfigurationValue.Create("30"), + ConfigurationScope.Global, + actor); + suite.AddAppSetting( + ConfigurationKey.Create("MaxRetries"), + ConfigurationValue.Create("5"), + ConfigurationScope.Global, + actor); + + // Add IDM module + var modIdm = suite.AddModule(Code.Create("IDM"), Name.Create("Identidad y Accesos"), Description.Create("Gestión de empresas, usuarios y delegaciones"), 1, actor, IconoDeModulo("IDM")); + if (modIdm.IsSuccess) + { + var module = suite.Modules.First(m => m.Code.GetValue() == "IDM"); + suite.ActivateModule(module.Props.Id, actor); + + SeedNavMenu(suite, module, 1, "TENANTS", "Empresas", "TENANTS_LIST", "Lista de Empresas", + new[] { ("VIEW_TENANTS", "Ver Empresas", "VIEW"), ("MANAGE_TENANTS", "Gestionar Empresas", "MANAGE") }, actor, "/tenants"); + SeedNavMenu(suite, module, 2, "USERS", "Usuarios", "USERS_LIST", "Lista de Usuarios", + new[] { ("VIEW_USERS", "Ver Usuarios", "VIEW"), ("MANAGE_USERS", "Gestionar Usuarios", "MANAGE") }, actor, "/users"); + SeedNavMenu(suite, module, 3, "DELEGATIONS", "Delegaciones", "DELEGATIONS_LIST", "Lista de Delegaciones", + new[] { ("VIEW_DELEGATIONS", "Ver Delegaciones", "VIEW"), ("MANAGE_DELEGATIONS", "Gestionar Delegaciones", "MANAGE") }, actor, "/delegations"); + } - module.AddMenu(Code.Create("APP_CONFIG"), Name.Create("App Configurations"), Description.Create("Manage App Configs"), 2, actor); - var menuConfig = module.Menus.First(m => m.Code.GetValue() == "APP_CONFIG"); - menuConfig.AddSubMenu(Code.Create("CONFIG_LIST"), Name.Create("Config List"), Description.Create("Config List"), 1, actor); - var subMenuConfig = menuConfig.SubMenus.First(); - subMenuConfig.AddOption(Code.Create("VIEW_CONFIG"), Name.Create("View Configs"), Description.Create("View Configs"), ActionCode.Create("VIEW"), 1, actor); - subMenuConfig.AddOption(Code.Create("MANAGE_CONFIG"), Name.Create("Manage Configs"), Description.Create("Manage Configs"), ActionCode.Create("MANAGE"), 2, actor); + // Add AUTH module + var modAuth = suite.AddModule(Code.Create("AUTH"), Name.Create("Autorización"), Description.Create("Perfiles, plantillas y sistemas"), 2, actor, IconoDeModulo("AUTH")); + if (modAuth.IsSuccess) + { + var module = suite.Modules.First(m => m.Code.GetValue() == "AUTH"); + suite.ActivateModule(module.Props.Id, actor); - module.AddMenu(Code.Create("PARAM_CATALOG"), Name.Create("Parameter Catalog"), Description.Create("Manage Parameters"), 3, actor); - var menuParam = module.Menus.First(m => m.Code.GetValue() == "PARAM_CATALOG"); - menuParam.AddSubMenu(Code.Create("PARAM_LIST"), Name.Create("Param List"), Description.Create("Param List"), 1, actor); - var subMenuParam = menuParam.SubMenus.First(); - subMenuParam.AddOption(Code.Create("VIEW_PARAMS"), Name.Create("View Params"), Description.Create("View Params"), ActionCode.Create("VIEW"), 1, actor); - subMenuParam.AddOption(Code.Create("MANAGE_PARAMS"), Name.Create("Manage Params"), Description.Create("Manage Params"), ActionCode.Create("MANAGE"), 2, actor); + SeedNavMenu(suite, module, 1, "SYSTEM_SUITES", "Sistemas", "SUITES_LIST", "Lista de Sistemas", + new[] { ("VIEW_SUITES", "Ver Sistemas", "VIEW"), ("MANAGE_SUITES", "Gestionar Sistemas", "MANAGE") }, actor, "/system-suites"); + SeedNavMenu(suite, module, 2, "PERMISSION_TEMPLATES", "Plantillas de Permisos", "TEMPLATES_LIST", "Lista de Plantillas", + new[] { ("VIEW_TEMPLATES", "Ver Plantillas", "VIEW"), ("MANAGE_TEMPLATES", "Gestionar Plantillas", "MANAGE") }, actor, "/permission-templates"); + SeedNavMenu(suite, module, 3, "PROFILES", "Perfiles", "PROFILES_LIST", "Lista de Perfiles", + new[] { ("VIEW_PROFILES", "Ver Perfiles", "VIEW"), ("MANAGE_PROFILES", "Gestionar Perfiles", "MANAGE") }, actor, "/profiles"); + } + + // Add SYS module + var modSys = suite.AddModule(Code.Create("SYS"), Name.Create("Configuración del Sistema"), Description.Create("Propiedades globales, ajustes y banderas"), 3, actor, IconoDeModulo("SYS")); + if (modSys.IsSuccess) + { + var module = suite.Modules.First(m => m.Code.GetValue() == "SYS"); + suite.ActivateModule(module.Props.Id, actor); + + SeedNavMenu(suite, module, 1, "FEATURE_FLAGS", "Banderas de Función", "FLAGS_LIST", "Lista de Banderas", + new[] { ("VIEW_FLAGS", "Ver Banderas", "VIEW"), ("MANAGE_FLAGS", "Gestionar Banderas", "MANAGE") }, actor, "/feature-flags"); + SeedNavMenu(suite, module, 2, "APP_CONFIG", "Configuraciones", "CONFIG_LIST", "Lista de Configuraciones", + new[] { ("VIEW_CONFIG", "Ver Configuraciones", "VIEW"), ("MANAGE_CONFIG", "Gestionar Configuraciones", "MANAGE") }, actor, "/app-configurations"); + SeedNavMenu(suite, module, 3, "PARAM_CATALOG", "Catálogo de Parámetros", "PARAM_LIST", "Lista de Parámetros", + new[] { ("VIEW_PARAMS", "Ver Parámetros", "VIEW"), ("MANAGE_PARAMS", "Gestionar Parámetros", "MANAGE") }, actor, "/parameter-catalog"); } // Add domain resources linked to modules @@ -327,17 +1166,17 @@ private static IReadOnlyList BuildSeedSystemSuites(TenantI var authMod = suite.Modules.First(m => m.Code.GetValue() == "AUTH"); var sysMod = suite.Modules.First(m => m.Code.GetValue() == "SYS"); - suite.AddDomainResource(idmMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("TENANT"), Name.Create("Tenant Aggregate"), Description.Create("Tenant aggregate root"), actor); - suite.AddDomainResource(idmMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("USER"), Name.Create("User Aggregate"), Description.Create("User aggregate root"), actor); - suite.AddDomainResource(idmMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("DELEGATION"), Name.Create("Delegation Aggregate"), Description.Create("Delegation aggregate root"), actor); + suite.AddDomainResource(idmMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("TENANT"), Name.Create("Agregado de Empresa"), Description.Create("Raíz del agregado de empresa"), actor); + suite.AddDomainResource(idmMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("USER"), Name.Create("Agregado de Usuario"), Description.Create("Raíz del agregado de usuario"), actor); + suite.AddDomainResource(idmMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("DELEGATION"), Name.Create("Agregado de Delegación"), Description.Create("Raíz del agregado de delegación"), actor); - suite.AddDomainResource(authMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("SYSTEM_SUITE"), Name.Create("System Suite Aggregate"), Description.Create("System Suite aggregate root"), actor); - suite.AddDomainResource(authMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("PERMISSION_TEMPLATE"), Name.Create("Template Aggregate"), Description.Create("Template aggregate root"), actor); - suite.AddDomainResource(authMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("PROFILE"), Name.Create("Profile Aggregate"), Description.Create("Profile aggregate root"), actor); + suite.AddDomainResource(authMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("SYSTEM_SUITE"), Name.Create("Agregado de Sistema"), Description.Create("Raíz del agregado de sistema"), actor); + suite.AddDomainResource(authMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("PERMISSION_TEMPLATE"), Name.Create("Agregado de Plantilla"), Description.Create("Raíz del agregado de plantilla"), actor); + suite.AddDomainResource(authMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("PROFILE"), Name.Create("Agregado de Perfil"), Description.Create("Raíz del agregado de perfil"), actor); - suite.AddDomainResource(sysMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("FEATURE_FLAG"), Name.Create("Feature Flag Aggregate"), Description.Create("Feature Flag aggregate root"), actor); - suite.AddDomainResource(sysMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("APP_CONFIG"), Name.Create("App Config Aggregate"), Description.Create("App Config aggregate root"), actor); - suite.AddDomainResource(sysMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("PARAMETER"), Name.Create("Parameter Aggregate"), Description.Create("Parameter aggregate root"), actor); + suite.AddDomainResource(sysMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("FEATURE_FLAG"), Name.Create("Agregado de Bandera de Función"), Description.Create("Raíz del agregado de bandera de función"), actor); + suite.AddDomainResource(sysMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("APP_CONFIG"), Name.Create("Agregado de Configuración"), Description.Create("Raíz del agregado de configuración"), actor); + suite.AddDomainResource(sysMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("PARAMETER"), Name.Create("Agregado de Parámetro"), Description.Create("Raíz del agregado de parámetro"), actor); suites.Add(suite); } @@ -345,19 +1184,19 @@ private static IReadOnlyList BuildSeedSystemSuites(TenantI var wmsResult = SystemSuiteAggregate.Create( tenantId, Code.Create("WMS"), - Name.Create("Warehouse Management"), - Description.Create("Warehouse inventory management"), + Name.Create("Almacén"), + Description.Create("Gestión de inventario de almacén"), actor); if (wmsResult.IsSuccess) { var suite = wmsResult.Value; - suite.RegisterAction(ActionCode.Create("INVENTORY_VIEW"), Name.Create("View Inventory"), actor); - suite.RegisterAction(ActionCode.Create("INVENTORY_EDIT"), Name.Create("Edit Inventory"), actor); - suite.RegisterAction(ActionCode.Create("GENERATE_REPORT"), Name.Create("Generate Report"), actor); - suite.RegisterAction(ActionCode.Create("EXPORT_DATA"), Name.Create("Export Data"), actor); - suite.RegisterAction(ActionCode.Create("IMPORT_DATA"), Name.Create("Import Data"), actor); + suite.RegisterAction(ActionCode.Create("INVENTORY_VIEW"), Name.Create("Ver Inventario"), actor); + suite.RegisterAction(ActionCode.Create("INVENTORY_EDIT"), Name.Create("Editar Inventario"), actor); + suite.RegisterAction(ActionCode.Create("GENERATE_REPORT"), Name.Create("Generar Reporte"), actor); + suite.RegisterAction(ActionCode.Create("EXPORT_DATA"), Name.Create("Exportar Datos"), actor); + suite.RegisterAction(ActionCode.Create("IMPORT_DATA"), Name.Create("Importar Datos"), actor); suite.AddAppSetting( ConfigurationKey.Create("AllowNegativeStock"), @@ -365,65 +1204,53 @@ private static IReadOnlyList BuildSeedSystemSuites(TenantI ConfigurationScope.Global, actor); - var modInv = suite.AddModule(Code.Create("INV"), Name.Create("Inventory Control"), Description.Create("Inventory management and levels"), 1, actor); + var modInv = suite.AddModule(Code.Create("INV"), Name.Create("Inventario"), Description.Create("Gestión y niveles de inventario"), 1, actor, IconoDeModulo("INV")); if (modInv.IsSuccess) { var module = suite.Modules.First(m => m.Code.GetValue() == "INV"); suite.ActivateModule(module.Props.Id, actor); - // Menu 1: Stock levels - module.AddMenu(Code.Create("STOCK"), Name.Create("Stock Administration"), Description.Create("Stock levels and status"), 1, actor); - var menuStock = module.Menus.First(m => m.Code.GetValue() == "STOCK"); - - menuStock.AddSubMenu(Code.Create("LEVELS"), Name.Create("Real-time Levels"), Description.Create("Current physical stock status"), 1, actor); - var subMenuLevels = menuStock.SubMenus.First(sm => sm.Code.GetValue() == "LEVELS"); - subMenuLevels.AddOption(Code.Create("VIEW_STOCK"), Name.Create("View Stock Levels"), Description.Create("Permission to view real-time inventory counts"), ActionCode.Create("INVENTORY_VIEW"), 1, actor); - subMenuLevels.AddOption(Code.Create("ADJUST_STOCK"), Name.Create("Adjust Inventory Counts"), Description.Create("Permission to perform physical inventory adjustments"), ActionCode.Create("INVENTORY_EDIT"), 2, actor); - - // Menu 2: Operations - module.AddMenu(Code.Create("OPS"), Name.Create("Warehouse Operations"), Description.Create("Stock movements and transfers"), 2, actor); - var menuOps = module.Menus.First(m => m.Code.GetValue() == "OPS"); + // Menú 1: niveles de stock + SeedNavMenu(suite, module, 1, "STOCK", "Administración de Stock", "LEVELS", "Niveles en Tiempo Real", + new[] { ("VIEW_STOCK", "Ver Niveles de Stock", "INVENTORY_VIEW"), ("ADJUST_STOCK", "Ajustar Conteos de Inventario", "INVENTORY_EDIT") }, actor); - menuOps.AddSubMenu(Code.Create("TRANSFERS"), Name.Create("Warehouse Transfers"), Description.Create("Move stock between physical locations"), 1, actor); - var subMenuTransfers = menuOps.SubMenus.First(sm => sm.Code.GetValue() == "TRANSFERS"); - subMenuTransfers.AddOption(Code.Create("INITIATE_TRANSFER"), Name.Create("Initiate Stock Transfer"), Description.Create("Permission to draft and start a transfer request"), ActionCode.Create("INVENTORY_EDIT"), 1, actor); - subMenuTransfers.AddOption(Code.Create("APPROVE_TRANSFER"), Name.Create("Approve Location Transfer"), Description.Create("Permission to authorize inventory relocation"), ActionCode.Create("INVENTORY_EDIT"), 2, actor); + // Menú 2: operaciones + SeedNavMenu(suite, module, 2, "OPS", "Operaciones de Almacén", "TRANSFERS", "Transferencias de Almacén", + new[] { ("INITIATE_TRANSFER", "Iniciar Transferencia de Stock", "INVENTORY_EDIT"), ("APPROVE_TRANSFER", "Aprobar Transferencia de Ubicación", "INVENTORY_EDIT") }, actor); } // GAP-7: Add Reports module to WMS - var modReports = suite.AddModule(Code.Create("REPORTS"), Name.Create("Reports & Analytics"), Description.Create("Warehouse reporting and analytics"), 2, actor); + var modReports = suite.AddModule(Code.Create("REPORTS"), Name.Create("Reportes"), Description.Create("Reportes y analítica de almacén"), 2, actor, IconoDeModulo("REPORTS")); if (modReports.IsSuccess) { var module = suite.Modules.First(m => m.Code.GetValue() == "REPORTS"); suite.ActivateModule(module.Props.Id, actor); - // Menu 1: Inventory Reports - module.AddMenu(Code.Create("INV_REPORTS"), Name.Create("Inventory Reports"), Description.Create("Stock and inventory reports"), 1, actor); - var menuInvReports = module.Menus.First(m => m.Code.GetValue() == "INV_REPORTS"); + // Menú 1: reportes de inventario (dos submenús — se usa el helper de nodos) + var invReportsMenu = AddNode(suite, module, (MenuNodeEntity?)null, NodeKind.Menu, "INV_REPORTS", "Reportes de Inventario", 1, actor); - menuInvReports.AddSubMenu(Code.Create("STOCK_SUMMARY"), Name.Create("Stock Summary"), Description.Create("Overall stock summary report"), 1, actor); - var subMenuStockSummary = menuInvReports.SubMenus.First(sm => sm.Code.GetValue() == "STOCK_SUMMARY"); - subMenuStockSummary.AddOption(Code.Create("VIEW_STOCK_REPORT"), Name.Create("View Stock Report"), Description.Create("Permission to view stock summary"), ActionCode.Create("GENERATE_REPORT"), 1, actor); - subMenuStockSummary.AddOption(Code.Create("EXPORT_STOCK"), Name.Create("Export Stock Data"), Description.Create("Permission to export stock data"), ActionCode.Create("EXPORT_DATA"), 2, actor); + var stockSummary = AddNode(suite, module, invReportsMenu, NodeKind.SubMenu, "STOCK_SUMMARY", "Resumen de Stock", 1, actor); + var viewStockReport = AddNode(suite, module, stockSummary, NodeKind.Option, "VIEW_STOCK_REPORT", "Ver Reporte de Stock", 1, actor); + suite.LinkModuleNodeAction(module.Props.Id, viewStockReport.GetId(), ActionCode.Create("GENERATE_REPORT"), actor); + var exportStock = AddNode(suite, module, stockSummary, NodeKind.Option, "EXPORT_STOCK", "Exportar Datos de Stock", 2, actor); + suite.LinkModuleNodeAction(module.Props.Id, exportStock.GetId(), ActionCode.Create("EXPORT_DATA"), actor); - menuInvReports.AddSubMenu(Code.Create("MOVEMENT_REPORTS"), Name.Create("Movement Reports"), Description.Create("Stock movement history"), 2, actor); - var subMenuMovement = menuInvReports.SubMenus.First(sm => sm.Code.GetValue() == "MOVEMENT_REPORTS"); - subMenuMovement.AddOption(Code.Create("VIEW_MOVEMENT"), Name.Create("View Movement Report"), Description.Create("Permission to view movement history"), ActionCode.Create("GENERATE_REPORT"), 1, actor); + var movement = AddNode(suite, module, invReportsMenu, NodeKind.SubMenu, "MOVEMENT_REPORTS", "Reportes de Movimiento", 2, actor); + var viewMovement = AddNode(suite, module, movement, NodeKind.Option, "VIEW_MOVEMENT", "Ver Reporte de Movimiento", 1, actor); + suite.LinkModuleNodeAction(module.Props.Id, viewMovement.GetId(), ActionCode.Create("GENERATE_REPORT"), actor); - // Menu 2: Import/Export - module.AddMenu(Code.Create("IO"), Name.Create("Import / Export"), Description.Create("Data import and export operations"), 2, actor); - var menuIO = module.Menus.First(m => m.Code.GetValue() == "IO"); - - menuIO.AddSubMenu(Code.Create("IMPORT"), Name.Create("Data Import"), Description.Create("Import inventory data from external sources"), 1, actor); - var subMenuImport = menuIO.SubMenus.First(sm => sm.Code.GetValue() == "IMPORT"); - subMenuImport.AddOption(Code.Create("RUN_IMPORT"), Name.Create("Run Data Import"), Description.Create("Permission to execute data import"), ActionCode.Create("IMPORT_DATA"), 1, actor); + // Menú 2: import/export + var ioMenu = AddNode(suite, module, (MenuNodeEntity?)null, NodeKind.Menu, "IO", "Importar / Exportar", 2, actor); + var import = AddNode(suite, module, ioMenu, NodeKind.SubMenu, "IMPORT", "Importación de Datos", 1, actor); + var runImport = AddNode(suite, module, import, NodeKind.Option, "RUN_IMPORT", "Ejecutar Importación de Datos", 1, actor); + suite.LinkModuleNodeAction(module.Props.Id, runImport.GetId(), ActionCode.Create("IMPORT_DATA"), actor); } // Add domain resources for WMS var invMod = suite.Modules.First(m => m.Code.GetValue() == "INV"); - suite.AddDomainResource(invMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("INVENTORY_WMS"), Name.Create("WMS Inventory Aggregate"), Description.Create("Warehouse Inventory Management"), actor); - suite.AddDomainResource(invMod.GetId(), null, DomainResourceType.Entity, Code.Create("STOCK_MOVEMENT"), Name.Create("Stock Movement Entity"), Description.Create("Stock Movement Tracking"), actor); - suite.AddDomainResource(invMod.GetId(), null, DomainResourceType.Entity, Code.Create("TRANSFER_ORDER"), Name.Create("Transfer Order Entity"), Description.Create("Warehouse Transfer Orders"), actor); + suite.AddDomainResource(invMod.GetId(), null, DomainResourceType.Aggregate, Code.Create("INVENTORY_WMS"), Name.Create("Agregado de Inventario de Almacén"), Description.Create("Gestión de inventario de almacén"), actor); + suite.AddDomainResource(invMod.GetId(), null, DomainResourceType.Entity, Code.Create("STOCK_MOVEMENT"), Name.Create("Entidad de Movimiento de Stock"), Description.Create("Seguimiento de movimientos de stock"), actor); + suite.AddDomainResource(invMod.GetId(), null, DomainResourceType.Entity, Code.Create("TRANSFER_ORDER"), Name.Create("Entidad de Orden de Transferencia"), Description.Create("Órdenes de transferencia de almacén"), actor); suites.Add(suite); } @@ -431,7 +1258,7 @@ private static IReadOnlyList BuildSeedSystemSuites(TenantI return suites; } - private static IReadOnlyList BuildSeedPermissionTemplates(TenantId tenantId, IReadOnlyList suites, IReadOnlyList roles, ActorId actor) + private static IReadOnlyList BuildGenericPermissionTemplates(TenantId tenantId, IReadOnlyList suites, IReadOnlyList roles, ActorId actor) { var templates = new List(); if (suites.Count == 0 || roles.Count == 0) return templates; @@ -454,7 +1281,6 @@ private static IReadOnlyList BuildSeedPermissionTem // Helper to find a domain resource by code from the core suite var tenantResource = coreSuite.DomainResources.FirstOrDefault(r => r.Code.GetValue() == "TENANT"); var userResource = coreSuite.DomainResources.FirstOrDefault(r => r.Code.GetValue() == "USER"); - var auditLogResource = coreSuite.DomainResources.FirstOrDefault(r => r.Code.GetValue() == "AUDIT_LOG"); // if still relevant, though we removed it, so we'll ignore it // ── 1. ADMIN V2 — Published, full suite access ──────────────────── if (adminRole != null) @@ -466,24 +1292,21 @@ private static IReadOnlyList BuildSeedPermissionTem foreach (var mod in coreSuite.Modules) { adminV2.AddItem(ExclusiveArcTarget.Module, mod.Props.Id, ActionId.Create(), true, false, actor); - foreach (var menu in mod.Menus) + foreach (var root in mod.Nodes) { - adminV2.AddItem(ExclusiveArcTarget.Submodule, menu.Props.Id, ActionId.Create(), true, false, actor); - foreach (var subMenu in menu.SubMenus) - { - adminV2.AddItem(ExclusiveArcTarget.Option, subMenu.Props.Id, ActionId.Create(), true, false, actor); - foreach (var opt in subMenu.Options) - { - adminV2.AddItem(ExclusiveArcTarget.Option, opt.Props.Id, ActionId.Create(), true, false, actor); - } - } + AddNodeNavigation(adminV2, root, coreSuite, actor); } } - // Domain resources: full access + // Domain resources: full access. El grafo (fail-closed, G-039) evalúa el + // efecto por (resourceId, actionId) sobre TODA acción de la suite, así que + // se concede cada acción real; un único ActionId aleatorio no concedería nada. foreach (var res in coreSuite.DomainResources) { var targetType = res.Type == DomainResourceType.Aggregate ? ExclusiveArcTarget.Aggregate : ExclusiveArcTarget.Entity; - adminV2.AddItem(targetType, res.Id, ActionId.Create(), true, false, actor); + foreach (var action in coreSuite.Actions) + { + adminV2.AddItem(targetType, res.Id, action.GetId(), true, false, actor); + } } adminV2.Publish(actor); templates.Add(adminV2); @@ -516,17 +1339,17 @@ private static IReadOnlyList BuildSeedPermissionTem var idmMod = coreSuite.Modules.FirstOrDefault(m => m.Code.GetValue() == "IDM"); if (idmMod != null) { - var usersMenu = idmMod.Menus.FirstOrDefault(m => m.Code.GetValue() == "USERS"); + var usersMenu = FindNodeByCode(idmMod, "USERS"); if (usersMenu != null) { - var listSubMenu = usersMenu.SubMenus.FirstOrDefault(sm => sm.Code.GetValue() == "USERS_LIST"); + var listSubMenu = FindNodeByCode(idmMod, "USERS_LIST"); if (listSubMenu != null) { - var viewUsersOpt = listSubMenu.Options.FirstOrDefault(o => o.Code.GetValue() == "VIEW_USERS"); + var viewUsersOpt = FindNodeByCode(idmMod, "VIEW_USERS"); // Navigation: view users options only - auditorTpl.AddItem(ExclusiveArcTarget.Submodule, usersMenu.Props.Id, ActionId.Create(), true, false, actor); - auditorTpl.AddItem(ExclusiveArcTarget.Option, listSubMenu.Props.Id, ActionId.Create(), true, false, actor); - if (viewUsersOpt != null) auditorTpl.AddItem(ExclusiveArcTarget.Option, viewUsersOpt.Props.Id, ActionId.Create(), true, false, actor); + auditorTpl.AddItem(ExclusiveArcTarget.Submodule, usersMenu.GetId(), ActionId.Create(), true, false, actor); + auditorTpl.AddItem(ExclusiveArcTarget.Option, listSubMenu.GetId(), ActionId.Create(), true, false, actor); + if (viewUsersOpt != null) AddOptionActionItems(auditorTpl, viewUsersOpt, coreSuite, actor); } } } @@ -544,14 +1367,14 @@ private static IReadOnlyList BuildSeedPermissionTem var idmMod = coreSuite.Modules.FirstOrDefault(m => m.Code.GetValue() == "IDM"); if (idmMod != null) { - var usersMenu = idmMod.Menus.FirstOrDefault(m => m.Code.GetValue() == "USERS"); + var usersMenu = FindNodeByCode(idmMod, "USERS"); if (usersMenu != null) { - var listSubMenu = usersMenu.SubMenus.FirstOrDefault(sm => sm.Code.GetValue() == "USERS_LIST"); + var listSubMenu = FindNodeByCode(idmMod, "USERS_LIST"); if (listSubMenu != null) { - var viewUsersOpt = listSubMenu.Options.FirstOrDefault(o => o.Code.GetValue() == "VIEW_USERS"); - if (viewUsersOpt != null) readonlyTpl.AddItem(ExclusiveArcTarget.Option, viewUsersOpt.Props.Id, ActionId.Create(), true, false, actor); + var viewUsersOpt = FindNodeByCode(idmMod, "VIEW_USERS"); + if (viewUsersOpt != null) AddOptionActionItems(readonlyTpl, viewUsersOpt, coreSuite, actor); } } } @@ -568,15 +1391,15 @@ private static IReadOnlyList BuildSeedPermissionTem var idmMod = coreSuite.Modules.FirstOrDefault(m => m.Code.GetValue() == "IDM"); if (idmMod != null) { - var usersMenu = idmMod.Menus.FirstOrDefault(m => m.Code.GetValue() == "USERS"); + var usersMenu = FindNodeByCode(idmMod, "USERS"); if (usersMenu != null) { - var listSubMenu = usersMenu.SubMenus.FirstOrDefault(sm => sm.Code.GetValue() == "USERS_LIST"); + var listSubMenu = FindNodeByCode(idmMod, "USERS_LIST"); if (listSubMenu != null) { - var editUsersOpt = listSubMenu.Options.FirstOrDefault(o => o.Code.GetValue() == "MANAGE_USERS"); - dataEntryTpl.AddItem(ExclusiveArcTarget.Option, listSubMenu.Props.Id, ActionId.Create(), true, false, actor); - if (editUsersOpt != null) dataEntryTpl.AddItem(ExclusiveArcTarget.Option, editUsersOpt.Props.Id, ActionId.Create(), true, false, actor); + var editUsersOpt = FindNodeByCode(idmMod, "MANAGE_USERS"); + dataEntryTpl.AddItem(ExclusiveArcTarget.Option, listSubMenu.GetId(), ActionId.Create(), true, false, actor); + if (editUsersOpt != null) AddOptionActionItems(dataEntryTpl, editUsersOpt, coreSuite, actor); } } } @@ -591,16 +1414,15 @@ private static IReadOnlyList BuildSeedPermissionTem { var operatorTpl = PermissionTemplateAggregate.Create(tenantId, operatorRole.GetId(), wmsSuite.GetId(), actor).Value; var invMod = wmsSuite.Modules.First(m => m.Code.GetValue() == "INV"); - var stockMenu = invMod.Menus.First(m => m.Code.GetValue() == "STOCK"); - var levelsSubMenu = stockMenu.SubMenus.First(sm => sm.Code.GetValue() == "LEVELS"); - var viewStockOpt = levelsSubMenu.Options.First(o => o.Code.GetValue() == "VIEW_STOCK"); - var adjustStockOpt = levelsSubMenu.Options.First(o => o.Code.GetValue() == "ADJUST_STOCK"); + var levelsSubMenu = FindNodeByCode(invMod, "LEVELS")!; + var viewStockOpt = FindNodeByCode(invMod, "VIEW_STOCK")!; + var adjustStockOpt = FindNodeByCode(invMod, "ADJUST_STOCK")!; var invWms = wmsSuite.DomainResources.First(x => x.Code.GetValue() == "INVENTORY_WMS"); operatorTpl.AddItem(ExclusiveArcTarget.Module, invMod.Props.Id, ActionId.Create(), true, false, actor); - operatorTpl.AddItem(ExclusiveArcTarget.Option, levelsSubMenu.Props.Id, ActionId.Create(), true, false, actor); - operatorTpl.AddItem(ExclusiveArcTarget.Option, viewStockOpt.Props.Id, ActionId.Create(), true, false, actor); - operatorTpl.AddItem(ExclusiveArcTarget.Option, adjustStockOpt.Props.Id, ActionId.Create(), false, true, actor); + operatorTpl.AddItem(ExclusiveArcTarget.Option, levelsSubMenu.GetId(), ActionId.Create(), true, false, actor); + AddOptionActionItems(operatorTpl, viewStockOpt, wmsSuite, actor); + AddOptionActionItems(operatorTpl, adjustStockOpt, wmsSuite, actor, isAllowed: false, isDenied: true); operatorTpl.AddItem(ExclusiveArcTarget.Aggregate, invWms.Id, ActionId.Create(), true, false, actor); operatorTpl.Publish(actor); templates.Add(operatorTpl); @@ -611,13 +1433,12 @@ private static IReadOnlyList BuildSeedPermissionTem { var inspectorTpl = PermissionTemplateAggregate.Create(tenantId, inspectorRole.GetId(), wmsSuite.GetId(), actor).Value; var invMod = wmsSuite.Modules.First(m => m.Code.GetValue() == "INV"); - var stockMenu = invMod.Menus.First(m => m.Code.GetValue() == "STOCK"); - var levelsSubMenu = stockMenu.SubMenus.First(sm => sm.Code.GetValue() == "LEVELS"); - var viewStockOpt = levelsSubMenu.Options.First(o => o.Code.GetValue() == "VIEW_STOCK"); + var levelsSubMenu = FindNodeByCode(invMod, "LEVELS")!; + var viewStockOpt = FindNodeByCode(invMod, "VIEW_STOCK")!; inspectorTpl.AddItem(ExclusiveArcTarget.Module, invMod.Props.Id, ActionId.Create(), true, false, actor); - inspectorTpl.AddItem(ExclusiveArcTarget.Option, levelsSubMenu.Props.Id, ActionId.Create(), true, false, actor); - inspectorTpl.AddItem(ExclusiveArcTarget.Option, viewStockOpt.Props.Id, ActionId.Create(), true, false, actor); + inspectorTpl.AddItem(ExclusiveArcTarget.Option, levelsSubMenu.GetId(), ActionId.Create(), true, false, actor); + AddOptionActionItems(inspectorTpl, viewStockOpt, wmsSuite, actor); inspectorTpl.Publish(actor); templates.Add(inspectorTpl); } @@ -630,17 +1451,9 @@ private static IReadOnlyList BuildSeedPermissionTem foreach (var mod in wmsSuite.Modules) { managerTpl.AddItem(ExclusiveArcTarget.Module, mod.Props.Id, ActionId.Create(), true, false, actor); - foreach (var menu in mod.Menus) + foreach (var root in mod.Nodes) { - managerTpl.AddItem(ExclusiveArcTarget.Submodule, menu.Props.Id, ActionId.Create(), true, false, actor); - foreach (var subMenu in menu.SubMenus) - { - managerTpl.AddItem(ExclusiveArcTarget.Option, subMenu.Props.Id, ActionId.Create(), true, false, actor); - foreach (var opt in subMenu.Options) - { - managerTpl.AddItem(ExclusiveArcTarget.Option, opt.Props.Id, ActionId.Create(), true, false, actor); - } - } + AddNodeNavigation(managerTpl, root, wmsSuite, actor); } } managerTpl.Publish(actor); @@ -652,16 +1465,16 @@ private static IReadOnlyList BuildSeedPermissionTem { var dispatcherTpl = PermissionTemplateAggregate.Create(tenantId, dispatcherRole.GetId(), wmsSuite.GetId(), actor).Value; var invMod = wmsSuite.Modules.First(m => m.Code.GetValue() == "INV"); - var opsMenu = invMod.Menus.First(m => m.Code.GetValue() == "OPS"); - var transfersSubMenu = opsMenu.SubMenus.First(sm => sm.Code.GetValue() == "TRANSFERS"); - var initiateOpt = transfersSubMenu.Options.First(o => o.Code.GetValue() == "INITIATE_TRANSFER"); - var approveOpt = transfersSubMenu.Options.First(o => o.Code.GetValue() == "APPROVE_TRANSFER"); + var opsMenu = FindNodeByCode(invMod, "OPS")!; + var transfersSubMenu = FindNodeByCode(invMod, "TRANSFERS")!; + var initiateOpt = FindNodeByCode(invMod, "INITIATE_TRANSFER")!; + var approveOpt = FindNodeByCode(invMod, "APPROVE_TRANSFER")!; var transferOrder = wmsSuite.DomainResources.First(x => x.Code.GetValue() == "TRANSFER_ORDER"); - dispatcherTpl.AddItem(ExclusiveArcTarget.Submodule, opsMenu.Props.Id, ActionId.Create(), true, false, actor); - dispatcherTpl.AddItem(ExclusiveArcTarget.Option, transfersSubMenu.Props.Id, ActionId.Create(), true, false, actor); - dispatcherTpl.AddItem(ExclusiveArcTarget.Option, initiateOpt.Props.Id, ActionId.Create(), true, false, actor); - dispatcherTpl.AddItem(ExclusiveArcTarget.Option, approveOpt.Props.Id, ActionId.Create(), true, false, actor); + dispatcherTpl.AddItem(ExclusiveArcTarget.Submodule, opsMenu.GetId(), ActionId.Create(), true, false, actor); + dispatcherTpl.AddItem(ExclusiveArcTarget.Option, transfersSubMenu.GetId(), ActionId.Create(), true, false, actor); + AddOptionActionItems(dispatcherTpl, initiateOpt, wmsSuite, actor); + AddOptionActionItems(dispatcherTpl, approveOpt, wmsSuite, actor); dispatcherTpl.AddItem(ExclusiveArcTarget.Entity, transferOrder.Id, ActionId.Create(), true, false, actor); dispatcherTpl.Publish(actor); templates.Add(dispatcherTpl); @@ -672,15 +1485,14 @@ private static IReadOnlyList BuildSeedPermissionTem { var reporterTpl = PermissionTemplateAggregate.Create(tenantId, reporterRole.GetId(), wmsSuite.GetId(), actor).Value; var reportsMod = wmsSuite.Modules.First(m => m.Code.GetValue() == "REPORTS"); - var invReportsMenu = reportsMod.Menus.First(m => m.Code.GetValue() == "INV_REPORTS"); - var stockSummarySubMenu = invReportsMenu.SubMenus.First(sm => sm.Code.GetValue() == "STOCK_SUMMARY"); - var viewReportOpt = stockSummarySubMenu.Options.First(o => o.Code.GetValue() == "VIEW_STOCK_REPORT"); - var exportOpt = stockSummarySubMenu.Options.First(o => o.Code.GetValue() == "EXPORT_STOCK"); + var stockSummarySubMenu = FindNodeByCode(reportsMod, "STOCK_SUMMARY")!; + var viewReportOpt = FindNodeByCode(reportsMod, "VIEW_STOCK_REPORT")!; + var exportOpt = FindNodeByCode(reportsMod, "EXPORT_STOCK")!; reporterTpl.AddItem(ExclusiveArcTarget.Module, reportsMod.Props.Id, ActionId.Create(), true, false, actor); - reporterTpl.AddItem(ExclusiveArcTarget.Option, stockSummarySubMenu.Props.Id, ActionId.Create(), true, false, actor); - reporterTpl.AddItem(ExclusiveArcTarget.Option, viewReportOpt.Props.Id, ActionId.Create(), true, false, actor); - reporterTpl.AddItem(ExclusiveArcTarget.Option, exportOpt.Props.Id, ActionId.Create(), true, false, actor); + reporterTpl.AddItem(ExclusiveArcTarget.Option, stockSummarySubMenu.GetId(), ActionId.Create(), true, false, actor); + AddOptionActionItems(reporterTpl, viewReportOpt, wmsSuite, actor); + AddOptionActionItems(reporterTpl, exportOpt, wmsSuite, actor); reporterTpl.Publish(actor); templates.Add(reporterTpl); } @@ -688,7 +1500,7 @@ private static IReadOnlyList BuildSeedPermissionTem return templates; } - private static IReadOnlyList BuildSeedProfiles(TenantId tenantId, IReadOnlyList roles, IReadOnlyList templates, ActorId actor) + private static IReadOnlyList BuildGenericProfiles(TenantId tenantId, IReadOnlyList roles, IReadOnlyList templates, ActorId actor) { var profiles = new List(); @@ -821,7 +1633,6 @@ private static async Task EnsureInternalAdminProfileAsync( private static async Task EnsureDomainResourcesAsync( IReadOnlyList existingSuites, - TenantId tenantId, ActorId actor, ISystemSuiteRepository repository, CancellationToken cancellationToken) @@ -875,3 +1686,5 @@ private static async Task EnsureDomainResourcesAsync( }; } } + +#pragma warning restore S1144 diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/ConfigurationDevDataSeeder.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/ConfigurationDevDataSeeder.cs index ba5a03cd..92e95896 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/ConfigurationDevDataSeeder.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/ConfigurationDevDataSeeder.cs @@ -14,18 +14,25 @@ namespace Ums.Infrastructure.Persistence.Seeders; using AppConfigurationAggregate = Ums.Domain.Configuration.AppConfiguration.AppConfiguration; using FeatureFlagAggregate = Ums.Domain.Configuration.FeatureFlag.FeatureFlag; using IdpConfigurationAggregate = Ums.Domain.Configuration.IdpConfiguration.IdpConfiguration; +using SystemSuiteAggregate = Ums.Domain.Authorization.SystemSuite.SystemSuite; using TenantParameterAggregate = Ums.Domain.Identity.Tenant.TenantParameter.TenantParameter; public static class ConfigurationDevDataSeeder { private const string TestIdpSystemSuiteId = "11111111-1111-1111-1111-111111111111"; + private const string PaitaAgroexportFlagCode = "PAITA_AGROEXPORT"; + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Security Hotspot", "S3011:Reflection should not be used to increase accessibility of classes, methods, or fields", + Justification = "ADR-UMS-099: reflexión sancionada en seeder de desarrollo/UAT. La siembra determinista con " + + "IDs bien conocidos exige fijar identidades y estados que el dominio no permite por vías " + + "públicas. No se ejecuta en producción (SeedDevData && !IsProduction).")] private static readonly BindingFlags PrivateInstanceFlags = BindingFlags.Instance | BindingFlags.NonPublic; private static readonly Guid DemoSystemSuiteGuid = Guid.Parse(CoreDevDataSeeder.DemoSystemSuiteId); private static readonly Guid RansaTenantGuid = Guid.Parse(CoreDevDataSeeder.RansaTenantId); private static readonly Guid InternalAdminTenantGuid = Guid.Parse(CoreDevDataSeeder.InternalAdminTenantId); private static readonly Guid ApmTenantGuid = Guid.Parse("A3F5B9D2-7C3D-4C8E-A9B0-123456789ABC"); private static readonly Guid NeptuniaTenantGuid = Guid.Parse("C9B736B4-6A84-48F8-B34D-176BC5A6D542"); - private static readonly Guid UnimarTenantGuid = Guid.Parse("5F4E3D2C-1B0A-9F8E-7D6C-543210987654"); + private static readonly Guid BeyondNetTenantGuid = Guid.Parse("5F4E3D2C-1B0A-9F8E-7D6C-543210987654"); private static readonly Guid PaitaTenantGuid = Guid.Parse("9E8D7C6B-5A4F-3E2D-1C0B-9876543210FE"); private static readonly Guid IntradevcoTenantGuid = Guid.Parse("F3E2D1C0-B9A8-7F6E-5D4C-321098765432"); @@ -57,6 +64,11 @@ private static async Task SeedAppConfigurationsAsync( var needsSave = false; + // NOTA: `GetByScopeAndCodeAsync` solo ve las configuraciones VIVAS desde que la ranura se + // libera al borrar. Consecuencia deliberada: si en un entorno de desarrollo alguien elimina + // una configuración sembrada, el siguiente arranque la vuelve a crear —con id nuevo, junto a + // la lápida—. Es el contrato de un sembrado: garantizar la línea base del entorno. No afecta + // a producción, donde este sembrador no corre (SeedDevData && !IsProduction). foreach (var config in desiredConfigs) { var existing = await repository.GetByScopeAndCodeAsync( @@ -101,6 +113,14 @@ private static IReadOnlyList BuildSeedAppConfiguratio Description.Create("Maximum login attempts before lockout"), actor); + // ADR-UMS-095: duración del bloqueo temporal por intentos fallidos (nivel Global). + AddPublishedConfiguration(results, + null, + Code.Create(AppConfigurationCodes.AccountLockoutDurationMinutes), + ConfigurationValue.Create(AppConfigurationDefaults.AccountLockoutDurationMinutes.ToString()), + Description.Create("Temporary account lockout duration in minutes after reaching the max login attempts"), + actor); + AddPublishedConfiguration(results, null, Code.Create(AppConfigurationCodes.AccessTokenDurationMs), @@ -133,7 +153,7 @@ private static IReadOnlyList BuildSeedAppConfiguratio null, Code.Create(AppConfigurationCodes.FrontendConfigTransport), ConfigurationValue.Create(AppConfigurationDefaults.FrontendConfigTransport), - Description.Create("Transport mode for frontend config queries: 'graphql' or 'rest'"), + Description.Create("Transport mode for frontend config queries (REST only)"), actor); AddPublishedConfiguration(results, @@ -189,6 +209,14 @@ private static IReadOnlyList BuildTenantSpecificConfi Description.Create($"Login attempt limit for {profile.Name}"), actor); + // ADR-UMS-095: duración del bloqueo temporal por intentos fallidos (nivel Tenant). + AddPublishedConfiguration(results, + tenantId, + Code.Create(AppConfigurationCodes.AccountLockoutDurationMinutes), + ConfigurationValue.Create(profile.AccountLockoutDurationMinutes), + Description.Create($"Temporary account lockout duration in minutes for {profile.Name}"), + actor); + AddPublishedConfiguration(results, tenantId, Code.Create(AppConfigurationCodes.MinPasswordLength), @@ -348,12 +376,73 @@ private static async Task SeedFeatureFlagsAsync( } } + // FS-25 §4.5 / criterio 12: bandera de agroexportación acotada al tenant BEYONDNET (sucursal Paita). + needsSave = await SeedBeyondNetAgroexportFlagAsync(repository, suites, existingByKey, actor, cancellationToken) || needsSave; + if (needsSave) { await repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); } } + private static async Task SeedBeyondNetAgroexportFlagAsync( + IFeatureFlagRepository repository, + IReadOnlyList suites, + IDictionary<(Guid SystemSuiteId, string FlagCode), FeatureFlagAggregate> existingByKey, + ActorId actor, + CancellationToken cancellationToken) + { + var anchorSuiteId = ResolveBeyondNetAnchorSuiteId(suites); + if (anchorSuiteId is null || existingByKey.ContainsKey((anchorSuiteId.Value, PaitaAgroexportFlagCode))) + { + return false; + } + + var paitaFlag = BuildBeyondNetAgroexportFlag(anchorSuiteId.Value, actor); + if (paitaFlag is null) + { + return false; + } + + await repository.AddAsync(paitaFlag, cancellationToken); + existingByKey[(anchorSuiteId.Value, PaitaAgroexportFlagCode)] = paitaFlag; + return true; + } + + private static Guid? ResolveBeyondNetAnchorSuiteId(IReadOnlyList suites) + { + var beyondNetSuites = suites.Where(suite => suite.TenantId.GetValue() == BeyondNetTenantGuid).ToList(); + var anchor = beyondNetSuites.FirstOrDefault(suite => suite.Code.GetValue() == "WMS") + ?? beyondNetSuites.FirstOrDefault(); + return anchor?.GetId().GetValue(); + } + + private static FeatureFlagAggregate? BuildBeyondNetAgroexportFlag(Guid anchorSuiteId, ActorId actor) + { + var flagResult = FeatureFlagAggregate.Create( + IdValueObject.Load(anchorSuiteId), + IdValueObject.Load(BeyondNetTenantGuid), + PaitaAgroexportFlagCode, + FlagType.Boolean, + "*", + null, + null, + null, + actor); + + if (flagResult.IsFailure) + { + return null; + } + + var flag = flagResult.Value; + flag.AddCriteria("TenantId", "Equals", CoreDevDataSeeder.BeyondNetTenantId, actor); + flag.DomainEvents.MarkChangesAsCommitted(); + flag.Activate(actor); + flag.DomainEvents.MarkChangesAsCommitted(); + return flag; + } + private static void ApplyFeatureFlagDemoRules( FeatureFlagAggregate flag, FeatureFlagSeedDefinition definition, @@ -423,6 +512,10 @@ private static IReadOnlyList GetFeatureFlagDefinition }; } + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Security Hotspot", "S3011:Reflection should not be used to increase accessibility of classes, methods, or fields", + Justification = "ADR-UMS-099: reflexión sancionada en seeder de desarrollo/UAT. Fija el TenantId del feature " + + "flag semilla sobre props no públicos; no se ejecuta en producción (SeedDevData && !IsProduction).")] private static void SetFeatureFlagTenantId(FeatureFlagAggregate featureFlag, Guid tenantId) { var propsField = typeof(FeatureFlagAggregate).GetField("_props", PrivateInstanceFlags); @@ -810,8 +903,8 @@ private static IReadOnlyList GetTenantSeedProfiles() "keyvault://ums/neptunia-entra-secret", 1), new TenantSeedProfile( - UnimarTenantGuid, - "UNIMAR", + BeyondNetTenantGuid, + "BEYONDNET", AppConfigurationDefaults.SessionTimeoutMinutes.ToString(), AppConfigurationDefaults.MaxLoginAttempts.ToString(), AppConfigurationDefaults.MinPasswordLength.ToString(), @@ -883,7 +976,10 @@ private sealed record TenantSeedProfile( string[] ExternalIdpDomainHints, string ExternalIdpPayload, string ExternalIdpSecretRef, - int ExternalIdpResolutionPriority); + int ExternalIdpResolutionPriority, + // ADR-UMS-095: duración del bloqueo temporal por intentos fallidos. Default seguro (15 min), + // alineado con AppConfigurationDefaults.AccountLockoutDurationMinutes; parametrizable por perfil. + string AccountLockoutDurationMinutes = "15"); private sealed record FeatureFlagSeedDefinition( string Code, diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/CoreDevDataSeeder.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/CoreDevDataSeeder.cs index d9e8dfea..7752512a 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/CoreDevDataSeeder.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/CoreDevDataSeeder.cs @@ -1,6 +1,9 @@ namespace Ums.Infrastructure.Persistence.Seeders; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Ums.Infrastructure.Persistence.Options; +using Ums.Domain.Identity; public static class CoreDevDataSeeder { @@ -14,6 +17,10 @@ public static class CoreDevDataSeeder // ── SuperAdmin User (global admin) ───────────────────────────────────────── public const string SuperAdminUserId = "22222222-2222-2222-2222-222222222222"; public const string SuperAdminUsername = "admin"; + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Security", "S2068:Hard-coded credentials are security-sensitive", + Justification = "Contraseña de datos SEMILLA de desarrollo, nunca de producción: solo se aplica bajo " + + "SeedDevData && !IsProduction. No es un secreto real (RB-06, stage UAT determinista).")] public const string SuperAdminPassword = "root"; // Default password for INTERNAL admin (change in production) public const string InternalAdminPendingUserId = "11111103-1111-1111-1111-111111111111"; @@ -36,16 +43,117 @@ public static class CoreDevDataSeeder public const string DemoSystemSuiteId = "dddd0001-0000-0000-0000-000000000001"; public const string InternalAdminInboxWorkflowId = "88888888-3333-3333-3333-333333333333"; + // ── BEYONDNET operator (SUPPLIER) — FS-25 seed dataset ─────────────────────── + // Existing stable tenant GUID; reconciled by upsert (RUC + branches), never recreated. + public const string BeyondNetTenantId = "5f4e3d2c-1b0a-9f8e-7d6c-543210987654"; + public const string BeyondNetRuc = "RUC-20100412447"; + // Uniform dev password (min. 12 chars) for BEYONDNET internal and client users (FS-25 §4.4). + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Security", "S2068:Hard-coded credentials are security-sensitive", + Justification = "Contraseña de datos SEMILLA de desarrollo (FS-25 §4.4), nunca de producción: solo bajo " + + "SeedDevData && !IsProduction. No es un secreto real (RB-06, stage UAT determinista).")] + public const string BeyondNetDevPassword = "BeyondNet.Dev.2026"; + + // BEYONDNET client tenants (importer/exporter) added by FS-25 §4.1. + public const string ComexAndinaTenantId = "c0e1a000-1111-4c0e-a000-000000000001"; // COMEX_ANDINA (impo Lima) + public const string AgronorteTenantId = "a9701e00-2222-4a97-b000-000000000002"; // AGRONORTE (expo Paita) + public const string FrupiuraTenantId = "f3401a00-3333-4f34-c000-000000000003"; // FRUPIURA (expo Paita, opcional) + + // "Cliente de mi cliente": a CLIENT tenant that hangs off COMEX_ANDINA (ParentTenantId), + // to exercise the tenant hierarchy. Gets the same scoped treatment as the external clients + // (PORTAL_CLIENTE-only + CLIENTE_EXTERNO). GUID is deterministic and distinct from its parent. + public const string ImpoAndinaSubTenantId = "c0e1b000-1111-4c0e-b000-000000000011"; // IMPO_ANDINA_SUB (hijo de COMEX_ANDINA) + public const string ImpoAndinaSubTenantCode = "IMPO_ANDINA_SUB"; + public const string ImpoAndinaSubTenantName = "Importadora Sub-Cliente de Comex Andina S.A.C."; + public const string ImpoAndinaSubBranchCode = "IASUB_LIMA"; + public const string ImpoAndinaSubBranchName = "Almacén Lima Sub-Cliente"; + public const string ImpoAndinaSubUserEmail = "usuario@impo-subcliente.com.pe"; + + // BEYONDNET internal user deterministic GUIDs are derived from the BEYONDNET tenant GUID + // by replacing byte[0] with the index below (same little-endian scheme used by the + // Identity and Authorization seeders). Profiles link to users through these indices. + // 1 admin.callao · 2 agente.aduanas.callao · 3 despachador.callao · 4 jefe.almacen.callao + // 5 coordinador.transporte.callao · 6 ejecutivo.cuenta.callao · 7 analista.doc.callao + // 8 auditor.callao · 9 jefe.almacen.paita · 10 operario.almacen.paita + // 11 agente.aduanas.paita · 12 ejecutivo.cuenta.paita · 20 admin (root BEYONDNET, sin sucursal) + public const byte BeyondNetJefeAlmacenPaitaUserIndex = 9; // FS-25 criterio 7 + + // BEYONDNET ROOT ADMIN: transversal operator admin (no branch), distinct from the per-branch + // admin (index 1) and from the platform super-admin (admin@ums.local). Same derived-guid scheme. + public const byte BeyondNetRootAdminUserIndex = 20; + public const string BeyondNetRootAdminEmail = "admin@beyondnet.com.pe"; + + // Tenant ancla usado como marca de "ya sembrado" por la guarda de idempotencia. + public const string SeedAnchorTenantCode = "RANSA_PERU"; + public static async Task SeedAllAsync(IServiceProvider serviceProvider, CancellationToken cancellationToken = default) { + // G-127: guarda de idempotencia a nivel de orquestador. Si el dataset ya está sembrado + // (tenant ancla RANSA_PERU presente), se omite TODA la siembra → los reinicios de pod no + // re-ejecutan los 7 seeders ni ensucian los logs con conflictos de PK esperados. Los + // seeders siguen siendo idempotentes por sí mismos (GUID fijos + reconciliación por clave + // natural), así que un `reset` —que dropea el esquema— siempre re-siembra limpio. Solo + // aplica al backend persistente (PostgreSQL); en modo in-memory ITenantRepository no está + // registrado y siempre se siembra desde cero. Recuperación de una siembra inicial parcial + // (un seeder falló en el primer arranque): ejecutar `reset` (ver runbook UAT). + // `Persistence:ForceReseed` salta la guarda. Sin esa escotilla, el conjunto sembrado queda + // congelado en lo que hubiera el primer día: añadir después un sistema al catálogo no lo + // hace llegar a ninguna base existente, y la única salida era un `reset` que dropea el + // esquema —y con él lo que NO siembra el código, como el Tablero SDLC, que se carga por + // API—. Los sembradores reconcilian por clave natural, así que volver a pasarlos añade lo + // que falta sin duplicar lo que ya está. + var forzar = serviceProvider.GetService>()?.Value.ForceReseed ?? false; + + if (!forzar && await IsAlreadySeededAsync(serviceProvider, cancellationToken)) + { + Console.WriteLine( + $"Seed skipped: anchor tenant '{SeedAnchorTenantCode}' already present (idempotent restart)."); + return; + } + + if (forzar) + { + Console.WriteLine( + "Seed forced: Persistence:ForceReseed=true — se ejecutan los sembradores aunque el " + + "dataset ya exista. Añaden lo que falta por clave natural; apágalo tras comprobar."); + } + await RunSeederAsync(serviceProvider, "Identity", IdentityDevDataSeeder.SeedAsync, cancellationToken); await RunSeederAsync(serviceProvider, "Authorization", AuthorizationDevDataSeeder.SeedAsync, cancellationToken); await RunSeederAsync(serviceProvider, "Configuration", ConfigurationDevDataSeeder.SeedAsync, cancellationToken); await RunSeederAsync(serviceProvider, "Parameter catalog", ParameterCatalogSeeder.SeedAsync, cancellationToken); await RunSeederAsync(serviceProvider, "Approvals", ApprovalsDevDataSeeder.SeedAsync, cancellationToken); + await RunSeederAsync(serviceProvider, "IGA", IgaDevDataSeeder.SeedAsync, cancellationToken); await RunSeederAsync(serviceProvider, "Audit", AuditDevDataSeeder.SeedAsync, cancellationToken); } + private static async Task IsAlreadySeededAsync( + IServiceProvider serviceProvider, + CancellationToken cancellationToken) + { + var scopeFactory = serviceProvider.GetRequiredService(); + using var scope = scopeFactory.CreateScope(); + + // En modo in-memory el repositorio persistente no está registrado → nunca se omite. + var tenantRepository = scope.ServiceProvider.GetService(); + if (tenantRepository is null) + { + return false; + } + + try + { + var anchor = await tenantRepository.GetByCodeAsync(SeedAnchorTenantCode, cancellationToken); + return anchor is not null; + } + catch + { + // Ante cualquier fallo del chequeo (p. ej. esquema aún migrándose) se cae a sembrar: + // los seeders son idempotentes, de modo que sembrar de más es seguro y no duplica. + return false; + } + } + private static async Task RunSeederAsync( IServiceProvider serviceProvider, string seederName, diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/IdentityDevDataSeeder.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/IdentityDevDataSeeder.cs index 85a35ad6..7745a89e 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/IdentityDevDataSeeder.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/IdentityDevDataSeeder.cs @@ -1,10 +1,10 @@ namespace Ums.Infrastructure.Persistence.Seeders; +using System.Reflection; using Microsoft.Extensions.DependencyInjection; using Ums.Application.Common.Interfaces; using Ums.Domain.Enums; using Ums.Domain.Identity.Tenant; -using Ums.Domain.Identity.Tenant.Branding; using Ums.Domain.Identity.UserAccount; using Ums.Domain.Identity.UserManagementDelegation; using Ums.Domain.Kernel.ValueObjects; @@ -57,9 +57,22 @@ public static async Task SeedAsync(IServiceProvider serviceProvider, Cancellatio } } + // FS-25 Flujo B: reconcile a legacy BEYONDNET snapshot (wrong RUC/branches) in place, + // by its stable GUID — never recreate the tenant nor break existing references. + if (inMemoryTenantRepository is null && tenantRepository is not null) + { + await ReconcileBeyondNetTenantAsync(tenantRepository, actor, cancellationToken); + } + + // Branch GUIDs are generated inside the aggregate (Branch.Create), so we read them back + // from the freshly built tenants by code and pass them to the users we associate (FS-25). + var beyondNetCallaoBranchId = FindBranchId(tenants, "BEYONDNET", "BN_CALLAO"); + var beyondNetPaitaBranchId = FindBranchId(tenants, "BEYONDNET", "BN_PAITA"); + var impoAndinaSubBranchId = FindBranchId(tenants, CoreDevDataSeeder.ImpoAndinaSubTenantCode, CoreDevDataSeeder.ImpoAndinaSubBranchCode); + // Seed / sync User Accounts (including SuperAdmin) so local password logins stay valid // even when the DB already contains an older dev snapshot. - var userAccounts = BuildSeedUserAccounts(actor, passwordHasher); + var userAccounts = BuildSeedUserAccounts(actor, passwordHasher, beyondNetCallaoBranchId, beyondNetPaitaBranchId, impoAndinaSubBranchId); if (inMemoryUserAccountRepository is null && userAccountRepository is not null) { foreach (var userAccount in userAccounts) @@ -124,7 +137,10 @@ public static async Task SeedAsync(IServiceProvider serviceProvider, Cancellatio private static IReadOnlyList BuildSeedTenants(ActorId actor) { - // ── 0. Internal Admin Tenant (global administration) ──────────────────── + // ── 0. Internal Admin Tenant (break-glass) ────────────────────────────── + // ADR-0071 / FS-26: el Admin Root (propietario de gestión) es BEYONDNET, no este + // tenant sintético. INTERNAL_ADMIN queda como cuenta de emergencia degradada: + // NO es propietario de gestión (isManagementOwner: false). var internalAdminTenantResult = TenantAggregate.Create( Code.Create(CoreDevDataSeeder.InternalAdminTenantCode), Name.Create(CoreDevDataSeeder.InternalAdminTenantName), @@ -134,7 +150,7 @@ private static IReadOnlyList BuildSeedTenants(ActorId actor) null, null, TenantId.Load(Guid.Parse(CoreDevDataSeeder.InternalAdminTenantId)), - isManagementOwner: true); + isManagementOwner: false); if (internalAdminTenantResult.IsFailure) { @@ -161,13 +177,32 @@ private static IReadOnlyList BuildSeedTenants(ActorId actor) var paita = BuildTenant(Guid.Parse("9e8d7c6b-5a4f-3e2d-1c0b-9876543210fe"), "PAITA_PORT", "Terminal Portuario de Paita S.A.", "RUC-20512180098", OrganizationType.CLIENT, null, false, actor, [("PAITA_MUELLE", "Muelle de Transferencia — Puerto Paita"), ("PAITA_ALMACEN", "Almacén General Paita")]); - var unimar = BuildTenant(Guid.Parse("5f4e3d2c-1b0a-9f8e-7d6c-543210987654"), "UNIMAR", "Unimar S.A. — Lima", "RUC-20101523381", OrganizationType.SUPPLIER, null, false, actor, - [("UNI_MIRAFLORES", "Oficina Miraflores — Av. Larco"), ("UNI_CALLAO_OP", "Operaciones Callao — Jr. Colón")]); + // FS-25: BEYONDNET operator reconciled to its real RUC (20100412447) and its two + // real branches (Operaciones Callao, Sucursal Paita). Same stable tenant GUID. + // ADR-0071 / FS-26: BEYONDNET es el Tenant Raíz / Admin Root del ecosistema — + // OrganizationType.INTERNAL + único propietario de gestión (isManagementOwner: true). + // De ese flag deriva el login (AuthEndpoints) el privilegio transversal is_internal_admin. + var beyondnet = BuildTenant(Guid.Parse(CoreDevDataSeeder.BeyondNetTenantId), "BEYONDNET", "BeyondNet S.A.C.", CoreDevDataSeeder.BeyondNetRuc, OrganizationType.INTERNAL, null, true, actor, + [("BN_CALLAO", "Operaciones Callao"), ("BN_PAITA", "Sucursal Paita")]); var intradevco = BuildTenant(Guid.Parse("f3e2d1c0-b9a8-7f6e-5d4c-321098765432"), "INTRADEVCO", "Intradevco Industrial S.A.", "RUC-20101041268", OrganizationType.SUPPLIER, null, false, actor, [("INTRA_SJL", "Planta San Juan de Lurigancho"), ("INTRA_ATE", "Almacén Ate Vitarte — Carretera Central")]); - return [internalAdminTenant, ransa, neptunia, apm, paita, unimar, intradevco]; + // FS-25 §4.1: BEYONDNET client companies (importer/exporter), one branch each. + var comexAndina = BuildTenant(Guid.Parse(CoreDevDataSeeder.ComexAndinaTenantId), "COMEX_ANDINA", "Comercializadora Andina S.A.C.", "RUC-20512345671", OrganizationType.CLIENT, null, false, actor, + [("CAND_LIMA", "Almacén Lima")]); + + var agronorte = BuildTenant(Guid.Parse(CoreDevDataSeeder.AgronorteTenantId), "AGRONORTE", "Agroexportadora del Norte S.A.C.", "RUC-20484123456", OrganizationType.CLIENT, null, false, actor, + [("AGRN_PAITA", "Planta Paita")]); + + var frupiura = BuildTenant(Guid.Parse(CoreDevDataSeeder.FrupiuraTenantId), "FRUPIURA", "Frutícola Piura S.A.C.", "RUC-20526098765", OrganizationType.CLIENT, null, false, actor, + [("FRPI_PAITA", "Packing Paita")]); + + // "Cliente de mi cliente": CLIENT tenant HIJO de COMEX_ANDINA (ParentTenantId), una sucursal. + var impoAndinaSub = BuildTenant(Guid.Parse(CoreDevDataSeeder.ImpoAndinaSubTenantId), CoreDevDataSeeder.ImpoAndinaSubTenantCode, CoreDevDataSeeder.ImpoAndinaSubTenantName, "RUC-20609988771", OrganizationType.CLIENT, Guid.Parse(CoreDevDataSeeder.ComexAndinaTenantId), false, actor, + [(CoreDevDataSeeder.ImpoAndinaSubBranchCode, CoreDevDataSeeder.ImpoAndinaSubBranchName)]); + + return [internalAdminTenant, ransa, neptunia, apm, paita, beyondnet, intradevco, comexAndina, agronorte, frupiura, impoAndinaSub]; } private static TenantAggregate BuildTenant( @@ -215,28 +250,10 @@ private static TenantAggregate BuildTenant( // IDP registered but NOT activated — dev mode uses InternalBcrypt (local password login). // Activate in production/staging when Azure AD SSO is configured. tenant.RegisterIdentityProvider(Code.Create("ENTRA_ID"), Name.Create("Azure AD Corporativo"), Description.Create("Directorio principal Ransa"), IdpStrategy.AzureAd, actor); - - var branding = BrandingSettings.CreateBuilder() - .WithLogo(Logo.Create("base64_ransa_logo_data"), LogoFormat.Png) - .WithTheme(HexColor.Create("#006400"), BackgroundStyle.SolidColor) - .WithTexts(LoginText.Create("Bienvenido a Ransa"), LoginText.Create("Ingresa tus credenciales"), LoginText.Create("Iniciar sesión"), LoginText.Create("© 2026 Ransa Comercial")) - .WithCustomDomain(CustomDomain.Create("login.ransa.pe")) - .WithMagicLinkFallback(true) - .Build(); - tenant.SetBranding(branding, actor); } else if (code == "NEPTUNIA") { tenant.RegisterIdentityProvider(Code.Create("OKTA_CORP"), Name.Create("Okta Neptunia"), Description.Create("Directorio subsidiarias"), IdpStrategy.Okta, actor); - - var branding = BrandingSettings.CreateBuilder() - .WithLogo(Logo.Create("base64_neptunia_logo_data"), LogoFormat.Png) - .WithTheme(HexColor.Create("#00008B"), BackgroundStyle.Gradient) - .WithTexts(LoginText.Create("Portal Neptunia"), LoginText.Create("Accesos a operaciones portuarias"), LoginText.Create("Entrar"), LoginText.Create("© 2026 Neptunia")) - .WithCustomDomain(CustomDomain.Create("acceso.neptunia.pe")) - .WithMagicLinkFallback(false) - .Build(); - tenant.SetBranding(branding, actor); } else if (code == "PAITA_PORT") { @@ -250,14 +267,26 @@ private static TenantAggregate BuildTenant( return tenant; } - private static IReadOnlyList BuildSeedUserAccounts(ActorId actor, IPasswordHashingService? passwordHasher) + private static BranchId? FindBranchId(IReadOnlyList tenants, string tenantCode, string branchCode) + { + var tenant = tenants.FirstOrDefault(t => t.Code.GetValue() == tenantCode); + var branch = tenant?.Branches.FirstOrDefault(b => b.Code.GetValue() == branchCode); + return branch?.GetId(); + } + + private static IReadOnlyList BuildSeedUserAccounts( + ActorId actor, + IPasswordHashingService? passwordHasher, + BranchId? beyondNetCallaoBranchId = null, + BranchId? beyondNetPaitaBranchId = null, + BranchId? impoAndinaSubBranchId = null) { var internalAdminTenantId = TenantId.Load(Guid.Parse(CoreDevDataSeeder.InternalAdminTenantId)); var ransaTenantId = TenantId.Load(Guid.Parse("3fa85f64-5717-4562-b3fc-2c963f66afa6")); var neptuniaTenantId = TenantId.Load(Guid.Parse("c9b736b4-6a84-48f8-b34d-176bc5a6d542")); var apmTenantId = TenantId.Load(Guid.Parse("a3f5b9d2-7c3d-4c8e-a9b0-123456789abc")); var paitaTenantId = TenantId.Load(Guid.Parse("9e8d7c6b-5a4f-3e2d-1c0b-9876543210fe")); - var unimarTenantId = TenantId.Load(Guid.Parse("5f4e3d2c-1b0a-9f8e-7d6c-543210987654")); + var beyondNetTenantId = TenantId.Load(Guid.Parse("5f4e3d2c-1b0a-9f8e-7d6c-543210987654")); var intradevcoTenantId = TenantId.Load(Guid.Parse("f3e2d1c0-b9a8-7f6e-5d4c-321098765432")); var result = new List(); @@ -305,12 +334,92 @@ private static IReadOnlyList BuildSeedUserAccounts(ActorId result.AddRange(BuildSeedUserAccountsForTenant(neptuniaTenantId, actor, passwordHasher)); result.AddRange(BuildSeedUserAccountsForTenant(apmTenantId, actor, passwordHasher)); result.AddRange(BuildSeedUserAccountsForTenant(paitaTenantId, actor, passwordHasher)); - result.AddRange(BuildSeedUserAccountsForTenant(unimarTenantId, actor, passwordHasher)); result.AddRange(BuildSeedUserAccountsForTenant(intradevcoTenantId, actor, passwordHasher)); + // ── FS-25: BEYONDNET operator internal users (by branch) and client users ── + result.AddRange(BuildBeyondNetUserAccounts(beyondNetTenantId, actor, passwordHasher, beyondNetCallaoBranchId, beyondNetPaitaBranchId)); + + // BEYONDNET ROOT ADMIN — transversal operator admin, no branch (distinct from admin.callao). + result.Add(BuildActiveLocalUser( + beyondNetTenantId, CoreDevDataSeeder.BeyondNetRootAdminUserIndex, + CoreDevDataSeeder.BeyondNetRootAdminEmail, actor, passwordHasher)); + + result.Add(BuildActiveLocalUser( + TenantId.Load(Guid.Parse(CoreDevDataSeeder.ComexAndinaTenantId)), 1, + "usuario.impo@comexandina.com.pe", actor, passwordHasher)); + result.Add(BuildActiveLocalUser( + TenantId.Load(Guid.Parse(CoreDevDataSeeder.AgronorteTenantId)), 1, + "usuario.expo@agronorte.com.pe", actor, passwordHasher)); + + // "Cliente de mi cliente": single external user of the sub-client tenant, on its branch. + result.Add(BuildActiveLocalUser( + TenantId.Load(Guid.Parse(CoreDevDataSeeder.ImpoAndinaSubTenantId)), 1, + CoreDevDataSeeder.ImpoAndinaSubUserEmail, actor, passwordHasher, impoAndinaSubBranchId)); + return result; } + // FS-25 §4.4: the 12 BEYONDNET internal users (Callao + Paita). Indices match the + // profile→user mapping in AuthorizationDevDataSeeder (byte[0] = index). Each user is + // associated to its branch (Callao 1-8, Paita 9-12) via BranchId. + private static IReadOnlyList BuildBeyondNetUserAccounts( + TenantId beyondNetTenantId, + ActorId actor, + IPasswordHashingService? passwordHasher, + BranchId? callaoBranchId, + BranchId? paitaBranchId) + { + var users = new (byte Index, string Email, BranchId? BranchId)[] + { + (1, "admin.callao@beyondnet.com.pe", callaoBranchId), + (2, "agente.aduanas.callao@beyondnet.com.pe", callaoBranchId), + (3, "despachador.callao@beyondnet.com.pe", callaoBranchId), + (4, "jefe.almacen.callao@beyondnet.com.pe", callaoBranchId), + (5, "coordinador.transporte.callao@beyondnet.com.pe", callaoBranchId), + (6, "ejecutivo.cuenta.callao@beyondnet.com.pe", callaoBranchId), + (7, "analista.doc.callao@beyondnet.com.pe", callaoBranchId), + (8, "auditor.callao@beyondnet.com.pe", callaoBranchId), + (9, "jefe.almacen.paita@beyondnet.com.pe", paitaBranchId), + (10, "operario.almacen.paita@beyondnet.com.pe", paitaBranchId), + (11, "agente.aduanas.paita@beyondnet.com.pe", paitaBranchId), + (12, "ejecutivo.cuenta.paita@beyondnet.com.pe", paitaBranchId), + }; + + return users + .Select(u => BuildActiveLocalUser(beyondNetTenantId, u.Index, u.Email, actor, passwordHasher, u.BranchId)) + .ToList(); + } + + // Creates an Active user with the uniform dev password. No IdentityReference so the + // federated-user invariant does not block the local password login (FS-25 §4.4). + private static UserAccountAggregate BuildActiveLocalUser(TenantId tenantId, byte index, string email, ActorId actor, IPasswordHashingService? passwordHasher, BranchId? branchId = null) + { + var baseBytes = tenantId.GetValue().ToByteArray(); + baseBytes[0] = index; + var userId = UserAccountId.Load(new Guid(baseBytes)); + + var result = UserAccountAggregate.Create( + tenantId, Email.Create(email), + UserCategory.Internal, null, null, actor, + branchId: branchId, + userAccountId: userId); + + if (result.IsFailure) + { + throw new InvalidOperationException($"Unable to build BEYONDNET dev user seed {email}: {result.Error}"); + } + + var user = result.Value; + user.Activate(actor); + if (passwordHasher != null) + { + var hash = PasswordHash.Create(passwordHasher.Hash(CoreDevDataSeeder.BeyondNetDevPassword)); + user.AddPassword(hash, actor); + } + + return user; + } + private static IReadOnlyList BuildSeedUserAccountsForTenant(TenantId tenantId, ActorId actor, IPasswordHashingService? passwordHasher = null) { var baseGuidBytes = tenantId.GetValue().ToByteArray(); @@ -321,13 +430,17 @@ Guid DeriveGuid(byte index) return new Guid(bytes); } - var domain = tenantId.GetValue().ToString().StartsWith("3fa8") ? "ransa.pe" : - tenantId.GetValue().ToString().StartsWith("c9b7") ? "neptunia.pe" : - tenantId.GetValue().ToString().StartsWith("a3f5") ? "apmterminals.com" : - tenantId.GetValue().ToString().StartsWith("9e8d") ? "tpp-paita.com.pe" : - tenantId.GetValue().ToString().StartsWith("5f4e") ? "unimar.com.pe" : - tenantId.GetValue().ToString().StartsWith("f3e2") ? "intradevco.com.pe" : - "logistics.pe"; + var tenantStr = tenantId.GetValue().ToString(); + var domain = tenantStr switch + { + _ when tenantStr.StartsWith("3fa8") => "ransa.pe", + _ when tenantStr.StartsWith("c9b7") => "neptunia.pe", + _ when tenantStr.StartsWith("a3f5") => "apmterminals.com", + _ when tenantStr.StartsWith("9e8d") => "tpp-paita.com.pe", + _ when tenantStr.StartsWith("5f4e") => "evolith.com.pe", + _ when tenantStr.StartsWith("f3e2") => "intradevco.com.pe", + _ => "logistics.pe" + }; // Admin uses local password — no IdentityReference so the federated-user invariant doesn't block AddPassword var adminResult = UserAccountAggregate.Create( @@ -408,31 +521,105 @@ private static UserAccountAggregate BuildUserAccount( return result.Value; } + private static async Task ReconcileBeyondNetTenantAsync(ITenantRepository tenantRepository, ActorId actor, CancellationToken cancellationToken) + { + var beyondnet = await tenantRepository.GetByIdAsync(Guid.Parse(CoreDevDataSeeder.BeyondNetTenantId), cancellationToken); + if (beyondnet is null) + { + return; + } + + var changed = false; + + // Correct the RUC (CompanyReference) if a legacy snapshot carries a wrong value. + if (beyondnet.CompanyReference?.GetValue() != CoreDevDataSeeder.BeyondNetRuc) + { + SetCompanyReference(beyondnet, CompanyReference.Create(CoreDevDataSeeder.BeyondNetRuc)); + changed = true; + } + + // Ensure the two real branches exist (Callao + Paita). + var required = new[] { ("BN_CALLAO", "Operaciones Callao"), ("BN_PAITA", "Sucursal Paita") }; + foreach (var (code, name) in required) + { + if (beyondnet.Branches.All(b => b.Code.GetValue() != code)) + { + beyondnet.AddBranch(Code.Create(code), Name.Create(name), actor); + changed = true; + } + } + + // Cierra cualquier sucursal ajena al par real (Flujo B) — incluida la instantánea heredada + // UNI_LIMA, hoy renombrada a BN_CALLAO. + // + // ADR-0164: CIERRE, no borrado. La fila se queda y su código queda ocupado, que es lo que se + // quiere: si UNI_LIMA vuelve a aparecer en un volcado antiguo, el alta chocará en vez de + // crear una segunda sucursal con el mismo código. Se excluyen las ya cerradas para que el + // sembrado siga siendo idempotente: sin ese filtro, cada ejecución reintentaría cerrarlas y + // marcaría un cambio que no existe. + var stale = beyondnet.Branches + .Where(b => !b.IsClosed && b.Code.GetValue() != "BN_CALLAO" && b.Code.GetValue() != "BN_PAITA") + .ToList(); + foreach (var branch in stale) + { + // Recuentos en cero: el sembrado corre sobre datos de desarrollo/UAT que él mismo + // controla y no hay usuarios ni perfiles colgando de estas sucursales heredadas. La + // guarda real vive en CloseBranchCommandHandler, que sí consulta la base. + beyondnet.CloseBranch(branch.Props.Id, actor, reason: "Sucursal heredada fuera del par operativo real (siembra)."); + changed = true; + } + + if (changed) + { + await tenantRepository.UpdateAsync(beyondnet, cancellationToken); + await tenantRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + } + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Security Hotspot", "S3011:Reflection should not be used to increase accessibility of classes, methods, or fields", + Justification = "ADR-UMS-099: reflexión sancionada en seeder de desarrollo/UAT. Fija la referencia de empresa " + + "del tenant semilla sobre props no públicos; no se ejecuta en producción " + + "(SeedDevData && !IsProduction).")] + private static void SetCompanyReference(TenantAggregate tenant, CompanyReference companyReference) + { + const BindingFlags privateInstance = BindingFlags.Instance | BindingFlags.NonPublic; + var propsField = typeof(TenantAggregate).GetField("_props", privateInstance); + var props = propsField?.GetValue(tenant) as TenantProps; + if (props is null) + { + return; + } + + var companyReferenceProperty = typeof(TenantProps).GetProperty(nameof(TenantProps.CompanyReference)); + companyReferenceProperty?.SetValue(props, companyReference); + } + private static IReadOnlyList BuildSeedDelegations(ActorId actor) { - var unimarTenantId = TenantId.Load(Guid.Parse("5f4e3d2c-1b0a-9f8e-7d6c-543210987654")); - var unimarBaseBytes = unimarTenantId.GetValue().ToByteArray(); - Guid DeriveUnimarGuid(byte index) + var beyondNetTenantId = TenantId.Load(Guid.Parse("5f4e3d2c-1b0a-9f8e-7d6c-543210987654")); + var beyondNetBaseBytes = beyondNetTenantId.GetValue().ToByteArray(); + Guid DeriveBeyondNetGuid(byte index) { - var bytes = (byte[])unimarBaseBytes.Clone(); + var bytes = (byte[])beyondNetBaseBytes.Clone(); bytes[0] = index; return new Guid(bytes); } - var adminId = UserAccountId.Load(DeriveUnimarGuid(1)); - var analystId = UserAccountId.Load(DeriveUnimarGuid(2)); - var partnerId = UserAccountId.Load(DeriveUnimarGuid(5)); + var adminId = UserAccountId.Load(DeriveBeyondNetGuid(1)); + var analystId = UserAccountId.Load(DeriveBeyondNetGuid(2)); + var partnerId = UserAccountId.Load(DeriveBeyondNetGuid(5)); // 1. Active Delegation var activeDel = UserManagementDelegationAggregate.Create( - unimarTenantId, adminId, analystId, DelegationScopeType.Tenant, null, + beyondNetTenantId, adminId, analystId, DelegationScopeType.Tenant, null, new[] { DelegatedAction.CreateUser, DelegatedAction.BlockUser }, DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(30), 90, false, actor).Value; activeDel.Activate(actor); // 2. Expired Delegation var expiredDel = UserManagementDelegationAggregate.Create( - unimarTenantId, adminId, partnerId, DelegationScopeType.Tenant, null, + beyondNetTenantId, adminId, partnerId, DelegationScopeType.Tenant, null, new[] { DelegatedAction.CreateUser }, DateTimeOffset.UtcNow.AddDays(-30), DateTimeOffset.UtcNow.AddDays(-1), 90, false, actor).Value; expiredDel.Activate(actor); @@ -440,7 +627,7 @@ Guid DeriveUnimarGuid(byte index) // 3. Revoked Delegation var revokedDel = UserManagementDelegationAggregate.Create( - unimarTenantId, analystId, adminId, DelegationScopeType.Tenant, null, + beyondNetTenantId, analystId, adminId, DelegationScopeType.Tenant, null, new[] { DelegatedAction.BlockUser }, DateTimeOffset.UtcNow.AddDays(-10), DateTimeOffset.UtcNow.AddDays(20), 90, false, actor).Value; revokedDel.Activate(actor); @@ -448,7 +635,7 @@ Guid DeriveUnimarGuid(byte index) // 4. Draft / Pending Approval var draftDel = UserManagementDelegationAggregate.Create( - unimarTenantId, partnerId, analystId, DelegationScopeType.Tenant, null, + beyondNetTenantId, partnerId, analystId, DelegationScopeType.Tenant, null, new[] { DelegatedAction.CreateUser }, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddDays(15), 90, true, actor).Value; // Not activated diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/IgaDevDataSeeder.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/IgaDevDataSeeder.cs new file mode 100644 index 00000000..e3548232 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/IgaDevDataSeeder.cs @@ -0,0 +1,128 @@ +namespace Ums.Infrastructure.Persistence.Seeders; + +using Microsoft.Extensions.DependencyInjection; +using Ums.Domain.Enums; +using Ums.Domain.IGA; +using Ums.Domain.Kernel.ValueObjects; +using Ums.Infrastructure.Persistence; +using RoleMaturityStatusAggregate = Ums.Domain.IGA.RoleMaturityStatus.RoleMaturityStatus; + +/// +/// Datos de desarrollo del contexto acotado IGA (ADR-UMS-093, G-052). Siembra estados de madurez +/// () para el inquilino RANSA, de modo que el happy-path de +/// promoción se pueda ejercer de punta a punta: la confirmación de elegibilidad es fail-closed +/// y, sin un RoleMaturityStatus sembrado, siempre RECHAZA (INV-RPR4). Aquí se siembra: +/// +/// • un estado ELEGIBLE para RansaAdminUserId en su rol actual (DemoAdminRoleId): +/// nivel Junior, ingreso al nivel > 2 años atrás (supera el mínimo de 6 meses del salto Junior), +/// desempeño 4.5 (≥ 3.0) y sin incidencias de cumplimiento ⇒ EvaluateEligibility aprueba; +/// +/// • un estado NO ELEGIBLE (borde) para RansaAnalystUserId en DemoOperatorRoleId: +/// ingreso reciente (10 días) ⇒ no supera el tiempo mínimo en nivel ⇒ RECHAZA, ejercitando el +/// corte fail-closed sin depender de la ausencia del dato. +/// +/// Respeta el acotamiento por inquilino (todo se siembra bajo RansaTenantId) y es idempotente +/// bajo PostgreSQL (comprueba existencia por usuario+rol antes de insertar). Sigue el patrón de +/// : usa la variante en memoria cuando está registrada (dev/tests) +/// y la variante PostgreSQL en el entorno desplegado. +/// +public static class IgaDevDataSeeder +{ + // Nivel de madurez inicial del estado ELEGIBLE (Junior exige 6 meses en nivel, INV-RMS3). + private const decimal EligiblePerformanceScore = 4.5m; + private const decimal BorderlinePerformanceScore = 3.5m; + + public static async Task SeedAsync(IServiceProvider serviceProvider, CancellationToken cancellationToken = default) + { + var maturityRepository = serviceProvider.GetService(); + var inMemoryMaturityRepository = serviceProvider.GetService(); + + var actor = ActorId.Create(CoreDevDataSeeder.SystemActorId); + var tenantId = TenantId.Load(Guid.Parse(CoreDevDataSeeder.RansaTenantId)); + var nowUtc = DateTime.UtcNow; + + // Estado ELEGIBLE: objetivo = gerente de operaciones RANSA en su rol actual ADMIN. + var eligible = BuildMaturityStatus( + tenantId, + userId: UserId.Load(Guid.Parse(CoreDevDataSeeder.RansaAdminUserId)), + roleId: RoleId.Load(Guid.Parse(CoreDevDataSeeder.DemoAdminRoleId)), + level: RoleMaturityLevel.Junior, + assignedAtUtc: nowUtc.AddYears(-2), // supera con holgura los 6 meses mínimos del salto Junior + performanceScore: EligiblePerformanceScore, + actor); + + // Estado NO ELEGIBLE (borde): objetivo = analista de inventario RANSA en rol OPERATOR, + // recién ingresado al nivel ⇒ no supera el tiempo mínimo ⇒ elegibilidad rechazada (fail-closed). + var borderline = BuildMaturityStatus( + tenantId, + userId: UserId.Load(Guid.Parse(CoreDevDataSeeder.RansaAnalystUserId)), + roleId: RoleId.Load(Guid.Parse(CoreDevDataSeeder.DemoOperatorRoleId)), + level: RoleMaturityLevel.Junior, + assignedAtUtc: nowUtc.AddDays(-10), // insuficiente tiempo en nivel + performanceScore: BorderlinePerformanceScore, + actor); + + var seedItems = new[] { eligible, borderline }.Where(x => x is not null).Select(x => x!).ToList(); + if (seedItems.Count == 0) + { + return; + } + + if (inMemoryMaturityRepository is not null) + { + foreach (var item in seedItems) + { + inMemoryMaturityRepository.Seed(item); + } + + return; + } + + if (maturityRepository is null) + { + return; + } + + // PostgreSQL: idempotente por usuario+rol (no recrea si ya existe). + var added = false; + foreach (var item in seedItems) + { + var existing = await maturityRepository.GetByUserAndRoleAsync( + item.TenantId.GetValue(), + item.UserId.GetValue(), + item.RoleId.GetValue(), + cancellationToken); + + if (existing is null) + { + await maturityRepository.AddAsync(item, cancellationToken); + added = true; + } + } + + if (added) + { + await maturityRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + } + } + + private static RoleMaturityStatusAggregate? BuildMaturityStatus( + TenantId tenantId, + UserId userId, + RoleId roleId, + RoleMaturityLevel level, + DateTime assignedAtUtc, + decimal performanceScore, + ActorId actor) + { + var result = RoleMaturityStatusAggregate.Create(tenantId, userId, roleId, level, assignedAtUtc, actor); + if (result.IsFailure) + { + return null; + } + + var status = result.Value; + status.UpdatePerformanceScore(performanceScore, actor); + return status; + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/ParameterCatalogSeeder.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/ParameterCatalogSeeder.cs index af63462c..96428890 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/ParameterCatalogSeeder.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/Seeders/ParameterCatalogSeeder.cs @@ -20,17 +20,30 @@ public static class ParameterCatalogSeeder Guid.Parse("F3E2D1C0-B9A8-7F6E-5D4C-321098765432"), ]; + // FS-25 §4.5 — parámetros de operación impo/expo acotados al tenant BEYONDNET. + private static readonly Guid BeyondNetTenantId = Guid.Parse(CoreDevDataSeeder.BeyondNetTenantId); + private const string IncotermCode = "INCOTERM"; + private const string MonedaCode = "MONEDA"; + private const string TipoDuaCode = "TIPO_DUA"; + public static async Task SeedAsync(IServiceProvider serviceProvider, CancellationToken cancellationToken = default) { var dbContext = serviceProvider.GetRequiredService(); await SeedDefinitionsAsync(dbContext, cancellationToken); await SeedGlobalValuesAsync(dbContext, cancellationToken); await SeedTenantValuesAsync(dbContext, cancellationToken); + await SeedBeyondNetOperationValuesAsync(dbContext, cancellationToken); } private static async Task SeedDefinitionsAsync(UmsPlatformDbContext dbContext, CancellationToken cancellationToken = default) { + // IgnoreQueryFilters, y ahora por una razón distinta a la original: el índice único de `Code` + // pasó a ser parcial, así que reinsertar el código de una definición eliminada YA NO chocaría + // contra el índice. Se mantiene mirando también las lápidas para no RESUCITAR lo que alguien + // retiró a propósito: el sembrado garantiza que el catálogo base existe, no que reaparezca + // cada arranque lo que se decidió quitar. Volver a declararla es un acto deliberado del alta. var existingCodes = (await dbContext.ParameterDefinitions + .IgnoreQueryFilters() .Select(definition => definition.Code) .ToListAsync(cancellationToken)) .ToHashSet(StringComparer.OrdinalIgnoreCase); @@ -50,6 +63,8 @@ private static async Task SeedDefinitionsAsync(UmsPlatformDbContext dbContext, C public static async Task SeedGlobalValuesAsync(UmsPlatformDbContext dbContext, CancellationToken cancellationToken = default) { + // Cuenta también los valores eliminados lógicamente, a propósito: liberar la ranura permite + // volver a fijar el valor global, pero eso es un acto del operador, no del sembrado. var existingDefinitionIds = await dbContext.ParameterGlobalValues .Select(value => value.ParameterDefinitionId) .ToHashSetAsync(cancellationToken); @@ -127,6 +142,67 @@ public static async Task SeedTenantValuesAsync(UmsPlatformDbContext dbContext, C await dbContext.SaveChangesAsync(cancellationToken); } + // FS-25 §4.5 / criterio 11: valores de operación impo/expo acotados al tenant BEYONDNET. + private static async Task SeedBeyondNetOperationValuesAsync(UmsPlatformDbContext dbContext, CancellationToken cancellationToken = default) + { + var definitionsByCode = (await dbContext.ParameterDefinitions.ToListAsync(cancellationToken)) + .ToDictionary(definition => definition.Code, StringComparer.OrdinalIgnoreCase); + + var existingDefinitionIds = (await dbContext.ParameterTenantValues + .Where(value => value.TenantId == BeyondNetTenantId) + .Select(value => value.ParameterDefinitionId) + .ToListAsync(cancellationToken)) + .ToHashSet(); + + var now = DateTime.UtcNow; + var systemActorId = "SYSTEM"; + var tenantValues = new List(); + + foreach (var seedValue in BuildBeyondNetOperationValues()) + { + if (!definitionsByCode.TryGetValue(seedValue.Code, out var definition)) + { + continue; + } + + if (existingDefinitionIds.Contains(definition.Id)) + { + continue; + } + + tenantValues.Add(new ParameterTenantValueRecord + { + Id = Guid.NewGuid(), + TenantId = BeyondNetTenantId, + ParameterDefinitionId = definition.Id, + OverrideValue = seedValue.Value, + StatusId = 2, + Version = "1.0.0", + CreatedBy = systemActorId, + CreatedAtUtc = now, + AuditTimeSpan = now.ToString("O") + }); + } + + if (tenantValues.Count == 0) + { + return; + } + + await dbContext.ParameterTenantValues.AddRangeAsync(tenantValues, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + } + + private static IReadOnlyList<(string Code, string Value)> BuildBeyondNetOperationValues() + { + return + [ + (IncotermCode, "FOB,CIF,EXW,FCA,CFR,CPT"), + (MonedaCode, "USD,PEN"), + (TipoDuaCode, "Importación Definitiva,Exportación Definitiva,Admisión Temporal"), + ]; + } + private static IReadOnlyList BuildDefinitions() { var now = DateTime.UtcNow; @@ -169,6 +245,24 @@ private static IReadOnlyList BuildDefinitions() AuditTimeSpan = now.ToString("O") }, new ParameterDefinitionRecord + { + // ADR-UMS-095: duración del bloqueo temporal por intentos fallidos (junto a MAX_LOGIN_ATTEMPTS). + Id = Guid.Parse("11111111-1111-1111-1111-111111111117"), + Code = AppConfigurationCodes.AccountLockoutDurationMinutes, + Name = "Account Lockout Duration Minutes", + Description = "Temporary account lockout duration in minutes after reaching the max login attempts", + DataTypeId = 2, + DefaultValue = AppConfigurationDefaults.AccountLockoutDurationMinutes.ToString(), + ScopeId = 3, + IsActive = true, + IsMandatory = false, + DisplayOrder = 15, + Version = "1.0.0", + CreatedBy = systemActorId, + CreatedAtUtc = now, + AuditTimeSpan = now.ToString("O") + }, + new ParameterDefinitionRecord { Id = Guid.Parse("11111111-1111-1111-1111-111111111103"), Code = AppConfigurationCodes.AccessTokenDurationMs, @@ -275,7 +369,7 @@ private static IReadOnlyList BuildDefinitions() Id = Guid.Parse("11111111-1111-1111-1111-111111111108"), Code = AppConfigurationCodes.FrontendConfigTransport, Name = "Frontend Config Transport", - Description = "Transport mode for frontend config: graphql or rest", + Description = "Transport mode for frontend config queries (REST only)", DataTypeId = 1, DefaultValue = AppConfigurationDefaults.FrontendConfigTransport, ScopeId = 1, @@ -320,6 +414,57 @@ private static IReadOnlyList BuildDefinitions() CreatedBy = systemActorId, CreatedAtUtc = now, AuditTimeSpan = now.ToString("O") + }, + new ParameterDefinitionRecord + { + Id = Guid.Parse("11111111-1111-1111-1111-111111111120"), + Code = IncotermCode, + Name = "Incoterm", + Description = "Términos de comercio internacional (Incoterms) aplicables a las operaciones de impo/expo", + DataTypeId = 1, + DefaultValue = "FOB,CIF,EXW,FCA,CFR,CPT", + ScopeId = 2, + IsActive = true, + IsMandatory = false, + DisplayOrder = 12, + Version = "1.0.0", + CreatedBy = systemActorId, + CreatedAtUtc = now, + AuditTimeSpan = now.ToString("O") + }, + new ParameterDefinitionRecord + { + Id = Guid.Parse("11111111-1111-1111-1111-111111111121"), + Code = MonedaCode, + Name = "Moneda", + Description = "Monedas admitidas en las operaciones de impo/expo", + DataTypeId = 1, + DefaultValue = "USD,PEN", + ScopeId = 2, + IsActive = true, + IsMandatory = false, + DisplayOrder = 13, + Version = "1.0.0", + CreatedBy = systemActorId, + CreatedAtUtc = now, + AuditTimeSpan = now.ToString("O") + }, + new ParameterDefinitionRecord + { + Id = Guid.Parse("11111111-1111-1111-1111-111111111122"), + Code = TipoDuaCode, + Name = "Tipo de DUA", + Description = "Tipos de declaración aduanera (DUA) para las operaciones de impo/expo", + DataTypeId = 1, + DefaultValue = "Importación Definitiva,Exportación Definitiva,Admisión Temporal", + ScopeId = 2, + IsActive = true, + IsMandatory = false, + DisplayOrder = 14, + Version = "1.0.0", + CreatedBy = systemActorId, + CreatedAtUtc = now, + AuditTimeSpan = now.ToString("O") } ]; } diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/SqliteSchemaBootstrapper.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/SqliteSchemaBootstrapper.cs deleted file mode 100644 index ae5f5032..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/SqliteSchemaBootstrapper.cs +++ /dev/null @@ -1,118 +0,0 @@ -using Microsoft.EntityFrameworkCore; - -namespace Ums.Infrastructure.Persistence; - -using Ums.Infrastructure.Persistence.Seeders; - -public static class SqliteSchemaBootstrapper -{ - public static async Task InitializeAsync(UmsPlatformDbContext dbContext, CancellationToken cancellationToken = default) - { - await dbContext.Database.EnsureCreatedAsync(cancellationToken); - await EnsureTenantManagementOwnerColumnAsync(dbContext, cancellationToken); - await EnsureInternalAdminTenantManagementOwnerAsync(dbContext, cancellationToken); - - await dbContext.Database.ExecuteSqlRawAsync( - """ - CREATE TABLE IF NOT EXISTS "Roles" ( - "Id" TEXT NOT NULL CONSTRAINT "PK_Roles" PRIMARY KEY, - "TenantId" TEXT NOT NULL, - "SystemSuiteId" TEXT NOT NULL, - "ParentRoleId" TEXT NULL, - "Code" TEXT NOT NULL, - "Value" TEXT NOT NULL, - "Description" TEXT NOT NULL, - "HierarchyLevel" INTEGER NOT NULL, - "PromotionOrder" INTEGER NOT NULL, - "IsActive" INTEGER NOT NULL, - "CreatedBy" TEXT NOT NULL, - "CreatedAtUtc" TEXT NOT NULL, - "UpdatedBy" TEXT NULL, - "UpdatedAtUtc" TEXT NULL, - "AuditTimeSpan" TEXT NOT NULL, - "RowVersion" BLOB NOT NULL, - CONSTRAINT "FK_Roles_SystemSuites_SystemSuiteId" - FOREIGN KEY ("SystemSuiteId") REFERENCES "SystemSuites" ("Id") ON DELETE RESTRICT, - CONSTRAINT "FK_Roles_Roles_ParentRoleId" - FOREIGN KEY ("ParentRoleId") REFERENCES "Roles" ("Id") ON DELETE RESTRICT - ); - """, - cancellationToken); - - await dbContext.Database.ExecuteSqlRawAsync( - """CREATE INDEX IF NOT EXISTS "IX_Roles_TenantId" ON "Roles" ("TenantId");""", - cancellationToken); - await dbContext.Database.ExecuteSqlRawAsync( - """CREATE INDEX IF NOT EXISTS "IX_Roles_ParentRoleId" ON "Roles" ("ParentRoleId");""", - cancellationToken); - await dbContext.Database.ExecuteSqlRawAsync( - """CREATE INDEX IF NOT EXISTS "IX_Roles_SystemSuiteId" ON "Roles" ("SystemSuiteId");""", - cancellationToken); - await dbContext.Database.ExecuteSqlRawAsync( - """CREATE UNIQUE INDEX IF NOT EXISTS "IX_Roles_SystemSuiteId_Code" ON "Roles" ("SystemSuiteId", "Code");""", - cancellationToken); - } - - private static async Task EnsureTenantManagementOwnerColumnAsync( - UmsPlatformDbContext dbContext, - CancellationToken cancellationToken) - { - var connection = dbContext.Database.GetDbConnection(); - var shouldClose = connection.State != System.Data.ConnectionState.Open; - - if (shouldClose) - { - await connection.OpenAsync(cancellationToken); - } - - try - { - await using var command = connection.CreateCommand(); - command.CommandText = """ - SELECT 1 - FROM pragma_table_info('Tenants') - WHERE name = 'IsManagementOwner' - LIMIT 1; - """; - - var exists = await command.ExecuteScalarAsync(cancellationToken); - if (exists is not null) - { - return; - } - - await dbContext.Database.ExecuteSqlRawAsync( - """ - ALTER TABLE "Tenants" - ADD COLUMN "IsManagementOwner" INTEGER NOT NULL DEFAULT 0; - """, - cancellationToken); - } - finally - { - if (shouldClose) - { - await connection.CloseAsync(); - } - } - } - - private static async Task EnsureInternalAdminTenantManagementOwnerAsync( - UmsPlatformDbContext dbContext, - CancellationToken cancellationToken) - { - if (!dbContext.Database.IsSqlite()) - { - return; - } - - await dbContext.Database.ExecuteSqlRawAsync( - $""" - UPDATE "Tenants" - SET "IsManagementOwner" = 1 - WHERE upper("Code") = '{CoreDevDataSeeder.InternalAdminTenantCode}' - AND COALESCE("IsManagementOwner", 0) = 0; - """, - cancellationToken); - } -} diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/UmsPlatformDbContext.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/UmsPlatformDbContext.cs index c5fefa88..d25d3427 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/UmsPlatformDbContext.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/UmsPlatformDbContext.cs @@ -1,5 +1,7 @@ using Microsoft.EntityFrameworkCore; using MassTransit; +using MediatR; +using Microsoft.Extensions.Logging; using Ums.Infrastructure.Persistence.Audit.Configurations; using Ums.Infrastructure.Persistence.Audit.Entities; using Ums.Infrastructure.Persistence.Authorization.Configurations; @@ -12,6 +14,9 @@ using Ums.Infrastructure.Persistence.Approvals.Configurations; using Ums.Infrastructure.Persistence.Approvals.Entities; +using Ums.Infrastructure.Persistence.Iga.Configurations; +using Ums.Infrastructure.Persistence.Iga.Entities; + namespace Ums.Infrastructure.Persistence; /// @@ -28,31 +33,115 @@ namespace Ums.Infrastructure.Persistence; /// - OrganizationId.HasValue → strict per-tenant rows only. /// - is nullable-TenantId → also includes global /// records (TenantId IS NULL) so system-level config is always visible. -/// -/// The SQL Server RLS predicates set via -/// remain as the database-level failsafe. +/// +/// Tenant isolation is enforced entirely by these application-layer query filters under the +/// PostgreSQL-only persistence model. /// public sealed class UmsPlatformDbContext( DbContextOptions options, ITenantContext tenantContext, - IPublishEndpoint publishEndpoint) : DbContext(options) + IPublisher domainEventDispatcher, + ILogger logger) : DbContext(options) { public const string DefaultSchema = "ums_platform"; - public IPublishEndpoint PublishEndpoint { get; } = publishEndpoint; + // G-246: identificador del estado terminal `SystemStatus.Deleted`, para el filtro global que + // oculta los sistemas eliminados lógicamente. Se lee del propio enumerado del dominio —no se + // escribe el 4 a mano— para que no puedan divergir; se materializa en un campo porque la + // expresión del filtro debe quedar traducible a SQL, no invocar una cadena de propiedades. + private static readonly int DeletedSystemSuiteStatusId = Ums.Domain.Enums.SystemStatus.Deleted.Id; + + /// Nombre del filtro que oculta los sistemas eliminados lógicamente (G-246). + public const string SystemSuiteSoftDeleteFilter = "SystemSuiteSoftDelete"; + + /// Nombre del filtro de aislamiento por inquilino de los sistemas. + public const string SystemSuiteTenantFilter = "SystemSuiteTenant"; + + // G-066 / ADR-0098 D4/D7 + KB-TXN-001: los eventos de DOMINIO se manejan EN PROCESO. Se + // recolectan aquí durante SaveEntitiesAsync y se despachan por MediatR DESPUÉS del commit del + // agregado (en el override de SaveChangesAsync). NO se publican crudos al bróker inter-sistema + // (D7/D9.6): eso convertiría el modelo interno en contrato de transporte. Solo los eventos de + // INTEGRACIÓN explícitos (IIntegrationEvent, contrato aparte) salen al bróker por el outbox + // (IIntegrationEventPublisher). + private readonly List _pendingDomainEvents = []; + private bool _isDispatchingDomainEvents; + + /// + /// Recolecta los eventos de dominio no confirmados de un agregado para despacharlos EN PROCESO + /// tras el commit (ADR-0098 D4.2). No publica al bróker (D7/D9.6). Los repositorios llaman a + /// este método ANTES de ; el despacho + /// real ocurre en el override de SaveChangesAsync, una vez confirmada la transacción del + /// agregado. + /// + public Task PublishDomainEventsAsync(IEnumerable domainEvents, CancellationToken cancellationToken = default) + { + _pendingDomainEvents.AddRange(domainEvents); + return Task.CompletedTask; + } + + public override async Task SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default) + { + var result = await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken).ConfigureAwait(false); + await DispatchDomainEventsAsync(cancellationToken).ConfigureAwait(false); + return result; + } + + public override int SaveChanges(bool acceptAllChangesOnSuccess) + { + var result = base.SaveChanges(acceptAllChangesOnSuccess); + DispatchDomainEventsAsync(CancellationToken.None).GetAwaiter().GetResult(); + return result; + } - public async Task PublishDomainEventsAsync(IEnumerable domainEvents, CancellationToken cancellationToken = default) + /// + /// Despacha en proceso, tras el commit, los eventos de dominio recolectados (ADR-0098 D4). Un + /// fallo de un manejador en proceso NO revierte el agregado ya confirmado ni tumba el caso de + /// uso: es un handoff post-commit reconstruible/best-effort (D4/D6), así que se registra como + /// advertencia. Los efectos durables e irreparables (auditoría, no-repudiación) NO viajan por + /// esta vía: usan el outbox (G-040). + /// + /// TODO(D-016): para los casos de uso multi-agregado con [TransactionAspect] (excepciones de + /// consistencia inmediata gobernadas por D-016), este despacho ocurre en el SaveChanges interno, + /// antes del commit de la transacción externa. Revisar al separar esos agregados (ADR-0098 D2). + /// + private async Task DispatchDomainEventsAsync(CancellationToken cancellationToken) { - foreach (var domainEvent in domainEvents) + // Guarda de reentrada: un manejador en proceso puede provocar otro SaveChanges sobre este + // mismo contexto; no re-despachar dentro de un despacho en curso. + if (_isDispatchingDomainEvents || _pendingDomainEvents.Count == 0) + return; + + _isDispatchingDomainEvents = true; + try { - await PublishEndpoint.Publish(domainEvent, domainEvent.GetType(), cancellationToken); + var toDispatch = _pendingDomainEvents.ToArray(); + _pendingDomainEvents.Clear(); + + foreach (var domainEvent in toDispatch) + { + try + { + await domainEventDispatcher.Publish(domainEvent, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + logger.LogWarning(ex, + "Handoff en proceso fallido para el evento de dominio {DomainEvent} (post-commit, ADR-0098 D4). El agregado ya está confirmado; el efecto es reconstruible.", + domainEvent.GetType().Name); + } + } + } + finally + { + _isDispatchingDomainEvents = false; } } public DbSet Tenants => Set(); public DbSet TenantBranches => Set(); + /// ADR-0164: bitácora de episodios de sucursal (apertura, baja, reapertura, cierre). + public DbSet TenantBranchLifecycleEntries => Set(); public DbSet TenantIdentityProviders => Set(); - public DbSet TenantBrandings => Set(); public DbSet TenantParameters => Set(); public DbSet TenantSignupRequests => Set(); public DbSet UserAccounts => Set(); @@ -62,9 +151,8 @@ public async Task PublishDomainEventsAsync(IEnumerable ProfilePermissions => Set(); public DbSet SystemSuites => Set(); public DbSet SystemSuiteModules => Set(); - public DbSet SystemSuiteMenus => Set(); - public DbSet SystemSuiteSubMenus => Set(); - public DbSet SystemSuiteOptions => Set(); + public DbSet SystemSuiteNodes => Set(); + public DbSet SystemSuiteNodeActions => Set(); public DbSet SystemSuiteAppSettings => Set(); public DbSet SystemSuiteActions => Set(); public DbSet SystemSuiteDomainResources => Set(); @@ -90,6 +178,10 @@ public async Task PublishDomainEventsAsync(IEnumerable UserDocuments => Set(); public DbSet UserDocumentNotifications => Set(); public DbSet AccessEnforcementPolicies => Set(); + public DbSet RefreshTokens => Set(); + public DbSet PasswordResetTokens => Set(); + public DbSet RoleMaturityStatuses => Set(); + public DbSet RolePromotionRequests => Set(); protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { @@ -109,20 +201,21 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.ApplyConfiguration(new TenantRecordConfiguration()); modelBuilder.ApplyConfiguration(new TenantBranchRecordConfiguration()); + modelBuilder.ApplyConfiguration(new TenantBranchLifecycleEntryRecordConfiguration()); modelBuilder.ApplyConfiguration(new TenantIdentityProviderRecordConfiguration()); - modelBuilder.ApplyConfiguration(new TenantBrandingRecordConfiguration()); modelBuilder.ApplyConfiguration(new TenantParameterRecordConfiguration()); modelBuilder.ApplyConfiguration(new TenantSignupRequestRecordConfiguration()); modelBuilder.ApplyConfiguration(new UserAccountRecordConfiguration()); modelBuilder.ApplyConfiguration(new UserAccountMfaEnrollmentRecordConfiguration()); modelBuilder.ApplyConfiguration(new UserAccountPasswordCredentialRecordConfiguration()); + modelBuilder.ApplyConfiguration(new RefreshTokenRecordConfiguration()); + modelBuilder.ApplyConfiguration(new PasswordResetTokenRecordConfiguration()); modelBuilder.ApplyConfiguration(new ProfileRecordConfiguration()); modelBuilder.ApplyConfiguration(new ProfilePermissionRecordConfiguration()); modelBuilder.ApplyConfiguration(new SystemSuiteRecordConfiguration()); modelBuilder.ApplyConfiguration(new SystemSuiteModuleRecordConfiguration()); - modelBuilder.ApplyConfiguration(new SystemSuiteMenuRecordConfiguration()); - modelBuilder.ApplyConfiguration(new SystemSuiteSubMenuRecordConfiguration()); - modelBuilder.ApplyConfiguration(new SystemSuiteOptionRecordConfiguration()); + modelBuilder.ApplyConfiguration(new SystemSuiteNodeRecordConfiguration()); + modelBuilder.ApplyConfiguration(new SystemSuiteNodeActionRecordConfiguration()); modelBuilder.ApplyConfiguration(new SystemSuiteAppSettingRecordConfiguration()); modelBuilder.ApplyConfiguration(new SystemSuiteActionRecordConfiguration()); modelBuilder.ApplyConfiguration(new SystemSuiteDomainResourceRecordConfiguration()); @@ -147,6 +240,16 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.ApplyConfiguration(new UserDocumentRecordConfiguration()); modelBuilder.ApplyConfiguration(new AccessNotificationRecordConfiguration()); modelBuilder.ApplyConfiguration(new AccessEnforcementPolicyRecordConfiguration()); + modelBuilder.ApplyConfiguration(new RoleMaturityStatusRecordConfiguration()); + modelBuilder.ApplyConfiguration(new RolePromotionRequestRecordConfiguration()); + + // G-066 / ADR-0098 D4: MassTransit transactional bus-outbox for the WRITE context. + // Domain events are staged into the outbox tables and committed atomically with the + // aggregate change set; delivery to the broker happens AFTER commit (no «phantom + // messages»). Mirrors the pattern already used by TenantProjectionDbContext. + modelBuilder.AddInboxStateEntity(); + modelBuilder.AddOutboxMessageEntity(); + modelBuilder.AddOutboxStateEntity(); // ------------------------------------------------------------------------- // FIX-05: Global query filters — primary tenant isolation mechanism. // @@ -161,20 +264,30 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity() .HasQueryFilter(x => !x.IsDeleted); + // ADR-0164: la sucursal CERRADA no lleva filtro global de ocultación, y es deliberado. La + // resolución por id tiene que seguir devolviéndola —el perfil de un usuario apunta a la + // sucursal desde la que operó, y el grafo de autorización la resuelve por ese id— y la vía + // de escritura necesita verla para que su código siga ocupado (§2.3). Quien LISTA para un + // humano filtra en su consulta; ver `GetBranchesByTenantIdQueryHandler`. Un filtro global + // aquí habría convertido el borrado lógico en una desaparición igual de opaca que el físico. modelBuilder.Entity() .HasQueryFilter(x => !tenantContext.OrganizationId.HasValue || x.TenantId == tenantContext.OrganizationId); - modelBuilder.Entity() + modelBuilder.Entity() .HasQueryFilter(x => !tenantContext.OrganizationId.HasValue || x.TenantId == tenantContext.OrganizationId); + // El borrado de parámetros es LÓGICO: el filtro de soft-delete se combina con el de inquilino + // para que ninguna lectura —repositorio, provider o resolución de configuración— vea una fila + // eliminada. Quien necesite el histórico debe pedirlo explícitamente con IgnoreQueryFilters(). modelBuilder.Entity() .HasQueryFilter(x => - !tenantContext.OrganizationId.HasValue || - x.TenantId == tenantContext.OrganizationId); + !x.IsDeleted && + (!tenantContext.OrganizationId.HasValue || + x.TenantId == tenantContext.OrganizationId)); modelBuilder.Entity() .HasQueryFilter(x => @@ -197,8 +310,21 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) !tenantContext.OrganizationId.HasValue || x.TenantId == tenantContext.OrganizationId); + // G-246: un sistema eliminado lógicamente (StatusId = SystemStatus.Deleted) sigue en la tabla + // —el catálogo se consulta sobre datos antiguos— pero no debe aparecer en ninguna lectura: el + // GET devuelve 404 y el listado no lo trae. El filtro va aquí, y no repartido por consulta, + // para que ninguna consulta futura pueda olvidarse de él; misma decisión que ya se tomó con + // `UserAccountRecord.IsDeleted`. La escritura no se ve afectada: los filtros solo aplican a + // consultas, y el UPDATE que marca el borrado sale de la entidad ya rastreada. + // + // Van como filtros CON NOMBRE (EF Core 10) y no como una sola expresión porque hay una + // consulta —y solo una— que necesita apagar el de borrado sin apagar el de inquilino: la + // comprobación de código duplicado del alta, que debe ver también las lápidas porque el + // índice único de la base las sigue viendo. Con un filtro único, `IgnoreQueryFilters()` los + // apagaría los dos y abriría el catálogo de todos los inquilinos. modelBuilder.Entity() - .HasQueryFilter(x => + .HasQueryFilter(SystemSuiteSoftDeleteFilter, x => x.StatusId != DeletedSystemSuiteStatusId) + .HasQueryFilter(SystemSuiteTenantFilter, x => !tenantContext.OrganizationId.HasValue || x.TenantId == tenantContext.OrganizationId); @@ -226,6 +352,14 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) x.TenantId == null || x.TenantId == tenantContext.OrganizationId); + // ParameterDefinition: catálogo global (no acotado por inquilino) con borrado LÓGICO. + // El filtro es el único punto donde se garantiza que una definición eliminada desaparece de + // TODAS las lecturas —incluidos los endpoints de consulta que van directos al DbContext—. + // Los caminos de escritura que necesitan ver la fila física (unicidad de `Code`) usan + // IgnoreQueryFilters() de forma explícita. + modelBuilder.Entity() + .HasQueryFilter(x => !x.IsDeleted); + // ParameterTenantValue: tenant-specific parameter overrides modelBuilder.Entity() .HasQueryFilter(x => @@ -254,20 +388,17 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) !tenantContext.OrganizationId.HasValue || x.TenantId == tenantContext.OrganizationId); - if (Database.IsSqlite()) - { - foreach (var entityType in modelBuilder.Model.GetEntityTypes()) - { - var rowVersionProperty = entityType.FindProperty("RowVersion"); - if (rowVersionProperty != null && rowVersionProperty.ClrType == typeof(byte[])) - { - rowVersionProperty.ValueGenerated = Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.Never; - rowVersionProperty.IsConcurrencyToken = false; - rowVersionProperty.SetBeforeSaveBehavior(Microsoft.EntityFrameworkCore.Metadata.PropertySaveBehavior.Save); - rowVersionProperty.SetAfterSaveBehavior(Microsoft.EntityFrameworkCore.Metadata.PropertySaveBehavior.Save); - } - } - } + // IGA (ADR-UMS-093): tablas acotadas por inquilino — la elegibilidad y las promociones nunca + // cruzan fronteras de inquilino. + modelBuilder.Entity() + .HasQueryFilter(x => + !tenantContext.OrganizationId.HasValue || + x.TenantId == tenantContext.OrganizationId); + + modelBuilder.Entity() + .HasQueryFilter(x => + !tenantContext.OrganizationId.HasValue || + x.TenantId == tenantContext.OrganizationId); if (Database.IsNpgsql()) { @@ -290,7 +421,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) } // ADR-0076 D1: Force DateTimeKind.Utc on all DateTime properties read from the - // database. EF Core / SQLite does not preserve timezone info, so values are stored + // database. EF Core does not preserve timezone info, so values are stored // as ISO-8601 strings. Without this converter, DateTime.Kind == Unspecified after // materialisation, which breaks serialisation and comparisons with DateTime.UtcNow. var utcConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter( diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/UmsPlatformDbContextFactory.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/UmsPlatformDbContextFactory.cs index 54e9bcfd..26f5f4f9 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/UmsPlatformDbContextFactory.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/UmsPlatformDbContextFactory.cs @@ -1,7 +1,9 @@ +#pragma warning disable S1144 using System; -using MassTransit; +using MediatR; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; +using Microsoft.Extensions.Logging.Abstractions; using BeyondNetCode.Shell.Ddd.Interfaces; using Ums.Domain.Identity; @@ -12,14 +14,24 @@ public class UmsPlatformDbContextFactory : IDesignTimeDbContextFactory(); - + // This is strictly used for EF Core tool generation at design time. +#pragma warning disable S2068 // Cadena de conexión de DISEÑO (EF Core tooling), solo local; el runtime resuelve por configuración. No es un secreto de producción. optionsBuilder.UseNpgsql("Host=localhost;Database=ums_test;Username=postgres;Password=postgres"); +#pragma warning restore S2068 - return new UmsPlatformDbContext(optionsBuilder.Options, new DesignTimeTenantContext(), new DesignTimePublishEndpoint()); + return new UmsPlatformDbContext( + optionsBuilder.Options, + new DesignTimeTenantContext(), + new DesignTimePublisher(), + NullLogger.Instance); } - private class DesignTimeTenantContext : ITenantContext + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Minor Code Smell", "S2325:Methods and properties that don't access instance data should be static", + Justification = "Stub de solo diseño (EF Core tooling). Sus miembros replican la forma de " + + "ITenantContext y deben permanecer de instancia para sustituir al contrato en runtime.")] + private sealed class DesignTimeTenantContext : ITenantContext { public Guid? OrganizationId => null; public Guid? UserId => null; @@ -32,25 +44,18 @@ private class DesignTimeTenantContext : ITenantContext public Guid? OriginalTenantId => null; public bool IsInternalAdmin => false; - public void SetOrganizationId(Guid tenantId) { } + public void SetOrganizationId(Guid organizationId) { } public void EnableCrossTenantAccess() { } public void DisableCrossTenantAccess() { } - public void Initialize(Guid tenantId, bool isInternalAdmin) { } + public void Initialize(Guid userTenantId, bool isInternalAdmin) { } } - private class DesignTimePublishEndpoint : IPublishEndpoint + private sealed class DesignTimePublisher : IPublisher { - public Task Publish(T message, CancellationToken cancellationToken = default) where T : class => Task.CompletedTask; - public Task Publish(T message, IPipe> publishPipe, CancellationToken cancellationToken = default) where T : class => Task.CompletedTask; - public Task Publish(T message, IPipe publishPipe, CancellationToken cancellationToken = default) where T : class => Task.CompletedTask; - public Task Publish(object message, CancellationToken cancellationToken = default) => Task.CompletedTask; - public Task Publish(object message, Type messageType, CancellationToken cancellationToken = default) => Task.CompletedTask; - public Task Publish(object message, IPipe publishPipe, CancellationToken cancellationToken = default) => Task.CompletedTask; - public Task Publish(object message, Type messageType, IPipe publishPipe, CancellationToken cancellationToken = default) => Task.CompletedTask; - public Task Publish(object values, CancellationToken cancellationToken = default) where T : class => Task.CompletedTask; - public Task Publish(object values, IPipe> publishPipe, CancellationToken cancellationToken = default) where T : class => Task.CompletedTask; - public Task Publish(object values, IPipe publishPipe, CancellationToken cancellationToken = default) where T : class => Task.CompletedTask; - - public ConnectHandle ConnectPublishObserver(IPublishObserver observer) => throw new NotImplementedException(); + public Task Publish(object notification, CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task Publish(TNotification notification, CancellationToken cancellationToken = default) + where TNotification : INotification => Task.CompletedTask; } } + +#pragma warning restore S1144 diff --git a/src/apps/ums.api/Ums.Infrastructure/Persistence/UnitOfWorkScope.cs b/src/apps/ums.api/Ums.Infrastructure/Persistence/UnitOfWorkScope.cs index 12498b3e..05ba3222 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Persistence/UnitOfWorkScope.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Persistence/UnitOfWorkScope.cs @@ -15,6 +15,20 @@ public async Task BeginAsync(CancellationToken cancellationTo return new EfTransactionScope(transaction); } + // G-117: begin+commit dentro de la ExecutionStrategy del proveedor. Con EnableRetryOnFailure una + // transacción iniciada a mano rompe ('does not support user-initiated transactions'); aquí la + // estrategia posee el ciclo y reintenta todo el bloque (rollback previo) ante fallos transitorios. + public async Task ExecuteInTransactionAsync(Func operation, CancellationToken cancellationToken = default) + { + var strategy = dbContext.Database.CreateExecutionStrategy(); + await strategy.ExecuteAsync(async () => + { + await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); + await operation(cancellationToken); + await transaction.CommitAsync(cancellationToken); + }); + } + private sealed class EfTransactionScope(IDbContextTransaction transaction) : ITransactionScope { private bool _completed; @@ -52,6 +66,10 @@ public sealed class NoOpUnitOfWorkScope : IUnitOfWorkScope public Task BeginAsync(CancellationToken cancellationToken = default) => Task.FromResult(new NoOpTransactionScope()); + // InMemory: sin transacciones ni estrategia de reintentos; solo ejecuta la operación. + public Task ExecuteInTransactionAsync(Func operation, CancellationToken cancellationToken = default) + => operation(cancellationToken); + private sealed class NoOpTransactionScope : ITransactionScope { public Task CommitAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; diff --git a/src/apps/ums.api/Ums.Infrastructure/Services/LimitadorDePeticionesDistribuido.cs b/src/apps/ums.api/Ums.Infrastructure/Services/LimitadorDePeticionesDistribuido.cs new file mode 100644 index 00000000..104b2cec --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Services/LimitadorDePeticionesDistribuido.cs @@ -0,0 +1,64 @@ +using StackExchange.Redis; + +namespace Ums.Infrastructure.Services; + +/// +/// G-248 — el cupo se cuenta en Redis, así que lo comparten todas las réplicas. +/// +/// El contador es un INCR sobre una clave que incluye el número de ventana, de modo +/// que cambiar de ventana es empezar a escribir en otra clave: no hace falta reiniciar nada ni +/// borrar nada, y dos réplicas que sirvan la misma petición coinciden en qué ventana es sin +/// hablarse. La clave anterior caduca sola. +/// +/// INCR primero y EXPIRE después, solo cuando el contador vale 1: es la +/// secuencia estándar, y su único riesgo —que el proceso muera entre las dos y deje una clave +/// eterna— está acotado porque la clave lleva el número de ventana y nunca se vuelve a usar. Aun +/// así se pide el vencimiento en el mismo viaje (batch), que reduce la ventana a prácticamente +/// nada. +/// +/// Si Redis no responde, la petición se PERMITE. Es una decisión deliberada: un limitador es +/// una protección contra abuso, no un control de acceso, y convertir una caída de Redis en una +/// caída de UMS cambiaría un problema de capacidad por uno de disponibilidad. Queda registrado en +/// el log para que la degradación no sea invisible. +/// +public sealed class LimitadorDePeticionesDistribuido( + IConnectionMultiplexer redis, + ILogger logger) : ILimitadorDePeticiones +{ + public async Task RegistrarAsync( + string clave, int cupo, TimeSpan ventana, CancellationToken ct = default) + { + var ahora = DateTimeOffset.UtcNow; + var ticksDeVentana = Math.Max(1, ventana.Ticks); + var numeroDeVentana = ahora.UtcTicks / ticksDeVentana; + var restante = TimeSpan.FromTicks(((numeroDeVentana + 1) * ticksDeVentana) - ahora.UtcTicks); + + RedisKey redisKey = $"limite:{clave}:{numeroDeVentana}"; + + try + { + var db = redis.GetDatabase(); + + // Un solo viaje: incrementar y, si es la primera de la ventana, fijarle el vencimiento. + var batch = db.CreateBatch(); + var incremento = batch.StringIncrementAsync(redisKey); + // El margen extra evita que la clave caduque justo antes de que termine su ventana por + // una diferencia de reloj entre el proceso y Redis. + var vencimiento = batch.KeyExpireAsync(redisKey, ventana + TimeSpan.FromSeconds(5), ExpireWhen.HasNoExpiry); + batch.Execute(); + + var consumidas = await incremento.ConfigureAwait(false); + await vencimiento.ConfigureAwait(false); + + return new ResultadoDelLimite(consumidas <= cupo, consumidas, restante); + } + catch (RedisException ex) + { + logger.LogWarning(ex, + "El límite de peticiones no pudo consultarse en Redis; la petición se permite. " + + "El cupo queda sin aplicar mientras dure la incidencia (G-248)."); + + return new ResultadoDelLimite(Permitida: true, Consumidas: 0, EsperaSugerida: restante); + } + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Services/LimitadorDePeticionesEnProceso.cs b/src/apps/ums.api/Ums.Infrastructure/Services/LimitadorDePeticionesEnProceso.cs new file mode 100644 index 00000000..5250a483 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Services/LimitadorDePeticionesEnProceso.cs @@ -0,0 +1,44 @@ +using System.Collections.Concurrent; + +namespace Ums.Infrastructure.Services; + +/// +/// G-248 — respaldo cuando no hay Redis: cuenta dentro del proceso. +/// +/// Es el comportamiento anterior, y solo es correcto con UNA réplica. Existe para que el +/// desarrollo local y las pruebas no exijan un Redis, no como modo de despliegue: el arranque ya +/// declara en el log si el estado compartido quedó distribuido o en memoria, y esta clase cae del +/// lado «en memoria». +/// +/// Las claves de ventanas pasadas se retiran al vuelo, cuando se toca esa misma clave, y no +/// con una tarea de limpieza: sin Redis no hay vencimiento automático, y un diccionario que solo +/// crece es una fuga con nombre de caché. +/// +public sealed class LimitadorDePeticionesEnProceso : ILimitadorDePeticiones +{ + private readonly ConcurrentDictionary _conteos = new(); + + private sealed record Conteo(long Ventana, long Consumidas); + + public Task RegistrarAsync( + string clave, int cupo, TimeSpan ventana, CancellationToken ct = default) + { + var ahora = DateTimeOffset.UtcNow; + var ticksDeVentana = Math.Max(1, ventana.Ticks); + var numeroDeVentana = ahora.UtcTicks / ticksDeVentana; + var restante = TimeSpan.FromTicks(((numeroDeVentana + 1) * ticksDeVentana) - ahora.UtcTicks); + + var actualizado = _conteos.AddOrUpdate( + clave, + _ => new Conteo(numeroDeVentana, 1), + // Ventana distinta ⇒ el conteo anterior ya no cuenta: se reemplaza en vez de sumarse. + (_, previo) => previo.Ventana == numeroDeVentana + ? previo with { Consumidas = previo.Consumidas + 1 } + : new Conteo(numeroDeVentana, 1)); + + return Task.FromResult(new ResultadoDelLimite( + Permitida: actualizado.Consumidas <= cupo, + Consumidas: actualizado.Consumidas, + EsperaSugerida: restante)); + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Services/RequestContextAccessor.cs b/src/apps/ums.api/Ums.Infrastructure/Services/RequestContextAccessor.cs deleted file mode 100644 index 926f0fa0..00000000 --- a/src/apps/ums.api/Ums.Infrastructure/Services/RequestContextAccessor.cs +++ /dev/null @@ -1,30 +0,0 @@ - -namespace Ums.Infrastructure.Services; - -/// -/// Scoped execution context for the current request. It is populated by middleware and can -/// be reused by loggers, handlers, background dispatch handoff, and observability adapters. -/// -public sealed class RequestContextAccessor : IRequestContext, IExecutionContextAccessor -{ - private ExecutionContextSnapshot _current = ExecutionContextSnapshot.Empty; - - public string? SessionTrackingId => string.IsNullOrWhiteSpace(_current.SessionTrackingId) ? null : _current.SessionTrackingId; - - public string? CorrelationId => string.IsNullOrWhiteSpace(_current.CorrelationId) ? null : _current.CorrelationId; - - public string? TraceId => string.IsNullOrWhiteSpace(_current.TraceId) ? null : _current.TraceId; - - public string? SpanId => string.IsNullOrWhiteSpace(_current.SpanId) ? null : _current.SpanId; - - private string? _clientTimezone; - public string? ClientTimezone => _clientTimezone; - public void SetClientTimezone(string? timezone) => _clientTimezone = timezone; - - public ExecutionContextSnapshot Current => _current; - - public void Set(ExecutionContextSnapshot snapshot) - { - _current = snapshot ?? ExecutionContextSnapshot.Empty; - } -} diff --git a/src/apps/ums.api/Ums.Infrastructure/Services/SessionRevocationStore.cs b/src/apps/ums.api/Ums.Infrastructure/Services/SessionRevocationStore.cs new file mode 100644 index 00000000..8e772cb2 --- /dev/null +++ b/src/apps/ums.api/Ums.Infrastructure/Services/SessionRevocationStore.cs @@ -0,0 +1,45 @@ +using Microsoft.Extensions.Caching.Distributed; + +namespace Ums.Infrastructure.Services; + +/// +/// G-247 — sesiones cerradas, sobre IDistributedCache. +/// +/// Una sola implementación, y no un par Redis/InMemory como en +/// : IDistributedCache ya es Redis cuando hay +/// Redis configurado y memoria del proceso cuando no lo hay, así que duplicar la clase solo +/// duplicaría el sitio donde equivocarse. Lo que cambia entre los dos modos no es el código, es el +/// alcance, y el arranque ya lo declara en el log. +/// +/// Clave: sesion:cerrada:{sid}. El valor guarda el instante de cierre para que quien +/// depure vea CUÁNDO se cerró, no solo que está cerrada. El TTL lo pone el propio almacén, así que +/// no hace falta ninguna tarea de limpieza: la entrada desaparece sola cuando el portador que +/// bloqueaba ya no valdría de todos modos. +/// +public sealed class SessionRevocationStore(IDistributedCache cache) : ISessionRevocationStore +{ + private static string Clave(string sessionId) => $"sesion:cerrada:{sessionId}"; + + /// + public async Task RevocarAsync(string sessionId, DateTime cerrarHastaUtc, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(sessionId)) return; + + // Ya pasó: el portador no puede seguir siendo válido, así que recordarlo no aporta nada. + if (cerrarHastaUtc <= DateTime.UtcNow) return; + + await cache.SetStringAsync( + Clave(sessionId), + DateTime.UtcNow.ToString("O"), + new DistributedCacheEntryOptions { AbsoluteExpiration = cerrarHastaUtc }, + ct); + } + + /// + public async Task EstaRevocadaAsync(string sessionId, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(sessionId)) return false; + + return await cache.GetStringAsync(Clave(sessionId), ct) is not null; + } +} diff --git a/src/apps/ums.api/Ums.Infrastructure/Services/UserContext.cs b/src/apps/ums.api/Ums.Infrastructure/Services/UserContext.cs index f5387f20..ea729cf1 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Services/UserContext.cs +++ b/src/apps/ums.api/Ums.Infrastructure/Services/UserContext.cs @@ -27,13 +27,22 @@ public UserContext(IHttpContextAccessor httpContextAccessor) public bool HasPermission(string permission) { + var user = _httpContextAccessor.HttpContext?.User; + + // ADR-0071 / FS-26: el Admin Root (propietario de gestión, is_internal_admin=true) + // tiene acceso total al sistema, sin límites de permisos. Concede cualquier permiso. + if (string.Equals(user?.FindFirstValue("is_internal_admin"), "true", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + // JWT uses 'scope' claim and format 'resource.action' (lowercase) var normalizedPerm = permission.Replace(":", ".").ToLowerInvariant(); - var scopeClaims = _httpContextAccessor.HttpContext?.User?.FindAll("scope") ?? Enumerable.Empty(); - + var scopeClaims = user?.FindAll("scope") ?? Enumerable.Empty(); + // Split space-separated scopes if they are returned as a single string var scopes = scopeClaims.SelectMany(c => c.Value.Split(' ')); - + return scopes.Any(s => s.Equals(normalizedPerm, StringComparison.OrdinalIgnoreCase)); } } diff --git a/src/apps/ums.api/Ums.Infrastructure/Ums.Infrastructure.csproj b/src/apps/ums.api/Ums.Infrastructure/Ums.Infrastructure.csproj index 7fc72289..b82e1786 100644 --- a/src/apps/ums.api/Ums.Infrastructure/Ums.Infrastructure.csproj +++ b/src/apps/ums.api/Ums.Infrastructure/Ums.Infrastructure.csproj @@ -10,7 +10,7 @@ - + @@ -35,7 +35,6 @@ - diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Approvals/NotificationRuleRestEndpointTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Approvals/NotificationRuleRestEndpointTests.cs index 040be11f..d7e689e8 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Approvals/NotificationRuleRestEndpointTests.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Approvals/NotificationRuleRestEndpointTests.cs @@ -26,7 +26,7 @@ public async Task CreateNotificationRule_WithEmailRecipient_ShouldNormalizeRecip { tenantId = Guid.Parse(CoreDevDataSeeder.InternalAdminTenantId), channel = "Email", - recipient = " Alerts@BeyondNet.Com " + recipient = " Alerts@beyondnet.com.pe " }, TestContext.Current.CancellationToken); createResponse.StatusCode.Should().Be(HttpStatusCode.Created); @@ -38,7 +38,11 @@ public async Task CreateNotificationRule_WithEmailRecipient_ShouldNormalizeRecip getResponse.StatusCode.Should().Be(HttpStatusCode.OK); using var payload = JsonDocument.Parse(await getResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); - payload.RootElement.GetProperty("recipient").GetString().Should().Be("alerts@beyondnet.com"); + // El normalizador de email (EmailNotificationRecipientStrategy.Normalize = Trim().ToLowerInvariant()) + // solo recorta y pasa a minúsculas; no reescribe el dominio. La entrada « Alerts@beyondnet.com.pe » + // se canonicaliza a «alerts@beyondnet.com.pe». La expectativa anterior («beyondnet.com») quedó obsoleta + // por el rebranding beyondnet→BeyondNet. + payload.RootElement.GetProperty("recipient").GetString().Should().Be("alerts@beyondnet.com.pe"); payload.RootElement.GetProperty("channel").GetString().Should().Be("Email"); } diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Audit/AuditTrailOutboxSinkSanitizationTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Audit/AuditTrailOutboxSinkSanitizationTests.cs new file mode 100644 index 00000000..675c066d --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Audit/AuditTrailOutboxSinkSanitizationTests.cs @@ -0,0 +1,65 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using MassTransit; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Ums.Application.Common.Aop; +using Ums.Infrastructure.Aop; + +namespace Ums.Presentation.IntegrationTest.Audit; + +/// +/// G-040 (residual #5, FR-072): el sink de la vía AUTOMÁTICA (AuditTrailOutboxSink) es el único punto +/// por el que pasan todas las emisiones automáticas de la traza (AuditTrailAspect y +/// ConfigurationAuditService). Debe desinfectar la metadata ANTES de encolarla por el outbox, de modo +/// que un secreto no llegue ni a la tabla de salida ni a la traza inmutable (G-081). Prueba unitaria +/// pura (sin Docker). +/// +public sealed class AuditTrailOutboxSinkSanitizationTests +{ + [Fact] + public async Task PublishAsync_WithSecretInMetadata_PublishesSanitizedMetadata() + { + AuditTrailEntry? published = null; + + var publishEndpoint = new Mock(); + publishEndpoint + .Setup(p => p.Publish(It.IsAny(), It.IsAny())) + .Callback((entry, _) => published = entry) + .Returns(Task.CompletedTask); + + // Sin UmsPlatformDbContext registrado → el flush del bus-outbox se omite (modo en memoria). + var serviceProvider = new Mock(); + serviceProvider.Setup(s => s.GetService(It.IsAny())).Returns((object?)null); + + var functionalTransaction = new Mock(); + + var sink = new AuditTrailOutboxSink( + publishEndpoint.Object, + serviceProvider.Object, + NullLogger.Instance, + functionalTransaction.Object); + + var entry = new AuditTrailEntry( + WhoActed: Guid.NewGuid(), + SubjectType: "AppConfiguration", + WhatChanged: "OVERRIDE", + EventType: "OVERRIDE", + AuditResult: "MODIFIED", + AffectedEntityId: Guid.Empty, + AffectedEntityType: "AppConfiguration", + RootTenantId: Guid.NewGuid(), + Metadata: "{\"parameterCode\":\"smtp\",\"secret\":\"p@ssw0rd\",\"apiKey\":\"sk-live-xyz\"}"); + + await sink.PublishAsync(entry, CancellationToken.None); + + published.Should().NotBeNull("el sink debe publicar la traza"); + published!.Metadata.Should().NotContain("p@ssw0rd").And.NotContain("sk-live-xyz"); + published.Metadata.Should().Contain("[REDACTED]"); + // La clave no sensible se conserva; los campos no-metadata no se tocan. + published.Metadata.Should().Contain("smtp"); + published.EventType.Should().Be("OVERRIDE"); + published.AffectedEntityType.Should().Be("AppConfiguration"); + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Audit/AuditTrailPersistenceAntiTamperTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Audit/AuditTrailPersistenceAntiTamperTests.cs new file mode 100644 index 00000000..0601aef7 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Audit/AuditTrailPersistenceAntiTamperTests.cs @@ -0,0 +1,375 @@ +using System.Linq; +using Microsoft.EntityFrameworkCore; +using Ums.Infrastructure.Persistence.Audit; +using Ums.Infrastructure.Persistence.Audit.Entities; +using Ums.Infrastructure.Persistence.Configuration.Entities; +using Ums.Infrastructure.Persistence.Interceptors; +using Ums.Presentation.IntegrationTest.Infrastructure; +using AuditRecordAggregate = Ums.Domain.Audit.AuditRecord.AuditRecord; + +namespace Ums.Presentation.IntegrationTest.Audit; + +/// +/// G-081 — Anti-tamper de la traza de auditoría a NIVEL DE PERSISTENCIA, contra un PostgreSQL real +/// (Testcontainers). Es el punto del gap: la inmutabilidad ya se probaba estructuralmente en el +/// dominio (AuditRecordTests.Record_HasNoMutationMethods_AppendOnly) y la EMISIÓN en +/// AuditTrailCommandTests; faltaba garantizar que la capa de persistencia RECHAZA mutar o +/// borrar una traza ya escrita, y verificar el aislamiento por inquilino (RootTenantId). +/// +/// +/// El host re-registra el +/// sin interceptores (para las pruebas de query-filter), de modo que el guard NO estaría +/// activo por esa vía. Por eso estos tests construyen el contexto directamente contra el contenedor +/// —igual que hace para migrar—, cableando el interceptor +/// real (tal y como lo cablea producción en +/// DependencyInjection). Así se ejercita PostgreSQL real + mapeo EF real + guard real. +/// +/// +[Collection("PostgreSql")] +public sealed class AuditTrailPersistenceAntiTamperTests +{ + private readonly PostgreSqlContainerFixture _fixture; + + public AuditTrailPersistenceAntiTamperTests(PostgreSqlContainerFixture fixture) + { + _fixture = fixture; + } + + // ── Append OK: una traza se persiste por el flujo real (repo → SaveChanges con el guard) ── + [Fact] + public async Task AppendAuditRecord_ViaRepository_Persists() + { + if (!_fixture.IsAvailable) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + var tenant = Guid.NewGuid(); + var eventType = $"AppendOk_{Guid.NewGuid():N}"; + + var id = await AppendAuditAsync(tenant, eventType, "traza legítima", ct); + + await using var verify = CreateGuardedContext(); + var repo = new PostgreSqlAuditRecordRepository(verify, new NoTenantContext()); + var persisted = await repo.GetByIdAsync(id, ct); + + persisted.Should().NotBeNull("una traza en estado Added debe persistirse: el guard solo bloquea UPDATE/DELETE"); + persisted!.EventType.Should().Be(eventType); + persisted.RootTenantId.Should().Be(tenant); + persisted.WhatChanged.Should().Be("traza legítima"); + } + + // ── Anti-tamper UPDATE: mutar una traza persistida y guardar → rechazado por el guard ── + [Fact] + public async Task UpdateAuditRecord_ViaEf_IsRejected() + { + if (!_fixture.IsAvailable) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + var tenant = Guid.NewGuid(); + var eventType = $"AntiTamperUpdate_{Guid.NewGuid():N}"; + var id = await AppendAuditAsync(tenant, eventType, "original", ct); + + await using var ctx = CreateGuardedContext(); + var record = await ctx.Set().FirstAsync(x => x.Id == id, ct); + record.WhatChanged = "MANIPULADO"; + + var act = async () => await ctx.SaveChangesAsync(ct); + + await act.Should().ThrowAsync() + .WithMessage("*No repudio (G-081)*") + .WithMessage("*append-only*"); + + // La fila permanece intacta: el SaveChanges se abortó antes de tocar la BD. + await using var verify = CreateGuardedContext(); + var reread = await verify.Set().AsNoTracking().FirstAsync(x => x.Id == id, ct); + reread.WhatChanged.Should().Be("original", "un UPDATE rechazado no debe persistir cambio alguno"); + } + + // ── Anti-tamper DELETE: intentar borrar una traza vía EF → rechazado por el guard ── + [Fact] + public async Task DeleteAuditRecord_ViaEf_IsRejected() + { + if (!_fixture.IsAvailable) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + var tenant = Guid.NewGuid(); + var eventType = $"AntiTamperDelete_{Guid.NewGuid():N}"; + var id = await AppendAuditAsync(tenant, eventType, "no borrable", ct); + + await using var ctx = CreateGuardedContext(); + var record = await ctx.Set().FirstAsync(x => x.Id == id, ct); + ctx.Set().Remove(record); + + var act = async () => await ctx.SaveChangesAsync(ct); + + await act.Should().ThrowAsync() + .WithMessage("*No repudio (G-081)*"); + + // La traza sigue presente: el DELETE fue rechazado. + await using var verify = CreateGuardedContext(); + var stillThere = await verify.Set().AsNoTracking().AnyAsync(x => x.Id == id, ct); + stillThere.Should().BeTrue("un DELETE rechazado no debe borrar la traza"); + } + + // ── Aislamiento por inquilino: consultar como inquilino A no revela las trazas de B ── + // La tabla de auditoría NO está cubierta por RLS (no figura en la migración EnableRowLevelSecurity) + // ni por un global query filter de EF; el aislamiento se apoya en el filtro de aplicación del + // repositorio (parámetro RootTenantId en QueryBy*). Se prueba ahí, donde vive. + [Fact] + public async Task QueryByTenant_DoesNotLeakOtherTenantsAuditRecords() + { + if (!_fixture.IsAvailable) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + var tenantA = Guid.NewGuid(); + var tenantB = Guid.NewGuid(); + var eventType = $"Isolation_{Guid.NewGuid():N}"; // mismo tipo de evento en ambos: filtra el tenant + + var idA = await AppendAuditAsync(tenantA, eventType, "traza-A", ct); + var idB = await AppendAuditAsync(tenantB, eventType, "traza-B", ct); + + await using var ctx = CreateGuardedContext(); + var repo = new PostgreSqlAuditRecordRepository(ctx, new NoTenantContext()); + var from = DateTime.UtcNow.AddMinutes(-5); + var to = DateTime.UtcNow.AddMinutes(5); + + var forA = await repo.QueryByEventTypeAsync(eventType, tenantA, from, to, ct); + forA.Should().OnlyContain(r => r.RootTenantId == tenantA, "el inquilino A solo debe ver sus propias trazas"); + forA.Select(r => r.GetId().GetValue()).Should().Contain(idA).And.NotContain(idB); + + var forB = await repo.QueryByEventTypeAsync(eventType, tenantB, from, to, ct); + forB.Should().OnlyContain(r => r.RootTenantId == tenantB, "el inquilino B solo debe ver sus propias trazas"); + forB.Select(r => r.GetId().GetValue()).Should().Contain(idB).And.NotContain(idA); + } + + // ── G-103: aislamiento por inquilino en la LECTURA POR ID. GetByIdAsync filtraba SOLO por Id, + // así que un actor podía leer la traza de otro inquilino conociendo su Id (fuga cross-tenant). + // La tabla de auditoría no lleva query filter global (rompería la lectura cross-tenant legítima + // del admin de plataforma y las QueryBy* con inquilino explícito), así que el aislamiento vive + // en el repositorio, igual que las QueryBy*. Aquí se prueba con PostgreSQL real. ── + [Fact] + public async Task GetById_OtherTenantRecord_AsRegularUser_ReturnsNull() + { + if (!_fixture.IsAvailable) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + var tenantA = Guid.NewGuid(); + var tenantB = Guid.NewGuid(); + var eventType = $"CrossTenantRead_{Guid.NewGuid():N}"; + + var idB = await AppendAuditAsync(tenantB, eventType, "traza-de-B", ct); + + // Actor REGULAR del inquilino A intentando leer la traza de B por un Id conocido. + await using var ctx = CreateGuardedContext(); + var repo = new PostgreSqlAuditRecordRepository(ctx, new FixedTenantContext(tenantA, isInternalAdmin: false)); + + var leaked = await repo.GetByIdAsync(idB, ct); + + leaked.Should().BeNull( + "un actor del inquilino A no debe poder leer la traza de auditoría del inquilino B conociendo su Id (G-103)"); + } + + // ── G-103: la lectura por Id de la PROPIA traza sigue funcionando para un usuario regular. ── + [Fact] + public async Task GetById_OwnTenantRecord_AsRegularUser_ReturnsRecord() + { + if (!_fixture.IsAvailable) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + var tenantA = Guid.NewGuid(); + var eventType = $"OwnTenantRead_{Guid.NewGuid():N}"; + + var idA = await AppendAuditAsync(tenantA, eventType, "traza-de-A", ct); + + await using var ctx = CreateGuardedContext(); + var repo = new PostgreSqlAuditRecordRepository(ctx, new FixedTenantContext(tenantA, isInternalAdmin: false)); + + var own = await repo.GetByIdAsync(idA, ct); + + own.Should().NotBeNull("un actor debe poder leer la traza de su propio inquilino por Id"); + own!.RootTenantId.Should().Be(tenantA); + own.EventType.Should().Be(eventType); + } + + // ── G-103: el admin de plataforma (IsInternalAdmin) conserva la vista cross-tenant de la traza. + // OrganizationId es su propio inquilino (≠ B), pero el bypass legítimo de admin interno —el mismo + // patrón que usa GetAllAuditRecordsQueryHandler— le permite leer la traza de otro inquilino. ── + [Fact] + public async Task GetById_OtherTenantRecord_AsInternalAdmin_ReturnsRecord() + { + if (!_fixture.IsAvailable) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + var tenantB = Guid.NewGuid(); + var adminOwnTenant = Guid.NewGuid(); + var eventType = $"AdminCrossTenantRead_{Guid.NewGuid():N}"; + + var idB = await AppendAuditAsync(tenantB, eventType, "traza-de-B", ct); + + await using var ctx = CreateGuardedContext(); + var repo = new PostgreSqlAuditRecordRepository(ctx, new FixedTenantContext(adminOwnTenant, isInternalAdmin: true)); + + var seen = await repo.GetByIdAsync(idB, ct); + + seen.Should().NotBeNull( + "el admin interno conserva la vista cross-tenant de la traza: no se rompe la consulta de auditoría del admin de plataforma"); + seen!.RootTenantId.Should().Be(tenantB); + } + + // ── No-regresión: el guard es específico de la traza; NO bloquea la modificación (ni el + // estampado) de una entidad normal que SÍ se modifica. Con el guard activo, insertar y luego + // MODIFICAR una entidad no-auditoría debe funcionar y persistir. ── + [Fact] + public async Task ModifyNonAuditEntity_ViaEf_IsAllowedByTheGuard() + { + if (!_fixture.IsAvailable) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + var id = Guid.NewGuid(); + var code = $"pd_{Guid.NewGuid():N}"; + + await using (var insert = CreateGuardedContext()) + { + insert.Set().Add(new ParameterDefinitionRecord + { + Id = id, + Code = code, + Name = "original", + Description = "def de no-regresión", + DataTypeId = 1, + DefaultValue = "x", + ScopeId = 1, + IsActive = true, + IsMandatory = false, + DisplayOrder = 0, + Version = "1.0.0", + CreatedBy = "tester", + CreatedAtUtc = DateTime.UtcNow, + AuditTimeSpan = "0", + }); + await insert.SaveChangesAsync(ct); + } + + await using (var update = CreateGuardedContext()) + { + var pd = await update.Set().FirstAsync(x => x.Id == id, ct); + pd.Name = "modificado"; + + var act = async () => await update.SaveChangesAsync(ct); + + await act.Should().NotThrowAsync( + "el guard anti-tamper solo protege la traza de auditoría (AuditRecordRecord); las entidades normales se modifican con normalidad"); + } + + await using var verify = CreateGuardedContext(); + var reread = await verify.Set().AsNoTracking().FirstAsync(x => x.Id == id, ct); + reread.Name.Should().Be("modificado", "una modificación legítima de una entidad no-auditoría debe persistir"); + } + + // ───────────────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────────────── + + // Construye un UmsPlatformDbContext contra el contenedor con el guard anti-tamper cableado, + // espejo de cómo AddInfrastructure lo registra en producción (options.AddInterceptors(...)). + private UmsPlatformDbContext CreateGuardedContext() + { + var options = new DbContextOptionsBuilder() + .UseNpgsql(_fixture.ConnectionString, sql => sql.EnableRetryOnFailure(3)) + .AddInterceptors(new AuditAppendOnlyGuardInterceptor()) + .Options; + + return new UmsPlatformDbContext( + options, + new NoTenantContext(), + new Moq.Mock().Object, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + } + + // Persiste una traza por el repositorio real (AppendAsync → SaveChanges con el guard activo). + private async Task AppendAuditAsync(Guid rootTenantId, string eventType, string whatChanged, CancellationToken ct) + { + await using var ctx = CreateGuardedContext(); + var repo = new PostgreSqlAuditRecordRepository(ctx, new NoTenantContext()); + + var record = AuditRecordAggregate.Record( + whoActed: Guid.NewGuid(), + subjectType: SubjectType.User, + whatChanged: whatChanged, + eventType: eventType, + auditResult: AuditResult.Success, + affectedEntityId: Guid.NewGuid(), + affectedEntityType: "UserAccount", + rootTenantId: rootTenantId, + metadata: null).Value; + + await repo.AppendAsync(record, ct); + await repo.SaveChangesAsync(ct); + + return record.GetId().GetValue(); + } + + // Contexto de inquilino nulo → sin restricción (vista sistema/admin); la tabla de auditoría no + // lleva query filter, así que el aislamiento se prueba por el parámetro RootTenantId del repo. + private sealed class NoTenantContext : ITenantContext + { + public Guid? OrganizationId => null; + public Guid? OriginalTenantId => null; + public bool IsInternalAdmin => true; + public void Initialize(Guid userTenantId, bool isInternalAdmin) { } + public void SetOrganizationId(Guid organizationId) { } + public void EnableCrossTenantAccess() { } + public void DisableCrossTenantAccess() { } + } + + // Contexto de inquilino fijo para las pruebas de aislamiento de GetByIdAsync (G-103): un actor + // ligado a un OrganizationId concreto, admin interno o no. Espeja la semántica de TenantContext. + private sealed class FixedTenantContext : ITenantContext + { + public FixedTenantContext(Guid? organizationId, bool isInternalAdmin) + { + OrganizationId = organizationId; + OriginalTenantId = organizationId; + IsInternalAdmin = isInternalAdmin; + } + + public Guid? OrganizationId { get; } + public Guid? OriginalTenantId { get; } + public bool IsInternalAdmin { get; } + public void Initialize(Guid userTenantId, bool isInternalAdmin) { } + public void SetOrganizationId(Guid organizationId) { } + public void EnableCrossTenantAccess() { } + public void DisableCrossTenantAccess() { } + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Authorization/PermissionTemplateItemRetirementTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Authorization/PermissionTemplateItemRetirementTests.cs new file mode 100644 index 00000000..cb9f76ad --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Authorization/PermissionTemplateItemRetirementTests.cs @@ -0,0 +1,278 @@ +using Microsoft.EntityFrameworkCore; +using Ums.Presentation.IntegrationTest.Infrastructure; + +namespace Ums.Presentation.IntegrationTest.Authorization; + +/// +/// ADR-0164 aplicado a los ÍTEMS de una plantilla de permisos. Un ítem es una concesión —«este rol +/// puede hacer esta acción sobre este recurso»—, es decir una DECISIÓN en el sentido del ADR-0162, y +/// por tanto no se borra: se retira. +/// +/// Antes de este cambio PermissionTemplate.RemoveItem hacía _items.Remove(...) y +/// EfChildCollectionReconciler traducía esa ausencia en un DELETE físico. La fila desaparecía +/// y con ella la prueba de que alguien concedió ese permiso. +/// +/// Estas pruebas fijan sobre PostgreSQL real (Testcontainers) las cuatro afirmaciones del ADR: +/// +/// a. El contrato HTTP no cambia: DELETE del ítem sigue devolviendo 204. +/// b. La FILA SIGUE EN LA BASE, marcada como retirada. Se lee con un contexto propio, SIN los +/// filtros de lectura: preguntar a la API no distingue «oculto» de «borrado» (ADR-0164 §5). +/// c. El ciclo retirar/reactivar sigue funcionando y opera sobre la MISMA fila. +/// d. La clave natural del ítem no se libera: el alta con la misma terna responde 409 de dominio +/// legible, no una violación de índice. +/// +[Collection("PostgreSql")] +public sealed class PermissionTemplateItemRetirementTests : IntegrationTestBase +{ + public PermissionTemplateItemRetirementTests(PostgreSqlContainerFixture fixture) : base(fixture) { } + + [Fact] + public async Task RetirarItem_Responde204_YLaFilaSigueEnLaBaseMarcadaComoRetirada() + { + if (!Fixture.IsAvailable) Assert.Skip("Docker es necesario para las pruebas de integración PostgreSQL."); + var ct = TestContext.Current.CancellationToken; + + var contexto = await ProvisionarSuiteYRolAsync(ct); + var templateId = await CrearPlantillaAsync(contexto, ct); + var actionId = Guid.NewGuid(); + var itemId = await AnadirItemAsync(templateId, contexto.SystemSuiteId, actionId, ct); + + // (a) El contrato no cambia: 204 No Content, igual que con el borrado físico. + var retirada = await Client.DeleteAsync($"/api/v1/permission-templates/{templateId}/items/{itemId}", ct); + retirada.StatusCode.Should().Be(HttpStatusCode.NoContent, await retirada.Content.ReadAsStringAsync(ct)); + + // (b) LA FILA SIGUE EN LA BASE. Es LA prueba: la evidencia viene del almacenamiento, no del + // API, porque el API es precisamente lo que ahora la presenta como retirada. + await using var db = CrearContextoDirecto(); + var fila = await db.PermissionTemplateItems.SingleOrDefaultAsync(x => x.Id == itemId, ct); + + fila.Should().NotBeNull("la retirada es LÓGICA: la fila del ítem no puede desaparecer de la tabla"); + fila!.IsActive.Should().BeFalse("la retirada se registra marcando el ítem, no quitándolo"); + fila.TargetId.Should().Be(contexto.SystemSuiteId, "el destino de la concesión sobrevive para poder auditarla"); + fila.ActionId.Should().Be(actionId, "la acción concedida sobrevive: es la mitad de la respuesta a «quién pudo hacer qué»"); + fila.UpdatedBy.Should().NotBeNullOrWhiteSpace("la auditoría debe registrar quién retiró la concesión"); + + // La plantilla lo sigue mostrando en su detalle, apagado: el operador necesita verlo para + // poder reactivarlo, y ocultarlo lo dejaría sin forma de recuperar una retirada por error. + var detalle = await LeerItemsDelDetalleAsync(templateId, ct); + detalle.Should().ContainSingle(i => i.ItemId == itemId && !i.IsActive); + } + + [Fact] + public async Task RetirarYReactivar_OperaSobreLaMismaFila_SinCrearOtra() + { + if (!Fixture.IsAvailable) Assert.Skip("Docker es necesario para las pruebas de integración PostgreSQL."); + var ct = TestContext.Current.CancellationToken; + + var contexto = await ProvisionarSuiteYRolAsync(ct); + var templateId = await CrearPlantillaAsync(contexto, ct); + var itemId = await AnadirItemAsync(templateId, contexto.SystemSuiteId, Guid.NewGuid(), ct); + + (await Client.DeleteAsync($"/api/v1/permission-templates/{templateId}/items/{itemId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + var reactivada = await Client.PostAsync( + $"/api/v1/permission-templates/{templateId}/items/{itemId}/activate", null, ct); + reactivada.StatusCode.Should().Be(HttpStatusCode.NoContent, await reactivada.Content.ReadAsStringAsync(ct)); + + // (c) Misma fila, ahora vigente. El conteo importa tanto como el estado: si la reactivación + // insertase una fila nueva, el ítem volvería con otra identidad y las referencias al anterior + // apuntarían a una concesión fantasma. + await using var db = CrearContextoDirecto(); + var filas = await db.PermissionTemplateItems.Where(x => x.TemplateId == templateId).ToListAsync(ct); + + filas.Should().ContainSingle("retirar y reactivar mueven un estado; no crean ni destruyen filas"); + filas[0].Id.Should().Be(itemId); + filas[0].IsActive.Should().BeTrue(); + } + + [Fact] + public async Task AltaConLaClaveDeUnItemRetirado_Responde409_LaClaveNoSeLibera() + { + if (!Fixture.IsAvailable) Assert.Skip("Docker es necesario para las pruebas de integración PostgreSQL."); + var ct = TestContext.Current.CancellationToken; + + var contexto = await ProvisionarSuiteYRolAsync(ct); + var templateId = await CrearPlantillaAsync(contexto, ct); + var actionId = Guid.NewGuid(); + var itemId = await AnadirItemAsync(templateId, contexto.SystemSuiteId, actionId, ct); + + (await Client.DeleteAsync($"/api/v1/permission-templates/{templateId}/items/{itemId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + // (d) ADR-0164 §2.3: la clave natural (plantilla, tipo, destino, acción) queda ocupada para + // siempre. El rechazo llega del DOMINIO —conflicto legible— y no del índice único: si la + // guarda no comparase por valor, esto caería a 23505 y saldría como un 409 opaco de + // concurrencia, indistinguible de una carrera. + var repetida = await Client.PostAsJsonAsync($"/api/v1/permission-templates/{templateId}/items", new + { + targetType = "SystemSuite", + targetId = contexto.SystemSuiteId, + actionId, + isAllowed = true, + isDenied = false, + }, ct); + + repetida.StatusCode.Should().Be(HttpStatusCode.Conflict, await repetida.Content.ReadAsStringAsync(ct)); + (await repetida.Content.ReadAsStringAsync(ct)) + .Should().Contain("template_item_target_retired", + "el conflicto nombra la retirada para que el cliente proponga reactivar, no repetir el alta"); + + // Y no se coló una segunda fila con la misma clave. + await using var db = CrearContextoDirecto(); + var filas = await db.PermissionTemplateItems + .Where(x => x.TemplateId == templateId && x.TargetId == contexto.SystemSuiteId && x.ActionId == actionId) + .ToListAsync(ct); + filas.Should().ContainSingle(); + } + + [Fact] + public async Task PublicarConTodasLasConcesionesRetiradas_SeRechaza() + { + if (!Fixture.IsAvailable) Assert.Skip("Docker es necesario para las pruebas de integración PostgreSQL."); + var ct = TestContext.Current.CancellationToken; + + // Puerta que abre el borrado lógico: con el borrado físico, retirar el último ítem dejaba la + // plantilla sin filas y `Publish` la rechazaba. Ahora la fila sobrevive, así que sin una + // guarda sobre lo VIGENTE se publicaría un contrato que no concede nada — y sería asignable. + var contexto = await ProvisionarSuiteYRolAsync(ct); + var templateId = await CrearPlantillaAsync(contexto, ct); + var itemId = await AnadirItemAsync(templateId, contexto.SystemSuiteId, Guid.NewGuid(), ct); + + (await Client.DeleteAsync($"/api/v1/permission-templates/{templateId}/items/{itemId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + var publicacion = await Client.PostAsync($"/api/v1/permission-templates/{templateId}/publish", null, ct); + + publicacion.IsSuccessStatusCode.Should().BeFalse( + "una plantilla cuyas concesiones están todas retiradas no concede nada: publicarla sería publicar un contrato vacío"); + (await publicacion.Content.ReadAsStringAsync(ct)).Should().Contain("template_items_required"); + } + + // ── Utilidades de aprovisionamiento ───────────────────────────────────── + + private sealed record ContextoDePrueba(Guid TenantId, Guid SystemSuiteId, Guid RoleId); + + private sealed record ItemDelDetalle(Guid ItemId, bool IsActive); + + private async Task> LeerItemsDelDetalleAsync(Guid templateId, CancellationToken ct) + { + var response = await Client.GetAsync($"/api/v1/permission-templates/{templateId}", ct); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); + return payload.RootElement.GetProperty("items").EnumerateArray() + .Select(i => new ItemDelDetalle( + i.GetProperty("itemId").GetGuid(), + i.GetProperty("isActive").GetBoolean())) + .ToList(); + } + + private async Task ProvisionarSuiteYRolAsync(CancellationToken ct) + { + // El host PostgreSQL corre con SeedDevData=false: no hay ningún inquilino sembrado, así que + // cada prueba levanta el suyo (mismo patrón que PermissionTemplateSoftDeleteTests). + var code = $"PTIR{Guid.NewGuid():N}"[..12].ToUpperInvariant(); + var tenantResponse = await Client.PostAsJsonAsync("/api/v1/tenants", new + { + code, + name = $"Inquilino de retirada de ítems {code}", + type = "CLIENT", + isManagementOwner = false, + }, ct); + tenantResponse.StatusCode.Should().Be(HttpStatusCode.Created, await tenantResponse.Content.ReadAsStringAsync(ct)); + var tenantId = Guid.Parse(tenantResponse.Headers.Location!.ToString().Split('/')[^1]); + + // ADR-0077: aprovisionar recursos de un inquilino CLIENT es una operación ON-BEHALF que solo + // ejerce el internal-admin; sin la cabecera, TenantScopePolicy devuelve AUTH_015 → 400. + Client.DefaultRequestHeaders.Remove("X-Tenant-Id"); + Client.DefaultRequestHeaders.Add("X-Tenant-Id", tenantId.ToString()); + Client.DefaultRequestHeaders.Remove("X-Is-Internal-Admin"); + Client.DefaultRequestHeaders.Add("X-Is-Internal-Admin", "true"); + + var suiteResponse = await Client.PostAsJsonAsync("/api/v1/system-suites", new + { + tenantId, + code = $"SS{Guid.NewGuid():N}"[..10], + name = "Consola de despacho", + description = "Suite para las pruebas de retirada de ítems de plantilla.", + }, ct); + suiteResponse.StatusCode.Should().Be(HttpStatusCode.Created, await suiteResponse.Content.ReadAsStringAsync(ct)); + using var suitePayload = JsonDocument.Parse(await suiteResponse.Content.ReadAsStringAsync(ct)); + var systemSuiteId = suitePayload.RootElement.GetProperty("systemSuiteId").GetGuid(); + + var roleResponse = await Client.PostAsJsonAsync($"/api/v1/system-suites/{systemSuiteId}/roles", new + { + code = $"ROLE{Guid.NewGuid():N}"[..12].ToUpperInvariant(), + value = "Analista de despacho aduanero", + description = "Rol para las pruebas de retirada de ítems de plantilla.", + parentRoleId = (Guid?)null, + hierarchyLevel = 0, + promotionOrder = 0, + }, ct); + roleResponse.StatusCode.Should().Be(HttpStatusCode.Created, await roleResponse.Content.ReadAsStringAsync(ct)); + using var rolePayload = JsonDocument.Parse(await roleResponse.Content.ReadAsStringAsync(ct)); + var roleId = rolePayload.RootElement.GetProperty("roleId").GetGuid(); + + return new ContextoDePrueba(tenantId, systemSuiteId, roleId); + } + + private async Task CrearPlantillaAsync(ContextoDePrueba contexto, CancellationToken ct) + { + var response = await Client.PostAsJsonAsync("/api/v1/permission-templates", new + { + tenantId = contexto.TenantId, + roleId = contexto.RoleId, + systemSuiteId = contexto.SystemSuiteId, + }, ct); + response.StatusCode.Should().Be(HttpStatusCode.Created, await response.Content.ReadAsStringAsync(ct)); + + using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); + return payload.RootElement.GetProperty("templateId").GetGuid(); + } + + private async Task AnadirItemAsync(Guid templateId, Guid targetId, Guid actionId, CancellationToken ct) + { + var response = await Client.PostAsJsonAsync($"/api/v1/permission-templates/{templateId}/items", new + { + targetType = "SystemSuite", + targetId, + actionId, + isAllowed = true, + isDenied = false, + }, ct); + response.StatusCode.Should().Be(HttpStatusCode.Created, await response.Content.ReadAsStringAsync(ct)); + + using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); + return payload.RootElement.GetProperty("itemId").GetGuid(); + } + + /// + /// Contexto EF conectado al MISMO contenedor que el API, con inquilino nulo para que ningún filtro + /// global recorte la vista. Es la ventana al almacenamiento real: lo que el API presenta como + /// retirado, aquí se ve como la fila que sigue existiendo. + /// + private UmsPlatformDbContext CrearContextoDirecto() + { + var options = new DbContextOptionsBuilder() + .UseNpgsql(Fixture.ConnectionString) + .Options; + + return new UmsPlatformDbContext( + options, + new ContextoDeInquilinoDelSistema(), + new Moq.Mock().Object, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + } + + private sealed class ContextoDeInquilinoDelSistema : ITenantContext + { + public Guid? OrganizationId => null; + public Guid? OriginalTenantId => null; + public bool IsInternalAdmin => true; + public void Initialize(Guid userTenantId, bool isInternalAdmin) { } + public void SetOrganizationId(Guid organizationId) { } + public void EnableCrossTenantAccess() { } + public void DisableCrossTenantAccess() { } + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Authorization/PermissionTemplateSoftDeleteTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Authorization/PermissionTemplateSoftDeleteTests.cs new file mode 100644 index 00000000..6c3221a4 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Authorization/PermissionTemplateSoftDeleteTests.cs @@ -0,0 +1,356 @@ +using Microsoft.EntityFrameworkCore; +using Ums.Presentation.IntegrationTest.Infrastructure; + +namespace Ums.Presentation.IntegrationTest.Authorization; + +/// +/// Política del propietario: SOLO existe borrado lógico. Estas pruebas fijan sobre PostgreSQL real +/// (Testcontainers) las cuatro afirmaciones que la sostienen para PermissionTemplate: +/// +/// a. El contrato HTTP no cambia: DELETE sigue devolviendo 204. +/// b. La FILA SIGUE EN LA BASE tras el borrado —con el estado terminal Deleted y sus ítems—. Es la +/// prueba que fija la política: sin ella el cambio no está demostrado. +/// c. El GET posterior devuelve 404 y la plantilla no aparece en el listado. +/// d. Con una referencia VIVA (un perfil activo que la usa) el DELETE devuelve 409 estructurado. +/// e. Con esa referencia ya eliminada lógicamente (perfil desactivado), el borrado sí procede. +/// +/// Antes de este cambio el repositorio hacía dbContext.PermissionTemplates.Remove(record), que +/// además arrastraba por cascada los ítems de la plantilla: se perdía sin remedio el rastro de qué +/// concesiones había otorgado, justo lo que el negocio consulta hacia atrás. +/// +[Collection("PostgreSql")] +public sealed class PermissionTemplateSoftDeleteTests : IntegrationTestBase +{ + /// Id de TemplateStatus.Deleted tal y como se persiste en la columna StatusId. + private const int DeletedStatusId = 4; + + public PermissionTemplateSoftDeleteTests(PostgreSqlContainerFixture fixture) : base(fixture) { } + + [Fact] + public async Task Delete_PlantillaSinReferencias_Responde204_DejaLaFilaEnLaBase_YOcultaLasLecturas() + { + if (!Fixture.IsAvailable) Assert.Skip("Docker es necesario para las pruebas de integración PostgreSQL."); + var ct = TestContext.Current.CancellationToken; + + var contexto = await ProvisionarSuiteYRolAsync(ct); + var templateId = await CrearPlantillaConItemAsync(contexto, ct); + + // (a) El contrato no cambia: 204 No Content, igual que con el borrado físico. + var deleteResponse = await Client.DeleteAsync($"/api/v1/permission-templates/{templateId}", ct); + deleteResponse.StatusCode.Should().Be(HttpStatusCode.NoContent, + await deleteResponse.Content.ReadAsStringAsync(ct)); + + // (b) LA FILA SIGUE EN LA BASE. Se consulta con un contexto propio, no por el API, porque el + // API es precisamente lo que ahora la oculta: la evidencia tiene que venir del almacenamiento. + await using var db = CrearContextoDirecto(); + var fila = await db.PermissionTemplates + .Include(x => x.Items) + .SingleOrDefaultAsync(x => x.Id == templateId, ct); + + fila.Should().NotBeNull("el borrado es LÓGICO: la fila no puede desaparecer de la tabla"); + fila!.StatusId.Should().Be(DeletedStatusId, "la eliminación se registra como estado terminal Deleted"); + fila.Items.Should().NotBeEmpty("los ítems son el rastro de las concesiones otorgadas y sobreviven al borrado"); + fila.UpdatedBy.Should().NotBeNullOrWhiteSpace("la auditoría debe registrar quién eliminó la plantilla"); + + // (c) Las lecturas la ocultan: por id da 404 y no aparece en el listado. + var getResponse = await Client.GetAsync($"/api/v1/permission-templates/{templateId}", ct); + getResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); + + var listado = await Client.GetAsync( + $"/api/v1/permission-templates?page=1&pageSize=200&tenantId={contexto.TenantId}&systemSuiteId={contexto.SystemSuiteId}", ct); + listado.StatusCode.Should().Be(HttpStatusCode.OK); + + using var payload = JsonDocument.Parse(await listado.Content.ReadAsStringAsync(ct)); + payload.RootElement.GetProperty("items").EnumerateArray() + .Should().NotContain(i => i.GetProperty("templateId").GetGuid() == templateId, + "una plantilla eliminada lógicamente no puede seguir apareciendo en el catálogo"); + } + + [Fact] + public async Task Delete_SegundoIntento_Responde404_PorqueLasLecturasOcultanLoEliminado() + { + if (!Fixture.IsAvailable) Assert.Skip("Docker es necesario para las pruebas de integración PostgreSQL."); + var ct = TestContext.Current.CancellationToken; + + var contexto = await ProvisionarSuiteYRolAsync(ct); + var templateId = await CrearPlantillaConItemAsync(contexto, ct); + + (await Client.DeleteAsync($"/api/v1/permission-templates/{templateId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + var segundo = await Client.DeleteAsync($"/api/v1/permission-templates/{templateId}", ct); + + segundo.StatusCode.Should().Be(HttpStatusCode.NotFound, + "el borrado no es idempotente en silencio: lo ya eliminado simplemente no existe para el API"); + } + + [Fact] + public async Task Delete_ConPerfilVivo_Responde409_YTrasDesactivarlo_Permite() + { + if (!Fixture.IsAvailable) Assert.Skip("Docker es necesario para las pruebas de integración PostgreSQL."); + var ct = TestContext.Current.CancellationToken; + + var contexto = await ProvisionarSuiteYRolAsync(ct); + var templateId = await CrearPlantillaConItemAsync(contexto, ct); + + // Solo una plantilla PUBLICADA puede asignarse a un perfil (Profile.AssignTemplate). + (await Client.PostAsync($"/api/v1/permission-templates/{templateId}/publish", null, ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + var profileId = await CrearPerfilActivoAsync(contexto, ct); + (await Client.PostAsync($"/api/v1/profiles/{profileId}/templates/{templateId}", null, ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + // (d) Referencia VIVA → 409 con las dependencias que bloquean, no un 500 ni un borrado a + // traición que dejaría al perfil apuntando a la nada. + var bloqueado = await Client.DeleteAsync($"/api/v1/permission-templates/{templateId}", ct); + bloqueado.StatusCode.Should().Be(HttpStatusCode.Conflict, + await bloqueado.Content.ReadAsStringAsync(ct)); + + using var error = JsonDocument.Parse(await bloqueado.Content.ReadAsStringAsync(ct)); + error.RootElement.GetProperty("errorCode").GetString().Should().Be("TEMPLATE_HAS_ACTIVE_PROFILES"); + var deps = error.RootElement.GetProperty("blockingDependencies"); + deps.GetArrayLength().Should().BeGreaterThan(0); + deps[0].GetProperty("entityType").GetString().Should().Be("Profile"); + deps[0].GetProperty("count").GetInt32().Should().BeGreaterThan(0); + + // La plantilla sigue viva: el rechazo es previo a cualquier escritura. + (await Client.GetAsync($"/api/v1/permission-templates/{templateId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.OK); + + // (e) Se elimina la referencia (perfil desactivado) y ahora el borrado sí procede. La plantilla + // se deprecia primero porque una PUBLICADA está en uso por definición: deprecar es la retirada + // explícita de la oferta y es lo que habilita el borrado. + (await Client.PostAsync($"/api/v1/profiles/{profileId}/deactivate", null, ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + (await Client.PostAsync($"/api/v1/permission-templates/{templateId}/deprecate", null, ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + var permitido = await Client.DeleteAsync($"/api/v1/permission-templates/{templateId}", ct); + permitido.StatusCode.Should().Be(HttpStatusCode.NoContent, + await permitido.Content.ReadAsStringAsync(ct)); + + // Y de nuevo: la fila sigue en la base, ahora en estado terminal. + await using var db = CrearContextoDirecto(); + var fila = await db.PermissionTemplates.SingleOrDefaultAsync(x => x.Id == templateId, ct); + fila.Should().NotBeNull(); + fila!.StatusId.Should().Be(DeletedStatusId); + } + + [Fact] + public async Task Delete_NoLiberaLaVersionParaUnAltaPosterior() + { + if (!Fixture.IsAvailable) Assert.Skip("Docker es necesario para las pruebas de integración PostgreSQL."); + var ct = TestContext.Current.CancellationToken; + + // G-140 revisitado bajo borrado lógico: la fila eliminada SIGUE ocupando su versión en el + // índice único IX_PermissionTemplates_TenantId_RoleId_SystemSuiteId_Version. Si el cálculo de + // la versión siguiente ignorase las eliminadas, el alta posterior reutilizaría 0.1.0 y chocaría + // con 23505. Por eso GetByTenantRoleSuiteAsync es la ÚNICA lectura que no filtra. + var contexto = await ProvisionarSuiteYRolAsync(ct); + var primera = await CrearPlantillaConItemAsync(contexto, ct); + + (await Client.DeleteAsync($"/api/v1/permission-templates/{primera}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + var segunda = await Client.PostAsJsonAsync("/api/v1/permission-templates", new + { + tenantId = contexto.TenantId, + roleId = contexto.RoleId, + systemSuiteId = contexto.SystemSuiteId, + }, ct); + + segunda.StatusCode.Should().Be(HttpStatusCode.Created, await segunda.Content.ReadAsStringAsync(ct)); + + using var payload = JsonDocument.Parse(await segunda.Content.ReadAsStringAsync(ct)); + var segundaId = payload.RootElement.GetProperty("templateId").GetGuid(); + + var get = await Client.GetAsync($"/api/v1/permission-templates/{segundaId}", ct); + get.StatusCode.Should().Be(HttpStatusCode.OK); + using var detalle = JsonDocument.Parse(await get.Content.ReadAsStringAsync(ct)); + detalle.RootElement.GetProperty("version").GetString().Should().Be("0.2.0", + "la versión de la plantilla eliminada sigue ocupada en el índice único"); + } + + [Fact] + public async Task CargadorDelTablero_BorraLasPlantillasDuplicadas_YEnLaReejecucionYaNoLasVe() + { + if (!Fixture.IsAvailable) Assert.Skip("Docker es necesario para las pruebas de integración PostgreSQL."); + var ct = TestContext.Current.CancellationToken; + + // Reproduce la secuencia EXACTA de src/provisioning/sdlc/cargar-en-ums.mjs (paso 7): + // 1. lee el catálogo con GET /permission-templates?...&systemSuiteId=… + // 2. agrupa por rol, se queda con la canónica (menor versión) y borra las sobrantes con + // DELETE /permission-templates/{id}, contando como FALLO cualquier respuesta no-ok + // 3. en la siguiente pasada vuelve a leer el catálogo + // El borrado lógico tiene que bastarle: la plantilla deja de estorbar porque desaparece del + // catálogo. Si siguiera apareciendo, el cargador la trataría otra vez como sobrante, el + // segundo DELETE daría 404 y sumaría un fallo en cada reejecución. + var contexto = await ProvisionarSuiteYRolAsync(ct); + var canonica = await CrearPlantillaConItemAsync(contexto, ct); + var duplicada = await CrearPlantillaConItemAsync(contexto, ct); + + var primeraLectura = await LeerCatalogoDelCargadorAsync(contexto, ct); + primeraLectura.Should().Contain(canonica).And.Contain(duplicada, + "el alta repetida genera una versión más, que es justo el duplicado que el cargador limpia"); + + var borrado = await Client.DeleteAsync($"/api/v1/permission-templates/{duplicada}", ct); + borrado.IsSuccessStatusCode.Should().BeTrue( + "el cargador solo mira `r.ok`; cualquier otra cosa cuenta como fallo de carga"); + + var segundaLectura = await LeerCatalogoDelCargadorAsync(contexto, ct); + segundaLectura.Should().Contain(canonica); + segundaLectura.Should().NotContain(duplicada, + "en la reejecución la duplicada ya no aparece, así que el cargador no vuelve a intentar borrarla"); + } + + /// Misma consulta que hace el cargador del Tablero para construir su catálogo. + private async Task> LeerCatalogoDelCargadorAsync(ContextoDePrueba contexto, CancellationToken ct) + { + var response = await Client.GetAsync( + $"/api/v1/permission-templates?page=1&pageSize=200&tenantId={contexto.TenantId}&systemSuiteId={contexto.SystemSuiteId}", ct); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); + return payload.RootElement.GetProperty("items").EnumerateArray() + .Select(i => i.GetProperty("templateId").GetGuid()) + .ToList(); + } + + // ── Utilidades de aprovisionamiento ───────────────────────────────────── + + private sealed record ContextoDePrueba(Guid TenantId, Guid SystemSuiteId, Guid RoleId); + + private async Task ProvisionarSuiteYRolAsync(CancellationToken ct) + { + // El host PostgreSQL corre con SeedDevData=false: no hay ningún inquilino sembrado, así que + // cada prueba levanta el suyo (mismo patrón que PostgreSqlAuthorizationPersistenceTests). + var code = $"PTSD{Guid.NewGuid():N}"[..12].ToUpperInvariant(); + var tenantResponse = await Client.PostAsJsonAsync("/api/v1/tenants", new + { + code, + name = $"Inquilino de borrado lógico {code}", + type = "CLIENT", + isManagementOwner = false, + }, ct); + tenantResponse.StatusCode.Should().Be(HttpStatusCode.Created, await tenantResponse.Content.ReadAsStringAsync(ct)); + var tenantId = Guid.Parse(tenantResponse.Headers.Location!.ToString().Split('/')[^1]); + + // ADR-0077: aprovisionar recursos de un inquilino CLIENT es una operación ON-BEHALF que solo + // ejerce el internal-admin; sin la cabecera, TenantScopePolicy devuelve AUTH_015 → 400. + Client.DefaultRequestHeaders.Remove("X-Tenant-Id"); + Client.DefaultRequestHeaders.Add("X-Tenant-Id", tenantId.ToString()); + Client.DefaultRequestHeaders.Remove("X-Is-Internal-Admin"); + Client.DefaultRequestHeaders.Add("X-Is-Internal-Admin", "true"); + + var suiteResponse = await Client.PostAsJsonAsync("/api/v1/system-suites", new + { + tenantId, + code = $"SS{Guid.NewGuid():N}"[..10], + name = "Consola de operaciones", + description = "Suite para las pruebas de borrado lógico de plantillas.", + }, ct); + suiteResponse.StatusCode.Should().Be(HttpStatusCode.Created, await suiteResponse.Content.ReadAsStringAsync(ct)); + using var suitePayload = JsonDocument.Parse(await suiteResponse.Content.ReadAsStringAsync(ct)); + var systemSuiteId = suitePayload.RootElement.GetProperty("systemSuiteId").GetGuid(); + + var roleResponse = await Client.PostAsJsonAsync($"/api/v1/system-suites/{systemSuiteId}/roles", new + { + code = $"ROLE{Guid.NewGuid():N}"[..12].ToUpperInvariant(), + value = "Coordinador de operaciones", + description = "Rol para las pruebas de borrado lógico de plantillas.", + parentRoleId = (Guid?)null, + hierarchyLevel = 0, + promotionOrder = 0, + }, ct); + roleResponse.StatusCode.Should().Be(HttpStatusCode.Created, await roleResponse.Content.ReadAsStringAsync(ct)); + using var rolePayload = JsonDocument.Parse(await roleResponse.Content.ReadAsStringAsync(ct)); + var roleId = rolePayload.RootElement.GetProperty("roleId").GetGuid(); + + return new ContextoDePrueba(tenantId, systemSuiteId, roleId); + } + + private async Task CrearPlantillaConItemAsync(ContextoDePrueba contexto, CancellationToken ct) + { + var response = await Client.PostAsJsonAsync("/api/v1/permission-templates", new + { + tenantId = contexto.TenantId, + roleId = contexto.RoleId, + systemSuiteId = contexto.SystemSuiteId, + }, ct); + response.StatusCode.Should().Be(HttpStatusCode.Created, await response.Content.ReadAsStringAsync(ct)); + + using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); + var templateId = payload.RootElement.GetProperty("templateId").GetGuid(); + + // Publicar exige al menos un ítem; además el ítem es la evidencia de que el borrado lógico no + // arrastra por cascada el detalle de las concesiones. + var item = await Client.PostAsJsonAsync($"/api/v1/permission-templates/{templateId}/items", new + { + targetType = "SystemSuite", + targetId = contexto.SystemSuiteId, + actionId = Guid.NewGuid(), + isAllowed = true, + isDenied = false, + }, ct); + item.StatusCode.Should().Be(HttpStatusCode.Created, await item.Content.ReadAsStringAsync(ct)); + + return templateId; + } + + private async Task CrearPerfilActivoAsync(ContextoDePrueba contexto, CancellationToken ct) + { + var userResponse = await Client.PostAsJsonAsync("/api/v1/user-accounts", new + { + tenantId = contexto.TenantId, + branchId = (Guid?)null, + email = $"operador.{Guid.NewGuid():N}"[..24] + "@beyondnet.local", + category = "Internal", + identityReference = $"EMP-{Guid.NewGuid():N}"[..10], + identityReferenceType = "HrId", + }, ct); + userResponse.StatusCode.Should().Be(HttpStatusCode.Created, await userResponse.Content.ReadAsStringAsync(ct)); + using var userPayload = JsonDocument.Parse(await userResponse.Content.ReadAsStringAsync(ct)); + var userId = userPayload.RootElement.GetProperty("userAccountId").GetGuid(); + + var profileResponse = await Client.PostAsJsonAsync("/api/v1/profiles", new + { + tenantId = contexto.TenantId, + userId, + roleId = contexto.RoleId, + branchId = (Guid?)null, + }, ct); + profileResponse.StatusCode.Should().Be(HttpStatusCode.Created, await profileResponse.Content.ReadAsStringAsync(ct)); + using var profilePayload = JsonDocument.Parse(await profileResponse.Content.ReadAsStringAsync(ct)); + return profilePayload.RootElement.GetProperty("profileId").GetGuid(); + } + + /// + /// Contexto EF conectado al MISMO contenedor que el API, con inquilino nulo para que ningún filtro + /// global recorte la vista. Es la ventana al almacenamiento real: lo que el API oculta, aquí se ve. + /// + private UmsPlatformDbContext CrearContextoDirecto() + { + var options = new DbContextOptionsBuilder() + .UseNpgsql(Fixture.ConnectionString) + .Options; + + return new UmsPlatformDbContext( + options, + new ContextoDeInquilinoDelSistema(), + new Moq.Mock().Object, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + } + + private sealed class ContextoDeInquilinoDelSistema : ITenantContext + { + public Guid? OrganizationId => null; + public Guid? OriginalTenantId => null; + public bool IsInternalAdmin => true; + public void Initialize(Guid userTenantId, bool isInternalAdmin) { } + public void SetOrganizationId(Guid organizationId) { } + public void EnableCrossTenantAccess() { } + public void DisableCrossTenantAccess() { } + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Authorization/ProfileRestEndpointTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Authorization/ProfileRestEndpointTests.cs index 4fb9479a..45eb5137 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Authorization/ProfileRestEndpointTests.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Authorization/ProfileRestEndpointTests.cs @@ -19,6 +19,67 @@ public ProfileRestEndpointTests(UmsApiWebApplicationFactory factory) _client.DefaultRequestHeaders.Add("X-User-Id", "00000000-0000-0000-0000-000000000123"); _client.DefaultRequestHeaders.Add("X-User-Name", "Integration Tester"); _client.DefaultRequestHeaders.Add("X-Tenant-Id", CoreDevDataSeeder.InternalAdminTenantId); + // ADR-0071 / FS-26: INTERNAL_ADMIN ya no es management owner; las escrituras acotadas + // (crear usuario, sucursal, perfil) exigen contexto internal-admin explícito o devuelven + // AUTH_015 → 400. Se declara el rol de operador de gestión de forma explícita. + _client.DefaultRequestHeaders.Add("X-Is-Internal-Admin", "true"); + } + + /// + /// G-215 — un perfil por (usuario, rol, sucursal) activo. + /// + /// Nada lo impedía: cada llamada con los mismos datos creaba OTRO perfil. El + /// aprovisionamiento del Tablero SDLC, reejecutado, dejó a ocho usuarios con perfiles + /// duplicados —uno con concesiones y otro vacío, porque la plantilla se asigna a uno solo— y el + /// selector de perfil se los ofrecía indistinguibles. Elegir el vacío es entrar sin un solo + /// permiso, con todos los HTTP en 2xx. + /// + [Fact] + public async Task CreateProfile_Duplicado_Del_Mismo_Rol_Y_Sucursal_Se_Rechaza() + { + var ct = TestContext.Current.CancellationToken; + var tenantId = Guid.Parse(CoreDevDataSeeder.InternalAdminTenantId); + var userId = await CreateUserAsync(tenantId, ct); + var systemSuiteId = await GetManagementSystemSuiteIdAsync(tenantId, ct); + var roleId = await CreateRoleAsync(systemSuiteId, ct); + var branchId = await CreateBranchAsync(tenantId, ct); + + var primero = await _client.PostAsJsonAsync("/api/v1/profiles", + new { tenantId, userId, roleId, branchId }, ct); + primero.StatusCode.Should().Be(HttpStatusCode.Created); + + var segundo = await _client.PostAsJsonAsync("/api/v1/profiles", + new { tenantId, userId, roleId, branchId }, ct); + + segundo.StatusCode.Should().NotBe(HttpStatusCode.Created, + because: "el segundo perfil idéntico quedaría sin plantilla y el usuario podría elegirlo"); + var cuerpo = await segundo.Content.ReadAsStringAsync(ct); + cuerpo.Should().Contain("profile_already_exists_for_role"); + } + + /// + /// El mismo rol en OTRA sucursal sí es un perfil distinto: la guarda acota por + /// (usuario, rol, sucursal), no por (usuario, rol). Sin esta prueba, endurecer la guarda de más + /// —y romper el multi-sucursal, que es un caso real del negocio— pasaría inadvertido. + /// + [Fact] + public async Task CreateProfile_Mismo_Rol_En_Otra_Sucursal_Se_Admite() + { + var ct = TestContext.Current.CancellationToken; + var tenantId = Guid.Parse(CoreDevDataSeeder.InternalAdminTenantId); + var userId = await CreateUserAsync(tenantId, ct); + var systemSuiteId = await GetManagementSystemSuiteIdAsync(tenantId, ct); + var roleId = await CreateRoleAsync(systemSuiteId, ct); + var sucursalA = await CreateBranchAsync(tenantId, ct); + var sucursalB = await CreateBranchAsync(tenantId, ct); + + var a = await _client.PostAsJsonAsync("/api/v1/profiles", + new { tenantId, userId, roleId, branchId = sucursalA }, ct); + a.StatusCode.Should().Be(HttpStatusCode.Created); + + var b = await _client.PostAsJsonAsync("/api/v1/profiles", + new { tenantId, userId, roleId, branchId = sucursalB }, ct); + b.StatusCode.Should().Be(HttpStatusCode.Created); } [Fact] diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Configuration/AppConfigurationRanuraLiberadaE2ETests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Configuration/AppConfigurationRanuraLiberadaE2ETests.cs new file mode 100644 index 00000000..abf55834 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Configuration/AppConfigurationRanuraLiberadaE2ETests.cs @@ -0,0 +1,172 @@ +using Microsoft.EntityFrameworkCore; +using Ums.Infrastructure.Persistence.Configuration.Entities; +using Ums.Presentation.IntegrationTest.Infrastructure; + +namespace Ums.Presentation.IntegrationTest.Configuration; + +/// +/// E2E sobre PostgreSQL real (Testcontainers) de la excepción que el propietario del producto acotó +/// sobre ADR-0164 §2.3 el 2026-08-04: el borrado lógico de una configuración LIBERA su ranura. +/// +/// Por qué hace falta el motor real y no basta el host InMemory: lo que aquí se demuestra es que el +/// índice único de `AppConfigurations` es PARCIAL. Con el índice total, volver a crear la misma +/// (ámbito, código) tras borrarla reventaba con 23505 → 500. El almacén en memoria no tiene índices, +/// así que allí la regla se puede cumplir por accidente. +/// +/// El ámbito elegido tiene los tres identificadores presentes (inquilino + sistema + módulo) a +/// propósito. PostgreSQL trata los NULL como distintos dentro de un índice único, de modo que en el +/// ámbito Global —los tres NULL— el índice nunca llegó a arbitrar nada: la unicidad la sostiene solo +/// la comprobación del handler. Probar sobre el ámbito de módulo es lo único que interroga al índice. +/// +[Collection("PostgreSql")] +public sealed class AppConfigurationRanuraLiberadaE2ETests +{ + private static readonly Guid SeededTenantId = Guid.Parse("3fa85f64-5717-4562-b3fc-2c963f66afa6"); + private static readonly Guid SeededSystemSuiteId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + + private readonly PostgreSqlContainerFixture _fixture; + private readonly PostgreSqlWebApplicationFactory? _factory; + + public AppConfigurationRanuraLiberadaE2ETests(PostgreSqlContainerFixture fixture) + { + _fixture = fixture; + if (fixture.IsAvailable) + { + _factory = new PostgreSqlWebApplicationFactory(fixture.ConnectionString); + } + } + + [Fact] + public async Task ElIndiceUnicoDeConfiguraciones_EsParcial_EnLaBaseReal() + { + if (!_fixture.IsAvailable || _factory is null) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + // Se pregunta al catálogo del motor, no al modelo de EF: es la diferencia entre creer que la + // migración se aplicó y comprobarlo. + var definiciones = new List(); + var connection = db.Database.GetDbConnection(); + await db.Database.OpenConnectionAsync(ct); + await using (var command = connection.CreateCommand()) + { + command.CommandText = + "SELECT indexdef FROM pg_indexes " + + "WHERE schemaname = 'ums_configuration' AND indexname IN (" + + " 'IX_AppConfigurations_TenantId_SystemSuiteId_ModuleId_Code'," + + " 'IX_ParameterDefinitions_Code'," + + " 'IX_ParameterGlobalValues_ParameterDefinitionId'," + + " 'IX_ParameterTenantValues_TenantId_ParameterDefinitionId')"; + + await using var reader = await command.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + { + definiciones.Add(reader.GetString(0)); + } + } + + definiciones.Should().HaveCount(4, "los cuatro índices existen en el esquema"); + definiciones.Should().OnlyContain(d => d.Contains("WHERE", StringComparison.Ordinal), + "los cuatro son índices PARCIALES: sin el WHERE, la lápida seguiría ocupando la ranura"); + definiciones.Should().OnlyContain(d => d.Contains("UNIQUE", StringComparison.Ordinal), + "liberar la ranura no relaja la unicidad entre filas vivas"); + } + + /// + /// El ciclo completo contra el índice real: crear, borrar y volver a crear con el MISMO ámbito y + /// código. Cubre las cuatro mitades del cambio (recrear · las dos filas siguen ahí · la lectura + /// resuelve la viva · el duplicado de una viva sigue en conflicto). + /// + [Fact] + public async Task Configuracion_DeModulo_TrasBorrarse_SeVuelveACrear_YLaLecturaResuelveLaViva() + { + if (!_fixture.IsAvailable || _factory is null) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + var admin = CreateAdminClient(); + var moduleId = Guid.NewGuid(); + var code = $"DESPACHO_ALERTA_DIAS_{Guid.NewGuid():N}"[..30].ToUpperInvariant(); + + async Task CrearAsync(string value) => + await admin.PostAsJsonAsync("/api/v1/app-configurations", new + { + tenantId = SeededTenantId, + systemSuiteId = SeededSystemSuiteId, + moduleId, + code, + value, + description = "Días de antelación para avisar del vencimiento de un despacho", + isInheritable = false, + isEncrypted = false, + }, ct); + + var primera = await CrearAsync("5"); + primera.StatusCode.Should().Be(HttpStatusCode.Created); + using var primeraPayload = JsonDocument.Parse(await primera.Content.ReadAsStringAsync(ct)); + var primeraId = primeraPayload.RootElement.GetProperty("appConfigurationId").GetGuid(); + + (await admin.DeleteAsync($"/api/v1/app-configurations/{primeraId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + // (a) La ranura quedó libre: el mismo ámbito y código admiten un alta nueva. Contra el índice + // total esto era un 23505 → 500, y por la vía del handler un 409. + var segunda = await CrearAsync("7"); + segunda.StatusCode.Should().Be(HttpStatusCode.Created, + "borrar la configuración de un parámetro no puede impedir volver a configurarlo"); + using var segundaPayload = JsonDocument.Parse(await segunda.Content.ReadAsStringAsync(ct)); + var segundaId = segundaPayload.RootElement.GetProperty("appConfigurationId").GetGuid(); + + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + // (b) Las DOS filas siguen en la tabla. Se leen sin filtros: la API no distingue «oculto» de + // «borrado», así que preguntarle a ella no probaría nada (ADR-0164 §5). + var filas = await db.Set() + .IgnoreQueryFilters() + .Where(x => x.Code == code) + .ToListAsync(ct); + + filas.Should().HaveCount(2, "esto no es volver al borrado físico"); + filas.Single(x => x.Id == primeraId).StatusId.Should().Be(ConfigStatus.Deleted.Id); + filas.Single(x => x.Id == segundaId).StatusId.Should().NotBe(ConfigStatus.Deleted.Id); + + // (c) La lectura por ámbito+código resuelve LA VIVA, sin ambigüedad, con la lápida presente. + var repositorio = scope.ServiceProvider.GetRequiredService(); + var viva = await repositorio.GetByScopeAndCodeAsync(SeededTenantId, SeededSystemSuiteId, moduleId, code, ct); + viva.Should().NotBeNull(); + viva!.Props.Id.GetValue().Should().Be(segundaId, "el lookup por código nunca devuelve la lápida"); + + (await admin.GetAsync($"/api/v1/app-configurations/{segundaId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.OK); + (await admin.GetAsync($"/api/v1/app-configurations/{primeraId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NotFound, "la eliminada sigue oculta"); + + // (d) Duplicar la VIVA sigue dando conflicto legible del dominio, no un error de índice. + var duplicada = await CrearAsync("9"); + duplicada.StatusCode.Should().Be(HttpStatusCode.Conflict); + (await duplicada.Content.ReadAsStringAsync(ct)) + .Should().Contain("already exists", "el conflicto tiene que decir qué pasa"); + } + + private HttpClient CreateAdminClient() + { + var client = _factory!.CreateClient(new WebApplicationFactoryClientOptions + { + BaseAddress = new Uri("https://localhost"), + AllowAutoRedirect = false, + }); + client.DefaultRequestHeaders.Add("X-Tenant-Id", SeededTenantId.ToString()); + client.DefaultRequestHeaders.Add("X-Is-Internal-Admin", "true"); + return client; + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Configuration/AppConfigurationRestEndpointTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Configuration/AppConfigurationRestEndpointTests.cs index b4b5a6cd..2a49333f 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Configuration/AppConfigurationRestEndpointTests.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Configuration/AppConfigurationRestEndpointTests.cs @@ -11,6 +11,9 @@ public sealed class AppConfigurationRestEndpointTests : IClassFixture(); + _adminClient = factory.CreateClient(new WebApplicationFactoryClientOptions { BaseAddress = new Uri("https://localhost"), @@ -353,4 +358,166 @@ public async Task PublishAppConfiguration_WhenAlreadyPublished_ShouldReturn422() secondPublishResponse.StatusCode.Should().Be(HttpStatusCode.BadRequest); } + + // ----------------------------------------------------------------------- + // Delete (borrado LÓGICO: mismo contrato HTTP, la fila NO se pierde) + // ----------------------------------------------------------------------- + + [Fact] + public async Task DeleteAppConfiguration_WhenExists_Devuelve204_YElRegistroSigueExistiendo() + { + var code = $"FS20-DEL-{Guid.NewGuid():N}"; + + var createResponse = await _adminClient.PostAsJsonAsync("/api/v1/app-configurations", new + { + tenantId = (Guid?)null, systemSuiteId = (Guid?)null, moduleId = (Guid?)null, + code, value = "to-delete", description = "Borrado lógico", + isInheritable = false, isEncrypted = false, + }, TestContext.Current.CancellationToken); + createResponse.StatusCode.Should().Be(HttpStatusCode.Created); + using var createPayload = JsonDocument.Parse(await createResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + var configId = createPayload.RootElement.GetProperty("appConfigurationId").GetGuid(); + + var deleteResponse = await _adminClient.DeleteAsync( + $"/api/v1/app-configurations/{configId}", TestContext.Current.CancellationToken); + deleteResponse.StatusCode.Should().Be(HttpStatusCode.NoContent, "el contrato HTTP no cambia"); + + // La lectura la oculta… + var getResponse = await _adminClient.GetAsync( + $"/api/v1/app-configurations/{configId}", TestContext.Current.CancellationToken); + getResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); + + // …pero el registro SIGUE en el almacén, en estado terminal Deleted. Esta es la aserción que + // fija la política del propietario: sin ella, el borrado lógico no está demostrado. Se lee + // SIN filtros (`GetAllIncludingDeleted`) y ya no por `GetByScopeAndCodeAsync`: desde que el + // borrado libera la ranura, ese lookup solo devuelve la viva y no vería la lápida. + var stored = _store.GetAllIncludingDeleted() + .SingleOrDefault(item => string.Equals(item.Code.GetValue(), code, StringComparison.OrdinalIgnoreCase)); + stored.Should().NotBeNull("la fila jamás se elimina físicamente"); + stored!.Status.Should().Be(ConfigStatus.Deleted); + + // Y tampoco aparece en el listado. + var listResponse = await _adminClient.GetAsync( + "/api/v1/app-configurations?page=1&pageSize=200", TestContext.Current.CancellationToken); + listResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var listBody = await listResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + listBody.Should().NotContain(code, "lo eliminado no se lista"); + } + + /// + /// La ranura (ámbito, código) se LIBERA al borrar: borrar la configuración de un parámetro no + /// puede impedir volver a configurarlo. Es la excepción que el propietario del producto acotó + /// sobre ADR-0164 §2.3 el 2026-08-04, tras medirse el efecto en vivo: el código de una + /// configuración sale de un catálogo cerrado —`MFA_REQUIRED_FOR_ADMIN` es *el* nombre de ese + /// parámetro— y no identifica una cosa del mundo real, a diferencia del código de una sucursal. + /// + /// La prueba cubre las cuatro mitades del cambio: se puede recrear; las dos filas siguen ahí; + /// la lectura por código resuelve LA VIVA; y duplicar una viva sigue siendo conflicto. + /// + [Fact] + public async Task DeleteAppConfiguration_LiberaLaRanura_YLaLecturaPorCodigoResuelveLaViva() + { + var ct = TestContext.Current.CancellationToken; + var code = $"FS20-RANURA-{Guid.NewGuid():N}"; + + async Task CrearAsync(string value) => + await _adminClient.PostAsJsonAsync("/api/v1/app-configurations", new + { + tenantId = (Guid?)null, systemSuiteId = (Guid?)null, moduleId = (Guid?)null, + code, value, description = "Ranura de configuración", + isInheritable = false, isEncrypted = false, + }, ct); + + var primera = await CrearAsync("original"); + primera.StatusCode.Should().Be(HttpStatusCode.Created); + using var primeraPayload = JsonDocument.Parse(await primera.Content.ReadAsStringAsync(ct)); + var primeraId = primeraPayload.RootElement.GetProperty("appConfigurationId").GetGuid(); + + (await _adminClient.DeleteAsync($"/api/v1/app-configurations/{primeraId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + // (a) Se puede volver a crear con EL MISMO código. + var segunda = await CrearAsync("reconfigurada"); + segunda.StatusCode.Should().Be(HttpStatusCode.Created, + "una ranura de configuración liberada admite un alta nueva"); + using var segundaPayload = JsonDocument.Parse(await segunda.Content.ReadAsStringAsync(ct)); + var segundaId = segundaPayload.RootElement.GetProperty("appConfigurationId").GetGuid(); + + // (b) LAS DOS filas siguen en el almacén; la vieja marcada como eliminada. + var todas = _store.GetAllIncludingDeleted() + .Where(item => string.Equals(item.Code.GetValue(), code, StringComparison.OrdinalIgnoreCase)) + .ToList(); + todas.Should().HaveCount(2, "esto NO es volver al borrado físico: la historia se conserva"); + todas.Single(item => item.Props.Id.GetValue() == primeraId).Status.Should().Be(ConfigStatus.Deleted); + todas.Single(item => item.Props.Id.GetValue() == segundaId).Status.Should().Be(ConfigStatus.Draft); + + // (c) La lectura por código resuelve LA VIVA, sin ambigüedad. + var viva = await _store.GetByScopeAndCodeAsync(null, null, null, code, ct); + viva.Should().NotBeNull(); + viva!.Props.Id.GetValue().Should().Be(segundaId, "el lookup por código nunca devuelve la lápida"); + (await _adminClient.GetAsync($"/api/v1/app-configurations/{segundaId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.OK); + (await _adminClient.GetAsync($"/api/v1/app-configurations/{primeraId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NotFound, "la eliminada sigue oculta para las lecturas"); + + // (d) Duplicar una VIVA sigue dando conflicto legible. + var duplicada = await CrearAsync("duplicada"); + duplicada.StatusCode.Should().Be(HttpStatusCode.Conflict, + "liberar la ranura no debilita la unicidad entre las configuraciones vivas"); + (await duplicada.Content.ReadAsStringAsync(ct)) + .Should().Contain("already exists", "el conflicto tiene que decir qué pasa"); + } + + [Fact] + public async Task DeleteAppConfiguration_DosVeces_LaSegundaDevuelve404() + { + var code = $"FS20-DEL2-{Guid.NewGuid():N}"; + + var createResponse = await _adminClient.PostAsJsonAsync("/api/v1/app-configurations", new + { + tenantId = (Guid?)null, systemSuiteId = (Guid?)null, moduleId = (Guid?)null, + code, value = "v", description = "Doble borrado", + isInheritable = false, isEncrypted = false, + }, TestContext.Current.CancellationToken); + using var createPayload = JsonDocument.Parse(await createResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + var configId = createPayload.RootElement.GetProperty("appConfigurationId").GetGuid(); + + (await _adminClient.DeleteAsync($"/api/v1/app-configurations/{configId}", TestContext.Current.CancellationToken)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + var second = await _adminClient.DeleteAsync( + $"/api/v1/app-configurations/{configId}", TestContext.Current.CancellationToken); + + second.StatusCode.Should().Be(HttpStatusCode.NotFound, "para las lecturas ya no existe"); + } + + [Fact] + public async Task DeleteAppConfiguration_WhenNotFound_ShouldReturn404() + { + var deleteResponse = await _adminClient.DeleteAsync( + $"/api/v1/app-configurations/{Guid.NewGuid()}", TestContext.Current.CancellationToken); + + deleteResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task DeleteAppConfiguration_GlobalScope_AsTenantAdmin_ShouldReturn403() + { + // Internal admin creates a global config + var code = $"FS20-DEL-403-{Guid.NewGuid():N}"; + var createResponse = await _adminClient.PostAsJsonAsync("/api/v1/app-configurations", new + { + tenantId = (Guid?)null, systemSuiteId = (Guid?)null, moduleId = (Guid?)null, + code, value = "v", description = "Admin creates, tenant tries to delete", + isInheritable = false, isEncrypted = false, + }, TestContext.Current.CancellationToken); + createResponse.StatusCode.Should().Be(HttpStatusCode.Created); + using var createPayload = JsonDocument.Parse(await createResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + var configId = createPayload.RootElement.GetProperty("appConfigurationId").GetGuid(); + + var deleteResponse = await _tenantClient.DeleteAsync( + $"/api/v1/app-configurations/{configId}", TestContext.Current.CancellationToken); + + deleteResponse.StatusCode.Should().Be(HttpStatusCode.Forbidden); + } } diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Configuration/ConfigurationRestEndpointTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Configuration/ConfigurationRestEndpointTests.cs index 8dbc42e2..ff3b4031 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Configuration/ConfigurationRestEndpointTests.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Configuration/ConfigurationRestEndpointTests.cs @@ -72,4 +72,41 @@ public async Task GetAppConfigurations_ShouldReturnSeededConfiguration() .ToList(); codes.Should().Contain("SESSION_TIMEOUT_MINUTES"); } + + /// + /// G-104: eliminar un criterio inexistente de un feature flag EXISTENTE debe resolver 404. + /// El flag existe (se crea antes), así que el handler pasa la guarda de «flag no encontrado» y + /// el dominio devuelve el código estable configuration.criteria_not_found al direccionar el + /// criterio por su id. Antes ese código ni estaba en la lista del DomainErrorStatusMapper ni + /// contenía la frase inglesa «not found», así que colapsaba al 400 por defecto —contradiciendo el + /// contrato del endpoint, que ya declaraba .ProducesProblem(404)—. Recorre endpoint + mapeador + /// reales, verificando la clasificación por código con independencia del idioma del mensaje. + /// + [Fact] + public async Task RemoveFeatureFlagCriteria_WhenCriteriaDoesNotExist_ShouldReturn404() + { + var createResponse = await _client.PostAsJsonAsync("/api/v1/feature-flags", new + { + systemSuiteId = "11111111-1111-1111-1111-111111111111", + tenantId = (string?)null, + flagCode = $"g104_criteria_{Guid.NewGuid():N}", + flagType = "Boolean", + flagTargets = "tenant-console", + linkedResourceType = "Module", + linkedResourceId = "33333333-3333-3333-3333-333333333333", + rolloutPercentage = (int?)null, + }, TestContext.Current.CancellationToken); + + createResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + using var createdPayload = JsonDocument.Parse(await createResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + var featureFlagId = createdPayload.RootElement.GetProperty("featureFlagId").GetGuid(); + + var missingCriteriaId = Guid.NewGuid(); + var deleteResponse = await _client.DeleteAsync( + $"/api/v1/feature-flags/{featureFlagId}/criteria/{missingCriteriaId}", + TestContext.Current.CancellationToken); + + deleteResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); + } } diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Configuration/ConfigurationSecretExposureE2ETests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Configuration/ConfigurationSecretExposureE2ETests.cs new file mode 100644 index 00000000..1ad8702d --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Configuration/ConfigurationSecretExposureE2ETests.cs @@ -0,0 +1,350 @@ +using Microsoft.EntityFrameworkCore; +using Ums.Presentation.IntegrationTest.Infrastructure; + +namespace Ums.Presentation.IntegrationTest.Configuration; + +/// +/// G-088 (contexto de seguridad): E2E sobre un contenedor PostgreSQL real (Testcontainers) +/// que garantiza, extremo a extremo por REST, dos invariantes de Configuration que hasta ahora +/// sólo se probaban a nivel unitario: +/// +/// 1. Los valores marcados como cifrados/secretos NO se devuelven en claro por REST a un usuario +/// NO administrador (se redactan a "***"), y el texto cifrado crudo (prefijo AES256:) +/// NUNCA se serializa en la respuesta —ni siquiera al administrador, que lo recibe descifrado—. +/// Además se verifica el cifrado EN REPOSO leyendo la fila directamente de la base. +/// +/// 2. La evaluación de un feature flag responde correctamente al kill-switch (fail-closed: un flag +/// inactivo evalúa deshabilitado, ADR/G-048) y a los extremos del porcentaje de rollout +/// (0% ⇒ deshabilitado, 100% ⇒ habilitado) de forma determinista. +/// +/// Calca el patrón de +/// (host PostgreSQL de Testcontainers, cliente por actor). Para ejercer la vista de un usuario NO +/// administrador —la única en la que la redacción es observable— se usa el override aditivo y +/// retrocompatible X-Test-Is-Internal-Admin: false de . +/// +[Collection("PostgreSql")] +public sealed class ConfigurationSecretExposureE2ETests +{ + private const string EncryptedPrefix = "AES256:"; // Contrato de wire de AesValueEncryptionService. + + // Tenant sembrado por el host (coincide con el default de TestAuthHandler): el usuario NO + // administrador de este tenant puede resolver configuraciones Global (TenantId IS NULL). + private static readonly Guid SeededTenantId = Guid.Parse("3fa85f64-5717-4562-b3fc-2c963f66afa6"); + private static readonly Guid SeededSystemSuiteId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + + private readonly PostgreSqlContainerFixture _fixture; + private readonly PostgreSqlWebApplicationFactory? _factory; + + public ConfigurationSecretExposureE2ETests(PostgreSqlContainerFixture fixture) + { + _fixture = fixture; + if (fixture.IsAvailable) + { + _factory = new PostgreSqlWebApplicationFactory(fixture.ConnectionString); + } + } + + // --------------------------------------------------------------------------------------- + // 1. Redacción de secretos cifrados por REST (fuga de secretos) + // --------------------------------------------------------------------------------------- + + [Fact] + public async Task EncryptedConfig_IsStoredCiphered_RedactedForNonAdmin_AndNeverLeaksRawCiphertext() + { + if (!_fixture.IsAvailable || _factory is null) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + const string secretPlaintext = "S3cr3t-Api-Key-p!auth-9f2c-DO-NOT-LEAK"; + + var admin = CreateAdminClient(); + var nonAdmin = CreateNonAdminClient(); + + // 1. El administrador crea una configuración Global marcada como cifrada. + var code = $"G088-SECRET-{Guid.NewGuid():N}"; + var createResponse = await admin.PostAsJsonAsync("/api/v1/app-configurations", new + { + tenantId = (Guid?)null, + systemSuiteId = (Guid?)null, + moduleId = (Guid?)null, + code, + value = secretPlaintext, + description = "G-088 configuración con valor secreto", + isInheritable = false, + isEncrypted = true, + }, ct); + + createResponse.StatusCode.Should().Be(HttpStatusCode.Created); + using var createPayload = JsonDocument.Parse(await createResponse.Content.ReadAsStringAsync(ct)); + var configId = createPayload.RootElement.GetProperty("appConfigurationId").GetGuid(); + + // 2. Cifrado EN REPOSO: la fila persistida guarda el ciphertext (prefijo AES256:), nunca el claro. + string storedValue; + bool storedIsEncrypted; + using (var scope = _factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var record = await db.AppConfigurations + .IgnoreQueryFilters() + .FirstOrDefaultAsync(x => x.Id == configId, ct); + + record.Should().NotBeNull("la configuración debe haberse persistido"); + storedValue = record!.Value; + storedIsEncrypted = record.IsEncrypted; + } + + storedIsEncrypted.Should().BeTrue("la bandera de cifrado debe persistirse"); + storedValue.Should().StartWith(EncryptedPrefix, "un valor marcado como cifrado debe almacenarse cifrado en reposo"); + storedValue.Should().NotContain(secretPlaintext, "el texto en claro nunca debe persistirse"); + + // 3. Vista NO administrador: el valor se redacta a "***" y el cuerpo no filtra ni el claro + // ni el ciphertext crudo. + var nonAdminResponse = await nonAdmin.GetAsync($"/api/v1/app-configurations/{configId}", ct); + nonAdminResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var nonAdminBody = await nonAdminResponse.Content.ReadAsStringAsync(ct); + + using (var nonAdminJson = JsonDocument.Parse(nonAdminBody)) + { + nonAdminJson.RootElement.GetProperty("isEncrypted").GetBoolean().Should().BeTrue(); + nonAdminJson.RootElement.GetProperty("value").GetString() + .Should().Be("***", "un usuario no administrador nunca debe ver el valor de un secreto"); + } + + nonAdminBody.Should().NotContain(secretPlaintext, "FUGA: el secreto en claro apareció en la respuesta a un no administrador"); + nonAdminBody.Should().NotContain(EncryptedPrefix, "el texto cifrado crudo (AES256:) no forma parte del contrato de respuesta"); + + // 4. Vista administrador: recibe el valor DESCIFRADO (round-trip correcto), pero el ciphertext + // crudo tampoco se expone. + var adminResponse = await admin.GetAsync($"/api/v1/app-configurations/{configId}", ct); + adminResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var adminBody = await adminResponse.Content.ReadAsStringAsync(ct); + + using (var adminJson = JsonDocument.Parse(adminBody)) + { + adminJson.RootElement.GetProperty("value").GetString() + .Should().Be(secretPlaintext, "el administrador ve el secreto descifrado (round-trip de AesValueEncryptionService)"); + } + + adminBody.Should().NotContain(EncryptedPrefix, "ni siquiera al administrador se le devuelve el ciphertext crudo: se descifra"); + } + + [Fact] + public async Task PlaintextConfig_IsReturnedAsIs_ForBothAudiences() + { + if (!_fixture.IsAvailable || _factory is null) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + const string plainValue = "7200"; + + var admin = CreateAdminClient(); + var nonAdmin = CreateNonAdminClient(); + + // Configuración Global NO cifrada: control negativo de la redacción — no debe redactarse. + var code = $"G088-PLAIN-{Guid.NewGuid():N}"; + var createResponse = await admin.PostAsJsonAsync("/api/v1/app-configurations", new + { + tenantId = (Guid?)null, + systemSuiteId = (Guid?)null, + moduleId = (Guid?)null, + code, + value = plainValue, + description = "G-088 configuración no cifrada (control)", + isInheritable = false, + isEncrypted = false, + }, ct); + createResponse.StatusCode.Should().Be(HttpStatusCode.Created); + using var createPayload = JsonDocument.Parse(await createResponse.Content.ReadAsStringAsync(ct)); + var configId = createPayload.RootElement.GetProperty("appConfigurationId").GetGuid(); + + // En reposo se guarda tal cual (sin prefijo de cifrado). + using (var scope = _factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var record = await db.AppConfigurations.IgnoreQueryFilters().FirstAsync(x => x.Id == configId, ct); + record.Value.Should().Be(plainValue); + record.IsEncrypted.Should().BeFalse(); + } + + var nonAdminResponse = await nonAdmin.GetAsync($"/api/v1/app-configurations/{configId}", ct); + using var nonAdminJson = JsonDocument.Parse(await nonAdminResponse.Content.ReadAsStringAsync(ct)); + nonAdminJson.RootElement.GetProperty("value").GetString() + .Should().Be(plainValue, "un valor no cifrado no se redacta para nadie"); + nonAdminJson.RootElement.GetProperty("value").GetString() + .Should().NotBe("***"); + } + + // --------------------------------------------------------------------------------------- + // 2. Kill-switch y porcentaje de rollout (feature flags) extremo a extremo + // --------------------------------------------------------------------------------------- + + [Fact] + public async Task BooleanFlag_KillSwitch_DisablesEvaluationEndToEnd() + { + if (!_fixture.IsAvailable || _factory is null) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + var admin = CreateAdminClient(); + + var flagId = await CreateFlagAsync(admin, ct, flagType: "Boolean", rolloutPercentage: null); + + // Activo ⇒ habilitado (sin criterios: activo para todos). + (await admin.PostAsync($"/api/v1/feature-flags/{flagId}/activate", null, ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + var enabled = await EvaluateAsync(admin, flagId, ct); + enabled.IsEnabled.Should().BeTrue("un flag booleano activo sin criterios está habilitado para todos"); + + // Kill-switch: desactivar ⇒ deshabilitado (fail-closed). + (await admin.PostAsync($"/api/v1/feature-flags/{flagId}/deactivate", null, ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + var killed = await EvaluateAsync(admin, flagId, ct); + killed.IsEnabled.Should().BeFalse("el kill-switch (flag inactivo) debe deshabilitar la evaluación"); + killed.Reason.Should().Contain("not active"); + } + + [Fact] + public async Task PercentageFlag_ZeroRollout_EvaluatesDisabled() + { + if (!_fixture.IsAvailable || _factory is null) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + var admin = CreateAdminClient(); + + var flagId = await CreateFlagAsync(admin, ct, flagType: "Percentage", rolloutPercentage: 0); + (await admin.PostAsync($"/api/v1/feature-flags/{flagId}/activate", null, ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + var result = await EvaluateAsync(admin, flagId, ct, profileId: Guid.NewGuid()); + result.IsEnabled.Should().BeFalse("un rollout del 0% no habilita a ningún sujeto"); + result.Reason.Should().Contain("0"); + } + + [Fact] + public async Task PercentageFlag_FullRollout_EvaluatesEnabled() + { + if (!_fixture.IsAvailable || _factory is null) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + var admin = CreateAdminClient(); + + var flagId = await CreateFlagAsync(admin, ct, flagType: "Percentage", rolloutPercentage: 100); + (await admin.PostAsync($"/api/v1/feature-flags/{flagId}/activate", null, ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + var result = await EvaluateAsync(admin, flagId, ct, profileId: Guid.NewGuid()); + result.IsEnabled.Should().BeTrue("un rollout del 100% habilita a todo sujeto"); + result.Reason.Should().Contain("100"); + } + + [Fact] + public async Task PercentageFlag_MidRollout_IsDeterministicPerSubject() + { + if (!_fixture.IsAvailable || _factory is null) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + var admin = CreateAdminClient(); + + var flagId = await CreateFlagAsync(admin, ct, flagType: "Percentage", rolloutPercentage: 50); + (await admin.PostAsync($"/api/v1/feature-flags/{flagId}/activate", null, ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + // El mismo sujeto cae siempre en el mismo bucket ⇒ el resultado es estable entre llamadas + // (bucketing determinista FNV-1a). No acoplamos el test al valor concreto del bucket. + var subject = Guid.NewGuid(); + var first = await EvaluateAsync(admin, flagId, ct, profileId: subject); + var second = await EvaluateAsync(admin, flagId, ct, profileId: subject); + + second.IsEnabled.Should().Be(first.IsEnabled, "el porcentaje de rollout debe ser determinista por sujeto"); + } + + // --------------------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------------------- + + private async Task CreateFlagAsync(HttpClient client, CancellationToken ct, string flagType, int? rolloutPercentage) + { + var response = await client.PostAsJsonAsync("/api/v1/feature-flags", new + { + systemSuiteId = SeededSystemSuiteId, + tenantId = (Guid?)null, + flagCode = $"g088_flag_{Guid.NewGuid():N}", + flagType, + flagTargets = "g088-tests", + linkedResourceType = (string?)null, + linkedResourceId = (Guid?)null, + rolloutPercentage, + }, ct); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); + return payload.RootElement.GetProperty("featureFlagId").GetGuid(); + } + + private static async Task<(bool IsEnabled, string? Reason)> EvaluateAsync( + HttpClient client, Guid flagId, CancellationToken ct, Guid? profileId = null) + { + var response = await client.PostAsJsonAsync($"/api/v1/feature-flags/{flagId}/evaluate", new + { + tenantId = SeededTenantId, + profileId, + }, ct); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); + var root = payload.RootElement; + var reason = root.TryGetProperty("reason", out var r) && r.ValueKind == JsonValueKind.String ? r.GetString() : null; + return (root.GetProperty("isEnabled").GetBoolean(), reason); + } + + // En el host de pruebas (entorno Development) DevAuthMiddleware corre ANTES de la autenticación + // y es quien inicializa el ITenantContext a partir de las cabeceras X-Tenant-Id / X-Is-Internal-Admin + // (la autenticación de TestAuthHandler sólo gobierna el IUserContext). Por eso el privilegio + // transversal se conmuta con la cabecera X-Is-Internal-Admin de DevAuth, no vía claims. + private HttpClient CreateAdminClient() + { + var client = _factory!.CreateClient(new WebApplicationFactoryClientOptions + { + BaseAddress = new Uri("https://localhost"), + AllowAutoRedirect = false, + }); + client.DefaultRequestHeaders.Add("X-Tenant-Id", SeededTenantId.ToString()); + client.DefaultRequestHeaders.Add("X-Is-Internal-Admin", "true"); + return client; + } + + private HttpClient CreateNonAdminClient() + { + var client = _factory!.CreateClient(new WebApplicationFactoryClientOptions + { + BaseAddress = new Uri("https://localhost"), + AllowAutoRedirect = false, + }); + client.DefaultRequestHeaders.Add("X-Tenant-Id", SeededTenantId.ToString()); + client.DefaultRequestHeaders.Add("X-Is-Internal-Admin", "false"); + return client; + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Configuration/ConfigurationSeedCompletenessTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Configuration/ConfigurationSeedCompletenessTests.cs index 6d1eed3d..352158b2 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Configuration/ConfigurationSeedCompletenessTests.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Configuration/ConfigurationSeedCompletenessTests.cs @@ -39,8 +39,15 @@ public async Task SeededTenants_ShouldHaveCompleteConfigurationCatalogs() appConfigCodes.Should().Contain("UI_LANGUAGE_DEFAULT"); appConfigCodes.Should().Contain("UI_TIMEZONE_DEFAULT"); + // G-014: las feature flags se siembran POR SUITE (ConfigurationDevDataSeeder. + // GetFeatureFlagDefinitions: UMS=7, WMS=3), no un catálogo fijo por inquilino. Un + // inquilino con solo suite WMS obtiene 3 (BEYONDNET: +PAITA_AGROEXPORT = 4). El umbral + // fijo >=10 asumía que todo inquilino tuviera suites UMS+WMS, lo que el seed NO + // garantiza. La completitud real es que cada inquilino sembrado tenga las flags de + // su(s) suite(s): no vacío. var featureFlags = await featureFlagRepository.GetAllAsync(tenantId, ct); - featureFlags.Count.Should().BeGreaterOrEqualTo(10); + featureFlags.Should().NotBeEmpty( + because: "cada inquilino sembrado con suite debe recibir las feature flags de su(s) suite(s)"); var tenantParameters = await tenantParameterRepository.GetByTenantIdAsync(tenantId, ct); tenantParameters.Should().HaveCount(10); diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Configuration/ParameterDefinitionSoftDeleteE2ETests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Configuration/ParameterDefinitionSoftDeleteE2ETests.cs new file mode 100644 index 00000000..56f9b5cb --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Configuration/ParameterDefinitionSoftDeleteE2ETests.cs @@ -0,0 +1,451 @@ +using Microsoft.EntityFrameworkCore; +using Ums.Infrastructure.Configuration; +using Ums.Infrastructure.Persistence.Configuration.Entities; +using Ums.Presentation.IntegrationTest.Infrastructure; + +namespace Ums.Presentation.IntegrationTest.Configuration; + +/// +/// E2E sobre PostgreSQL real (Testcontainers) de la política de borrado del catálogo de parámetros: +/// solo existe borrado lógico. Sobre esta configuración se hacen consultas históricas, así que +/// la fila nunca puede desaparecer: una definición retirada hace un año tiene que seguir explicando +/// por qué el sistema se comportó como se comportó. +/// +/// Lo que fija cada prueba: +/// 1. `DELETE /parameter-definitions/{id}` responde 204 —el contrato HTTP no cambió— y la FILA +/// SIGUE EN LA BASE con `IsDeleted = true`. Sin esta segunda mitad, el cambio no está demostrado. +/// 2. Tras el borrado, el GET responde 404 y la definición desaparece del listado. +/// 3. Regla transaccional: con un valor global VIVO, el borrado se rechaza con 409. +/// 4. Con ese mismo valor ya eliminado lógicamente, el borrado sí procede — y el valor también +/// sigue en la base. +/// +/// Usa el host PostgreSQL (no el InMemory) porque solo ahí comandos y consultas del catálogo de +/// parámetros comparten el mismo almacén, que es justo lo que hay que observar. +/// +[Collection("PostgreSql")] +public sealed class ParameterDefinitionSoftDeleteE2ETests +{ + private static readonly Guid SeededTenantId = Guid.Parse("3fa85f64-5717-4562-b3fc-2c963f66afa6"); + + private readonly PostgreSqlContainerFixture _fixture; + private readonly PostgreSqlWebApplicationFactory? _factory; + + public ParameterDefinitionSoftDeleteE2ETests(PostgreSqlContainerFixture fixture) + { + _fixture = fixture; + if (fixture.IsAvailable) + { + _factory = new PostgreSqlWebApplicationFactory(fixture.ConnectionString); + } + } + + [Fact] + public async Task Delete_Devuelve204_YLaFilaSigueEnLaBase() + { + if (!_fixture.IsAvailable || _factory is null) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + var admin = CreateAdminClient(); + var (definitionId, code) = await CreateDefinitionAsync(admin, ct); + + var deleteResponse = await admin.DeleteAsync($"/api/v1/parameter-definitions/{definitionId}", ct); + deleteResponse.StatusCode.Should().Be(HttpStatusCode.NoContent, "el contrato HTTP no cambia"); + + // LA PRUEBA DE LA POLÍTICA: la fila sigue ahí, marcada, con su sello de quién y cuándo. + using (var scope = _factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var record = await db.Set() + .IgnoreQueryFilters() + .FirstOrDefaultAsync(x => x.Id == definitionId, ct); + + record.Should().NotBeNull("el borrado es lógico: la fila jamás se elimina"); + record!.IsDeleted.Should().BeTrue(); + record.Code.Should().Be(code, "el dato histórico se conserva íntegro"); + record.DeletedAtUtc.Should().NotBeNull("hay que poder decir cuándo se retiró"); + record.DeletedBy.Should().NotBeNullOrWhiteSpace("hay que poder decir quién la retiró"); + record.IsActive.Should().BeFalse("una definición eliminada tampoco resuelve"); + } + + // …y, aun así, para las lecturas ya no existe. + (await admin.GetAsync($"/api/v1/parameter-definitions/{definitionId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NotFound); + + var listBody = await (await admin.GetAsync("/api/v1/parameter-definitions", ct)) + .Content.ReadAsStringAsync(ct); + listBody.Should().NotContain(code, "lo eliminado no aparece en los listados"); + } + + [Fact] + public async Task Delete_ConValorGlobalVivo_Devuelve409() + { + if (!_fixture.IsAvailable || _factory is null) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + var admin = CreateAdminClient(); + var (definitionId, _) = await CreateDefinitionAsync(admin, ct); + await CreateGlobalValueAsync(admin, definitionId, ct); + + var deleteResponse = await admin.DeleteAsync($"/api/v1/parameter-definitions/{definitionId}", ct); + + deleteResponse.StatusCode.Should().Be(HttpStatusCode.Conflict, + because: "no se elimina lógicamente algo con referencias reales vivas"); + + var body = await deleteResponse.Content.ReadAsStringAsync(ct); + body.Should().Contain("parameter_has_active_values"); + body.Should().Contain("ParameterGlobalValue", "la respuesta dice QUÉ bloquea"); + + // La definición sigue viva: un rechazo no deja rastro. + (await admin.GetAsync($"/api/v1/parameter-definitions/{definitionId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Delete_ConValorGlobalYaEliminadoLogicamente_SiProcede() + { + if (!_fixture.IsAvailable || _factory is null) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + var admin = CreateAdminClient(); + var (definitionId, _) = await CreateDefinitionAsync(admin, ct); + var valueId = await CreateGlobalValueAsync(admin, definitionId, ct); + + // Con el valor vivo, bloquea… + (await admin.DeleteAsync($"/api/v1/parameter-definitions/{definitionId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.Conflict); + + // …se elimina lógicamente el dependiente… + (await admin.DeleteAsync($"/api/v1/parameter-definitions/{definitionId}/global-values/{valueId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + // …y entonces la definición ya se puede eliminar: la referencia dejó de ser real. + (await admin.DeleteAsync($"/api/v1/parameter-definitions/{definitionId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + // Ninguna de las dos filas se perdió. + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var definitionRecord = await db.Set() + .IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == definitionId, ct); + definitionRecord.Should().NotBeNull(); + definitionRecord!.IsDeleted.Should().BeTrue(); + + var valueRecord = await db.Set() + .IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == valueId, ct); + valueRecord.Should().NotBeNull("el valor dependiente tampoco se borra físicamente"); + valueRecord!.StatusId.Should().Be(ConfigStatus.Deleted.Id); + } + + [Fact] + public async Task Delete_ConOverrideDeInquilinoVivo_Devuelve409_YTrasEliminarloProcede() + { + if (!_fixture.IsAvailable || _factory is null) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + var admin = CreateAdminClient(); + var (definitionId, _) = await CreateDefinitionAsync(admin, ct); + + var createValue = await admin.PostAsJsonAsync( + $"/api/v1/parameter-definitions/{definitionId}/tenant-values", + new { definitionId, tenantId = SeededTenantId, value = "48" }, ct); + createValue.StatusCode.Should().Be(HttpStatusCode.Created); + using var valuePayload = JsonDocument.Parse(await createValue.Content.ReadAsStringAsync(ct)); + var valueId = valuePayload.RootElement.GetGuid(); + + (await admin.DeleteAsync($"/api/v1/parameter-definitions/{definitionId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.Conflict); + + (await admin.DeleteAsync($"/api/v1/parameter-definitions/{definitionId}/tenant-values/{valueId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + (await admin.DeleteAsync($"/api/v1/parameter-definitions/{definitionId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var valueRecord = await db.Set() + .IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == valueId, ct); + valueRecord.Should().NotBeNull(); + valueRecord!.StatusId.Should().Be(ConfigStatus.Deleted.Id); + } + + [Fact] + public async Task Delete_DosVeces_LaSegundaDevuelve404() + { + if (!_fixture.IsAvailable || _factory is null) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + var admin = CreateAdminClient(); + var (definitionId, _) = await CreateDefinitionAsync(admin, ct); + + (await admin.DeleteAsync($"/api/v1/parameter-definitions/{definitionId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + (await admin.DeleteAsync($"/api/v1/parameter-definitions/{definitionId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NotFound, "para las lecturas ya no existe"); + } + + /// + /// La ranura del catálogo se LIBERA al borrar. Contra PostgreSQL real, que es lo único que + /// demuestra que el índice único parcial existe: si siguiera siendo total, el segundo alta no + /// devolvería 201 sino un 23505 convertido en 500. + /// + /// Cubre las cuatro mitades: se recrea, las dos filas siguen ahí, la lectura por código resuelve + /// la viva, y duplicar una viva sigue siendo conflicto legible. + /// + [Fact] + public async Task Definicion_TrasBorrarse_LiberaSuCodigo_YLaLecturaResuelveLaViva() + { + if (!_fixture.IsAvailable || _factory is null) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + var admin = CreateAdminClient(); + var (primeraId, code) = await CreateDefinitionAsync(admin, ct); + + (await admin.DeleteAsync($"/api/v1/parameter-definitions/{primeraId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + // (a) El MISMO código vuelve a poder declararse. + var segundaId = await CreateDefinitionWithCodeAsync(admin, code, ct); + + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + // (b) Las dos filas conviven; la vieja marcada. Se lee SIN filtros: preguntar a la API no + // distinguiría «oculto» de «borrado» (ADR-0164 §5). + var filas = await db.Set() + .IgnoreQueryFilters() + .Where(x => x.Code == code) + .ToListAsync(ct); + + filas.Should().HaveCount(2, "liberar la ranura no borra la fila anterior"); + filas.Single(x => x.Id == primeraId).IsDeleted.Should().BeTrue(); + filas.Single(x => x.Id == segundaId).IsDeleted.Should().BeFalse(); + + // (c) La lectura por código resuelve LA VIVA: el filtro global deja una sola candidata. + var repositorio = scope.ServiceProvider.GetRequiredService(); + var viva = await repositorio.GetByCodeAsync(code, ct); + viva.Should().NotBeNull(); + viva!.Props.Id.GetValue().Should().Be(segundaId, "nunca se resuelve la lápida"); + + (await admin.GetAsync($"/api/v1/parameter-definitions/{segundaId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.OK); + (await admin.GetAsync($"/api/v1/parameter-definitions/{primeraId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NotFound); + + // (d) Duplicar una VIVA sigue siendo conflicto de dominio legible, no un error de índice. + var duplicada = await PostDefinitionAsync(admin, code, ct); + duplicada.IsSuccessStatusCode.Should().BeFalse("dos definiciones vivas no pueden compartir código"); + (await duplicada.Content.ReadAsStringAsync(ct)) + .Should().Contain("parameter_code_not_unique", "el rechazo lo nombra el dominio, no PostgreSQL"); + } + + /// + /// Mismo criterio para los VALORES: global y de inquilino. Borrar el valor de un parámetro no + /// puede impedir volver a fijarlo, y las lápidas no pueden hacer ambigua la resolución. + /// + [Fact] + public async Task ValorGlobal_TrasBorrarse_SePuedeVolverAFijar_YSoloUnoVivo() + { + if (!_fixture.IsAvailable || _factory is null) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + var admin = CreateAdminClient(); + var (definitionId, _) = await CreateDefinitionAsync(admin, ct); + + var primerValorId = await CreateGlobalValueAsync(admin, definitionId, ct); + (await admin.DeleteAsync($"/api/v1/parameter-definitions/{definitionId}/global-values/{primerValorId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + // (a) Vuelve a admitir un valor global. + var segundoValorId = await CreateGlobalValueAsync(admin, definitionId, ct); + + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + // (b) Las dos filas siguen en la base. + var filas = await db.Set() + .IgnoreQueryFilters() + .Where(x => x.ParameterDefinitionId == definitionId) + .ToListAsync(ct); + filas.Should().HaveCount(2); + filas.Single(x => x.Id == primerValorId).StatusId.Should().Be(ConfigStatus.Deleted.Id); + filas.Single(x => x.Id == segundoValorId).StatusId.Should().NotBe(ConfigStatus.Deleted.Id); + + // (c) La resolución devuelve el VIVO. Se instancia el servicio contra este mismo DbContext + // porque el host no lo registra; lo que importa es su consulta, no cómo se resuelve. + // Sin el filtro de estado, el `ToDictionary` interno reventaría por clave duplicada en + // cuanto conviven la lápida y el valor nuevo — la ambigüedad se paga en tiempo de + // ejecución, no en una aserción. + var resolucion = new ParameterResolutionService(db); + var globales = await resolucion.GetGlobalParametersAsync(ct); + globales.Should().ContainSingle(p => p.DefinitionId == definitionId); + + var repositorio = scope.ServiceProvider.GetRequiredService(); + var vivo = await repositorio.GetByDefinitionIdAsync(definitionId, ct); + vivo.Should().NotBeNull(); + vivo!.Props.Id.GetValue().Should().Be(segundoValorId); + + // (d) Un segundo valor sobre el VIVO sigue rechazándose. + var duplicado = await admin.PostAsJsonAsync( + $"/api/v1/parameter-definitions/{definitionId}/global-values", + new { definitionId, value = "99" }, ct); + duplicado.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task ValorDeInquilino_TrasBorrarse_SePuedeVolverAFijar_YSoloUnoVivo() + { + if (!_fixture.IsAvailable || _factory is null) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + var admin = CreateAdminClient(); + var (definitionId, _) = await CreateDefinitionAsync(admin, ct); + + var primerValorId = await CreateTenantValueAsync(admin, definitionId, "48", ct); + (await admin.DeleteAsync($"/api/v1/parameter-definitions/{definitionId}/tenant-values/{primerValorId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + var segundoValorId = await CreateTenantValueAsync(admin, definitionId, "72", ct); + + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var filas = await db.Set() + .IgnoreQueryFilters() + .Where(x => x.TenantId == SeededTenantId && x.ParameterDefinitionId == definitionId) + .ToListAsync(ct); + filas.Should().HaveCount(2); + filas.Single(x => x.Id == primerValorId).StatusId.Should().Be(ConfigStatus.Deleted.Id); + filas.Single(x => x.Id == segundoValorId).StatusId.Should().NotBe(ConfigStatus.Deleted.Id); + + var repositorio = scope.ServiceProvider.GetRequiredService(); + var vivo = await repositorio.GetByTenantAndDefinitionAsync(SeededTenantId, definitionId, ct); + vivo.Should().NotBeNull(); + vivo!.Props.Id.GetValue().Should().Be(segundoValorId, "la resolución del inquilino usa el override VIVO"); + + var duplicado = await admin.PostAsJsonAsync( + $"/api/v1/parameter-definitions/{definitionId}/tenant-values", + new { definitionId, tenantId = SeededTenantId, value = "96" }, ct); + duplicado.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task Delete_ComoNoAdministrador_Devuelve403() + { + if (!_fixture.IsAvailable || _factory is null) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + var admin = CreateAdminClient(); + var (definitionId, _) = await CreateDefinitionAsync(admin, ct); + + var nonAdmin = CreateClient(isInternalAdmin: false); + + (await nonAdmin.DeleteAsync($"/api/v1/parameter-definitions/{definitionId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.Forbidden, "la autorización del borrado no cambia"); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private static async Task<(Guid Id, string Code)> CreateDefinitionAsync(HttpClient admin, CancellationToken ct) + { + // Código realista del dominio aduanero + sufijo único; Code lo normaliza a mayúsculas. + var code = $"SESSION_TIMEOUT_{Guid.NewGuid():N}"[..24].ToUpperInvariant(); + return (await CreateDefinitionWithCodeAsync(admin, code, ct), code); + } + + private static async Task CreateDefinitionWithCodeAsync(HttpClient admin, string code, CancellationToken ct) + { + var response = await PostDefinitionAsync(admin, code, ct); + response.StatusCode.Should().Be(HttpStatusCode.Created); + using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); + return payload.RootElement.GetGuid(); + } + + private static Task PostDefinitionAsync(HttpClient admin, string code, CancellationToken ct) + => admin.PostAsJsonAsync("/api/v1/parameter-definitions", new + { + code, + name = "Tiempo de expiración de sesión", + description = "Minutos de inactividad antes de cerrar la sesión del operador aduanero", + dataTypeId = 2, // Number + defaultValue = "30", + scopeId = 3, // GlobalAndTenant + isMandatory = false, + displayOrder = 10, + }, ct); + + private static async Task CreateTenantValueAsync( + HttpClient admin, Guid definitionId, string value, CancellationToken ct) + { + var response = await admin.PostAsJsonAsync( + $"/api/v1/parameter-definitions/{definitionId}/tenant-values", + new { definitionId, tenantId = SeededTenantId, value }, ct); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); + return payload.RootElement.GetGuid(); + } + + private static async Task CreateGlobalValueAsync(HttpClient admin, Guid definitionId, CancellationToken ct) + { + var response = await admin.PostAsJsonAsync( + $"/api/v1/parameter-definitions/{definitionId}/global-values", + new { definitionId, value = "45" }, ct); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); + return payload.RootElement.GetGuid(); + } + + private HttpClient CreateAdminClient() => CreateClient(isInternalAdmin: true); + + private HttpClient CreateClient(bool isInternalAdmin) + { + var client = _factory!.CreateClient(new WebApplicationFactoryClientOptions + { + BaseAddress = new Uri("https://localhost"), + AllowAutoRedirect = false, + }); + client.DefaultRequestHeaders.Add("X-Tenant-Id", SeededTenantId.ToString()); + client.DefaultRequestHeaders.Add("X-Is-Internal-Admin", isInternalAdmin ? "true" : "false"); + return client; + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/AccessEnforcementPolicyE2ETests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/AccessEnforcementPolicyE2ETests.cs index 481791e5..f91ea844 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/AccessEnforcementPolicyE2ETests.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/AccessEnforcementPolicyE2ETests.cs @@ -37,6 +37,13 @@ public AccessEnforcementPolicyE2ETests(PostgreSqlContainerFixture fixture) }); _client.DefaultRequestHeaders.Add("X-User-Id", "00000000-0000-0000-0000-000000000001"); _client.DefaultRequestHeaders.Add("X-User-Name", "e2e-test"); + // ADR-0077 (evolith-core#18): estas pruebas aprovisionan SystemSuite + Role sobre un + // inquilino CLIENT auxiliar (X-Tenant-Id apunta a él). Eso es una acción ON-BEHALF que + // sólo el operador de gestión (internal-admin) puede ejecutar; sin esta cabecera, + // TenantScopePolicy devuelve AUTH_015 → 400 en el setup. No hay tests de denegación en + // esta clase: el único 4xx afirmado (CreatePolicy_WithoutProfileOrRole → 400) proviene + // de la validación de dominio (falta profile/role), no del scope, y sigue siendo válido. + _client.DefaultRequestHeaders.Add("X-Is-Internal-Admin", "true"); } else { @@ -201,12 +208,14 @@ private async Task CreateTenantId(CancellationToken ct) type = "CLIENT", idpStrategy = (string?)null, companyReference = (string?)null, - isManagementOwner = true + // G-037: el management owner es único a nivel de sistema; un inquilino CLIENT + // auxiliar nunca debe reclamarlo (colisionaría con el pre-check → 409). + isManagementOwner = false }, ct); response.EnsureSuccessStatusCode(); var location = response.Headers.Location?.ToString(); - var idString = location!.Split('/').Last(); + var idString = location!.Split('/')[^1]; var id = Guid.Parse(idString); _client.DefaultRequestHeaders.Remove("X-Tenant-Id"); diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/IntegridadYResultPatternE2ETests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/IntegridadYResultPatternE2ETests.cs new file mode 100644 index 00000000..4bb754f1 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/IntegridadYResultPatternE2ETests.cs @@ -0,0 +1,188 @@ +using Ums.Presentation.IntegrationTest.Infrastructure; +using Ums.Infrastructure.Persistence.Seeders; + +namespace Ums.Presentation.IntegrationTest.E2E; + +/// +/// E2E dedicados de integridad de datos y patrón Result (gaps G-037, G-045, G-046). +/// +/// Verifican que caminos que antes colapsaban a 500 ahora resuelven un +/// Result.Failure mapeado al estado HTTP correcto: +/// - G-037: un segundo management owner (por POST /tenants y por +/// set-management-owner) devuelve 409, no 500. +/// - G-045: un enum inválido en el payload y un listado sin paginación +/// obligatoria nunca colapsan a 500 (400/422, sin excepciones de control de flujo). +/// - G-046: una referencia fallbackToId colgante en una IdpConfiguration +/// se rechaza por integridad referencial (4xx), no colapsa a 500. +/// +/// Usa (dev-seed activo → el inquilino BEYONDNET +/// ya es el único management owner), de modo que las aserciones de unicidad son +/// deterministas con independencia del orden de ejecución. +/// +public sealed class IntegridadYResultPatternE2ETests : IClassFixture +{ + private readonly HttpClient _client; + + public IntegridadYResultPatternE2ETests(UmsApiWebApplicationFactory factory) + { + _client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + BaseAddress = new Uri("https://localhost"), + AllowAutoRedirect = false, + }); + _client.DefaultRequestHeaders.Add("X-User-Id", "00000000-0000-0000-0000-000000000001"); + _client.DefaultRequestHeaders.Add("X-User-Name", "integridad-e2e"); + _client.DefaultRequestHeaders.Add("X-Tenant-Id", CoreDevDataSeeder.InternalAdminTenantId); + } + + // ───────────────────────────────────────────────────────────────────────── + // G-037 — un segundo management owner devuelve 409, no 500 + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task CrearSegundoManagementOwner_Devuelve409() + { + var ct = TestContext.Current.CancellationToken; + + // Garantiza que exista un management owner (lo crea si aún no lo hubiera). + var primero = await _client.PostAsJsonAsync("/api/v1/tenants", NewOwnerPayload(), ct); + primero.StatusCode.Should().BeOneOf(HttpStatusCode.Created, HttpStatusCode.Conflict); + + // Con un owner ya existente, un segundo intento debe rechazarse con 409 (no 500). + var segundo = await _client.PostAsJsonAsync("/api/v1/tenants", NewOwnerPayload(), ct); + + segundo.StatusCode.Should().Be(HttpStatusCode.Conflict, + because: "el management owner es único a nivel de sistema; el pre-check de dominio debe resolver 409 antes de persistir"); + } + + [Fact] + public async Task SetManagementOwner_SobreSegundoInquilino_Devuelve409() + { + var ct = TestContext.Current.CancellationToken; + + // Asegura que exista un management owner en el sistema. + (await _client.PostAsJsonAsync("/api/v1/tenants", NewOwnerPayload(), ct)) + .StatusCode.Should().BeOneOf(HttpStatusCode.Created, HttpStatusCode.Conflict); + + // Crea un inquilino CLIENT que NO es owner. + var createRes = await _client.PostAsJsonAsync("/api/v1/tenants", NewClientPayload(), ct); + createRes.StatusCode.Should().Be(HttpStatusCode.Created); + var tenantId = await ReadGuid(createRes, "tenantId", ct); + + // Intentar otorgarle la propiedad de gestión debe devolver 409, no 500. + var setRes = await _client.PostAsJsonAsync( + $"/api/v1/tenants/{tenantId}/set-management-owner", new { value = true }, ct); + + setRes.StatusCode.Should().Be(HttpStatusCode.Conflict, + because: "ya existe un management owner; el segundo debe rechazarse con 409 vía chequeo explícito de dominio"); + } + + // ───────────────────────────────────────────────────────────────────────── + // G-045 — enum inválido y listado sin paginación no colapsan a 500 + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task CrearTenant_TipoEnumInvalido_NoColapsaA500() + { + var ct = TestContext.Current.CancellationToken; + + var payload = new + { + code = $"ENUM{Guid.NewGuid():N}"[..10].ToUpperInvariant(), + name = "Tenant con tipo inválido", + type = "NO_ES_UN_TIPO_VALIDO", + idpStrategy = (string?)null, + companyReference = (string?)null, + isManagementOwner = false, + }; + + var res = await _client.PostAsJsonAsync("/api/v1/tenants", payload, ct); + + ((int)res.StatusCode).Should().BeLessThan(500, + because: "un enum desconocido debe resolver Result.Failure (400/422), nunca una NullReferenceException que colapse a 500"); + res.StatusCode.Should().BeOneOf(HttpStatusCode.BadRequest, HttpStatusCode.UnprocessableEntity); + } + + [Fact] + public async Task ListarSystemSuites_SinPaginacionObligatoria_NoColapsaA500() + { + var ct = TestContext.Current.CancellationToken; + + // page/pageSize son parámetros de query obligatorios (no anulables). Omitirlos + // producía un 500 disfrazado de «Internal Server Error»; ahora ASP.NET lo resuelve + // como 400 vía el GlobalExceptionHandler (BadHttpRequestException → 400). + var res = await _client.GetAsync("/api/v1/system-suites", ct); + + ((int)res.StatusCode).Should().BeLessThan(500, + because: "un listado sin paginación obligatoria debe devolver 400, no un 500 disfrazado"); + res.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + // ───────────────────────────────────────────────────────────────────────── + // G-046 — integridad referencial: fallbackToId colgante no colapsa a 500 + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task CrearIdpConfiguration_FallbackColgante_NoColapsaA500() + { + var ct = TestContext.Current.CancellationToken; + + var payload = new + { + tenantId = Guid.Parse(CoreDevDataSeeder.InternalAdminTenantId), + systemSuiteId = Guid.Parse(CoreDevDataSeeder.InternalAdminTenantId), + providerType = "AZURE_AD", + domainHints = new[] { "corp.local" }, + configPayload = "{\"authority\":\"https://login.microsoftonline.com/tenant-x\"}", + secretRef = "kv/idp/dangling", + resolutionPriority = 10, + // Referencia colgante: ninguna IdpConfiguration con este id existe. + fallbackToId = (Guid?)Guid.NewGuid(), + }; + + var res = await _client.PostAsJsonAsync("/api/v1/idp-configurations", payload, ct); + + ((int)res.StatusCode).Should().BeLessThan(500, + because: "un fallbackToId colgante debe rechazarse por integridad referencial (4xx), nunca colapsar a 500"); + ((int)res.StatusCode).Should().BeGreaterThanOrEqualTo(400, + because: "la creación con una referencia colgante no debe tener éxito"); + } + + // ───────────────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────────────── + + private static object NewOwnerPayload() + { + var uid = Guid.NewGuid().ToString("N")[..10].ToUpperInvariant(); + return new + { + code = $"OWN{uid}", + name = $"Owner {uid}", + type = "INTERNAL", + idpStrategy = (string?)null, + companyReference = (string?)null, + isManagementOwner = true, + }; + } + + private static object NewClientPayload() + { + var uid = Guid.NewGuid().ToString("N")[..10].ToUpperInvariant(); + return new + { + code = $"CLI{uid}", + name = $"Client {uid}", + type = "CLIENT", + idpStrategy = (string?)null, + companyReference = (string?)null, + isManagementOwner = false, + }; + } + + private static async Task ReadGuid(HttpResponseMessage response, string property, CancellationToken ct) + { + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); + return doc.RootElement.GetProperty(property).GetGuid(); + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/RoleE2ETests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/RoleE2ETests.cs index 6d5f3c6f..6c0543fa 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/RoleE2ETests.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/RoleE2ETests.cs @@ -7,7 +7,7 @@ namespace Ums.Presentation.IntegrationTest.E2E; /// /// E2E tests for the Role bounded context (Authorization). -/// Covers creation, update, lifecycle status, and GraphQL list exposure against +/// Covers creation, update, lifecycle status, and REST list exposure against /// a real SQL Server Testcontainer. /// /// Each test creates its own Tenant + SystemSuite to guarantee isolation. @@ -32,6 +32,11 @@ public RoleE2ETests(PostgreSqlContainerFixture fixture) }); _client.DefaultRequestHeaders.Add("X-User-Id", "00000000-0000-0000-0000-000000000001"); _client.DefaultRequestHeaders.Add("X-User-Name", "e2e-test"); + // ADR-0077 (G-014 residual): tras fijar X-Tenant-Id a un inquilino CLIENT auxiliar, + // el aprovisionamiento (suites/roles) es una operación ON-BEHALF que solo el + // operador management-owner/internal-admin puede ejecutar (TenantScopePolicy → AUTH_015). + // Estos E2E aprovisionan recursos para el CLIENT, modelando al internal-admin de BEYONDNET. + _client.DefaultRequestHeaders.Add("X-Is-Internal-Admin", "true"); } else { @@ -40,7 +45,7 @@ public RoleE2ETests(PostgreSqlContainerFixture fixture) } [Fact] - public async Task CreateRole_ValidPayload_Returns201AndAppearsInGraphQl() + public async Task CreateRole_ValidPayload_Returns201AndAppearsInRestList() { if (!_fixture.IsAvailable) Assert.Skip("Docker required."); var ct = TestContext.Current.CancellationToken; @@ -63,11 +68,10 @@ public async Task CreateRole_ValidPayload_Returns201AndAppearsInGraphQl() var roleId = await ReadGuid(createResponse, "roleId", ct); - using var doc = await GqlRolesBySystemSuiteAsync(suiteId, ct); - var roles = doc.RootElement.GetProperty("data").GetProperty("rolesBySystemSuite"); + using var doc = await GetRolesBySystemSuiteAsync(suiteId, ct); - roles.EnumerateArray().Any(role => role.GetProperty("roleId").GetGuid() == roleId).Should().BeTrue( - because: "the created role should be visible in the system suite graph"); + doc.RootElement.EnumerateArray().Any(role => role.GetProperty("roleId").GetGuid() == roleId).Should().BeTrue( + because: "the created role should be visible in the system suite roles list"); } [Fact] @@ -91,8 +95,8 @@ public async Task UpdateRole_ValidPayload_Returns204AndPersistsChanges() updateResponse.StatusCode.Should().Be(HttpStatusCode.NoContent); - using var doc = await GqlRolesBySystemSuiteAsync(suiteId, ct); - var role = doc.RootElement.GetProperty("data").GetProperty("rolesBySystemSuite") + using var doc = await GetRolesBySystemSuiteAsync(suiteId, ct); + var role = doc.RootElement .EnumerateArray() .First(candidate => candidate.GetProperty("roleId").GetGuid() == roleId); @@ -113,9 +117,9 @@ public async Task SetRoleStatus_DeactivateAndReactivate_Returns204AndPersistsSta var deactivateResponse = await _client.PostAsync($"/api/v1/system-suites/{suiteId}/roles/{roleId}/deactivate", null, ct); deactivateResponse.StatusCode.Should().Be(HttpStatusCode.NoContent); - using (var afterDeactivate = await GqlRolesBySystemSuiteAsync(suiteId, ct)) + using (var afterDeactivate = await GetRolesBySystemSuiteAsync(suiteId, ct)) { - afterDeactivate.RootElement.GetProperty("data").GetProperty("rolesBySystemSuite") + afterDeactivate.RootElement .EnumerateArray() .First(candidate => candidate.GetProperty("roleId").GetGuid() == roleId) .GetProperty("isActive").GetBoolean().Should().BeFalse(); @@ -124,8 +128,8 @@ public async Task SetRoleStatus_DeactivateAndReactivate_Returns204AndPersistsSta var activateResponse = await _client.PostAsync($"/api/v1/system-suites/{suiteId}/roles/{roleId}/activate", null, ct); activateResponse.StatusCode.Should().Be(HttpStatusCode.NoContent); - using var afterActivate = await GqlRolesBySystemSuiteAsync(suiteId, ct); - afterActivate.RootElement.GetProperty("data").GetProperty("rolesBySystemSuite") + using var afterActivate = await GetRolesBySystemSuiteAsync(suiteId, ct); + afterActivate.RootElement .EnumerateArray() .First(candidate => candidate.GetProperty("roleId").GetGuid() == roleId) .GetProperty("isActive").GetBoolean().Should().BeTrue(); @@ -158,37 +162,13 @@ public async Task CreateRole_DuplicateCode_Returns409() duplicate.StatusCode.Should().BeOneOf(HttpStatusCode.Conflict, HttpStatusCode.BadRequest); } - private async Task GqlRolesBySystemSuiteAsync(Guid suiteId, CancellationToken ct) + private async Task GetRolesBySystemSuiteAsync(Guid suiteId, CancellationToken ct) { - var query = $$""" - query RolesBySystemSuite($systemSuiteId: UUID!) { - rolesBySystemSuite(systemSuiteId: $systemSuiteId) { - roleId - tenantId - systemSuiteId - parentRoleId - code - value - description - hierarchyLevel - promotionOrder - isActive - } - } - """; + var response = await _client.GetAsync($"/api/v1/system-suites/{suiteId}/roles", ct); - var response = await _client.PostAsJsonAsync("/graphql", new - { - query, - variables = new { systemSuiteId = suiteId }, - }, ct); - - response.EnsureSuccessStatusCode(); + response.StatusCode.Should().Be(HttpStatusCode.OK); - var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); - doc.RootElement.TryGetProperty("errors", out _).Should().BeFalse( - because: "the GraphQL query should not return errors"); - return doc; + return JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); } private async Task CreateRoleId(Guid suiteId, CancellationToken ct) @@ -235,12 +215,14 @@ private async Task CreateTenantId(CancellationToken ct) type = "CLIENT", idpStrategy = (string?)null, companyReference = (string?)null, - isManagementOwner = true + // G-037: el management owner es único a nivel de sistema; un inquilino CLIENT + // auxiliar nunca debe reclamarlo (colisionaría con el pre-check → 409). + isManagementOwner = false }, ct); response.EnsureSuccessStatusCode(); var location = response.Headers.Location?.ToString(); - var idString = location!.Split('/').Last(); + var idString = location!.Split('/')[^1]; var id = Guid.Parse(idString); _client.DefaultRequestHeaders.Remove("X-Tenant-Id"); diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/SystemSuiteE2ETests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/SystemSuiteE2ETests.cs index 74bb34b7..d559ba2a 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/SystemSuiteE2ETests.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/SystemSuiteE2ETests.cs @@ -15,7 +15,7 @@ namespace Ums.Presentation.IntegrationTest.E2E; /// /// Architecture: /// - Commands → REST API (POST / PUT / DELETE) -/// - Queries → GraphQL (POST /graphql) +/// - Queries → REST API (GET) /// /// Each test creates its own Tenant + SystemSuite to guarantee isolation. /// Prerequisites: Docker must be running locally. @@ -40,6 +40,13 @@ public SystemSuiteE2ETests(PostgreSqlContainerFixture fixture) }); _client.DefaultRequestHeaders.Add("X-User-Id", "00000000-0000-0000-0000-000000000001"); _client.DefaultRequestHeaders.Add("X-User-Name", "e2e-test"); + // ADR-0077: aprovisionar recursos (system-suites, módulos, roles, acciones, app-settings) es + // una operación ON-BEHALF que solo el internal-admin/management-owner puede ejecutar. Estos E2E + // fijan X-Tenant-Id a un inquilino CLIENT auxiliar (isManagementOwner:false), por lo que sin + // contexto internal-admin explícito TenantScopePolicy devuelve AUTH_015 → 400. Se modela al + // internal-admin de BEYONDNET aprovisionando en nombre del CLIENT. Esta clase no contiene tests de + // denegación/aislamiento, así que el header global es seguro y no altera ningún 4xx esperado. + _client.DefaultRequestHeaders.Add("X-Is-Internal-Admin", "true"); } else { @@ -59,7 +66,7 @@ public async Task CreateSystemSuite_ValidPayload_Returns201WithId() var tenantId = await CreateTenantId(ct); var response = await _client.PostAsJsonAsync("/api/v1/system-suites", NewSuitePayload(tenantId), ct); - + response.StatusCode.Should().Be(HttpStatusCode.Created); response.Headers.Location.Should().NotBeNull(); using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); @@ -67,7 +74,7 @@ public async Task CreateSystemSuite_ValidPayload_Returns201WithId() } [Fact] - public async Task CreateSystemSuite_MissingName_Returns422() + public async Task CreateSystemSuite_MissingName_Returns400() { if (!_fixture.IsAvailable) Assert.Skip("Docker required."); var ct = TestContext.Current.CancellationToken; @@ -75,7 +82,11 @@ public async Task CreateSystemSuite_MissingName_Returns422() var payload = new { code = UniqueCode("INV"), description = "No Name" }; // Missing Name var res = await _client.PostAsJsonAsync("/api/v1/system-suites", payload, ct); - res.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + // La API devuelve 400 (BadRequest) de forma consistente para errores de validación de + // campos (p. ej. CreateUserAccount_InvalidEmail_Returns400); el 422 se reserva para + // conflicto/duplicado. Se alinea el test al contrato real. (Convención 400 vs 422 global + // = decisión de producto, fuera de alcance de este test.) + res.StatusCode.Should().Be(HttpStatusCode.BadRequest); } [Fact] @@ -94,11 +105,11 @@ public async Task CreateSystemSuite_DuplicateCode_SameTenant_Returns409() } // ───────────────────────────────────────────────────────────────────────── - // READ — via GraphQL + // READ — via REST // ───────────────────────────────────────────────────────────────────────── [Fact] - public async Task GetSystemSuiteById_ExistingSuite_GqlReturnsCorrectFields() + public async Task GetSystemSuiteById_ExistingSuite_ReturnsCorrectFields() { if (!_fixture.IsAvailable) Assert.Skip("Docker required."); var ct = TestContext.Current.CancellationToken; @@ -109,10 +120,9 @@ public async Task GetSystemSuiteById_ExistingSuite_GqlReturnsCorrectFields() createRes.StatusCode.Should().Be(HttpStatusCode.Created); var suiteId = await ReadGuid(createRes, "systemSuiteId", ct); - using var doc = await GqlSuiteByIdAsync(suiteId, ct); - var suite = doc.RootElement.GetProperty("data").GetProperty("systemSuiteById"); + using var doc = await GetSuiteByIdAsync(suiteId, ct); + var suite = doc.RootElement; - suite.ValueKind.Should().NotBe(JsonValueKind.Null, because: "suite should exist"); suite.GetProperty("systemSuiteId").GetGuid().Should().Be(suiteId); suite.GetProperty("tenantId").GetGuid().Should().Be(tenantId); suite.GetProperty("code").GetString().Should().Be(payload.Code); @@ -123,28 +133,26 @@ public async Task GetSystemSuiteById_ExistingSuite_GqlReturnsCorrectFields() } [Fact] - public async Task GetSystemSuiteById_NonExistent_GqlReturnsNull() + public async Task GetSystemSuiteById_NonExistent_Returns404() { if (!_fixture.IsAvailable) Assert.Skip("Docker required."); var ct = TestContext.Current.CancellationToken; - using var doc = await GqlSuiteByIdAsync(Guid.NewGuid(), ct); - var suite = doc.RootElement.GetProperty("data").GetProperty("systemSuiteById"); + var res = await _client.GetAsync($"/api/v1/system-suites/{Guid.NewGuid()}", ct); - suite.ValueKind.Should().Be(JsonValueKind.Null, - because: "querying a non-existent suite ID should return null"); + res.StatusCode.Should().Be(HttpStatusCode.NotFound, + because: "querying a non-existent suite ID should return 404"); } [Fact] - public async Task GetSystemSuites_Pagination_GqlReturnsPageMetadata() + public async Task GetSystemSuites_Pagination_ReturnsPageMetadata() { if (!_fixture.IsAvailable) Assert.Skip("Docker required."); var ct = TestContext.Current.CancellationToken; - const string gql = "{ systemSuites(page: 1, pageSize: 5) { page pageSize totalItems items { systemSuiteId code } } }"; - using var doc = await GqlQueryAsync(gql, ct); + using var doc = await GetSuitesAsync("page=1&pageSize=5", ct); - var list = doc.RootElement.GetProperty("data").GetProperty("systemSuites"); + var list = doc.RootElement; list.GetProperty("page").GetInt32().Should().Be(1); list.GetProperty("pageSize").GetInt32().Should().Be(5); list.GetProperty("totalItems").GetInt32().Should().BeGreaterThanOrEqualTo(0); @@ -152,7 +160,7 @@ public async Task GetSystemSuites_Pagination_GqlReturnsPageMetadata() } [Fact] - public async Task GetSystemSuites_FilterByTenantId_GqlOnlyReturnsTenantSuites() + public async Task GetSystemSuites_FilterByTenantId_OnlyReturnsTenantSuites() { if (!_fixture.IsAvailable) Assert.Skip("Docker required."); var ct = TestContext.Current.CancellationToken; @@ -161,10 +169,9 @@ public async Task GetSystemSuites_FilterByTenantId_GqlOnlyReturnsTenantSuites() (await _client.PostAsJsonAsync("/api/v1/system-suites", NewSuitePayload(tenantId), ct)) .StatusCode.Should().Be(HttpStatusCode.Created); - var gql = $"{{ systemSuites(page: 1, pageSize: 50, tenantId: \"{tenantId}\") {{ items {{ systemSuiteId tenantId code }} }} }}"; - using var doc = await GqlQueryAsync(gql, ct); + using var doc = await GetSuitesAsync($"page=1&pageSize=50&tenantId={tenantId}", ct); - var items = doc.RootElement.GetProperty("data").GetProperty("systemSuites").GetProperty("items"); + var items = doc.RootElement.GetProperty("items"); items.GetArrayLength().Should().BeGreaterThan(0); foreach (var item in items.EnumerateArray()) { @@ -189,8 +196,8 @@ public async Task UpdateSystemSuite_ValidPayload_Returns204AndPersistsChanges() var updateRes = await _client.PutAsJsonAsync($"/api/v1/system-suites/{suiteId}", updatePayload, ct); updateRes.StatusCode.Should().Be(HttpStatusCode.NoContent); - using var doc = await GqlSuiteByIdAsync(suiteId, ct); - var suite = doc.RootElement.GetProperty("data").GetProperty("systemSuiteById"); + using var doc = await GetSuiteByIdAsync(suiteId, ct); + var suite = doc.RootElement; suite.GetProperty("name").GetString().Should().Be("Updated Suite Name"); suite.GetProperty("description").GetString().Should().Be("Updated description for E2E test"); } @@ -219,14 +226,15 @@ public async Task SetSystemSuiteStatus_Inactive_Returns204AndPersistsStatus() var ct = TestContext.Current.CancellationToken; var suiteId = await CreateSuiteId(ct); - var payload = new { systemSuiteId = suiteId, status = "Inactive" }; + // Los estados de SUITE son Active/Maintenance/Deprecated (SystemStatus); "Inactive" solo aplica a + // módulos (ModuleStatus). Se usa "Maintenance" como estado no-activo válido. + var payload = new { systemSuiteId = suiteId, status = "Maintenance" }; var res = await _client.PutAsJsonAsync($"/api/v1/system-suites/{suiteId}/status", payload, ct); - + res.StatusCode.Should().Be(HttpStatusCode.NoContent); - using var doc = await GqlSuiteByIdAsync(suiteId, ct); - doc.RootElement.GetProperty("data").GetProperty("systemSuiteById") - .GetProperty("status").GetString().Should().Be("Inactive"); + using var doc = await GetSuiteByIdAsync(suiteId, ct); + doc.RootElement.GetProperty("status").GetString().Should().Be("Maintenance"); } [Fact] @@ -236,14 +244,15 @@ public async Task SetSystemSuiteStatus_BackToActive_Returns204() var ct = TestContext.Current.CancellationToken; var suiteId = await CreateSuiteId(ct); - await _client.PostAsync($"/api/v1/system-suites/{suiteId}/status?status=Inactive", null, ct); + // El endpoint de status es PUT con body { status } (no POST con query param); estados de suite + // válidos: Active/Maintenance/Deprecated. + await _client.PutAsJsonAsync($"/api/v1/system-suites/{suiteId}/status", new { systemSuiteId = suiteId, status = "Maintenance" }, ct); - var res = await _client.PostAsync($"/api/v1/system-suites/{suiteId}/status?status=Active", null, ct); + var res = await _client.PutAsJsonAsync($"/api/v1/system-suites/{suiteId}/status", new { systemSuiteId = suiteId, status = "Active" }, ct); res.StatusCode.Should().Be(HttpStatusCode.NoContent); - using var doc = await GqlSuiteByIdAsync(suiteId, ct); - doc.RootElement.GetProperty("data").GetProperty("systemSuiteById") - .GetProperty("status").GetString().Should().Be("Active"); + using var doc = await GetSuiteByIdAsync(suiteId, ct); + doc.RootElement.GetProperty("status").GetString().Should().Be("Active"); } // ───────────────────────────────────────────────────────────────────────── @@ -263,8 +272,8 @@ public async Task AddModule_ValidPayload_Returns204AndAppearsInSuite() var res = await _client.PostAsJsonAsync($"/api/v1/system-suites/{suiteId}/modules", payload, ct); res.StatusCode.Should().Be(HttpStatusCode.Created); - using var doc = await GqlSuiteByIdAsync(suiteId, ct); - var modules = doc.RootElement.GetProperty("data").GetProperty("systemSuiteById").GetProperty("modules"); + using var doc = await GetSuiteByIdAsync(suiteId, ct); + var modules = doc.RootElement.GetProperty("modules"); var found = modules.EnumerateArray().Any(m => m.GetProperty("code").GetString() == moduleCode); found.Should().BeTrue(because: "the module should appear in the suite after being added"); } @@ -280,9 +289,9 @@ public async Task UpdateModule_ValidPayload_Returns204AndPersistsChanges() await _client.PostAsJsonAsync($"/api/v1/system-suites/{suiteId}/modules", new { systemSuiteId = suiteId, code = moduleCode, name = "Original", description = "Desc", sortOrder = 1 }, ct); - // Get moduleId via GraphQL - using var beforeDoc = await GqlSuiteByIdAsync(suiteId, ct); - var moduleId = beforeDoc.RootElement.GetProperty("data").GetProperty("systemSuiteById") + // Get moduleId via REST + using var beforeDoc = await GetSuiteByIdAsync(suiteId, ct); + var moduleId = beforeDoc.RootElement .GetProperty("modules").EnumerateArray() .First(m => m.GetProperty("code").GetString() == moduleCode) .GetProperty("id").GetGuid(); @@ -291,8 +300,8 @@ await _client.PostAsJsonAsync($"/api/v1/system-suites/{suiteId}/modules", var res = await _client.PutAsJsonAsync($"/api/v1/system-suites/{suiteId}/modules/{moduleId}", updatePayload, ct); res.StatusCode.Should().Be(HttpStatusCode.NoContent); - using var afterDoc = await GqlSuiteByIdAsync(suiteId, ct); - var module = afterDoc.RootElement.GetProperty("data").GetProperty("systemSuiteById") + using var afterDoc = await GetSuiteByIdAsync(suiteId, ct); + var module = afterDoc.RootElement .GetProperty("modules").EnumerateArray() .First(m => m.GetProperty("id").GetGuid() == moduleId); module.GetProperty("name").GetString().Should().Be("Updated Module"); @@ -310,21 +319,26 @@ public async Task ModuleLifecycle_DeactivateActivateRemove_FullCycle() await _client.PostAsJsonAsync($"/api/v1/system-suites/{suiteId}/modules", new { systemSuiteId = suiteId, code, name = "Lifecycle Module", description = "E2E lifecycle", sortOrder = 5 }, ct); - // Get moduleId via GraphQL - using var addedDoc = await GqlSuiteByIdAsync(suiteId, ct); - var moduleId = addedDoc.RootElement.GetProperty("data").GetProperty("systemSuiteById") + // Get moduleId via REST + using var addedDoc = await GetSuiteByIdAsync(suiteId, ct); + var moduleId = addedDoc.RootElement .GetProperty("modules").EnumerateArray() .First(m => m.GetProperty("code").GetString() == code) .GetProperty("id").GetGuid(); + // Los módulos se crean Inactivos (se activan explícitamente antes de usarse); hay que activar + // antes de poder desactivar en este ciclo de vida. + (await _client.PostAsync($"/api/v1/system-suites/{suiteId}/modules/{moduleId}/activate", null, ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + // Deactivate (await _client.PostAsync($"/api/v1/system-suites/{suiteId}/modules/{moduleId}/deactivate", null, ct)) .StatusCode.Should().Be(HttpStatusCode.NoContent); - // Verify deactivated via GraphQL - using (var deactivatedDoc = await GqlSuiteByIdAsync(suiteId, ct)) + // Verify deactivated via REST + using (var deactivatedDoc = await GetSuiteByIdAsync(suiteId, ct)) { - deactivatedDoc.RootElement.GetProperty("data").GetProperty("systemSuiteById") + deactivatedDoc.RootElement .GetProperty("modules").EnumerateArray() .First(m => m.GetProperty("id").GetGuid() == moduleId) .GetProperty("status").GetString().Should().Be("Inactive"); @@ -342,9 +356,9 @@ await _client.PostAsJsonAsync($"/api/v1/system-suites/{suiteId}/modules", (await _client.DeleteAsync($"/api/v1/system-suites/{suiteId}/modules/{moduleId}", ct)) .StatusCode.Should().Be(HttpStatusCode.NoContent); - // Verify gone via GraphQL - using var afterDoc = await GqlSuiteByIdAsync(suiteId, ct); - var still = afterDoc.RootElement.GetProperty("data").GetProperty("systemSuiteById") + // Verify gone via REST + using var afterDoc = await GetSuiteByIdAsync(suiteId, ct); + var still = afterDoc.RootElement .GetProperty("modules").EnumerateArray() .Any(m => m.GetProperty("id").GetGuid() == moduleId); still.Should().BeFalse(because: "removed module should not appear in suite"); @@ -473,8 +487,8 @@ public async Task RegisterAction_ValidPayload_Returns204AndAppearsInSuite() var res = await _client.PostAsJsonAsync($"/api/v1/system-suites/{suiteId}/actions", payload, ct); res.StatusCode.Should().Be(HttpStatusCode.Created); - using var doc = await GqlSuiteByIdAsync(suiteId, ct); - var actions = doc.RootElement.GetProperty("data").GetProperty("systemSuiteById").GetProperty("actions"); + using var doc = await GetSuiteByIdAsync(suiteId, ct); + var actions = doc.RootElement.GetProperty("actions"); actions.EnumerateArray().Any(a => a.GetProperty("code").GetString() == code).Should().BeTrue( because: "registered action should appear in suite actions"); } @@ -493,8 +507,8 @@ await _client.PostAsJsonAsync($"/api/v1/system-suites/{suiteId}/actions", var res = await _client.DeleteAsync($"/api/v1/system-suites/{suiteId}/actions/{code}", ct); res.StatusCode.Should().Be(HttpStatusCode.NoContent); - using var doc = await GqlSuiteByIdAsync(suiteId, ct); - doc.RootElement.GetProperty("data").GetProperty("systemSuiteById") + using var doc = await GetSuiteByIdAsync(suiteId, ct); + doc.RootElement .GetProperty("actions").EnumerateArray() .Any(a => a.GetProperty("code").GetString() == code).Should().BeFalse( because: "removed action should not appear in suite"); @@ -532,27 +546,34 @@ public async Task RemoveAction_NonExistentCode_Returns404() // Helpers // ───────────────────────────────────────────────────────────────────────── - /// Sends a raw GraphQL query to POST /graphql and returns the parsed response. - private async Task GqlQueryAsync(string gql, CancellationToken ct) + /// + /// Gets a system suite by ID via GET /api/v1/system-suites/{id}. Caller must dispose. + /// Fails the test if the suite does not exist (non-200). + /// + private async Task GetSuiteByIdAsync(Guid suiteId, CancellationToken ct) { - var res = await _client.PostAsJsonAsync("/graphql", new { query = gql }, ct); - res.EnsureSuccessStatusCode(); - var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync(ct)); - doc.RootElement.TryGetProperty("errors", out _).Should().BeFalse( - because: "GraphQL query should not return errors"); - return doc; + var res = await _client.GetAsync($"/api/v1/system-suites/{suiteId}", ct); + res.StatusCode.Should().Be(HttpStatusCode.OK, because: "the suite should exist"); + return JsonDocument.Parse(await res.Content.ReadAsStringAsync(ct)); } /// - /// Queries systemSuiteById(systemSuiteId) with full fields via GraphQL. Caller must dispose. + /// Gets a paged list of system suites via GET /api/v1/system-suites?{query}. Caller must dispose. + /// The root element is the page: { items, page, pageSize, totalItems, totalPages }. /// - private Task GqlSuiteByIdAsync(Guid suiteId, CancellationToken ct) => - GqlQueryAsync( - $"{{ systemSuiteById(systemSuiteId: \"{suiteId}\") {{ systemSuiteId tenantId code name description status modules {{ id code name description status sortOrder }} actions {{ id code name }} }} }}", - ct); + private async Task GetSuitesAsync(string query, CancellationToken ct) + { + var res = await _client.GetAsync($"/api/v1/system-suites?{query}", ct); + res.StatusCode.Should().Be(HttpStatusCode.OK); + return JsonDocument.Parse(await res.Content.ReadAsStringAsync(ct)); + } + // Los códigos se normalizan en el dominio (DomainGuards.NormalizeCode: Trim + ToUpperInvariant + + // espacios→'_'). El GUID :N es hex en minúsculas, así que se normaliza aquí para que el código + // enviado coincida con el que el API almacena y devuelve (evita falsos "no aparece" por diferencia + // de mayúsculas en las comparaciones de round-trip). private static string UniqueCode(string prefix) - => $"{prefix}{Guid.NewGuid():N}"[..Math.Min(20, prefix.Length + 32)]; + => $"{prefix}{Guid.NewGuid():N}"[..Math.Min(20, prefix.Length + 32)].ToUpperInvariant(); private record SuitePayload(Guid TenantId, string Code, string Name, string Description); private static SuitePayload NewSuitePayload(Guid tenantId) @@ -564,12 +585,13 @@ private static SuitePayload NewSuitePayload(Guid tenantId) private async Task CreateTenantId(CancellationToken ct) { var uid = Guid.NewGuid().ToString("N")[..10].ToUpper(); - var payload = new { code = $"T{uid}", name = $"E2E SS Tenant {uid}", type = "CLIENT", idpStrategy = (string?)null, companyReference = (string?)null, isManagementOwner = true }; + // G-037: management owner único; el inquilino CLIENT auxiliar no debe reclamarlo. + var payload = new { code = $"T{uid}", name = $"E2E SS Tenant {uid}", type = "CLIENT", idpStrategy = (string?)null, companyReference = (string?)null, isManagementOwner = false }; var response = await _client.PostAsJsonAsync("/api/v1/tenants", payload, ct); response.EnsureSuccessStatusCode(); var location = response.Headers.Location?.ToString(); - var idString = location!.Split('/').Last(); + var idString = location!.Split('/')[^1]; var id = Guid.Parse(idString); _client.DefaultRequestHeaders.Remove("X-Tenant-Id"); diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/SystemSuiteLogicalDeletionE2ETests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/SystemSuiteLogicalDeletionE2ETests.cs new file mode 100644 index 00000000..b09f06eb --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/SystemSuiteLogicalDeletionE2ETests.cs @@ -0,0 +1,483 @@ +using Npgsql; +using Ums.Presentation.IntegrationTest.Infrastructure; + +namespace Ums.Presentation.IntegrationTest.E2E; + +/// +/// Eliminación LÓGICA de un sistema del catálogo — G-246. +/// +/// La política es del propietario del producto y no admite matices: el borrado físico no +/// existe. Sobre el catálogo se consultan datos antiguos y una fila borrada de verdad se pierde +/// para siempre. Un sistema eliminado se marca Deleted, sigue en la tabla y desaparece de +/// todas las lecturas. +/// +/// La segunda mitad de la política es la regla de cascada, análoga a un +/// ON DELETE RESTRICT: no se elimina lógicamente algo a lo que todavía apuntan referencias +/// vivas. Las que ya están eliminadas lógicamente no bloquean. +/// +/// Estas pruebas van contra PostgreSQL real porque las dos mitades son exactamente lo que un +/// doble en memoria no puede demostrar: el conteo sobre las tablas que apuntan al sistema, y que la +/// fila siga ahí después del DELETE. +/// +/// Requisito: Docker en marcha. +/// +[Collection("PostgreSql")] +public sealed class SystemSuiteLogicalDeletionE2ETests +{ + private const string TenantsTable = "ums_identity.\"Tenants\""; + private const string SystemSuitesTable = "ums_authorization.\"SystemSuites\""; + + private readonly PostgreSqlContainerFixture _fixture; + private readonly HttpClient _client; + + public SystemSuiteLogicalDeletionE2ETests(PostgreSqlContainerFixture fixture) + { + _fixture = fixture; + + if (fixture.IsAvailable) + { + var factory = new PostgreSqlWebApplicationFactory(fixture.ConnectionString); + _client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + BaseAddress = new Uri("https://localhost"), + AllowAutoRedirect = false, + }); + _client.DefaultRequestHeaders.Add("X-User-Id", "00000000-0000-0000-0000-000000000001"); + _client.DefaultRequestHeaders.Add("X-User-Name", "e2e-logical-deletion"); + // ADR-0077: aprovisionar y ELIMINAR sistemas son operaciones on-behalf del internal-admin + // sobre un inquilino CLIENT auxiliar; sin este contexto TenantScopePolicy devuelve AUTH_015. + _client.DefaultRequestHeaders.Add("X-Is-Internal-Admin", "true"); + } + else + { + _client = new HttpClient(); + } + } + + [Fact] + public async Task DeleteSystemSuite_DeprecatedWithoutLiveReferences_Returns204AndDisappearsFromReads() + { + if (!_fixture.IsAvailable) Assert.Skip("Docker required."); + var ct = TestContext.Current.CancellationToken; + + var suiteId = await CreateSuiteId(ct); + + // El sistema lleva su propia topología: es la forma exacta en que el carril A los deja + // (suite → módulo → nodo). La composición del agregado no debe bloquear la eliminación. + var moduleId = await AddModuleAsync(suiteId, ct); + await AddRootNodeAsync(suiteId, moduleId, ct); + + var code = await CodeOfAsync(suiteId, ct); + await DeprecateAsync(suiteId, ct); + + var delete = await _client.DeleteAsync($"/api/v1/system-suites/{suiteId}", ct); + delete.StatusCode.Should().Be(HttpStatusCode.NoContent); + + var afterGet = await _client.GetAsync($"/api/v1/system-suites/{suiteId}", ct); + afterGet.StatusCode.Should().Be( + HttpStatusCode.NotFound, + because: "un sistema eliminado lógicamente deja de leerse, aunque su fila siga en la base"); + + // Y tampoco por el listado, que es la otra puerta de lectura: si el filtro solo cubriera el + // GET por id, el catálogo seguiría mostrando lo eliminado. + (await ListedCodesAsync(ct)).Should().NotContain( + code, + because: "lo eliminado tampoco aparece en el catálogo"); + } + + /// + /// LA prueba de la política: tras el 204, la fila SIGUE en la base con su estado terminal. + /// + /// Se consulta con SQL crudo y no por la API a propósito: la API está obligada a no verla, así + /// que preguntarle a ella no distinguiría «oculto» de «borrado». Aquí se mira el almacenamiento. + /// + [Fact] + public async Task DeleteSystemSuite_RowSurvivesInDatabaseWithDeletedStatus() + { + if (!_fixture.IsAvailable) Assert.Skip("Docker required."); + var ct = TestContext.Current.CancellationToken; + + var suiteId = await CreateSuiteId(ct); + await DeprecateAsync(suiteId, ct); + + (await _client.DeleteAsync($"/api/v1/system-suites/{suiteId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + var statusId = await ScalarAsync( + $"SELECT \"StatusId\" FROM {SystemSuitesTable} WHERE \"Id\" = @id", + ct, + ("id", suiteId)); + + statusId.Should().NotBeNull( + because: "el borrado es LÓGICO: la fila no se borra, porque sobre el catálogo se consultan datos antiguos"); + statusId.Should().Be( + SystemStatus.Deleted.Id, + because: "la fila que sobrevive debe quedar marcada como eliminada, no en un estado cualquiera"); + } + + [Fact] + public async Task DeleteSystemSuite_WithLiveRole_Returns409WithBlockingDependenciesAndSuiteSurvives() + { + if (!_fixture.IsAvailable) Assert.Skip("Docker required."); + var ct = TestContext.Current.CancellationToken; + + var suiteId = await CreateSuiteId(ct); + await CreateRoleAsync(suiteId, ct); + + await DeprecateAsync(suiteId, ct); + + var delete = await _client.DeleteAsync($"/api/v1/system-suites/{suiteId}", ct); + delete.StatusCode.Should().Be(HttpStatusCode.Conflict); + + using var error = JsonDocument.Parse(await delete.Content.ReadAsStringAsync(ct)); + error.RootElement.GetProperty("errorCode").GetString() + .Should().Be("SYSTEM_SUITE_HAS_DEPENDENTS", + because: "el rechazo debe ser una regla de dominio con nombre, no una violación de FK"); + + var blockers = error.RootElement.GetProperty("blockingDependencies"); + blockers.EnumerateArray() + .Any(d => d.GetProperty("entityType").GetString() == "Role" && d.GetProperty("count").GetInt32() > 0) + .Should().BeTrue(because: "la respuesta debe decir QUÉ bloquea, para que el llamador sepa qué eliminar antes"); + + var afterGet = await _client.GetAsync($"/api/v1/system-suites/{suiteId}", ct); + afterGet.StatusCode.Should().Be(HttpStatusCode.OK, because: "una eliminación rechazada no cambia nada"); + } + + /// + /// Las dos caras de la regla de cascada sobre el MISMO dependiente. + /// + /// Se usa el inquilino que apunta al sistema como su sistema por defecto porque hoy es el + /// ÚNICO de los siete referentes con eliminación lógica de verdad (Tenants.IsDeleted). + /// Roles, plantillas, banderas, configuraciones y flujos de aprobación no la tienen: para ellos + /// toda fila existente cuenta como viva, y por eso la mitad «ya eliminado, deja pasar» no se + /// puede demostrar con un rol. + /// + /// El estado se dispone con SQL porque la API no expone ni fijar el sistema por defecto de + /// un inquilino ni eliminar un inquilino; lo que la prueba ejercita es la GUARDA, no esos + /// caminos. + /// + [Fact] + public async Task DeleteSystemSuite_TenantDefault_BlocksWhileLive_AndPassesOnceLogicallyDeleted() + { + if (!_fixture.IsAvailable) Assert.Skip("Docker required."); + var ct = TestContext.Current.CancellationToken; + + var suiteId = await CreateSuiteId(ct); + var dependentTenantId = await CreateTenantAsync(setAsCurrent: false, ct); + + await ExecuteAsync( + $"UPDATE {TenantsTable} SET \"DefaultSystemSuiteId\" = @suite WHERE \"Id\" = @tenant", + ct, + ("suite", suiteId), ("tenant", dependentTenantId)); + + await DeprecateAsync(suiteId, ct); + + // (b) Referencia VIVA → bloquea, con el desglose. + var blocked = await _client.DeleteAsync($"/api/v1/system-suites/{suiteId}", ct); + blocked.StatusCode.Should().Be(HttpStatusCode.Conflict); + + using (var error = JsonDocument.Parse(await blocked.Content.ReadAsStringAsync(ct))) + { + error.RootElement.GetProperty("errorCode").GetString().Should().Be("SYSTEM_SUITE_HAS_DEPENDENTS"); + error.RootElement.GetProperty("blockingDependencies").EnumerateArray() + .Any(d => d.GetProperty("entityType").GetString() == "Tenant" && d.GetProperty("count").GetInt32() > 0) + .Should().BeTrue(because: "el inquilino que lo usa por defecto es el referente más peligroso: se queda sin destino de autenticación"); + } + + // (c) El MISMO dependiente, ya eliminado lógicamente → es una lápida y deja de bloquear. + await ExecuteAsync( + $"UPDATE {TenantsTable} SET \"IsDeleted\" = true, \"DeletedAtUtc\" = (now() at time zone 'utc'), \"DeletedBy\" = 'e2e-logical-deletion' WHERE \"Id\" = @tenant", + ct, + ("tenant", dependentTenantId)); + + var allowed = await _client.DeleteAsync($"/api/v1/system-suites/{suiteId}", ct); + allowed.StatusCode.Should().Be( + HttpStatusCode.NoContent, + because: "lo ya eliminado lógicamente no es una referencia real: no puede bloquear para siempre"); + } + + [Fact] + public async Task DeleteSystemSuite_StillInService_Returns409() + { + if (!_fixture.IsAvailable) Assert.Skip("Docker required."); + var ct = TestContext.Current.CancellationToken; + + // Sin archivar (nace Active): solo se elimina lo que ya se dio de baja. + var suiteId = await CreateSuiteId(ct); + + var delete = await _client.DeleteAsync($"/api/v1/system-suites/{suiteId}", ct); + delete.StatusCode.Should().Be(HttpStatusCode.Conflict); + + (await _client.GetAsync($"/api/v1/system-suites/{suiteId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task DeleteSystemSuite_Repeated_Returns404() + { + if (!_fixture.IsAvailable) Assert.Skip("Docker required."); + var ct = TestContext.Current.CancellationToken; + + var suiteId = await CreateSuiteId(ct); + await DeprecateAsync(suiteId, ct); + + (await _client.DeleteAsync($"/api/v1/system-suites/{suiteId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + // Ya no se lee, así que el segundo intento no lo encuentra: coherente con el GET. + (await _client.DeleteAsync($"/api/v1/system-suites/{suiteId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task SetStatus_ToDeleted_Returns409() + { + if (!_fixture.IsAvailable) Assert.Skip("Docker required."); + var ct = TestContext.Current.CancellationToken; + + // La puerta de atrás: si el cambio de estado admitiera «Deleted», eliminaría el sistema sin + // pasar por la guarda de cascada. + var suiteId = await CreateSuiteId(ct); + + var res = await _client.PutAsJsonAsync( + $"/api/v1/system-suites/{suiteId}/status", new { status = "Deleted" }, ct); + + res.StatusCode.Should().Be(HttpStatusCode.Conflict); + (await _client.GetAsync($"/api/v1/system-suites/{suiteId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.OK); + } + + /// + /// Consecuencia directa de que la fila sobreviva: su código sigue ocupado. + /// + /// El índice único (TenantId, Code) cuenta también las lápidas, así que el alta tiene que + /// verlas. Si no las viera, reutilizar el código pasaría la validación y reventaría en el commit + /// con una violación de integridad: un 500 en lugar de una respuesta con sentido. + /// + [Fact] + public async Task CreateSystemSuite_ReusingCodeOfDeletedSuite_IsRejectedCleanly() + { + if (!_fixture.IsAvailable) Assert.Skip("Docker required."); + var ct = TestContext.Current.CancellationToken; + + var suiteId = await CreateSuiteId(ct); + var code = await CodeOfAsync(suiteId, ct); + var tenantId = await ScalarAsync( + $"SELECT \"TenantId\" FROM {SystemSuitesTable} WHERE \"Id\" = @id", ct, ("id", suiteId)); + + await DeprecateAsync(suiteId, ct); + (await _client.DeleteAsync($"/api/v1/system-suites/{suiteId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + var res = await _client.PostAsJsonAsync("/api/v1/system-suites", new + { + tenantId = (Guid)tenantId!, + code, + name = "Sistema que reutiliza un código eliminado", + description = "El código de un sistema eliminado sigue ocupado por su lápida.", + }, ct); + + ((int)res.StatusCode).Should().BeLessThan( + 500, + because: "el rechazo debe ser una respuesta de negocio, nunca una violación de índice único"); + res.StatusCode.Should().BeOneOf(HttpStatusCode.Conflict, HttpStatusCode.BadRequest); + } + + [Fact] + public async Task DeleteSystemSuite_UnknownId_Returns404() + { + if (!_fixture.IsAvailable) Assert.Skip("Docker required."); + var ct = TestContext.Current.CancellationToken; + + var delete = await _client.DeleteAsync($"/api/v1/system-suites/{Guid.NewGuid()}", ct); + delete.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + // ───────────────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────────────── + + private async Task DeprecateAsync(Guid suiteId, CancellationToken ct) + { + var res = await _client.PutAsJsonAsync( + $"/api/v1/system-suites/{suiteId}/status", new { status = "Deprecated" }, ct); + res.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + private async Task CodeOfAsync(Guid suiteId, CancellationToken ct) + => (string?)await ScalarAsync($"SELECT \"Code\" FROM {SystemSuitesTable} WHERE \"Id\" = @id", ct, ("id", suiteId)) + ?? throw new InvalidOperationException($"El sistema {suiteId} no existe ni siquiera en la base."); + + private async Task> ListedCodesAsync(CancellationToken ct) + { + var res = await _client.GetAsync("/api/v1/system-suites?page=1&pageSize=100", ct); + res.StatusCode.Should().Be(HttpStatusCode.OK); + + using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync(ct)); + var items = doc.RootElement.TryGetProperty("items", out var itemsElement) + ? itemsElement + : doc.RootElement; + + return items.EnumerateArray() + .Select(x => x.TryGetProperty("code", out var code) ? code.GetString() : null) + .ToList(); + } + + private async Task AddModuleAsync(Guid suiteId, CancellationToken ct) + { + var res = await _client.PostAsJsonAsync($"/api/v1/system-suites/{suiteId}/modules", new + { + systemSuiteId = suiteId, + code = UniqueCode("DELMOD"), + name = "Módulo de la suite eliminada", + description = "Composición propia del sistema, creada por el E2E de eliminación lógica.", + sortOrder = 1, + }, ct); + + res.StatusCode.Should().Be(HttpStatusCode.Created); + return await ReadGuid(res, "moduleId", ct); + } + + private async Task AddRootNodeAsync(Guid suiteId, Guid moduleId, CancellationToken ct) + { + // Los nodos exigen módulo activo (el dominio rechaza colgar navegación de un módulo inactivo). + (await _client.PostAsync($"/api/v1/system-suites/{suiteId}/modules/{moduleId}/activate", null, ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + var res = await _client.PostAsJsonAsync($"/api/v1/system-suites/{suiteId}/modules/{moduleId}/nodes", new + { + systemSuiteId = suiteId, + moduleId, + parentNodeId = (Guid?)null, + kind = "Menu", + code = UniqueCode("DELNODE"), + label = "Menú de la suite eliminada", + description = "Nodo raíz creado por el E2E de eliminación lógica.", + sortOrder = 1, + }, ct); + + res.StatusCode.Should().Be(HttpStatusCode.Created); + } + + private async Task CreateRoleAsync(Guid suiteId, CancellationToken ct) + { + var res = await _client.PostAsJsonAsync($"/api/v1/system-suites/{suiteId}/roles", new + { + code = UniqueCode("DELROLE"), + value = "Rol que bloquea la eliminación", + description = "Referencia externa viva del sistema, creada por el E2E de eliminación lógica.", + parentRoleId = (Guid?)null, + hierarchyLevel = 0, + promotionOrder = 0, + }, ct); + + res.StatusCode.Should().Be(HttpStatusCode.Created); + return await ReadGuid(res, "roleId", ct); + } + + // Los códigos se normalizan en el dominio (Trim + ToUpperInvariant); se normalizan aquí para que + // lo enviado coincida con lo almacenado. + private static string UniqueCode(string prefix) + => $"{prefix}{Guid.NewGuid():N}"[..Math.Min(20, prefix.Length + 32)].ToUpperInvariant(); + + /// + /// Si el inquilino recién creado pasa a ser el del encabezado X-Tenant-Id. Los inquilinos + /// auxiliares —el que solo referencia al sistema— NO deben cambiarlo: el filtro de inquilino + /// escondería el sistema bajo prueba, que pertenece a otro. + /// + private async Task CreateTenantAsync(bool setAsCurrent, CancellationToken ct) + { + var uid = Guid.NewGuid().ToString("N")[..10].ToUpper(); + // G-037: la propiedad de gestión es única; el inquilino CLIENT auxiliar no debe reclamarla. + var payload = new + { + code = $"T{uid}", + name = $"E2E Eliminacion Tenant {uid}", + type = "CLIENT", + idpStrategy = (string?)null, + companyReference = (string?)null, + isManagementOwner = false, + }; + + var response = await _client.PostAsJsonAsync("/api/v1/tenants", payload, ct); + response.EnsureSuccessStatusCode(); + + var id = Guid.Parse(response.Headers.Location!.ToString().Split('/')[^1]); + + if (setAsCurrent) + { + _client.DefaultRequestHeaders.Remove("X-Tenant-Id"); + _client.DefaultRequestHeaders.Add("X-Tenant-Id", id.ToString()); + } + + return id; + } + + private async Task CreateSuiteId(CancellationToken ct) + { + var tenantId = await CreateTenantAsync(setAsCurrent: true, ct); + var uid = Guid.NewGuid().ToString("N")[..8].ToUpper(); + + var res = await _client.PostAsJsonAsync("/api/v1/system-suites", new + { + tenantId, + code = $"DEL{uid}", + name = $"E2E Eliminacion Suite {uid}", + description = "Sistema creado por el E2E de eliminación lógica (G-246).", + }, ct); + + res.StatusCode.Should().Be(HttpStatusCode.Created); + return await ReadGuid(res, "systemSuiteId", ct); + } + + private static async Task ReadGuid(HttpResponseMessage response, string property, CancellationToken ct) + { + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); + return doc.RootElement.GetProperty(property).GetGuid(); + } + + // ── Acceso directo a la base ───────────────────────────────────────────── + // Se usa para DOS cosas que la API no puede dar: disponer estados que no expone (el sistema por + // defecto de un inquilino, la eliminación lógica de un inquilino) y, sobre todo, mirar el + // almacenamiento por debajo de la API para distinguir «oculto» de «borrado». + + private async Task OpenAsync(CancellationToken ct) + { + var connection = new NpgsqlConnection(_fixture.ConnectionString); + await connection.OpenAsync(ct); + + // Neutraliza la compuerta de RLS (`app.current_organization_id` vacía = sin restricción de + // inquilino). Sin esto, la aserción mediría visibilidad y no existencia, que es justo lo que + // esta prueba tiene que separar. + await using var gate = new NpgsqlCommand("SET app.current_organization_id = ''", connection); + await gate.ExecuteNonQueryAsync(ct); + + return connection; + } + + private async Task ExecuteAsync(string sql, CancellationToken ct, params (string Name, object Value)[] parameters) + { + await using var connection = await OpenAsync(ct); + await using var command = new NpgsqlCommand(sql, connection); + foreach (var (name, value) in parameters) + { + command.Parameters.AddWithValue(name, value); + } + + await command.ExecuteNonQueryAsync(ct); + } + + /// Escalar crudo, o null si la consulta no devolvió fila. + private async Task ScalarAsync(string sql, CancellationToken ct, params (string Name, object Value)[] parameters) + { + await using var connection = await OpenAsync(ct); + await using var command = new NpgsqlCommand(sql, connection); + foreach (var (name, value) in parameters) + { + command.Parameters.AddWithValue(name, value); + } + + var result = await command.ExecuteScalarAsync(ct); + return result is DBNull ? null : result; + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/TenantE2ETests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/TenantE2ETests.cs index c9bb3d24..2e303d91 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/TenantE2ETests.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/TenantE2ETests.cs @@ -12,7 +12,7 @@ namespace Ums.Presentation.IntegrationTest.E2E; /// /// Architecture: /// - Commands → REST API (POST / PUT / DELETE) -/// - Queries → GraphQL (POST /graphql) +/// - Queries → REST API (GET) /// /// Prerequisites: Docker must be running locally. /// Tests are automatically skipped when Docker is unavailable. @@ -37,6 +37,14 @@ public TenantE2ETests(PostgreSqlContainerFixture fixture) }); _client.DefaultRequestHeaders.Add("X-User-Id", "00000000-0000-0000-0000-000000000001"); _client.DefaultRequestHeaders.Add("X-User-Name", "e2e-test"); + // ADR-0077 (evolith-core#18): las operaciones de Branch (Add/Deactivate/Reactivate/ + // Remove) sobre un inquilino CLIENT auxiliar (X-Tenant-Id apunta a él) son acciones + // ON-BEHALF que sólo el operador de gestión (internal-admin) puede ejecutar; sin esta + // cabecera, TenantScopePolicy devuelve AUTH_015 → 400 en el setup. No hay tests de + // denegación-auth en esta clase: los 4xx afirmados (código vacío → 400, código/branch + // duplicado → 409, inquilino/branch inexistente → 404) son validación, conflicto y + // not-found, y siguen siendo correctos bajo internal-admin. + _client.DefaultRequestHeaders.Add("X-Is-Internal-Admin", "true"); } else { @@ -90,11 +98,11 @@ public async Task CreateTenant_DuplicateCode_Returns409() } // ───────────────────────────────────────────────────────────────────────── - // READ — via GraphQL + // READ // ───────────────────────────────────────────────────────────────────────── [Fact] - public async Task GetTenantById_ExistingTenant_GqlReturnsCorrectData() + public async Task GetTenantById_ExistingTenant_ReturnsCorrectData() { if (!_fixture.IsAvailable) Assert.Skip("Docker required."); var ct = TestContext.Current.CancellationToken; @@ -104,10 +112,12 @@ public async Task GetTenantById_ExistingTenant_GqlReturnsCorrectData() createRes.StatusCode.Should().Be(HttpStatusCode.Created); var tenantId = await ReadGuidProperty(createRes, "tenantId", ct); - using var doc = await GqlTenantByIdAsync(tenantId, ct); - var tenant = doc.RootElement.GetProperty("data").GetProperty("tenantById"); + var getRes = await _client.GetAsync($"/api/v1/tenants/{tenantId}", ct); + getRes.StatusCode.Should().Be(HttpStatusCode.OK, because: "tenant should exist"); + + using var doc = JsonDocument.Parse(await getRes.Content.ReadAsStringAsync(ct)); + var tenant = doc.RootElement; - tenant.ValueKind.Should().NotBe(JsonValueKind.Null, because: "tenant should exist"); tenant.GetProperty("tenantId").GetGuid().Should().Be(tenantId); tenant.GetProperty("code").GetString().Should().Be(payload.Code); tenant.GetProperty("name").GetString().Should().Be(payload.Name); @@ -116,28 +126,28 @@ public async Task GetTenantById_ExistingTenant_GqlReturnsCorrectData() } [Fact] - public async Task GetTenantById_NonExistent_GqlReturnsNull() + public async Task GetTenantById_NonExistent_Returns404() { if (!_fixture.IsAvailable) Assert.Skip("Docker required."); var ct = TestContext.Current.CancellationToken; - using var doc = await GqlTenantByIdAsync(Guid.NewGuid(), ct); - var tenant = doc.RootElement.GetProperty("data").GetProperty("tenantById"); + var res = await _client.GetAsync($"/api/v1/tenants/{Guid.NewGuid()}", ct); - tenant.ValueKind.Should().Be(JsonValueKind.Null, - because: "querying a non-existent tenant ID should return null"); + res.StatusCode.Should().Be(HttpStatusCode.NotFound, + because: "querying a non-existent tenant ID should return 404"); } [Fact] - public async Task GetTenants_Pagination_GqlReturnsCorrectMetadata() + public async Task GetTenants_Pagination_ReturnsCorrectMetadata() { if (!_fixture.IsAvailable) Assert.Skip("Docker required."); var ct = TestContext.Current.CancellationToken; - const string gql = "{ tenants(page: 1, pageSize: 5) { page pageSize totalItems items { tenantId code } } }"; - using var doc = await GqlQueryAsync(gql, ct); + var res = await _client.GetAsync("/api/v1/tenants?page=1&pageSize=5", ct); + res.StatusCode.Should().Be(HttpStatusCode.OK); - var list = doc.RootElement.GetProperty("data").GetProperty("tenants"); + using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync(ct)); + var list = doc.RootElement; list.GetProperty("page").GetInt32().Should().Be(1); list.GetProperty("pageSize").GetInt32().Should().Be(5); list.GetProperty("totalItems").GetInt32().Should().BeGreaterThanOrEqualTo(0); @@ -145,7 +155,7 @@ public async Task GetTenants_Pagination_GqlReturnsCorrectMetadata() } [Fact] - public async Task GetTenants_SearchByCode_GqlFindsTenant() + public async Task GetTenants_SearchByCode_FindsTenant() { if (!_fixture.IsAvailable) Assert.Skip("Docker required."); var ct = TestContext.Current.CancellationToken; @@ -154,10 +164,12 @@ public async Task GetTenants_SearchByCode_GqlFindsTenant() (await _client.PostAsJsonAsync("/api/v1/tenants", payload, ct)) .StatusCode.Should().Be(HttpStatusCode.Created); - var gql = $"{{ tenants(page: 1, pageSize: 50, search: \"{payload.Code}\", criteria: \"code\") {{ items {{ tenantId code }} }} }}"; - using var doc = await GqlQueryAsync(gql, ct); + var res = await _client.GetAsync( + $"/api/v1/tenants?page=1&pageSize=50&search={payload.Code}&criteria=code", ct); + res.StatusCode.Should().Be(HttpStatusCode.OK); - var items = doc.RootElement.GetProperty("data").GetProperty("tenants").GetProperty("items"); + using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync(ct)); + var items = doc.RootElement.GetProperty("items"); items.GetArrayLength().Should().BeGreaterThan(0); var found = items.EnumerateArray().Any(i => i.GetProperty("code").GetString() == payload.Code); found.Should().BeTrue(because: "tenant should be findable by exact code search"); @@ -180,23 +192,15 @@ public async Task SuspendAndActivateTenant_FullCycle_Returns204() suspendRes.StatusCode.Should().Be(HttpStatusCode.NoContent, because: "a newly created tenant should be suspendable"); - // Verify status via GraphQL - using (var afterSuspend = await GqlTenantByIdAsync(tenantId, ct)) - { - afterSuspend.RootElement.GetProperty("data").GetProperty("tenantById") - .GetProperty("status").GetString().Should().Be("Suspended"); - } + // Verify status via REST + (await GetTenantStatusAsync(tenantId, ct)).Should().Be("Suspended"); // Activate var activateRes = await _client.PostAsync($"/api/v1/tenants/{tenantId}/activate", null, ct); activateRes.StatusCode.Should().Be(HttpStatusCode.NoContent); - // Verify restored status via GraphQL - using (var afterActivate = await GqlTenantByIdAsync(tenantId, ct)) - { - afterActivate.RootElement.GetProperty("data").GetProperty("tenantById") - .GetProperty("status").GetString().Should().Be("Active"); - } + // Verify restored status via REST + (await GetTenantStatusAsync(tenantId, ct)).Should().Be("Active"); } [Fact] @@ -237,7 +241,7 @@ public async Task ActivateTenant_NonExistent_Returns404() // ───────────────────────────────────────────────────────────────────────── [Fact] - public async Task AddBranch_ValidPayload_Returns201AndAppearsInGqlBranches() + public async Task AddBranch_ValidPayload_Returns201AndAppearsInBranches() { if (!_fixture.IsAvailable) Assert.Skip("Docker required."); var ct = TestContext.Current.CancellationToken; @@ -249,12 +253,13 @@ public async Task AddBranch_ValidPayload_Returns201AndAppearsInGqlBranches() var res = await _client.PostAsJsonAsync($"/api/v1/tenants/{tenantId}/branches", branchPayload, ct); res.StatusCode.Should().Be(HttpStatusCode.Created); - // Verify via GraphQL tenantBranches query - var gql = $"{{ tenantBranches(tenantId: \"{tenantId}\") {{ branchId code name isActive }} }}"; - using var doc = await GqlQueryAsync(gql, ct); - var branches = doc.RootElement.GetProperty("data").GetProperty("tenantBranches"); - var found = branches.EnumerateArray().Any(b => b.GetProperty("code").GetString() == branchCode); - found.Should().BeTrue(because: "the added branch should appear in tenantBranches query"); + // Verify via REST branches endpoint. El value object Code normaliza a MAYÚSCULAS al + // persistir, así que la comparación debe ser insensible a mayúsculas (el código generado + // usa hex en minúsculas de Guid:N). + using var doc = await GetTenantBranchesAsync(tenantId, ct); + var found = doc.RootElement.EnumerateArray() + .Any(b => string.Equals(b.GetProperty("code").GetString(), branchCode, StringComparison.OrdinalIgnoreCase)); + found.Should().BeTrue(because: "the added branch should appear in the branches list"); } [Fact] @@ -270,7 +275,7 @@ public async Task AddBranch_ToNonExistentTenant_Returns404() } [Fact] - public async Task BranchLifecycle_DeactivateReactivateRemove_FullCycle() + public async Task BranchLifecycle_DesactivarReactivarYCerrar_CicloCompleto() { if (!_fixture.IsAvailable) Assert.Skip("Docker required."); var ct = TestContext.Current.CancellationToken; @@ -282,27 +287,61 @@ public async Task BranchLifecycle_DeactivateReactivateRemove_FullCycle() var addRes = await _client.PostAsJsonAsync($"/api/v1/tenants/{tenantId}/branches", branchPayload, ct); addRes.StatusCode.Should().Be(HttpStatusCode.Created); - // Retrieve branchId via GraphQL (AddBranchResponse only contains tenantId) + // Retrieve branchId via REST (AddBranchResponse only contains tenantId) var branchId = await GetBranchIdByCodeAsync(tenantId, branchCode, ct); - // Deactivate + // Desactivar y reactivar SIGUEN funcionando: ADR-0164 no toca el verbo reversible. (await _client.PostAsync($"/api/v1/tenants/{tenantId}/branches/{branchId}/deactivate", null, ct)) .StatusCode.Should().Be(HttpStatusCode.NoContent); - // Reactivate (await _client.PostAsync($"/api/v1/tenants/{tenantId}/branches/{branchId}/reactivate", null, ct)) .StatusCode.Should().Be(HttpStatusCode.NoContent); - // Remove - (await _client.DeleteAsync($"/api/v1/tenants/{tenantId}/branches/{branchId}", ct)) + // Y cerrar NO exige desactivar antes: son dos verbos independientes (ADR-0164 §2.4). Antes el + // invariante de `RemoveBranch` obligaba a desactivar primero, porque «eliminar» era el paso + // siguiente de «retirar del servicio»; ya no lo es. + (await _client.DeleteAsync($"/api/v1/tenants/{tenantId}/branches/{branchId}?reason=Cese%20de%20operaciones", ct)) .StatusCode.Should().Be(HttpStatusCode.NoContent); - // Verify branch is gone via GraphQL - var gql = $"{{ tenantBranches(tenantId: \"{tenantId}\") {{ branchId code }} }}"; - using var after = await GqlQueryAsync(gql, ct); - var branches = after.RootElement.GetProperty("data").GetProperty("tenantBranches"); - var stillPresent = branches.EnumerateArray().Any(b => b.GetProperty("branchId").GetGuid() == branchId); - stillPresent.Should().BeFalse(because: "removed branch should not appear in branches list"); + // El listado ordinario ya no la trae… + using var after = await GetTenantBranchesAsync(tenantId, ct); + after.RootElement.EnumerateArray().Any(b => b.GetProperty("branchId").GetGuid() == branchId) + .Should().BeFalse(because: "una sucursal cerrada desaparece del listado operativo"); + + // …pero se puede pedir, y eso es lo que distingue «oculta» de «borrada». + var conCerradas = await _client.GetAsync($"/api/v1/tenants/{tenantId}/branches?includeClosed=true", ct); + conCerradas.StatusCode.Should().Be(HttpStatusCode.OK); + using var doc = JsonDocument.Parse(await conCerradas.Content.ReadAsStringAsync(ct)); + var cerrada = doc.RootElement.EnumerateArray() + .Single(b => b.GetProperty("branchId").GetGuid() == branchId); + cerrada.GetProperty("isClosed").GetBoolean().Should().BeTrue(); + + // Cerrada no se reactiva ni se desactiva: el estado terminal no tiene puerta de atrás. + (await _client.PostAsync($"/api/v1/tenants/{tenantId}/branches/{branchId}/reactivate", null, ct)) + .StatusCode.Should().Be(HttpStatusCode.Conflict); + (await _client.PostAsync($"/api/v1/tenants/{tenantId}/branches/{branchId}/deactivate", null, ct)) + .StatusCode.Should().Be(HttpStatusCode.Conflict); + + // Reintentar el cierre tampoco es idempotente en silencio: es un conflicto de estado. + (await _client.DeleteAsync($"/api/v1/tenants/{tenantId}/branches/{branchId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.Conflict); + + // Y el código NO se libera (ADR-0164 §2.3): dar de alta otra con el mismo choca de forma + // legible, no con una violación de índice único convertida en 500. + var reintentoDeCodigo = await _client.PostAsJsonAsync($"/api/v1/tenants/{tenantId}/branches", branchPayload, ct); + reintentoDeCodigo.StatusCode.Should().Be(HttpStatusCode.Conflict); + + // La bitácora conserva los cuatro episodios, con su autor y su fecha. + var bitacora = await _client.GetAsync($"/api/v1/tenants/{tenantId}/branches/{branchId}/bitacora", ct); + bitacora.StatusCode.Should().Be(HttpStatusCode.OK); + using var log = JsonDocument.Parse(await bitacora.Content.ReadAsStringAsync(ct)); + var episodios = log.RootElement.EnumerateArray().Select(e => e.GetProperty("episode").GetString()).ToList(); + episodios.Should().Equal("Opened", "Deactivated", "Reactivated", "Closed"); + log.RootElement.EnumerateArray().Last().GetProperty("reason").GetString() + .Should().Be("Cese de operaciones"); + log.RootElement.EnumerateArray() + .All(e => !string.IsNullOrWhiteSpace(e.GetProperty("actorId").GetString())) + .Should().BeTrue(because: "cada episodio debe decir quién lo hizo"); } [Fact] @@ -326,34 +365,37 @@ public async Task AddBranch_DuplicateCode_Returns409() // Helpers // ───────────────────────────────────────────────────────────────────────── - /// Sends a raw GraphQL query to POST /graphql and returns the parsed response. - private async Task GqlQueryAsync(string gql, CancellationToken ct) + /// Queries GET /api/v1/tenants/{tenantId}/branches. Caller must dispose the document. + private async Task GetTenantBranchesAsync(Guid tenantId, CancellationToken ct) { - var res = await _client.PostAsJsonAsync("/graphql", new { query = gql }, ct); - res.EnsureSuccessStatusCode(); + var res = await _client.GetAsync($"/api/v1/tenants/{tenantId}/branches", ct); + res.StatusCode.Should().Be(HttpStatusCode.OK, because: "branches list should be retrievable"); var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync(ct)); - doc.RootElement.TryGetProperty("errors", out _).Should().BeFalse( - because: "GraphQL query should not return errors"); + doc.RootElement.ValueKind.Should().Be(JsonValueKind.Array, + because: "the branches endpoint returns a JSON array"); return doc; } - /// Queries tenantById(tenantId) via GraphQL. Caller must dispose. - private Task GqlTenantByIdAsync(Guid tenantId, CancellationToken ct) => - GqlQueryAsync( - $"{{ tenantById(tenantId: \"{tenantId}\") {{ tenantId code name type status }} }}", - ct); + /// Reads the current status of a tenant via GET /api/v1/tenants/{tenantId}. + private async Task GetTenantStatusAsync(Guid tenantId, CancellationToken ct) + { + var res = await _client.GetAsync($"/api/v1/tenants/{tenantId}", ct); + res.StatusCode.Should().Be(HttpStatusCode.OK); + using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync(ct)); + return doc.RootElement.GetProperty("status").GetString(); + } /// - /// Retrieves the branchId for a given tenant+code via GraphQL. + /// Retrieves the branchId for a given tenant+code via the REST branches endpoint. /// Needed because AddBranchResponse only contains tenantId. /// private async Task GetBranchIdByCodeAsync(Guid tenantId, string code, CancellationToken ct) { - var gql = $"{{ tenantBranches(tenantId: \"{tenantId}\") {{ branchId code }} }}"; - using var doc = await GqlQueryAsync(gql, ct); - var branches = doc.RootElement.GetProperty("data").GetProperty("tenantBranches"); - return branches.EnumerateArray() - .First(b => b.GetProperty("code").GetString() == code) + // El value object Code normaliza a MAYÚSCULAS al persistir; la búsqueda por código debe + // ser insensible a mayúsculas (el código generado usa hex en minúsculas de Guid:N). + using var doc = await GetTenantBranchesAsync(tenantId, ct); + return doc.RootElement.EnumerateArray() + .First(b => string.Equals(b.GetProperty("code").GetString(), code, StringComparison.OrdinalIgnoreCase)) .GetProperty("branchId").GetGuid(); } @@ -361,7 +403,9 @@ private record TenantPayload(string Code, string Name, string Type, string? IdpS private static TenantPayload NewTenantPayload() { var uid = Guid.NewGuid().ToString("N")[..10].ToUpper(); - return new TenantPayload($"T{uid}", $"E2E Tenant {uid}", "CLIENT", null, null, true); + // G-037: el management owner es único a nivel de sistema. Los inquilinos genéricos de + // estas pruebas no deben reclamarlo — la unicidad se cubre en IntegridadYResultPatternE2ETests. + return new TenantPayload($"T{uid}", $"E2E Tenant {uid}", "CLIENT", null, null, false); } private async Task CreateTenantAndGetId(CancellationToken ct) @@ -370,7 +414,7 @@ private async Task CreateTenantAndGetId(CancellationToken ct) res.StatusCode.Should().Be(HttpStatusCode.Created); var location = res.Headers.Location?.ToString(); - var idString = location!.Split('/').Last(); + var idString = location!.Split('/')[^1]; var id = Guid.Parse(idString); _client.DefaultRequestHeaders.Remove("X-Tenant-Id"); diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/UserAccountE2ETests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/UserAccountE2ETests.cs index 14cba9a8..94ec59e5 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/UserAccountE2ETests.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/E2E/UserAccountE2ETests.cs @@ -13,7 +13,7 @@ namespace Ums.Presentation.IntegrationTest.E2E; /// /// Architecture: /// - Commands → REST API (POST / PUT / DELETE) -/// - Queries → GraphQL (POST /graphql) +/// - Queries → REST API (GET) /// /// Each test creates its own Tenant to guarantee isolation. /// Prerequisites: Docker must be running locally. @@ -38,6 +38,12 @@ public UserAccountE2ETests(PostgreSqlContainerFixture fixture) }); _client.DefaultRequestHeaders.Add("X-User-Id", "00000000-0000-0000-0000-000000000001"); _client.DefaultRequestHeaders.Add("X-User-Name", "e2e-test"); + // ADR-0077: el aprovisionamiento (crear usuario, registrar intento de autenticación, + // transiciones de estado) es una operación ON-BEHALF que solo ejerce el operador de + // gestión / internal-admin. Estos E2E fijan X-Tenant-Id a un inquilino CLIENT auxiliar + // (CreateTenantId), lo que hace perder el contexto internal-admin y TenantScopePolicy + // devuelve AUTH_015 → 400. Se declara el rol de forma explícita para actuar on-behalf. + _client.DefaultRequestHeaders.Add("X-Is-Internal-Admin", "true"); } else { @@ -59,7 +65,7 @@ public async Task CreateUserAccount_ValidPayload_Returns201WithId() var response = await _client.PostAsJsonAsync("/api/v1/user-accounts", NewUserPayload(tenantId), ct); var content = await response.Content.ReadAsStringAsync(ct); - System.IO.File.WriteAllText("e2e_error.txt", content); + await System.IO.File.WriteAllTextAsync("e2e_error.txt", content, ct); response.StatusCode.Should().Be(HttpStatusCode.Created, content); response.Headers.Location.Should().NotBeNull(); @@ -77,7 +83,8 @@ public async Task CreateUserAccount_InvalidEmail_Returns400() var payload = new { tenantId, branchId = (Guid?)null, email = "NOT_AN_EMAIL", category = "Internal", identityReference = (string?)null, identityReferenceType = (string?)null }; var res = await _client.PostAsJsonAsync("/api/v1/user-accounts", payload, ct); - res.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + // G-061: la convención de validación del API devuelve 400 (no 422); el nombre del test lo refleja. + res.StatusCode.Should().Be(HttpStatusCode.BadRequest); } [Fact] @@ -97,11 +104,11 @@ public async Task CreateUserAccount_DuplicateEmail_SameTenant_Returns409() } // ───────────────────────────────────────────────────────────────────────── - // READ — via GraphQL + // READ — via REST // ───────────────────────────────────────────────────────────────────────── [Fact] - public async Task GetUserAccountById_ExistingAccount_GqlReturnsCorrectFields() + public async Task GetUserAccountById_ExistingAccount_ReturnsCorrectFields() { if (!_fixture.IsAvailable) Assert.Skip("Docker required."); var ct = TestContext.Current.CancellationToken; @@ -112,10 +119,12 @@ public async Task GetUserAccountById_ExistingAccount_GqlReturnsCorrectFields() createRes.StatusCode.Should().Be(HttpStatusCode.Created); var userId = await ReadGuid(createRes, "userAccountId", ct); - using var doc = await GqlUserAccountByIdAsync(userId, ct); - var user = doc.RootElement.GetProperty("data").GetProperty("userAccountById"); + var getRes = await _client.GetAsync($"/api/v1/user-accounts/{userId}", ct); + getRes.StatusCode.Should().Be(HttpStatusCode.OK, because: "user account should exist"); + + using var doc = JsonDocument.Parse(await getRes.Content.ReadAsStringAsync(ct)); + var user = doc.RootElement; - user.ValueKind.Should().NotBe(JsonValueKind.Null, because: "user account should exist"); user.GetProperty("userAccountId").GetGuid().Should().Be(userId); user.GetProperty("tenantId").GetGuid().Should().Be(tenantId); user.GetProperty("email").GetString().Should().Be(payload.Email); @@ -124,28 +133,28 @@ public async Task GetUserAccountById_ExistingAccount_GqlReturnsCorrectFields() } [Fact] - public async Task GetUserAccountById_NonExistent_GqlReturnsNull() + public async Task GetUserAccountById_NonExistent_Returns404() { if (!_fixture.IsAvailable) Assert.Skip("Docker required."); var ct = TestContext.Current.CancellationToken; - using var doc = await GqlUserAccountByIdAsync(Guid.NewGuid(), ct); - var user = doc.RootElement.GetProperty("data").GetProperty("userAccountById"); + var getRes = await _client.GetAsync($"/api/v1/user-accounts/{Guid.NewGuid()}", ct); - user.ValueKind.Should().Be(JsonValueKind.Null, - because: "querying a non-existent user account ID should return null"); + getRes.StatusCode.Should().Be(HttpStatusCode.NotFound, + because: "querying a non-existent user account ID should return 404"); } [Fact] - public async Task GetUserAccounts_Pagination_GqlReturnsPageMetadata() + public async Task GetUserAccounts_Pagination_ReturnsPageMetadata() { if (!_fixture.IsAvailable) Assert.Skip("Docker required."); var ct = TestContext.Current.CancellationToken; - const string gql = "{ userAccounts(page: 1, pageSize: 5) { page pageSize totalItems items { userAccountId email } } }"; - using var doc = await GqlQueryAsync(gql, ct); + var getRes = await _client.GetAsync("/api/v1/user-accounts?page=1&pageSize=5", ct); + getRes.StatusCode.Should().Be(HttpStatusCode.OK); - var list = doc.RootElement.GetProperty("data").GetProperty("userAccounts"); + using var doc = JsonDocument.Parse(await getRes.Content.ReadAsStringAsync(ct)); + var list = doc.RootElement; list.GetProperty("page").GetInt32().Should().Be(1); list.GetProperty("pageSize").GetInt32().Should().Be(5); list.GetProperty("totalItems").GetInt32().Should().BeGreaterThanOrEqualTo(0); @@ -153,7 +162,7 @@ public async Task GetUserAccounts_Pagination_GqlReturnsPageMetadata() } [Fact] - public async Task GetUserAccounts_FilterByTenantId_GqlReturnsOnlyTenantUsers() + public async Task GetUserAccounts_FilterByTenantId_ReturnsOnlyTenantUsers() { if (!_fixture.IsAvailable) Assert.Skip("Docker required."); var ct = TestContext.Current.CancellationToken; @@ -163,10 +172,11 @@ public async Task GetUserAccounts_FilterByTenantId_GqlReturnsOnlyTenantUsers() (await _client.PostAsJsonAsync("/api/v1/user-accounts", payload, ct)) .StatusCode.Should().Be(HttpStatusCode.Created); - var gql = $"{{ userAccounts(page: 1, pageSize: 50, tenantId: \"{tenantId}\") {{ items {{ userAccountId tenantId email }} }} }}"; - using var doc = await GqlQueryAsync(gql, ct); + var getRes = await _client.GetAsync($"/api/v1/user-accounts?page=1&pageSize=50&tenantId={tenantId}", ct); + getRes.StatusCode.Should().Be(HttpStatusCode.OK); - var items = doc.RootElement.GetProperty("data").GetProperty("userAccounts").GetProperty("items"); + using var doc = JsonDocument.Parse(await getRes.Content.ReadAsStringAsync(ct)); + var items = doc.RootElement.GetProperty("items"); items.GetArrayLength().Should().BeGreaterThan(0); foreach (var item in items.EnumerateArray()) { @@ -176,7 +186,7 @@ public async Task GetUserAccounts_FilterByTenantId_GqlReturnsOnlyTenantUsers() } [Fact] - public async Task GetUserAccounts_SearchByEmail_GqlFindsCreatedUser() + public async Task GetUserAccounts_SearchByEmail_FindsCreatedUser() { if (!_fixture.IsAvailable) Assert.Skip("Docker required."); var ct = TestContext.Current.CancellationToken; @@ -187,10 +197,11 @@ public async Task GetUserAccounts_SearchByEmail_GqlFindsCreatedUser() .StatusCode.Should().Be(HttpStatusCode.Created); var emailPrefix = payload.Email.Split('@')[0]; - var gql = $"{{ userAccounts(page: 1, pageSize: 50, search: \"{emailPrefix}\", criteria: \"email\") {{ items {{ userAccountId email }} }} }}"; - using var doc = await GqlQueryAsync(gql, ct); + var getRes = await _client.GetAsync($"/api/v1/user-accounts?page=1&pageSize=50&search={emailPrefix}&criteria=email", ct); + getRes.StatusCode.Should().Be(HttpStatusCode.OK); - var items = doc.RootElement.GetProperty("data").GetProperty("userAccounts").GetProperty("items"); + using var doc = JsonDocument.Parse(await getRes.Content.ReadAsStringAsync(ct)); + var items = doc.RootElement.GetProperty("items"); items.GetArrayLength().Should().BeGreaterThan(0); var found = items.EnumerateArray().Any(i => i.GetProperty("email").GetString() == payload.Email); found.Should().BeTrue(because: "user should be findable by email prefix search"); @@ -211,9 +222,7 @@ public async Task ActivateUserAccount_FromPending_Returns204AndChangesStatus() var res = await _client.PostAsync($"/api/v1/user-accounts/{userId}/activate", null, ct); res.StatusCode.Should().Be(HttpStatusCode.NoContent); - using var doc = await GqlUserAccountByIdAsync(userId, ct); - doc.RootElement.GetProperty("data").GetProperty("userAccountById") - .GetProperty("status").GetString().Should().Be("Active"); + (await ReadStatus(userId, ct)).Should().Be("Active"); } [Fact] @@ -229,9 +238,7 @@ public async Task BlockUserAccount_ActiveAccount_Returns204AndChangesStatus() var res = await _client.PostAsync($"/api/v1/user-accounts/{userId}/block?reason=Policy+violation", null, ct); res.StatusCode.Should().Be(HttpStatusCode.NoContent); - using var doc = await GqlUserAccountByIdAsync(userId, ct); - doc.RootElement.GetProperty("data").GetProperty("userAccountById") - .GetProperty("status").GetString().Should().Be("Blocked"); + (await ReadStatus(userId, ct)).Should().Be("Blocked"); } [Fact] @@ -247,9 +254,7 @@ public async Task RestoreUserAccount_BlockedAccount_Returns204AndChangesStatus() var res = await _client.PostAsync($"/api/v1/user-accounts/{userId}/restore", null, ct); res.StatusCode.Should().Be(HttpStatusCode.NoContent); - using var doc = await GqlUserAccountByIdAsync(userId, ct); - doc.RootElement.GetProperty("data").GetProperty("userAccountById") - .GetProperty("status").GetString().Should().Be("Active"); + (await ReadStatus(userId, ct)).Should().Be("Active"); } [Fact] @@ -272,10 +277,8 @@ public async Task FullStatusCycle_Pending_Active_Blocked_Restored_Active() (await _client.PostAsync($"/api/v1/user-accounts/{userId}/restore", null, ct)) .StatusCode.Should().Be(HttpStatusCode.NoContent); - // Verify final state via GraphQL - using var doc = await GqlUserAccountByIdAsync(userId, ct); - doc.RootElement.GetProperty("data").GetProperty("userAccountById") - .GetProperty("status").GetString().Should().Be("Active"); + // Verify final state via REST + (await ReadStatus(userId, ct)).Should().Be("Active"); } [Fact] @@ -307,7 +310,7 @@ public async Task BlockUserAccount_AlreadyBlocked_Returns409() // ───────────────────────────────────────────────────────────────────────── [Fact] - public async Task DeleteUserAccount_ExistingAccount_Returns204AndNotAccessibleViaGql() + public async Task DeleteUserAccount_ExistingAccount_Returns204AndNotAccessible() { if (!_fixture.IsAvailable) Assert.Skip("Docker required."); var ct = TestContext.Current.CancellationToken; @@ -317,10 +320,9 @@ public async Task DeleteUserAccount_ExistingAccount_Returns204AndNotAccessibleVi var deleteRes = await _client.DeleteAsync($"/api/v1/user-accounts/{userId}", ct); deleteRes.StatusCode.Should().Be(HttpStatusCode.NoContent); - // After GDPR deletion the account should not be retrievable via GraphQL either - using var doc = await GqlUserAccountByIdAsync(userId, ct); - var user = doc.RootElement.GetProperty("data").GetProperty("userAccountById"); - user.ValueKind.Should().Be(JsonValueKind.Null, + // After GDPR deletion the account should no longer be retrievable via the REST query + var getRes = await _client.GetAsync($"/api/v1/user-accounts/{userId}", ct); + getRes.StatusCode.Should().Be(HttpStatusCode.NotFound, because: "soft-deleted accounts should not be returned by queries"); } @@ -387,30 +389,23 @@ public async Task RecordAuthAttempt_MissingReason_Returns400() var payload = new { userAccountId = userId, success = true, reason = "", ipAddress = "10.0.0.1" }; var res = await _client.PostAsJsonAsync($"/api/v1/user-accounts/{userId}/authentication-attempts", payload, ct); - res.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + // G-061: la convención de validación del API devuelve 400 (no 422); el nombre del test lo refleja. + res.StatusCode.Should().Be(HttpStatusCode.BadRequest); } // ───────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────── - /// Sends a raw GraphQL query to POST /graphql and returns the parsed response. - private async Task GqlQueryAsync(string gql, CancellationToken ct) + /// Reads the current status of a user account via GET /api/v1/user-accounts/{id}. + private async Task ReadStatus(Guid userId, CancellationToken ct) { - var res = await _client.PostAsJsonAsync("/graphql", new { query = gql }, ct); - res.EnsureSuccessStatusCode(); - var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync(ct)); - doc.RootElement.TryGetProperty("errors", out _).Should().BeFalse( - because: "GraphQL query should not return errors"); - return doc; + var res = await _client.GetAsync($"/api/v1/user-accounts/{userId}", ct); + res.StatusCode.Should().Be(HttpStatusCode.OK, because: "user account should exist"); + using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync(ct)); + return doc.RootElement.GetProperty("status").GetString(); } - /// Queries userAccountById(userAccountId) via GraphQL. Caller must dispose. - private Task GqlUserAccountByIdAsync(Guid userId, CancellationToken ct) => - GqlQueryAsync( - $"{{ userAccountById(userAccountId: \"{userId}\") {{ userAccountId tenantId email category status }} }}", - ct); - private record UserPayload(Guid TenantId, Guid? BranchId, string Email, string Category, string? IdentityReference, string? IdentityReferenceType); private static UserPayload NewUserPayload(Guid tenantId) { @@ -421,17 +416,18 @@ private static UserPayload NewUserPayload(Guid tenantId) private async Task CreateTenantId(CancellationToken ct) { var uid = Guid.NewGuid().ToString("N")[..10].ToUpper(); - var payload = new { code = $"T{uid}", name = $"E2E UA Tenant {uid}", type = "CLIENT", idpStrategy = (string?)null, companyReference = (string?)null, isManagementOwner = true }; + // G-037: management owner único; el inquilino CLIENT auxiliar no debe reclamarlo. + var payload = new { code = $"T{uid}", name = $"E2E UA Tenant {uid}", type = "CLIENT", idpStrategy = (string?)null, companyReference = (string?)null, isManagementOwner = false }; var response = await _client.PostAsJsonAsync("/api/v1/tenants", payload, ct); if (response.StatusCode != HttpStatusCode.Created) { var err = await response.Content.ReadAsStringAsync(ct); - System.IO.File.WriteAllText("e2e_tenant_error.txt", err); + await System.IO.File.WriteAllTextAsync("e2e_tenant_error.txt", err, ct); } response.EnsureSuccessStatusCode(); var location = response.Headers.Location?.ToString(); - var idString = location!.Split('/').Last(); + var idString = location!.Split('/')[^1]; var id = Guid.Parse(idString); _client.DefaultRequestHeaders.Remove("X-Tenant-Id"); diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/GlobalUsings.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/GlobalUsings.cs index ab00a4bb..3919519c 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/GlobalUsings.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/GlobalUsings.cs @@ -19,7 +19,7 @@ global using Ums.Infrastructure.Persistence.Configuration; global using Ums.Infrastructure.Persistence.Identity; global using BeyondNetCode.Shell.Aop.Aspects; -global using BeyondNetCode.Shell.Aop.Aspects.Logger.Serilog; +global using Ums.Infrastructure.Observability; global using BeyondNetCode.Shell.Ddd; global using Xunit; diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/Auth/Oidc/Fr042DbBackedRealIdpChainTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/Auth/Oidc/Fr042DbBackedRealIdpChainTests.cs new file mode 100644 index 00000000..7ea8cdda --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/Auth/Oidc/Fr042DbBackedRealIdpChainTests.cs @@ -0,0 +1,560 @@ +using System.Net.Http; +using System.Text.RegularExpressions; +using Microsoft.EntityFrameworkCore; +using Moq; +using Ums.Application.Configuration.Services; +using Ums.Application.Identity.Auth; +using Ums.Domain.Identity.Auth; +using Ums.Domain.Identity.Tenant.IdentityProvider; +using Ums.Domain.Kernel; +using Ums.Infrastructure.Identity.Auth.Oidc; +using Ums.Infrastructure.Persistence.Configuration; +using AppConfigurationAggregate = Ums.Domain.Configuration.AppConfiguration.AppConfiguration; +using IdpConfigurationAggregate = Ums.Domain.Configuration.IdpConfiguration.IdpConfiguration; +using TenantAggregate = Ums.Domain.Identity.Tenant.Tenant; + +namespace Ums.Presentation.IntegrationTest.Identity.Auth.Oidc; + +/// +/// G-109 (residual de FR-042, ADR-UMS-097): E2E de resolución/fallback FR-042 respaldado por BD, +/// juntando los dos mundos que hasta ahora vivían aparte —el arnés Keycloak real (protocolo OIDC) y el +/// camino de producción de resolución por reglas (IdpConfigurationSelector → +/// IdpConfigurationOidcProviderConfigStore → adaptador OIDC → IdpChainAuthenticator)— +/// contra DOS contenedores reales (Postgres + Keycloak). +/// +/// A diferencia del KeycloakOidcRealHarnessTests (que usa un StaticOidcProviderConfigStore +/// y no ejercita la resolución), aquí el store REAL lee las IdpConfiguration sembradas en Postgres, +/// y sus endpoints apuntan al issuer/endpoints REALES del Keycloak del contenedor —tomados de +/// /, +/// nunca hardcodeados—. +/// +/// Escenarios (por valor de seguridad): +/// +/// Camino feliz: una IdpConfiguration real resuelta por reglas autentica contra el +/// Keycloak real (Authorization Code + PKCE) por la ruta real del IdpChainAuthenticator. +/// Resolución por reglas (AuthMethodResolverService) sobre configs reales de BD → +/// proveedor Keycloak del inquilino. +/// Fallback por indisponibilidad: primaria a endpoint muerto (AUTH_035 infra) → la cadena +/// avanza a la secundaria (Keycloak real) y el login procede. +/// Invariante anti credential-spraying: primaria = Keycloak real que RECHAZA la credencial +/// (400 invalid_grant → AUTH_021) es TERMINAL — la cadena NO avanza a la secundaria. +/// Ciclo con todos los eslabones indisponibles → cortado → 503 (AUTH_018). +/// +/// +/// El único doble de test es : enruta cualquier estrategia OIDC +/// al REAL, igual que hace IdpAuthAdapterFactorySetup (Shell.Factory) +/// en producción para Keycloak/GenericOidc/…; evita cablear la fábrica sin cambiar el camino real +/// (store → adaptador → token client → validador). No se toca producción (SD-05). +/// +[Collection("PostgresKeycloak")] +public sealed class Fr042DbBackedRealIdpChainTests +{ + private const string DeadIssuerPrimary = "http://127.0.0.1:1/realms/dead-primary"; + private const string DeadIssuerSecondary = "http://127.0.0.1:1/realms/dead-secondary"; + private static readonly ActorId Actor = ActorId.Create("00000000-0000-0000-0000-000000000109"); + + private readonly PostgresKeycloakFixture _fixture; + private readonly KeycloakContainerFixture _keycloak; + + public Fr042DbBackedRealIdpChainTests(PostgresKeycloakFixture fixture) + { + _fixture = fixture; + _keycloak = fixture.Keycloak; + } + + // ── (1) Camino feliz: resolución por reglas desde BD + auth contra Keycloak real ──────────────── + + [Fact] + public async Task HappyPath_SingleRealKeycloakConfigFromDb_AuthenticatesViaRealChain() + { + if (!_fixture.BothAvailable) + { + Assert.Skip($"Postgres+Keycloak requeridos. {_fixture.StartupError}"); + return; + } + + var ct = TestContext.Current.CancellationToken; + var tenantId = Guid.NewGuid(); + + // Se siembra en Postgres UNA IdpConfiguration Keycloak activa apuntando al Keycloak REAL. + await SeedConfigAsync(NewConfig(tenantId, ProviderType.Keycloak, _keycloak.RealmIssuer, priority: 1), ct); + + // Login federado real (Authorization Code + PKCE headless) → callback con code/state/nonce reales. + var callbackJson = await ObtainRealCallbackJsonAsync(BuildRealKeycloakConfig(), ct); + + // Inquilino con proveedor Keycloak activo (el puente reglas↔dominio lo requiere). + var tenant = BuildTenant(active: IdpStrategy.Keycloak, tenantId); + + await using var chainDb = CreateDbContext(); + await using var storeDb = CreateDbContext(); + using var tokenHttp = NewHttpClient(); + using var jwksHttp = NewHttpClient(); + var audit = new Mock(); + var chain = BuildChain(chainDb, storeDb, tokenHttp, jwksHttp, audit.Object); + + var result = await chain.AuthenticateAsync(tenant, callbackJson, systemSuiteId: null, emailDomain: null, "10.0.0.1", ct); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error : null); + result.Value.Identity.Email.Should().Be(KeycloakContainerFixture.TestEmail); + result.Value.Identity.ExternalId.Should().NotBeNullOrWhiteSpace("el 'sub' real de Keycloak mapea a ExternalId"); + result.Value.Provider.Strategy.Id.Should().Be(IdpStrategy.Keycloak.Id); + audit.Verify(evt => evt.RecordAuthEventAsync(It.IsAny(), It.IsAny()), Times.Once()); + } + + // ── (2) Resolución por reglas: AuthMethodResolverService sobre configs reales de BD ───────────── + + [Fact] + public async Task AuthMethodResolver_ResolvesRealKeycloakProviderByRules_FromDb() + { + if (!_fixture.PostgresAvailable) + { + Assert.Skip($"Postgres requerido. {_fixture.StartupError}"); + return; + } + + var ct = TestContext.Current.CancellationToken; + var tenantId = Guid.NewGuid(); + + // Dos configs activas en BD: Keycloak (prioridad 1, gana) y AzureAd (prioridad 10). El selector + // elige por prioridad y el resolver la reconcilia con el proveedor Keycloak activo del inquilino. + await SeedConfigAsync(NewConfig(tenantId, ProviderType.Keycloak, _keycloak.IsAvailable ? _keycloak.RealmIssuer : DeadIssuerPrimary, priority: 1), ct); + await SeedConfigAsync(NewConfig(tenantId, ProviderType.AzureAd, DeadIssuerSecondary, priority: 10), ct); + + var tenant = BuildTenant(active: IdpStrategy.Keycloak, tenantId); + + var config = new Mock(); + config.Setup(c => c.GetWithPrecedence(AppConfigurationCodes.AuthUseExternalIdp, tenantId, It.IsAny(), It.IsAny())) + .Returns(BuildUseExternalIdp(tenantId, true)); + var tenantRepo = new Mock(); + tenantRepo.Setup(r => r.GetByIdAsync(tenantId, It.IsAny())).ReturnsAsync(tenant); + + await using var db = CreateDbContext(); + var resolver = new AuthMethodResolverService(config.Object, tenantRepo.Object, new PostgreSqlIdpConfigurationRepository(db)); + + var result = await resolver.ResolveAsync(tenantId, AuthAccessScope.ExternalApi, systemSuiteId: null, emailDomain: null, ct); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error : null); + result.Value.Type.Should().Be(AuthMethodType.IDP); + result.Value.Provider.Should().NotBeNull(); + result.Value.Provider!.Strategy.Id.Should().Be(IdpStrategy.Keycloak.Id, "la regla FR-042 gana por prioridad y se reconcilia con el proveedor activo"); + } + + // ── (3) Fallback por indisponibilidad: primaria muerta → avanza a Keycloak real ───────────────── + + [Fact] + public async Task Fallback_PrimaryInfraDown_AdvancesToRealKeycloak_Succeeds() + { + if (!_fixture.BothAvailable) + { + Assert.Skip($"Postgres+Keycloak requeridos. {_fixture.StartupError}"); + return; + } + + var ct = TestContext.Current.CancellationToken; + var tenantId = Guid.NewGuid(); + + // Secundaria (Keycloak real) se crea primero para conocer su Id y enlazar la primaria a ella. + var secondary = NewConfig(tenantId, ProviderType.Keycloak, _keycloak.RealmIssuer, priority: 2); + var primary = NewConfig(tenantId, ProviderType.GenericOidc, DeadIssuerPrimary, priority: 1, fallbackToId: secondary.GetId().GetValue()); + await SeedConfigAsync(secondary, ct); + await SeedConfigAsync(primary, ct); + + // La credencial (callback) se emite en un login real contra el Keycloak real (la secundaria). + var callbackJson = await ObtainRealCallbackJsonAsync(BuildRealKeycloakConfig(), ct); + + // El inquilino registra AMBAS estrategias: GenericOidc (primaria) y Keycloak (secundaria). + var tenant = BuildTenant(active: IdpStrategy.Keycloak, tenantId, alsoRegister: IdpStrategy.GenericOidc); + + await using var chainDb = CreateDbContext(); + await using var storeDb = CreateDbContext(); + using var tokenHttp = NewHttpClient(); + using var jwksHttp = NewHttpClient(); + var audit = new Mock(); + var chain = BuildChain(chainDb, storeDb, tokenHttp, jwksHttp, audit.Object); + + var result = await chain.AuthenticateAsync(tenant, callbackJson, systemSuiteId: null, emailDomain: null, "10.0.0.1", ct); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error : null); + result.Value.Identity.Email.Should().Be(KeycloakContainerFixture.TestEmail); + // Autenticó la SECUNDARIA (Keycloak), no la primaria muerta (GenericOidc). + result.Value.Provider.Strategy.Id.Should().Be(IdpStrategy.Keycloak.Id); + // Un evento por proveedor intentado: primaria (infra→advance) + secundaria (success). + audit.Verify(evt => evt.RecordAuthEventAsync(It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + + // ── (4) Invariante anti credential-spraying: rechazo real de credencial es TERMINAL ───────────── + + [Fact] + public async Task CredentialRejectedByRealKeycloak_IsTerminal_ChainDoesNotAdvance() + { + if (!_fixture.BothAvailable) + { + Assert.Skip($"Postgres+Keycloak requeridos. {_fixture.StartupError}"); + return; + } + + var ct = TestContext.Current.CancellationToken; + var tenantId = Guid.NewGuid(); + + // Primaria = Keycloak REAL (prioridad 1). Secundaria = endpoint muerto (prioridad 2). Si —y solo + // si— la cadena avanzara indebidamente tras un rechazo de credencial, caería en la secundaria + // muerta (infra) y agotaría la cadena → AUTH_018. El rechazo terminal debe cortar ANTES: AUTH_021. + var secondary = NewConfig(tenantId, ProviderType.GenericOidc, DeadIssuerSecondary, priority: 2); + var primary = NewConfig(tenantId, ProviderType.Keycloak, _keycloak.RealmIssuer, priority: 1, fallbackToId: secondary.GetId().GetValue()); + await SeedConfigAsync(secondary, ct); + await SeedConfigAsync(primary, ct); + + // Callback con code REAL emitido por Keycloak pero MANIPULADO: el token endpoint real lo rechaza + // con 400 invalid_grant (rechazo de credencial/petición) → AUTH_021 (terminal), no 5xx (infra). + var callbackJson = await ObtainRealCallbackJsonAsync(BuildRealKeycloakConfig(), ct, TamperCode); + + var tenant = BuildTenant(active: IdpStrategy.Keycloak, tenantId, alsoRegister: IdpStrategy.GenericOidc); + + await using var chainDb = CreateDbContext(); + await using var storeDb = CreateDbContext(); + using var tokenHttp = NewHttpClient(); + using var jwksHttp = NewHttpClient(); + var audit = new Mock(); + var chain = BuildChain(chainDb, storeDb, tokenHttp, jwksHttp, audit.Object); + + var result = await chain.AuthenticateAsync(tenant, callbackJson, systemSuiteId: null, emailDomain: null, "10.0.0.1", ct); + + result.IsFailure.Should().BeTrue("un rechazo real de credencial por Keycloak (400 invalid_grant) es terminal"); + result.Error.Should().Contain("AUTH_021", "el token endpoint real devolvió 4xx → clasificación terminal"); + result.Error.Should().NotContain("AUTH_018", "TERMINAL: la cadena NO avanzó a la secundaria (anti credential-spraying, ADR-UMS-097 §2.3)"); + // Solo el intento primario se auditó: la secundaria nunca se tocó. + audit.Verify(evt => evt.RecordAuthEventAsync(It.IsAny(), It.IsAny()), Times.Once()); + } + + // ── (5) Ciclo con todos los eslabones indisponibles → cortado → 503 (opcional, barato) ────────── + + [Fact] + public async Task Cycle_AllInfraUnavailable_Detected_Returns503() + { + if (!_fixture.PostgresAvailable) + { + Assert.Skip($"Postgres requerido. {_fixture.StartupError}"); + return; + } + + var ct = TestContext.Current.CancellationToken; + var tenantId = Guid.NewGuid(); + + // A (GenericOidc, muerto) → B (Keycloak-typed, muerto) → A. Ambos endpoints cerrados ⇒ AUTH_035 + // (infra) en cada intento ⇒ la cadena avanza hasta detectar el ciclo y se corta con 503. + var a = NewConfig(tenantId, ProviderType.GenericOidc, DeadIssuerPrimary, priority: 1); + var b = NewConfig(tenantId, ProviderType.Keycloak, DeadIssuerSecondary, priority: 2, fallbackToId: a.GetId().GetValue()); + a.Props.FallbackToId = b.GetId().GetValue(); // cierra el ciclo A→B→A + await SeedConfigAsync(a, ct); + await SeedConfigAsync(b, ct); + + // Callback bien formado (state==expectedState): la resolución de config y la comprobación de + // 'state' pasan; el intercambio de código muere en transporte (endpoint cerrado) → infra. + var callbackJson = BuildWellFormedButUnusedCallback(); + + var tenant = BuildTenant(active: IdpStrategy.Keycloak, tenantId, alsoRegister: IdpStrategy.GenericOidc); + + await using var chainDb = CreateDbContext(); + await using var storeDb = CreateDbContext(); + using var tokenHttp = NewHttpClient(); + using var jwksHttp = NewHttpClient(); + var audit = new Mock(); + var chain = BuildChain(chainDb, storeDb, tokenHttp, jwksHttp, audit.Object); + + var result = await chain.AuthenticateAsync(tenant, callbackJson, systemSuiteId: null, emailDomain: null, "10.0.0.1", ct); + + result.IsFailure.Should().BeTrue(); + result.Error.Should().Contain("AUTH_018", "cadena agotada por indisponibilidad + ciclo → 503, no 401"); + // A y B intentados una vez cada uno; sin bucle infinito. + audit.Verify(evt => evt.RecordAuthEventAsync(It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + + // ── Composición del camino REAL (store → adaptador → token client → validador → cadena) ───────── + + private static IdpChainAuthenticator BuildChain( + UmsPlatformDbContext chainDb, + UmsPlatformDbContext storeDb, + HttpClient tokenHttp, + HttpClient jwksHttp, + IAuthAuditService audit) + { + // Store REAL: lee las IdpConfiguration del inquilino de Postgres y parsea sus endpoints. + var store = new IdpConfigurationOidcProviderConfigStore(new PostgreSqlIdpConfigurationRepository(storeDb)); + // Adaptador OIDC REAL (Authorization Code + PKCE) con token client + validador reales. + var adapter = new OidcIdpAuthAdapter( + store, + new HttpOidcTokenClient(tokenHttp), + new OidcIdTokenValidator(new HttpJwksProvider(jwksHttp), TimeProvider.System)); + // Estrategia = enruta al adaptador OIDC real (mismo destino que la Shell.Factory de producción). + var strategy = new SingleAdapterIdpAuthStrategy(adapter); + + // Tope de saltos: default seguro (no configurado ⇒ el proveedor devuelve el default pasado). + var config = new Mock(); + config.Setup(c => c.GetValueAs(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((_, _, def) => def); + + return new IdpChainAuthenticator(new PostgreSqlIdpConfigurationRepository(chainDb), strategy, audit, config.Object); + } + + // ── Siembra y lectura sobre el Postgres REAL ──────────────────────────────────────────────────── + + private async Task SeedConfigAsync(IdpConfigurationAggregate config, CancellationToken ct) + { + await using var db = CreateDbContext(); + var repo = new PostgreSqlIdpConfigurationRepository(db); + await repo.AddAsync(config, ct); + await repo.SaveChangesAsync(ct); + } + + private UmsPlatformDbContext CreateDbContext() + { + var options = new DbContextOptionsBuilder() + .UseNpgsql(_fixture.ConnectionString, sql => sql.EnableRetryOnFailure(3)) + .Options; + // Contexto de sistema (org nula) ⇒ sin filtro global por inquilino: siembra y lectura ven la fila. + return new UmsPlatformDbContext( + options, + new SystemTenantContext(), + new Mock().Object, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + } + + private static IdpConfigurationAggregate NewConfig( + Guid tenantId, ProviderType providerType, string issuer, int priority, Guid? fallbackToId = null) + { + var payload = JsonSerializer.Serialize(new + { + issuer, + client_id = KeycloakContainerFixture.ClientId, + redirect_uri = KeycloakContainerFixture.RedirectUri, + scope = "openid email profile", + }); + + var config = IdpConfigurationAggregate.Create( + TenantId.Load(tenantId), + SystemSuiteId.Create(), + providerType, + Array.Empty(), + payload, + "vault/secret/idp", + priority, + fallbackToId, + Actor).Value; + config.Activate(Actor); + return config; + } + + // ── Inquilino con proveedores registrados (puente reglas↔dominio por Id de estrategia) ────────── + + private static TenantAggregate BuildTenant(IdpStrategy active, Guid tenantId, params IdpStrategy[] alsoRegister) + { + var tenant = TenantAggregate.Create( + Code.Create("RANSA"), + Name.Create("Ransa"), + OrganizationType.INTERNAL, + Actor, + active, + tenantId: TenantId.Load(tenantId)).Value; + + tenant.RegisterIdentityProvider( + Code.Create(active.Name.ToUpperInvariant()), Name.Create(active.Name), Description.Create(string.Empty), active, Actor); + + foreach (var strategy in alsoRegister) + { + tenant.RegisterIdentityProvider( + Code.Create(strategy.Name.ToUpperInvariant()), Name.Create(strategy.Name), Description.Create(string.Empty), strategy, Actor); + } + + // Se activa el proveedor primario; los demás quedan registrados+inactivos (la cadena 2b los usa + // igualmente porque la gobierna IdpConfiguration.Status, no el flag de proveedor activo, §2.5). + tenant.ActivateIdentityProvider(tenant.IdentityProviders.First().GetId(), Actor); + tenant.DomainEvents.MarkChangesAsCommitted(); + return tenant; + } + + private OidcProviderConfig BuildRealKeycloakConfig() + => new( + _keycloak.Endpoints, + new OidcClientSettings( + KeycloakContainerFixture.ClientId, + ClientSecret: null, // cliente público + PKCE + KeycloakContainerFixture.RedirectUri, + "openid email profile")); + + private static AppConfigurationAggregate BuildUseExternalIdp(Guid tenantId, bool value) + => AppConfigurationAggregate.Create( + TenantId.Load(tenantId), null, null, + Code.Create(AppConfigurationCodes.AuthUseExternalIdp), + ConfigurationValue.Create(value.ToString().ToLowerInvariant()), + Description.Create("Use external IDP"), + true, false, Actor).Value; + + // ── Login headless (Authorization Code + PKCE) contra el Keycloak real ────────────────────────── + + /// + /// Ejerce el login real (GET del formulario → POST de credenciales → 302 con ?code=&state=) + /// y empaqueta el callback como JSON tal cual lo consume el adaptador. Un + /// opcional permite manipular el code (para forzar un rechazo real de credencial, escenario 4). + /// + private async Task ObtainRealCallbackJsonAsync( + OidcProviderConfig config, CancellationToken ct, Func? codeMutator = null) + { + var authRequest = new OidcAuthorizationRequestFactory().Build(config); + var (code, state) = await PerformHeadlessLoginAsync(authRequest.AuthorizationUrl, ct); + state.Should().Be(authRequest.State, "Keycloak devuelve el mismo 'state' emitido"); + + return JsonSerializer.Serialize(new + { + code = codeMutator?.Invoke(code) ?? code, + state, + expectedState = authRequest.State, + expectedNonce = authRequest.Nonce, + codeVerifier = authRequest.CodeVerifier, + redirectUri = KeycloakContainerFixture.RedirectUri, + }); + } + + /// Callback bien formado con state==expectedState pero sin login real (para eslabones muertos). + private static string BuildWellFormedButUnusedCallback() + { + var opaque = Guid.NewGuid().ToString("N"); + return JsonSerializer.Serialize(new + { + code = "unused-code-" + opaque, + state = opaque, + expectedState = opaque, + expectedNonce = Guid.NewGuid().ToString("N"), + codeVerifier = Guid.NewGuid().ToString("N") + Guid.NewGuid().ToString("N"), + redirectUri = KeycloakContainerFixture.RedirectUri, + }); + } + + /// Manipula el code preservando el formato: Keycloak lo rechaza con 400 invalid_grant. + private static string TamperCode(string code) + { + var chars = code.ToCharArray(); + chars[0] = chars[0] == 'A' ? 'B' : 'A'; + return new string(chars); + } + + private static async Task<(string Code, string State)> PerformHeadlessLoginAsync( + string authorizationUrl, CancellationToken ct) + { + // Manejo manual de cookies: la CookieContainer de .NET descarta las cookies de sesión de Keycloak + // (SameSite=None sobre HTTP), lo que hace que el POST de login sea rechazado. Se reenvían a mano. + using var handler = new HttpClientHandler { AllowAutoRedirect = false, UseCookies = false }; + using var http = new HttpClient(handler); + + using var pageResponse = await http.GetAsync(authorizationUrl, ct); + pageResponse.IsSuccessStatusCode.Should().BeTrue("el endpoint de autorización debe servir el formulario de login"); + var html = await pageResponse.Content.ReadAsStringAsync(ct); + var cookieHeader = BuildCookieHeader(pageResponse); + + var formAction = ExtractLoginFormAction(html); + formAction.Should().NotBeNullOrWhiteSpace("el formulario de login de Keycloak debe exponer su 'action'"); + + var loginForm = new Dictionary + { + ["username"] = KeycloakContainerFixture.TestUsername, + ["password"] = KeycloakContainerFixture.TestPassword, + ["credentialId"] = string.Empty, + }; + + using var loginRequest = new HttpRequestMessage(HttpMethod.Post, formAction) + { + Content = new FormUrlEncodedContent(loginForm), + }; + if (!string.IsNullOrEmpty(cookieHeader)) + { + loginRequest.Headers.Add("Cookie", cookieHeader); + } + + using var loginResponse = await http.SendAsync(loginRequest, ct); + if (loginResponse.StatusCode is not (HttpStatusCode.Found or HttpStatusCode.SeeOther or HttpStatusCode.Redirect)) + { + var body = await loginResponse.Content.ReadAsStringAsync(ct); + var snippet = body.Length > 600 ? body[..600] : body; + throw new Xunit.Sdk.XunitException( + $"Login POST no redirigió. Status={(int)loginResponse.StatusCode}. Action={formAction}. Body:\n{snippet}"); + } + + var location = loginResponse.Headers.Location; + location.Should().NotBeNull("Keycloak debe devolver Location con el redirect_uri + code"); + + var query = ParseQuery(location!); + query.Should().ContainKey("code"); + query.Should().ContainKey("state"); + return (query["code"], query["state"]); + } + + private static string BuildCookieHeader(HttpResponseMessage response) + { + if (!response.Headers.TryGetValues("Set-Cookie", out var setCookies)) + { + return string.Empty; + } + + var pairs = setCookies + .Select(sc => sc.Split(';', 2)[0].Trim()) + .Where(pair => pair.Length > 0 && pair.Contains('=')); + + return string.Join("; ", pairs); + } + + private static string ExtractLoginFormAction(string html) + { + var login = Regex.Match( + html, "id=\"kc-form-login\"[^>]*action=\"(?[^\"]+)\"", + RegexOptions.IgnoreCase | RegexOptions.Singleline); + var raw = login.Success + ? login.Groups["url"].Value + : Regex.Match(html, "]*action=\"(?[^\"]+)\"[^>]*method=\"post\"", + RegexOptions.IgnoreCase | RegexOptions.Singleline).Groups["url"].Value; + + return WebUtility.HtmlDecode(raw); + } + + private static Dictionary ParseQuery(Uri location) + { + var result = new Dictionary(StringComparer.Ordinal); + foreach (var pair in location.Query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + var kv = pair.Split('=', 2); + result[Uri.UnescapeDataString(kv[0])] = kv.Length > 1 ? Uri.UnescapeDataString(kv[1]) : string.Empty; + } + + return result; + } + + private static HttpClient NewHttpClient() => new() { Timeout = TimeSpan.FromSeconds(30) }; + + // ── Dobles de test (mínimos, documentados) ────────────────────────────────────────────────────── + + /// + /// Único doble del test: enruta cualquier estrategia al REAL, tal como + /// hace IdpAuthAdapterFactorySetup (Shell.Factory) en producción para las estrategias OIDC. + /// Evita cablear la fábrica DI sin desviarse del camino real (store → adaptador → token client → validador). + /// + private sealed class SingleAdapterIdpAuthStrategy : IIdpAuthStrategy + { + private readonly OidcIdpAuthAdapter _adapter; + + public SingleAdapterIdpAuthStrategy(OidcIdpAuthAdapter adapter) => _adapter = adapter; + + public Task> AuthenticateAsync( + Guid tenantId, string credential, IdentityProvider provider, CancellationToken cancellationToken = default) + => _adapter.ValidateAsync(provider, credential, cancellationToken); + } + + /// Contexto de inquilino de sistema (org nula ⇒ sin filtro global por inquilino). + private sealed class SystemTenantContext : ITenantContext + { + public Guid? OrganizationId => null; + public Guid? OriginalTenantId => null; + public bool IsInternalAdmin => true; + public void Initialize(Guid userTenantId, bool isInternalAdmin) { } + public void SetOrganizationId(Guid organizationId) { } + public void EnableCrossTenantAccess() { } + public void DisableCrossTenantAccess() { } + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/Auth/Oidc/KeycloakCollectionDefinition.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/Auth/Oidc/KeycloakCollectionDefinition.cs new file mode 100644 index 00000000..085d6891 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/Auth/Oidc/KeycloakCollectionDefinition.cs @@ -0,0 +1,11 @@ +namespace Ums.Presentation.IntegrationTest.Identity.Auth.Oidc; + +/// +/// G-109: colección que arranca el Keycloak del arnés () +/// una sola vez y lo comparte entre los tests del arnés OIDC real. +/// +[CollectionDefinition("Keycloak")] +public sealed class KeycloakCollectionDefinition : ICollectionFixture +{ + // Clase marcador — xUnit cablea el fixture por el atributo de arriba. +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/Auth/Oidc/KeycloakContainerFixture.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/Auth/Oidc/KeycloakContainerFixture.cs new file mode 100644 index 00000000..c538d737 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/Auth/Oidc/KeycloakContainerFixture.cs @@ -0,0 +1,123 @@ +using System.Net; +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Configurations; +using DotNet.Testcontainers.Containers; +using Ums.Infrastructure.Identity.Auth.Oidc; + +namespace Ums.Presentation.IntegrationTest.Identity.Auth.Oidc; + +/// +/// G-109 (deuda de ADR-UMS-094): arnés Keycloak REAL sobre Testcontainers. Levanta un +/// Keycloak contenerizado (start-dev --import-realm) con el realm beyondnet +/// importado —cliente ums-client (PKCE S256 + Direct Access Grant) y usuario de +/// prueba con contraseña— para ejercer el adaptador OIDC federado contra un IdP real, +/// sin mocks del token endpoint ni del JWKS (lo que el ADR-UMS-094 prometió y el +/// slice 1 no construyó: sus tests usan JWKS/token mockeados con issuer ficticio +/// idp.test). +/// +/// Calca el patrón desechable/reproducible de PostgreSqlContainerFixture: si +/// Docker no está disponible o la imagen no se puede traer, +/// queda en false y los tests hacen Assert.Skip(...) en vez de fallar en +/// crudo. El contenedor se comparte una vez por la colección [Collection("Keycloak")]. +/// +public sealed class KeycloakContainerFixture : IAsyncLifetime +{ + // Imagen estable y pineada de Keycloak (26.x). start-dev habilita HTTP y no exige TLS, + // apropiado para un IdP de pruebas efímero. + private const string KeycloakImage = "quay.io/keycloak/keycloak:26.3.2"; + private const string RealmName = "beyondnet"; + + private IContainer? _container; + + /// Cliente OIDC público registrado en el realm importado. + public const string ClientId = "ums-client"; + + /// Redirect URI válida registrada en el cliente para el flujo authorization_code. + public const string RedirectUri = "https://ums.test/callback"; + + /// Usuario de prueba (con contraseña) sembrado por el realm importado. + public const string TestUsername = "ana.ransa"; + public const string TestPassword = "Password123!"; + public const string TestEmail = "ana.ransa@ransa.pe"; + + /// URL base del Keycloak del contenedor (host + puerto mapeado). Vacía si no arrancó. + public string BaseAddress { get; private set; } = string.Empty; + + /// Issuer real del realm: {BaseAddress}/realms/beyondnet. + public string RealmIssuer { get; private set; } = string.Empty; + + /// + /// Endpoints OIDC reales derivados del issuer del contenedor (auth/token/jwks), tal + /// como los consume el adaptador. Mismo esquema que Keycloak publica en su discovery. + /// + public OidcEndpoints Endpoints { get; private set; } = + new(string.Empty, string.Empty, string.Empty, string.Empty); + + /// + /// true cuando Keycloak arrancó y el realm quedó servido. Los tests deben + /// llamar Assert.Skip(!IsAvailable, "Docker/Keycloak requerido"). + /// + public bool IsAvailable { get; private set; } + + /// Diagnóstico del fallo de arranque (si lo hubo), para reportar sin fingir verde. + public string? StartupError { get; private set; } + + public async ValueTask InitializeAsync() + { + try + { + var realmPath = Path.Combine( + AppContext.BaseDirectory, "Identity", "Auth", "Oidc", "beyondnet-realm.json"); + var realmBytes = await File.ReadAllBytesAsync(realmPath); + + _container = new ContainerBuilder() + .WithImage(KeycloakImage) + // Bootstrap admin (KC 26+). No lo usa el test, pero evita advertencias de arranque. + .WithEnvironment("KC_BOOTSTRAP_ADMIN_USERNAME", "admin") + .WithEnvironment("KC_BOOTSTRAP_ADMIN_PASSWORD", "admin") + .WithEnvironment("KC_HTTP_ENABLED", "true") + .WithEnvironment("KC_HOSTNAME_STRICT", "false") + // Inyecta el realm importable en el directorio que --import-realm lee al arrancar. + .WithResourceMapping( + realmBytes, + "/opt/keycloak/data/import/beyondnet-realm.json", + UnixFileModes.UserRead | UnixFileModes.GroupRead | UnixFileModes.OtherRead) + .WithCommand("start-dev", "--import-realm") + .WithPortBinding(8080, assignRandomHostPort: true) + // Listo = el realm responde su discovery OIDC (el import ya ocurrió). + .WithWaitStrategy(Wait.ForUnixContainer() + .UntilHttpRequestIsSucceeded(request => request + .ForPort(8080) + .ForPath($"/realms/{RealmName}/.well-known/openid-configuration") + .ForStatusCode(HttpStatusCode.OK))) + .Build(); + + await _container.StartAsync(); + + BaseAddress = $"http://{_container.Hostname}:{_container.GetMappedPublicPort(8080)}"; + RealmIssuer = $"{BaseAddress}/realms/{RealmName}"; + Endpoints = new OidcEndpoints( + AuthorizationEndpoint: $"{RealmIssuer}/protocol/openid-connect/auth", + TokenEndpoint: $"{RealmIssuer}/protocol/openid-connect/token", + JwksUri: $"{RealmIssuer}/protocol/openid-connect/certs", + Issuer: RealmIssuer); + + IsAvailable = true; + } + catch (Exception ex) + { + // No relanzamos: Docker/Keycloak no disponibles ⇒ los tests se saltan con mensaje claro + // (a diferencia de Postgres, este arnés es aditivo y no debe tumbar la corrida entera). + StartupError = ex.ToString(); + IsAvailable = false; + } + } + + public async ValueTask DisposeAsync() + { + if (_container is not null) + { + await _container.DisposeAsync(); + } + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/Auth/Oidc/KeycloakOidcRealHarnessTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/Auth/Oidc/KeycloakOidcRealHarnessTests.cs new file mode 100644 index 00000000..db9450dc --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/Auth/Oidc/KeycloakOidcRealHarnessTests.cs @@ -0,0 +1,408 @@ +using System.Net.Http; +using System.Text.RegularExpressions; +using Ums.Domain.Identity.Tenant.IdentityProvider; +using Ums.Infrastructure.Identity.Auth.Oidc; + +namespace Ums.Presentation.IntegrationTest.Identity.Auth.Oidc; + +/// +/// G-109 (deuda de ADR-UMS-094): arnés de integración que ejerce el adaptador OIDC federado +/// contra un Keycloak real (Testcontainers), sin mocks del token endpoint ni del JWKS. +/// +/// Los tests unitarios (Application.Test/.../Oidc) validan el id_token con JWKS/token +/// mockeados e issuer ficticio idp.test. Aquí lo esencial es lo que esos mocks NO cubren: +/// que un id_token emitido y firmado por llaves RSA reales de Keycloak pase por la ruta +/// de validación real del adaptador — contra el JWKS real + +/// (firma RS256, iss/aud/exp/nonce). +/// +/// Cobertura por test: +/// +/// ESENCIAL (Direct Access Grant): id_token real → JWKS real → validador real. +/// Positivo (firma/iss/aud reales) y negativos con llaves reales (aud, firma manipulada, iss). +/// DESEABLE (authorization_code + PKCE headless): flujo completo a través de +/// con real (intercambio de +/// código + PKCE) — incluye validación de nonce real emitido en la autorización. +/// OPCIONAL (resolución por configuración, estilo FR-042): endpoints derivados por +/// desde un ConfigPayload con solo el issuer del +/// Keycloak del contenedor, y validación de un id_token real contra ellos. +/// +/// +[Collection("Keycloak")] +public sealed class KeycloakOidcRealHarnessTests +{ + private readonly KeycloakContainerFixture _keycloak; + + public KeycloakOidcRealHarnessTests(KeycloakContainerFixture keycloak) => _keycloak = keycloak; + + // ── ESENCIAL: id_token REAL por la ruta de validación REAL (JWKS + validador) ────────────── + + [Fact] + public async Task RealIdToken_ViaDirectAccessGrant_PassesRealJwksAndValidator() + { + if (!_keycloak.IsAvailable) + { + Assert.Skip($"Keycloak/Docker requerido. {_keycloak.StartupError}"); + return; + } + + var ct = TestContext.Current.CancellationToken; + + using var http = new HttpClient(); + var idToken = await RequestIdTokenViaDirectAccessGrantAsync(http, ct); + idToken.Should().NotBeNullOrWhiteSpace("Keycloak debe emitir un id_token real para el password grant con scope openid"); + + // Ruta de validación REAL: HttpJwksProvider hace GET al JWKS real de Keycloak; + // OidcIdTokenValidator verifica firma RS256 contra las llaves reales + iss/aud/exp. + using var jwksHttp = new HttpClient(); + var validator = new OidcIdTokenValidator(new HttpJwksProvider(jwksHttp), TimeProvider.System); + + var result = await validator.ValidateAsync( + idToken!, + _keycloak.Endpoints, + expectedAudience: KeycloakContainerFixture.ClientId, + // Direct Access Grant no emite 'nonce' (solo el authorization endpoint lo hace): + // el validador omite la comprobación de nonce cuando expectedNonce es vacío. + expectedNonce: string.Empty, + ct); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error : null); + result.Value.Email.Should().Be(KeycloakContainerFixture.TestEmail); + result.Value.ExternalId.Should().NotBeNullOrWhiteSpace("el 'sub' real de Keycloak mapea a ExternalId"); + result.Value.Claims.Should().ContainKey("preferred_username"); + } + + [Fact] + public async Task RealIdToken_WrongAudience_RejectedByRealValidator_AUTH028() + { + if (!_keycloak.IsAvailable) + { + Assert.Skip($"Keycloak/Docker requerido. {_keycloak.StartupError}"); + return; + } + + var ct = TestContext.Current.CancellationToken; + using var http = new HttpClient(); + var idToken = await RequestIdTokenViaDirectAccessGrantAsync(http, ct); + + using var jwksHttp = new HttpClient(); + var validator = new OidcIdTokenValidator(new HttpJwksProvider(jwksHttp), TimeProvider.System); + + // Firma/JWKS reales, pero exigimos una audiencia distinta a la del token real ⇒ rechazo. + var result = await validator.ValidateAsync( + idToken!, _keycloak.Endpoints, expectedAudience: "otro-cliente", expectedNonce: string.Empty, ct); + + result.IsFailure.Should().BeTrue(); + result.Error.Should().Contain("AUTH_028"); + } + + [Fact] + public async Task RealIdToken_TamperedSignature_RejectedByRealValidator_AUTH026() + { + if (!_keycloak.IsAvailable) + { + Assert.Skip($"Keycloak/Docker requerido. {_keycloak.StartupError}"); + return; + } + + var ct = TestContext.Current.CancellationToken; + using var http = new HttpClient(); + var idToken = await RequestIdTokenViaDirectAccessGrantAsync(http, ct); + + // Manipula la firma (último segmento) preservando el formato JWT: el validador debe + // rechazarla al verificar contra las llaves públicas reales del JWKS. + var parts = idToken!.Split('.'); + var sig = parts[2].ToCharArray(); + sig[0] = sig[0] == 'A' ? 'B' : 'A'; + var tampered = $"{parts[0]}.{parts[1]}.{new string(sig)}"; + + using var jwksHttp = new HttpClient(); + var validator = new OidcIdTokenValidator(new HttpJwksProvider(jwksHttp), TimeProvider.System); + + var result = await validator.ValidateAsync( + tampered, _keycloak.Endpoints, expectedAudience: KeycloakContainerFixture.ClientId, + expectedNonce: string.Empty, ct); + + result.IsFailure.Should().BeTrue(); + result.Error.Should().Contain("AUTH_026"); + } + + [Fact] + public async Task RealIdToken_WrongIssuer_RejectedByRealValidator_AUTH027() + { + if (!_keycloak.IsAvailable) + { + Assert.Skip($"Keycloak/Docker requerido. {_keycloak.StartupError}"); + return; + } + + var ct = TestContext.Current.CancellationToken; + using var http = new HttpClient(); + var idToken = await RequestIdTokenViaDirectAccessGrantAsync(http, ct); + + using var jwksHttp = new HttpClient(); + var validator = new OidcIdTokenValidator(new HttpJwksProvider(jwksHttp), TimeProvider.System); + + // JWKS real (mismo host), pero declaramos un issuer esperado distinto ⇒ rechazo por iss. + var endpointsWithWrongIssuer = _keycloak.Endpoints with { Issuer = "https://malicioso.test/realms/beyondnet" }; + + var result = await validator.ValidateAsync( + idToken!, endpointsWithWrongIssuer, expectedAudience: KeycloakContainerFixture.ClientId, + expectedNonce: string.Empty, ct); + + result.IsFailure.Should().BeTrue(); + result.Error.Should().Contain("AUTH_027"); + } + + // ── DESEABLE: flujo authorization_code + PKCE headless por el adaptador completo ───────────── + + [Fact] + public async Task FullAuthorizationCodePkceFlow_ThroughAdapter_ReturnsExternalIdentity() + { + if (!_keycloak.IsAvailable) + { + Assert.Skip($"Keycloak/Docker requerido. {_keycloak.StartupError}"); + return; + } + + var ct = TestContext.Current.CancellationToken; + + var config = new OidcProviderConfig( + _keycloak.Endpoints, + new OidcClientSettings( + ClientId: KeycloakContainerFixture.ClientId, + ClientSecret: null, // cliente público + PKCE + RedirectUri: KeycloakContainerFixture.RedirectUri, + Scopes: "openid email profile")); + + // 1) El adaptador construye la URL de autorización (state/nonce/code_verifier/code_challenge S256). + var authRequest = new OidcAuthorizationRequestFactory().Build(config); + + // 2) Login headless contra Keycloak real: GET del form → POST credenciales → 302 con ?code=&state=. + var (code, returnedState) = await PerformHeadlessAuthorizationCodeLoginAsync(authRequest.AuthorizationUrl, ct); + returnedState.Should().Be(authRequest.State, "Keycloak devuelve el mismo 'state' emitido"); + + // 3) El nivel de presentación empaqueta el callback como JSON; el adaptador lo intercambia por + // tokens (HttpOidcTokenClient real, grant_type=authorization_code + code_verifier) y valida + // el id_token real (JWKS real + nonce real emitido en la autorización). + var callbackJson = JsonSerializer.Serialize(new + { + code, + state = returnedState, + expectedState = authRequest.State, + expectedNonce = authRequest.Nonce, + codeVerifier = authRequest.CodeVerifier, + redirectUri = KeycloakContainerFixture.RedirectUri, + }); + + using var tokenHttp = new HttpClient(); + using var jwksHttp = new HttpClient(); + var adapter = new OidcIdpAuthAdapter( + new StaticOidcProviderConfigStore(config), + new HttpOidcTokenClient(tokenHttp), + new OidcIdTokenValidator(new HttpJwksProvider(jwksHttp), TimeProvider.System)); + + var result = await adapter.ValidateAsync(BuildKeycloakProvider(), callbackJson, ct); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error : null); + result.Value.Email.Should().Be(KeycloakContainerFixture.TestEmail); + result.Value.DisplayName.Should().NotBeNullOrWhiteSpace(); + } + + // ── OPCIONAL: resolución por configuración (parser) contra el Keycloak real (estilo FR-042) ── + + [Fact] + public async Task ConfigPayloadIssuerOnly_ResolvesRealKeycloakEndpoints_AndValidatesRealToken() + { + if (!_keycloak.IsAvailable) + { + Assert.Skip($"Keycloak/Docker requerido. {_keycloak.StartupError}"); + return; + } + + var ct = TestContext.Current.CancellationToken; + + // ConfigPayload con SOLO el issuer del contenedor: el parser deriva auth/token/jwks al estilo + // Keycloak/OIDC (como lo haría IdpConfiguration.ConfigPayload en runtime, sin hardcodear endpoints). + var configPayload = JsonSerializer.Serialize(new + { + issuer = _keycloak.RealmIssuer, + client_id = KeycloakContainerFixture.ClientId, + redirect_uri = KeycloakContainerFixture.RedirectUri, + scope = "openid email profile", + }); + + var parsed = OidcProviderConfigParser.Parse(configPayload); + parsed.IsSuccess.Should().BeTrue(parsed.IsFailure ? parsed.Error : null); + + // Los endpoints derivados por el parser deben coincidir con los reales de Keycloak. + parsed.Value.Endpoints.JwksUri.Should().Be(_keycloak.Endpoints.JwksUri); + parsed.Value.Endpoints.TokenEndpoint.Should().Be(_keycloak.Endpoints.TokenEndpoint); + + // Y un id_token real debe validar contra esos endpoints derivados (JWKS real alcanzable). + using var http = new HttpClient(); + var idToken = await RequestIdTokenViaDirectAccessGrantAsync(http, ct); + + using var jwksHttp = new HttpClient(); + var validator = new OidcIdTokenValidator(new HttpJwksProvider(jwksHttp), TimeProvider.System); + var result = await validator.ValidateAsync( + idToken!, parsed.Value.Endpoints, expectedAudience: KeycloakContainerFixture.ClientId, + expectedNonce: string.Empty, ct); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error : null); + result.Value.Email.Should().Be(KeycloakContainerFixture.TestEmail); + } + + // ── Helpers ───────────────────────────────────────────────────────────────────────────────── + + /// + /// Pide un id_token REAL a Keycloak por Direct Access Grant (grant_type=password) con + /// scope openid. Es un token firmado por las llaves reales del realm, aunque no sea el + /// flujo authorization_code — suficiente para ejercer la validación real (JWKS + firma). + /// + private async Task RequestIdTokenViaDirectAccessGrantAsync(HttpClient http, CancellationToken ct) + { + var form = new Dictionary + { + ["grant_type"] = "password", + ["client_id"] = KeycloakContainerFixture.ClientId, + ["username"] = KeycloakContainerFixture.TestUsername, + ["password"] = KeycloakContainerFixture.TestPassword, + ["scope"] = "openid email profile", + }; + + using var response = await http.PostAsync( + _keycloak.Endpoints.TokenEndpoint, new FormUrlEncodedContent(form), ct); + var body = await response.Content.ReadAsStringAsync(ct); + response.IsSuccessStatusCode.Should().BeTrue($"token endpoint real debe responder 200; body: {body}"); + + using var doc = JsonDocument.Parse(body); + return doc.RootElement.TryGetProperty("id_token", out var idToken) ? idToken.GetString() : null; + } + + /// + /// Ejercita el login headless del flujo authorization_code contra Keycloak real: GET del + /// endpoint de autorización → parsea el action del formulario de login → POST de + /// credenciales → captura el 302 al redirect_uri y extrae code/state. + /// No se sigue el redirect (redirect_uri no resuelve; solo interesa el 'code'). + /// + private static async Task<(string Code, string State)> PerformHeadlessAuthorizationCodeLoginAsync( + string authorizationUrl, CancellationToken ct) + { + // Manejo manual de cookies: la CookieContainer de .NET descarta las cookies de sesión de + // Keycloak (KC_RESTART/AUTH_SESSION_ID con SameSite=None sobre HTTP), lo que hace que el POST + // de login sea rechazado con 400 «cookie not found». Reenviamos los pares name=value a mano. + using var handler = new HttpClientHandler { AllowAutoRedirect = false, UseCookies = false }; + using var http = new HttpClient(handler); + + using var pageResponse = await http.GetAsync(authorizationUrl, ct); + pageResponse.IsSuccessStatusCode.Should().BeTrue("el endpoint de autorización debe servir el formulario de login"); + var html = await pageResponse.Content.ReadAsStringAsync(ct); + var cookieHeader = BuildCookieHeader(pageResponse); + + var formAction = ExtractLoginFormAction(html); + formAction.Should().NotBeNullOrWhiteSpace("el formulario de login de Keycloak debe exponer su 'action'"); + + var loginForm = new Dictionary + { + ["username"] = KeycloakContainerFixture.TestUsername, + ["password"] = KeycloakContainerFixture.TestPassword, + ["credentialId"] = string.Empty, + }; + + using var loginRequest = new HttpRequestMessage(HttpMethod.Post, formAction) + { + Content = new FormUrlEncodedContent(loginForm), + }; + if (!string.IsNullOrEmpty(cookieHeader)) + { + loginRequest.Headers.Add("Cookie", cookieHeader); + } + + using var loginResponse = await http.SendAsync(loginRequest, ct); + if (loginResponse.StatusCode is not (HttpStatusCode.Found or HttpStatusCode.SeeOther or HttpStatusCode.Redirect)) + { + var diagBody = await loginResponse.Content.ReadAsStringAsync(ct); + var snippet = diagBody.Length > 600 ? diagBody[..600] : diagBody; + throw new Xunit.Sdk.XunitException( + $"Login POST no redirigió. Status={(int)loginResponse.StatusCode}. Cookies=[{cookieHeader}]. Action={formAction}. Body:\n{snippet}"); + } + + var location = loginResponse.Headers.Location; + location.Should().NotBeNull("Keycloak debe devolver Location con el redirect_uri + code"); + + var query = ParseQuery(location!); + query.Should().ContainKey("code"); + query.Should().ContainKey("state"); + return (query["code"], query["state"]); + } + + /// + /// Extrae los pares name=value de todas las cabeceras Set-Cookie de la respuesta + /// y los une para reenviarlos como cabecera Cookie en el POST de login (manejo manual, + /// evitando las peculiaridades de con Keycloak). + /// + private static string BuildCookieHeader(HttpResponseMessage response) + { + if (!response.Headers.TryGetValues("Set-Cookie", out var setCookies)) + { + return string.Empty; + } + + var pairs = setCookies + .Select(sc => sc.Split(';', 2)[0].Trim()) + .Where(pair => pair.Length > 0 && pair.Contains('=')); + + return string.Join("; ", pairs); + } + + private static string ExtractLoginFormAction(string html) + { + // El template de login de Keycloak usa id="kc-form-login" con un action de POST. + var login = Regex.Match( + html, "id=\"kc-form-login\"[^>]*action=\"(?[^\"]+)\"", + RegexOptions.IgnoreCase | RegexOptions.Singleline); + var raw = login.Success + ? login.Groups["url"].Value + : Regex.Match(html, "]*action=\"(?[^\"]+)\"[^>]*method=\"post\"", + RegexOptions.IgnoreCase | RegexOptions.Singleline).Groups["url"].Value; + + return WebUtility.HtmlDecode(raw); + } + + private static Dictionary ParseQuery(Uri location) + { + var result = new Dictionary(StringComparer.Ordinal); + foreach (var pair in location.Query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + var kv = pair.Split('=', 2); + result[Uri.UnescapeDataString(kv[0])] = kv.Length > 1 ? Uri.UnescapeDataString(kv[1]) : string.Empty; + } + + return result; + } + + private static IdentityProvider BuildKeycloakProvider() + => IdentityProvider.Create( + TenantId.Load(Guid.NewGuid()), + Code.Create("KC"), + Name.Create("Keycloak"), + Description.Create("IdP corporativo (arnes real G-109)"), + IdpStrategy.Keycloak, + ActorId.Create("00000000-0000-0000-0000-000000000109")).Value; + + /// + /// estático que devuelve la configuración OIDC del + /// Keycloak del contenedor. La resolución real por prioridad/suite/dominio (FR-042) vive en + /// otro subsistema; aquí no se prueba la resolución sino el protocolo OIDC contra el IdP real. + /// + private sealed class StaticOidcProviderConfigStore : IOidcProviderConfigStore + { + private readonly OidcProviderConfig _config; + + public StaticOidcProviderConfigStore(OidcProviderConfig config) => _config = config; + + public Task> GetAsync( + IdentityProvider provider, CancellationToken cancellationToken = default) + => Task.FromResult(Ums.Domain.Kernel.Result.Success(_config)); + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/Auth/Oidc/PostgresKeycloakCollectionDefinition.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/Auth/Oidc/PostgresKeycloakCollectionDefinition.cs new file mode 100644 index 00000000..5194947d --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/Auth/Oidc/PostgresKeycloakCollectionDefinition.cs @@ -0,0 +1,12 @@ +namespace Ums.Presentation.IntegrationTest.Identity.Auth.Oidc; + +/// +/// G-109: colección que arranca UNA sola vez el fixture compuesto Postgres+Keycloak +/// () y lo comparte entre los tests del E2E de +/// resolución/fallback FR-042 respaldado por BD. +/// +[CollectionDefinition("PostgresKeycloak")] +public sealed class PostgresKeycloakCollectionDefinition : ICollectionFixture +{ + // Clase marcador — xUnit cablea el fixture por el atributo de arriba. +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/Auth/Oidc/PostgresKeycloakFixture.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/Auth/Oidc/PostgresKeycloakFixture.cs new file mode 100644 index 00000000..783798da --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/Auth/Oidc/PostgresKeycloakFixture.cs @@ -0,0 +1,76 @@ +using Ums.Presentation.IntegrationTest.Infrastructure; + +namespace Ums.Presentation.IntegrationTest.Identity.Auth.Oidc; + +/// +/// G-109 (residual de FR-042, ADR-UMS-097): fixture compuesto que orquesta a la vez los DOS +/// contenedores reales que exige el E2E de resolución/fallback FR-042 respaldado por BD: +/// +/// Postgres — donde se siembran y de donde se leen las IdpConfiguration reales +/// (el camino de producción del store IdpConfigurationOidcProviderConfigStore). +/// Keycloak — el IdP OIDC real contra el que se autentica (Authorization Code + PKCE). +/// +/// +/// Reto xUnit v3 resuelto: una clase de test pertenece a UNA sola colección, así que no +/// puede combinar las colecciones PostgreSql y Keycloak por separado. Aquí se elige la +/// opción de fixture que compone ambos: reutiliza tal cual los fixtures existentes +/// ( y ) sin tocarlos, y +/// se expone bajo un único ICollectionFixture (ver PostgresKeycloakCollectionDefinition). +/// +/// Skip sin Docker en AMBOS: el fixture de Keycloak ya traga el fallo de arranque; el de +/// Postgres, en cambio, relanza (semántica de su colección original). Este compuesto captura ese +/// relanzamiento para conservar la semántica de Assert.Skip también cuando falta Docker. +/// +public sealed class PostgresKeycloakFixture : IAsyncLifetime +{ + private readonly PostgreSqlContainerFixture _postgres = new(); + + /// Fixture del Keycloak real (endpoints, issuer y constantes del realm importado). + public KeycloakContainerFixture Keycloak { get; } = new(); + + /// Cadena de conexión al Postgres del contenedor (vacía si no arrancó). + public string ConnectionString => _postgres.ConnectionString; + + public bool PostgresAvailable { get; private set; } + + public bool KeycloakAvailable => Keycloak.IsAvailable; + + /// Ambos contenedores listos: precondición para no saltar los tests del E2E. + public bool BothAvailable => PostgresAvailable && KeycloakAvailable; + + /// Diagnóstico agregado del fallo de arranque (sin fingir verde, SD-05). + public string StartupError => + $"Postgres={(PostgresAvailable ? "OK" : _postgresError ?? "no disponible")}; " + + $"Keycloak={(KeycloakAvailable ? "OK" : Keycloak.StartupError ?? "no disponible")}"; + + private string? _postgresError; + + public async ValueTask InitializeAsync() + { + // Dos contenedores son LENTOS: se arrancan en PARALELO para acotar el tiempo de la colección. + var keycloakTask = Keycloak.InitializeAsync().AsTask(); + var postgresTask = StartPostgresAsync(); + await Task.WhenAll(keycloakTask, postgresTask); + } + + private async Task StartPostgresAsync() + { + try + { + await _postgres.InitializeAsync(); + PostgresAvailable = _postgres.IsAvailable; + } + catch (Exception ex) + { + // El fixture de Postgres relanza ante Docker ausente; lo capturamos para saltar (no tumbar). + _postgresError = ex.Message; + PostgresAvailable = false; + } + } + + public async ValueTask DisposeAsync() + { + await Keycloak.DisposeAsync(); + await _postgres.DisposeAsync(); + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/Auth/Oidc/beyondnet-realm.json b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/Auth/Oidc/beyondnet-realm.json new file mode 100644 index 00000000..abd894f8 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/Auth/Oidc/beyondnet-realm.json @@ -0,0 +1,63 @@ +{ + "realm": "beyondnet", + "enabled": true, + "sslRequired": "none", + "registrationAllowed": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "accessTokenLifespan": 300, + "clients": [ + { + "clientId": "ums-client", + "name": "UMS Federated Client (arnes de prueba)", + "enabled": true, + "protocol": "openid-connect", + "publicClient": true, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "fullScopeAllowed": true, + "redirectUris": [ + "https://ums.test/callback", + "http://localhost/*", + "http://127.0.0.1/*" + ], + "webOrigins": ["+"], + "attributes": { + "pkce.code.challenge.method": "S256", + "post.logout.redirect.uris": "+" + }, + "defaultClientScopes": [ + "acr", + "basic", + "email", + "profile", + "roles", + "web-origins" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + } + ], + "users": [ + { + "username": "ana.ransa", + "enabled": true, + "emailVerified": true, + "email": "ana.ransa@ransa.pe", + "firstName": "Ana", + "lastName": "Ransa", + "credentials": [ + { + "type": "password", + "value": "Password123!", + "temporary": false + } + ] + } + ] +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/AuthEndpointRoutingTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/AuthEndpointRoutingTests.cs index 16d2b6f1..79f96c8e 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/AuthEndpointRoutingTests.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/AuthEndpointRoutingTests.cs @@ -26,4 +26,24 @@ public void VisualLoginAndClientAuthentication_ShouldBeMappedToDifferentApiRoots routes.Should().Contain("/api/v1/client/authenticate"); routes.Should().NotContain("/api/v1/auth/client/authenticate"); } + + /// + /// El carril de satélite tiene su propio cambio de perfil (ADR-0156 §8). Los dos endpoints + /// coexisten a propósito: `/auth/switch-profile` sirve al portal —forma de respuesta propia y + /// cookie de sesión— y `/client/switch-profile` al portador semántico, que no tiene ni puede + /// usar esa cookie. Que este test los exija a los dos es lo que impide que alguien «unifique» + /// el carril de satélite dentro del endpoint del portal, que valida el token a mano con + /// `ValidateIssuer=false` (G-201). + /// + [Fact] + public void SwitchProfile_ShouldExistOnBothRails() + { + var routes = _endpointDataSource.Endpoints + .OfType() + .Select(endpoint => endpoint.RoutePattern.RawText) + .ToArray(); + + routes.Should().Contain("/api/v1/auth/switch-profile"); + routes.Should().Contain("/api/v1/client/switch-profile"); + } } diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/BranchClosureTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/BranchClosureTests.cs new file mode 100644 index 00000000..bd0e1067 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/BranchClosureTests.cs @@ -0,0 +1,344 @@ +using Microsoft.EntityFrameworkCore; +using Ums.Presentation.IntegrationTest.Infrastructure; + +namespace Ums.Presentation.IntegrationTest.Identity; + +/// +/// ADR-0164 aplicado a las SUCURSALES, verificado contra PostgreSQL real (Testcontainers). +/// +/// Antes de este cambio, DELETE /tenants/{id}/branches/{branchId} ejecutaba un +/// DELETE de verdad: Tenant.RemoveBranch quitaba la sucursal de la colección y +/// EfChildCollectionReconciler traducía la ausencia en un borrado físico. Como +/// UserAccounts.BranchId y Profiles.BranchId no tienen clave ajena contra +/// TenantBranches, esas filas quedaban apuntando al vacío sin que nadie se enterara. +/// +/// Lo que se fija aquí, y por qué cada cosa se mide donde se mide: +/// +/// +/// La fila sigue en la base. Se lee con un contexto EF propio, NO por el API: preguntar al +/// API no distingue «oculto» de «borrado», y esa distinción es justamente lo que hay que probar. +/// +/// +/// Cerrar con referencias vivas se rechaza con 409 y el cuerpo nombra qué bloquea. Con la +/// contraprueba obligatoria: una vez que esa referencia deja de estar activa, el cierre procede. +/// +/// +/// El código no se libera. El índice único no filtra por el estado de cierre, y el alta +/// responde un conflicto legible en vez de reventar con un 23505. +/// +/// La bitácora registra el episodio, con su fecha y su autor. +/// +/// +[Collection("PostgreSql")] +public sealed class BranchClosureTests : IntegrationTestBase +{ + /// Identificadores de BranchLifecycleEpisode tal y como se persisten. + private const int EpisodioApertura = 1; + private const int EpisodioCierre = 4; + + public BranchClosureTests(PostgreSqlContainerFixture fixture) : base(fixture) { } + + [Fact] + public async Task Cerrar_DejaLaFilaEnLaBase_ConSuCodigoYSuAuditoria() + { + if (!Fixture.IsAvailable) Assert.Skip("Docker es necesario para las pruebas de integración PostgreSQL."); + var ct = TestContext.Current.CancellationToken; + + var tenantId = await ProvisionarInquilinoAsync(ct); + var (branchId, codigo) = await CrearSucursalAsync(tenantId, "Terminal Callao", ct); + + var cierre = await Client.DeleteAsync( + $"/api/v1/tenants/{tenantId}/branches/{branchId}?reason=Cese%20de%20operaciones%20en%20la%20plaza", ct); + cierre.StatusCode.Should().Be(HttpStatusCode.NoContent, await cierre.Content.ReadAsStringAsync(ct)); + + // LA FILA SIGUE AHÍ. Esta es la afirmación central del ADR y se mide sobre el almacenamiento, + // no sobre el API. + await using var db = CrearContextoDirecto(); + var fila = await db.TenantBranches.SingleOrDefaultAsync(b => b.Id == branchId, ct); + + fila.Should().NotBeNull("el borrado es LÓGICO: la fila no puede desaparecer de la tabla"); + fila!.IsClosed.Should().BeTrue(); + fila.IsActive.Should().BeFalse("una sucursal cerrada evidentemente no opera"); + fila.ClosedAtUtc.Should().NotBeNull(); + fila.ClosedBy.Should().NotBeNullOrWhiteSpace("la fila debe decir quién la cerró"); + fila.Code.Should().Be(codigo, "el histórico conserva el código con el que operó"); + fila.Name.Should().Be("Terminal Callao"); + + // Y la lectura ordinaria la esconde, que es lo que ve quien opera hoy. + using var listado = await LeerSucursalesAsync(tenantId, ct); + listado.RootElement.EnumerateArray().Any(b => b.GetProperty("branchId").GetGuid() == branchId) + .Should().BeFalse(); + } + + [Fact] + public async Task Cerrar_ConUsuariosActivos_Responde409ConElDesglose_YProcedeCuandoDejanDeEstarlo() + { + if (!Fixture.IsAvailable) Assert.Skip("Docker es necesario para las pruebas de integración PostgreSQL."); + var ct = TestContext.Current.CancellationToken; + + var tenantId = await ProvisionarInquilinoAsync(ct); + var (branchId, _) = await CrearSucursalAsync(tenantId, "Almacén Paita", ct); + var userId = await CrearUsuarioActivoEnSucursalAsync(tenantId, branchId, ct); + + // (b) Referencia VIVA: se rechaza y se nombra qué bloquea. + var bloqueado = await Client.DeleteAsync($"/api/v1/tenants/{tenantId}/branches/{branchId}", ct); + bloqueado.StatusCode.Should().Be(HttpStatusCode.Conflict, await bloqueado.Content.ReadAsStringAsync(ct)); + + using var error = JsonDocument.Parse(await bloqueado.Content.ReadAsStringAsync(ct)); + error.RootElement.GetProperty("errorCode").GetString().Should().Be("BRANCH_HAS_LIVE_REFERENCES"); + error.RootElement.GetProperty("message").GetString().Should().NotBeNullOrWhiteSpace(); + var deps = error.RootElement.GetProperty("blockingDependencies"); + deps.GetArrayLength().Should().BeGreaterThan(0); + deps.EnumerateArray().Any(d => d.GetProperty("entityType").GetString() == "UserAccount") + .Should().BeTrue("el desglose debe decir que lo que bloquea son cuentas de usuario"); + deps.EnumerateArray().First(d => d.GetProperty("entityType").GetString() == "UserAccount") + .GetProperty("count").GetInt32().Should().Be(1); + + // Un rechazo no puede haber escrito nada. + await using (var verificacion = CrearContextoDirecto()) + { + (await verificacion.TenantBranches.SingleAsync(b => b.Id == branchId, ct)).IsClosed + .Should().BeFalse("un cierre rechazado no toca la fila"); + } + + // CONTRAPRUEBA (ADR-0164 §2.2): lo ya retirado NO bloquea. Sin ella la regla se cumpliría por + // accidente —bastaría con rechazar siempre— y nadie lo notaría. + (await Client.PostAsync($"/api/v1/user-accounts/{userId}/block?reason=reubicacion", null, ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + var cierre = await Client.DeleteAsync($"/api/v1/tenants/{tenantId}/branches/{branchId}", ct); + cierre.StatusCode.Should().Be(HttpStatusCode.NoContent, await cierre.Content.ReadAsStringAsync(ct)); + } + + [Fact] + public async Task ElCodigoDeUnaSucursalCerrada_NoSeLibera() + { + if (!Fixture.IsAvailable) Assert.Skip("Docker es necesario para las pruebas de integración PostgreSQL."); + var ct = TestContext.Current.CancellationToken; + + var tenantId = await ProvisionarInquilinoAsync(ct); + var (branchId, codigo) = await CrearSucursalAsync(tenantId, "Depósito Tacna", ct); + + (await Client.DeleteAsync($"/api/v1/tenants/{tenantId}/branches/{branchId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + // ADR-0164 §2.3: el índice único NO se filtra por el estado de cierre, así que el alta con el + // mismo código responde un CONFLICTO DE DOMINIO legible —no una violación de índice + // convertida en 500, y desde luego no un 201 que dejaría dos sucursales con el mismo código y + // volvería ambigua cualquier consulta histórica. + var reintento = await Client.PostAsJsonAsync($"/api/v1/tenants/{tenantId}/branches", new + { + code = codigo, + name = "Depósito Tacna (segunda época)", + geofencingMetadata = (string?)null, + }, ct); + + reintento.StatusCode.Should().Be(HttpStatusCode.Conflict, await reintento.Content.ReadAsStringAsync(ct)); + + await using var db = CrearContextoDirecto(); + var filas = await db.TenantBranches.Where(b => b.TenantId == tenantId && b.Code == codigo).ToListAsync(ct); + filas.Should().HaveCount(1, "el código sigue ocupado por la sucursal cerrada y no admite una segunda"); + } + + [Fact] + public async Task LaBitacora_RegistraElCierre_ConSuFechaSuAutorYSuMotivo() + { + if (!Fixture.IsAvailable) Assert.Skip("Docker es necesario para las pruebas de integración PostgreSQL."); + var ct = TestContext.Current.CancellationToken; + + var tenantId = await ProvisionarInquilinoAsync(ct); + var (branchId, _) = await CrearSucursalAsync(tenantId, "Terminal Matarani", ct); + + (await Client.PostAsync($"/api/v1/tenants/{tenantId}/branches/{branchId}/deactivate", null, ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + (await Client.PostAsync($"/api/v1/tenants/{tenantId}/branches/{branchId}/reactivate", null, ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + (await Client.DeleteAsync($"/api/v1/tenants/{tenantId}/branches/{branchId}?reason=Fin%20de%20concesion", ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + // Se lee de la TABLA, no del API: la bitácora tiene que estar persistida en la misma + // transacción que el cambio de estado, no depender de un manejador post-commit que por + // contrato (ADR-0098 D4) es best-effort y puede perder episodios sin avisar. + await using var db = CrearContextoDirecto(); + var asientos = await db.TenantBranchLifecycleEntries + .Where(e => e.BranchId == branchId) + .OrderBy(e => e.OccurredAtUtc) + .ToListAsync(ct); + + asientos.Should().HaveCount(4, "apertura, baja, reapertura y cierre"); + asientos[0].EpisodeId.Should().Be(EpisodioApertura); + asientos[^1].EpisodeId.Should().Be(EpisodioCierre); + asientos[^1].Reason.Should().Be("Fin de concesion"); + asientos.Should().OnlyContain(a => a.TenantId == tenantId); + asientos.Should().OnlyContain(a => !string.IsNullOrWhiteSpace(a.ActorId)); + asientos.Should().OnlyContain(a => a.OccurredAtUtc != default); + asientos.Should().OnlyContain(a => a.NameSnapshot == "Terminal Matarani"); + + // Y es consultable por el API, también después de cerrada: preguntar por el pasado de una + // sucursal cerrada es el caso de uso, no una excepción. + var respuesta = await Client.GetAsync($"/api/v1/tenants/{tenantId}/branches/{branchId}/bitacora", ct); + respuesta.StatusCode.Should().Be(HttpStatusCode.OK); + using var payload = JsonDocument.Parse(await respuesta.Content.ReadAsStringAsync(ct)); + payload.RootElement.EnumerateArray().Select(e => e.GetProperty("episode").GetString()) + .Should().Equal("Opened", "Deactivated", "Reactivated", "Closed"); + } + + [Fact] + public async Task DesactivarYReactivar_SiguenFuncionando_YNoSeConfundenConCerrar() + { + if (!Fixture.IsAvailable) Assert.Skip("Docker es necesario para las pruebas de integración PostgreSQL."); + var ct = TestContext.Current.CancellationToken; + + var tenantId = await ProvisionarInquilinoAsync(ct); + var (branchId, _) = await CrearSucursalAsync(tenantId, "Sucursal Ilo", ct); + + (await Client.PostAsync($"/api/v1/tenants/{tenantId}/branches/{branchId}/deactivate", null, ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + // Desactivada NO es cerrada: sigue en el listado, marcada como inactiva, y vuelve. + await using (var db = CrearContextoDirecto()) + { + var fila = await db.TenantBranches.SingleAsync(b => b.Id == branchId, ct); + fila.IsActive.Should().BeFalse(); + fila.IsClosed.Should().BeFalse("desactivar no es eliminar (ADR-0164 §2.4)"); + fila.ClosedAtUtc.Should().BeNull(); + } + + using (var listado = await LeerSucursalesAsync(tenantId, ct)) + { + var dto = listado.RootElement.EnumerateArray() + .Single(b => b.GetProperty("branchId").GetGuid() == branchId); + dto.GetProperty("isActive").GetBoolean().Should().BeFalse(); + dto.GetProperty("isClosed").GetBoolean().Should().BeFalse(); + } + + (await Client.PostAsync($"/api/v1/tenants/{tenantId}/branches/{branchId}/reactivate", null, ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + await using (var db = CrearContextoDirecto()) + { + (await db.TenantBranches.SingleAsync(b => b.Id == branchId, ct)).IsActive.Should().BeTrue(); + } + } + + [Fact] + public async Task UnaSucursalCerrada_NoSePuedeReactivar() + { + if (!Fixture.IsAvailable) Assert.Skip("Docker es necesario para las pruebas de integración PostgreSQL."); + var ct = TestContext.Current.CancellationToken; + + var tenantId = await ProvisionarInquilinoAsync(ct); + var (branchId, _) = await CrearSucursalAsync(tenantId, "Sucursal Chimbote", ct); + + (await Client.DeleteAsync($"/api/v1/tenants/{tenantId}/branches/{branchId}", ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + // La puerta de atrás cerrada: al estado terminal no se entra ni se sale manipulando el + // estado reversible (ADR-0164 §2.4). + var reactivacion = await Client.PostAsync($"/api/v1/tenants/{tenantId}/branches/{branchId}/reactivate", null, ct); + reactivacion.StatusCode.Should().Be(HttpStatusCode.Conflict, await reactivacion.Content.ReadAsStringAsync(ct)); + + await using var db = CrearContextoDirecto(); + var fila = await db.TenantBranches.SingleAsync(b => b.Id == branchId, ct); + fila.IsClosed.Should().BeTrue(); + fila.IsActive.Should().BeFalse("una reactivación rechazada no puede haber devuelto la sucursal al servicio"); + } + + // ── Utilidades de aprovisionamiento ───────────────────────────────────── + + private async Task ProvisionarInquilinoAsync(CancellationToken ct) + { + // El host PostgreSQL corre con SeedDevData=false: cada prueba levanta su propio inquilino. + var code = $"BRCL{Guid.NewGuid():N}"[..12].ToUpperInvariant(); + var response = await Client.PostAsJsonAsync("/api/v1/tenants", new + { + code, + name = $"Operador logístico {code}", + type = "CLIENT", + isManagementOwner = false, + }, ct); + response.StatusCode.Should().Be(HttpStatusCode.Created, await response.Content.ReadAsStringAsync(ct)); + var tenantId = Guid.Parse(response.Headers.Location!.ToString().Split('/')[^1]); + + // ADR-0077: aprovisionar recursos de un inquilino CLIENT es una operación ON-BEHALF que solo + // ejerce el internal-admin; sin la cabecera, TenantScopePolicy devuelve AUTH_015 → 400. + Client.DefaultRequestHeaders.Remove("X-Tenant-Id"); + Client.DefaultRequestHeaders.Add("X-Tenant-Id", tenantId.ToString()); + Client.DefaultRequestHeaders.Remove("X-Is-Internal-Admin"); + Client.DefaultRequestHeaders.Add("X-Is-Internal-Admin", "true"); + + return tenantId; + } + + private async Task<(Guid BranchId, string Code)> CrearSucursalAsync(Guid tenantId, string nombre, CancellationToken ct) + { + var codigo = $"SUC{Guid.NewGuid():N}"[..10].ToUpperInvariant(); + var response = await Client.PostAsJsonAsync($"/api/v1/tenants/{tenantId}/branches", new + { + code = codigo, + name = nombre, + geofencingMetadata = (string?)null, + }, ct); + response.StatusCode.Should().Be(HttpStatusCode.Created, await response.Content.ReadAsStringAsync(ct)); + + using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); + return (payload.RootElement.GetProperty("branchId").GetGuid(), codigo); + } + + private async Task CrearUsuarioActivoEnSucursalAsync(Guid tenantId, Guid branchId, CancellationToken ct) + { + var response = await Client.PostAsJsonAsync("/api/v1/user-accounts", new + { + tenantId, + branchId, + email = $"operador.{Guid.NewGuid():N}"[..24] + "@beyondnet.local", + category = "Internal", + identityReference = $"EMP-{Guid.NewGuid():N}"[..10], + identityReferenceType = "HrId", + }, ct); + response.StatusCode.Should().Be(HttpStatusCode.Created, await response.Content.ReadAsStringAsync(ct)); + + using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); + var userId = payload.RootElement.GetProperty("userAccountId").GetGuid(); + + (await Client.PostAsync($"/api/v1/user-accounts/{userId}/activate", null, ct)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + return userId; + } + + private async Task LeerSucursalesAsync(Guid tenantId, CancellationToken ct) + { + var response = await Client.GetAsync($"/api/v1/tenants/{tenantId}/branches", ct); + response.StatusCode.Should().Be(HttpStatusCode.OK, await response.Content.ReadAsStringAsync(ct)); + return JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); + } + + /// + /// Contexto EF conectado al MISMO contenedor que el API, con inquilino nulo para que ningún filtro + /// global recorte la vista. Es la ventana al almacenamiento real: lo que el API oculta, aquí se ve. + /// + private UmsPlatformDbContext CrearContextoDirecto() + { + var options = new DbContextOptionsBuilder() + .UseNpgsql(Fixture.ConnectionString) + .Options; + + return new UmsPlatformDbContext( + options, + new ContextoDeInquilinoDelSistema(), + new Moq.Mock().Object, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + } + + private sealed class ContextoDeInquilinoDelSistema : ITenantContext + { + public Guid? OrganizationId => null; + public Guid? OriginalTenantId => null; + public bool IsInternalAdmin => true; + public void Initialize(Guid userTenantId, bool isInternalAdmin) { } + public void SetOrganizationId(Guid organizationId) { } + public void EnableCrossTenantAccess() { } + public void DisableCrossTenantAccess() { } + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/DelegationApprovalGateBehavioralTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/DelegationApprovalGateBehavioralTests.cs new file mode 100644 index 00000000..83b38ffb --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/DelegationApprovalGateBehavioralTests.cs @@ -0,0 +1,106 @@ +using Ums.Infrastructure.Persistence.Seeders; +using Ums.Presentation.IntegrationTest.Infrastructure; + +namespace Ums.Presentation.IntegrationTest.Identity; + +/// +/// G-056 — E2E dedicado de la compuerta de aprobación de UserManagementDelegation. +/// +/// Prueba, extremo a extremo por REST, que una delegación creada con requiresApproval=true +/// NO puede activarse por la vía directa POST /delegations/{id}/activate (fail-closed): la +/// única promoción legítima a Active es SubmitForApproval → Approve. Como control positivo, una +/// delegación sin aprobación requerida sí se activa por esa misma vía. +/// +/// Los dos administradores son usuarios internos BEYONDNET sembrados (Callao, índices 1 y 2). Sus +/// GUID se derivan del GUID del inquilino BEYONDNET igual que en el seeder (byte[0] = índice). +/// +public sealed class DelegationApprovalGateBehavioralTests : IClassFixture +{ + private readonly UmsApiWebApplicationFactory _factory; + + public DelegationApprovalGateBehavioralTests(UmsApiWebApplicationFactory factory) + { + _factory = factory; + } + + private static Guid DeriveBeyondNetUserId(byte index) + { + var bytes = Guid.Parse(CoreDevDataSeeder.BeyondNetTenantId).ToByteArray(); + bytes[0] = index; + return new Guid(bytes); + } + + private HttpClient CreateClientAs(Guid delegatingAdminId) + { + var client = _factory.CreateClient(new WebApplicationFactoryClientOptions + { + BaseAddress = new Uri("https://localhost"), + AllowAutoRedirect = false, + }); + client.DefaultRequestHeaders.Add("X-User-Id", delegatingAdminId.ToString()); + client.DefaultRequestHeaders.Add("X-User-Name", "Delegating Admin"); + client.DefaultRequestHeaders.Add("X-Tenant-Id", CoreDevDataSeeder.BeyondNetTenantId); + return client; + } + + private static object BuildCreateBody(Guid delegatingAdminId, Guid delegatedAdminId, bool requiresApproval) => new + { + tenantId = Guid.Parse(CoreDevDataSeeder.BeyondNetTenantId), + delegatingAdminId, + delegatedAdminId, + scopeType = "Tenant", + scopeId = (Guid?)null, + allowedActions = new[] { "CreateUser" }, + validFrom = DateTimeOffset.UtcNow, + validUntil = DateTimeOffset.UtcNow.AddDays(15), + maxDurationDays = (int?)null, + requiresApproval, + }; + + [Fact] + public async Task Activate_WhenRequiresApproval_IsRejected_FailClosed() + { + var ct = TestContext.Current.CancellationToken; + var delegatingAdminId = DeriveBeyondNetUserId(1); // admin.callao@beyondnet.com.pe + var delegatedAdminId = DeriveBeyondNetUserId(2); // agente.aduanas.callao@beyondnet.com.pe + var client = CreateClientAs(delegatingAdminId); + + var createResponse = await client.PostAsJsonAsync( + "/api/v1/delegations", BuildCreateBody(delegatingAdminId, delegatedAdminId, requiresApproval: true), ct); + + var createBody = await createResponse.Content.ReadAsStringAsync(ct); + createResponse.StatusCode.Should().Be(HttpStatusCode.Created, because: createBody); + + using var created = JsonDocument.Parse(createBody); + var delegationId = created.RootElement.GetProperty("delegationId").GetGuid(); + + var activateResponse = await client.PostAsync($"/api/v1/delegations/{delegationId}/activate", null, ct); + + activateResponse.StatusCode.Should().NotBe(HttpStatusCode.NoContent, + because: "una delegación que exige aprobación no puede activarse por la vía directa (G-056)"); + activateResponse.StatusCode.Should().BeOneOf(HttpStatusCode.BadRequest, HttpStatusCode.Conflict); + } + + [Fact] + public async Task Activate_WhenNoApprovalRequired_Succeeds() + { + var ct = TestContext.Current.CancellationToken; + var delegatingAdminId = DeriveBeyondNetUserId(1); + var delegatedAdminId = DeriveBeyondNetUserId(2); + var client = CreateClientAs(delegatingAdminId); + + var createResponse = await client.PostAsJsonAsync( + "/api/v1/delegations", BuildCreateBody(delegatingAdminId, delegatedAdminId, requiresApproval: false), ct); + + var createBody = await createResponse.Content.ReadAsStringAsync(ct); + createResponse.StatusCode.Should().Be(HttpStatusCode.Created, because: createBody); + + using var created = JsonDocument.Parse(createBody); + var delegationId = created.RootElement.GetProperty("delegationId").GetGuid(); + + var activateResponse = await client.PostAsync($"/api/v1/delegations/{delegationId}/activate", null, ct); + + activateResponse.StatusCode.Should().Be(HttpStatusCode.NoContent, + because: "sin aprobación requerida, la activación directa es la vía legítima"); + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/DependencyGuardIntegrationTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/DependencyGuardIntegrationTests.cs index 89b0de83..3e9032b8 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/DependencyGuardIntegrationTests.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/DependencyGuardIntegrationTests.cs @@ -24,6 +24,9 @@ public DependencyGuardIntegrationTests(UmsApiWebApplicationFactory factory) _client.DefaultRequestHeaders.Add("X-User-Id", "00000000-0000-0000-0000-000000000123"); _client.DefaultRequestHeaders.Add("X-User-Name", "Integration Tester"); _client.DefaultRequestHeaders.Add("X-Tenant-Id", CoreDevDataSeeder.InternalAdminTenantId); + // ADR-0071 / FS-26: INTERNAL_ADMIN ya no es management owner; las escrituras acotadas + // exigen contexto internal-admin explícito o devuelven AUTH_015 → 400. + _client.DefaultRequestHeaders.Add("X-Is-Internal-Admin", "true"); } [Fact] @@ -36,7 +39,10 @@ public async Task SuspendTenant_WithActiveUsers_ShouldReturn409WithBlockingDepen code = tenantCode, name = "Dependency Guard Test Tenant", type = "CLIENT", - isManagementOwner = true, + // G-045/G-037: la propiedad de gestión es única en todo el sistema (BEYONDNET ya la ostenta). + // Un segundo owner devuelve 409. El guard de dependencias se ejerce como internal-admin + // (on-behalf) sobre un tenant cliente ordinario, no marcándolo como owner. + isManagementOwner = false, }, TestContext.Current.CancellationToken); createTenantResponse.StatusCode.Should().Be(HttpStatusCode.Created); @@ -198,7 +204,9 @@ private HttpClient CreateTenantClient(Guid tenantId) tenantClient.DefaultRequestHeaders.Add("X-User-Id", "00000000-0000-0000-0000-000000000456"); tenantClient.DefaultRequestHeaders.Add("X-User-Name", "Tenant Dependency Guard Tester"); tenantClient.DefaultRequestHeaders.Add("X-Tenant-Id", tenantId.ToString()); - tenantClient.DefaultRequestHeaders.Add("X-Is-Internal-Admin", "false"); + // El tenant objetivo es un cliente ordinario (no management owner). Para crear/activar + // usuarios y suspenderlo se actúa como operador de gestión (internal-admin on-behalf). + tenantClient.DefaultRequestHeaders.Add("X-Is-Internal-Admin", "true"); return tenantClient; } diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/RefreshTokenStoreTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/RefreshTokenStoreTests.cs new file mode 100644 index 00000000..851f8dc0 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/RefreshTokenStoreTests.cs @@ -0,0 +1,400 @@ +namespace Ums.Presentation.IntegrationTest.Identity; + +using Microsoft.EntityFrameworkCore; +using Moq; +using Ums.Application.Authorization.Graph; +using Ums.Application.Authorization.Graph.Serializers; +using Ums.Application.Identity.Auth; +using Ums.Application.Identity.Auth.Commands; +using Ums.Domain.Authorization.Graph; +using Ums.Domain.Identity.Auth; +using Ums.Domain.Kernel; +using Ums.Infrastructure.Persistence.Identity.Entities; +using TenantAggregate = Ums.Domain.Identity.Tenant.Tenant; +using UserAccountAggregate = Ums.Domain.Identity.UserAccount.UserAccount; + +/// +/// Cobertura de persistencia (SD-04/SD-05) de la revocación/rotación de refresh tokens +/// (ADR-UMS-091 / FR-015/016, G-050). Los tests de handler de la capa Application mockean el +/// ; aquí se ejerce el store real (RefreshTokenStore +/// sobre EF InMemory, igual patrón que PostgreSqlUserAccountRepositoryTests) para probar +/// que la revocación es efectiva en el almacén y no solo en el mock: +/// +/// · logout () marca Revoked todas las +/// familias vivas del usuario ⇒ un token revocado ya no renueva (round-trip +/// store + real); +/// · la revocación de familia (reuso / max-renewals / principal inactivo) invalida la cadena +/// entera y es idempotente; +/// · la rotación deja el token anterior Rotated (detectable como reuso) y crea el nuevo Active +/// con el contador incrementado; +/// · aislamiento estricto multi-inquilino: revocar (usuario, inquilino) no toca otros +/// usuarios ni otros inquilinos (invariante de ADR-UMS-091). +/// +/// El mecanismo opaco sigue apagado en el piloto (fail-closed); estos tests NO lo encienden a +/// nivel de configuración: ejercen directamente la lógica de store/handler, que debe estar +/// completa y probada aunque el flag esté OFF. +/// +public sealed class RefreshTokenStoreTests +{ + private static readonly Guid TenantGuid = Guid.NewGuid(); + private static readonly Guid UserGuid = Guid.NewGuid(); + + // ── Store: emisión + búsqueda por hash ─────────────────────────────────────── + + [Fact] + public async Task IssueAsync_ThenFindByHash_ReturnsActiveSnapshot() + { + var ct = TestContext.Current.CancellationToken; + await using var ctx = CreateContext(); + var store = new RefreshTokenStore(ctx); + + var plaintext = RefreshTokenGenerator.Generate(); + var hash = RefreshTokenHasher.Hash(plaintext); + var now = DateTime.UtcNow; + + await store.IssueAsync(TenantGuid, UserGuid, Guid.NewGuid(), hash, now, now.AddMinutes(60), ct); + + var found = await store.FindByHashAsync(hash, ct); + + found.Should().NotBeNull(); + found!.Status.Should().Be(RefreshTokenStatuses.Active); + found.TenantId.Should().Be(TenantGuid); + found.UserId.Should().Be(UserGuid); + found.RenewalCount.Should().Be(0); + } + + // ── Store: logout revoca ⇒ el token deja de ser renovable ──────────────────── + + [Fact] + public async Task RevokeAllForUserAsync_MarksLiveTokensRevoked_SoTheyNoLongerRenew() + { + var ct = TestContext.Current.CancellationToken; + await using var ctx = CreateContext(); + var store = new RefreshTokenStore(ctx); + + var hash = RefreshTokenHasher.Hash(RefreshTokenGenerator.Generate()); + var now = DateTime.UtcNow; + await store.IssueAsync(TenantGuid, UserGuid, Guid.NewGuid(), hash, now, now.AddMinutes(60), ct); + + // Logout: no conoce el familyId, revoca por (inquilino, usuario). + await store.RevokeAllForUserAsync(TenantGuid, UserGuid, "logout", now, ct); + + // El token sigue existiendo pero ya no está Active ⇒ el handler lo rechaza como Revoked. + var found = await store.FindByHashAsync(hash, ct); + found.Should().NotBeNull(); + found!.Status.Should().Be(RefreshTokenStatuses.Revoked); + + var record = await ctx.RefreshTokens.SingleAsync(r => r.TokenHash == hash, ct); + record.RevokedReason.Should().Be("logout"); + record.RevokedAtUtc.Should().NotBeNull(); + } + + [Fact] + public async Task RevokeAllForUserAsync_DoesNotCrossUsersOrTenants() + { + var ct = TestContext.Current.CancellationToken; + await using var ctx = CreateContext(); + var store = new RefreshTokenStore(ctx); + var now = DateTime.UtcNow; + + var targetHash = RefreshTokenHasher.Hash("target"); + var otherUserHash = RefreshTokenHasher.Hash("other-user"); + var otherTenantHash = RefreshTokenHasher.Hash("other-tenant"); + var otherUserId = Guid.NewGuid(); + var otherTenantId = Guid.NewGuid(); + + await store.IssueAsync(TenantGuid, UserGuid, Guid.NewGuid(), targetHash, now, now.AddMinutes(60), ct); + await store.IssueAsync(TenantGuid, otherUserId, Guid.NewGuid(), otherUserHash, now, now.AddMinutes(60), ct); + await store.IssueAsync(otherTenantId, UserGuid, Guid.NewGuid(), otherTenantHash, now, now.AddMinutes(60), ct); + + await store.RevokeAllForUserAsync(TenantGuid, UserGuid, "logout", now, ct); + + // Solo el token del (inquilino, usuario) objetivo se revoca. + (await store.FindByHashAsync(targetHash, ct))!.Status.Should().Be(RefreshTokenStatuses.Revoked); + // El mismo inquilino, otro usuario ⇒ intacto. + (await store.FindByHashAsync(otherUserHash, ct))!.Status.Should().Be(RefreshTokenStatuses.Active); + // El mismo usuario, otro inquilino ⇒ intacto (aislamiento estricto de ADR-UMS-091). + (await store.FindByHashAsync(otherTenantHash, ct))!.Status.Should().Be(RefreshTokenStatuses.Active); + } + + // ── Store: revocación de familia (reuso / max-renewals / principal inactivo) ── + + [Fact] + public async Task RevokeFamilyAsync_RevokesWholeFamily_AndIsIdempotent() + { + var ct = TestContext.Current.CancellationToken; + await using var ctx = CreateContext(); + var store = new RefreshTokenStore(ctx); + var now = DateTime.UtcNow; + var familyId = Guid.NewGuid(); + + var firstHash = RefreshTokenHasher.Hash("family-1"); + var secondHash = RefreshTokenHasher.Hash("family-2"); + await store.IssueAsync(TenantGuid, UserGuid, familyId, firstHash, now, now.AddMinutes(60), ct); + await store.IssueAsync(TenantGuid, UserGuid, familyId, secondHash, now, now.AddMinutes(60), ct); + + await store.RevokeFamilyAsync(familyId, "reuse_detected", now, ct); + + (await store.FindByHashAsync(firstHash, ct))!.Status.Should().Be(RefreshTokenStatuses.Revoked); + (await store.FindByHashAsync(secondHash, ct))!.Status.Should().Be(RefreshTokenStatuses.Revoked); + + // Idempotente: repetir no arroja ni cambia nada. + await store.RevokeFamilyAsync(familyId, "reuse_detected", now.AddSeconds(1), ct); + var revokedCount = await ctx.RefreshTokens.CountAsync(r => r.FamilyId == familyId && r.Status == RefreshTokenStatuses.Revoked, ct); + revokedCount.Should().Be(2); + } + + // ── Store: rotación ⇒ el anterior queda reutilizable-detectable, el nuevo Active ─ + + [Fact] + public async Task RotateAsync_MarksOldRotated_AndAddsActiveWithIncrementedRenewalCount() + { + var ct = TestContext.Current.CancellationToken; + await using var ctx = CreateContext(); + var store = new RefreshTokenStore(ctx); + var now = DateTime.UtcNow; + var familyId = Guid.NewGuid(); + + var oldHash = RefreshTokenHasher.Hash("old"); + await store.IssueAsync(TenantGuid, UserGuid, familyId, oldHash, now, now.AddMinutes(60), ct); + var current = await store.FindByHashAsync(oldHash, ct); + + var newTokenId = Guid.NewGuid(); + var newHash = RefreshTokenHasher.Hash("new"); + await store.RotateAsync(current!, newTokenId, newHash, now, now.AddMinutes(60), ct); + + // El anterior queda Rotated (presentarlo de nuevo será reuso), apuntando al nuevo. + var oldSnapshot = await store.FindByHashAsync(oldHash, ct); + oldSnapshot!.Status.Should().Be(RefreshTokenStatuses.Rotated); + var oldRecord = await ctx.RefreshTokens.SingleAsync(r => r.TokenHash == oldHash, ct); + oldRecord.ReplacedByTokenId.Should().Be(newTokenId); + + // El nuevo entra Active en la misma familia con el contador incrementado. + var newSnapshot = await store.FindByHashAsync(newHash, ct); + newSnapshot!.Status.Should().Be(RefreshTokenStatuses.Active); + newSnapshot.FamilyId.Should().Be(familyId); + newSnapshot.RenewalCount.Should().Be(1); + } + + // ── Round-trip store + handler real: un token ACTIVO renueva ───────────────── + + [Fact] + public async Task ActiveToken_RenewsThroughRealStore_AndRotationIsPersisted() + { + var ct = TestContext.Current.CancellationToken; + await using var ctx = CreateContext(); + var store = new RefreshTokenStore(ctx); + + var plaintext = RefreshTokenGenerator.Generate(); + var oldHash = RefreshTokenHasher.Hash(plaintext); + var now = DateTime.UtcNow; + await store.IssueAsync(TenantGuid, UserGuid, Guid.NewGuid(), oldHash, now, now.AddMinutes(60), ct); + + var handler = CreateHandler(store, out var audit); + + var result = await handler.Handle(new RefreshAuthenticationCommand(plaintext, "10.0.0.1"), ct); + + result.IsSuccess.Should().BeTrue(); + result.Value.NewRefreshToken.Should().NotBeNullOrWhiteSpace(); + // La rotación se persistió: el token presentado quedó Rotated en el store real. + (await store.FindByHashAsync(oldHash, ct))!.Status.Should().Be(RefreshTokenStatuses.Rotated); + audit.Verify(a => a.RecordAuthEventAsync( + It.Is(e => e.EventType == "Auth.Refresh.Success" && e.Succeeded), + It.IsAny()), Times.Once); + } + + // ── Round-trip store + handler real: tras logout, el MISMO token ya no renueva ─ + + [Fact] + public async Task RevokedTokenNoLongerRenews_AfterLogout_EndToEnd() + { + var ct = TestContext.Current.CancellationToken; + await using var ctx = CreateContext(); + var store = new RefreshTokenStore(ctx); + + var plaintext = RefreshTokenGenerator.Generate(); + var hash = RefreshTokenHasher.Hash(plaintext); + var now = DateTime.UtcNow; + await store.IssueAsync(TenantGuid, UserGuid, Guid.NewGuid(), hash, now, now.AddMinutes(60), ct); + + // Logout real (ADR-UMS-091/FR-016): revoca todas las familias vivas del usuario. + await store.RevokeAllForUserAsync(TenantGuid, UserGuid, "logout", now, ct); + + var handler = CreateHandler(store, out var audit); + + var result = await handler.Handle(new RefreshAuthenticationCommand(plaintext, "10.0.0.1"), ct); + + // Revocación efectiva: el token revocado ya NO renueva, y el fallo se audita. + result.IsFailure.Should().BeTrue(); + result.Error.Should().Contain(RefreshErrorCodes.Revoked); + audit.Verify(a => a.RecordAuthEventAsync( + It.Is(e => + e.EventType == "Auth.Refresh.Failure" && !e.Succeeded && + e.FailureReason != null && e.FailureReason.Contains(RefreshErrorCodes.Revoked)), + It.IsAny()), Times.Once); + } + + // ── Round-trip store + handler real: el reuso de un token rotado revoca la familia ─ + + [Fact] + public async Task ReuseOfRotatedToken_RevokesFamilyInRealStore_AndAudits() + { + var ct = TestContext.Current.CancellationToken; + await using var ctx = CreateContext(); + var store = new RefreshTokenStore(ctx); + + var plaintext = RefreshTokenGenerator.Generate(); + var hash = RefreshTokenHasher.Hash(plaintext); + var now = DateTime.UtcNow; + var familyId = Guid.NewGuid(); + await store.IssueAsync(TenantGuid, UserGuid, familyId, hash, now, now.AddMinutes(60), ct); + + var handler = CreateHandler(store, out var audit); + + // Primera renovación: rota ⇒ el token presentado queda Rotated y nace uno nuevo Active. + var first = await handler.Handle(new RefreshAuthenticationCommand(plaintext, "10.0.0.1"), ct); + first.IsSuccess.Should().BeTrue(); + + // Segunda presentación del MISMO (ya rotado) ⇒ reuso ⇒ familia revocada en el store real. + var reuse = await handler.Handle(new RefreshAuthenticationCommand(plaintext, "10.0.0.1"), ct); + + reuse.IsFailure.Should().BeTrue(); + reuse.Error.Should().Contain(RefreshErrorCodes.ReuseDetected); + // Toda la familia (incluido el token nuevo Active) queda Revoked: la sesión se corta. + var live = await ctx.RefreshTokens.CountAsync( + r => r.FamilyId == familyId && r.Status != RefreshTokenStatuses.Revoked, ct); + live.Should().Be(0); + audit.Verify(a => a.RecordAuthEventAsync( + It.Is(e => + e.EventType == "Auth.Refresh.Failure" && !e.Succeeded && + e.FailureReason != null && e.FailureReason.Contains(RefreshErrorCodes.ReuseDetected)), + It.IsAny()), Times.Once); + } + + // ── Infra de test ──────────────────────────────────────────────────────────── + + private static UmsPlatformDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + // Contexto de sistema (OrganizationId null): RefreshTokenRecord no lleva filtro global + // de inquilino — el aislamiento lo impone el store por predicado explícito, y estos + // tests lo verifican. + return new UmsPlatformDbContext( + options, + new SystemTenantContext(), + new Mock().Object, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + } + + /// + /// Handler real cableado con el real; el resto de colaboradores + /// se mockean con un principal (inquilino/usuario) activo y una política habilitada, para + /// aislar el efecto de la revocación en el almacén. + /// + private static RefreshAuthenticationCommandHandler CreateHandler( + IRefreshTokenStore store, out Mock audit) + { + var policyProvider = new Mock(); + policyProvider.Setup(p => p.Resolve(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(new RefreshTokenPolicy(Enabled: true, LifetimeMinutes: 60, Rotate: true, DetectReuse: true, MaxRenewals: 0)); + + var tenantRepo = new Mock(); + tenantRepo.Setup(r => r.GetByIdAsync(TenantGuid, It.IsAny())) + .ReturnsAsync(BuildActiveTenant()); + + var userRepo = new Mock(); + userRepo.Setup(r => r.GetByIdAsync(UserGuid, It.IsAny())) + .ReturnsAsync(BuildActiveUser()); + + var methodResolver = new Mock(); + methodResolver.Setup(m => m.ResolveAsync(TenantGuid, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success(AuthMethod.Local())); + + var graphBuilder = new Mock(); + graphBuilder.Setup(g => g.BuildAsync(It.IsAny(), TenantGuid, It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success(BuildGraph())); + + var formatProvider = new Mock(); + formatProvider.Setup(f => f.GetDefaultFormatAsync(TenantGuid, It.IsAny())) + .ReturnsAsync("JSON"); + + var serializer = new Mock(); + serializer.Setup(s => s.Serialize(It.IsAny(), It.IsAny())) + .Returns("{}"); + + audit = new Mock(); + + return new RefreshAuthenticationCommandHandler( + store, policyProvider.Object, tenantRepo.Object, userRepo.Object, + methodResolver.Object, graphBuilder.Object, formatProvider.Object, + serializer.Object, audit.Object); + } + + private static TenantAggregate BuildActiveTenant() + { + var tenant = TenantAggregate.Create( + Code.Create("TEST"), + Name.Create("Test Tenant"), + OrganizationType.INTERNAL, + ActorId.Create("test"), + IdpStrategy.InternalBcrypt, + tenantId: TenantId.Load(TenantGuid)).Value; // Create ⇒ Status Active + tenant.DomainEvents.MarkChangesAsCommitted(); + return tenant; + } + + private static UserAccountAggregate BuildActiveUser() + { + var user = UserAccountAggregate.Create( + TenantId.Load(TenantGuid), + Email.Create("user@test.com"), + UserCategory.Internal, + null, null, + ActorId.Create("test"), + null, + UserAccountId.Load(UserGuid)).Value; + user.Activate(ActorId.Create("test")); + user.DomainEvents.MarkChangesAsCommitted(); + return user; + } + + private static AuthorizationGraph BuildGraph() + { + var context = new GraphContext( + new GraphUser(UserGuid, "user@test.com", "user", "User", "Active"), + new GraphTenant(TenantGuid, "TEST", "Test Tenant", "Active", false), + SystemSuite: null, Role: null, Profile: null, Branch: null); + + var authentication = new GraphAuthentication( + "Local", Provider: null, MfaRequired: false, + IssuedAt: DateTime.UtcNow, SessionExpiresAt: DateTime.UtcNow.AddMinutes(30)); + + var effectiveConfig = new GraphEffectiveConfig( + SessionTimeoutMinutes: 30, MaxLoginAttempts: 5, MinPasswordLength: 8, + MfaRequiredForAdmin: false, MfaAllowedMethods: Array.Empty(), + AccessTokenDurationMs: 900_000, AuthUseExternalIdp: false); + + return AuthorizationGraph.Build( + context, authentication, + Array.Empty(), + Array.Empty(), + Array.Empty(), + Array.Empty(), + effectiveConfig, + Array.Empty(), + DateTime.UtcNow); + } + + private sealed class SystemTenantContext : ITenantContext + { + public Guid? OrganizationId => null; + public Guid? OriginalTenantId => null; + public bool IsInternalAdmin => true; + public void Initialize(Guid userTenantId, bool isInternalAdmin) { } + public void SetOrganizationId(Guid organizationId) { } + public void EnableCrossTenantAccess() { } + public void DisableCrossTenantAccess() { } + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/TenantParameterSoftDeleteTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/TenantParameterSoftDeleteTests.cs new file mode 100644 index 00000000..d858a19f --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/TenantParameterSoftDeleteTests.cs @@ -0,0 +1,231 @@ +using Microsoft.EntityFrameworkCore; +using Ums.Domain.Identity.Tenant.TenantParameter; +using Ums.Domain.Kernel; +using Ums.Infrastructure.Persistence.Identity.TenantParameter; +using Ums.Presentation.IntegrationTest.Infrastructure; +using TenantParameterAggregate = Ums.Domain.Identity.Tenant.TenantParameter.TenantParameter; + +namespace Ums.Presentation.IntegrationTest.Identity; + +/// +/// Política del propietario: SOLO existe borrado lógico. El repositorio de parámetros de inquilino +/// hacía dbContext.TenantParameters.Remove(...) y la configuración histórica del inquilino +/// —qué valor regía, quién lo puso y cuándo— se perdía sin remedio. +/// +/// El agregado NO tiene endpoint DELETE (nadie llamaba a DeleteAsync: el borrado físico era +/// código muerto alcanzable solo desde infraestructura), así que la evidencia se toma en el nivel +/// donde el borrado ocurre de verdad —repositorio sobre PostgreSQL real— en vez de simularla por HTTP: +/// +/// a. DeleteAsync responde igual que antes (sin excepción, sin valor de retorno nuevo). +/// b. La FILA SIGUE EN LA BASE, con todos sus datos y con IsDeleted = true. +/// c. Las lecturas la ocultan: el repositorio devuelve null / listas sin ella. +/// d. Con el vínculo VIVO (parámetro activo) el dominio rechaza con el código de operación +/// bloqueada que la presentación traduce a 409. +/// e. Con ese vínculo ya eliminado lógicamente (parámetro desactivado), el borrado sí procede. +/// +[Collection("PostgreSql")] +public sealed class TenantParameterSoftDeleteTests +{ + private readonly PostgreSqlContainerFixture _fixture; + private static readonly ActorId Actor = ActorId.Create("integration-test"); + + public TenantParameterSoftDeleteTests(PostgreSqlContainerFixture fixture) => _fixture = fixture; + + [Fact] + public async Task Delete_ConVinculoActivo_EsRechazadoPorElDominio_YNoTocaLaBase() + { + if (!_fixture.IsAvailable) Assert.Skip("Docker es necesario para las pruebas de integración PostgreSQL."); + var ct = TestContext.Current.CancellationToken; + + var tenantId = Guid.NewGuid(); + var parametro = NuevoParametro(tenantId, "AUTH_GRAPH_DEFAULT_FORMAT", "JSON"); + + await using (var db = CrearContexto()) + { + var repo = new PostgreSqlTenantParameterRepository(db); + await repo.AddAsync(parametro, ct); + await repo.UnitOfWork.SaveEntitiesAsync(ct); + } + + // (d) Referencia viva: el parámetro está ACTIVO, o sea que la configuración del inquilino lo + // resuelve ahora mismo por su código. El borrado se rechaza con el código de bloqueo. + var resultado = parametro.Delete(Actor); + + resultado.IsFailure.Should().BeTrue(); + resultado.Error.Should().Contain(DomainErrors.TenantParameter.HasActiveBinding); + parametro.IsDeleted.Should().BeFalse(); + + await using var verificacion = CrearContexto(); + var fila = await verificacion.TenantParameters + .IgnoreQueryFilters() + .SingleAsync(x => x.Id == parametro.GetId().GetValue(), ct); + fila.IsDeleted.Should().BeFalse("un borrado rechazado no puede haber escrito nada"); + } + + [Fact] + public async Task Delete_TrasDesactivar_DejaLaFilaEnLaBase_YLaOcultaDeLasLecturas() + { + if (!_fixture.IsAvailable) Assert.Skip("Docker es necesario para las pruebas de integración PostgreSQL."); + var ct = TestContext.Current.CancellationToken; + + var tenantId = Guid.NewGuid(); + var parametro = NuevoParametro(tenantId, "EXPORT_PROFILE_PERMISSION_GRAPH_DEFAULT_FORMAT", "XML"); + var parametroId = parametro.GetId().GetValue(); + + await using (var db = CrearContexto()) + { + var repo = new PostgreSqlTenantParameterRepository(db); + await repo.AddAsync(parametro, ct); + await repo.UnitOfWork.SaveEntitiesAsync(ct); + } + + // (e) Se desactiva primero —esa desactivación ES la eliminación lógica del vínculo— y entonces + // el borrado procede. + parametro.Deactivate(Actor).IsSuccess.Should().BeTrue(); + parametro.Delete(Actor).IsSuccess.Should().BeTrue(); + + await using (var db = CrearContexto()) + { + var repo = new PostgreSqlTenantParameterRepository(db); + // (a) Misma firma y mismo comportamiento observable que antes: no lanza y no devuelve nada. + await repo.DeleteAsync(parametro, ct); + await repo.UnitOfWork.SaveEntitiesAsync(ct); + } + + // (b) LA FILA SIGUE EN LA BASE, con su valor y su auditoría intactos. Es la prueba que fija la + // política: hay que saltarse el filtro global para verla, pero está. + await using var verificacion = CrearContexto(); + var fila = await verificacion.TenantParameters + .IgnoreQueryFilters() + .SingleOrDefaultAsync(x => x.Id == parametroId, ct); + + fila.Should().NotBeNull("el borrado es LÓGICO: la fila no puede desaparecer de la tabla"); + fila!.IsDeleted.Should().BeTrue(); + fila.Value.Should().Be("XML", "el histórico conserva el valor que regía cuando se eliminó"); + fila.Code.Should().Be("EXPORT_PROFILE_PERMISSION_GRAPH_DEFAULT_FORMAT"); + + // (c) Las lecturas la ocultan, tanto por el filtro global como por el repositorio. + (await verificacion.TenantParameters.SingleOrDefaultAsync(x => x.Id == parametroId, ct)) + .Should().BeNull("el filtro global de borrado lógico saca la fila de toda consulta ordinaria"); + + var lectura = new PostgreSqlTenantParameterRepository(verificacion); + (await lectura.GetByIdAsync(parametroId, ct)).Should().BeNull(); + (await lectura.GetByCodeAsync(tenantId, "EXPORT_PROFILE_PERMISSION_GRAPH_DEFAULT_FORMAT", ct)).Should().BeNull(); + (await lectura.GetByTenantIdAsync(tenantId, ct)).Should().BeEmpty(); + (await lectura.ExistsActiveCodeAsync(tenantId, "EXPORT_PROFILE_PERMISSION_GRAPH_DEFAULT_FORMAT", ct)) + .Should().BeFalse(); + } + + [Fact] + public async Task DeleteAsync_SinPasarPorElDominio_SeCorta() + { + if (!_fixture.IsAvailable) Assert.Skip("Docker es necesario para las pruebas de integración PostgreSQL."); + var ct = TestContext.Current.CancellationToken; + + // La DECISIÓN de eliminar es del dominio, que es donde vive la guardia de cascada. Si alguien + // llama al repositorio saltándosela, se corta en vez de escribir un borrado que nadie validó: + // así el borrado físico no puede reaparecer por la puerta de atrás. + var parametro = NuevoParametro(Guid.NewGuid(), "SESSION_IDLE_TIMEOUT_MINUTES", "30"); + + await using var db = CrearContexto(); + var repo = new PostgreSqlTenantParameterRepository(db); + await repo.AddAsync(parametro, ct); + await repo.UnitOfWork.SaveEntitiesAsync(ct); + + var act = async () => await repo.DeleteAsync(parametro, ct); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Delete_PermiteVolverAAltaElMismoCodigo() + { + if (!_fixture.IsAvailable) Assert.Skip("Docker es necesario para las pruebas de integración PostgreSQL."); + var ct = TestContext.Current.CancellationToken; + + // El índice único parcial IX_TenantParameters_TenantId_Code_IsActive solo cubre filas ACTIVAS. + // Como el borrado lógico exige desactivación previa, la fila eliminada queda fuera del índice y + // el inquilino puede volver a dar de alta ese código sin chocar con 23505. + var tenantId = Guid.NewGuid(); + const string codigo = "AUTH_GRAPH_ALLOWED_FORMATS"; + + var original = NuevoParametro(tenantId, codigo, "JSON,XML"); + await using (var db = CrearContexto()) + { + var repo = new PostgreSqlTenantParameterRepository(db); + await repo.AddAsync(original, ct); + await repo.UnitOfWork.SaveEntitiesAsync(ct); + } + + original.Deactivate(Actor); + original.Delete(Actor); + await using (var db = CrearContexto()) + { + var repo = new PostgreSqlTenantParameterRepository(db); + await repo.DeleteAsync(original, ct); + await repo.UnitOfWork.SaveEntitiesAsync(ct); + } + + var reemplazo = NuevoParametro(tenantId, codigo, "JSON,XML,YAML"); + await using (var db = CrearContexto()) + { + var repo = new PostgreSqlTenantParameterRepository(db); + await repo.AddAsync(reemplazo, ct); + await repo.UnitOfWork.SaveEntitiesAsync(ct); + } + + await using var verificacion = CrearContexto(); + var filas = await verificacion.TenantParameters + .IgnoreQueryFilters() + .Where(x => x.TenantId == tenantId && x.Code == codigo) + .ToListAsync(ct); + + filas.Should().HaveCount(2, "la eliminada permanece como histórico junto a la nueva"); + filas.Count(x => x.IsDeleted).Should().Be(1); + filas.Single(x => !x.IsDeleted).Value.Should().Be("JSON,XML,YAML"); + } + + // ── Utilidades ────────────────────────────────────────────────────────── + + private static TenantParameterAggregate NuevoParametro(Guid tenantId, string codigo, string valor) + => TenantParameterAggregate.Create( + TenantId.Load(tenantId), + codigo, + "Parámetro de inquilino para la prueba de borrado lógico.", + valor, + TenantParameterValueType.String, + TenantParameterCategory.Export, + isSensitive: false, + defaultValue: null, + allowedValues: null, + Actor).Value; + + /// + /// Contexto EF contra el contenedor real, con inquilino nulo: el filtro de aislamiento no recorta y + /// el ÚNICO predicado que queda sobre TenantParameters es el de borrado lógico, que es lo que se + /// quiere observar. + /// + private UmsPlatformDbContext CrearContexto() + { + var options = new DbContextOptionsBuilder() + .UseNpgsql(_fixture.ConnectionString) + .Options; + + return new UmsPlatformDbContext( + options, + new ContextoDeInquilinoDelSistema(), + new Moq.Mock().Object, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + } + + private sealed class ContextoDeInquilinoDelSistema : ITenantContext + { + public Guid? OrganizationId => null; + public Guid? OriginalTenantId => null; + public bool IsInternalAdmin => true; + public void Initialize(Guid userTenantId, bool isInternalAdmin) { } + public void SetOrganizationId(Guid organizationId) { } + public void EnableCrossTenantAccess() { } + public void DisableCrossTenantAccess() { } + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/UserAccountRestEndpointTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/UserAccountRestEndpointTests.cs index 9995e5f6..69451cde 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/UserAccountRestEndpointTests.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/UserAccountRestEndpointTests.cs @@ -17,6 +17,11 @@ public UserAccountRestEndpointTests(UmsApiWebApplicationFactory factory) _client.DefaultRequestHeaders.Add("X-User-Id", "00000000-0000-0000-0000-000000000123"); _client.DefaultRequestHeaders.Add("X-User-Name", "Integration Tester"); _client.DefaultRequestHeaders.Add("X-Tenant-Id", CoreDevDataSeeder.InternalAdminTenantId); + // ADR-0071 / FS-26: tras mover la propiedad de gestión al Tenant Raíz (BEYONDNET), el tenant + // sintético INTERNAL_ADMIN ya NO es management owner (isManagementOwner:false). Las escrituras + // acotadas por EnsureManagementOwnerScopeAsync exigen contexto internal-admin explícito; sin él + // el comando devuelve AUTH_015 → 400. Se declara el rol de operador de gestión de forma explícita. + _client.DefaultRequestHeaders.Add("X-Is-Internal-Admin", "true"); } [Fact] diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Iga/RolePromotionEffectE2ETests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Iga/RolePromotionEffectE2ETests.cs new file mode 100644 index 00000000..86abb017 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Iga/RolePromotionEffectE2ETests.cs @@ -0,0 +1,218 @@ +using Microsoft.AspNetCore.Mvc.Testing; +using Ums.Application.IGA.DTOs; +using Ums.Domain.IGA; +using Ums.Presentation.IntegrationTest.Infrastructure; +using ProfileAggregate = Ums.Domain.Authorization.Profile.Profile; +using RoleAggregate = Ums.Domain.Authorization.Role.Role; +using RoleMaturityStatusAggregate = Ums.Domain.IGA.RoleMaturityStatus.RoleMaturityStatus; +using SystemSuiteAggregate = Ums.Domain.Authorization.SystemSuite.SystemSuite; + +namespace Ums.Presentation.IntegrationTest.Iga; + +/// +/// ADR-UMS-096 (Brecha 2 / G-093) endurecido por G-094: E2E del EFECTO real de la promoción de rol IGA +/// sobre un contenedor PostgreSQL real (Testcontainers). El efecto ya NO se aplica por un manejador +/// in-process best-effort, sino por el Transactional Outbox de MassTransit: +/// ExecuteRolePromotionCommandHandler publica +/// dentro de la +/// transacción que confirma Execute, y RolePromotionRoleAssignmentConsumer reasigna el +/// rol del Profile del usuario objetivo en su propia transacción (D-016). +/// +/// En este host el bus es en memoria (sin outbox EF): el publish entrega el mensaje al consumidor en +/// un task de fondo, de modo que la reasignación es ASÍNCRONA respecto a la respuesta HTTP de +/// execute. Por eso la aserción del efecto relee el perfil con reintentos deterministas (no +/// sleeps arbitrarios) hasta observar el cambio. +/// +/// El test conduce el happy-path completo (Create → Submit → ConfirmEligibility → ManagerApprove → +/// [SecurityApprove] → Execute) con actores DISTINTOS por transición (segregación de funciones, +/// INV-RPR3 endurecida por ADR-UMS-096) y, tras Execute, asevera que Profile.RoleId del usuario +/// objetivo pasó del rol origen al rol destino. +/// +[Collection("PostgreSql")] +public sealed class RolePromotionEffectE2ETests +{ + private readonly PostgreSqlContainerFixture _fixture; + private readonly PostgreSqlWebApplicationFactory? _factory; + + private static readonly Guid SeedTenantId = Guid.NewGuid(); + private static readonly Guid SeedTargetUserId = Guid.NewGuid(); + + // Actores distintos entre sí y del objetivo, para respetar la SoD a lo largo del flujo. + private static readonly Guid RequesterActor = Guid.NewGuid(); + private static readonly Guid ApproverActor = Guid.NewGuid(); + private static readonly Guid SecurityActor = Guid.NewGuid(); + private static readonly Guid ExecutorActor = Guid.NewGuid(); + + public RolePromotionEffectE2ETests(PostgreSqlContainerFixture fixture) + { + _fixture = fixture; + if (fixture.IsAvailable) + { + _factory = new PostgreSqlWebApplicationFactory(fixture.ConnectionString); + } + } + + [Fact] + public async Task FullPromotionFlow_UntilExecute_ReassignsTargetProfileRole() + { + if (!_fixture.IsAvailable || _factory is null) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + + // Fuerza la construcción del host (que resetea la BD y siembra la línea base) ANTES de sembrar + // nuestros propios agregados, para que el TRUNCATE de arranque no borre lo que sembramos. + using var warmup = _factory.CreateClient(); + + var (currentRoleId, targetRoleId, profileId) = await SeedRolesProfileAndEligibilityAsync(ct); + + var requester = CreateActorClient(RequesterActor); + var approver = CreateActorClient(ApproverActor); + var security = CreateActorClient(SecurityActor); + var executor = CreateActorClient(ExecutorActor); + + // 1. Create (Draft) — el solicitante no es el objetivo. + var createResponse = await requester.PostAsJsonAsync( + "/api/v1/role-promotion-requests", + new + { + TenantId = SeedTenantId, + TargetUserId = SeedTargetUserId, + CurrentRoleId = currentRoleId, + TargetRoleId = targetRoleId, + }, + ct); + createResponse.StatusCode.Should().Be(HttpStatusCode.Created); + var created = await createResponse.Content.ReadFromJsonAsync(ct); + created.Should().NotBeNull(); + var baseUrl = $"/api/v1/role-promotion-requests/{created!.RolePromotionRequestId}"; + + // 2. Submit → PendingEligibilityCheck (congela el RiskScore; el cálculo lee los roles sembrados). + (await requester.PostAsync($"{baseUrl}/submit", null, ct)).StatusCode.Should().Be(HttpStatusCode.NoContent); + + // 3. ConfirmEligibility → PendingManagerApproval (elegible por el RoleMaturityStatus sembrado). + (await requester.PostAsync($"{baseUrl}/confirm-eligibility", null, ct)).StatusCode.Should().Be(HttpStatusCode.NoContent); + (await GetAsync(requester, baseUrl, ct)).Status.Should().Be("PendingManagerApproval"); + + // 4. ManagerApprove → Approved o PendingSecurityReview según el RiskScore. + (await approver.PostAsync($"{baseUrl}/manager-approve", null, ct)).StatusCode.Should().Be(HttpStatusCode.NoContent); + var afterManager = await GetAsync(approver, baseUrl, ct); + afterManager.Status.Should().BeOneOf("Approved", "PendingSecurityReview"); + + // 4b. Si escaló a revisión de seguridad, un revisor distinto la aprueba. + if (afterManager.Status == "PendingSecurityReview") + { + (await security.PostAsync($"{baseUrl}/security-approve", null, ct)).StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + (await GetAsync(security, baseUrl, ct)).Status.Should().Be("Approved"); + + // 5. Execute → Executed. El efecto (G-094) se publica al outbox y lo aplica el consumidor. + (await executor.PostAsync($"{baseUrl}/execute", null, ct)).StatusCode.Should().Be(HttpStatusCode.NoContent); + (await GetAsync(executor, baseUrl, ct)).Status.Should().Be("Executed"); + + // 6. Aserción del EFECTO: el Profile del usuario objetivo quedó reasignado al rol destino. + // La reasignación llega por el bus (RolePromotionRoleAssignmentConsumer) de forma asíncrona, + // así que se relee con reintentos deterministas —creando un scope/DbContext nuevo por intento + // para no leer del mapa de identidad— hasta observar el rol destino. No hay sleeps ciegos: + // la espera termina en cuanto el efecto es visible o se agotan los intentos. + ProfileAggregate? profile = null; + for (var attempt = 0; attempt < 50; attempt++) + { + using var scope = _factory.Services.CreateScope(); + var profileRepository = scope.ServiceProvider.GetRequiredService(); + profile = await profileRepository.GetByIdAsync(profileId, ct); + + if (profile is not null && profile.RoleId.GetValue() == targetRoleId) + { + break; + } + + await Task.Delay(100, ct); + } + + profile.Should().NotBeNull(); + profile!.RoleId.GetValue().Should().Be(targetRoleId, "la promoción ejecutada debe reasignar el rol del perfil objetivo (ADR-UMS-096 / G-094, vía outbox + consumidor)"); + profile.IsActive.Should().BeTrue(); + } + + /// + /// Siembra, sobre el contenedor real: un SystemSuite y dos Role raíz (origen/destino) + /// —que el cálculo de RiskScore necesita resolver por id—; un RoleMaturityStatus ELEGIBLE + /// para (inquilino, objetivo, rol origen) —Junior, ingreso > 2 años, desempeño 4.5, sin + /// incidencias— para que la confirmación de elegibilidad avance; y un Profile ACTIVO del + /// usuario objetivo con el rol origen, que es el que la promoción debe reasignar. Devuelve los ids + /// de rol origen/destino y del perfil. + /// + private async Task<(Guid CurrentRoleId, Guid TargetRoleId, Guid ProfileId)> SeedRolesProfileAndEligibilityAsync(CancellationToken ct) + { + using var scope = _factory!.Services.CreateScope(); + var actor = ActorId.Create(RequesterActor.ToString()); + var tenantId = TenantId.Load(SeedTenantId); + var targetUser = UserId.Load(SeedTargetUserId); + var suffix = Guid.NewGuid().ToString("N")[..8].ToUpperInvariant(); + + // SystemSuite (contenedor de los roles; el RoleRecord tiene FK a SystemSuiteRecord). + var suiteRepository = scope.ServiceProvider.GetRequiredService(); + var suite = SystemSuiteAggregate.Create( + tenantId, + Code.Create($"SS{suffix}"), + Name.Create($"IGA Effect Suite {suffix}"), + Description.Create("Suite sembrada por RolePromotionEffectE2ETests (ADR-UMS-096)."), + actor).Value; + await suiteRepository.AddAsync(suite, ct); + await suiteRepository.UnitOfWork.SaveEntitiesAsync(ct); + + // Dos roles raíz: origen (orden 0) y destino (orden 1). RiskScore resultante < umbral ⇒ Approved. + var roleRepository = scope.ServiceProvider.GetRequiredService(); + var currentRole = RoleAggregate.Create( + tenantId, suite.GetId(), Code.Create($"RLCUR{suffix}"), Name.Create($"Current {suffix}"), + Description.Create("Rol origen (ADR-UMS-096)."), parentRoleId: null, hierarchyLevel: 0, promotionOrder: 0, actor).Value; + var targetRole = RoleAggregate.Create( + tenantId, suite.GetId(), Code.Create($"RLTGT{suffix}"), Name.Create($"Target {suffix}"), + Description.Create("Rol destino (ADR-UMS-096)."), parentRoleId: null, hierarchyLevel: 0, promotionOrder: 1, actor).Value; + await roleRepository.AddAsync(currentRole, ct); + await roleRepository.AddAsync(targetRole, ct); + await roleRepository.UnitOfWork.SaveEntitiesAsync(ct); + + var currentRoleId = currentRole.GetId(); + + // RoleMaturityStatus ELEGIBLE del objetivo en el rol origen. + var maturityRepository = scope.ServiceProvider.GetRequiredService(); + var eligible = RoleMaturityStatusAggregate.Create( + tenantId, targetUser, currentRoleId, RoleMaturityLevel.Junior, DateTime.UtcNow.AddYears(-2), actor).Value; + eligible.UpdatePerformanceScore(4.5m, actor); + await maturityRepository.AddAsync(eligible, ct); + await maturityRepository.UnitOfWork.SaveEntitiesAsync(ct); + + // Profile ACTIVO del objetivo con el rol origen: el sujeto del efecto. + var profileRepository = scope.ServiceProvider.GetRequiredService(); + var profile = ProfileAggregate.Create(tenantId, targetUser, currentRoleId, branchId: null, actor).Value; + await profileRepository.AddAsync(profile, ct); + await profileRepository.UnitOfWork.SaveEntitiesAsync(ct); + + return (currentRole.GetId().GetValue(), targetRole.GetId().GetValue(), profile.GetId().GetValue()); + } + + private HttpClient CreateActorClient(Guid actorId) + { + var client = _factory!.CreateClient(new WebApplicationFactoryClientOptions + { + BaseAddress = new Uri("https://localhost"), + AllowAutoRedirect = false, + }); + client.DefaultRequestHeaders.Add("X-Tenant-Id", SeedTenantId.ToString()); + client.DefaultRequestHeaders.Add("X-Test-Actor-Id", actorId.ToString()); + return client; + } + + private static async Task GetAsync(HttpClient client, string baseUrl, CancellationToken ct) + { + var dto = await client.GetFromJsonAsync(baseUrl, ct); + dto.Should().NotBeNull(); + return dto!; + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Iga/RolePromotionLifecycleE2ETests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Iga/RolePromotionLifecycleE2ETests.cs new file mode 100644 index 00000000..d2c8beba --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Iga/RolePromotionLifecycleE2ETests.cs @@ -0,0 +1,489 @@ +using Microsoft.AspNetCore.Mvc.Testing; +using Ums.Application.IGA.DTOs; +using Ums.Domain.IGA; +using Ums.Presentation.IntegrationTest.Infrastructure; +using ProfileAggregate = Ums.Domain.Authorization.Profile.Profile; +using RoleAggregate = Ums.Domain.Authorization.Role.Role; +using RoleMaturityStatusAggregate = Ums.Domain.IGA.RoleMaturityStatus.RoleMaturityStatus; +using RolePromotionRequestAggregate = Ums.Domain.IGA.RolePromotionRequest.RolePromotionRequest; +using SystemSuiteAggregate = Ums.Domain.Authorization.SystemSuite.SystemSuite; + +namespace Ums.Presentation.IntegrationTest.Iga; + +/// +/// G-087 (ADR-UMS-093 / ADR-UMS-096, D-014 / D-020): E2E de integración del ciclo de vida completo +/// de la promoción de rol IGA sobre un contenedor PostgreSQL real (Testcontainers), complementando el +/// —que sólo llega a Execute y verifica el efecto— y las +/// pruebas InMemory de RolePromotionRequestRestEndpointTests. Aquí se ejercitan, de forma +/// DETERMINISTA (los datos de siembra fuerzan cada rama, sin BeOneOf): +/// +/// 1. Ciclo feliz de BAJO riesgo de punta a punta —Create → Submit → ConfirmEligibility → +/// ManagerApprove(→Approved) → Execute → Verify— aseverando cada transición, que el RiskScore +/// congelado queda por debajo del umbral, y el EFECTO real (reasignación del rol del perfil objetivo). +/// 2. Ruta de ALTO riesgo (RiskScore ≥ umbral) que enruta a PendingSecurityReview y sólo tras la +/// aprobación de seguridad llega a Approved y Executed. +/// 3. Rechazo fail-closed de elegibilidad (sin RoleMaturityStatus sembrado) y verificación +/// de que la promoción no puede avanzar tras el corte (INV-RPR4). +/// 4. Violación de segregación de funciones (ADR-UMS-096): el aprobador NO puede ejecutar su propia decisión +/// (ejecutor ≠ aprobador); tras el rechazo, un ejecutor distinto sí puede. +/// 5. Consulta de RoleMaturityStatus por usuario, aislada por inquilino (la madurez no cruza +/// fronteras de inquilino). +/// +/// Cada transición la ejecuta un actor DISTINTO mediante el encabezado X-Test-Actor-Id que honra el +/// del host PostgreSQL, respetando la segregación de funciones (INV-RPR3). +/// El bus es en memoria (sin outbox EF): el efecto de Execute se aplica de forma ASÍNCRONA, por lo +/// que se relee el perfil con reintentos deterministas (no sleep ciegos). +/// +[Collection("PostgreSql")] +public sealed class RolePromotionLifecycleE2ETests +{ + private readonly PostgreSqlContainerFixture _fixture; + private readonly PostgreSqlWebApplicationFactory? _factory; + + public RolePromotionLifecycleE2ETests(PostgreSqlContainerFixture fixture) + { + _fixture = fixture; + if (fixture.IsAvailable) + { + _factory = new PostgreSqlWebApplicationFactory(fixture.ConnectionString); + } + } + + /// + /// (1) Ciclo feliz completo de BAJO riesgo: con ambos roles raíz (nivel 0) el RiskScore resultante + /// queda < umbral, de modo que ManagerApprove va directo a Approved (sin revisión de + /// seguridad). La solicitud atraviesa toda la máquina hasta Verified y el perfil del objetivo + /// queda reasignado al rol destino (efecto de ADR-UMS-096 / G-094 vía outbox + consumidor). + /// + [Fact] + public async Task FullLowRiskLifecycle_ReachesVerified_AndReassignsTargetProfileRole() + { + if (!_fixture.IsAvailable || _factory is null) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + using var warmup = _factory.CreateClient(); + + var tenantId = Guid.NewGuid(); + var targetUserId = Guid.NewGuid(); + var requesterActor = Guid.NewGuid(); + var approverActor = Guid.NewGuid(); + var executorActor = Guid.NewGuid(); + var verifierActor = Guid.NewGuid(); + + var (currentRoleId, targetRoleId, profileId) = await SeedScenarioAsync( + tenantId, targetUserId, highRisk: false, seedEligibleMaturity: true, ct); + + var requester = CreateActorClient(tenantId, requesterActor); + var approver = CreateActorClient(tenantId, approverActor); + var executor = CreateActorClient(tenantId, executorActor); + var verifier = CreateActorClient(tenantId, verifierActor); + + var baseUrl = await CreateAsync(requester, tenantId, targetUserId, currentRoleId, targetRoleId, ct); + + // Draft → PendingEligibilityCheck (congela el RiskScore). + (await requester.PostAsync($"{baseUrl}/submit", null, ct)).StatusCode.Should().Be(HttpStatusCode.NoContent); + var afterSubmit = await GetAsync(requester, baseUrl, ct); + afterSubmit.Status.Should().Be("PendingEligibilityCheck"); + afterSubmit.RiskScore.Should().NotBeNull(); + afterSubmit.RiskScore!.Value.Should().BeLessThan( + RolePromotionRequestAggregate.DefaultHighRiskThreshold, + "los roles raíz de nivel 0 producen un RiskScore de bajo riesgo (< umbral)"); + + // PendingEligibilityCheck → PendingManagerApproval (elegible por el RoleMaturityStatus sembrado). + (await requester.PostAsync($"{baseUrl}/confirm-eligibility", null, ct)).StatusCode.Should().Be(HttpStatusCode.NoContent); + (await GetAsync(requester, baseUrl, ct)).Status.Should().Be("PendingManagerApproval"); + + // PendingManagerApproval → Approved DIRECTO (bajo riesgo ⇒ sin revisión de seguridad). + (await approver.PostAsync($"{baseUrl}/manager-approve", null, ct)).StatusCode.Should().Be(HttpStatusCode.NoContent); + var afterManager = await GetAsync(approver, baseUrl, ct); + afterManager.Status.Should().Be("Approved", "el bajo riesgo no debe enrutar a revisión de seguridad"); + afterManager.SecurityReviewerId.Should().BeNull(); + + // Approved → Executed (ejecutor distinto del objetivo y del aprobador). + (await executor.PostAsync($"{baseUrl}/execute", null, ct)).StatusCode.Should().Be(HttpStatusCode.NoContent); + (await GetAsync(executor, baseUrl, ct)).Status.Should().Be("Executed"); + + // Executed → Verified (verificador distinto del ejecutor y del objetivo). + (await verifier.PostAsync($"{baseUrl}/verify", null, ct)).StatusCode.Should().Be(HttpStatusCode.NoContent); + var verified = await GetAsync(verifier, baseUrl, ct); + verified.Status.Should().Be("Verified"); + verified.ApproverId.Should().NotBeNull(); + verified.ExecutorId.Should().NotBeNull(); + verified.VerifierId.Should().NotBeNull(); + + // Efecto: el rol del perfil objetivo quedó reasignado al rol destino (reentrega asíncrona por el bus). + var profile = await WaitForProfileRoleAsync(profileId, targetRoleId, ct); + profile.Should().NotBeNull(); + profile!.RoleId.GetValue().Should().Be( + targetRoleId, + "la promoción ejecutada debe reasignar el rol del perfil objetivo (ADR-UMS-096 / G-094)"); + profile.IsActive.Should().BeTrue(); + } + + /// + /// (2) Ruta de ALTO riesgo: el rol destino es un rol hijo de nivel jerárquico 4, de modo que el + /// RiskScore congelado alcanza/supera el umbral y ManagerApprove enruta a + /// PendingSecurityReview. Sólo tras la aprobación de un revisor de seguridad distinto llega a + /// Approved, y un ejecutor distinto la ejecuta. + /// + [Fact] + public async Task HighRiskLifecycle_RoutesToSecurityReview_ThenApprovesAndExecutes() + { + if (!_fixture.IsAvailable || _factory is null) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + using var warmup = _factory.CreateClient(); + + var tenantId = Guid.NewGuid(); + var targetUserId = Guid.NewGuid(); + var requesterActor = Guid.NewGuid(); + var approverActor = Guid.NewGuid(); + var securityActor = Guid.NewGuid(); + var executorActor = Guid.NewGuid(); + + var (currentRoleId, targetRoleId, _) = await SeedScenarioAsync( + tenantId, targetUserId, highRisk: true, seedEligibleMaturity: true, ct); + + var requester = CreateActorClient(tenantId, requesterActor); + var approver = CreateActorClient(tenantId, approverActor); + var security = CreateActorClient(tenantId, securityActor); + var executor = CreateActorClient(tenantId, executorActor); + + var baseUrl = await CreateAsync(requester, tenantId, targetUserId, currentRoleId, targetRoleId, ct); + + (await requester.PostAsync($"{baseUrl}/submit", null, ct)).StatusCode.Should().Be(HttpStatusCode.NoContent); + var afterSubmit = await GetAsync(requester, baseUrl, ct); + afterSubmit.RiskScore.Should().NotBeNull(); + afterSubmit.RiskScore!.Value.Should().BeGreaterThanOrEqualTo( + RolePromotionRequestAggregate.DefaultHighRiskThreshold, + "una escalación jerárquica de 4 niveles sobre un rol destino sensible produce alto riesgo (≥ umbral)"); + + (await requester.PostAsync($"{baseUrl}/confirm-eligibility", null, ct)).StatusCode.Should().Be(HttpStatusCode.NoContent); + (await GetAsync(requester, baseUrl, ct)).Status.Should().Be("PendingManagerApproval"); + + // PendingManagerApproval → PendingSecurityReview (DETERMINISTA por el alto RiskScore). + (await approver.PostAsync($"{baseUrl}/manager-approve", null, ct)).StatusCode.Should().Be(HttpStatusCode.NoContent); + (await GetAsync(approver, baseUrl, ct)).Status.Should().Be( + "PendingSecurityReview", "el alto riesgo debe enrutar a revisión de seguridad"); + + // PendingSecurityReview → Approved (revisor distinto del objetivo y del aprobador). + (await security.PostAsync($"{baseUrl}/security-approve", null, ct)).StatusCode.Should().Be(HttpStatusCode.NoContent); + var afterSecurity = await GetAsync(security, baseUrl, ct); + afterSecurity.Status.Should().Be("Approved"); + afterSecurity.SecurityReviewerId.Should().NotBeNull("la ruta de alto riesgo registra al revisor de seguridad"); + + // Approved → Executed (ejecutor distinto del objetivo, del aprobador y del revisor). + (await executor.PostAsync($"{baseUrl}/execute", null, ct)).StatusCode.Should().Be(HttpStatusCode.NoContent); + (await GetAsync(executor, baseUrl, ct)).Status.Should().Be("Executed"); + } + + /// + /// (3) Rechazo fail-closed de elegibilidad: sin un RoleMaturityStatus sembrado, la + /// confirmación traduce la ausencia a «no elegible» y la solicitud pasa a Rejected (INV-RPR4). + /// Se verifica además que NO puede avanzar tras el corte: un ManagerApprove posterior falla con + /// transición de estado inválida. + /// + [Fact] + public async Task ConfirmEligibility_WithoutSeededMaturity_RejectsFailClosed_AndHalts() + { + if (!_fixture.IsAvailable || _factory is null) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + using var warmup = _factory.CreateClient(); + + var tenantId = Guid.NewGuid(); + var targetUserId = Guid.NewGuid(); + var requesterActor = Guid.NewGuid(); + var approverActor = Guid.NewGuid(); + + // Roles y perfil, pero SIN estado de madurez ⇒ la confirmación de elegibilidad rechaza (fail-closed). + var (currentRoleId, targetRoleId, _) = await SeedScenarioAsync( + tenantId, targetUserId, highRisk: false, seedEligibleMaturity: false, ct); + + var requester = CreateActorClient(tenantId, requesterActor); + var approver = CreateActorClient(tenantId, approverActor); + + var baseUrl = await CreateAsync(requester, tenantId, targetUserId, currentRoleId, targetRoleId, ct); + + (await requester.PostAsync($"{baseUrl}/submit", null, ct)).StatusCode.Should().Be(HttpStatusCode.NoContent); + (await requester.PostAsync($"{baseUrl}/confirm-eligibility", null, ct)).StatusCode.Should().Be(HttpStatusCode.NoContent); + + var rejected = await GetAsync(requester, baseUrl, ct); + rejected.Status.Should().Be("Rejected", "sin madurez sembrada la elegibilidad debe rechazar (fail-closed)"); + rejected.DecisionReason.Should().NotBeNullOrWhiteSpace(); + + // La promoción rechazada no puede avanzar: aprobar un estado terminal falla (INV-RPR1). + var approveAttempt = await approver.PostAsync($"{baseUrl}/manager-approve", null, ct); + approveAttempt.StatusCode.Should().Be(HttpStatusCode.BadRequest, "una solicitud rechazada no puede aprobarse"); + (await GetAsync(requester, baseUrl, ct)).Status.Should().Be("Rejected", "el rechazo es terminal"); + } + + /// + /// (4) Segregación de funciones endurecida (ADR-UMS-096, INV-RPR3): quien AUTORIZA no puede EJECUTAR su + /// propia decisión. Se conduce la solicitud hasta Approved y el APROBADOR intenta ejecutarla: + /// debe rechazarse (400) y el estado permanecer Approved. Un ejecutor distinto sí puede ejecutar, + /// lo que confirma que el corte es la SoD y no un flujo roto. + /// + [Fact] + public async Task Execute_WhenExecutorIsApprover_RejectsSegregationOfDuties() + { + if (!_fixture.IsAvailable || _factory is null) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + using var warmup = _factory.CreateClient(); + + var tenantId = Guid.NewGuid(); + var targetUserId = Guid.NewGuid(); + var requesterActor = Guid.NewGuid(); + var approverActor = Guid.NewGuid(); + var executorActor = Guid.NewGuid(); + + var (currentRoleId, targetRoleId, _) = await SeedScenarioAsync( + tenantId, targetUserId, highRisk: false, seedEligibleMaturity: true, ct); + + var requester = CreateActorClient(tenantId, requesterActor); + var approver = CreateActorClient(tenantId, approverActor); + var executor = CreateActorClient(tenantId, executorActor); + + var baseUrl = await CreateAsync(requester, tenantId, targetUserId, currentRoleId, targetRoleId, ct); + + (await requester.PostAsync($"{baseUrl}/submit", null, ct)).StatusCode.Should().Be(HttpStatusCode.NoContent); + (await requester.PostAsync($"{baseUrl}/confirm-eligibility", null, ct)).StatusCode.Should().Be(HttpStatusCode.NoContent); + (await approver.PostAsync($"{baseUrl}/manager-approve", null, ct)).StatusCode.Should().Be(HttpStatusCode.NoContent); + (await GetAsync(approver, baseUrl, ct)).Status.Should().Be("Approved"); + + // El aprobador intenta ejecutar su propia decisión ⇒ violación de SoD (ejecutor ≠ aprobador). + var sodViolation = await approver.PostAsync($"{baseUrl}/execute", null, ct); + sodViolation.StatusCode.Should().Be(HttpStatusCode.BadRequest, "el aprobador no puede ejecutar su propia promoción (ADR-UMS-096)"); + (await GetAsync(approver, baseUrl, ct)).Status.Should().Be("Approved", "la ejecución rechazada por SoD no cambia el estado"); + + // Un ejecutor distinto sí puede ejecutar: el corte era la SoD, no un flujo roto. + (await executor.PostAsync($"{baseUrl}/execute", null, ct)).StatusCode.Should().Be(HttpStatusCode.NoContent); + (await GetAsync(executor, baseUrl, ct)).Status.Should().Be("Executed"); + } + + /// + /// (5) Consulta de RoleMaturityStatus por usuario, aislada por inquilino: se siembra un estado + /// para el MISMO usuario en dos inquilinos distintos. La consulta acotada a un inquilino devuelve + /// exclusivamente su estado (nunca el del otro inquilino), verificando que la madurez no cruza + /// fronteras de inquilino (FR-062, filtro global + parámetro tenantId). + /// + [Fact] + public async Task GetRoleMaturityStatusByUser_IsScopedByTenant() + { + if (!_fixture.IsAvailable || _factory is null) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + using var warmup = _factory.CreateClient(); + + var tenantA = Guid.NewGuid(); + var tenantB = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var roleInA = Guid.NewGuid(); + var roleInB = Guid.NewGuid(); + + await SeedMaturityAsync(tenantA, userId, roleInA, RoleMaturityLevel.Junior, 4.5m, ct); + await SeedMaturityAsync(tenantB, userId, roleInB, RoleMaturityLevel.Senior, 3.2m, ct); + + // Inquilino A: sólo ve su propio estado (Junior en roleInA), nunca el de B. + var clientA = CreateActorClient(tenantA, Guid.NewGuid()); + var listA = await clientA.GetFromJsonAsync>( + $"/api/v1/role-maturity-status/users/{userId}?tenantId={tenantA}", ct); + listA.Should().NotBeNull(); + listA!.Should().ContainSingle(); + listA.Should().NotContain(s => s.TenantId == tenantB, "la madurez del inquilino B no puede filtrarse al inquilino A"); + var statusA = listA!.Single(); + statusA.TenantId.Should().Be(tenantA); + statusA.RoleId.Should().Be(roleInA); + statusA.CurrentMaturityLevel.Should().Be("Junior"); + + // Inquilino B: simétricamente, sólo ve su propio estado (Senior en roleInB). + var clientB = CreateActorClient(tenantB, Guid.NewGuid()); + var listB = await clientB.GetFromJsonAsync>( + $"/api/v1/role-maturity-status/users/{userId}?tenantId={tenantB}", ct); + listB.Should().NotBeNull(); + listB!.Should().ContainSingle(); + listB.Should().NotContain(s => s.TenantId == tenantA, "la madurez del inquilino A no puede filtrarse al inquilino B"); + var statusB = listB!.Single(); + statusB.TenantId.Should().Be(tenantB); + statusB.RoleId.Should().Be(roleInB); + statusB.CurrentMaturityLevel.Should().Be("Senior"); + } + + // ── Siembra ─────────────────────────────────────────────────────────────── + + /// + /// Siembra, sobre el contenedor real: un SystemSuite y dos Role (origen/destino) que el + /// cálculo de RiskScore resuelve por id; opcionalmente un RoleMaturityStatus ELEGIBLE del objetivo + /// en el rol origen; y un Profile ACTIVO del objetivo con el rol origen (sujeto del efecto). + /// Con el rol destino es un rol hijo de nivel jerárquico 4 (escalación que + /// lleva el RiskScore ≥ umbral); en caso contrario ambos roles son raíz (nivel 0) ⇒ bajo riesgo. + /// + private async Task<(Guid CurrentRoleId, Guid TargetRoleId, Guid ProfileId)> SeedScenarioAsync( + Guid tenantIdValue, + Guid targetUserIdValue, + bool highRisk, + bool seedEligibleMaturity, + CancellationToken ct) + { + using var scope = _factory!.Services.CreateScope(); + var actor = ActorId.Create(Guid.NewGuid().ToString()); + var tenantId = TenantId.Load(tenantIdValue); + var targetUser = UserId.Load(targetUserIdValue); + var suffix = Guid.NewGuid().ToString("N")[..8].ToUpperInvariant(); + + var suiteRepository = scope.ServiceProvider.GetRequiredService(); + var suite = SystemSuiteAggregate.Create( + tenantId, + Code.Create($"SS{suffix}"), + Name.Create($"IGA Lifecycle Suite {suffix}"), + Description.Create("Suite sembrada por RolePromotionLifecycleE2ETests (G-087)."), + actor).Value; + await suiteRepository.AddAsync(suite, ct); + await suiteRepository.UnitOfWork.SaveEntitiesAsync(ct); + + var roleRepository = scope.ServiceProvider.GetRequiredService(); + + // Rol origen: raíz (nivel 0, orden 0). + var currentRole = RoleAggregate.Create( + tenantId, suite.GetId(), Code.Create($"RLCUR{suffix}"), Name.Create($"Current {suffix}"), + Description.Create("Rol origen (G-087)."), parentRoleId: null, hierarchyLevel: 0, promotionOrder: 0, actor).Value; + + // Rol destino: raíz nivel 0 (bajo riesgo) o hijo de nivel 4 (alto riesgo). Un rol raíz DEBE tener + // nivel 0 y un rol hijo un nivel distinto de 0 (ValidateHierarchy en el dominio de Autorización). + var targetRole = highRisk + ? RoleAggregate.Create( + tenantId, suite.GetId(), Code.Create($"RLTGT{suffix}"), Name.Create($"Target {suffix}"), + Description.Create("Rol destino de alto riesgo (G-087)."), + parentRoleId: currentRole.GetId(), hierarchyLevel: 4, promotionOrder: 1, actor).Value + : RoleAggregate.Create( + tenantId, suite.GetId(), Code.Create($"RLTGT{suffix}"), Name.Create($"Target {suffix}"), + Description.Create("Rol destino de bajo riesgo (G-087)."), + parentRoleId: null, hierarchyLevel: 0, promotionOrder: 1, actor).Value; + + await roleRepository.AddAsync(currentRole, ct); + await roleRepository.AddAsync(targetRole, ct); + await roleRepository.UnitOfWork.SaveEntitiesAsync(ct); + + var currentRoleId = currentRole.GetId(); + + if (seedEligibleMaturity) + { + var maturityRepository = scope.ServiceProvider.GetRequiredService(); + var eligible = RoleMaturityStatusAggregate.Create( + tenantId, targetUser, currentRoleId, RoleMaturityLevel.Junior, DateTime.UtcNow.AddYears(-2), actor).Value; + eligible.UpdatePerformanceScore(4.5m, actor); + await maturityRepository.AddAsync(eligible, ct); + await maturityRepository.UnitOfWork.SaveEntitiesAsync(ct); + } + + var profileRepository = scope.ServiceProvider.GetRequiredService(); + var profile = ProfileAggregate.Create(tenantId, targetUser, currentRoleId, branchId: null, actor).Value; + await profileRepository.AddAsync(profile, ct); + await profileRepository.UnitOfWork.SaveEntitiesAsync(ct); + + return (currentRole.GetId().GetValue(), targetRole.GetId().GetValue(), profile.GetId().GetValue()); + } + + /// Siembra un RoleMaturityStatus aislado (sin roles/perfil) para la consulta por inquilino. + private async Task SeedMaturityAsync( + Guid tenantIdValue, + Guid userIdValue, + Guid roleIdValue, + RoleMaturityLevel level, + decimal performanceScore, + CancellationToken ct) + { + using var scope = _factory!.Services.CreateScope(); + var actor = ActorId.Create(Guid.NewGuid().ToString()); + var maturityRepository = scope.ServiceProvider.GetRequiredService(); + + var status = RoleMaturityStatusAggregate.Create( + TenantId.Load(tenantIdValue), + UserId.Load(userIdValue), + RoleId.Load(roleIdValue), + level, + DateTime.UtcNow.AddYears(-1), + actor).Value; + status.UpdatePerformanceScore(performanceScore, actor); + + await maturityRepository.AddAsync(status, ct); + await maturityRepository.UnitOfWork.SaveEntitiesAsync(ct); + } + + // ── Utilidades HTTP ───────────────────────────────────────────────────────── + + private async Task CreateAsync( + HttpClient requester, Guid tenantId, Guid targetUserId, Guid currentRoleId, Guid targetRoleId, CancellationToken ct) + { + var createResponse = await requester.PostAsJsonAsync( + "/api/v1/role-promotion-requests", + new { TenantId = tenantId, TargetUserId = targetUserId, CurrentRoleId = currentRoleId, TargetRoleId = targetRoleId }, + ct); + createResponse.StatusCode.Should().Be(HttpStatusCode.Created); + var created = await createResponse.Content.ReadFromJsonAsync(ct); + created.Should().NotBeNull(); + return $"/api/v1/role-promotion-requests/{created!.RolePromotionRequestId}"; + } + + private HttpClient CreateActorClient(Guid tenantId, Guid actorId) + { + var client = _factory!.CreateClient(new WebApplicationFactoryClientOptions + { + BaseAddress = new Uri("https://localhost"), + AllowAutoRedirect = false, + }); + client.DefaultRequestHeaders.Add("X-Tenant-Id", tenantId.ToString()); + client.DefaultRequestHeaders.Add("X-Test-Actor-Id", actorId.ToString()); + return client; + } + + private async Task WaitForProfileRoleAsync(Guid profileId, Guid expectedRoleId, CancellationToken ct) + { + // La reasignación llega por el bus de forma asíncrona: se relee con reintentos deterministas + // —scope/DbContext nuevo por intento para no leer del mapa de identidad— hasta observar el rol. + ProfileAggregate? profile = null; + for (var attempt = 0; attempt < 50; attempt++) + { + using var scope = _factory!.Services.CreateScope(); + var profileRepository = scope.ServiceProvider.GetRequiredService(); + profile = await profileRepository.GetByIdAsync(profileId, ct); + + if (profile is not null && profile.RoleId.GetValue() == expectedRoleId) + { + return profile; + } + + await Task.Delay(100, ct); + } + + return profile; + } + + private static async Task GetAsync(HttpClient client, string baseUrl, CancellationToken ct) + { + var dto = await client.GetFromJsonAsync(baseUrl, ct); + dto.Should().NotBeNull(); + return dto!; + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Iga/RolePromotionRequestRestEndpointTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Iga/RolePromotionRequestRestEndpointTests.cs new file mode 100644 index 00000000..0c379f2a --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Iga/RolePromotionRequestRestEndpointTests.cs @@ -0,0 +1,303 @@ +using System.Net; +using System.Net.Http.Json; +using Ums.Application.IGA.DTOs; +using Ums.Infrastructure.Persistence.Seeders; +using Ums.Presentation.IntegrationTest.Infrastructure; + +namespace Ums.Presentation.IntegrationTest.Iga; + +/// +/// Pruebas de los endpoints REST del contexto acotado IGA (ADR-UMS-093, G-052). Cubren la máquina de +/// estados de promoción de rol de punta a punta —Draft → PendingEligibilityCheck → PendingManagerApproval +/// → (PendingSecurityReview) → Approved → Executed → Verified—, la confirmación de elegibilidad +/// fail-closed (feliz y borde no elegible), la lectura acotada por inquilino y la segregación de +/// funciones (SoD, INV-RPR3). +/// +/// El happy-path se apoya en el : siembra un RoleMaturityStatus +/// ELEGIBLE para RansaAdminUserId en DemoAdminRoleId y uno NO ELEGIBLE (borde) para +/// RansaAnalystUserId en DemoOperatorRoleId. La segregación de funciones se ejerce con +/// actores distintos por transición mediante el encabezado de prueba X-Test-Actor-Id. +/// +public sealed class RolePromotionRequestRestEndpointTests : IClassFixture +{ + private static readonly Guid SeededTenantId = Guid.Parse(CoreDevDataSeeder.RansaTenantId); + + // Coincide con el NameIdentifier por defecto que emite TestAuthHandler cuando no se envía override. + private static readonly Guid AuthenticatedRequesterId = Guid.Parse("00000000-0000-0000-0000-000000000111"); + + // Usuario objetivo ELEGIBLE (con RoleMaturityStatus sembrado) y sus roles (ambos sembrados en RANSA). + private static readonly Guid TargetUserId = Guid.Parse(CoreDevDataSeeder.RansaAdminUserId); + private static readonly Guid CurrentRoleId = Guid.Parse(CoreDevDataSeeder.DemoAdminRoleId); + private static readonly Guid TargetRoleId = Guid.Parse(CoreDevDataSeeder.DemoOperatorRoleId); + + // Usuario objetivo NO ELEGIBLE (borde): tiempo insuficiente en nivel ⇒ elegibilidad rechazada. + private static readonly Guid IneligibleTargetUserId = Guid.Parse(CoreDevDataSeeder.RansaAnalystUserId); + private static readonly Guid IneligibleCurrentRoleId = Guid.Parse(CoreDevDataSeeder.DemoOperatorRoleId); + private static readonly Guid IneligibleTargetRoleId = Guid.Parse(CoreDevDataSeeder.DemoAdminRoleId); + + // Actores distintos para respetar la segregación de funciones a lo largo del flujo. + private const string RequesterActor = "a0000000-0000-0000-0000-000000000a01"; + private const string ApproverActor = "b0000000-0000-0000-0000-000000000b02"; + private const string SecurityActor = "c0000000-0000-0000-0000-000000000c03"; + private const string ExecutorActor = "d0000000-0000-0000-0000-000000000d04"; + private const string VerifierActor = "e0000000-0000-0000-0000-000000000e05"; + + private readonly UmsApiWebApplicationFactory _factory; + private readonly HttpClient _client; + + public RolePromotionRequestRestEndpointTests(UmsApiWebApplicationFactory factory) + { + _factory = factory; + _client = factory.CreateClient(); + _client.DefaultRequestHeaders.Add("X-User-Id", AuthenticatedRequesterId.ToString()); + _client.DefaultRequestHeaders.Add("X-Tenant-Id", SeededTenantId.ToString()); + } + + private HttpClient CreateActorClient(string actorId) + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Add("X-Tenant-Id", SeededTenantId.ToString()); + // G-057: el host InMemory (UmsApiWebApplicationFactory) autentica con DevAuthMiddleware, que lee + // X-User-Id (→ NameIdentifier) e IGNORA X-Test-Actor-Id; sin X-User-Id el actor quedaba en + // "dev-user" (no-GUID) y el create fallaba con 400 «identificador no válido». Se envía el actor + // también como X-User-Id para que el host InMemory lo honre (el TestAuthHandler del host PostgreSQL + // sigue leyendo X-Test-Actor-Id); ambos resuelven el mismo actorId → segregación de funciones intacta. + client.DefaultRequestHeaders.Add("X-User-Id", actorId); + client.DefaultRequestHeaders.Add("X-Test-Actor-Id", actorId); + return client; + } + + [Fact] + public async Task Create_ThenSubmit_ShouldAdvanceThroughStateMachine() + { + var cancellationToken = TestContext.Current.CancellationToken; + var command = new + { + TenantId = SeededTenantId, + TargetUserId, + CurrentRoleId, + TargetRoleId, + }; + + var createResponse = await _client.PostAsJsonAsync("/api/v1/role-promotion-requests", command, cancellationToken); + createResponse.EnsureSuccessStatusCode(); + + var created = await createResponse.Content.ReadFromJsonAsync(cancellationToken); + created.Should().NotBeNull(); + + var getResponse = await _client.GetAsync($"/api/v1/role-promotion-requests/{created!.RolePromotionRequestId}", cancellationToken); + getResponse.EnsureSuccessStatusCode(); + + var draft = await getResponse.Content.ReadFromJsonAsync(cancellationToken); + draft.Should().NotBeNull(); + draft!.Status.Should().Be("Draft"); + draft.TargetUserId.Should().Be(TargetUserId); + draft.RequesterId.Should().Be(AuthenticatedRequesterId); + + var submitResponse = await _client.PostAsync( + $"/api/v1/role-promotion-requests/{created.RolePromotionRequestId}/submit", content: null, cancellationToken); + submitResponse.StatusCode.Should().Be(HttpStatusCode.NoContent); + + var afterSubmit = await _client.GetFromJsonAsync( + $"/api/v1/role-promotion-requests/{created.RolePromotionRequestId}", cancellationToken); + afterSubmit.Should().NotBeNull(); + afterSubmit!.Status.Should().Be("PendingEligibilityCheck"); + afterSubmit.RiskScore.Should().NotBeNull(); + } + + /// + /// Happy-path completo (G-052): con el RoleMaturityStatus sembrado ELEGIBLE, la solicitud + /// atraviesa toda la máquina de estados hasta Verified. Cada transición la ejecuta un actor distinto + /// para respetar la segregación de funciones (INV-RPR3). + /// + [Fact] + public async Task FullPromotionFlow_WithEligibleMaturity_ShouldReachVerified() + { + var cancellationToken = TestContext.Current.CancellationToken; + + var requester = CreateActorClient(RequesterActor); + var approver = CreateActorClient(ApproverActor); + var security = CreateActorClient(SecurityActor); + var executor = CreateActorClient(ExecutorActor); + var verifier = CreateActorClient(VerifierActor); + + // 1. Crear (Draft) — el solicitante no es el objetivo. + var createResponse = await requester.PostAsJsonAsync( + "/api/v1/role-promotion-requests", + new { TenantId = SeededTenantId, TargetUserId, CurrentRoleId, TargetRoleId }, + cancellationToken); + createResponse.StatusCode.Should().Be(HttpStatusCode.Created); + var created = await createResponse.Content.ReadFromJsonAsync(cancellationToken); + created.Should().NotBeNull(); + var id = created!.RolePromotionRequestId; + var baseUrl = $"/api/v1/role-promotion-requests/{id}"; + + // 2. Submit → PendingEligibilityCheck (congela el RiskScore). + (await requester.PostAsync($"{baseUrl}/submit", null, cancellationToken)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + (await GetAsync(requester, baseUrl, cancellationToken)).Status.Should().Be("PendingEligibilityCheck"); + + // 3. ConfirmEligibility → PendingManagerApproval (ELEGIBLE gracias al seed; ya no rechaza). + (await requester.PostAsync($"{baseUrl}/confirm-eligibility", null, cancellationToken)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + (await GetAsync(requester, baseUrl, cancellationToken)).Status.Should().Be("PendingManagerApproval"); + + // 4. ManagerApprove → Approved o PendingSecurityReview según el RiskScore. + (await approver.PostAsync($"{baseUrl}/manager-approve", null, cancellationToken)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + var afterManager = await GetAsync(approver, baseUrl, cancellationToken); + afterManager.Status.Should().BeOneOf("Approved", "PendingSecurityReview"); + + // 4b. Si el riesgo escaló a revisión de seguridad, un revisor distinto la aprueba. + if (afterManager.Status == "PendingSecurityReview") + { + (await security.PostAsync($"{baseUrl}/security-approve", null, cancellationToken)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + (await GetAsync(security, baseUrl, cancellationToken)).Status.Should().Be("Approved"); + + // 5. Execute → Executed (ejecutor distinto del objetivo). + (await executor.PostAsync($"{baseUrl}/execute", null, cancellationToken)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + (await GetAsync(executor, baseUrl, cancellationToken)).Status.Should().Be("Executed"); + + // 6. Verify → Verified (verificador distinto del ejecutor, del aprobador y del objetivo). + (await verifier.PostAsync($"{baseUrl}/verify", null, cancellationToken)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + var verified = await GetAsync(verifier, baseUrl, cancellationToken); + verified.Status.Should().Be("Verified"); + verified.ApproverId.Should().NotBeNull(); + verified.ExecutorId.Should().NotBeNull(); + verified.VerifierId.Should().NotBeNull(); + } + + /// + /// Borde fail-closed (G-052): el objetivo tiene un RoleMaturityStatus sembrado pero con tiempo + /// insuficiente en nivel, de modo que la confirmación de elegibilidad RECHAZA y la promoción nunca + /// avanza (INV-RPR4). Cubre el corte sin depender de la ausencia del dato de madurez. + /// + [Fact] + public async Task ConfirmEligibility_WithIneligibleMaturity_ShouldRejectAndHalt() + { + var cancellationToken = TestContext.Current.CancellationToken; + var requester = CreateActorClient(RequesterActor); + + var createResponse = await requester.PostAsJsonAsync( + "/api/v1/role-promotion-requests", + new + { + TenantId = SeededTenantId, + TargetUserId = IneligibleTargetUserId, + CurrentRoleId = IneligibleCurrentRoleId, + TargetRoleId = IneligibleTargetRoleId, + }, + cancellationToken); + createResponse.StatusCode.Should().Be(HttpStatusCode.Created); + var created = await createResponse.Content.ReadFromJsonAsync(cancellationToken); + created.Should().NotBeNull(); + var baseUrl = $"/api/v1/role-promotion-requests/{created!.RolePromotionRequestId}"; + + (await requester.PostAsync($"{baseUrl}/submit", null, cancellationToken)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + (await requester.PostAsync($"{baseUrl}/confirm-eligibility", null, cancellationToken)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + var rejected = await GetAsync(requester, baseUrl, cancellationToken); + rejected.Status.Should().Be("Rejected"); + rejected.DecisionReason.Should().NotBeNullOrWhiteSpace(); + } + + [Fact] + public async Task Create_WhenRequesterIsTargetUser_ShouldFailSegregationOfDuties() + { + var cancellationToken = TestContext.Current.CancellationToken; + var command = new + { + TenantId = SeededTenantId, + TargetUserId = AuthenticatedRequesterId, // viola SoD: el solicitante no puede promoverse a sí mismo. + CurrentRoleId, + TargetRoleId, + }; + + var response = await _client.PostAsJsonAsync("/api/v1/role-promotion-requests", command, cancellationToken); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task ManagerApprove_WhenApproverIsRequester_ShouldFailSegregationOfDuties() + { + var cancellationToken = TestContext.Current.CancellationToken; + var requester = CreateActorClient(RequesterActor); + + var createResponse = await requester.PostAsJsonAsync( + "/api/v1/role-promotion-requests", + new { TenantId = SeededTenantId, TargetUserId, CurrentRoleId, TargetRoleId }, + cancellationToken); + createResponse.StatusCode.Should().Be(HttpStatusCode.Created); + var created = await createResponse.Content.ReadFromJsonAsync(cancellationToken); + var baseUrl = $"/api/v1/role-promotion-requests/{created!.RolePromotionRequestId}"; + + (await requester.PostAsync($"{baseUrl}/submit", null, cancellationToken)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + (await requester.PostAsync($"{baseUrl}/confirm-eligibility", null, cancellationToken)) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + // El solicitante intenta aprobar su propia solicitud → violación de SoD (INV-RPR3). + var approveResponse = await requester.PostAsync($"{baseUrl}/manager-approve", null, cancellationToken); + approveResponse.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task List_ShouldReturnCreatedRequestScopedByTenant() + { + var cancellationToken = TestContext.Current.CancellationToken; + var command = new + { + TenantId = SeededTenantId, + TargetUserId, + CurrentRoleId, + TargetRoleId, + }; + + var createResponse = await _client.PostAsJsonAsync("/api/v1/role-promotion-requests", command, cancellationToken); + createResponse.EnsureSuccessStatusCode(); + var created = await createResponse.Content.ReadFromJsonAsync(cancellationToken); + created.Should().NotBeNull(); + + var list = await _client.GetFromJsonAsync>( + $"/api/v1/role-promotion-requests?tenantId={SeededTenantId}", cancellationToken); + + list.Should().NotBeNull(); + list!.Should().Contain(r => r.Id == created!.RolePromotionRequestId); + } + + /// + /// G-100: un GET por id inexistente debe resolver 404. El handler devuelve el error de dominio de + /// «no encontrado» en español (código estable iga.role_promotion_request_not_found); antes el + /// DomainErrorStatusMapper sólo reconocía el substring en inglés «not found» y lo colapsaba a 400. + /// Esta prueba recorre el endpoint real y el mapeador real, con lo que verifica la clasificación por + /// código con independencia del idioma del mensaje. + /// + [Fact] + public async Task GetById_WhenRequestDoesNotExist_ShouldReturn404() + { + var cancellationToken = TestContext.Current.CancellationToken; + var missingId = Guid.NewGuid(); + + var response = await _client.GetAsync($"/api/v1/role-promotion-requests/{missingId}", cancellationToken); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + private static async Task GetAsync(HttpClient client, string baseUrl, CancellationToken cancellationToken) + { + var dto = await client.GetFromJsonAsync(baseUrl, cancellationToken); + dto.Should().NotBeNull(); + return dto!; + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Iga/RolePromotionRoleAssignmentConsumerTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Iga/RolePromotionRoleAssignmentConsumerTests.cs new file mode 100644 index 00000000..fbdac628 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Iga/RolePromotionRoleAssignmentConsumerTests.cs @@ -0,0 +1,106 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Ums.Domain.Events; +using Ums.Infrastructure.Hosting; +using ProfileAggregate = Ums.Domain.Authorization.Profile.Profile; + +namespace Ums.Presentation.IntegrationTest.Iga; + +/// +/// G-094: pruebas AISLADAS de —el consumidor que +/// aplica el efecto de la promoción de rol IGA entregado por el Transactional Outbox—. Se ejercita su +/// núcleo (ApplyRoleAssignmentAsync) contra un real, sin +/// bus ni contenedor, para verificar de forma determinista: +/// +/// 1. Dado el evento, reasigna el rol del perfil ACTIVO del objetivo cuyo rol coincide con el origen. +/// 2. Ante un ChangeRole fallido NO confirma éxito: lanza excepción para que MassTransit +/// reintente y, agotados los reintentos, mueva el mensaje a la dead-letter — el efecto de la +/// promoción no se descarta en silencio (fin del fallo silencioso G-093). +/// +/// Complementa a (happy-path E2E por el bus real) cubriendo +/// la rama de fallo, que el harness in-memory no puede forzar de forma determinista. +/// +public sealed class RolePromotionRoleAssignmentConsumerTests +{ + // El campo del inquilino NO se llama «TenantId» para no ensombrecer el tipo homónimo del dominio. + private static readonly Guid SeedTenantId = Guid.NewGuid(); + private static readonly Guid TargetUserId = Guid.NewGuid(); + private static readonly Guid CurrentRoleId = Guid.NewGuid(); + private static readonly Guid TargetRoleId = Guid.NewGuid(); + private static readonly Guid ExecutorId = Guid.NewGuid(); + + private static RolePromotionRoleAssignmentConsumer CreateConsumer(InMemoryProfileRepository repository) + => new(repository, NullLogger.Instance); + + private static ProfileAggregate SeedActiveProfile(InMemoryProfileRepository repository, Guid roleId) + { + var profile = ProfileAggregate.Create( + TenantId.Load(SeedTenantId), + UserId.Load(TargetUserId), + RoleId.Load(roleId), + branchId: null, + ActorId.Create(ExecutorId.ToString())).Value; + + repository.Seed(profile); + return profile; + } + + [Fact] + public async Task ApplyRoleAssignment_WhenActiveProfileMatchesSourceRole_ReassignsToTargetRole() + { + var ct = TestContext.Current.CancellationToken; + var repository = new InMemoryProfileRepository(); + var seeded = SeedActiveProfile(repository, CurrentRoleId); + var consumer = CreateConsumer(repository); + + var message = new RolePromotionExecutedIntegrationEvent( + SeedTenantId, RequestId: Guid.NewGuid(), TargetUserId, CurrentRoleId, TargetRoleId, ExecutorId); + + await consumer.ApplyRoleAssignmentAsync(message, ct); + + var reloaded = await repository.GetByIdAsync(seeded.GetId().GetValue(), ct); + reloaded.Should().NotBeNull(); + reloaded!.RoleId.GetValue().Should().Be(TargetRoleId, "el consumidor debe reasignar el perfil objetivo al rol destino (G-094)"); + reloaded.IsActive.Should().BeTrue(); + } + + [Fact] + public async Task ApplyRoleAssignment_WhenNoMatchingProfile_DoesNothingAndDoesNotThrow() + { + var ct = TestContext.Current.CancellationToken; + var repository = new InMemoryProfileRepository(); + // Perfil activo con OTRO rol: la regla de selección (RoleId == CurrentRoleId) no lo alcanza. + var seeded = SeedActiveProfile(repository, Guid.NewGuid()); + var consumer = CreateConsumer(repository); + + var message = new RolePromotionExecutedIntegrationEvent( + SeedTenantId, RequestId: Guid.NewGuid(), TargetUserId, CurrentRoleId, TargetRoleId, ExecutorId); + + // No hay sujeto sobre el que actuar: se confirma el mensaje (reintentar no ayudaría) sin lanzar. + await consumer.ApplyRoleAssignmentAsync(message, ct); + + var reloaded = await repository.GetByIdAsync(seeded.GetId().GetValue(), ct); + reloaded!.RoleId.GetValue().Should().NotBe(TargetRoleId, "sin perfil coincidente no debe tocarse ningún rol"); + } + + [Fact] + public async Task ApplyRoleAssignment_WhenChangeRoleFails_ThrowsAndDoesNotMarkSuccess() + { + var ct = TestContext.Current.CancellationToken; + var repository = new InMemoryProfileRepository(); + var seeded = SeedActiveProfile(repository, CurrentRoleId); + var consumer = CreateConsumer(repository); + + // Evento degenerado (rol destino == rol origen): el perfil ES seleccionado, pero ChangeRole + // falla («rol sin cambio»). El consumidor debe LANZAR —no confirmar éxito— para que el mensaje + // se reintente y termine en la dead-letter en lugar de descartarse en silencio (G-094). + var message = new RolePromotionExecutedIntegrationEvent( + SeedTenantId, RequestId: Guid.NewGuid(), TargetUserId, CurrentRoleId, TargetRoleId: CurrentRoleId, ExecutorId); + + var act = async () => await consumer.ApplyRoleAssignmentAsync(message, ct); + + await act.Should().ThrowAsync("un ChangeRole fallido no debe confirmarse como éxito (G-094)"); + + var reloaded = await repository.GetByIdAsync(seeded.GetId().GetValue(), ct); + reloaded!.RoleId.GetValue().Should().Be(CurrentRoleId, "un efecto fallido no debe dejar el perfil mutado a medias"); + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/AuthorizationConfigurationRepositoryTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/AuthorizationConfigurationRepositoryTests.cs index 51fcb035..5d0be02a 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/AuthorizationConfigurationRepositoryTests.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/AuthorizationConfigurationRepositoryTests.cs @@ -128,7 +128,7 @@ public async Task Profile_CrudOperations_WorkCorrectly() private UmsPlatformDbContext CreateContext(DbContextOptions options) { var tenantContext = new TestTenantContext(_testTenantId); - return new UmsPlatformDbContext(options, tenantContext, new Moq.Mock().Object); + return new UmsPlatformDbContext(options, tenantContext, new Moq.Mock().Object, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); } private class TestTenantContext(Guid tenantId) : ITenantContext @@ -287,13 +287,13 @@ public async Task FeatureFlagCriteria_AddAndRetrieve_WorkCorrectly() .ToListAsync(ct); retrievedCriteria.Should().HaveCount(1); - retrievedCriteria.First().Value.Should().Be("test-value"); + retrievedCriteria[0].Value.Should().Be("test-value"); } private UmsPlatformDbContext CreateContext(DbContextOptions options) { var tenantContext = new TestTenantContext(_testTenantId); - return new UmsPlatformDbContext(options, tenantContext, new Moq.Mock().Object); + return new UmsPlatformDbContext(options, tenantContext, new Moq.Mock().Object, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); } private class TestTenantContext(Guid tenantId) : ITenantContext diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/EntityRepositoryTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/EntityRepositoryTests.cs index 4dfb31e1..79468686 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/EntityRepositoryTests.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/EntityRepositoryTests.cs @@ -76,7 +76,7 @@ await context.Tenants.AddRangeAsync( private UmsPlatformDbContext CreateContext(DbContextOptions options) { var tenantContext = new TestTenantContext(_testTenantId); - return new UmsPlatformDbContext(options, tenantContext, new Moq.Mock().Object); + return new UmsPlatformDbContext(options, tenantContext, new Moq.Mock().Object, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); } private class TestTenantContext(Guid tenantId) : ITenantContext @@ -203,7 +203,7 @@ await context.UserAccounts.AddRangeAsync( private UmsPlatformDbContext CreateContext(DbContextOptions options) { var tenantContext = new TestTenantContext(_testTenantId); - return new UmsPlatformDbContext(options, tenantContext, new Moq.Mock().Object); + return new UmsPlatformDbContext(options, tenantContext, new Moq.Mock().Object, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); } private class TestTenantContext(Guid tenantId) : ITenantContext diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/FakeFeatureFlagHandler.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/FakeFeatureFlagHandler.cs index a1cb4da6..76a80c9d 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/FakeFeatureFlagHandler.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/FakeFeatureFlagHandler.cs @@ -4,5 +4,6 @@ public sealed class FakeFeatureFlagHandler { public void Handle() { + // Fake handler for testing purposes, deliberately left empty. } } diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/PostgreSqlAuthorizationPersistenceTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/PostgreSqlAuthorizationPersistenceTests.cs index 79935384..65edb907 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/PostgreSqlAuthorizationPersistenceTests.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/PostgreSqlAuthorizationPersistenceTests.cs @@ -10,80 +10,169 @@ namespace Ums.Presentation.IntegrationTest.Infrastructure; [Collection("PostgreSql")] public sealed class PostgreSqlAuthorizationPersistenceTests : IntegrationTestBase { - private static readonly Guid TenantId = Guid.Parse("3fa85f64-5717-4562-b3fc-2c963f66afa6"); - public PostgreSqlAuthorizationPersistenceTests(PostgreSqlContainerFixture fixture) : base(fixture) { } + // G-014 (residual): el host PostgreSQL corre con SeedDevData=false y ResetDatabase() TRUNCA toda + // la data antes de cada test; PostgresTestSeeder no siembra NINGÚN tenant (ni RANSA ni BEYONDNET). + // Antes estas pruebas apuntaban al GUID fijo de RANSA, que ya no existe en la BD → EnsureManagementOwnerScope + // no resuelve el tenant objetivo ("AUTH_002: Tenant not found") y el create devolvía 404. Se crea el + // tenant objetivo por REST (como hacen los demás E2E de este host) y se opera sobre su id real. + private async Task CreateTenantAsync(CancellationToken ct) + { + var code = $"AUTHZ{Guid.NewGuid():N}"[..12].ToUpperInvariant(); + var response = await Client.PostAsJsonAsync("/api/v1/tenants", new + { + code, + name = $"Authz Persistence Tenant {code}", + type = "CLIENT", + isManagementOwner = false + }, ct); + response.StatusCode.Should().Be(HttpStatusCode.Created); + + var location = response.Headers.Location?.ToString(); + return Guid.Parse(location!.Split('/')[^1]); + } + [Fact] public async Task CreateAndGetSystemSuite_UsesPostgreSqlAuthorizationStore() { if (!Fixture.IsAvailable) Assert.Skip("Docker is required for SQL Server integration tests."); + var ct = TestContext.Current.CancellationToken; + var tenantId = await CreateTenantAsync(ct); var code = $"SS{Guid.NewGuid():N}"[..10]; var createBody = new { - tenantId = TenantId, + tenantId, code, name = "Tenant Console", description = "SQL-backed authorization system suite." }; - var createResponse = await Client.PostAsJsonAsync("/api/v1/system-suites", createBody, TestContext.Current.CancellationToken); + var createResponse = await Client.PostAsJsonAsync("/api/v1/system-suites", createBody, ct); createResponse.StatusCode.Should().Be(HttpStatusCode.Created); - using var createPayload = JsonDocument.Parse(await createResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + using var createPayload = JsonDocument.Parse(await createResponse.Content.ReadAsStringAsync(ct)); var systemSuiteId = createPayload.RootElement.GetProperty("systemSuiteId").GetGuid(); - var getResponse = await Client.GetAsync($"/api/v1/system-suites/{systemSuiteId}", TestContext.Current.CancellationToken); + Client.DefaultRequestHeaders.Remove("X-Tenant-Id"); + Client.DefaultRequestHeaders.Add("X-Tenant-Id", tenantId.ToString()); + var getResponse = await Client.GetAsync($"/api/v1/system-suites/{systemSuiteId}", ct); getResponse.StatusCode.Should().Be(HttpStatusCode.OK); - using var getPayload = JsonDocument.Parse(await getResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + using var getPayload = JsonDocument.Parse(await getResponse.Content.ReadAsStringAsync(ct)); getPayload.RootElement.GetProperty("systemSuiteId").GetGuid().Should().Be(systemSuiteId); - getPayload.RootElement.GetProperty("tenantId").GetGuid().Should().Be(TenantId); - getPayload.RootElement.GetProperty("code").GetString().Should().Be(code); + getPayload.RootElement.GetProperty("tenantId").GetGuid().Should().Be(tenantId); + // G-014 (residual): producción canonicaliza el code a MAYÚSCULAS (Code.Create → + // DomainGuards.NormalizeCode = Trim().ToUpperInvariant()). El code enviado lleva la parte + // del GUID en minúsculas, así que la expectativa correcta es su forma canónica en mayúsculas. + getPayload.RootElement.GetProperty("code").GetString().Should().Be(code.ToUpperInvariant()); } [Fact] public async Task CreatePublishAndGetPermissionTemplate_UsesPostgreSqlAuthorizationStore() { if (!Fixture.IsAvailable) Assert.Skip("Docker is required for SQL Server integration tests."); + var ct = TestContext.Current.CancellationToken; + var tenantId = await CreateTenantAsync(ct); var suiteCode = $"PT{Guid.NewGuid():N}"[..10]; var createSuite = await Client.PostAsJsonAsync("/api/v1/system-suites", new { - tenantId = TenantId, + tenantId, code = suiteCode, name = "Approvals", description = "System suite for template integration." - }, TestContext.Current.CancellationToken); + }, ct); createSuite.StatusCode.Should().Be(HttpStatusCode.Created); - using var suitePayload = JsonDocument.Parse(await createSuite.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + using var suitePayload = JsonDocument.Parse(await createSuite.Content.ReadAsStringAsync(ct)); var systemSuiteId = suitePayload.RootElement.GetProperty("systemSuiteId").GetGuid(); - var roleId = Guid.NewGuid(); + + // G-014 (residual): en el host PostgreSQL las FK son reales. Antes el test usaba un roleId + // aleatorio inexistente y el insert de la plantilla violaba FK_PermissionTemplates_Roles_RoleId + // → DbUpdateException → 500. Se crea un rol real bajo la suite y se usa su id (FK satisfecha). + var roleCode = $"ROLE{Guid.NewGuid():N}"[..12].ToUpperInvariant(); + Client.DefaultRequestHeaders.Remove("X-Tenant-Id"); + Client.DefaultRequestHeaders.Add("X-Tenant-Id", tenantId.ToString()); + // ADR-0077 (G-014 residual): fijado X-Tenant-Id a un inquilino CLIENT, aprovisionar roles es + // una operación ON-BEHALF que solo el operador management-owner/internal-admin puede ejecutar + // (TenantScopePolicy → AUTH_015 → 400). Modelamos al internal-admin de BEYONDNET aprovisionando + // recursos para el CLIENT (mismo patrón que RoleE2ETests, verde 4/4). + Client.DefaultRequestHeaders.Remove("X-Is-Internal-Admin"); + Client.DefaultRequestHeaders.Add("X-Is-Internal-Admin", "true"); + var createRole = await Client.PostAsJsonAsync($"/api/v1/system-suites/{systemSuiteId}/roles", new + { + code = roleCode, + value = "Authz Persistence Role", + description = "Rol para la prueba de round-trip del store de autorización.", + parentRoleId = (Guid?)null, + hierarchyLevel = 0, + promotionOrder = 0, + }, ct); + createRole.StatusCode.Should().Be(HttpStatusCode.Created); + using var rolePayload = JsonDocument.Parse(await createRole.Content.ReadAsStringAsync(ct)); + var roleId = rolePayload.RootElement.GetProperty("roleId").GetGuid(); var createTemplate = await Client.PostAsJsonAsync("/api/v1/permission-templates", new { - tenantId = TenantId, + tenantId, roleId, systemSuiteId - }, TestContext.Current.CancellationToken); + }, ct); createTemplate.StatusCode.Should().Be(HttpStatusCode.Created); - using var templatePayload = JsonDocument.Parse(await createTemplate.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + using var templatePayload = JsonDocument.Parse(await createTemplate.Content.ReadAsStringAsync(ct)); var templateId = templatePayload.RootElement.GetProperty("templateId").GetGuid(); - var publishResponse = await Client.PostAsync($"/api/v1/permission-templates/{templateId}/publish", content: null, TestContext.Current.CancellationToken); + // G-014 (residual): publicar una plantilla exige al menos un ítem de permiso + // (PermissionTemplate.Publish → DomainErrors.Authorization.TemplateItemsRequired). El round-trip + // original nunca añadía ítems; con el rojo previo de AUTH_015 corregido, la publicación de una + // plantilla vacía devolvía 400. Se añade un ítem allow válido antes de publicar. TargetId/ActionId + // no tienen FK (solo TemplateId la tiene), así que basta con GUIDs; TargetType es un display name + // válido de ExclusiveArcTarget ("SystemSuite"). + var addItem = await Client.PostAsJsonAsync($"/api/v1/permission-templates/{templateId}/items", new + { + targetType = "SystemSuite", + targetId = systemSuiteId, + actionId = Guid.NewGuid(), + isAllowed = true, + isDenied = false + }, ct); + addItem.StatusCode.Should().Be(HttpStatusCode.Created); + + var publishResponse = await Client.PostAsync($"/api/v1/permission-templates/{templateId}/publish", content: null, ct); publishResponse.StatusCode.Should().Be(HttpStatusCode.NoContent); - var getResponse = await Client.GetAsync($"/api/v1/permission-templates/{templateId}", TestContext.Current.CancellationToken); + var getResponse = await Client.GetAsync($"/api/v1/permission-templates/{templateId}", ct); getResponse.StatusCode.Should().Be(HttpStatusCode.OK); - using var getPayload = JsonDocument.Parse(await getResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + using var getPayload = JsonDocument.Parse(await getResponse.Content.ReadAsStringAsync(ct)); getPayload.RootElement.GetProperty("templateId").GetGuid().Should().Be(templateId); - getPayload.RootElement.GetProperty("tenantId").GetGuid().Should().Be(TenantId); + getPayload.RootElement.GetProperty("tenantId").GetGuid().Should().Be(tenantId); getPayload.RootElement.GetProperty("roleId").GetGuid().Should().Be(roleId); getPayload.RootElement.GetProperty("systemSuiteId").GetGuid().Should().Be(systemSuiteId); getPayload.RootElement.GetProperty("status").GetString().Should().Be("Published"); + + // G-140: dar de alta una SEGUNDA plantilla para la misma terna (tenant, rol, suite) —ya + // plantillada por la anterior— debe generar una versión nueva (0.2.0) en lugar de colisionar + // con IX_PermissionTemplates_TenantId_RoleId_SystemSuiteId_Version → 500. La primera quedó en + // 0.1.0; la segunda toma la siguiente. + var createSecond = await Client.PostAsJsonAsync("/api/v1/permission-templates", new + { + tenantId, + roleId, + systemSuiteId + }, ct); + createSecond.StatusCode.Should().Be(HttpStatusCode.Created); + + using var secondPayload = JsonDocument.Parse(await createSecond.Content.ReadAsStringAsync(ct)); + var secondTemplateId = secondPayload.RootElement.GetProperty("templateId").GetGuid(); + secondTemplateId.Should().NotBe(templateId); + + var getSecond = await Client.GetAsync($"/api/v1/permission-templates/{secondTemplateId}", ct); + getSecond.StatusCode.Should().Be(HttpStatusCode.OK); + using var getSecondPayload = JsonDocument.Parse(await getSecond.Content.ReadAsStringAsync(ct)); + getSecondPayload.RootElement.GetProperty("version").GetString().Should().Be("0.2.0"); } } diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/PostgreSqlContainerFixture.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/PostgreSqlContainerFixture.cs index 666de69d..03035f12 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/PostgreSqlContainerFixture.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/PostgreSqlContainerFixture.cs @@ -44,19 +44,19 @@ public async ValueTask InitializeAsync() ConnectionString = _container.GetConnectionString(); - // Bootstrap the UMS platform schema using the same bootstrapper as production. + // Bootstrap the UMS platform schema via EF Core migrations, as production does. var options = new DbContextOptionsBuilder() .UseNpgsql(ConnectionString, sql => sql.EnableRetryOnFailure(3)) .Options; - await using var ctx = new UmsPlatformDbContext(options, new SystemTenantContext(), new Moq.Mock().Object); - await PostgreSqlSchemaBootstrapper.InitializeAsync(ctx, new PostgreSqlDistributedLockProvider()); + await using var ctx = new UmsPlatformDbContext(options, new SystemTenantContext(), new Moq.Mock().Object, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + await ctx.Database.MigrateAsync(); IsAvailable = true; } catch (Exception ex) { - System.IO.File.WriteAllText("testcontainers-error.log", "Testcontainers failed: " + ex.ToString()); + await System.IO.File.WriteAllTextAsync("testcontainers-error.log", "Testcontainers failed: " + ex.ToString()); IsAvailable = false; throw; // Fail the test run immediately if the DB cannot start } diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/PostgreSqlUserAccountRepositoryTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/PostgreSqlUserAccountRepositoryTests.cs index fa950310..e7cbf87e 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/PostgreSqlUserAccountRepositoryTests.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/PostgreSqlUserAccountRepositoryTests.cs @@ -102,7 +102,7 @@ await context.UserAccounts.AddRangeAsync( private static UmsPlatformDbContext CreateContext(DbContextOptions options, Guid tenantId) { var tenantContext = new TestTenantContext(tenantId); - return new UmsPlatformDbContext(options, tenantContext, new Moq.Mock().Object); + return new UmsPlatformDbContext(options, tenantContext, new Moq.Mock().Object, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); } private sealed class TestTenantContext(Guid tenantId) : Ums.Application.Common.Interfaces.ITenantContext diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/PostgreSqlWebApplicationFactory.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/PostgreSqlWebApplicationFactory.cs index a967af57..f84a1732 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/PostgreSqlWebApplicationFactory.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/PostgreSqlWebApplicationFactory.cs @@ -2,10 +2,15 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; +using Ums.Infrastructure.MasterData; +using Ums.Infrastructure.Persistence; using Ums.Infrastructure.Persistence.Options; +using Ums.ReadModels; using Ums.Presentation; using Ums.Domain.Configuration; using Ums.Domain.Approvals; @@ -71,17 +76,80 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) options.DefaultChallengeScheme = "Test"; }) .AddScheme("Test", options => { }); + + // G-014 (causa b) — CAUSA REAL: el UmsPlatformDbContext (el sistema bajo prueba) acababa + // conectando a la cadena por defecto de appsettings (Host=localhost;Database=UmsDev), NO a la + // del Testcontainer → "Connection refused" y arranque del host roto (IntegrationTestBase cae a + // un HttpClient vacío ⇒ TODA la clase E2E/PostgreSQL falla). Motivo: AddInfrastructure captura + // ConnectionStrings:DefaultConnection en tiempo de REGISTRO, y el override de este factory + // (ConfigureAppConfiguration) aún no es visible entonces —mismo pitfall de timing que la causa + // (a)—. El host InMemory lo evita re-registrando el contexto; el host PostgreSQL nunca hizo el + // equivalente para la cadena. Fix: re-registrar UmsPlatformDbContext y ReadModelDbContext + // apuntando al Testcontainer real (cuya cadena es el parámetro `connectionString`, ya migrado + // por PostgreSqlContainerFixture). Se re-registra sin interceptores, igual que el host InMemory. + ReplaceNpgsqlDbContext(services, connectionString); + ReplaceNpgsqlDbContext(services, connectionString); + + // Defensa: el TenantProjectionConsumer (registrado SIEMPRE por AddConsumers) consume los + // TenantEvent que publican los E2E y escribe en TenantProjectionDbContext, que en este host + // conserva su fallback Npgsql a localhost:5432 (no hay MasterDataDb). Una vez que el fix de + // arriba revive el flujo E2E, esas publicaciones dispararían el consumer contra localhost:5432. + // Se apunta la proyección MMS a InMemory (el consumer escribe ahí, inofensivo). + services.RemoveAll(); + services.RemoveAll>(); + services.RemoveAll>(); + services.AddDbContext(options => options.UseInMemoryDatabase("TestProjectionDb")); }); } protected override IHost CreateHost(IHostBuilder builder) { var host = base.CreateHost(builder); - // Seed the database with required configuration and approval aggregates using the real DbContext + // G-014 (causa b, capa 3 — aislamiento): el Testcontainer es de colección (una sola BD para + // todos los tests) y no se reseteaba entre pruebas. Como cada test crea su propio host aquí, + // truncamos toda la data ANTES de re-sembrar la línea base, dando a cada test una pizarra limpia + // y eliminando los choques de clave duplicada (los tests E2E crean entidades con IDs/constraints + // fijos que colisionaban con las del test anterior). La colección xUnit serializa sus tests, así + // que no hay carreras sobre la BD compartida. + ResetDatabase(host.Services); PostgresTestSeeder.SeedConfigurationAggregates(host.Services); PostgresTestSeeder.SeedApprovalAggregates(host.Services); return host; } + + // Vacía toda la data del contenedor (TRUNCATE ... CASCADE de cada tabla, preservando el historial de + // migraciones) para aislar cada test. Dinámico: no hay que mantener la lista de tablas a mano. + private static void ResetDatabase(IServiceProvider services) + { + using var scope = services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.ExecuteSqlRaw(@" + DO $$ + DECLARE r RECORD; + BEGIN + FOR r IN ( + SELECT schemaname, tablename + FROM pg_tables + WHERE schemaname NOT IN ('pg_catalog', 'information_schema') + AND tablename <> '__EFMigrationsHistory' + ) + LOOP + EXECUTE 'TRUNCATE TABLE ' || quote_ident(r.schemaname) || '.' || quote_ident(r.tablename) || ' RESTART IDENTITY CASCADE'; + END LOOP; + END $$;"); + } + + // Retira el registro de que dejó AddInfrastructure (contexto, + // DbContextOptions y la configuración de opciones específica del proveedor —EF Core 9+—, cuya + // cadena de conexión se capturó de appsettings) y lo re-registra sobre el Npgsql del Testcontainer. + private static void ReplaceNpgsqlDbContext(IServiceCollection services, string connectionString) + where TContext : DbContext + { + services.RemoveAll(); + services.RemoveAll>(); + services.RemoveAll>(); + services.AddDbContext(options => options.UseNpgsql(connectionString, sql => sql.EnableRetryOnFailure(3))); + } } // Shared seeder for PostgreSQL test container – uses EfCore DbContext directly @@ -185,11 +253,18 @@ public static void SeedConfigurationAggregates(IServiceProvider services) public static void SeedApprovalAggregates(IServiceProvider services) { using var scope = services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); + // G-014 (causa b, capa 2): appsettings activa UsePostgreSqlApprovalsStores=true, así que en este + // host los approvals SON PostgreSQL. El sembrado hacía db.Set() sobre el + // UmsPlatformDbContext, pero ApprovalWorkflow es el AGREGADO DE DOMINIO —no está mapeado en el + // modelo (el contexto mapea ApprovalWorkflowRecord)— y EF lanzaba "Cannot create a DbSet for + // 'ApprovalWorkflow' ..." abortando el arranque del host y hundiendo las ~73 clases E2E de la + // colección. Se siembra vía el repositorio de approvals (que mapea el agregado a + // ApprovalWorkflowRecord y persiste vía su UnitOfWork sobre el Testcontainer real). + var workflowRepository = scope.ServiceProvider.GetRequiredService(); var actor = ActorId.Create("00000000-0000-0000-0000-000000000111"); var tenantId = TenantId.Load(Guid.Parse("3fa85f64-5717-4562-b3fc-2c963f66afa6")); - if (db.Set().Any()) + if (workflowRepository.GetAllAsync().GetAwaiter().GetResult().Count > 0) return; var manualWorkflow = ApprovalWorkflow.Create( @@ -203,7 +278,7 @@ public static void SeedApprovalAggregates(IServiceProvider services) actor, requiredDocumentCount: 1).Value; SetAggregateId(manualWorkflow.Props, Guid.Parse("88888888-1111-1111-1111-111111111111")); - db.Set().Add(manualWorkflow); + workflowRepository.AddAsync(manualWorkflow).GetAwaiter().GetResult(); var autoWorkflow = ApprovalWorkflow.Create( tenantId, @@ -215,9 +290,9 @@ public static void SeedApprovalAggregates(IServiceProvider services) null, actor).Value; SetAggregateId(autoWorkflow.Props, Guid.Parse("88888888-2222-2222-2222-222222222222")); - db.Set().Add(autoWorkflow); + workflowRepository.AddAsync(autoWorkflow).GetAwaiter().GetResult(); - db.SaveChanges(); + workflowRepository.UnitOfWork.SaveChangesAsync().GetAwaiter().GetResult(); } private static void SetAggregateId(object props, Guid id) diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/RequestContextAccessorTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/RequestContextAccessorTests.cs deleted file mode 100644 index af43ca82..00000000 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/RequestContextAccessorTests.cs +++ /dev/null @@ -1,57 +0,0 @@ -using Ums.Infrastructure.Services; - -namespace Ums.Presentation.IntegrationTest.Infrastructure; - -public sealed class RequestContextAccessorTests -{ - [Fact] - public void Set_ShouldExposeSnapshotThroughRequestContextProperties() - { - var accessor = new RequestContextAccessor(); - - accessor.Set(new ExecutionContextSnapshot( - CorrelationId: "corr-123", - SessionTrackingId: "session-123", - TraceId: "trace-123", - SpanId: "span-123")); - - accessor.CorrelationId.Should().Be("corr-123"); - accessor.SessionTrackingId.Should().Be("session-123"); - accessor.TraceId.Should().Be("trace-123"); - accessor.SpanId.Should().Be("span-123"); - } - - [Fact] - public void Set_WithEmptySnapshot_ShouldReturnNullProperties() - { - var accessor = new RequestContextAccessor(); - - accessor.Set(ExecutionContextSnapshot.Empty); - - accessor.CorrelationId.Should().BeNull(); - accessor.SessionTrackingId.Should().BeNull(); - accessor.TraceId.Should().BeNull(); - accessor.SpanId.Should().BeNull(); - } - - [Fact] - public void SetClientTimezone_ShouldExposeThroughClientTimezoneProperty() - { - var accessor = new RequestContextAccessor(); - - accessor.SetClientTimezone("America/Lima"); - - accessor.ClientTimezone.Should().Be("America/Lima"); - } - - [Fact] - public void SetClientTimezone_WithNull_ShouldReturnNull() - { - var accessor = new RequestContextAccessor(); - accessor.SetClientTimezone("America/Lima"); - - accessor.SetClientTimezone(null); - - accessor.ClientTimezone.Should().BeNull(); - } -} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/RequestContextTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/RequestContextTests.cs new file mode 100644 index 00000000..e52fabf0 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/RequestContextTests.cs @@ -0,0 +1,82 @@ +using System.Diagnostics; +using Ums.Infrastructure.Observability; + +namespace Ums.Presentation.IntegrationTest.Infrastructure; + +/// +/// Verifica el tras la unificación W3C (ADR-0046): TraceId/SpanId +/// se derivan de y la correlación es el trace_id (no un GUID propio). +/// +public sealed class RequestContextTests +{ + [Fact] + public void SetSessionTrackingId_ShouldExposeThroughProperty() + { + var context = new RequestContext(); + + context.SetSessionTrackingId("session-123"); + + context.SessionTrackingId.Should().Be("session-123"); + } + + [Fact] + public void SetSessionTrackingId_WithWhitespace_ShouldReturnNull() + { + var context = new RequestContext(); + + context.SetSessionTrackingId(" "); + + context.SessionTrackingId.Should().BeNull(); + } + + [Fact] + public void TraceAndSpan_ShouldDeriveFromCurrentW3CActivity() + { + var context = new RequestContext(); + + Activity.DefaultIdFormat = ActivityIdFormat.W3C; + Activity.ForceDefaultIdFormat = true; + using var activity = new Activity("test-request"); + activity.SetIdFormat(ActivityIdFormat.W3C); + activity.Start(); + + context.TraceId.Should().Be(activity.TraceId.ToString()); + context.SpanId.Should().Be(activity.SpanId.ToString()); + // Unificación W3C: la correlación es el trace_id. + context.CorrelationId.Should().Be(activity.TraceId.ToString()); + } + + [Fact] + public void TraceAndSpan_WithoutActivity_ShouldReturnNull() + { + var context = new RequestContext(); + + // Sin Activity actual no hay contexto de traza W3C. + Activity.Current = null; + + context.TraceId.Should().BeNull(); + context.SpanId.Should().BeNull(); + context.CorrelationId.Should().BeNull(); + } + + [Fact] + public void SetClientTimezone_ShouldExposeThroughClientTimezoneProperty() + { + var context = new RequestContext(); + + context.SetClientTimezone("America/Lima"); + + context.ClientTimezone.Should().Be("America/Lima"); + } + + [Fact] + public void SetClientTimezone_WithNull_ShouldReturnNull() + { + var context = new RequestContext(); + context.SetClientTimezone("America/Lima"); + + context.SetClientTimezone(null); + + context.ClientTimezone.Should().BeNull(); + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/SqliteSchemaBootstrapperTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/SqliteSchemaBootstrapperTests.cs deleted file mode 100644 index acd5da96..00000000 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/SqliteSchemaBootstrapperTests.cs +++ /dev/null @@ -1,162 +0,0 @@ -using System.Reflection; -using FluentAssertions; -using Microsoft.Data.Sqlite; -using Microsoft.EntityFrameworkCore; -using Ums.Infrastructure.Persistence; - -namespace Ums.Presentation.IntegrationTest.Infrastructure; - -public sealed class SqliteSchemaBootstrapperTests -{ - [Fact] - public async Task InitializeAsync_WhenTenantsTableMissesManagementOwnerColumn_AddsItWithoutFailing() - { - var ct = TestContext.Current.CancellationToken; - - await using var connection = new SqliteConnection("Data Source=file:ums-bootstrap-test?mode=memory&cache=shared"); - await connection.OpenAsync(ct); - - await using (var setup = connection.CreateCommand()) - { - setup.CommandText = """ - CREATE TABLE IF NOT EXISTS "Tenants" ( - "Id" TEXT NOT NULL CONSTRAINT "PK_Tenants" PRIMARY KEY, - "Code" TEXT NOT NULL, - "Name" TEXT NOT NULL, - "StatusId" INTEGER NOT NULL, - "CreatedBy" TEXT NOT NULL, - "CreatedAtUtc" TEXT NOT NULL, - "AuditTimeSpan" TEXT NOT NULL, - "IsDeleted" INTEGER NOT NULL DEFAULT 0 - ); - """; - await setup.ExecuteNonQueryAsync(ct); - } - - var options = new DbContextOptionsBuilder() - .UseSqlite(connection) - .Options; - - await using var context = new UmsPlatformDbContext(options, new SystemTenantContext(), new Moq.Mock().Object); - - var bootstrapper = typeof(SqliteSchemaBootstrapper) - .GetMethod("EnsureTenantManagementOwnerColumnAsync", BindingFlags.NonPublic | BindingFlags.Static) - ?? throw new InvalidOperationException("Bootstrapper helper method was not found."); - - var task = (Task)bootstrapper.Invoke(null, new object[] { context, TestContext.Current.CancellationToken })!; - await task; - - var columnExists = await ColumnExistsAsync(connection, "Tenants", "IsManagementOwner"); - columnExists.Should().BeTrue(); - } - - [Fact] - public async Task InitializeAsync_OnFreshDatabase_CreatesTenantManagementOwnerColumn() - { - var ct = TestContext.Current.CancellationToken; - - await using var connection = new SqliteConnection("Data Source=file:ums-bootstrap-fresh?mode=memory&cache=shared"); - await connection.OpenAsync(ct); - - var options = new DbContextOptionsBuilder() - .UseSqlite(connection) - .Options; - - await using var context = new UmsPlatformDbContext(options, new SystemTenantContext(), new Moq.Mock().Object); - - await SqliteSchemaBootstrapper.InitializeAsync(context, ct); - - var columnExists = await ColumnExistsAsync(connection, "Tenants", "IsManagementOwner"); - columnExists.Should().BeTrue(); - } - - [Fact] - public async Task InitializeAsync_WhenInternalAdminTenantExistsWithFalseFlag_RepairsItToTrue() - { - var ct = TestContext.Current.CancellationToken; - - await using var connection = new SqliteConnection("Data Source=file:ums-bootstrap-repair?mode=memory&cache=shared"); - await connection.OpenAsync(ct); - - await using (var setup = connection.CreateCommand()) - { - setup.CommandText = """ - CREATE TABLE IF NOT EXISTS "Tenants" ( - "Id" TEXT NOT NULL CONSTRAINT "PK_Tenants" PRIMARY KEY, - "Code" TEXT NOT NULL, - "Name" TEXT NOT NULL, - "StatusId" INTEGER NOT NULL, - "CreatedBy" TEXT NOT NULL, - "CreatedAtUtc" TEXT NOT NULL, - "AuditTimeSpan" TEXT NOT NULL, - "IsDeleted" INTEGER NOT NULL DEFAULT 0, - "IsManagementOwner" INTEGER NOT NULL DEFAULT 0 - ); - INSERT INTO "Tenants" ( - "Id", "Code", "Name", "StatusId", "CreatedBy", "CreatedAtUtc", "AuditTimeSpan", "IsDeleted", "IsManagementOwner" - ) VALUES ( - '11111111-1111-1111-1111-111111111111', - 'INTERNAL_ADMIN', - 'Internal Admin Tenant', - 1, - '00000000-0000-0000-0000-000000000001', - '2026-06-02T00:00:00Z', - '0:00:00', - 0, - 0 - ); - """; - await setup.ExecuteNonQueryAsync(ct); - } - - var options = new DbContextOptionsBuilder() - .UseSqlite(connection) - .Options; - - await using var context = new UmsPlatformDbContext(options, new SystemTenantContext(), new Moq.Mock().Object); - - await SqliteSchemaBootstrapper.InitializeAsync(context, ct); - - await using var verify = connection.CreateCommand(); - verify.CommandText = """ - SELECT "IsManagementOwner" - FROM "Tenants" - WHERE upper("Code") = 'INTERNAL_ADMIN' - LIMIT 1; - """; - - var value = await verify.ExecuteScalarAsync(ct); - value.Should().NotBeNull(); - Convert.ToInt32(value).Should().Be(1); - } - - private static async Task ColumnExistsAsync( - SqliteConnection connection, - string tableName, - string columnName) - { - var ct = TestContext.Current.CancellationToken; - - await using var command = connection.CreateCommand(); - command.CommandText = $""" - SELECT 1 - FROM pragma_table_info('{tableName}') - WHERE name = '{columnName}' - LIMIT 1; - """; - - var result = await command.ExecuteScalarAsync(ct); - return result is not null; - } - - private sealed class SystemTenantContext : Ums.Application.Common.Interfaces.ITenantContext - { - public Guid? OrganizationId => null; - public Guid? OriginalTenantId => null; - public bool IsInternalAdmin => true; - public void Initialize(Guid userTenantId, bool isInternalAdmin) { } - public void SetOrganizationId(Guid organizationId) { } - public void EnableCrossTenantAccess() { } - public void DisableCrossTenantAccess() { } - } -} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/TestAuthHandler.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/TestAuthHandler.cs index 2ea68f11..9bfaf149 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/TestAuthHandler.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/TestAuthHandler.cs @@ -25,9 +25,20 @@ protected override Task HandleAuthenticateAsync() tenantId = tenantHeader.ToString(); } + // Override opcional del actor autenticado (NameIdentifier). Aditivo y retrocompatible: + // por defecto se mantiene el actor histórico "…0111"; sólo las pruebas que necesitan + // varios actores distintos (p. ej. la segregación de funciones del happy-path IGA, G-052) + // envían este encabezado para conmutar la identidad por petición. + var actorId = "00000000-0000-0000-0000-000000000111"; + if (Request.Headers.TryGetValue("X-Test-Actor-Id", out var actorHeader) + && Guid.TryParse(actorHeader.ToString(), out _)) + { + actorId = actorHeader.ToString(); + } + var claims = new[] { - new Claim(ClaimTypes.NameIdentifier, "00000000-0000-0000-0000-000000000111"), + new Claim(ClaimTypes.NameIdentifier, actorId), new Claim("tenant_id", tenantId), new Claim("org_id", tenantId), new Claim("is_internal_admin", "true"), diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/UmsApiServiceBootstrappersTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/UmsApiServiceBootstrappersTests.cs index 0b161f2b..f09e3097 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/UmsApiServiceBootstrappersTests.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/UmsApiServiceBootstrappersTests.cs @@ -36,10 +36,37 @@ public void AddUmsApiServiceBootstrappers_ShouldRegisterPlatformPolicies() services.AddUmsApiServiceBootstrappers(configuration, new FakeHostEnvironment()); - services.Should().Contain(descriptor => descriptor.ServiceType == typeof(IConfigureOptions)); + // G-248: ya NO se registra `RateLimiterOptions`. El limitador de ASP.NET era en proceso + // —con N réplicas el cupo efectivo era N veces el declarado— y corría antes de autenticar, + // así que repartía por IP y dos usuarios distintos compartían cupo. Lo sustituye + // `LimiteDePeticionesMiddleware` sobre `ILimitadorDePeticiones`, que se registra en la capa + // de infraestructura junto al resto del estado compartido. Se comprueba su AUSENCIA: si + // alguien vuelve a añadir `AddRateLimiter`, habrá dos limitadores contando lo mismo. + services.Should().NotContain(descriptor => descriptor.ServiceType == typeof(IConfigureOptions)); services.Should().Contain(descriptor => descriptor.ServiceType == typeof(IConfigureOptions)); } + [Fact] + public void AddUmsApiServiceBootstrappers_SinSecretoDeFirma_FallaAlComponer() + { + // G-191: UMS firma y valida sus propios tokens en HS256. Un host sin `Jwt:Secret` no puede + // autenticar a ningún satélite, así que la composición falla en el arranque en vez de + // arrancar «a medias» y descubrirlo en la primera petición. + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["AllowedOrigins"] = "https://localhost:3000", + ["Persistence:Provider"] = "InMemory", + }) + .Build(); + + var componer = () => services.AddUmsApiServiceBootstrappers(configuration, new FakeHostEnvironment()); + + componer.Should().Throw() + .WithMessage("*Jwt:Secret*"); + } + private static IConfiguration CreateConfiguration() { return new ConfigurationBuilder() @@ -47,6 +74,8 @@ private static IConfiguration CreateConfiguration() { ["AllowedOrigins"] = "https://localhost:3000", ["Persistence:Provider"] = "InMemory", + // G-191: la autenticación por portador exige el secreto de firma para componerse. + ["Jwt:Secret"] = "SECRETO_DE_PRUEBA_HS256_CON_MAS_DE_32_CARACTERES", }) .Build(); } diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/UmsApiWebApplicationFactory.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/UmsApiWebApplicationFactory.cs index 3bc5b5b1..bed3b63d 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/UmsApiWebApplicationFactory.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/UmsApiWebApplicationFactory.cs @@ -1,12 +1,13 @@ -using MassTransit; using Microsoft.AspNetCore.Hosting; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Hosting; using Ums.Domain.Audit.AuditRecord; using Ums.Domain.Approvals; using Ums.Domain.Enums; +using Ums.Infrastructure.MasterData; using Ums.Infrastructure.Persistence; using Ums.Infrastructure.Persistence.Audit; using Ums.Infrastructure.Persistence.Options; @@ -16,19 +17,12 @@ namespace Ums.Presentation.IntegrationTest.Infrastructure; public sealed class UmsApiWebApplicationFactory : WebApplicationFactory { - static UmsApiWebApplicationFactory() - { -// InMemory overrides removed; test will use configuration from appsettings (e.g., PostgreSQL) - Environment.SetEnvironmentVariable("Persistence__Provider", "InMemory"); - Environment.SetEnvironmentVariable("Persistence__AggregateStoreMode", "InMemory"); - Environment.SetEnvironmentVariable("Persistence__UsePostgreSqlIdentityStores", "false"); - Environment.SetEnvironmentVariable("Persistence__UsePostgreSqlAuthorizationStores", "false"); - Environment.SetEnvironmentVariable("Persistence__UsePostgreSqlConfigurationStores", "false"); - Environment.SetEnvironmentVariable("Persistence__SeedDevData", "true"); - Environment.SetEnvironmentVariable("Persistence__EnableOutbox", "false"); - Environment.SetEnvironmentVariable("Persistence__InitializePlatformStoreOnStartup", "false"); - } - + // Sin overrides globales: cada host de test recibe su configuración de forma + // AISLADA vía ConfigureAppConfiguration (más abajo). Antes, un constructor + // estático fijaba variables de entorno del PROCESO (`Persistence__Provider= + // InMemory`) que se filtraban a los hosts de PostgreSqlWebApplicationFactory y + // dejaban su UmsPlatformDbContext sin registrar — la causa raíz de ~80 fallos + // de integración (G-014). protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.UseEnvironment("Development"); @@ -58,22 +52,40 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) builder.ConfigureServices(services => { - services.RemoveAll(); - services.AddMassTransitTestHarness(); - services.AddDbContext((serviceProvider, options) => - { - var cfg = serviceProvider.GetRequiredService(); - var provider = cfg["Persistence:Provider"]; - if (provider == PersistenceProvider.InMemory.ToString()) - { - options.UseInMemoryDatabase("TestDb"); - } - else - { - var connStr = cfg.GetConnectionString("DefaultConnection") ?? cfg["ConnectionStrings:DefaultConnection"]; - options.UseNpgsql(connStr, sql => sql.EnableRetryOnFailure(3)); - } - }); + // G-014 (causa a, 2.ª capa): antes se añadía aquí AddMassTransitTestHarness(), que registra + // un SEGUNDO bus de MassTransit y sustituye al de producción SIN su ConfigureJsonSerializerOptions + // (DependencyInjection.ConfigurePayload). Ese saneamiento elimina la propiedad IMetadata + // (BeyondNetCode.Shell.Ddd) del payload; sin él, publicar CUALQUIER evento de dominio lanza + // SerializationException ("Exception creating proxy ... IMetadata ... does not have an + // implementation"), lo que hacía fallar los seeders y devolver 400 en todo endpoint que emite + // eventos —incluido el flujo IGA (G-052)—. Ningún test consume ITestHarness, y la factoría de + // PostgreSQL ya opera sin arnés sobre el bus in-memory de producción; se retira el arnés para + // que el host InMemory use ese mismo bus (con ConfigurePayload) y la publicación no rompa. + + // G-014 (causa a, 1.ª capa): en el host InMemory coexistían DOS proveedores EF Core en el mismo + // ServiceProvider —el InMemory de UmsPlatformDbContext (que registra esta factoría) y el + // Npgsql que AddInfrastructure registra SIEMPRE, de forma incondicional, para + // TenantProjectionDbContext (proyección MMS, ADR-0083/ADR-0107)—. EF Core lo detecta al + // materializar cualquier DbContext y lanza "Only a single database provider can be + // registered", lo que ABORTA todos los seeders (RunSeederAsync traga la excepción) y deja + // la base sin datos semilla: de ahí la cascada de fallos, incluido el E2E de IGA (G-052) + // que depende del seed de RoleMaturityStatus. + // + // Fix (solo test-infra): se retira el conjunto COMPLETO de descriptores de cada DbContext + // —opciones genéricas, la configuración de opciones específica del proveedor (EF Core 9+) y + // el propio contexto— y se re-registran AMBOS sobre InMemory, de modo que NO quede ningún + // servicio del proveedor Npgsql en el contenedor cuando el host es InMemory. + ReplaceDbContextWithInMemory(services, "TestDb"); + ReplaceDbContextWithInMemory(services, "TestProjectionDb"); + // G-014 (causa raíz del CUELGUE en CI): el `ReadModelDbContext` (proyecciones de fase 1) + // seguía registrado con Npgsql apuntando a `127.0.0.1:5433` (appsettings.Development). En + // el host InMemory, cualquier proyección (p. ej. `PermissionTemplatePublishedEvent`) hacía + // que ese contexto intentara CONECTAR a PostgreSQL: en máquinas con un Postgres en 5433 el + // fallo se enmascaraba, pero en un entorno limpio (runner de CI, contenedor) da «Connection + // refused» y, con `EnableRetryOnFailure(3)`, se cuelga en reintentos. Se re-registra también + // sobre InMemory para que el host InMemory NO toque PostgreSQL en absoluto. + ReplaceDbContextWithInMemory(services, "TestReadModelDb"); + services.RemoveAll(); services.RemoveAll(); services.RemoveAll(); @@ -133,9 +145,40 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(); + + // G-014 (residual): el health check "postgresql" (AddInfrastructureHealthChecks) se + // registra según el Provider capturado al construir los servicios y no ve el override + // InMemory de esta factoría (mismo pitfall de timing que la causa b.1). En el host + // InMemory no hay PostgreSQL a la escucha en 127.0.0.1:5432, así que ese check queda + // Unhealthy y /health responde 503, rompiendo HealthEndpointTests y los de + // SessionTrackingMiddleware (que consultan /health). Se retira el registro del check + // postgresql —análogo al retiro de los DbContext Npgsql— para que /health refleje solo + // los checks efectivamente aplicables al host InMemory. + services.PostConfigure(options => + { + var postgresqlCheck = options.Registrations + .FirstOrDefault(registration => registration.Name == "postgresql"); + if (postgresqlCheck is not null) + { + options.Registrations.Remove(postgresqlCheck); + } + }); }); } + // Retira todo lo que dejó un AddDbContext previo (incluida la configuración de opciones + // específica del proveedor — IDbContextOptionsConfiguration, EF Core 9+) y re-registra el + // contexto sobre InMemory. Así el proveedor anterior (p. ej. Npgsql) deja de estar presente y no + // colisiona con InMemory en el mismo ServiceProvider (G-014, causa a). + private static void ReplaceDbContextWithInMemory(IServiceCollection services, string databaseName) + where TContext : DbContext + { + services.RemoveAll(); + services.RemoveAll>(); + services.RemoveAll>(); + services.AddDbContext(options => options.UseInMemoryDatabase(databaseName)); + } + protected override IHost CreateHost(IHostBuilder builder) { var host = base.CreateHost(builder); diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/UmsSerilogLoggerTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/UmsSerilogLoggerTests.cs index 88f723a3..4cbf04c5 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/UmsSerilogLoggerTests.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/UmsSerilogLoggerTests.cs @@ -1,6 +1,7 @@ +using System.Diagnostics; using Microsoft.Extensions.Logging; using Ums.Infrastructure.Aop; -using Ums.Infrastructure.Services; +using Ums.Infrastructure.Observability; using BeyondNetCode.Shell.Aop; namespace Ums.Presentation.IntegrationTest.Infrastructure; @@ -20,14 +21,11 @@ public void OnEntry_ShouldEmitFullObservabilityEnvelope() builder.AddProvider(provider); }); - var executionContext = new RequestContextAccessor(); - executionContext.Set(new ExecutionContextSnapshot( - CorrelationId: "corr-001", - SessionTrackingId: "session-001", - TraceId: "trace-001", - SpanId: "span-001")); + using var activity = StartW3CActivity(); + var requestContext = new RequestContext(); + requestContext.SetSessionTrackingId("session-001"); - var logger = new UmsSerilogLogger(loggerFactory, new StubUserContext("tenant-001"), executionContext); + var logger = new UmsSerilogLogger(loggerFactory, new StubUserContext("tenant-001"), requestContext); logger.OnEntry( CreateJoinPoint(), @@ -39,10 +37,11 @@ public void OnEntry_ShouldEmitFullObservabilityEnvelope() entry.Level.Should().Be(LogLevel.Information); entry.Properties["TenantId"].Should().Be("tenant-001"); - entry.Properties["CorrelationId"].Should().Be("corr-001"); entry.Properties["SessionTrackingId"].Should().Be("session-001"); - entry.Properties["TraceId"].Should().Be("trace-001"); - entry.Properties["SpanId"].Should().Be("span-001"); + entry.Properties["TraceId"].Should().Be(activity.TraceId.ToString()); + entry.Properties["SpanId"].Should().Be(activity.SpanId.ToString()); + // Unificación W3C: la correlación es el trace_id (no un GUID propio). + entry.Properties["CorrelationId"].Should().Be(activity.TraceId.ToString()); entry.Properties["BoundedContext"].Should().Be("Configuration"); } @@ -56,14 +55,11 @@ public void OnExit_ShouldKeepSessionAndTraceFields() builder.AddProvider(provider); }); - var executionContext = new RequestContextAccessor(); - executionContext.Set(new ExecutionContextSnapshot( - CorrelationId: "corr-002", - SessionTrackingId: "session-002", - TraceId: "trace-002", - SpanId: "span-002")); + using var activity = StartW3CActivity(); + var requestContext = new RequestContext(); + requestContext.SetSessionTrackingId("session-002"); - var logger = new UmsSerilogLogger(loggerFactory, new StubUserContext("tenant-002"), executionContext); + var logger = new UmsSerilogLogger(loggerFactory, new StubUserContext("tenant-002"), requestContext); logger.OnExit(CreateJoinPoint(), requestId: string.Empty, duration: 42L); @@ -71,10 +67,20 @@ public void OnExit_ShouldKeepSessionAndTraceFields() var entry = provider.Entries.Single(); entry.Properties["TenantId"].Should().Be("tenant-002"); - entry.Properties["CorrelationId"].Should().Be("corr-002"); entry.Properties["SessionTrackingId"].Should().Be("session-002"); - entry.Properties["TraceId"].Should().Be("trace-002"); - entry.Properties["SpanId"].Should().Be("span-002"); + entry.Properties["TraceId"].Should().Be(activity.TraceId.ToString()); + entry.Properties["SpanId"].Should().Be(activity.SpanId.ToString()); + entry.Properties["CorrelationId"].Should().Be(activity.TraceId.ToString()); + } + + private static Activity StartW3CActivity() + { + Activity.DefaultIdFormat = ActivityIdFormat.W3C; + Activity.ForceDefaultIdFormat = true; + var activity = new Activity("test-request"); + activity.SetIdFormat(ActivityIdFormat.W3C); + activity.Start(); + return activity; } private static IJoinPoint CreateJoinPoint() @@ -143,7 +149,7 @@ private sealed class StubUserContext(string? tenantId) : IUserContext public string? TenantId => tenantId; public bool IsAuthenticated => true; - + public bool HasPermission(string permission) => true; } } diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Presentation/DomainErrorStatusMapperTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Presentation/DomainErrorStatusMapperTests.cs new file mode 100644 index 00000000..bee32fa0 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Presentation/DomainErrorStatusMapperTests.cs @@ -0,0 +1,89 @@ +using Ums.Domain.Kernel; +using Ums.Presentation.Extensions; + +namespace Ums.Presentation.IntegrationTest.Presentation; + +/// +/// G-104 (patrón latente de G-100). Fija, sobre la función pura , +/// la decisión caso-por-caso de la semántica HTTP de los códigos de dominio *_not_found: +/// +/// +/// 404 cuando el código direcciona un recurso por id/clave que no existe +/// (recurso apuntado por la URI/segmento de ruta ausente). +/// 400 cuando el código NO direcciona el recurso de la URI, sino que valida +/// una referencia colgante en el CUERPO de un POST de creación (integridad referencial del payload: +/// un FallbackToId/parentResourceId inexistente). El recurso objetivo del POST sí +/// existe/se crea; forzar 404 sería incorrecto. +/// +/// +/// El mapeador es la ÚNICA pieza que cambia el mapeo código→status; probarlo directamente es la +/// evidencia más fiel y determinista del cambio (SD-05), sin depender del arranque del host. +/// +public sealed class DomainErrorStatusMapperTests +{ + // ── 404: «no encontrado por id/clave» ──────────────────────────────────── + // Los tres primeros son los que G-104 cablea; el resto son regresión (G-100 y previos). + [Theory] + [InlineData("configuration.criteria_not_found")] // DomainErrors.Configuration.CriteriaNotFound (G-104) + [InlineData("user_account.mfa_enrollment_not_found")] // DomainErrors.UserAccount.MfaEnrollmentNotFound (G-104) + [InlineData("tenant_parameter.not_found")] // DomainErrors.TenantParameter.NotFound (G-104) + [InlineData("iga.role_promotion_request_not_found")] // DomainErrors.IGA.RolePromotionRequestNotFound (G-100) + [InlineData("common.not_found")] // DomainErrors.Common.NotFound + [InlineData("system_suite.configuration_key_not_found")] // DomainErrors.SystemSuite.ConfigurationKeyNotFound + public void Map_NotFoundByKeyDomainCodes_ShouldReturn404(string errorCode) + { + var (status, _) = DomainErrorStatusMapper.Map(errorCode); + + status.Should().Be(404, "un código de dominio que direcciona un recurso ausente por id/clave debe resolver 404 Not Found"); + } + + // ── 400: referencia colgante en el cuerpo (NO 404) ─────────────────────── + // Excluidos deliberadamente del brazo 404: validan integridad referencial del payload de un POST. + [Theory] + [InlineData("configuration.idp_fallback_not_found")] // DomainErrors.Configuration.IdpFallbackNotFound + [InlineData("authorization.parent_resource_not_found")] // DomainErrors.Authorization.ParentResourceNotFound + public void Map_DanglingReferenceInBodyDomainCodes_ShouldReturn400_NotFound(string errorCode) + { + var (status, _) = DomainErrorStatusMapper.Map(errorCode); + + status.Should().Be(400, "una referencia colgante en el cuerpo de un POST de creación es validación del payload (400), no un recurso de URI ausente (404)"); + } + + // ── 409: guardias de cascada y estados terminales del borrado lógico ───── + // Solo existe borrado lógico. Intentar eliminar algo con referencias VIVAS —o volver a eliminar + // lo ya eliminado— es un conflicto con el estado actual del recurso, no una validación de payload. + // El TenantParameter no tiene endpoint DELETE hoy; el mapeo se fija aquí para que el 409 sea el + // contrato desde el primer día en que se cablee, y no un 400 heredado del brazo por defecto. + [Theory] + [InlineData("TEMPLATE_HAS_ACTIVE_PROFILES")] // DomainErrors.Authorization.TemplateHasActiveProfiles + [InlineData("authorization.template_already_deleted")] // DomainErrors.Authorization.TemplateAlreadyDeleted + [InlineData("TENANT_PARAMETER_HAS_ACTIVE_BINDING")] // DomainErrors.TenantParameter.HasActiveBinding + [InlineData("tenant_parameter.already_deleted")] // DomainErrors.TenantParameter.AlreadyDeleted + public void Map_SoftDeleteGuardCodes_ShouldReturn409(string errorCode) + { + var (status, _) = DomainErrorStatusMapper.Map(errorCode); + + status.Should().Be(409, "una referencia viva que impide el borrado lógico es un conflicto con el estado del recurso"); + } + + // ── Anclas explícitas: los símbolos coinciden con las constantes de dominio ─ + // Evita que un renombrado del literal en DomainErrors deje huérfano el mapeo probado arriba. + [Fact] + public void SoftDeleteGuardConstants_ShouldMatchTheStringsUnderTest() + { + DomainErrors.Authorization.TemplateHasActiveProfiles.Should().Be("TEMPLATE_HAS_ACTIVE_PROFILES"); + DomainErrors.Authorization.TemplateAlreadyDeleted.Should().Be("authorization.template_already_deleted"); + DomainErrors.TenantParameter.HasActiveBinding.Should().Be("TENANT_PARAMETER_HAS_ACTIVE_BINDING"); + DomainErrors.TenantParameter.AlreadyDeleted.Should().Be("tenant_parameter.already_deleted"); + } + + [Fact] + public void DomainErrorConstants_ShouldMatchTheStringsUnderTest() + { + DomainErrors.Configuration.CriteriaNotFound.Should().Be("configuration.criteria_not_found"); + DomainErrors.UserAccount.MfaEnrollmentNotFound.Should().Be("user_account.mfa_enrollment_not_found"); + DomainErrors.TenantParameter.NotFound.Should().Be("tenant_parameter.not_found"); + DomainErrors.Configuration.IdpFallbackNotFound.Should().Be("configuration.idp_fallback_not_found"); + DomainErrors.Authorization.ParentResourceNotFound.Should().Be("authorization.parent_resource_not_found"); + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Reliability/OutboxBusUnavailabilityIntegrationTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Reliability/OutboxBusUnavailabilityIntegrationTests.cs new file mode 100644 index 00000000..193ef440 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Reliability/OutboxBusUnavailabilityIntegrationTests.cs @@ -0,0 +1,252 @@ +using MassTransit; +using MassTransit.EntityFrameworkCoreIntegration; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Ums.Application.Common.Interfaces; +using Ums.Domain.Events; +using Ums.Infrastructure.Persistence; +using Ums.Infrastructure.Persistence.Configuration.Entities; +using Ums.Presentation.IntegrationTest.Infrastructure; + +namespace Ums.Presentation.IntegrationTest.Reliability; + +/// +/// G-003 (sub-caso «corte del bus / backpressure»): prueba de FALLO del Transactional Outbox de +/// MassTransit cuando el bróker / servicio de entrega está INDISPONIBLE. +/// +/// Verifica el invariante de resiliencia del outbox transaccional (ADR-0098 D4/D7, G-066): +/// 1. Con el bus CAÍDO, publicar un evento de integración dentro de la transacción del agregado +/// ESTACIONA el mensaje en el outbox (tabla OutboxMessage) y NO se pierde. +/// 2. El cambio del agregado quedó CONSISTENTE (commit atómico: agregado + fila de outbox). +/// 3. Cuando el bus VUELVE, el servicio de entrega (BusOutboxDeliveryService) drena el +/// outbox: el mensaje se entrega EXACTAMENTE una vez al consumidor y la fila del outbox +/// desaparece. +/// +/// A diferencia de IntegrationEventOutboxDispatchTests (dobles en memoria, nivel aplicación), +/// esta prueba ejercita el MECANISMO REAL: EF Core bus-outbox (AddEntityFrameworkOutbox + +/// UseBusOutbox) sobre un PostgreSQL de Testcontainers, con las tablas +/// OutboxMessage/OutboxState reales. La «indisponibilidad del bus» se modela NO +/// arrancando el host (el transporte y el servicio de entrega quedan detenidos); la «vuelta del bus» +/// se modela arrancándolo. +/// +[Collection("PostgreSql")] +public sealed class OutboxBusUnavailabilityIntegrationTests +{ + private static readonly Guid SeededTenantId = Guid.Parse("3fa85f64-5717-4562-b3fc-2c963f66afa6"); + + private readonly PostgreSqlContainerFixture _fixture; + + public OutboxBusUnavailabilityIntegrationTests(PostgreSqlContainerFixture fixture) + => _fixture = fixture; + + [Fact] + public async Task BusDown_OutboxRetainsMessage_AndDeliversWhenBusReturns() + { + if (!_fixture.IsAvailable) + { + Assert.Skip("Docker required."); + return; + } + + var ct = TestContext.Current.CancellationToken; + var capture = new IntegrationEventCapture(); + + using var host = BuildHost(_fixture.ConnectionString, capture); + + // Línea base limpia del outbox (la colección PostgreSql es secuencial; otras pruebas truncan + // al crear su host, así que partimos de una pizarra conocida para este contenedor). + await CleanOutboxAsync(host.Services, ct); + + var configId = Guid.NewGuid(); + var evt = new RolePromotionExecutedIntegrationEvent( + SeededTenantId, RequestId: Guid.NewGuid(), TargetUserId: Guid.NewGuid(), + CurrentRoleId: Guid.NewGuid(), TargetRoleId: Guid.NewGuid(), ExecutorId: Guid.NewGuid()); + + // ── Fase 1: BUS CAÍDO (host sin arrancar → sin transporte ni servicio de entrega) ─────────── + // Publicar dentro de la transacción del agregado: el evento se estaciona en el outbox junto al + // cambio del agregado y ambos se confirman atómicamente al guardar. + using (var scope = host.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var publishEndpoint = scope.ServiceProvider.GetRequiredService(); + + db.Set().Add(NewConfigRecord(configId)); + await publishEndpoint.Publish(evt, ct); + await db.SaveChangesAsync(ct); + } + + // (1) El mensaje quedó RETENIDO en el outbox (no se perdió pese a no haber bus). + // (2) El agregado quedó CONSISTENTE (commit atómico agregado + fila de outbox). + using (var scope = host.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + + (await db.Set().CountAsync(ct)).Should().BeGreaterThan(0, + "con el bus caído el evento debe permanecer estacionado en el outbox — no se pierde"); + (await db.Set().AnyAsync(r => r.Id == configId, ct)).Should().BeTrue( + "el cambio del agregado se confirmó atómicamente con la fila del outbox (consistencia)"); + } + + // (3) Con el bus caído no puede haberse entregado nada al consumidor. + capture.Count.Should().Be(0, "con el bus caído no puede haberse entregado nada al consumidor"); + + // ── Fase 2: EL BUS VUELVE (se arranca el host → transporte + BusOutboxDeliveryService) ─────── + await host.StartAsync(ct); + try + { + // El servicio de entrega drena el outbox y publica al transporte; el consumidor lo recibe. + await WaitUntilAsync( + async () => capture.Count >= 1 && await OutboxMessageCountAsync(host.Services, ct) == 0, + timeout: TimeSpan.FromSeconds(60), ct); + + capture.Count.Should().Be(1, + "al volver el bus, el outbox entrega el evento EXACTAMENTE una vez (sin pérdida ni duplicado)"); + capture.Last.Should().NotBeNull(); + capture.Last!.RequestId.Should().Be(evt.RequestId, + "el evento entregado es el mismo que se estacionó con el cambio del agregado"); + + (await OutboxMessageCountAsync(host.Services, ct)).Should().Be(0, + "tras la entrega el outbox queda drenado"); + + // El agregado sigue presente e íntegro tras la entrega. + using var scope = host.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + (await db.Set().AnyAsync(r => r.Id == configId, ct)).Should().BeTrue( + "la entrega del evento no altera el estado del agregado ya confirmado"); + } + finally + { + await host.StopAsync(ct); + } + } + + // ── Infraestructura del test ──────────────────────────────────────────────────────────────────── + + private static IHost BuildHost(string connectionString, IntegrationEventCapture capture) => + Host.CreateDefaultBuilder() + .ConfigureServices(services => + { + services.AddLogging(b => b.SetMinimumLevel(LogLevel.Warning)); + services.AddSingleton(capture); + services.AddScoped(); + services.AddScoped(); + + services.AddDbContext(o => + o.UseNpgsql(connectionString, sql => sql.EnableRetryOnFailure(3))); + + services.AddMassTransit(x => + { + x.AddConsumer(); + + // Bus-outbox EF real: los mensajes publicados por IPublishEndpoint se estacionan en el + // outbox con el cambio del agregado y se entregan POST-commit (UseBusOutbox()). + x.AddEntityFrameworkOutbox(o => + { + o.UsePostgres(); + o.QueryDelay = TimeSpan.FromSeconds(1); // barrido rápido del servicio de entrega + o.UseBusOutbox(); + }); + + x.UsingInMemory((context, cfg) => cfg.ConfigureEndpoints(context)); + }); + }) + .Build(); + + private static AppConfigurationRecord NewConfigRecord(Guid id) => new() + { + Id = id, + TenantId = SeededTenantId, + SystemSuiteId = Guid.NewGuid(), + ModuleId = Guid.NewGuid(), + Code = $"OUTBOX_G003_{id:N}"[..40], + Value = "outbox-bus-down", + Description = "Agregado sembrado por OutboxBusUnavailabilityIntegrationTests (G-003).", + ScopeId = 1, + IsInheritable = false, + IsEncrypted = false, + IsNonOverridable = false, + Version = "1", + StatusId = 1, + CreatedBy = "00000000-0000-0000-0000-000000000123", + CreatedAtUtc = DateTime.UtcNow, + AuditTimeSpan = string.Empty, + // RowVersion la genera PostgreSQL en el INSERT (gen_random_bytes(8), ValueGenerated.OnAdd). + }; + + private static async Task CleanOutboxAsync(IServiceProvider services, CancellationToken ct) + { + using var scope = services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + // Orden por las FKs: OutboxMessage → OutboxState / InboxState. + await db.Set().ExecuteDeleteAsync(ct); + await db.Set().ExecuteDeleteAsync(ct); + await db.Set().ExecuteDeleteAsync(ct); + } + + private static async Task OutboxMessageCountAsync(IServiceProvider services, CancellationToken ct) + { + using var scope = services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return await db.Set().CountAsync(ct); + } + + private static async Task WaitUntilAsync(Func> condition, TimeSpan timeout, CancellationToken ct) + { + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + if (await condition()) + return; + await Task.Delay(250, ct); + } + // Última evaluación: si expira, falla con el estado real observado. + (await condition()).Should().BeTrue( + "el outbox debió entregar el evento y drenarse dentro del tiempo límite tras volver el bus"); + } + + private sealed class IntegrationEventCapture + { + private int _count; + public int Count => Volatile.Read(ref _count); + public RolePromotionExecutedIntegrationEvent? Last { get; private set; } + + public void Record(RolePromotionExecutedIntegrationEvent evt) + { + Last = evt; + Interlocked.Increment(ref _count); + } + } + + private sealed class CapturingConsumer(IntegrationEventCapture capture) + : IConsumer + { + public Task Consume(ConsumeContext context) + { + capture.Record(context.Message); + return Task.CompletedTask; + } + } + + private sealed class NoOpPublisher : IPublisher + { + public Task Publish(object notification, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public Task Publish(TNotification notification, CancellationToken cancellationToken = default) + where TNotification : INotification => Task.CompletedTask; + } + + private sealed class SystemTenantContext : ITenantContext + { + public Guid? OrganizationId => null; + public Guid? OriginalTenantId => null; + public bool IsInternalAdmin => true; + public void Initialize(Guid userTenantId, bool isInternalAdmin) { } + public void SetOrganizationId(Guid organizationId) { } + public void EnableCrossTenantAccess() { } + public void DisableCrossTenantAccess() { } + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Reliability/ReliabilityIntegrationTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Reliability/ReliabilityIntegrationTests.cs index bdb935a1..6195be5e 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Reliability/ReliabilityIntegrationTests.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Reliability/ReliabilityIntegrationTests.cs @@ -315,13 +315,81 @@ public async Task T18_DuplicateRapidPost_BothSucceed_DocumentsIdempotencyGap() idA.Should().NotBe(idB, "RISK-05: two distinct resources were created — idempotency enforcement would return same id for same Idempotency-Key"); - // DESIRED behaviour after FIX-06 (IdempotencyMiddleware): - // When both requests carry the same X-Idempotency-Key header, - // the second response should return the cached first response body, - // and both ids should be equal: - // - // idA.Should().Be(idB, - // "after FIX-06: repeated requests with same Idempotency-Key return the same resource"); + // La conducta DESEADA tras FIX-06 (repetir la MISMA Idempotency-Key devuelve la respuesta + // cacheada, mismo id, sin doble efecto) ya NO es un gap: se verifica activamente en + // T18b (G-003). Este T18 conserva el escenario base «sin clave ⇒ sin deduplicación». + } + + // ========================================================================= + // T18b — [G-003] Idempotencia bajo reintento: misma Idempotency-Key → sin doble efecto + // ========================================================================= + + /// + /// T18b — Idempotencia VERIFICADA (cierra el gap que T18 solo documentaba — G-003 / RISK-05 / FIX-06). + /// + /// Simula el reintento de un cliente que no recibió la respuesta del primer POST (timeout, corte de + /// red) y reenvía la MISMA petición con la MISMA Idempotency-Key. El + /// debe: + /// · devolver la respuesta CACHEADA del primer intento (mismo 201, mismo id), + /// · marcar la repetición con X-Idempotency-Replayed: true, + /// · NO re-ejecutar el manejador → NO crea un segundo recurso (efecto único), y en particular + /// NO devuelve 409 pese a repetir el mismo (scope, code) —el reintento se resuelve desde caché + /// ANTES de llegar a la regla de unicidad de dominio—. + /// + /// Es la garantía de resiliencia «reintento seguro»: reprocesar el mismo mensaje/petición no + /// duplica el efecto. + /// + [Fact] + public async Task T18b_RetryWithSameIdempotencyKey_ReturnsCachedResponse_NoDuplicateEffect() + { + var code = UniqueCode("t18b"); + var idempotencyKey = Guid.NewGuid().ToString(); + var client = BuildClientWithIdempotencyKey(idempotencyKey); + + // 1.er intento — crea el recurso y cachea la respuesta bajo la Idempotency-Key. + var first = await client.PostAsJsonAsync( + "/api/v1/app-configurations", BuildCreatePayload(code), + TestContext.Current.CancellationToken); + first.StatusCode.Should().Be(HttpStatusCode.Created); + + using var firstPayload = JsonDocument.Parse( + await first.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + var idFirst = firstPayload.RootElement.GetProperty("appConfigurationId").GetGuid(); + + // 2.º intento — MISMA clave, mismo cuerpo: el reintento del cliente. + var retry = await client.PostAsJsonAsync( + "/api/v1/app-configurations", BuildCreatePayload(code), + TestContext.Current.CancellationToken); + + // Se devuelve la respuesta cacheada del primer intento, NO un 409 por (scope, code) duplicado. + retry.StatusCode.Should().Be(HttpStatusCode.Created, + "el reintento con la misma Idempotency-Key reproduce la respuesta original, no re-ejecuta el manejador ni choca con la unicidad de dominio"); + retry.Headers.TryGetValues("X-Idempotency-Replayed", out var replayed).Should().BeTrue( + "el middleware marca la respuesta reproducida desde caché"); + replayed!.Should().ContainSingle().Which.Should().Be("true"); + + using var retryPayload = JsonDocument.Parse( + await retry.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + var idRetry = retryPayload.RootElement.GetProperty("appConfigurationId").GetGuid(); + idRetry.Should().Be(idFirst, + "el reintento idempotente devuelve el MISMO recurso — sin doble creación"); + + // Efecto ÚNICO: existe exactamente un recurso con ese code (no se duplicó). + var listResponse = await _client.GetAsync( + "/api/v1/app-configurations?page=1&pageSize=200", + TestContext.Current.CancellationToken); + listResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + using var listPayload = JsonDocument.Parse( + await listResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + var items = listPayload.RootElement.GetProperty("items"); + var matches = Enumerable.Range(0, items.GetArrayLength()) + .Count(i => string.Equals( + items[i].GetProperty("code").GetString(), + code.ToUpperInvariant(), + StringComparison.OrdinalIgnoreCase)); + matches.Should().Be(1, + "el reintento idempotente no debe crear un segundo recurso (efecto único bajo reintento)"); } // ========================================================================= @@ -403,6 +471,7 @@ public async Task T19_ConcurrentUpdates_BothSucceed_DocumentsOptimisticConcurren "updated-by-request-1", "updated-by-request-2", "RISK-02: one value survived but the other was silently lost — no conflict detected"); +#pragma warning disable S125 // Commented out code explains desired behaviour // DESIRED behaviour after FIX-03 (RowVersion/ETag): // The second concurrent PUT (without the updated ETag from the first response) // should return 409 Conflict: @@ -414,6 +483,7 @@ public async Task T19_ConcurrentUpdates_BothSucceed_DocumentsOptimisticConcurren // }; // conflictingStatuses.Should().Contain(HttpStatusCode.Conflict, // "after FIX-03: concurrent update without matching ETag must return 409"); +#pragma warning restore S125 } // ========================================================================= @@ -478,12 +548,14 @@ public async Task T20_TenantIsolation_GetAllWithoutFilter_ReturnsAllTenantsData_ "SESSION_TIMEOUT_MINUTES", "Seeded config for SeededTenantId is also visible — confirms cross-tenant leakage"); +#pragma warning disable S125 // Commented out code explains desired behaviour // DESIRED behaviour after FIX-05 (HasQueryFilter + mandatory tenant scope): // When no tenantId filter is provided, only the caller's own tenant's resources // should be returned (or a 400 Bad Request if tenantId is required): // // codes.Should().NotContain(otherTenantCode.ToUpperInvariant(), // "after FIX-05: cross-tenant resources must not appear in unfiltered query"); +#pragma warning restore S125 } // ========================================================================= @@ -502,6 +574,17 @@ private HttpClient BuildClient(string userId, string userName) return client; } + /// + /// Cliente autenticado que adjunta la misma Idempotency-Key en TODAS sus peticiones —modela + /// un cliente que reintenta la misma operación lógica bajo una única clave de idempotencia. + /// + private HttpClient BuildClientWithIdempotencyKey(string idempotencyKey) + { + var client = BuildClient(ActorUserId, ActorUserName); + client.DefaultRequestHeaders.Add("Idempotency-Key", idempotencyKey); + return client; + } + /// /// Produces a unique, deterministic code string per test run. /// Uses a short prefix + 8 hex chars to stay within domain code length limits. diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/AutenticacionPorPortadorTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/AutenticacionPorPortadorTests.cs new file mode 100644 index 00000000..81cc65c3 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/AutenticacionPorPortadorTests.cs @@ -0,0 +1,284 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using Microsoft.IdentityModel.Tokens; +using Ums.Infrastructure.Persistence.Seeders; +using Ums.Presentation.IntegrationTest.Infrastructure; + +namespace Ums.Presentation.IntegrationTest.Security; + +/// +/// G-191 — UMS valida sus PROPIOS tokens y la superficie de satélites autentica por portador. +/// +/// Antes, `GET /api/v1/client/graph` con un `Authorization: Bearer` legítimo respondía 302: en +/// desarrollo no existía manejador de portador y el esquema por defecto era la cookie, así que el +/// reto redirigía al formulario de acceso. Estas pruebas fijan el contrato que consume cualquier +/// sistema satélite: +/// +/// portador válido → 200 +/// sin portador → 401 (nunca 3xx) +/// portador inválido, caducado, de otra firma o de otro emisor → 401 +/// cookie `ums.session` → sigue autenticando al portal, y NO sirve para la superficie de satélite +/// +public sealed class AutenticacionPorPortadorTests : IClassFixture +{ + // El mismo secreto que inyecta UmsApiWebApplicationFactory: el host firma y valida con él. + private const string SecretoDePrueba = "INTEGRATION_TEST_JWT_SECRET_KEY_CHANGE_ME_MIN_32_CHARS"; + private const string EmisorDePrueba = "ums-api"; + private const string AudienciaDePrueba = "ums-web-app"; + + private const string InquilinoCliente = "COMEX_ANDINA"; + private const string UsuarioCliente = "usuario.impo@comexandina.com.pe"; + + private readonly UmsApiWebApplicationFactory _factory; + + public AutenticacionPorPortadorTests(UmsApiWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task ClientGraph_ConPortadorValido_Devuelve200YElGrafoVigente() + { + var ct = TestContext.Current.CancellationToken; + var cliente = CrearCliente(); + var token = await ObtenerPortadorAsync(cliente, ct); + + cliente.DefaultRequestHeaders.Authorization = new("Bearer", token); + var respuesta = await cliente.GetAsync("/api/v1/client/graph", ct); + + var cuerpo = await respuesta.Content.ReadAsStringAsync(ct); + respuesta.StatusCode.Should().Be(HttpStatusCode.OK, + because: $"un satélite con portador válido debe recibir su grafo, no una redirección (G-191). Cuerpo: {cuerpo}"); + + using var payload = JsonDocument.Parse(cuerpo); + payload.RootElement.TryGetProperty("context", out _).Should().BeTrue( + "el grafo se reconstruye en el momento y trae su contexto de usuario e inquilino"); + } + + [Fact] + public async Task ClientGraph_SinPortador_Devuelve401YNoRedirige() + { + var ct = TestContext.Current.CancellationToken; + var cliente = CrearCliente(); + + var respuesta = await cliente.GetAsync("/api/v1/client/graph", ct); + + respuesta.StatusCode.Should().Be(HttpStatusCode.Unauthorized, + "un cliente de API no sigue un `Location`: la ausencia de credencial es 401, nunca 302"); + respuesta.Headers.Location.Should().BeNull("ninguna ruta /api/** puede responder con redirección"); + } + + [Fact] + public async Task ClientGraph_ConPortadorMalformado_Devuelve401() + { + var ct = TestContext.Current.CancellationToken; + var cliente = CrearCliente(); + cliente.DefaultRequestHeaders.Authorization = new("Bearer", "no-es-un-jwt"); + + var respuesta = await cliente.GetAsync("/api/v1/client/graph", ct); + + respuesta.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task ClientGraph_ConLaFirmaAlterada_Devuelve401() + { + // El token es legítimo salvo por la firma: prueba que la comprobación criptográfica + // ocurre de verdad y no basta con presentar algo con forma de JWT. + var ct = TestContext.Current.CancellationToken; + var cliente = CrearCliente(); + var token = await ObtenerPortadorAsync(cliente, ct); + + cliente.DefaultRequestHeaders.Authorization = new("Bearer", token + "xyz"); + var respuesta = await cliente.GetAsync("/api/v1/client/graph", ct); + + respuesta.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task ClientGraph_ConPortadorCaducado_Devuelve401() + { + var ct = TestContext.Current.CancellationToken; + var cliente = CrearCliente(); + cliente.DefaultRequestHeaders.Authorization = new("Bearer", ForjarToken( + SecretoDePrueba, EmisorDePrueba, DateTime.UtcNow.AddHours(-1))); + + var respuesta = await cliente.GetAsync("/api/v1/client/graph", ct); + + respuesta.StatusCode.Should().Be(HttpStatusCode.Unauthorized, + "la vida del token se valida: un portador caducado no sirve aunque su firma sea correcta"); + } + + [Fact] + public async Task ClientGraph_ConPortadorFirmadoConOtroSecreto_Devuelve401() + { + var ct = TestContext.Current.CancellationToken; + var cliente = CrearCliente(); + cliente.DefaultRequestHeaders.Authorization = new("Bearer", ForjarToken( + "OTRO_SECRETO_QUE_NO_ES_EL_DE_UMS_Y_MIDE_MAS_DE_32", EmisorDePrueba, DateTime.UtcNow.AddHours(1))); + + var respuesta = await cliente.GetAsync("/api/v1/client/graph", ct); + + respuesta.StatusCode.Should().Be(HttpStatusCode.Unauthorized, + "UMS valida la firma HS256 con su propio secreto: un token ajeno no entra"); + } + + [Fact] + public async Task ClientGraph_ConPortadorDeOtroEmisor_Devuelve401() + { + var ct = TestContext.Current.CancellationToken; + var cliente = CrearCliente(); + cliente.DefaultRequestHeaders.Authorization = new("Bearer", ForjarToken( + SecretoDePrueba, "emisor-ajeno", DateTime.UtcNow.AddHours(1))); + + var respuesta = await cliente.GetAsync("/api/v1/client/graph", ct); + + respuesta.StatusCode.Should().Be(HttpStatusCode.Unauthorized, + "el emisor se valida: un token con `iss` distinto no es de esta instalación"); + } + + [Fact] + public async Task ClientGraph_ConSoloLaCookieDelPortal_Devuelve401() + { + var ct = TestContext.Current.CancellationToken; + var cliente = CrearCliente(); + var cookie = await IniciarSesionWebAsync(cliente, ct); + + cliente.DefaultRequestHeaders.Remove("Cookie"); + cliente.DefaultRequestHeaders.Add("Cookie", cookie); + var respuesta = await cliente.GetAsync("/api/v1/client/graph", ct); + + respuesta.StatusCode.Should().Be(HttpStatusCode.Unauthorized, + "la política `Satelite` fija el esquema portador: la sesión de navegador no habilita la superficie servidor a servidor"); + respuesta.Headers.Location.Should().BeNull("tampoco con cookie puede redirigir una ruta /api/**"); + } + + [Fact] + public async Task Session_ConPortador_Devuelve200YNoRedirige() + { + // G-187 (parte del 302): preguntar «¿esta sesión sigue viva?» con portador ya no redirige. + var ct = TestContext.Current.CancellationToken; + var cliente = CrearCliente(); + var token = await ObtenerPortadorAsync(cliente, ct); + cliente.DefaultRequestHeaders.Authorization = new("Bearer", token); + + var respuesta = await cliente.GetAsync("/api/v1/auth/session", ct); + + respuesta.StatusCode.Should().Be(HttpStatusCode.OK); + using var payload = JsonDocument.Parse(await respuesta.Content.ReadAsStringAsync(ct)); + payload.RootElement.GetProperty("tenantCode").GetString().Should().Be(InquilinoCliente); + } + + [Fact] + public async Task Session_SinCredencial_Devuelve401() + { + var ct = TestContext.Current.CancellationToken; + var cliente = CrearCliente(); + + var respuesta = await cliente.GetAsync("/api/v1/auth/session", ct); + + respuesta.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + respuesta.Headers.Location.Should().BeNull(); + } + + [Fact] + public async Task PortalWeb_LoginYSesionPorCookie_SiguenFuncionando() + { + // El portal no debe notar ningún cambio: la cookie `ums.session` sigue siendo su credencial. + var ct = TestContext.Current.CancellationToken; + var cliente = CrearCliente(); + var cookie = await IniciarSesionWebAsync(cliente, ct); + + cliente.DefaultRequestHeaders.Remove("Cookie"); + cliente.DefaultRequestHeaders.Add("Cookie", cookie); + + var sesion = await cliente.GetAsync("/api/v1/auth/session", ct); + sesion.StatusCode.Should().Be(HttpStatusCode.OK, + "la sesión de cookie del portal debe seguir resolviéndose por el esquema de cookie"); + + using var payload = JsonDocument.Parse(await sesion.Content.ReadAsStringAsync(ct)); + payload.RootElement.GetProperty("tenantCode").GetString().Should().Be(InquilinoCliente); + payload.RootElement.GetProperty("email").GetString().Should().Be(UsuarioCliente); + } + + [Fact] + public async Task EndpointDeNegocio_ConPortadorInvalido_NoCaeEnLasClaimsDeDesarrollo() + { + // El host de pruebas corre en «Development», donde DevAuthMiddleware inyecta identidad de + // conveniencia. Si esa inyección tapara el rechazo del portador, un token falsificado + // acabaría respondiendo 200: la exención por cabecera `Authorization` lo impide (G-191). + var ct = TestContext.Current.CancellationToken; + var cliente = CrearCliente(); + cliente.DefaultRequestHeaders.Authorization = new("Bearer", ForjarToken( + "OTRO_SECRETO_QUE_NO_ES_EL_DE_UMS_Y_MIDE_MAS_DE_32", EmisorDePrueba, DateTime.UtcNow.AddHours(1))); + + var respuesta = await cliente.GetAsync("/api/v1/user-accounts?page=1&pageSize=1", ct); + + respuesta.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + // ── Auxiliares ─────────────────────────────────────────────────────────────── + + private HttpClient CrearCliente() => _factory.CreateClient(new WebApplicationFactoryClientOptions + { + BaseAddress = new Uri("https://localhost"), + // Sin seguir redirecciones: si algo respondiera 302, la prueba tiene que verlo. + AllowAutoRedirect = false, + HandleCookies = false, + }); + + private static async Task ObtenerPortadorAsync(HttpClient cliente, CancellationToken ct) + { + var respuesta = await cliente.PostAsJsonAsync("/api/v1/client/authenticate", new + { + tenantCode = InquilinoCliente, + username = UsuarioCliente, + password = CoreDevDataSeeder.BeyondNetDevPassword, + }, ct); + + respuesta.StatusCode.Should().Be(HttpStatusCode.OK); + using var payload = JsonDocument.Parse(await respuesta.Content.ReadAsStringAsync(ct)); + var token = payload.RootElement.GetProperty("token").GetString(); + token.Should().NotBeNullOrWhiteSpace(); + return token!; + } + + private static async Task IniciarSesionWebAsync(HttpClient cliente, CancellationToken ct) + { + var respuesta = await cliente.PostAsJsonAsync("/api/v1/auth/login", new + { + tenantCode = InquilinoCliente, + username = UsuarioCliente, + password = CoreDevDataSeeder.BeyondNetDevPassword, + rememberMe = false, + }, ct); + + respuesta.StatusCode.Should().Be(HttpStatusCode.OK, "el login del portal debe seguir intacto"); + var setCookie = respuesta.Headers.GetValues("Set-Cookie").FirstOrDefault(); + setCookie.Should().NotBeNullOrEmpty("el portal se autentica con la cookie ums.session"); + setCookie.Should().Contain("ums.session"); + return setCookie!.Split(';')[0]; + } + + private static string ForjarToken(string secreto, string emisor, DateTime expiraEnUtc) + { + var credenciales = new SigningCredentials( + new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secreto)), + SecurityAlgorithms.HmacSha256); + + var token = new JwtSecurityToken( + issuer: emisor, + audience: AudienciaDePrueba, + claims: + [ + new Claim(JwtRegisteredClaimNames.Sub, UsuarioCliente), + new Claim(JwtRegisteredClaimNames.Email, UsuarioCliente), + new Claim("tenant_code", InquilinoCliente), + ], + notBefore: expiraEnUtc.AddHours(-1), + expires: expiraEnUtc, + signingCredentials: credenciales); + + return new JwtSecurityTokenHandler().WriteToken(token); + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/AuthenticationFlowTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/AuthenticationFlowTests.cs index eb0ad6fd..618750d6 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/AuthenticationFlowTests.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/AuthenticationFlowTests.cs @@ -1,19 +1,31 @@ using System.Net; -using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; using FluentAssertions; +using Ums.Domain.Authorization.Graph; +using Ums.Domain.Identity.Auth; using Ums.Infrastructure.Persistence.Seeders; using Ums.Presentation.IntegrationTest.Infrastructure; namespace Ums.Presentation.IntegrationTest.Security; +/// +/// Pruebas del refresh deslizante por cookie de sesión (POST /api/v1/auth/refresh). +/// +/// D-019 / ADR-UMS-091: el refresh por cookie debe espejar el login — regenerar el grafo +/// de autorización vigente y emitir un graph JWT que porte el modelo de permisos actual —, en +/// vez de re-firmar un JWT con permisos vacíos. Estas pruebas endurecen el contrato para exigir +/// que el token/respuesta refrescados lleven el conjunto de permisos vigente (no vacío) y que un +/// cambio de permisos aplicado tras el login se refleje en la renovación. +/// public sealed class AuthenticationFlowTests : IClassFixture { + private readonly UmsApiWebApplicationFactory _factory; private readonly HttpClient _client; public AuthenticationFlowTests(UmsApiWebApplicationFactory factory) { + _factory = factory; _client = factory.CreateClient(new WebApplicationFactoryClientOptions { BaseAddress = new Uri("https://localhost"), @@ -24,41 +36,170 @@ public AuthenticationFlowTests(UmsApiWebApplicationFactory factory) [Fact] public async Task RefreshToken_WithValidSessionCookie_ShouldReturnNewToken() { - // 1. Iniciar sesión y capturar la cookie de sesión + var ct = TestContext.Current.CancellationToken; + + // 1. Iniciar sesión, capturar la cookie de sesión y los permisos del login. + var login = await LoginAsync(ct); + login.Permissions.Should().NotBeEmpty( + "el usuario de prueba debe tener permisos para que la aserción de 'permisos vigentes' sea significativa"); + + // 2. Refresh usando la cookie ([RequireAuthorization] valida la cookie). + var refreshResponse = await _client.PostAsync("/api/v1/auth/refresh", null, ct); + refreshResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + using var refreshPayload = JsonDocument.Parse(await refreshResponse.Content.ReadAsStringAsync(ct)); + var newToken = refreshPayload.RootElement.GetProperty("token").GetString(); + + // 3. El token refrescado es nuevo… + newToken.Should().NotBeNullOrEmpty(); + newToken.Should().NotBe(login.Token, "El refresh debe generar un JWT completamente nuevo."); + + // 4. …y —el fix real de D-019— porta el modelo de permisos VIGENTE, no un grafo vacío. + var refreshPermissions = ReadPermissions(refreshPayload.RootElement); + refreshPermissions.Should().NotBeEmpty( + "el token refrescado debe portar el conjunto de permisos vigente, no Array.Empty"); + refreshPermissions.Should().BeEquivalentTo(login.Permissions, + "el refresh espeja el login: el mismo grafo vigente ⇒ el mismo conjunto de permisos"); + + // 5. El token en sí (no solo el cuerpo) sigue siendo un graph JWT como el del login. + // + // G-172 cambió QUÉ lleva ese token: los claims `perm` —uno por cada par opción-acción, + // 16-24 KB en la cabecera de cada petición— se retiraron; el grafo viaja en el cuerpo y el + // cliente lo cachea. Lo que la autorización del servidor lee son los `scope`, así que es + // sobre ellos, y no sobre `perm`, sobre lo que hay que exigir contenido vigente. + using var tokenClaims = DecodeJwtPayload(newToken!); + tokenClaims.RootElement.TryGetProperty("scope", out var scopeClaims).Should().BeTrue( + "el JWT refrescado debe embeber los ámbitos vigentes (graph JWT), igual que el login"); + CountClaimValues(scopeClaims).Should().BeGreaterThan(0); + tokenClaims.RootElement.TryGetProperty("graph_valid_until", out _).Should().BeTrue( + "el JWT refrescado debe ser un graph JWT (espejo del login), no el JWT plano heredado"); + } + + [Fact] + public async Task RefreshToken_AfterPermissionChange_ReflectsCurrentPermissions_NotLoginSnapshot() + { + var ct = TestContext.Current.CancellationToken; + + // 1. Login: capturamos el conjunto de permisos vigente en ese momento. + var login = await LoginAsync(ct); + login.Permissions.Length.Should().BeGreaterThan(1, + "el usuario de prueba debe tener ≥2 permisos para revocar uno y conservar el resto"); + + // 2. Reconstruimos el grafo vigente EN PROCESO para localizar una opción Allow y su acción, + // y así saber qué permiso concreto revocar (los ids de nodo no viajan en el JSON). + using var scope = _factory.Services.CreateScope(); + var userRepo = scope.ServiceProvider.GetRequiredService(); + var profileRepo = scope.ServiceProvider.GetRequiredService(); + var graphBuilder = scope.ServiceProvider.GetRequiredService(); + + var user = await userRepo.GetByIdAsync(login.UserId, ct); + user.Should().NotBeNull(); + + var graphResult = await graphBuilder.BuildAsync(user!, login.TenantId, AuthMethod.Local(), systemCode: null, ct); + graphResult.IsSuccess.Should().BeTrue(); + var graph = graphResult.Value; + + // G-171: la navegación dejó de ser la cadena rígida Módulo→Menú→Submenú→Opción y pasó a ser + // un árbol de profundidad libre; el recorrido canónico es `GraphNavigation`. Esta prueba + // seguía con los tres bucles literales y dejó de compilar con el cambio de modelo. + var allowNode = GraphNavigation.AllNodes(graph) + .First(n => n.Actions.Any(a => a.Effect == AccessEffect.Allow)); + var allowAction = allowNode.Actions.First(a => a.Effect == AccessEffect.Allow); + var actionId = graph.Actions.First(a => a.Code == allowAction.ActionCode).Id; + var revokedPermission = $"{allowNode.Code}:{allowAction.ActionCode}"; + login.Permissions.Should().Contain(revokedPermission); + + // 3. Aplicamos un CAMBIO DE PERMISOS al principal: revocamos ese permiso en su perfil activo. + var profiles = await profileRepo.GetByUserIdAsync(login.UserId, ct); + var profile = profiles.First(p => p.Props.TenantId.GetValue() == login.TenantId && p.IsActive); + var permission = profile.Permissions.First(p => + p.IsActive && p.IsAllowed && !p.IsDenied && + p.TargetId.GetValue() == allowNode.Id && + p.ActionId.GetValue() == actionId); + // El aggregate localiza el permiso por su Id canónico (Props.Id == GetId()), no por el Id + // base de Entity<> (que se regenera aleatorio en construcción) — G-116. + var deny = profile.OverridePermissionDeny(permission.GetId(), ActorId.Create("integration-test")); + deny.IsSuccess.Should().BeTrue(); + await profileRepo.UpdateAsync(profile, ct); + + // 4. Refresh con la misma cookie. + var refreshResponse = await _client.PostAsync("/api/v1/auth/refresh", null, ct); + refreshResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + using var refreshPayload = JsonDocument.Parse(await refreshResponse.Content.ReadAsStringAsync(ct)); + var refreshPermissions = ReadPermissions(refreshPayload.RootElement); + + // 5. El refresh regenera el grafo: refleja los permisos ACTUALES, no la foto del login. + refreshPermissions.Should().NotBeEmpty( + "el token refrescado debe portar el modelo de permisos vigente, no un grafo vacío"); + refreshPermissions.Should().NotContain(revokedPermission, + "el permiso revocado tras el login NO debe seguir presente en el token refrescado"); + refreshPermissions.Length.Should().Be(login.Permissions.Length - 1, + "exactamente el permiso revocado debe desaparecer del conjunto vigente"); + } + + // ── Helpers ────────────────────────────────────────────────────────────────── + + private sealed record LoginOutcome(Guid UserId, Guid TenantId, string? Token, string[] Permissions); + + private async Task LoginAsync(CancellationToken ct) + { var loginResponse = await _client.PostAsJsonAsync("/api/v1/auth/login", new { tenantCode = "RANSA_PERU", username = "gerente.operaciones@ransa.pe", password = CoreDevDataSeeder.SuperAdminPassword, rememberMe = false, - }, TestContext.Current.CancellationToken); + }, ct); loginResponse.StatusCode.Should().Be(HttpStatusCode.OK); - // Capturar las cookies (la CookieAuthentication) var setCookieHeader = loginResponse.Headers.GetValues("Set-Cookie").FirstOrDefault(); setCookieHeader.Should().NotBeNullOrEmpty(); - - // 2. Extraer la cookie y configurar el HttpClient para la siguiente solicitud var cookieValue = setCookieHeader!.Split(';')[0]; + _client.DefaultRequestHeaders.Remove("Cookie"); _client.DefaultRequestHeaders.Add("Cookie", cookieValue); - // Opcional: obtener el Bearer Token para validar la respuesta original - using var payload = JsonDocument.Parse(await loginResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); - var initialToken = payload.RootElement.GetProperty("token").GetString(); - initialToken.Should().NotBeNullOrEmpty(); + using var payload = JsonDocument.Parse(await loginResponse.Content.ReadAsStringAsync(ct)); + var root = payload.RootElement; + var token = root.GetProperty("token").GetString(); + token.Should().NotBeNullOrEmpty(); - // 3. Realizar el request de Refresh (usando la cookie) - // El endpoint /refresh requiere [RequireAuthorization], por lo que valida la cookie - var refreshResponse = await _client.PostAsync("/api/v1/auth/refresh", null, TestContext.Current.CancellationToken); - - refreshResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var userId = Guid.Parse(root.GetProperty("userId").GetString()!); + var tenantId = Guid.Parse(root.GetProperty("tenantId").GetString()!); + var permissions = ReadPermissions(root); - // 4. Validar el nuevo token generado - using var refreshPayload = JsonDocument.Parse(await refreshResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); - var newToken = refreshPayload.RootElement.GetProperty("token").GetString(); - - newToken.Should().NotBeNullOrEmpty(); - newToken.Should().NotBe(initialToken, "El refresh token debe generar un JWT completamente nuevo."); + return new LoginOutcome(userId, tenantId, token, permissions); } + + private static string[] ReadPermissions(JsonElement root) + { + if (!root.TryGetProperty("permissions", out var permissions) || permissions.ValueKind != JsonValueKind.Array) + { + return Array.Empty(); + } + + return permissions.EnumerateArray() + .Select(p => p.GetString() ?? string.Empty) + .Where(s => s.Length > 0) + .ToArray(); + } + + private static JsonDocument DecodeJwtPayload(string token) + { + var segments = token.Split('.'); + segments.Length.Should().BeGreaterThanOrEqualTo(2, "un JWT tiene header.payload.signature"); + var payload = segments[1]; + var padded = payload.Replace('-', '+').Replace('_', '/'); + switch (padded.Length % 4) + { + case 2: padded += "=="; break; + case 3: padded += "="; break; + } + var bytes = Convert.FromBase64String(padded); + return JsonDocument.Parse(bytes); + } + + private static int CountClaimValues(JsonElement claim) + => claim.ValueKind == JsonValueKind.Array ? claim.GetArrayLength() : 1; } diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/CierreDeSesionPorDispositivoTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/CierreDeSesionPorDispositivoTests.cs new file mode 100644 index 00000000..4e7cb7d2 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/CierreDeSesionPorDispositivoTests.cs @@ -0,0 +1,105 @@ +using Ums.Infrastructure.Persistence.Seeders; +using Ums.Presentation.IntegrationTest.Infrastructure; + +namespace Ums.Presentation.IntegrationTest.Security; + +/// +/// G-247 — cerrar sesión cierra ESTE dispositivo, y solo este. +/// +/// Antes no cerraba nada: POST /auth/logout respondía 200 y la misma cookie seguía +/// devolviendo 200 en /auth/session. Se midió en vivo contra un despliegue de dos réplicas y +/// también contra una sola, así que no era un problema de reparto de carga: SignOutAsync +/// solo le pide al navegador que borre la cookie, y el portador seguía siendo válido hasta +/// caducar. +/// +/// La decisión de producto fue cerrar solo el dispositivo actual, no la cuenta entera. Estas +/// pruebas fijan las dos mitades de esa decisión —la que cierra y la que NO debe cerrar—, porque +/// una sola de ellas se puede satisfacer por accidente: revocar por usuario cerraría la sesión +/// actual igual de bien, y se llevaría por delante las demás. +/// +/// Las cookies se manejan a mano, no con un contenedor: el logout borra la del contenedor, y +/// lo que hay que comprobar es justo lo contrario —que una COPIA conservada del portador tampoco +/// sirva—. +/// +public sealed class CierreDeSesionPorDispositivoTests : IClassFixture +{ + private readonly UmsApiWebApplicationFactory _factory; + + public CierreDeSesionPorDispositivoTests(UmsApiWebApplicationFactory factory) => _factory = factory; + + private HttpClient NuevoCliente() => _factory.CreateClient(new WebApplicationFactoryClientOptions + { + BaseAddress = new Uri("https://localhost"), + AllowAutoRedirect = false, + }); + + /// Inicia sesión y devuelve la cookie emitida: eso es «un dispositivo». + private async Task AbrirSesionAsync(CancellationToken ct) + { + var cliente = NuevoCliente(); + var respuesta = await cliente.PostAsJsonAsync("/api/v1/auth/login", new + { + tenantCode = "BEYONDNET", + username = "admin@beyondnet.com.pe", + password = CoreDevDataSeeder.BeyondNetDevPassword, + rememberMe = false, + }, ct); + + respuesta.StatusCode.Should().Be(HttpStatusCode.OK, + because: await respuesta.Content.ReadAsStringAsync(ct)); + + var cookie = respuesta.Headers.TryGetValues("Set-Cookie", out var valores) + ? valores.Select(v => v.Split(';')[0]).FirstOrDefault(v => v.StartsWith("ums.session", StringComparison.Ordinal)) + : null; + + cookie.Should().NotBeNullOrWhiteSpace(because: "el login debe emitir la cookie de sesión"); + return cookie!; + } + + private HttpClient ClienteCon(string cookie) + { + var cliente = NuevoCliente(); + cliente.DefaultRequestHeaders.Add("Cookie", cookie); + return cliente; + } + + [Fact] + public async Task Cerrar_Sesion_Invalida_Una_Copia_Del_Portador() + { + var ct = TestContext.Current.CancellationToken; + var cookie = await AbrirSesionAsync(ct); + + (await ClienteCon(cookie).GetAsync("/api/v1/auth/session", ct)) + .StatusCode.Should().Be(HttpStatusCode.OK, because: "la sesión recién abierta debe servir"); + + (await ClienteCon(cookie).PostAsync("/api/v1/auth/logout", content: null, ct)) + .StatusCode.Should().Be(HttpStatusCode.OK); + + (await ClienteCon(cookie).GetAsync("/api/v1/auth/session", ct)) + .StatusCode.Should().Be(HttpStatusCode.Unauthorized, + because: "una copia del portador de una sesión cerrada no debe seguir abriendo la puerta"); + } + + [Fact] + public async Task Cerrar_Sesion_En_Un_Dispositivo_No_Cierra_El_Otro() + { + var ct = TestContext.Current.CancellationToken; + + // Mismo usuario, dos sesiones: el portátil y el móvil. + var portatil = await AbrirSesionAsync(ct); + var movil = await AbrirSesionAsync(ct); + portatil.Should().NotBe(movil, because: "cada login abre una sesión distinta"); + + (await ClienteCon(portatil).PostAsync("/api/v1/auth/logout", content: null, ct)) + .StatusCode.Should().Be(HttpStatusCode.OK); + + // ESTA es la mitad que se rompe si alguien «simplifica» revocando por usuario. + (await ClienteCon(movil).GetAsync("/api/v1/auth/session", ct)) + .StatusCode.Should().Be(HttpStatusCode.OK, + because: "cerrar sesión en el portátil no debe echar al mismo usuario de su móvil (G-247)"); + + // Y el portátil sigue cerrado: la comprobación anterior no vale si esta no se cumple. + (await ClienteCon(portatil).GetAsync("/api/v1/auth/session", ct)) + .StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/ClientAuthenticationBehavioralTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/ClientAuthenticationBehavioralTests.cs new file mode 100644 index 00000000..7a0e91e3 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/ClientAuthenticationBehavioralTests.cs @@ -0,0 +1,90 @@ +using Ums.Infrastructure.Persistence.Seeders; +using Ums.Presentation.IntegrationTest.Infrastructure; + +namespace Ums.Presentation.IntegrationTest.Security; + +/// +/// G-042 — E2E conductual de autenticación de sistemas cliente (RoboSoft/SDK). +/// +/// Verifica que DevAuthMiddleware, activo en el host de desarrollo, ya NO rompe el punto de +/// integración documentado POST /api/v1/client/authenticate: los inquilinos CLIENT +/// (COMEX_ANDINA, AGRONORTE) autentican por su TenantCode sin que el contexto se fije a BEYONDNET. +/// +/// Además ancla el endurecimiento del bypass X-Disable-Dev-Auth: sin credencial real +/// deja de ser un pase anónimo (fail-closed 401), pero con credencial (cookie de sesión) sigue +/// permitiendo ejercer la autenticación real. +/// +public sealed class ClientAuthenticationBehavioralTests : IClassFixture +{ + private readonly UmsApiWebApplicationFactory _factory; + private readonly HttpClient _client; + + public ClientAuthenticationBehavioralTests(UmsApiWebApplicationFactory factory) + { + _factory = factory; + _client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + BaseAddress = new Uri("https://localhost"), + AllowAutoRedirect = false, + }); + } + + [Theory] + [InlineData("COMEX_ANDINA", "usuario.impo@comexandina.com.pe")] + [InlineData("AGRONORTE", "usuario.expo@agronorte.com.pe")] + public async Task ClientAuthenticate_WithSeededClientTenant_ReturnsTokenAndGraph(string tenantCode, string username) + { + var ct = TestContext.Current.CancellationToken; + + var response = await _client.PostAsJsonAsync("/api/v1/client/authenticate", new + { + tenantCode, + username, + password = CoreDevDataSeeder.BeyondNetDevPassword, + }, ct); + + var body = await response.Content.ReadAsStringAsync(ct); + response.StatusCode.Should().Be(HttpStatusCode.OK, + because: $"el inquilino CLIENT {tenantCode} debe autenticar por su TenantCode y no colapsar a BEYONDNET (G-042). Cuerpo: {body}"); + + using var payload = JsonDocument.Parse(body); + payload.RootElement.GetProperty("token").GetString().Should().NotBeNullOrWhiteSpace(); + payload.RootElement.GetProperty("tokenType").GetString().Should().Be("Bearer"); + payload.RootElement.GetProperty("graph").GetString().Should().NotBeNull(); + } + + [Fact] + public async Task ClientAuthenticate_WithWrongPassword_ReturnsUnauthorized() + { + var ct = TestContext.Current.CancellationToken; + + var response = await _client.PostAsJsonAsync("/api/v1/client/authenticate", new + { + tenantCode = "COMEX_ANDINA", + username = "usuario.impo@comexandina.com.pe", + password = "contraseña-incorrecta", + }, ct); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task DisableDevAuth_WithoutCredential_OnProtectedEndpoint_IsFailClosed401() + { + // G-042 (endurecimiento): X-Disable-Dev-Auth:true sin credencial real ya NO permite + // saltar la autenticación (antes → 200 anónimo). El middleware corta con 401. + var ct = TestContext.Current.CancellationToken; + + var anonymous = _factory.CreateClient(new WebApplicationFactoryClientOptions + { + BaseAddress = new Uri("https://localhost"), + AllowAutoRedirect = false, + }); + anonymous.DefaultRequestHeaders.Add("X-Disable-Dev-Auth", "true"); + + var response = await anonymous.GetAsync("/api/v1/user-accounts?page=1&pageSize=1", ct); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized, + because: "sin cabecera Authorization ni cookie ums.session, el bypass debe fallar cerrado"); + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/ContratoDeClientAuthenticateTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/ContratoDeClientAuthenticateTests.cs new file mode 100644 index 00000000..929669ff --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/ContratoDeClientAuthenticateTests.cs @@ -0,0 +1,220 @@ +using System.Text.Json.Nodes; +using Ums.Infrastructure.Persistence.Seeders; +using Ums.Presentation.IntegrationTest.Infrastructure; + +namespace Ums.Presentation.IntegrationTest.Security; + +/// +/// G-207 — el sobre que devuelve POST /api/v1/client/authenticate, capturado de la API real. +/// +/// Los dos SDK de cliente tipaban graph como objeto. La API lo devuelve como +/// cadena serializada, porque el formato lo elige el inquilino (JSON, XML, YAML o CSV) y un +/// objeto no puede transportar XML. Consecuencia: parsed.graph?.schemaVersion daba +/// undefined, el cliente devolvía AUTH_204 y cualquier login a través del SDK +/// fallaba. Ningún SDK había ejercido nunca el endpoint real; sus pruebas usaban objetos +/// escritos a mano con la forma que el SDK creía correcta. +/// +/// Por eso este fichero no comprueba la forma contra una expectativa escrita a mano: la +/// captura de la API viva y la deja en un fixture que consumen las pruebas de los dos SDK. +/// Si la API cambia el sobre, el fixture deja de cuadrar y esta prueba falla — que es justo lo que +/// no ocurrió cuando el sobre y los SDK divergieron. +/// +public sealed class ContratoDeClientAuthenticateTests : IClassFixture +{ + private readonly HttpClient _client; + + public ContratoDeClientAuthenticateTests(UmsApiWebApplicationFactory factory) + { + _client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + BaseAddress = new Uri("https://localhost"), + AllowAutoRedirect = false, + }); + } + + /// + /// Campos que cambian en cada ejecución, con el valor fijo que los sustituye. + /// + /// Sin fijarlos, el fixture cambiaría en cada corrida y la comprobación de deriva no + /// diría nada. El sustituto conserva el TIPO —una fecha sigue siendo una fecha— porque el + /// fixture no es una foto para mirar: lo deserializan los dos SDK, y un marcador en prosa donde + /// va una fecha los haría fallar por el fixture, no por el contrato. + /// + /// El token se sustituye además por higiene: un portador real firmado no tiene por qué + /// quedar publicado en el repositorio. + /// + private static readonly Dictionary CamposVolatiles = new() + { + ["token"] = "PORTADOR.FIJADO.EN.EL.FIXTURE", + ["issuedAt"] = "2026-01-01T00:00:00.0000000+00:00", + ["requestId"] = "0HN7QMV8KJLDT:00000001", + }; + + [Fact] + public async Task El_Sobre_De_Client_Authenticate_Coincide_Con_El_Fixture_Capturado() + { + var ct = TestContext.Current.CancellationToken; + + var response = await _client.PostAsJsonAsync("/api/v1/client/authenticate", new + { + tenantCode = "COMEX_ANDINA", + username = "usuario.impo@comexandina.com.pe", + password = CoreDevDataSeeder.BeyondNetDevPassword, + }, ct); + + var cuerpo = await response.Content.ReadAsStringAsync(ct); + response.StatusCode.Should().Be(HttpStatusCode.OK, because: $"Cuerpo: {cuerpo}"); + + var capturado = Normalizar(cuerpo); + var ruta = RutaDelFixture(); + + if (!File.Exists(ruta)) + { + await File.WriteAllTextAsync(ruta, capturado, ct); + Assert.Fail($"No existía el fixture del sobre; se ha generado en {ruta}. Revísalo y publícalo."); + } + + var comprometido = await File.ReadAllTextAsync(ruta, ct); + capturado.Should().Be(comprometido, + because: "el sobre de /client/authenticate cambió respecto al fixture que consumen los SDK. " + + "Si el cambio es intencionado, borra el fichero y vuelve a ejecutar esta prueba para regenerarlo, " + + "y comprueba que los clientes de los dos SDK lo siguen entendiendo (G-207)."); + } + + /// + /// Comprobación explícita del hecho que rompía a los SDK, aparte de la comparación con el + /// fixture: un cambio de tipo aquí debe leerse en el nombre de la prueba que falla, no + /// deducirse de un diff de 10 000 caracteres. + /// + [Fact] + public async Task El_Grafo_Viaja_Como_Cadena_Serializada_No_Como_Objeto() + { + var ct = TestContext.Current.CancellationToken; + + var response = await _client.PostAsJsonAsync("/api/v1/client/authenticate", new + { + tenantCode = "COMEX_ANDINA", + username = "usuario.impo@comexandina.com.pe", + password = CoreDevDataSeeder.BeyondNetDevPassword, + }, ct); + + using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); + var grafo = payload.RootElement.GetProperty("graph"); + + grafo.ValueKind.Should().Be(JsonValueKind.String, + because: "el formato lo elige el inquilino (JSON/XML/YAML/CSV) y un objeto no puede transportar XML"); + + // `requestId` es el TraceIdentifier de ASP.NET Core (`0HN...:00000001`), no un GUID. El + // cliente .NET lo tipaba como Guid y reventaba al deserializar antes siquiera de mirar el + // grafo: dos defectos distintos que se manifestaban como el mismo fallo. + payload.RootElement.GetProperty("requestId").ValueKind.Should().Be(JsonValueKind.String); + Guid.TryParse(payload.RootElement.GetProperty("requestId").GetString(), out _) + .Should().BeFalse(because: "es un identificador de traza, no un GUID; tiparlo como Guid rompe el cliente"); + } + + /// + /// Lo volátil de DENTRO del grafo, que viaja serializado en el campo `graph`. + /// + /// Normalizar solo el sobre no basta: el grafo lleva cuatro marcas de tiempo y el + /// identificador del perfil, que cambian en cada ejecución (y el id, además, en cada + /// recreación de la base sembrada). Sin fijarlos, el fixture derivaba siempre y la + /// comprobación no distinguía un cambio de contrato de un simple reloj distinto. + /// + private static readonly Dictionary CamposVolatilesDelGrafo = new() + { + ["generatedAt"] = "2026-01-01T00:00:00.0000000Z", + // Lejano a propósito: un `validUntil` fijo en el pasado haría que cualquier consumidor del + // fixture lo rechazara por caducado, y el fixture existe para probar el contrato, no la + // caducidad — para eso está `expired-graph.json`. + ["validUntil"] = "2099-12-31T23:59:59.0000000Z", + ["issuedAt"] = "2026-01-01T00:00:00.0000000Z", + ["sessionExpiresAt"] = "2099-12-31T23:59:59.0000000Z", + ["id"] = "00000000-0000-0000-0000-000000000001", + }; + + /// Recorre el grafo y fija lo volátil, conservando el resto intacto. + private static string NormalizarGrafo(string json) + { + var raiz = JsonNode.Parse(json); + Recorrer(raiz); + + return raiz!.ToJsonString(new JsonSerializerOptions + { + WriteIndented = true, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }); + + static void Recorrer(JsonNode? nodo) + { + switch (nodo) + { + case JsonObject obj: + foreach (var clave in obj.Select(par => par.Key).ToList()) + { + if (CamposVolatilesDelGrafo.TryGetValue(clave, out var fijo) && + obj[clave] is JsonValue) + { + obj[clave] = fijo; + } + else + { + Recorrer(obj[clave]); + } + } + break; + + case JsonArray arr: + foreach (var hijo in arr) Recorrer(hijo); + break; + } + } + } + + /// Sustituye lo volátil por un marcador fijo y reindenta, para que el diff sea legible. + private static string Normalizar(string cuerpo) + { + using var doc = JsonDocument.Parse(cuerpo); + var campos = new Dictionary(); + + foreach (var prop in doc.RootElement.EnumerateObject()) + { + if (CamposVolatiles.TryGetValue(prop.Name, out var fijo)) + { + campos[prop.Name] = fijo; + } + else if (prop.Name == "graph") + { + campos[prop.Name] = NormalizarGrafo(prop.Value.GetString()!); + } + else + { + campos[prop.Name] = prop.Value.ValueKind switch + { + JsonValueKind.String => prop.Value.GetString(), + JsonValueKind.Number => prop.Value.GetInt64(), + _ => prop.Value.ToString() + }; + } + } + + return JsonSerializer.Serialize(campos, new JsonSerializerOptions + { + WriteIndented = true, + // Sin esto cada tilde y cada comilla del grafo salen como \uXXXX y el fixture es + // ilegible: un fixture que nadie puede leer no se revisa, se acepta. + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }); + } + + private static string RutaDelFixture() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !Directory.Exists(Path.Combine(dir.FullName, ".git"))) + dir = dir.Parent; + + if (dir is null) throw new InvalidOperationException("No se encontró la raíz del repositorio."); + + return Path.Combine(dir.FullName, "src", "libs", "sdk", "contracts", "fixtures", + "client-authenticate.envelope.json"); + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/LimiteDePeticionesTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/LimiteDePeticionesTests.cs new file mode 100644 index 00000000..4c14b375 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/LimiteDePeticionesTests.cs @@ -0,0 +1,109 @@ +using Ums.Infrastructure.Persistence.Seeders; +using Ums.Presentation.IntegrationTest.Infrastructure; + +namespace Ums.Presentation.IntegrationTest.Security; + +/// +/// G-248 — el cupo de peticiones se cuenta por QUIEN llama, no por la IP de la que vienen todos. +/// +/// El limitador anterior corría antes de UseAuthentication, así que +/// HttpContext.User venía vacío y la clave de reparto caía siempre a la IP. Medido el +/// 2026-08-04 con el cupo en 6: tres peticiones de una cuenta y tres de otra bastaban para que +/// AMBAS recibieran 429. Detrás de un Ingress —donde la IP que ve el proceso es la del +/// controlador— eso significaba un único cubo para todo el tráfico. +/// +/// Estas pruebas comprueban las dos mitades: que el cupo se aplica, y que NO se comparte +/// entre usuarios distintos. La primera sola pasaría también con el limitador roto. +/// +public sealed class LimiteDePeticionesTests : IClassFixture +{ + private readonly UmsApiWebApplicationFactory _factory; + + public LimiteDePeticionesTests(UmsApiWebApplicationFactory factory) => _factory = factory; + + private HttpClient NuevoCliente() => _factory.CreateClient(new WebApplicationFactoryClientOptions + { + BaseAddress = new Uri("https://localhost"), + AllowAutoRedirect = false, + }); + + private async Task AbrirSesionAsync(string tenantCode, string usuario, CancellationToken ct) + { + var respuesta = await NuevoCliente().PostAsJsonAsync("/api/v1/auth/login", new + { + tenantCode, + username = usuario, + password = CoreDevDataSeeder.BeyondNetDevPassword, + rememberMe = false, + }, ct); + + respuesta.StatusCode.Should().Be(HttpStatusCode.OK, + because: await respuesta.Content.ReadAsStringAsync(ct)); + + var cookie = respuesta.Headers.TryGetValues("Set-Cookie", out var valores) + ? valores.Select(v => v.Split(';')[0]) + .FirstOrDefault(v => v.StartsWith("ums.session", StringComparison.Ordinal)) + : null; + + cookie.Should().NotBeNullOrWhiteSpace(); + return cookie!; + } + + private HttpClient ClienteCon(string cookie) + { + var cliente = NuevoCliente(); + cliente.DefaultRequestHeaders.Add("Cookie", cookie); + return cliente; + } + + [Fact] + public async Task Dos_Usuarios_Distintos_No_Comparten_El_Cupo() + { + var ct = TestContext.Current.CancellationToken; + + // Dos cuentas de inquilinos distintos, desde la MISMA dirección: es el caso que el + // limitador anterior confundía en un solo cubo. + var uno = await AbrirSesionAsync("BEYONDNET", "admin@beyondnet.com.pe", ct); + var otro = await AbrirSesionAsync("COMEX_ANDINA", "usuario.impo@comexandina.com.pe", ct); + + // Se gastan bastantes peticiones con la PRIMERA cuenta. Con el cupo por defecto (100/min) + // no se llega a agotar; lo que se comprueba es que lo que gaste una no se le descuenta a la + // otra, que es la propiedad que estaba rota. + for (var i = 0; i < 40; i++) + { + var r = await ClienteCon(uno).GetAsync("/api/v1/auth/session", ct); + r.StatusCode.Should().NotBe(HttpStatusCode.TooManyRequests, + because: "40 peticiones no deben agotar un cupo de 100"); + } + + var deLaOtraCuenta = await ClienteCon(otro).GetAsync("/api/v1/auth/session", ct); + deLaOtraCuenta.StatusCode.Should().Be(HttpStatusCode.OK, + because: "lo que gasta una cuenta no puede descontarse del cupo de otra (G-248)"); + } + + [Fact] + public async Task El_Cupo_Se_Aplica_Y_Responde_429_Con_Retry_After() + { + var ct = TestContext.Current.CancellationToken; + + // Sin sesión: el cupo se cuenta por IP. Una cabecera `X-Forwarded-For` propia aísla esta + // prueba de las demás — si compartiera cubo con ellas, el resultado dependería del orden + // de ejecución, que es la clase de prueba que falla los martes. + var cliente = NuevoCliente(); + cliente.DefaultRequestHeaders.Add("X-Forwarded-For", "203.0.113.77"); + + HttpResponseMessage? rechazada = null; + for (var i = 0; i < 130 && rechazada is null; i++) + { + var r = await cliente.GetAsync("/api/v1/auth/session", ct); + if (r.StatusCode == HttpStatusCode.TooManyRequests) rechazada = r; + } + + rechazada.Should().NotBeNull(because: "pasado el cupo de 100 por ventana debe llegar un 429"); + rechazada!.Headers.RetryAfter.Should().NotBeNull( + because: "un 429 sin `Retry-After` obliga a quien llama a adivinar cuándo reintentar"); + + var cuerpo = await rechazada.Content.ReadAsStringAsync(ct); + cuerpo.Should().Contain("Too Many Requests"); + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/NormalizacionRedisTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/NormalizacionRedisTests.cs new file mode 100644 index 00000000..31303fd8 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/NormalizacionRedisTests.cs @@ -0,0 +1,49 @@ +namespace Ums.Presentation.IntegrationTest.Security; + +using FluentAssertions; +using Ums.Infrastructure.Configuration; + +/// +/// La forma del valor de Redis, que tumbó el arranque en el clúster. +/// +/// El manifiesto declara `REDIS_CONNECTION` como URI —`redis://ums-redis:6379`, que es la +/// convención razonable— y ese valor se pasaba tal cual a `ConnectionMultiplexer.Connect`, que no +/// entiende el esquema. Medido el 2026-08-02 al desplegar una imagen actual: el proceso no +/// arrancaba, con una excepción que acusaba a Redis —vivo y sano— en vez de a la notación. +/// +/// El defecto llevaba latente desde que se escribió el camino de código: el clúster corría una +/// imagen anterior, así que nunca se ejecutaba. Estas pruebas existen para que no vuelva a hacer +/// falta un despliegue para descubrirlo. +/// +public sealed class NormalizacionRedisTests +{ + [Theory] + [InlineData("redis://ums-redis:6379", "ums-redis:6379")] + [InlineData("rediss://ums-redis:6380", "ums-redis:6380")] + [InlineData("REDIS://UMS-REDIS:6379", "UMS-REDIS:6379")] + [InlineData(" redis://ums-redis:6379 ", "ums-redis:6379")] + public void Una_uri_pierde_el_esquema_y_conserva_todo_lo_demas(string entrada, string esperado) + { + Ums.Infrastructure.Configuration.CadenaDeRedis.Normalizar(entrada).Should().Be(esperado); + } + + [Theory] + [InlineData("ums-redis:6379")] + [InlineData("localhost:6379")] + // Las opciones van detrás del host y NO se tocan: recortar más de lo necesario cambiaría la + // conexión en vez de solo su notación. + [InlineData("ums-redis:6379,abortConnect=false,ssl=true")] + public void Lo_que_ya_viene_en_la_forma_correcta_no_se_altera(string entrada) + { + Ums.Infrastructure.Configuration.CadenaDeRedis.Normalizar(entrada).Should().Be(entrada); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Sin_valor_se_devuelve_tal_cual_para_que_el_llamante_caiga_al_anillo_en_memoria(string? entrada) + { + Ums.Infrastructure.Configuration.CadenaDeRedis.Normalizar(entrada).Should().Be(entrada); + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/RefreshTokenPerTenantE2ETests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/RefreshTokenPerTenantE2ETests.cs new file mode 100644 index 00000000..a1bcf7e4 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/RefreshTokenPerTenantE2ETests.cs @@ -0,0 +1,169 @@ +using Ums.Infrastructure.Persistence.Seeders; +using Ums.Presentation.IntegrationTest.Infrastructure; +using AppConfigProvider = Ums.Application.Configuration.Services.IConfigurationProvider; + +namespace Ums.Presentation.IntegrationTest.Security; + +/// +/// E2E HTTP del refresh token opaco configurable por inquilino (ADR-UMS-091 / FR-015/016, G-034). +/// +/// Cierra el residual de G-034: los 12 tests unitarios de RefreshAuthenticationCommandHandler +/// ejercen el handler con colaboradores mockeados; aquí se recorre el flujo HTTP completo +/// sobre el host de integración (, login real con la data +/// semilla) con el flag AUTH_REFRESH_TOKEN_ENABLED activado para un inquilino concreto: +/// +/// 1. Fail-closed: con el flag apagado (default de ADR-UMS-088) el login no emite refresh token. +/// 2. Activado el flag para el inquilino (config de alcance Tenant + recarga del proveedor), el +/// login emite un refresh token opaco. +/// 3. Ese token renueva la sesión por POST /api/v1/auth/refresh-token (200 + rotación: +/// devuelve un refresh token nuevo, distinto del presentado). +/// 4. Revocación: tras POST /api/v1/auth/logout (logout real, ADR-UMS-091/FR-016) el +/// token ya rotado/vivo deja de renovar (401): un token revocado no renueva. +/// +/// El flag se activa por inquilino (no global): honra la cascada Global>Suite>Tenant>Module +/// y la naturaleza multi-inquilino de la capacidad. +/// +public sealed class RefreshTokenPerTenantE2ETests : IClassFixture +{ + private const string TenantCode = "RANSA_PERU"; + private const string Username = "gerente.operaciones@ransa.pe"; + + private readonly UmsApiWebApplicationFactory _factory; + private readonly HttpClient _client; + + public RefreshTokenPerTenantE2ETests(UmsApiWebApplicationFactory factory) + { + _factory = factory; + _client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + BaseAddress = new Uri("https://localhost"), + AllowAutoRedirect = false, + }); + } + + [Fact] + public async Task RefreshToken_PerTenantFlag_IssuesRenewsAndRevocationStopsRenewal() + { + var ct = TestContext.Current.CancellationToken; + + // 1. Login con el flag APAGADO (default): fail-closed ⇒ no se emite refresh token. + // De paso descubrimos el TenantId real del inquilino semilla para activarle el flag. + var preFlag = await LoginAsync(ct); + preFlag.RefreshToken.Should().BeNullOrEmpty( + "con AUTH_REFRESH_TOKEN_ENABLED apagado el login no emite refresh token (fail-closed, ADR-UMS-088)"); + + // 2. Activar la capacidad SOLO para ese inquilino (config de alcance Tenant) y recargar el + // proveedor de configuración (singleton) para que la resuelva en caliente. + await EnableRefreshTokenForTenantAsync(preFlag.TenantId, ct); + + // 3. Login con el flag ENCENDIDO para el inquilino ⇒ ahora el login emite el refresh token opaco. + var login = await LoginAsync(ct); + login.RefreshToken.Should().NotBeNullOrEmpty( + "activada la capacidad para el inquilino, el login debe emitir un refresh token opaco (FR-015)"); + + // 4. El refresh token RENUEVA la sesión: 200 + rotación (nuevo refresh distinto del presentado). + var (renewStatus, rotatedRefreshToken) = await PostRefreshTokenAsync(login.RefreshToken!, ct); + renewStatus.Should().Be(HttpStatusCode.OK, + "un refresh token vivo debe renovar la sesión mientras el inquilino tenga la capacidad activa"); + rotatedRefreshToken.Should().NotBeNullOrEmpty("la política rota por defecto ⇒ se emite un refresh token nuevo"); + rotatedRefreshToken.Should().NotBe(login.RefreshToken, + "la rotación debe entregar un refresh token distinto del presentado (detección de reuso)"); + + // 5. REVOCACIÓN: logout real (ADR-UMS-091/FR-016) revoca todas las familias vivas del usuario. + // El cliente lleva la cookie de sesión del último login, que identifica al principal. + var logout = await _client.PostAsync("/api/v1/auth/logout", content: null, ct); + logout.StatusCode.Should().Be(HttpStatusCode.OK); + + // 6. Un token REVOCADO ya no renueva: presentar el refresh token vivo (rotado) tras el logout + // debe fallar (401), demostrando que la revocación es efectiva end-to-end. + var (afterRevokeStatus, _) = await PostRefreshTokenAsync(rotatedRefreshToken!, ct); + afterRevokeStatus.Should().Be(HttpStatusCode.Unauthorized, + "tras la revocación, el refresh token de la familia ya no debe renovar la sesión"); + } + + // ── Helpers ────────────────────────────────────────────────────────────────── + + private sealed record LoginOutcome(Guid UserId, Guid TenantId, string? RefreshToken); + + /// + /// Login real contra la data semilla; captura la cookie de sesión (para el logout) y el + /// refresh token opaco (null cuando el inquilino no activó la capacidad). + /// + private async Task LoginAsync(CancellationToken ct) + { + var loginResponse = await _client.PostAsJsonAsync("/api/v1/auth/login", new + { + tenantCode = TenantCode, + username = Username, + password = CoreDevDataSeeder.SuperAdminPassword, + rememberMe = false, + }, ct); + + loginResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var setCookieHeader = loginResponse.Headers.GetValues("Set-Cookie").FirstOrDefault(); + setCookieHeader.Should().NotBeNullOrEmpty(); + var cookieValue = setCookieHeader!.Split(';')[0]; + _client.DefaultRequestHeaders.Remove("Cookie"); + _client.DefaultRequestHeaders.Add("Cookie", cookieValue); + + using var payload = JsonDocument.Parse(await loginResponse.Content.ReadAsStringAsync(ct)); + var root = payload.RootElement; + + var userId = Guid.Parse(root.GetProperty("userId").GetString()!); + var tenantId = Guid.Parse(root.GetProperty("tenantId").GetString()!); + var refreshToken = root.TryGetProperty("refreshToken", out var rt) && rt.ValueKind == JsonValueKind.String + ? rt.GetString() + : null; + + return new LoginOutcome(userId, tenantId, refreshToken); + } + + private async Task<(HttpStatusCode Status, string? RefreshToken)> PostRefreshTokenAsync( + string refreshToken, CancellationToken ct) + { + var response = await _client.PostAsJsonAsync("/api/v1/auth/refresh-token", new + { + refreshToken, + }, ct); + + if (response.StatusCode != HttpStatusCode.OK) + { + return (response.StatusCode, null); + } + + using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); + var newToken = payload.RootElement.TryGetProperty("refreshToken", out var rt) && rt.ValueKind == JsonValueKind.String + ? rt.GetString() + : null; + return (response.StatusCode, newToken); + } + + /// + /// Siembra AUTH_REFRESH_TOKEN_ENABLED = true con alcance Tenant para el inquilino dado y + /// recarga el proveedor de configuración (singleton) para que la política se resuelva en caliente. + /// + private async Task EnableRefreshTokenForTenantAsync(Guid tenantId, CancellationToken ct) + { + var actor = ActorId.Create("g034-e2e"); + + var config = AppConfiguration.Create( + TenantId.Load(tenantId), + null, + null, + Code.Create(AppConfigurationCodes.AuthRefreshTokenEnabled), + ConfigurationValue.Create("true"), + Description.Create("G-034 E2E: refresh token opaco activado por inquilino."), + isInheritable: true, + isEncrypted: false, + actor).Value; + config.Publish(actor); + + using var scope = _factory.Services.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + repository.Seed(config); + + var provider = _factory.Services.GetRequiredService(); + await provider.ReloadAsync(ct); + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/TenantIsolationTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/TenantIsolationTests.cs index 68e274c4..4260c7ea 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/TenantIsolationTests.cs +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Security/TenantIsolationTests.cs @@ -36,8 +36,8 @@ public async Task ZeroDataLeakage_QueryingOtherTenantUsers_ShouldBeRejected() loginResponse.StatusCode.Should().Be(HttpStatusCode.OK); using var payload = JsonDocument.Parse(await loginResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); - var ransaTenantId = payload.RootElement.GetProperty("tenantId").GetString(); + if (loginResponse.Headers.TryGetValues("Set-Cookie", out var setCookies)) { var cookie = setCookies.FirstOrDefault(c => c.StartsWith("ums.session=")); diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Ums.Presentation.IntegrationTest.csproj b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Ums.Presentation.IntegrationTest.csproj index b7f5e4fd..8509c750 100644 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Ums.Presentation.IntegrationTest.csproj +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Ums.Presentation.IntegrationTest.csproj @@ -23,6 +23,7 @@ all + @@ -30,6 +31,11 @@ PreserveNewest + + + PreserveNewest + diff --git a/src/apps/ums.api/Ums.Presentation/Bootstrapping/LanguageHeaderOperationFilter.cs b/src/apps/ums.api/Ums.Presentation/Bootstrapping/LanguageHeaderOperationFilter.cs new file mode 100644 index 00000000..3bf19dcc --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation/Bootstrapping/LanguageHeaderOperationFilter.cs @@ -0,0 +1,25 @@ +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Ums.Presentation.Bootstrapping; + +/// +/// Filtro de operación de Swagger que añade la cabecera opcional X-Language +/// a cada endpoint documentado (selección de idioma; cae a Accept-Language y luego 'en'). +/// +internal sealed class LanguageHeaderOperationFilter : IOperationFilter +{ + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + operation.Parameters ??= new List(); + operation.Parameters.Add(new OpenApiParameter + { + Name = "X-Language", + In = ParameterLocation.Header, + Required = false, + Description = "Language code (e.g. 'en', 'es'). Falls back to Accept-Language then 'en'.", + Schema = new OpenApiSchema { Type = "string", Default = new OpenApiString("en") }, + }); + } +} diff --git a/src/apps/ums.api/Ums.Presentation/Bootstrapping/UmsApiServiceBootstrappers.cs b/src/apps/ums.api/Ums.Presentation/Bootstrapping/UmsApiServiceBootstrappers.cs index fb7919cc..e7c4ff56 100644 --- a/src/apps/ums.api/Ums.Presentation/Bootstrapping/UmsApiServiceBootstrappers.cs +++ b/src/apps/ums.api/Ums.Presentation/Bootstrapping/UmsApiServiceBootstrappers.cs @@ -1,7 +1,6 @@ using Asp.Versioning; using Asp.Versioning.Builder; using System.Diagnostics; -using System.Threading.RateLimiting; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; @@ -16,6 +15,7 @@ using Ums.Infrastructure.Persistence.Options; using Ums.Infrastructure.Persistence.Seeders; using Ums.Presentation.Endpoints; +using Ums.Presentation.GraphQL; using Ums.Presentation.Services; using Ums.Presentation.Endpoints.Approvals.AccessEnforcementPolicy; using Ums.Presentation.Endpoints.Approvals.AccessEnforcementPolicy.Queries; @@ -56,7 +56,9 @@ using Ums.Presentation.Endpoints.Identity.UserManagementDelegation; using Ums.Presentation.Endpoints.Identity.UserManagementDelegation.Queries; using Ums.Presentation.Endpoints.Identity.Onboarding; -using Ums.Presentation.GraphQL; +using Ums.Presentation.Endpoints.Iga.RolePromotionRequest; +using Ums.Presentation.Endpoints.Iga.RolePromotionRequest.Queries; +using Ums.Presentation.Endpoints.Iga.RoleMaturityStatus.Queries; using Ums.Presentation.Middleware; using Ums.Presentation.Bootstrapping.Bootstrappers; using BeyondNetCode.Shell.Bootstrapper.Impl; @@ -75,7 +77,6 @@ public static IServiceCollection AddUmsApiServiceBootstrappers( new CompositeBootstrapper() .Add(new UmsCoreApplicationBootstrapper(services, configuration, environment)) .Add(new UmsApiPlatformBootstrapper(services, configuration)) - .Add(new UmsApiRateLimitingBootstrapper(services, configuration)) .Add(new UmsApiDocumentationBootstrapper(services, configuration)) .Add(new ConfigurationBootstrapper(services)) .Run(); @@ -108,14 +109,19 @@ public void Run() Result.AddApplication(); Result.AddInfrastructure(_configuration, _environment); Result.AddScoped(); + // GraphQL: transporte de consulta propio del satélite (ausente en la plataforma de origen). Result.AddUmsGraphQl(_environment); Result.AddMemoryCache(); // required by IdempotencyMiddleware (FIX-06) // HARDENING-02: JWT Bearer authentication. Disabled in dev (DevAuthMiddleware handles it). // Production: set Authentication:Enabled=true and configure Authority + Audience. - Result.AddUmsAuthentication(_configuration); + Result.AddUmsAuthentication(_configuration, _environment); // JWT Token Service for session management + // El material de firma es unico y de por vida: la clave se carga y valida una sola + // vez, al arrancar. Si falta o es debil, UMS no arranca (ADR-0157 §4.7) en vez de + // descubrirlo al primer login. + Result.AddSingleton(); Result.AddSingleton(); } } @@ -147,6 +153,19 @@ public void Run() Result.AddProblemDetails(); Result.AddEndpointsApiExplorer(); + // G-175: compresión de respuesta. `EnableForHttps` va explícito porque el valor por + // defecto es false: sin él, en producción (que es HTTPS) no comprimiría nada. + // El riesgo BREACH que motiva ese default se acota aquí porque la API no emite + // formularios HTML ni refleja secretos en el cuerpo, y los tokens viajan en cabecera. + Result.AddResponseCompression(options => + { + options.EnableForHttps = true; + options.Providers.Add(); + options.Providers.Add(); + options.MimeTypes = Microsoft.AspNetCore.ResponseCompression.ResponseCompressionDefaults.MimeTypes + .Concat(["application/problem+json", "application/xml", "text/yaml", "text/csv"]); + }); + // REC-02: Real health checks — liveness + readiness + outbox backlog Result.AddInfrastructureHealthChecks(_configuration); @@ -155,108 +174,12 @@ public void Run() } } -internal sealed class UmsApiRateLimitingBootstrapper : IBootstrapper -{ - private readonly IConfiguration _configuration; - - public UmsApiRateLimitingBootstrapper(IServiceCollection services, IConfiguration configuration) - { - Result = services; - _configuration = configuration; - } - - public IServiceCollection? Result { get; private set; } - - public void Run() - { - ArgumentNullException.ThrowIfNull(Result); - - var rateLimit = _configuration.GetSection("ApiSettings:RateLimiting"); - var permitLimit = rateLimit.GetValue("PermitLimit", 100); - var windowMinutes = rateLimit.GetValue("WindowMinutes", 1); - - Result.AddRateLimiter(options => - { - static string ResolvePartitionKey(HttpContext ctx, string prefix = "") - { - var tenantId = ctx.User.FindFirst("tenant_id")?.Value - ?? ctx.User.FindFirst("org_id")?.Value; - - var sub = ctx.User.FindFirst("sub")?.Value - ?? ctx.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; - - if (!string.IsNullOrEmpty(tenantId) && !string.IsNullOrEmpty(sub)) - return $"{prefix}tenant:{tenantId}:user:{sub}"; - - if (!string.IsNullOrEmpty(sub)) - return $"{prefix}user:{sub}"; - - var apiKey = ctx.Request.Headers["X-Api-Key"].FirstOrDefault(); - if (!string.IsNullOrEmpty(apiKey)) - return $"{prefix}apikey:{apiKey}"; - - var ip = ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown"; - return $"{prefix}ip:{ip}"; - } - - options.AddPolicy("graphql", context => - { - var key = ResolvePartitionKey(context, "gql:"); - return RateLimitPartition.GetFixedWindowLimiter( - partitionKey: key, - factory: _ => new FixedWindowRateLimiterOptions - { - PermitLimit = Math.Max(1, permitLimit / 2), - Window = TimeSpan.FromMinutes(windowMinutes), - QueueProcessingOrder = QueueProcessingOrder.OldestFirst, - QueueLimit = 0, - }); - }); - - options.GlobalLimiter = PartitionedRateLimiter.Create(context => - { - var key = ResolvePartitionKey(context); - return RateLimitPartition.GetFixedWindowLimiter( - partitionKey: key, - factory: _ => new FixedWindowRateLimiterOptions - { - PermitLimit = permitLimit, - Window = TimeSpan.FromMinutes(windowMinutes), - QueueProcessingOrder = QueueProcessingOrder.OldestFirst, - QueueLimit = 0, - }); - }); - - options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; - options.OnRejected = async (context, token) => - { - var problemDetails = new ProblemDetails - { - Title = "Too Many Requests", - Detail = "Rate limit exceeded. Please try again later.", - Status = StatusCodes.Status429TooManyRequests, - Type = "https://httpstatuses.io/429", - Extensions = - { - ["retryAfter"] = context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retry) - ? retry.ToString() - : "60", - }, - }; - - context.HttpContext.Response.ContentType = "application/problem+json"; - context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; - - await context.HttpContext.Response.WriteAsJsonAsync(problemDetails, token); - }; - }); - } -} - public static class UmsApiApplicationBuilderExtensions { public static async Task InitializeUmsPlatformAsync(this WebApplication app) { + DeclararEstadoDistribuido(app); + var persistence = app.Services.GetRequiredService>().Value; if (persistence.InitializePlatformStoreOnStartup) @@ -264,18 +187,31 @@ public static async Task InitializeUmsPlatformAsync(this WebAppl using var scope = app.Services.CreateScope(); var platformDbContext = scope.ServiceProvider.GetRequiredService(); - if (persistence.Provider == PersistenceProvider.Sqlite) - { - await SqliteSchemaBootstrapper.InitializeAsync(platformDbContext); - } - else if (persistence.Provider == PersistenceProvider.PostgreSql) + if (persistence.Provider == PersistenceProvider.PostgreSql) { - // Usamos EF Core Migrations nativas para PostgreSQL + // EF Core native migrations for PostgreSQL. await platformDbContext.Database.MigrateAsync(); + + // G-072: el read model (proyecciones de fase 1) vive en la MISMA base + // pero en su propio DbContext con sus propias migraciones. Sin migrarlo + // aquí, su tabla (`Authorization.PermissionTemplateReadModels`) no existe + // en un despliegue desde cero, y la primera proyección + // (`PermissionTemplatePublishedEvent`, disparada por el seed) falla con + // 42P01. Se migra en el mismo arranque, junto al contexto de plataforma. + var readModelDbContext = scope.ServiceProvider + .GetRequiredService(); + await readModelDbContext.Database.MigrateAsync(); } } - if (app.Environment.IsDevelopment() && persistence.SeedDevData) + // G-127: la siembra del dataset FS-25 (datos de referencia + demo) se gobierna por el + // flag SeedDevData DESACOPLADO del entorno, para habilitar el stage UAT (usuarios + // personas reales que ingresan y encuentran datos visibles). Guarda de defensa en + // profundidad: NUNCA se siembra en Production, aunque el flag venga en true por una + // config errónea. Los backdoors propios de Development —DevAuth, el endpoint Pact + // `/_pact/provider-states` y Swagger— siguen gated a IsDevelopment() por separado (ver + // UseUmsApiPipeline / MapUmsApiSurface): UAT obtiene los datos SIN exponer esos backdoors. + if (persistence.SeedDevData && !app.Environment.IsProduction()) { await app.SeedDevelopmentDataAsync(); } @@ -285,7 +221,11 @@ public static async Task InitializeUmsPlatformAsync(this WebAppl public static WebApplication UseUmsApiPipeline(this WebApplication app) { - app.UseCorrelationId(); + // G-175: primero de la cadena, para comprimir también lo que escriban los middlewares + // posteriores. El grafo de autorización que devuelve el login son 43-50 KB de JSON muy + // repetitivo: medido sobre capturas reales, Brotli lo deja en 3,7-5,3 KB (9-12x). + app.UseResponseCompression(); + app.UseSessionTracking(); app.UseSerilogRequestLogging(opts => { @@ -293,7 +233,6 @@ public static WebApplication UseUmsApiPipeline(this WebApplication app) { var requestContext = httpContext.RequestServices.GetRequiredService(); diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value ?? string.Empty); - diagnosticContext.Set("CorrelationId", httpContext.TraceIdentifier); diagnosticContext.Set("SessionTrackingId", requestContext.SessionTrackingId ?? string.Empty); diagnosticContext.Set("TraceId", requestContext.TraceId ?? Activity.Current?.TraceId.ToString() ?? string.Empty); diagnosticContext.Set("SpanId", requestContext.SpanId ?? Activity.Current?.SpanId.ToString() ?? string.Empty); @@ -306,8 +245,15 @@ public static WebApplication UseUmsApiPipeline(this WebApplication app) }); app.UseCulture(); + // ADR-0096 / ADR-UMS-085: abre la transacción funcional por fuera del manejador global + // para garantizar el desenlace y que el localizador legible viaje en la respuesta de error. + app.UseFunctionalTransaction(); app.UseGlobalExceptionHandler(); - app.UseRateLimiter(); + // G-248: aquí estaba `UseRateLimiter()`, y estaba mal por dos motivos a la vez. Contaba en + // proceso —con N réplicas el cupo efectivo era N veces el declarado— y corría ANTES de + // `UseAuthentication`, así que `HttpContext.User` venía vacío y la clave de reparto caía + // siempre a la IP: dos usuarios distintos compartían cupo. El límite vive ahora más abajo, + // después de autenticar, que es el único punto donde se sabe a quién se limita. if (app.Environment.IsDevelopment()) { @@ -326,6 +272,12 @@ public static WebApplication UseUmsApiPipeline(this WebApplication app) app.UseDevAuth(); } app.UseAuthentication(); + // G-248: DESPUÉS de autenticar —para que el cupo sea de quien llama y no de una IP que + // comparten todos— y ANTES de autorizar. El orden importa en los dos lados. Puesto antes de + // autenticar, la identidad aún no está resuelta y dos usuarios distintos comparten cupo. + // Puesto después de autorizar, las peticiones rechazadas con 401 no llegan a contarse, y + // son justo las que más interesa contar cuando alguien prueba credenciales a ciegas. + app.UseLimiteDePeticiones(app.Configuration); app.UseAuthorization(); app.UseIdempotency(); // FIX-07: Must run after auth so cached responses are never served to unauthenticated callers. app.UseTenantContext(); @@ -346,19 +298,51 @@ public static WebApplication MapUmsApiSurface(this WebApplication app) { var versionedGroup = app.CreateVersionedApiGroup(); - app.MapGraphQlSurface(versionedGroup); app.MapHealthSurface(); app.MapAuthEndpoints(); app.MapClientAuthEndpoints(); - app.MapPactProviderStateEndpoints(); + // El material publico con el que los satelites verifican (ADR-0157 §4.1). Va + // fuera de /api y sin version: `.well-known` es una ruta reservada por RFC 8615 + // y los validadores de .NET y de Node la buscan ahi, no donde nos convenga. + app.MapJwksEndpoints(); + + // G-101: el endpoint de estados de proveedor de Pact (`POST /_pact/provider-states`) + // siembra datos de dominio arbitrarios (inquilinos, cuentas, aprobaciones, feature + // flags…) de forma anónima. Sólo puede existir en desarrollo/contract-test, jamás en + // producción. La factoría de contract-test arranca en «Development» + // (ContractTestWebApplicationFactory), por lo que esta guarda —consistente con el + // patrón «solo dev» de Swagger y DevAuth— mantiene verde al proveedor de Pact sin + // exponer la superficie en entornos productivos. + if (app.Environment.IsDevelopment()) + { + app.MapPactProviderStateEndpoints(); + } versionedGroup.MapUmsCommandEndpoints(); versionedGroup.MapUmsQueryEndpoints(); + // Transporte GraphQL de solo lectura: superficie propia del satélite. + app.MapGraphQlSurface(versionedGroup); + return app; } + /// + /// GraphQL es el transporte de consulta propio del satélite (no existe en la plataforma de + /// origen). Se expone en la raíz y bajo el grupo versionado para que un cliente pueda fijar + /// la versión igual que hace con REST. + /// + internal static IEndpointRouteBuilder MapGraphQlSurface( + this IEndpointRouteBuilder endpoints, + RouteGroupBuilder versionedGroup) + { + endpoints.MapGraphQL("/graphql").WithTags("GraphQL - Queries"); + versionedGroup.MapGraphQL("/graphql").WithTags("GraphQL - Queries"); + + return endpoints; + } + internal static RouteGroupBuilder CreateVersionedApiGroup(this WebApplication app) { var versionSet = app.NewApiVersionSet() @@ -370,21 +354,6 @@ internal static RouteGroupBuilder CreateVersionedApiGroup(this WebApplication ap .WithApiVersionSet(versionSet); } - internal static IEndpointRouteBuilder MapGraphQlSurface( - this IEndpointRouteBuilder endpoints, - RouteGroupBuilder versionedGroup) - { - endpoints.MapGraphQL("/graphql") - .WithTags("GraphQL - Queries") - .RequireRateLimiting("graphql"); - - versionedGroup.MapGraphQL("/graphql") - .WithTags("GraphQL - Queries") - .RequireRateLimiting("graphql"); - - return endpoints; - } - internal static IEndpointRouteBuilder MapHealthSurface(this IEndpointRouteBuilder endpoints) { endpoints.MapGet("/health/live", () => Results.Ok(new @@ -422,6 +391,7 @@ internal static RouteGroupBuilder MapUmsCommandEndpoints(this RouteGroupBuilder .MapAuthorizationCommandEndpoints() .MapAuditCommandEndpoints() .MapApprovalsCommandEndpoints() + .MapIgaCommandEndpoints() .MapConfigurationCommandEndpoints(); return versionedGroup; @@ -434,6 +404,7 @@ internal static RouteGroupBuilder MapUmsQueryEndpoints(this RouteGroupBuilder ve .MapAuthorizationQueryEndpoints() .MapAuditQueryEndpoints() .MapApprovalsQueryEndpoints() + .MapIgaQueryEndpoints() .MapConfigurationQueryEndpoints(); return versionedGroup; @@ -444,6 +415,7 @@ internal static RouteGroupBuilder MapIdentityCommandEndpoints(this RouteGroupBui versionedGroup.MapTenantEndpoints(); versionedGroup.MapTenantBranchEndpoints(); versionedGroup.MapTenantIdentityProviderEndpoints(); + // Branding por inquilino: superficie propia del satélite. versionedGroup.MapTenantBrandingEndpoints(); versionedGroup.MapUserAccountEndpoints(); versionedGroup.MapDelegationEndpoints(); @@ -456,6 +428,7 @@ internal static RouteGroupBuilder MapIdentityQueryEndpoints(this RouteGroupBuild { versionedGroup.MapTenantQueryEndpoints(); versionedGroup.MapBranchQueryEndpoints(); + // Branding por inquilino: superficie propia del satélite. versionedGroup.MapBrandingQueryEndpoints(); versionedGroup.MapIdentityProviderQueryEndpoints(); versionedGroup.MapUserAccountQueryEndpoints(); @@ -520,6 +493,21 @@ internal static RouteGroupBuilder MapApprovalsQueryEndpoints(this RouteGroupBuil return versionedGroup; } + internal static RouteGroupBuilder MapIgaCommandEndpoints(this RouteGroupBuilder versionedGroup) + { + versionedGroup.MapRolePromotionRequestEndpoints(); + + return versionedGroup; + } + + internal static RouteGroupBuilder MapIgaQueryEndpoints(this RouteGroupBuilder versionedGroup) + { + versionedGroup.MapRolePromotionRequestQueryEndpoints(); + versionedGroup.MapRoleMaturityStatusQueryEndpoints(); + + return versionedGroup; + } + internal static RouteGroupBuilder MapConfigurationCommandEndpoints(this RouteGroupBuilder versionedGroup) { versionedGroup.MapAppConfigurationEndpoints(); @@ -540,6 +528,38 @@ internal static RouteGroupBuilder MapConfigurationQueryEndpoints(this RouteGroup return versionedGroup; } + /// + /// Declara en el arranque qué implementación de estado compartido quedó activa. + /// + /// Existe porque el modo degradado era indistinguible del correcto: si la cadena de Redis no + /// resolvía, la API arrancaba igual de contenta con las implementaciones en memoria y el + /// clúster se comportaba mal solo bajo carga y con más de una réplica (G-169). Un despliegue + /// multi-réplica que lea `InMemory` en esta línea sabe que va a servir sesiones incoherentes + /// ANTES de que se lo diga un usuario. + /// + private static void DeclararEstadoDistribuido(WebApplication app) + { + var logger = app.Services.GetRequiredService().CreateLogger("Ums.Startup"); + + var revocacion = app.Services.GetRequiredService().GetType().Name; + var configuracion = app.Services.GetRequiredService().GetType().Name; + var distribuido = !revocacion.StartsWith("InMemory", StringComparison.Ordinal); + + if (distribuido) + { + logger.LogInformation( + "Estado compartido: DISTRIBUIDO. Revocación={Revocacion}, Configuración={Configuracion}. Apto para múltiples réplicas.", + revocacion, configuracion); + return; + } + + logger.LogWarning( + "Estado compartido: EN MEMORIA (sin Redis). Revocación={Revocacion}, Configuración={Configuracion}. " + + "Válido solo con UNA réplica: con más de una, la revocación de tokens y la configuración no se propagan entre pods. " + + "Configure `Redis:Connection` o la variable `REDIS_CONNECTION` para habilitar el modo distribuido (G-169).", + revocacion, configuracion); + } + } internal sealed class UmsApiDocumentationBootstrapper : IBootstrapper @@ -564,7 +584,7 @@ public void Run() { Title = "UMS Tenant API", Version = "v1", - Description = "User Management System — modular monolith API with REST commands and GraphQL queries, prepared for SQL Server platform persistence.", + Description = "User Management System — modular monolith API with REST commands and queries, prepared for SQL Server platform persistence.", }); options.AddSecurityDefinition("DevUserId", new OpenApiSecurityScheme @@ -591,8 +611,8 @@ public void Run() .AllowAnyHeader() .AllowCredentials() .WithExposedHeaders( - ObservabilityHeaders.CorrelationId, ObservabilityHeaders.SessionTrackingId, + "traceparent", "api-supported-versions", "api-deprecated-versions"); }); diff --git a/src/apps/ums.api/Ums.Presentation/Dockerfile b/src/apps/ums.api/Ums.Presentation/Dockerfile deleted file mode 100644 index 329feaa1..00000000 --- a/src/apps/ums.api/Ums.Presentation/Dockerfile +++ /dev/null @@ -1,47 +0,0 @@ -# ========================================================= -# Phase 1: Build Stage -# ========================================================= -FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build -WORKDIR /src - -RUN apt-get update \ - && apt-get install -y --no-install-recommends curl \ - && rm -rf /var/lib/apt/lists/* - -# Copy main application projects -COPY src/apps/ums.api/Ums.Presentation/Ums.Presentation.csproj apps/ums.api/Ums.Presentation/ -COPY src/apps/ums.api/Ums.Application/Ums.Application.csproj apps/ums.api/Ums.Application/ -COPY src/apps/ums.api/Ums.Infrastructure/Ums.Infrastructure.csproj apps/ums.api/Ums.Infrastructure/ -COPY src/apps/ums.api/Ums.Domain/Ums.Domain.csproj apps/ums.api/Ums.Domain/ -COPY src/apps/ums.api/Ums.Globalization/Ums.Globalization.csproj apps/ums.api/Ums.Globalization/ -COPY src/Ums.ReadModels/Ums.ReadModels.csproj Ums.ReadModels/ -COPY src/libs/ libs/ - -# Restore dependencies -RUN dotnet restore apps/ums.api/Ums.Presentation/Ums.Presentation.csproj - -# Copy everything else -COPY src/apps/ums.api/ apps/ums.api/ -COPY src/Ums.ReadModels/ Ums.ReadModels/ - -# Build and publish -WORKDIR /src/apps/ums.api/Ums.Presentation -RUN dotnet publish -c Release -o /app/publish - -# ========================================================= -# Phase 2: Runtime Stage -# ========================================================= -FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime -WORKDIR /app - -RUN apt-get update \ - && apt-get install -y --no-install-recommends curl \ - && rm -rf /var/lib/apt/lists/* - -COPY --from=build /app/publish . - -EXPOSE 8080 -ENV ASPNETCORE_URLS=http://+:8080 -ENV ASPNETCORE_ENVIRONMENT=Production - -ENTRYPOINT ["dotnet", "Ums.Presentation.dll"] diff --git a/src/apps/ums.api/Ums.Presentation/Endpoints/Audit/AuditRecord/AuditRecordEndpoints.cs b/src/apps/ums.api/Ums.Presentation/Endpoints/Audit/AuditRecord/AuditRecordEndpoints.cs index 8a010fab..2b2ae060 100644 --- a/src/apps/ums.api/Ums.Presentation/Endpoints/Audit/AuditRecord/AuditRecordEndpoints.cs +++ b/src/apps/ums.api/Ums.Presentation/Endpoints/Audit/AuditRecord/AuditRecordEndpoints.cs @@ -9,8 +9,11 @@ public static class AuditRecordEndpoints { public static IEndpointRouteBuilder MapAuditRecordEndpoints(this IEndpointRouteBuilder app) { + // G-040 (SEGURIDAD): la auditoría es sensible; todos los endpoints exigen + // autenticación. Sin esto la lectura y el registro eran anónimos. var group = app.MapGroup("/audit-records") - .WithTags("AuditRecords"); + .WithTags("AuditRecords") + .RequireAuthorization(); group.MapGet("/", async ( [FromQuery] int page, diff --git a/src/apps/ums.api/Ums.Presentation/Endpoints/Audit/AuditRecord/Queries/AuditRecordQueryEndpoints.cs b/src/apps/ums.api/Ums.Presentation/Endpoints/Audit/AuditRecord/Queries/AuditRecordQueryEndpoints.cs index 02baf875..4aa6f5a6 100644 --- a/src/apps/ums.api/Ums.Presentation/Endpoints/Audit/AuditRecord/Queries/AuditRecordQueryEndpoints.cs +++ b/src/apps/ums.api/Ums.Presentation/Endpoints/Audit/AuditRecord/Queries/AuditRecordQueryEndpoints.cs @@ -7,8 +7,10 @@ public static class AuditRecordQueryEndpoints { public static IEndpointRouteBuilder MapAuditRecordQueryEndpoints(this IEndpointRouteBuilder app) { + // G-040 (SEGURIDAD): lectura de auditoría solo para usuarios autenticados. var group = app.MapGroup("/audit-records") - .WithTags("AuditRecords - Queries"); + .WithTags("AuditRecords - Queries") + .RequireAuthorization(); group.MapGet("/{auditRecordId:guid}", async (Guid auditRecordId, IMediator mediator, HttpContext context, CancellationToken ct) => { diff --git a/src/apps/ums.api/Ums.Presentation/Endpoints/Authorization/SystemSuite/SystemSuiteEndpoints.cs b/src/apps/ums.api/Ums.Presentation/Endpoints/Authorization/SystemSuite/SystemSuiteEndpoints.cs index 6414eda3..79180e75 100644 --- a/src/apps/ums.api/Ums.Presentation/Endpoints/Authorization/SystemSuite/SystemSuiteEndpoints.cs +++ b/src/apps/ums.api/Ums.Presentation/Endpoints/Authorization/SystemSuite/SystemSuiteEndpoints.cs @@ -73,27 +73,31 @@ public static IEndpointRouteBuilder MapSystemSuiteEndpoints(this IEndpointRouteB .ProducesProblem(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status400BadRequest); + // Eliminación LÓGICA del sistema (G-246). El archivado sigue siendo el PUT /{id}/status → + // Deprecated; esto elimina lo ya archivado y sin referencias vivas. La fila NO se borra: se + // marca `Deleted` y desaparece de las lecturas. 409 si el sistema sigue vigente o si algo + // vivo lo apunta —la respuesta enumera qué—. + group.MapDelete("/{systemSuiteId:guid}", async (Guid systemSuiteId, IMediator mediator, HttpContext context, CancellationToken ct) => + { + var result = await mediator.Send(new DeleteSystemSuiteCommand(systemSuiteId), ct); + return result.ToNoContent(context); + }) + .WithName("DeleteSystemSuite") + .WithSummary("Logically delete a deprecated system suite that has no live dependents") + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict); + // ── Module lifecycle ───────────────────────────────────────────────── group.MapPost("/{systemSuiteId:guid}/modules", async (Guid systemSuiteId, AddModuleCommand command, IMediator mediator, HttpContext context, CancellationToken ct) => { var result = await mediator.Send(command with { SystemSuiteId = systemSuiteId }, ct); - if (result.IsSuccess) - return Results.Created($"/system-suites/{systemSuiteId}/modules", result); - // Map domain error to proper problem response - var (status, title) = DomainErrorStatusMapper.Map(result.Error); - var problem = new ProblemDetails - { - Title = title, - Status = status, - Detail = result.Error, - Instance = context?.Request?.Path, - Extensions = { ["timestamp"] = DateTimeOffset.UtcNow } - }; - return Results.Problem(problem); + // 201 con el id del módulo creado (G-053): el cliente ya no necesita un GET para obtenerlo. + return result.ToCreated(r => $"/system-suites/{systemSuiteId}/modules/{r.ModuleId}", context); }).WithName("AddModule") .WithSummary("Add a module to the system suite") - .Produces(StatusCodes.Status201Created) + .Produces(StatusCodes.Status201Created) .ProducesProblem(StatusCodes.Status400BadRequest) .ProducesProblem(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status409Conflict); @@ -137,131 +141,55 @@ public static IEndpointRouteBuilder MapSystemSuiteEndpoints(this IEndpointRouteB .ProducesProblem(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status409Conflict); - // ── Menu lifecycle ──────────────────────────────────────────────────── + // ── Node lifecycle (árbol recursivo, ADR-0090) ─────────────────────── - group.MapPost("/{systemSuiteId:guid}/modules/{moduleId:guid}/menus", async (Guid systemSuiteId, Guid moduleId, AddMenuCommand command, IMediator mediator, HttpContext context, CancellationToken ct) => + group.MapPost("/{systemSuiteId:guid}/modules/{moduleId:guid}/nodes", async (Guid systemSuiteId, Guid moduleId, AddNodeCommand command, IMediator mediator, HttpContext context, CancellationToken ct) => { var result = await mediator.Send(command with { SystemSuiteId = systemSuiteId, ModuleId = moduleId }, ct); - if (result.IsSuccess) - return Results.Created($"/system-suites/{systemSuiteId}/modules/{moduleId}/menus", result); - var (status, title) = DomainErrorStatusMapper.Map(result.Error); - var problem = new ProblemDetails - { - Title = title, - Status = status, - Detail = result.Error, - Instance = context?.Request?.Path, - Extensions = { ["timestamp"] = DateTimeOffset.UtcNow } - }; - return Results.Problem(problem); - }).WithName("AddMenu") - .WithSummary("Add a menu to a module") - .Produces(StatusCodes.Status201Created) + // 201 con el id del nodo creado (G-053): DELETE/GET de nodo se hacen por id, ya no se fuerza un GET. + return result.ToCreated(r => $"/system-suites/{systemSuiteId}/modules/{moduleId}/nodes/{r.NodeId}", context); + }).WithName("AddNode") + .WithSummary("Add a node (root when ParentNodeId is null, else child) to a module tree") + .Produces(StatusCodes.Status201Created) .ProducesProblem(StatusCodes.Status400BadRequest) .ProducesProblem(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status409Conflict); - group.MapPut("/{systemSuiteId:guid}/modules/{moduleId:guid}/menus/{menuId:guid}", async (Guid systemSuiteId, Guid moduleId, Guid menuId, UpdateMenuCommand command, IMediator mediator, HttpContext context, CancellationToken ct) => + group.MapPut("/{systemSuiteId:guid}/modules/{moduleId:guid}/nodes/{nodeId:guid}", async (Guid systemSuiteId, Guid moduleId, Guid nodeId, UpdateNodeCommand command, IMediator mediator, HttpContext context, CancellationToken ct) => { - var result = await mediator.Send(command with { SystemSuiteId = systemSuiteId, ModuleId = moduleId, MenuId = menuId }, ct); + var result = await mediator.Send(command with { SystemSuiteId = systemSuiteId, ModuleId = moduleId, NodeId = nodeId }, ct); return result.ToNoContent(context); - }).WithName("UpdateMenu") - .WithSummary("Update menu label, description, or sort order") - .Produces(StatusCodes.Status204NoContent) - .ProducesProblem(StatusCodes.Status404NotFound); + }).WithName("UpdateNode").Produces(StatusCodes.Status204NoContent).ProducesProblem(StatusCodes.Status404NotFound); - group.MapDelete("/{systemSuiteId:guid}/modules/{moduleId:guid}/menus/{menuId:guid}", async (Guid systemSuiteId, Guid moduleId, Guid menuId, IMediator mediator, HttpContext context, CancellationToken ct) => + group.MapDelete("/{systemSuiteId:guid}/modules/{moduleId:guid}/nodes/{nodeId:guid}", async (Guid systemSuiteId, Guid moduleId, Guid nodeId, IMediator mediator, HttpContext context, CancellationToken ct) => { - var result = await mediator.Send(new RemoveMenuCommand(systemSuiteId, moduleId, menuId), ct); + var result = await mediator.Send(new RemoveNodeCommand(systemSuiteId, moduleId, nodeId), ct); return result.ToNoContent(context); - }).WithName("RemoveMenu") - .WithSummary("Remove a menu from a module") - .Produces(StatusCodes.Status204NoContent) - .ProducesProblem(StatusCodes.Status404NotFound); - - // ── SubMenu lifecycle ───────────────────────────────────────────────── + }).WithName("RemoveNode").Produces(StatusCodes.Status204NoContent).ProducesProblem(StatusCodes.Status404NotFound); - group.MapPost("/{systemSuiteId:guid}/modules/{moduleId:guid}/menus/{menuId:guid}/submenus", async (Guid systemSuiteId, Guid moduleId, Guid menuId, AddSubMenuCommand command, IMediator mediator, HttpContext context, CancellationToken ct) => + group.MapPut("/{systemSuiteId:guid}/modules/{moduleId:guid}/nodes/{nodeId:guid}/status", async (Guid systemSuiteId, Guid moduleId, Guid nodeId, SetNodeStatusCommand command, IMediator mediator, HttpContext context, CancellationToken ct) => { - var result = await mediator.Send(command with { SystemSuiteId = systemSuiteId, ModuleId = moduleId, MenuId = menuId }, ct); - if (result.IsSuccess) - return Results.Created($"/system-suites/{systemSuiteId}/modules/{moduleId}/menus/{menuId}/submenus", result); - var (status, title) = DomainErrorStatusMapper.Map(result.Error); - var problem = new ProblemDetails - { - Title = title, - Status = status, - Detail = result.Error, - Instance = context?.Request?.Path, - Extensions = { ["timestamp"] = DateTimeOffset.UtcNow } - }; - return Results.Problem(problem); - }).WithName("AddSubMenu") - .WithSummary("Add a submenu to a menu") - .Produces(StatusCodes.Status201Created) - .ProducesProblem(StatusCodes.Status400BadRequest) - .ProducesProblem(StatusCodes.Status404NotFound) - .ProducesProblem(StatusCodes.Status409Conflict); - - group.MapPut("/{systemSuiteId:guid}/modules/{moduleId:guid}/menus/{menuId:guid}/submenus/{subMenuId:guid}", async (Guid systemSuiteId, Guid moduleId, Guid menuId, Guid subMenuId, UpdateSubMenuCommand command, IMediator mediator, HttpContext context, CancellationToken ct) => - { - var result = await mediator.Send(command with { SystemSuiteId = systemSuiteId, ModuleId = moduleId, MenuId = menuId, SubMenuId = subMenuId }, ct); + var result = await mediator.Send(command with { SystemSuiteId = systemSuiteId, ModuleId = moduleId, NodeId = nodeId }, ct); return result.ToNoContent(context); - }).WithName("UpdateSubMenu") - .WithSummary("Update submenu label, description, or sort order") - .Produces(StatusCodes.Status204NoContent) - .ProducesProblem(StatusCodes.Status404NotFound); + }).WithName("SetNodeStatus").Produces(StatusCodes.Status204NoContent).ProducesProblem(StatusCodes.Status404NotFound); - group.MapDelete("/{systemSuiteId:guid}/modules/{moduleId:guid}/menus/{menuId:guid}/submenus/{subMenuId:guid}", async (Guid systemSuiteId, Guid moduleId, Guid menuId, Guid subMenuId, IMediator mediator, HttpContext context, CancellationToken ct) => + group.MapPost("/{systemSuiteId:guid}/modules/{moduleId:guid}/nodes/{nodeId:guid}/actions", async (Guid systemSuiteId, Guid moduleId, Guid nodeId, LinkNodeActionCommand command, IMediator mediator, HttpContext context, CancellationToken ct) => { - var result = await mediator.Send(new RemoveSubMenuCommand(systemSuiteId, moduleId, menuId, subMenuId), ct); + var result = await mediator.Send(command with { SystemSuiteId = systemSuiteId, ModuleId = moduleId, NodeId = nodeId }, ct); return result.ToNoContent(context); - }).WithName("RemoveSubMenu") - .WithSummary("Remove a submenu from a menu") - .Produces(StatusCodes.Status204NoContent) - .ProducesProblem(StatusCodes.Status404NotFound); - - // ── Option lifecycle ────────────────────────────────────────────────── - - group.MapPost("/{systemSuiteId:guid}/modules/{moduleId:guid}/menus/{menuId:guid}/submenus/{subMenuId:guid}/options", async (Guid systemSuiteId, Guid moduleId, Guid menuId, Guid subMenuId, AddOptionCommand command, IMediator mediator, HttpContext context, CancellationToken ct) => - { - var result = await mediator.Send(command with { SystemSuiteId = systemSuiteId, ModuleId = moduleId, MenuId = menuId, SubMenuId = subMenuId }, ct); - if (result.IsSuccess) - return Results.Created($"/system-suites/{systemSuiteId}/modules/{moduleId}/menus/{menuId}/submenus/{subMenuId}/options", result); - var (status, title) = DomainErrorStatusMapper.Map(result.Error); - var problem = new ProblemDetails - { - Title = title, - Status = status, - Detail = result.Error, - Instance = context?.Request?.Path, - Extensions = { ["timestamp"] = DateTimeOffset.UtcNow } - }; - return Results.Problem(problem); - }).WithName("AddOption") - .WithSummary("Add an option to a submenu") - .Produces(StatusCodes.Status201Created) - .ProducesProblem(StatusCodes.Status400BadRequest) - .ProducesProblem(StatusCodes.Status404NotFound) - .ProducesProblem(StatusCodes.Status409Conflict); + }).WithName("LinkNodeAction").Produces(StatusCodes.Status204NoContent).ProducesProblem(StatusCodes.Status404NotFound); - group.MapPut("/{systemSuiteId:guid}/modules/{moduleId:guid}/menus/{menuId:guid}/submenus/{subMenuId:guid}/options/{optionId:guid}", async (Guid systemSuiteId, Guid moduleId, Guid menuId, Guid subMenuId, Guid optionId, UpdateOptionCommand command, IMediator mediator, HttpContext context, CancellationToken ct) => + group.MapDelete("/{systemSuiteId:guid}/modules/{moduleId:guid}/nodes/{nodeId:guid}/actions/{actionCode}", async (Guid systemSuiteId, Guid moduleId, Guid nodeId, string actionCode, IMediator mediator, HttpContext context, CancellationToken ct) => { - var result = await mediator.Send(command with { SystemSuiteId = systemSuiteId, ModuleId = moduleId, MenuId = menuId, SubMenuId = subMenuId, OptionId = optionId }, ct); + var result = await mediator.Send(new UnlinkNodeActionCommand(systemSuiteId, moduleId, nodeId, actionCode), ct); return result.ToNoContent(context); - }).WithName("UpdateOption") - .WithSummary("Update option label, description, action code, or sort order") - .Produces(StatusCodes.Status204NoContent) - .ProducesProblem(StatusCodes.Status404NotFound); + }).WithName("UnlinkNodeAction").Produces(StatusCodes.Status204NoContent).ProducesProblem(StatusCodes.Status404NotFound); - group.MapDelete("/{systemSuiteId:guid}/modules/{moduleId:guid}/menus/{menuId:guid}/submenus/{subMenuId:guid}/options/{optionId:guid}", async (Guid systemSuiteId, Guid moduleId, Guid menuId, Guid subMenuId, Guid optionId, IMediator mediator, HttpContext context, CancellationToken ct) => + group.MapPut("/{systemSuiteId:guid}/modules/{moduleId:guid}/nodes/{nodeId:guid}/metadata", async (Guid systemSuiteId, Guid moduleId, Guid nodeId, SetNodeMetadataCommand command, IMediator mediator, HttpContext context, CancellationToken ct) => { - var result = await mediator.Send(new RemoveOptionCommand(systemSuiteId, moduleId, menuId, subMenuId, optionId), ct); + var result = await mediator.Send(command with { SystemSuiteId = systemSuiteId, ModuleId = moduleId, NodeId = nodeId }, ct); return result.ToNoContent(context); - }).WithName("RemoveOption") - .WithSummary("Remove an option from a submenu") - .Produces(StatusCodes.Status204NoContent) - .ProducesProblem(StatusCodes.Status404NotFound); + }).WithName("SetNodeMetadata").Produces(StatusCodes.Status204NoContent).ProducesProblem(StatusCodes.Status404NotFound); // ── App settings ───────────────────────────────────────────────────── @@ -310,21 +238,11 @@ public static IEndpointRouteBuilder MapSystemSuiteEndpoints(this IEndpointRouteB group.MapPost("/{systemSuiteId:guid}/actions", async (Guid systemSuiteId, RegisterActionCommand command, IMediator mediator, HttpContext context, CancellationToken ct) => { var result = await mediator.Send(command with { SystemSuiteId = systemSuiteId }, ct); - if (result.IsSuccess) - return Results.Created($"/system-suites/{systemSuiteId}/actions", result); - var (status, title) = DomainErrorStatusMapper.Map(result.Error); - var problem = new ProblemDetails - { - Title = title, - Status = status, - Detail = result.Error, - Instance = context?.Request?.Path, - Extensions = { ["timestamp"] = DateTimeOffset.UtcNow } - }; - return Results.Problem(problem); + // 201 con el id de la acción creada (G-053). La ruta usa el code, clave de negocio del recurso. + return result.ToCreated(r => $"/system-suites/{systemSuiteId}/actions/{r.Code}", context); }).WithName("RegisterAction") .WithSummary("Register a new action code that can be used in permission templates") - .Produces(StatusCodes.Status201Created) + .Produces(StatusCodes.Status201Created) .ProducesProblem(StatusCodes.Status400BadRequest) .ProducesProblem(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status409Conflict); @@ -354,21 +272,11 @@ public static IEndpointRouteBuilder MapSystemSuiteEndpoints(this IEndpointRouteB group.MapPost("/{systemSuiteId:guid}/domain-resources", async (Guid systemSuiteId, AddDomainResourceCommand command, IMediator mediator, HttpContext context, CancellationToken ct) => { var result = await mediator.Send(command with { SystemSuiteId = systemSuiteId }, ct); - if (result.IsSuccess) - return Results.Created($"/system-suites/{systemSuiteId}/domain-resources", result); - var (status, title) = DomainErrorStatusMapper.Map(result.Error); - var problem = new ProblemDetails - { - Title = title, - Status = status, - Detail = result.Error, - Instance = context?.Request?.Path, - Extensions = { ["timestamp"] = DateTimeOffset.UtcNow } - }; - return Results.Problem(problem); + // 201 con el id del recurso de dominio creado (G-053): DELETE/PUT se hacen por id. + return result.ToCreated(r => $"/system-suites/{systemSuiteId}/domain-resources/{r.DomainResourceId}", context); }).WithName("AddDomainResource") .WithSummary("Add a domain resource (Aggregate or Entity) to the system suite") - .Produces(StatusCodes.Status201Created) + .Produces(StatusCodes.Status201Created) .ProducesProblem(StatusCodes.Status400BadRequest) .ProducesProblem(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status409Conflict); diff --git a/src/apps/ums.api/Ums.Presentation/Endpoints/Authorization/Template/PermissionTemplateEndpoints.cs b/src/apps/ums.api/Ums.Presentation/Endpoints/Authorization/Template/PermissionTemplateEndpoints.cs index 849c6f3c..31416314 100644 --- a/src/apps/ums.api/Ums.Presentation/Endpoints/Authorization/Template/PermissionTemplateEndpoints.cs +++ b/src/apps/ums.api/Ums.Presentation/Endpoints/Authorization/Template/PermissionTemplateEndpoints.cs @@ -89,20 +89,25 @@ public static IEndpointRouteBuilder MapPermissionTemplateEndpoints(this IEndpoin group.MapPost("/{templateId:guid}/items", async (Guid templateId, AddTemplateItemCommand command, IMediator mediator, HttpContext context, CancellationToken ct) => { var result = await mediator.Send(command with { TemplateId = templateId }, ct); - return result.ToNoContent(context); + // 201 con el id del ítem creado (G-053): DELETE/PUT del ítem se hacen por itemId. + return result.ToCreated(r => $"/permission-templates/{templateId}/items/{r.ItemId}", context); }).WithName("AddTemplateItem") .WithSummary("Add a permission item (target + action + effect) to a draft template") - .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status201Created) .ProducesProblem(StatusCodes.Status400BadRequest) .ProducesProblem(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status409Conflict); + // DELETE de un ítem = RETIRADA LÓGICA (ADR-0164). La ruta y su contrato —204/404/409— se + // conservan porque el cliente ya la invoca, pero por debajo ejecuta el mismo verbo que + // `/deactivate`: la concesión se marca, la fila no se toca. El borrado físico no se deja + // «por si acaso» en otra ruta; simplemente ya no existe en ninguna capa. group.MapDelete("/{templateId:guid}/items/{itemId:guid}", async (Guid templateId, Guid itemId, IMediator mediator, HttpContext context, CancellationToken ct) => { - var result = await mediator.Send(new RemoveTemplateItemCommand(templateId, itemId), ct); + var result = await mediator.Send(new DeactivateTemplateItemCommand(templateId, itemId), ct); return result.ToNoContent(context); - }).WithName("RemoveTemplateItem") - .WithSummary("Remove a permission item from a draft template") + }).WithName("RetireTemplateItem") + .WithSummary("Retire a permission item from a draft template — logical retirement, the row survives") .Produces(StatusCodes.Status204NoContent) .ProducesProblem(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status409Conflict); diff --git a/src/apps/ums.api/Ums.Presentation/Endpoints/Configuration/AppConfiguration/AppConfigurationEndpoints.cs b/src/apps/ums.api/Ums.Presentation/Endpoints/Configuration/AppConfiguration/AppConfigurationEndpoints.cs index e5dd956e..833b2049 100644 --- a/src/apps/ums.api/Ums.Presentation/Endpoints/Configuration/AppConfiguration/AppConfigurationEndpoints.cs +++ b/src/apps/ums.api/Ums.Presentation/Endpoints/Configuration/AppConfiguration/AppConfigurationEndpoints.cs @@ -210,6 +210,47 @@ public static IEndpointRouteBuilder MapAppConfigurationEndpoints(this IEndpointR .ProducesProblem(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status409Conflict); + // G-143: borrado duro (DELETE). Replica la guarda de autorización por ámbito del archive. + group.MapDelete("/{appConfigurationId:guid}", async ( + Guid appConfigurationId, + IMediator mediator, + ITenantContext tenantContext, + HttpContext context, + CancellationToken ct) => + { + // Get the existing config to check scope + var getResult = await mediator.Send(new GetAppConfigurationByIdQuery(appConfigurationId), ct); + if (getResult.IsFailure) + { + return Results.NotFound(); + } + + var existingConfig = getResult.Value; + + // Authorization: Check if user can delete this config + var isGlobalScope = existingConfig.Scope == "Global"; + var isOtherTenant = existingConfig.TenantId.HasValue && + existingConfig.TenantId != tenantContext.OrganizationId; + + if (isGlobalScope && !tenantContext.IsInternalAdmin) + { + return Results.Json(new { error = "Only internal administrators can delete global configurations." }, statusCode: 403); + } + + if (isOtherTenant && !tenantContext.IsInternalAdmin) + { + return Results.Json(new { error = "You do not have permission to delete this tenant's configurations." }, statusCode: 403); + } + + var result = await mediator.Send(new DeleteAppConfigurationCommand(appConfigurationId), ct); + return result.ToNoContent(context); + }) + .WithName("DeleteAppConfiguration") + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status403Forbidden) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict); + // DEFERRED: Rollback and version-comparison endpoints require a persisted // configuration history (audit table) — not yet implemented in the storage layer. return app; diff --git a/src/apps/ums.api/Ums.Presentation/Endpoints/Configuration/Parameter/ParameterEndpoints.cs b/src/apps/ums.api/Ums.Presentation/Endpoints/Configuration/Parameter/ParameterEndpoints.cs index 9dd4f78c..2e0e7b6b 100644 --- a/src/apps/ums.api/Ums.Presentation/Endpoints/Configuration/Parameter/ParameterEndpoints.cs +++ b/src/apps/ums.api/Ums.Presentation/Endpoints/Configuration/Parameter/ParameterEndpoints.cs @@ -55,6 +55,24 @@ public static IEndpointRouteBuilder MapParameterEndpoints(this IEndpointRouteBui .ProducesProblem(StatusCodes.Status403Forbidden) .ProducesProblem(StatusCodes.Status404NotFound); + // Borrado LÓGICO (el contrato HTTP no cambia: 204 en éxito). 409 si la definición todavía + // tiene valores globales/de inquilino VIVOS; el actor debe eliminarlos antes. + defs.MapDelete("/{id:guid}", async ( + Guid id, IMediator mediator, ITenantContext tenantContext, + HttpContext context, CancellationToken ct) => + { + if (!tenantContext.IsInternalAdmin) + return Results.Json(new { error = "Only internal administrators can delete parameter definitions." }, statusCode: 403); + + var result = await mediator.Send(new DeleteParameterDefinitionCommand(id), ct); + return result.ToNoContent(context); + }) + .WithName("DeleteParameterDefinition") + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status403Forbidden) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict); + // ── ParameterGlobalValue mutations ─────────────────────────────────── var gv = app.MapGroup("/parameter-definitions/{definitionId:guid}/global-values") .WithTags("Parameter Global Values"); @@ -119,6 +137,23 @@ public static IEndpointRouteBuilder MapParameterEndpoints(this IEndpointRouteBui .ProducesProblem(StatusCodes.Status403Forbidden) .ProducesProblem(StatusCodes.Status404NotFound); + // Borrado LÓGICO del valor global: libera la referencia que bloquea el borrado de su + // definición (archivarlo no la libera — una fila archivada sigue siendo una referencia real). + gv.MapDelete("/{id:guid}", async ( + Guid definitionId, Guid id, IMediator mediator, + ITenantContext tenantContext, HttpContext context, CancellationToken ct) => + { + if (!tenantContext.IsInternalAdmin) + return Results.Json(new { error = "Only internal administrators can delete parameter values." }, statusCode: 403); + + var result = await mediator.Send(new DeleteParameterGlobalValueCommand(id), ct); + return result.ToNoContent(context); + }) + .WithName("DeleteParameterGlobalValue") + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status403Forbidden) + .ProducesProblem(StatusCodes.Status404NotFound); + // ── ParameterTenantValue mutations ─────────────────────────────────── var tv = app.MapGroup("/parameter-definitions/{definitionId:guid}/tenant-values") .WithTags("Parameter Tenant Values"); @@ -150,6 +185,25 @@ public static IEndpointRouteBuilder MapParameterEndpoints(this IEndpointRouteBui .Produces(StatusCodes.Status204NoContent) .ProducesProblem(StatusCodes.Status404NotFound); + // Borrado LÓGICO del override de inquilino. Guarda deliberadamente estricta (solo + // administrador interno, como el resto de la gestión del catálogo de parámetros): el + // endpoint no conoce el inquilino del valor sin releerlo, y ante la duda no se abre + // una vía por la que un inquilino pudiera retirar el override de otro. + tv.MapDelete("/{id:guid}", async ( + Guid definitionId, Guid id, IMediator mediator, + ITenantContext tenantContext, HttpContext context, CancellationToken ct) => + { + if (!tenantContext.IsInternalAdmin) + return Results.Json(new { error = "Only internal administrators can delete parameter values." }, statusCode: 403); + + var result = await mediator.Send(new DeleteParameterTenantValueCommand(id), ct); + return result.ToNoContent(context); + }) + .WithName("DeleteParameterTenantValue") + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status403Forbidden) + .ProducesProblem(StatusCodes.Status404NotFound); + return app; } } diff --git a/src/apps/ums.api/Ums.Presentation/Endpoints/Configuration/ParameterCatalog/Queries/ParameterCatalogQueryEndpoints.cs b/src/apps/ums.api/Ums.Presentation/Endpoints/Configuration/ParameterCatalog/Queries/ParameterCatalogQueryEndpoints.cs index 018d67b9..2d73c267 100644 --- a/src/apps/ums.api/Ums.Presentation/Endpoints/Configuration/ParameterCatalog/Queries/ParameterCatalogQueryEndpoints.cs +++ b/src/apps/ums.api/Ums.Presentation/Endpoints/Configuration/ParameterCatalog/Queries/ParameterCatalogQueryEndpoints.cs @@ -5,6 +5,7 @@ namespace Ums.Presentation.Endpoints.Configuration.ParameterCatalog.Queries; using Microsoft.EntityFrameworkCore; using Ums.Application.Common; using Ums.Application.Configuration.ParameterCatalog.DTOs; +using Ums.Domain.Enums; using Ums.Infrastructure.Persistence; using Ums.Infrastructure.Persistence.Configuration.Entities; @@ -102,9 +103,13 @@ public static IEndpointRouteBuilder MapParameterCatalogQueryEndpoints(this IEndp return Results.BadRequest(new { error = "Parameter code is required." }); var normalizedCode = code.Trim().ToUpperInvariant(); + // Las definiciones eliminadas lógicamente ya las descarta el filtro global de consulta; + // los valores globales eliminados hay que descartarlos aquí: un valor retirado no puede + // seguir resolviendo, la resolución debe caer al DefaultValue de la definición. + var deletedStatusId = ConfigStatus.Deleted.Id; var resolved = await ( from definition in dbContext.ParameterDefinitions - join globalValue in dbContext.ParameterGlobalValues + join globalValue in dbContext.ParameterGlobalValues.Where(v => v.StatusId != deletedStatusId) on definition.Id equals globalValue.ParameterDefinitionId into globalValues from globalValue in globalValues.DefaultIfEmpty() where definition.Code == normalizedCode && definition.IsActive diff --git a/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Auth/AuthEndpoints.cs b/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Auth/AuthEndpoints.cs index e72fe9f4..c3382ef5 100644 --- a/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Auth/AuthEndpoints.cs +++ b/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Auth/AuthEndpoints.cs @@ -1,5 +1,7 @@ namespace Ums.Presentation.Endpoints.Identity.Auth; +#pragma warning disable S125 + using Ums.Application.Identity.Auth.Commands; using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; @@ -16,6 +18,7 @@ namespace Ums.Presentation.Endpoints.Identity.Auth; using MsConfigProvider = Microsoft.Extensions.Configuration.IConfigurationProvider; using Ums.Domain.Authorization; using Ums.Domain.Authorization.Graph; +using Ums.Application.Authorization.Graph.Serializers; using Ums.Domain.Identity; using Ums.Domain.Kernel.ValueObjects; using Ums.Presentation.Services; @@ -43,6 +46,15 @@ public static void MapAuthEndpoints(this WebApplication app) .WithSummary("Refresh access token using session cookie") .RequireAuthorization(); + // Renovación por refresh token opaco (ADR-UMS-091 / FR-015). Anónimo a propósito: + // el refresh token ES la credencial y se usa cuando el access token ya expiró, + // por lo que no puede exigir una sesión viva. Solo funciona si el inquilino + // activó la capacidad (fail-closed en el handler). + group.MapPost("/refresh-token", HandleRefreshTokenGrantAsync) + .WithName("RefreshTokenGrant") + .WithSummary("Renew the session with an opaque refresh token (ADR-UMS-091, tenant opt-in)") + .AllowAnonymous(); + // Get current session info group.MapGet("/session", HandleGetSessionAsync) .WithName("GetSession") @@ -50,12 +62,24 @@ public static void MapAuthEndpoints(this WebApplication app) .RequireAuthorization(); // Switch tenant context (internal admins only) + group.MapPost("/switch-profile", HandleSwitchProfileAsync) + .WithName("SwitchProfile") + .WithSummary("Cambia el perfil vigente de la sesión sin volver a autenticarse") + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status401Unauthorized) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict); + // Sin RequireAuthorization(): el token se valida en el manejador, igual que en + // switch-tenant, para no depender de que el esquema JWT esté configurado. + group.MapPost("/switch-tenant", HandleSwitchTenantAsync) .WithName("SwitchTenant") .WithSummary("Switch current tenant context (internal admins only)"); // No RequireAuthorization() - we validate the JWT token directly in the handler - // Forgot password — public, no auth required + // Solicitud de restablecimiento — pública. NO cambia la contraseña ni devuelve credencial + // alguna (G-188): solo emite un token de un solo uso hacia el buzón del titular. La + // respuesta es idéntica exista o no la cuenta, en cuerpo, código y tiempo. group.MapPost("/forgot-password", async ( ForgotPasswordCommand command, IMediator mediator, @@ -66,10 +90,20 @@ public static void MapAuthEndpoints(this WebApplication app) return result.ToOk(context); }) .WithName("ForgotPassword") - .WithSummary("Request a password reset. Always returns 200 to prevent user enumeration.") + .WithSummary("Solicita un restablecimiento de contraseña. Siempre 200 y siempre la misma respuesta.") .AllowAnonymous() .Produces(StatusCodes.Status200OK); + // Canje del token — pública porque el token ES la credencial: quien lo presenta acaba de + // probar la posesión del buzón y por definición no tiene sesión. Aquí, y solo aquí, + // cambia la contraseña (G-188). + group.MapPost("/reset-password", HandleResetPasswordAsync) + .WithName("ResetPassword") + .WithSummary("Canjea el token de restablecimiento y fija la nueva contraseña.") + .AllowAnonymous() + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest); + group.MapPost("/user-signup", async ( SignupUserCommand command, IMediator mediator, @@ -115,6 +149,9 @@ private static async Task HandleLoginAsync( IMediator mediator, IJwtTokenService jwtTokenService, Ums.Application.Configuration.Services.IConfigurationProvider configProvider, + Ums.Application.Identity.Auth.IRefreshTokenPolicyProvider refreshPolicyProvider, + Ums.Application.Identity.Auth.IRefreshTokenStore refreshTokenStore, + Ums.Application.Common.Interfaces.IFunctionalTransaction functionalTransaction, HttpContext httpContext, CancellationToken cancellationToken) { @@ -124,10 +161,12 @@ private static async Task HandleLoginAsync( string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password)) { + // ADR-0096 §2.2 / ADR-UMS-084: al usuario se le entrega el localizador legible, + // no el TraceIdentifier crudo. return Results.BadRequest(new LoginErrorResponse( ErrorCodes.ValidationError, "Tenant code, username and password are required.", - SupportReferenceId: supportReferenceId)); + SupportReferenceId: await functionalTransaction.GetOrMintLocatorAsync(cancellationToken))); } var clientIp = httpContext.Connection.RemoteIpAddress?.ToString() ?? "127.0.0.1"; @@ -137,22 +176,28 @@ private static async Task HandleLoginAsync( Username: request.Username.Trim(), Password: request.Password, ClientIp: clientIp, - AccessScope: Ums.Domain.Identity.Auth.AuthAccessScope.PortalManagement, + AccessScope: Ums.Domain.Identity.Auth.AuthAccessScope.ExternalApi, RememberMe: request.RememberMe); var result = await mediator.Send(command, cancellationToken); if (result.IsFailure) { - return MapAuthError(result.Error, supportReferenceId); + return MapAuthError(result.Error, await functionalTransaction.GetOrMintLocatorAsync(cancellationToken)); } var authResult = result.Value; var graph = authResult.Graph; var ctx = graph.Context; + // G-247: la sesión se acuña UNA vez y viaja en los dos portadores —cookie y JWT—, porque + // un mismo dispositivo puede hablar por cualquiera de los dos y cerrar sesión debe cerrarlo + // entero. Es lo que permite que el logout cierre este dispositivo y solo este: la clave de + // cierre es la sesión, no el usuario. + var sessionId = Guid.NewGuid().ToString("N"); + // Generate graph JWT - var jwtToken = jwtTokenService.GenerateGraphToken(graph); + var jwtToken = jwtTokenService.GenerateGraphToken(graph, sessionId); // Set session cookie for web frontend var isInternalAdmin = ctx.Tenant.IsManagementOwner; @@ -163,13 +208,22 @@ private static async Task HandleLoginAsync( new("tenant_id", ctx.Tenant.Id.ToString()), new("tenant_code", ctx.Tenant.Code), new("username", ctx.User.Username), - new(ClaimTypes.Role, ctx.Role.Code), - new("role_name", ctx.Role.Name), - new("profile_id", ctx.Profile.Id.ToString()), - new("sys_suite", ctx.SystemSuite.Code), new("auth_method", graph.Authentication.Method), new("is_internal_admin", isInternalAdmin ? "true" : "false"), + new("sid", sessionId), }; + // G-122: en el grafo lobby (onboarding pendiente, cuenta activa SIN perfil) Role/Profile/ + // SystemSuite son null; añadir esos claims incondicionalmente provocaba NullReferenceException + // → 500 en el login. Se agregan solo si existen (igual que Branch), preservando el flujo lobby. + if (ctx.Role is not null) + { + cookieClaims.Add(new Claim(ClaimTypes.Role, ctx.Role.Code)); + cookieClaims.Add(new Claim("role_name", ctx.Role.Name)); + } + if (ctx.Profile is not null) + cookieClaims.Add(new Claim("profile_id", ctx.Profile.Id.ToString())); + if (ctx.SystemSuite is not null) + cookieClaims.Add(new Claim("sys_suite", ctx.SystemSuite.Code)); if (ctx.Branch is not null) cookieClaims.Add(new Claim("branch_id", ctx.Branch.Id.ToString())); @@ -204,14 +258,30 @@ await httpContext.SignInAsync( DefaultTimezone: cfg.DefaultTimezone); // Build enriched permissions array from Allow options for backward compat - var permissions = graph.MenuAccess - .SelectMany(m => m.Menus) - .SelectMany(m => m.SubMenus) - .SelectMany(s => s.Options) - .Where(o => o.Effect == AccessEffect.Allow) - .Select(o => $"{o.Code}:{o.ActionCode}") + var permissions = GraphNavigation.AllowedPairs(graph) + .Select(p => $"{p.Code}:{p.ActionCode}") .ToArray(); + // Refresh token (ADR-UMS-091/FR-015): solo si el inquilino activó la capacidad. + // Fail-closed: política deshabilitada ⇒ no se emite (modelo de ADR-UMS-088 intacto). + string? refreshTokenPlaintext = null; + var refreshExpiresIn = request.RememberMe ? 604800 : 86400; + var refreshPolicy = refreshPolicyProvider.Resolve(tenantId); + if (refreshPolicy.Enabled) + { + refreshTokenPlaintext = jwtTokenService.GenerateRefreshToken(); + var issuedAtUtc = DateTime.UtcNow; + await refreshTokenStore.IssueAsync( + tenantId, + ctx.User.Id, + Guid.NewGuid(), + Ums.Application.Identity.Auth.RefreshTokenHasher.Hash(refreshTokenPlaintext), + issuedAtUtc, + issuedAtUtc.AddMinutes(refreshPolicy.LifetimeMinutes), + cancellationToken); + refreshExpiresIn = refreshPolicy.LifetimeMinutes * 60; + } + return Results.Ok(new LoginSuccessResponse( SessionId: supportReferenceId, SessionTrackingId: $"{supportReferenceId}-session", @@ -221,19 +291,52 @@ await httpContext.SignInAsync( TenantId: tenantId.ToString(), TenantCode: ctx.Tenant.Code, TenantName: ctx.Tenant.Name, - Role: ctx.Role.Code, - RoleName: ctx.Role.Name, - ProfileId: ctx.Profile.Id.ToString(), + // G-122: en el grafo lobby (onboarding pendiente) Role/Profile son null; se serializan como + // null y el cliente reacciona a AuthorizationGraph.onboardingPending (antes NRE → 500). + Role: ctx.Role?.Code, + RoleName: ctx.Role?.Name, + ProfileId: ctx.Profile?.Id.ToString(), Permissions: permissions, Language: sessionParams.DefaultLanguage, Token: jwtToken, TokenType: "Bearer", ExpiresIn: authResult.ExpiresIn, - RefreshExpiresIn: request.RememberMe ? 604800 : 86400, + RefreshExpiresIn: refreshExpiresIn, IsInternalAdmin: isInternalAdmin, SessionParameters: sessionParams, - AuthorizationGraph: graph, - GraphFormat: authResult.GraphFormat)); + AuthorizationGraph: AuthGraphPayload.Build(graph), + GraphFormat: authResult.GraphFormat, + RefreshToken: refreshTokenPlaintext)); + } + + /// + /// Canje del token de restablecimiento. Todo fallo del canje se traduce al MISMO 400 con el + /// mismo texto: el detalle interno (token inexistente, vencido, ya gastado o cuenta + /// inhabilitada) diría al atacante qué tokens y qué cuentas existen, que es justo lo que el + /// flujo anónimo oculta. Se exceptúan los fallos de validación de entrada —la política de + /// contraseña— porque hablan de lo que el llamante acaba de escribir, no del estado del + /// servidor, y ocultarlos dejaría al usuario legítimo sin saber por qué se le rechaza. + /// + private static async Task HandleResetPasswordAsync( + ResetPasswordCommand command, + IMediator mediator, + CancellationToken cancellationToken) + { + var result = await mediator.Send(command, cancellationToken); + + if (result.IsSuccess) + { + return Results.Ok(result.Value); + } + + var esFalloDeValidacion = result.Error.StartsWith("Validation.Failed", StringComparison.Ordinal); + + return Results.Json(new LoginErrorResponse( + esFalloDeValidacion ? ErrorCodes.ValidationError : ErrorCodes.InvalidResetToken, + esFalloDeValidacion + ? result.Error["Validation.Failed:".Length..].Trim() + : "El código de restablecimiento no es válido o ha expirado. Solicite uno nuevo.", + SupportReferenceId: null), statusCode: StatusCodes.Status400BadRequest); } private static IResult MapAuthError(string error, string supportReferenceId) => error switch @@ -250,12 +353,101 @@ var e when e.StartsWith("AUTH_006") => Results.Json(new LoginErrorResponse( ErrorCodes.InvalidCredentials, "No pudimos iniciar sesión. Verifique sus credenciales.", supportReferenceId), statusCode: 401), var e when e.StartsWith("AUTH_011") => Results.Json(new LoginErrorResponse( ErrorCodes.MfaEnrollmentRequired, "Se requiere MFA. Registre y verifique un método MFA para continuar.", supportReferenceId), statusCode: 403), + // ADR-UMS-095 (UMS-066): cuenta bloqueada temporalmente por intentos fallidos → 423 Locked. + // Mensaje accionable en español, sin revelar la validez de las credenciales. + // AUTH_017 (AUTH_012 ya está tomado con el significado «no IDP adapter registered»). + var e when e.StartsWith("AUTH_017") => Results.Json(new LoginErrorResponse( + ErrorCodes.AccountLocked, "Su cuenta fue bloqueada temporalmente por varios intentos fallidos. Espere unos minutos e intente nuevamente, o contacte al administrador.", supportReferenceId), statusCode: 423), + // FR-042 (ADR-UMS-097 §2.3): cadena de fallback de IdP agotada por indisponibilidad de infraestructura + // → 503 Service Unavailable (NO 401): el servicio de auth federada no está disponible, las credenciales + // no se pusieron en duda. AUTH_012 (sin adaptador) comparte la misma semántica de indisponibilidad. + // G-108: AUTH_035 (token endpoint OIDC 5xx/timeout/transporte) es INFRA → 503; el 4xx invalid_grant + // sigue en AUTH_021 (credencial), que colapsa al 401 por defecto y jamás llega aquí. + var e when e.StartsWith("AUTH_018") || e.StartsWith("AUTH_012") || e.StartsWith("AUTH_035") => Results.Json(new LoginErrorResponse( + "AUTH_018", "El servicio de autenticación no está disponible temporalmente. Intente más tarde.", supportReferenceId), statusCode: 503), _ => Results.Json(new LoginErrorResponse("AUTH_000", "No pudimos iniciar sesión. Intente nuevamente.", supportReferenceId), statusCode: 401), }; + /// + /// Renovación por refresh token opaco (ADR-UMS-091/FR-015). Valida el token, regenera + /// el grafo COMPLETO, rota el refresh y devuelve el nuevo access token + grafo, para + /// que el frontend actualice su modelo de permisos sin re-login. + /// + private static async Task HandleRefreshTokenGrantAsync( + RefreshTokenRequest request, + IMediator mediator, + IJwtTokenService jwtTokenService, + Ums.Application.Common.Interfaces.IFunctionalTransaction functionalTransaction, + HttpContext httpContext, + CancellationToken cancellationToken) + { + if (request is null || string.IsNullOrWhiteSpace(request.RefreshToken)) + { + // ADR-0096 §2.2 / ADR-UMS-084: referencia legible para el usuario final. + return Results.BadRequest(new LoginErrorResponse( + ErrorCodes.ValidationError, + "Refresh token is required.", + SupportReferenceId: await functionalTransaction.GetOrMintLocatorAsync(cancellationToken))); + } + + var clientIp = httpContext.Connection.RemoteIpAddress?.ToString() ?? "127.0.0.1"; + + var result = await mediator.Send( + new RefreshAuthenticationCommand(request.RefreshToken, clientIp), + cancellationToken); + + if (result.IsFailure) + { + return MapRefreshError(result.Error, await functionalTransaction.GetOrMintLocatorAsync(cancellationToken)); + } + + var value = result.Value; + var graph = value.Graph; + var jwtToken = jwtTokenService.GenerateGraphToken(graph); + + var permissions = GraphNavigation.AllowedPairs(graph) + .Select(p => $"{p.Code}:{p.ActionCode}") + .ToArray(); + + return Results.Ok(new RefreshTokenGrantResponse( + Token: jwtToken, + TokenType: "Bearer", + ExpiresIn: value.ExpiresIn, + RefreshToken: value.NewRefreshToken, + RefreshExpiresIn: value.RefreshExpiresIn, + Permissions: permissions, + AuthorizationGraph: AuthGraphPayload.Build(graph), + GraphFormat: value.GraphFormat)); + } + + private static IResult MapRefreshError(string error, string supportReferenceId) + { + // La capacidad apagada es una decisión de política ⇒ 403; el resto son + // credenciales de renovación inválidas ⇒ 401. + var code = error.Split(':', 2)[0]; + var statusCode = code == RefreshErrorCodes.Disabled ? 403 : 401; + var clientCode = code == RefreshErrorCodes.Disabled + ? ErrorCodes.AccessDenied + : ErrorCodes.SessionExpired; + return Results.Json( + new LoginErrorResponse(clientCode, "No se pudo renovar la sesión. Inicie sesión nuevamente.", supportReferenceId), + statusCode: statusCode); + } + + /// + /// Refresh deslizante por cookie de sesión (D-019 / ADR-UMS-091). Espeja el login: + /// resuelve el principal autenticado desde la cookie, regenera el grafo de autorización + /// vigente (RefreshSessionCommand → IAuthorizationGraphBuilder) y emite un GRAPH JWT que + /// porta los permisos actuales, en vez de re-firmar un JWT con permisos vacíos. Cierra la + /// brecha de latencia de permisos del refresh (los permisos ya no quedan estancados hasta el + /// próximo re-login). No es el flujo de refresh token opaco (/refresh-token), que sigue intacto. + /// private static async Task HandleRefreshTokenAsync( + IMediator mediator, IJwtTokenService jwtTokenService, - HttpContext httpContext) + Ums.Application.Configuration.Services.IConfigurationProvider configProvider, + HttpContext httpContext, + CancellationToken cancellationToken) { if (!httpContext.User.Identity?.IsAuthenticated ?? true) { @@ -265,51 +457,108 @@ private static async Task HandleRefreshTokenAsync( SupportReferenceId: null), statusCode: 401); } - var userId = httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier); - var email = httpContext.User.FindFirstValue(ClaimTypes.Name); - var username = httpContext.User.FindFirstValue("username") ?? email ?? ""; + var userIdStr = httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier); var tenantIdStr = httpContext.User.FindFirstValue("tenant_id"); - var tenantCode = httpContext.User.FindFirstValue("tenant_code") ?? ""; - var role = httpContext.User.FindFirstValue(ClaimTypes.Role); - var roleName = httpContext.User.FindFirstValue("role_name"); - var profileId = httpContext.User.FindFirstValue("profile_id"); - if (string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(tenantIdStr) || !Guid.TryParse(tenantIdStr, out var tenantId)) + if (!Guid.TryParse(userIdStr, out var userId) || !Guid.TryParse(tenantIdStr, out var tenantId)) { return Results.Unauthorized(); } - var newToken = jwtTokenService.GenerateToken(new TokenGenerationRequest( - UserId: userId, - Email: email ?? "", - Username: username, - TenantId: tenantId, - TenantCode: tenantCode, - Role: role, - RoleName: roleName, - ProfileId: profileId, - Permissions: Array.Empty(), - Language: "en" - )); + var clientIp = httpContext.Connection.RemoteIpAddress?.ToString() ?? "127.0.0.1"; + + // Regenera el grafo vigente (espejo del login) en vez de re-firmar claims vacíos. + var result = await mediator.Send( + new RefreshSessionCommand(userId, tenantId, clientIp), + cancellationToken); + + if (result.IsFailure) + { + // El principal ya no puede renovar (inquilino/usuario inactivo o grafo irreconstruible). + return Results.Json(new LoginErrorResponse( + ErrorCodes.SessionExpired, + "Session expired or invalid", + SupportReferenceId: null), statusCode: 401); + } + + var value = result.Value; + var graph = value.Graph; + + // Graph JWT: el token ES el grafo, portando los permisos actuales (igual que el login). + var newToken = jwtTokenService.GenerateGraphToken(graph); + + var permissions = GraphNavigation.AllowedPairs(graph) + .Select(p => $"{p.Code}:{p.ActionCode}") + .ToArray(); + + // Valores derivados de la config efectiva del inquilino, como en el login (no hardcodeados). + var cfg = configProvider.ForTenant(tenantId); + var refreshExpiresIn = cfg.RefreshTokenDurationMs / 1000; var sessionTrackingId = httpContext.User.FindFirstValue("session_tracking_id") ?? httpContext.TraceIdentifier; return Results.Ok(new RefreshTokenResponse( Token: newToken, TokenType: "Bearer", - ExpiresIn: 3600, - RefreshExpiresIn: 604800, - SessionTrackingId: sessionTrackingId - )); + ExpiresIn: value.ExpiresIn, + RefreshExpiresIn: refreshExpiresIn, + SessionTrackingId: sessionTrackingId, + Permissions: permissions, + Language: cfg.DefaultLanguage, + AuthorizationGraph: AuthGraphPayload.Build(graph), + GraphFormat: value.GraphFormat)); } - private static async Task HandleLogoutAsync(HttpContext httpContext) + /// + /// Cierra la sesión ACTUAL, y solo esa. + /// + /// Hasta G-247 esto no cerraba nada: revocaba las familias de refresh y llamaba a + /// SignOutAsync, que solo le pide al navegador que borre la cookie. El portador seguía + /// siendo criptográficamente válido, así que quien tuviera una copia —una máquina compartida, + /// una captura de red— conservaba el acceso hasta que caducara solo. Se midió en vivo: tras el + /// logout, la misma cookie seguía devolviendo 200, también contra el mismo pod. + /// + /// La sesión se anota como cerrada en un almacén compartido, y + /// TokenRevocationMiddleware la consulta en cada petición autenticada. La clave es la + /// SESIÓN y no el usuario: cerrar sesión en el portátil no debe echar a nadie del móvil. Para + /// «esta cuenta queda fuera de todas partes» está ITokenRevocationStore, que es por + /// usuario y lo usan el bloqueo, el borrado y el cambio de contraseña. + /// + private static async Task HandleLogoutAsync( + HttpContext httpContext, + Ums.Application.Identity.Auth.IRefreshTokenStore refreshTokenStore, + ISessionRevocationStore sessionRevocationStore, + CancellationToken cancellationToken) { + // El plazo: hasta donde puede llegar el portador más largo que emite el login + // (`RememberMe` = 7 días). Recordar la sesión más allá no protege de nada —el portador ya + // no valdría— y solo haría crecer la lista. + var sessionId = httpContext.User.FindFirstValue("sid"); + if (!string.IsNullOrWhiteSpace(sessionId)) + { + await sessionRevocationStore.RevocarAsync( + sessionId, DateTime.UtcNow.AddDays(7), cancellationToken); + } + + // Logout real (ADR-UMS-091/FR-016): además de cerrar la cookie, revoca todas las + // familias vivas de refresh del usuario para que ningún refresh token siga sirviendo. + var userIdStr = httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier); + var tenantIdStr = httpContext.User.FindFirstValue("tenant_id"); + if (Guid.TryParse(userIdStr, out var userId) && Guid.TryParse(tenantIdStr, out var tenantId)) + { + await refreshTokenStore.RevokeAllForUserAsync( + tenantId, + userId, + reason: "logout", + revokedAtUtc: DateTime.UtcNow, + cancellationToken); + } + await httpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); return Results.Ok(new { message = "Logged out successfully" }); } - private static IResult HandleGetSessionAsync(HttpContext httpContext, ITenantContext tenantContext) + private static IResult HandleGetSessionAsync(HttpContext httpContext) { if (!httpContext.User.Identity?.IsAuthenticated ?? true) { @@ -317,7 +566,14 @@ private static IResult HandleGetSessionAsync(HttpContext httpContext, ITenantCon } var userId = httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier); - var email = httpContext.User.FindFirstValue(ClaimTypes.Name); + + // G-191/G-187: este endpoint ya no lo consume solo el portal. Con portador, el principal + // viene del JWT, donde el correo viaja en `email` y el nombre en `name` —no en el claim + // `ClaimTypes.Name` que estampa la cookie—, así que sin estos respaldos la sesión de un + // satélite se respondía con usuario y correo vacíos. + var email = httpContext.User.FindFirstValue(ClaimTypes.Name) + ?? httpContext.User.FindFirstValue(ClaimTypes.Email) + ?? httpContext.User.FindFirstValue("email"); var tenantId = httpContext.User.FindFirstValue("tenant_id"); var tenantCode = httpContext.User.FindFirstValue("tenant_code"); var tenantName = httpContext.User.FindFirstValue("tenant_name"); @@ -331,7 +587,9 @@ private static IResult HandleGetSessionAsync(HttpContext httpContext, ITenantCon SessionId: httpContext.TraceIdentifier, SessionTrackingId: sessionTrackingId, UserId: userId ?? "", - Username: httpContext.User.FindFirstValue("username") ?? email ?? "", + Username: httpContext.User.FindFirstValue("username") + ?? httpContext.User.FindFirstValue("name") + ?? email ?? "", Email: email ?? "", TenantId: tenantId ?? "", TenantCode: tenantCode ?? "", @@ -349,6 +607,169 @@ private static IResult HandleGetSessionAsync(HttpContext httpContext, ITenantCon )); } + /// + /// Cambia el perfil vigente y devuelve la MISMA forma que el login, para que el cliente + /// reutilice tal cual su código de inicialización: grafo nuevo, token nuevo y cookie reescrita. + /// + /// Nota deliberada sobre el token anterior: NO se revoca. `ITokenRevocationStore` revoca por + /// usuario y ventana de tiempo, no por token, así que revocar aquí dejaría al usuario fuera de + /// la aplicación inmediatamente después de cambiarse de perfil —incluido el token que se acaba + /// de emitir—. Y no hay escalada: el usuario poseía legítimamente ambos perfiles, de modo que + /// mantener el token viejo vivo hasta que expire equivale a dos sesiones con dos sombreros. + /// Un solo perfil activo a la vez exigiría revocación por `jti`, que hoy no existe. + /// + private static async Task HandleSwitchProfileAsync( + SwitchProfileRequest request, + IMediator mediator, + IJwtTokenService jwtTokenService, + Ums.Application.Configuration.Services.IConfigurationProvider configProvider, + IConfiguration configuration, + HttpContext httpContext, + CancellationToken cancellationToken) + { + var principal = LeerTokenDeGrafo(httpContext, configuration); + if (principal is null) return Results.Unauthorized(); + + var (userId, tenantId) = principal.Value; + + if (!Guid.TryParse(request.ProfileId, out var profileId)) + { + return Results.BadRequest(new LoginErrorResponse( + ErrorCodes.ValidationError, "Identificador de perfil inválido.", SupportReferenceId: null)); + } + + var clientIp = httpContext.Connection.RemoteIpAddress?.ToString() ?? "127.0.0.1"; + var result = await mediator.Send( + new SwitchProfileCommand(profileId, userId, tenantId, clientIp), cancellationToken); + + if (result.IsFailure) + { + var codigo = result.Error switch + { + var e when e.StartsWith("AUTH_020") => StatusCodes.Status404NotFound, + var e when e.StartsWith("AUTH_021") => StatusCodes.Status409Conflict, + var e when e.StartsWith("AUTH_005") => StatusCodes.Status401Unauthorized, + _ => StatusCodes.Status400BadRequest, + }; + + return Results.Json(new LoginErrorResponse( + ErrorCodes.AccessDenied, + codigo == StatusCodes.Status404NotFound + ? "El perfil solicitado no existe o no le pertenece." + : "No se pudo cambiar de perfil.", + SupportReferenceId: null), statusCode: codigo); + } + + var graph = result.Value.Graph; + var ctx = graph.Context; + var token = jwtTokenService.GenerateGraphToken(graph); + + // La cookie se reescribe con los claims del perfil NUEVO: si no, el rol de la cookie y el + // del token discreparían y la autorización dependería de cuál mirase cada middleware. + await ReescribirCookieDeSesionAsync(httpContext, ctx, graph); + + var cfg = configProvider.ForTenant(ctx.Tenant.Id); + var permissions = GraphNavigation.AllowedPairs(graph) + .Select(p => $"{p.Code}:{p.ActionCode}") + .ToArray(); + + return Results.Ok(new LoginSuccessResponse( + SessionId: httpContext.TraceIdentifier, + SessionTrackingId: $"{httpContext.TraceIdentifier}-session", + UserId: ctx.User.Id.ToString(), + Username: ctx.User.Username, + Email: ctx.User.Email, + TenantId: ctx.Tenant.Id.ToString(), + TenantCode: ctx.Tenant.Code, + TenantName: ctx.Tenant.Name, + Role: ctx.Role?.Code, + RoleName: ctx.Role?.Name, + ProfileId: ctx.Profile?.Id.ToString(), + Permissions: permissions, + Language: cfg.DefaultLanguage, + Token: token, + TokenType: "Bearer", + ExpiresIn: result.Value.ExpiresIn, + RefreshExpiresIn: null, // el refresh token vigente no se rota: la sesión es la misma + IsInternalAdmin: ctx.Tenant.IsManagementOwner, + SessionParameters: null, + AuthorizationGraph: AuthGraphPayload.Build(graph), + GraphFormat: "JSON", + RefreshToken: null)); + } + + /// + /// Valida el token de grafo del encabezado y devuelve (usuario, inquilino). Mismo criterio que + /// `HandleSwitchTenantAsync`: se valida aquí y no con `RequireAuthorization` para no depender + /// de que el esquema JWT esté configurado en el entorno. + /// + private static (Guid UserId, Guid TenantId)? LeerTokenDeGrafo(HttpContext httpContext, IConfiguration configuration) + { + var authHeader = httpContext.Request.Headers["Authorization"].FirstOrDefault(); + if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + return null; + + var tokenString = authHeader["Bearer ".Length..].Trim(); + var secret = configuration["Jwt:Secret"] ?? throw new InvalidOperationException("Jwt:Secret is not configured"); + + var handler = new JwtSecurityTokenHandler(); + try + { + handler.ValidateToken(tokenString, new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret)), + ValidateIssuer = false, + ValidateAudience = false, + ValidateLifetime = true, + ClockSkew = TimeSpan.FromMinutes(5), + }, out _); + } + catch (SecurityTokenException) + { + return null; + } + + var jwt = handler.ReadJwtToken(tokenString); + var sub = jwt.Claims.FirstOrDefault(c => c.Type == JwtRegisteredClaimNames.Sub)?.Value; + var ten = jwt.Claims.FirstOrDefault(c => c.Type == "tenant_id")?.Value; + + return Guid.TryParse(sub, out var userId) && Guid.TryParse(ten, out var tenantId) + ? (userId, tenantId) + : null; + } + + private static async Task ReescribirCookieDeSesionAsync( + HttpContext httpContext, + GraphContext ctx, + AuthorizationGraph graph) + { + var claims = new List + { + new(ClaimTypes.NameIdentifier, ctx.User.Id.ToString()), + new(ClaimTypes.Name, ctx.User.Email), + new("tenant_id", ctx.Tenant.Id.ToString()), + new("tenant_code", ctx.Tenant.Code), + new("username", ctx.User.Username), + new("auth_method", graph.Authentication.Method), + new("is_internal_admin", ctx.Tenant.IsManagementOwner ? "true" : "false"), + }; + + if (ctx.Role is not null) + { + claims.Add(new Claim(ClaimTypes.Role, ctx.Role.Code)); + claims.Add(new Claim("role_name", ctx.Role.Name)); + } + if (ctx.Profile is not null) claims.Add(new Claim("profile_id", ctx.Profile.Id.ToString())); + if (ctx.SystemSuite is not null) claims.Add(new Claim("sys_suite", ctx.SystemSuite.Code)); + if (ctx.Branch is not null) claims.Add(new Claim("branch_id", ctx.Branch.Id.ToString())); + + await httpContext.SignInAsync( + CookieAuthenticationDefaults.AuthenticationScheme, + new ClaimsPrincipal(new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme)), + new AuthenticationProperties { IsPersistent = false }); + } + private static async Task HandleSwitchTenantAsync( SwitchTenantRequest request, ITenantContext tenantContext, @@ -380,7 +801,7 @@ private static async Task HandleSwitchTenantAsync( try { - tokenHandler.ValidateToken(tokenString, validationParameters, out var validatedToken); + tokenHandler.ValidateToken(tokenString, validationParameters, out _); } catch (SecurityTokenException) { @@ -515,20 +936,48 @@ public record LoginSuccessResponse( bool IsInternalAdmin = false, SessionParameters? SessionParameters = null, // ── Graph fields (null when called from refresh/session endpoints) ────── - Ums.Domain.Authorization.Graph.AuthorizationGraph? AuthorizationGraph = null, - string? GraphFormat = null); + IReadOnlyDictionary? AuthorizationGraph = null, + string? GraphFormat = null, + // Refresh token en claro — solo cuando el inquilino activa la capacidad (ADR-UMS-091/FR-015); + // null en caso contrario. Se devuelve una única vez y nunca se persiste en claro. + string? RefreshToken = null); public record LoginErrorResponse( string Code, string Message, string? SupportReferenceId); +// ── Renovación por refresh token opaco (ADR-UMS-091 / FR-015) ────────────────────── +public record RefreshTokenRequest(string RefreshToken); + +public record RefreshTokenGrantResponse( + string Token, + string TokenType, + int ExpiresIn, + // Nuevo refresh en claro tras la rotación; null si la política no rota (el cliente + // conserva el suyo). Se devuelve una única vez y nunca se persiste en claro. + string? RefreshToken, + int RefreshExpiresIn, + string[] Permissions, + // Grafo recién regenerado (completo) para que la UI actualice su modelo de permisos. + IReadOnlyDictionary? AuthorizationGraph = null, + string? GraphFormat = null); + public record RefreshTokenResponse( string Token, string TokenType, int ExpiresIn, int RefreshExpiresIn, - string SessionTrackingId); + string SessionTrackingId, + // ── D-019 / ADR-UMS-091: el refresh deslizante ahora espeja el login y regenera el grafo, + // así que el token/respuesta portan el modelo de permisos VIGENTE. Campos aditivos y + // opcionales: el front actual (auth.store.ts::refreshSession) los ignora sin romperse. ── + string[]? Permissions = null, + string? Language = null, + IReadOnlyDictionary? AuthorizationGraph = null, + string? GraphFormat = null); + +public record SwitchProfileRequest(string ProfileId); public record SwitchTenantRequest( string TenantId, @@ -557,10 +1006,20 @@ public static class ErrorCodes public const string AdminLacksPermission = "AUTH_009"; public const string TargetUserOutsideScope = "AUTH_010"; public const string MfaEnrollmentRequired = "AUTH_011"; + // NOTA: AUTH_012 está reservado para «no IDP adapter registered» (UmsErrorCodes.NoIdpAdapterRegistered). + // ADR-UMS-095: cuenta bloqueada temporalmente por intentos fallidos (HTTP 423 Locked) usa AUTH_017. + public const string AccountLocked = "AUTH_017"; + // G-188: único desenlace de error del canje de restablecimiento. Deliberadamente indistinguible + // entre token inexistente, vencido, ya usado y cuenta inhabilitada. + public const string InvalidResetToken = "AUTH_019"; } public static class UserAccountErrorCodes { + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Security", "S2068:Hard-coded credentials are security-sensitive", + Justification = "Falso positivo: es un CÓDIGO DE ERROR ('USER_015'), no una credencial. El analizador " + + "casa por el término 'Password' en el nombre del símbolo.")] public const string FederatedUserPasswordReset = "USER_015"; } diff --git a/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Auth/ClientAuthEndpoints.cs b/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Auth/ClientAuthEndpoints.cs index 75bb49e5..8352bb20 100644 --- a/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Auth/ClientAuthEndpoints.cs +++ b/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Auth/ClientAuthEndpoints.cs @@ -6,6 +6,10 @@ namespace Ums.Presentation.Endpoints.Identity.Auth; using Ums.Domain.Authorization.Graph; using Ums.Presentation.Services; using BeyondNetCode.Shell.Factory.Interfaces; +using System.Security.Claims; +using Ums.Domain.Identity; +using Ums.Domain.Identity.Auth; +using UserAccountAgregado = Ums.Domain.Identity.UserAccount.UserAccount; /// /// Public external API for client system authentication. @@ -34,6 +38,336 @@ public static void MapClientAuthEndpoints(this WebApplication app) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status404NotFound); + + // El grafo, aparte de la autenticación y con el portador que ya tiene el satélite. + // + // Existe porque el grafo NO viaja dentro del JWT (D-031): meterlo ahí serían decenas de KB + // en cada cabecera y, sobre todo, un grafo irrevocable —un permiso retirado seguiría + // valiendo hasta que expirara el token, porque el token sería la fuente. Aquí la fuente + // sigue siendo UMS: el satélite revalida cuando su copia caduca, sin volver a pedir + // credenciales al usuario. + // + // Sirve además para responder «¿esta sesión sigue viva?»: un 401 aquí es la respuesta. + // G-191: la política `Satelite` fija el esquema PORTADOR. Con `RequireAuthorization()` a + // secas se autenticaba por el esquema por defecto —la cookie—, y un satélite con un + // portador válido recibía 302 al formulario de acceso en vez de 200 o 401. + group.MapGet("/graph", HandleClientGraphAsync) + .WithName("ClientGraph") + .WithSummary("Devuelve el grafo de autorización vigente del portador") + .RequireAuthorization(UmsAuthPolicies.Satelite) + .Produces>(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound); + + // Cambio de perfil por el carril de satélite (ADR-0156 §8, G-206). + // + // El grafo publica `profiles[]` con su `id` desde 2.4.0, es decir, invita al cliente a + // cambiarse de perfil. Hasta ahora la única operación que lo hacía era + // `POST /api/v1/auth/switch-profile`, que el portador semántico NO puede usar: valida el + // token a mano exigiendo un `sub` GUID y un claim `tenant_id` que ese token no lleva. + // + // NO se extiende aquel endpoint, y no es por comodidad. Valida el token a mano con + // `ValidateIssuer=false` y `ValidateAudience=false` (G-201): encaminar el carril de + // satélite por ahí lo haría entrar por la puerta más floja de la API justo cuando lo que + // se quiere es acotarlo. Además devuelve la forma del portal y reescribe una cookie de + // sesión que un satélite no tiene ni debe recibir. + // + // Lo único nuevo es este adaptador HTTP: `SwitchProfileCommand` y `BuildForProfileAsync` + // se reutilizan sin tocarlos, así que las comprobaciones de pertenencia —las que impiden + // que enviar el id del perfil de un administrador sea una escalada de una línea— son + // exactamente las mismas que ya protegen al portal. + group.MapPost("/switch-profile", HandleClientSwitchProfileAsync) + .WithName("ClientSwitchProfile") + .WithSummary("Cambia el perfil vigente del portador y devuelve el grafo del perfil nuevo") + .RequireAuthorization(UmsAuthPolicies.Satelite) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status409Conflict); + } + + private static async Task HandleClientGraphAsync( + IUserAccountRepository userAccounts, + ITenantRepository tenants, + IAuthorizationGraphBuilder graphBuilder, + HttpContext httpContext, + CancellationToken cancellationToken) + { + var supportReferenceId = httpContext.TraceIdentifier; + var principal = httpContext.User; + + // G-191: el token que entrega `/client/authenticate` es el SEMÁNTICO — lleva el inquilino + // por CÓDIGO y su `sub` es el nombre de usuario, no un GUID, porque el token para sistemas + // externos evita identificadores internos a propósito. Exigir un GUID aquí devolvía 401 + // incluso con un portador válido. + // + // La resolución vive en un solo sitio y la comparten los dos endpoints de portador: si + // cada uno la escribiera, acabarían divergiendo en el status o en el mensaje, y esa + // diferencia es precisamente lo que un atacante mide. + var (tenant, user, errorDeToken) = await ResolverContextoDelPortadorAsync( + userAccounts, tenants, principal, supportReferenceId, cancellationToken); + + if (errorDeToken is not null) return errorDeToken; + + // Se RECONSTRUYE, no se devuelve una copia guardada: el sentido de pedirlo aparte es que + // refleje los permisos de ahora, no los de cuando el usuario entró. + // El método de autenticación solo describe CÓMO entró el usuario y no altera qué permisos + // tiene, que es lo único que se pide aquí. Reconstruir el proveedor IdP exacto exigiría un + // claim que el token no lleva, así que no se finge: se declara local. + // + // El sistema sale del claim `sys_suite` del propio portador, que es el que se emitió al + // autenticar. Sin él, un satélite que entró pidiendo `SDLC` recibiría al revalidar un + // grafo de otro alcance —y por tanto otros menús y otros permisos— sin haber pedido nada + // distinto. No se acepta el sistema por parámetro aquí a propósito: el portador ya dice de + // qué sistema es, y dejar que el llamante lo contradiga abriría un camino para pedir el + // grafo de un sistema distinto con un token acotado a otro. + // + // Un token de `NoProfileInSystem` NO lleva `sys_suite` (ADR-0156 §5.4). Ahí el eco se + // pierde y la revalidación devuelve el grafo sin acotar, que es lo correcto: en cuanto un + // administrador le asigne el perfil, el satélite lo verá sin volver a pedir credenciales. + var suiteDelToken = principal.FindFirst("sys_suite")?.Value; + + // El PERFIL VIGENTE también sale del token (ADR-0156 §8). Reconstruir por desempate + // ignoraba la conmutación: un usuario que acababa de cambiarse a PMO revalidaba y recibía + // DIRECTORIO otra vez, porque el desempate no sabe que hubo una elección. Con el claim, la + // conmutación sobrevive a la revalidación, que es lo único que la hace útil. + // + // El id NO se toma al pie de la letra: `BuildForProfileAsync` vuelve a comprobar que el + // perfil pertenezca a este usuario y a este inquilino y que esté activo. Un token cuyo + // perfil se desactivó desde que se emitió cae al desempate, que es el comportamiento + // correcto —tenía acceso, se le retiró ese sombrero, le quedan los demás— y no un 500. + var perfilDelToken = principal.FindFirst("profile_id")?.Value; + + var porPerfil = Guid.TryParse(perfilDelToken, out var perfilId) + ? await graphBuilder.BuildForProfileAsync( + user, tenant.Props.Id.GetValue(), perfilId, AuthMethod.Local(), + suiteDelToken, cancellationToken) + : null; + + // Sin perfil en el token —o con uno que ya no resuelve— se cae al desempate, UNA vez. No + // es un atajo: un token emitido antes de que existiera el claim, o cuyo perfil se desactivó + // desde entonces, debe seguir sirviendo el grafo que le corresponda hoy, no un 500. + var grafo = porPerfil is { IsSuccess: true } + ? porPerfil + : await graphBuilder.BuildAsync( + user, tenant.Props.Id.GetValue(), AuthMethod.Local(), suiteDelToken, cancellationToken); + + if (grafo.IsFailure) + { + return Results.Json(new ClientAuthErrorResponse("AUTH_021", + "No se pudo construir el grafo de autorización.", supportReferenceId), + statusCode: StatusCodes.Status500InternalServerError); + } + + return Results.Ok(AuthGraphPayload.Build(grafo.Value)); + } + + private static async Task HandleClientSwitchProfileAsync( + ClientSwitchProfileRequest request, + IUserAccountRepository userAccounts, + ITenantRepository tenants, + IMediator mediator, + IJwtTokenService jwtService, + IAuthGraphFormatProvider formatProvider, + IFactory factory, + HttpContext httpContext, + CancellationToken cancellationToken) + { + var supportReferenceId = httpContext.TraceIdentifier; + + if (request.ProfileId == Guid.Empty) + { + return Results.Json(new ClientAuthErrorResponse("AUTH_001", + "ProfileId es obligatorio.", supportReferenceId), + statusCode: StatusCodes.Status400BadRequest); + } + + var (tenant, user, errorDeToken) = await ResolverContextoDelPortadorAsync( + userAccounts, tenants, httpContext.User, supportReferenceId, cancellationToken); + + if (errorDeToken is not null) return errorDeToken; + + // El sistema que acota el bloque `profiles` sale del claim del PORTADOR, no del cuerpo: + // el `systemCode` del cuerpo es la guarda de coherencia y el llamante podría omitirlo o + // contradecirlo, mientras que el claim lo emitió UMS al autenticar y el cliente no puede + // alterarlo sin invalidar la firma. + var comando = new SwitchProfileCommand( + ProfileId: request.ProfileId, + UserId: user!.Props.Id.GetValue(), + TenantId: tenant!.Props.Id.GetValue(), + ClientIp: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown", + SystemCode: httpContext.User.FindFirst("sys_suite")?.Value); + + var resultado = await mediator.Send(comando, cancellationToken); + + if (resultado.IsFailure) + { + var code = ExtractCode(resultado.Error); + // AUTH_020 cubre «no existe» y «no es tuyo», que el manejador ya colapsa en el mismo + // error a propósito: distinguirlos convertiría este endpoint en un detector de + // perfiles ajenos para cualquiera con un portador válido. AUTH_021 es «inactivo». + var statusCode = code switch + { + "AUTH_020" => StatusCodes.Status404NotFound, + "AUTH_021" => StatusCodes.Status409Conflict, + "AUTH_005" => StatusCodes.Status401Unauthorized, + _ => StatusCodes.Status401Unauthorized, + }; + return Results.Json(new ClientAuthErrorResponse( + code, SpanishMessage(code), supportReferenceId), + statusCode: statusCode); + } + + var grafo = resultado.Value.Graph; + + // Guarda de coherencia de sistema (ADR-0156 §8.5). `systemCode` es opcional y el satélite + // lo envía siempre. En el flujo normal NUNCA dispara, porque el bloque `profiles` que el + // cliente leyó ya venía acotado a su sistema; existe para que esa propiedad la garantice + // el SERVIDOR y no la disciplina del cliente, por si alguna vez llega un `profileId` que + // no salió de su propio grafo. + // + // Se comprueba DESPUÉS del cambio porque el sistema sale del grafo resultante, que es la + // fuente autoritativa. La consecuencia, declarada y no escondida: el manejador ya habrá + // registrado `Auth.Profile.Switch` como exitoso cuando esta guarda rechaza la respuesta. + // El perfil vigente NO cambia en la base —no hay estado de sesión que mutar, el perfil + // viaja en el token— así que el efecto es solo esa imprecisión de auditoría. Registrada + // como G-225. + var sistemaPedido = request.SystemCode?.Trim(); + if (!string.IsNullOrWhiteSpace(sistemaPedido) && + !string.Equals(grafo.Context.SystemSuite?.Code, sistemaPedido, StringComparison.OrdinalIgnoreCase)) + { + return Results.Json(new ClientAuthErrorResponse("AUTH_036", + "El perfil solicitado no pertenece al sistema indicado.", supportReferenceId), + statusCode: StatusCodes.Status409Conflict); + } + + var (formato, serializado) = await SerializarGrafoAsync( + grafo, request.Format, formatProvider, factory, httpContext, cancellationToken); + + // El token anterior NO se revoca (ADR-0156 §8.6): `ITokenRevocationStore` revoca por + // usuario y ventana de tiempo, no por token, así que revocar aquí dejaría al usuario fuera + // inmediatamente después de cambiarse —incluido el token recién emitido—. No hay escalada: + // el usuario poseía legítimamente ambos perfiles. Quien debe descartar su copia anterior + // es el satélite, reemplazando la entrada de su caché en vez de añadir otra. + var token = jwtService.GenerateSemanticGraphToken(grafo); + + httpContext.Response.Headers["X-Graph-Format"] = formato; + return Results.Ok(new ClientAuthResponse( + Token: token, + TokenType: "Bearer", + ExpiresIn: resultado.Value.ExpiresIn, + IssuedAt: resultado.Value.IssuedAt, + Format: formato, + Graph: serializado, + RequestId: httpContext.TraceIdentifier)); + } + + /// + /// Inquilino y cuenta del portador, con el mismo criterio que `GET /client/graph`: el token de + /// cliente lleva el inquilino por CÓDIGO y su `sub` puede ser un GUID o el nombre de usuario. + /// Devuelve el resultado de error ya formado cuando no resuelve, para que los dos endpoints + /// que dependen del portador no puedan divergir en el status ni en el mensaje. + /// + private static async Task<(Ums.Domain.Identity.Tenant.Tenant? Tenant, + UserAccountAgregado? Usuario, + IResult? Error)> ResolverContextoDelPortadorAsync( + IUserAccountRepository userAccounts, + ITenantRepository tenants, + ClaimsPrincipal principal, + string supportReferenceId, + CancellationToken cancellationToken) + { + var subject = principal.FindFirst(ClaimTypes.NameIdentifier)?.Value + ?? principal.FindFirst("sub")?.Value; + var correo = principal.FindFirst(ClaimTypes.Email)?.Value + ?? principal.FindFirst("email")?.Value; + var tenantCode = principal.FindFirst("tenant_code")?.Value; + + if (string.IsNullOrWhiteSpace(tenantCode) || + (string.IsNullOrWhiteSpace(subject) && string.IsNullOrWhiteSpace(correo))) + { + return (null, null, Results.Json(new ClientAuthErrorResponse("AUTH_020", + "El token no identifica un usuario y un inquilino.", supportReferenceId), + statusCode: StatusCodes.Status401Unauthorized)); + } + + var tenant = await tenants.GetByCodeAsync(tenantCode.ToUpperInvariant(), cancellationToken); + var user = await ResolverUsuarioDelTokenAsync( + userAccounts, tenant, subject, correo, cancellationToken); + + // Mismo error para «no existe» y «no pertenece»: distinguirlos convertiría esto en un + // detector de cuentas e inquilinos para cualquiera con un token válido. + if (tenant is null || user is null || + user.Props.TenantId.GetValue() != tenant.Props.Id.GetValue()) + { + return (null, null, Results.Json(new ClientAuthErrorResponse("AUTH_020", + "No se encontró el contexto de autorización del token.", supportReferenceId), + statusCode: StatusCodes.Status404NotFound)); + } + + return (tenant, user, null); + } + + /// + /// Serializa el grafo en el formato negociado: parámetro explícito, cabecera `Accept` o el + /// que resuelva el inquilino. Compartido por la autenticación y el cambio de perfil para que + /// un satélite que ya sabe leer la respuesta del login sepa leer la del cambio sin aprender + /// nada nuevo. + /// + private static async Task<(string Formato, string Serializado)> SerializarGrafoAsync( + Ums.Domain.Authorization.Graph.AuthorizationGraph grafo, + string? formatoPedido, + IAuthGraphFormatProvider formatProvider, + IFactory factory, + HttpContext httpContext, + CancellationToken cancellationToken) + { + var solicitado = formatoPedido?.ToUpperInvariant() + ?? GetFormatFromAcceptHeader(httpContext.Request.Headers.Accept.ToString()); + + var formato = await formatProvider.ResolveFormatAsync( + grafo.Context.Tenant.Id, solicitado, cancellationToken); + + var serializer = factory + .Create( + new GraphSerializationCriteria(formato)) + .SingleOrDefault(); + + // Sin serializador para el formato resuelto no se inventa uno ni se calla: JSON es el + // formato que todo consumidor del contrato entiende, y el encabezado dirá cuál viajó. + return serializer is null + ? ("JSON", System.Text.Json.JsonSerializer.Serialize( + Ums.Application.Authorization.Graph.Serializers.AuthGraphPayload.Build(grafo))) + : (formato, serializer.Serialize(grafo)); + } + + /// + /// Resuelve la cuenta que representa el portador. Por id cuando el `sub` es un GUID (token de + /// grafo del portal) y por correo dentro del inquilino cuando el `sub` es el nombre de usuario + /// (token semántico de `/client/authenticate`, que evita identificadores internos a propósito). + /// + private static async Task ResolverUsuarioDelTokenAsync( + IUserAccountRepository userAccounts, + Ums.Domain.Identity.Tenant.Tenant? tenant, + string? subject, + string? correo, + CancellationToken cancellationToken) + { + if (Guid.TryParse(subject, out var userGuid)) + { + return await userAccounts.GetByIdAsync(userGuid, cancellationToken); + } + + if (tenant is null || string.IsNullOrWhiteSpace(correo)) + { + return null; + } + + return await userAccounts.GetByTenantAndEmailAsync( + tenant.Props.Id.GetValue(), + Ums.Domain.Kernel.ValueObjects.Email.Create(correo), + cancellationToken: cancellationToken); } private static async Task HandleClientAuthenticateAsync( @@ -45,12 +379,16 @@ private static async Task HandleClientAuthenticateAsync( HttpContext httpContext, CancellationToken cancellationToken) { + // SD-08: los mensajes al cliente se emiten en español y siempre acompañados + // de un supportReferenceId para trazabilidad (mismo contrato que /auth/login). + var supportReferenceId = httpContext.TraceIdentifier; + if (string.IsNullOrWhiteSpace(request.TenantCode) || string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password)) { return Results.Json(new ClientAuthErrorResponse("AUTH_001", - "TenantCode, Username and Password are required.", null), + "TenantCode, Username y Password son obligatorios.", supportReferenceId), statusCode: StatusCodes.Status400BadRequest); } @@ -62,15 +400,17 @@ private static async Task HandleClientAuthenticateAsync( Password: request.Password, ClientIp: clientIp, AccessScope: Ums.Domain.Identity.Auth.AuthAccessScope.ExternalApi, - RememberMe: false); + RememberMe: false, + SystemCode: request.SystemCode?.Trim()); var result = await mediator.Send(command, cancellationToken); if (result.IsFailure) { var statusCode = GetStatusCode(result.Error); + var code = ExtractCode(result.Error); return Results.Json(new ClientAuthErrorResponse( - ExtractCode(result.Error), CleanMessage(result.Error), null), + code, SpanishMessage(code), supportReferenceId), statusCode: statusCode); } @@ -125,15 +465,30 @@ private static async Task HandleClientAuthenticateAsync( // ── Helpers ────────────────────────────────────────────────────────────── + // G-053 (anti-enumeración): en este endpoint público y anónimo, «tenant no existe» + // (AUTH_002) y «tenant inactivo» (AUTH_003) NO deben distinguirse de «credenciales + // inválidas» (AUTH_006). Antes AUTH_002→404 y AUTH_003→400 permitían enumerar códigos + // de inquilino comparando el status contra el 401 de credenciales. Ahora todos colapsan + // a 401 con un mensaje genérico e indistinguible. AUTH_004/005 (nivel usuario, tras auth + // del IDP) y AUTH_011/012 (infraestructura) conservan su semántica. private static int GetStatusCode(string error) => error switch { - var e when e.StartsWith("AUTH_002") => StatusCodes.Status404NotFound, - var e when e.StartsWith("AUTH_003") => StatusCodes.Status400BadRequest, + var e when e.StartsWith("AUTH_002") => StatusCodes.Status401Unauthorized, + var e when e.StartsWith("AUTH_003") => StatusCodes.Status401Unauthorized, var e when e.StartsWith("AUTH_004") => StatusCodes.Status404NotFound, var e when e.StartsWith("AUTH_005") => StatusCodes.Status401Unauthorized, var e when e.StartsWith("AUTH_006") => StatusCodes.Status401Unauthorized, + // ADR-UMS-095: bloqueo temporal de cuenta (AUTH_017). Anti-enumeración G-053: en este endpoint + // público y anónimo NO se expone 423 Locked; colapsa a 401 e indistinguible de credenciales + // inválidas, para no revelar que la cuenta existe y está bloqueada. (En /auth/login sí es 423.) + var e when e.StartsWith("AUTH_017") => StatusCodes.Status401Unauthorized, var e when e.StartsWith("AUTH_011") => StatusCodes.Status503ServiceUnavailable, var e when e.StartsWith("AUTH_012") => StatusCodes.Status503ServiceUnavailable, + // FR-042 (ADR-UMS-097 §2.3): cadena de fallback de IdP agotada por indisponibilidad → 503, no 401. + var e when e.StartsWith("AUTH_018") => StatusCodes.Status503ServiceUnavailable, + // G-108 (ADR-UMS-097 §2.3): token endpoint OIDC indisponible (5xx/timeout/transporte) → 503, no 401. + // Es INFRA, no credencial (el 4xx invalid_grant es AUTH_021 y colapsa al 401 por defecto). + var e when e.StartsWith("AUTH_035") => StatusCodes.Status503ServiceUnavailable, _ => StatusCodes.Status401Unauthorized, }; @@ -143,11 +498,34 @@ private static string ExtractCode(string error) return idx > 0 ? error[..idx].Trim() : "AUTH_000"; } - private static string CleanMessage(string error) + // SD-08: traducción de los códigos de error del motor de autenticación a mensajes + // en español, seguros para el cliente (sin filtrar detalles internos). No se reusa el + // mensaje crudo del handler (en inglés y con posible detalle técnico). + private static string SpanishMessage(string code) => code switch { - var idx = error.IndexOf(':'); - return idx > 0 ? error[(idx + 1)..].Trim() : error; - } + // G-053 (anti-enumeración): AUTH_002 (tenant no existe) y AUTH_003 (tenant inactivo) + // devuelven el MISMO mensaje genérico que las credenciales inválidas, para no revelar + // la existencia ni el estado de un inquilino desde un endpoint público. + var c when c.StartsWith("AUTH_002") => "No pudimos autenticar. Verifique sus credenciales.", + var c when c.StartsWith("AUTH_003") => "No pudimos autenticar. Verifique sus credenciales.", + var c when c.StartsWith("AUTH_004") => "No pudimos autenticar. Verifique sus credenciales.", + var c when c.StartsWith("AUTH_005") => "Su cuenta no está activa. Contacte al administrador.", + var c when c.StartsWith("AUTH_006") => "No pudimos autenticar. Verifique sus credenciales.", + // ADR-UMS-095: bloqueo temporal (AUTH_017) — mismo mensaje genérico que credenciales inválidas + // (anti-enumeración G-053); el mensaje accionable de bloqueo solo se entrega en /auth/login. + var c when c.StartsWith("AUTH_017") => "No pudimos autenticar. Verifique sus credenciales.", + var c when c.StartsWith("AUTH_011") => "El servicio de autenticación no está disponible temporalmente. Intente más tarde.", + var c when c.StartsWith("AUTH_012") => "El servicio de autenticación no está disponible temporalmente. Intente más tarde.", + var c when c.StartsWith("AUTH_018") => "El servicio de autenticación no está disponible temporalmente. Intente más tarde.", + var c when c.StartsWith("AUTH_035") => "El servicio de autenticación no está disponible temporalmente. Intente más tarde.", + // Cambio de perfil (ADR-0156 §8.4). AUTH_020 cubre a la vez «el perfil no existe» y «el + // perfil no es tuyo»: el mensaje es deliberadamente el mismo, porque distinguirlos + // permitiría enumerar perfiles ajenos preguntando por identificadores. + var c when c.StartsWith("AUTH_020") => "No se encontró el perfil solicitado.", + var c when c.StartsWith("AUTH_021") => "El perfil solicitado no está activo.", + var c when c.StartsWith("AUTH_036") => "El perfil solicitado no pertenece al sistema indicado.", + _ => "No pudimos autenticar. Intente nuevamente.", + }; private static string? GetFormatFromAcceptHeader(string acceptHeader) => acceptHeader switch @@ -170,7 +548,33 @@ public record ClientAuthRequest( string Username, // email or identity reference string Password, // plaintext (Local) or MOCK-* (stub IDP) string? Format = null, // override graph format: JSON|XML|YAML|CSV - string[]? RequestedScopes = null); // optional scope filter (future use) + string[]? RequestedScopes = null, // optional scope filter (future use) + // ADR-0156 §3.2 — código del sistema que pide el grafo. OPCIONAL a propósito: un portal + // multiproducto lo omite y recibe los perfiles de todos sus sistemas; un satélite como el + // Tablero SDLC envía el suyo y recibe solo los de ese sistema. + // + // No se reutiliza `RequestedScopes` para esto: es un filtro de ÁMBITOS, no de sistema, y + // darle una segunda semántica lo dejaría inservible para la primera (§3.3). + string? SystemCode = null); + +/// +/// Cuerpo de POST /api/v1/client/switch-profile (ADR-0156 §8.2). +/// +/// +/// Obligatorio. Sale de graph.profiles[].id, que el contrato emite siempre desde 2.4.0. +/// Nunca se confía en él: el manejador comprueba que el perfil pertenezca al usuario y a su +/// inquilino antes de construir nada. +/// +/// +/// Opcional. Guarda de coherencia: si viene, el perfil debe pertenecer a ese sistema. En el flujo +/// normal no dispara, porque el bloque profiles ya viaja acotado; existe para que esa +/// propiedad la garantice el servidor y no la disciplina del cliente. +/// +/// Igual que en la autenticación: JSON|XML|YAML|CSV. +public record ClientSwitchProfileRequest( + Guid ProfileId, + string? SystemCode = null, + string? Format = null); /// /// Response from POST /api/v1/client/authenticate. diff --git a/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Auth/JwksEndpoints.cs b/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Auth/JwksEndpoints.cs new file mode 100644 index 00000000..eab9be03 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Auth/JwksEndpoints.cs @@ -0,0 +1,73 @@ +namespace Ums.Presentation.Endpoints.Identity.Auth; + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Ums.Presentation.Services; + +/// +/// El material público con el que cualquiera verifica un portador de UMS. +/// +/// +/// +/// Es la mitad que faltaba de ADR-0157. Firmar en RS256 sin publicar la clave +/// pública no sirve de nada: ningún satélite podría verificar, y la única salida +/// sería repartir material de firma, que es exactamente lo que la decisión elimina. +/// +/// +/// Anónimo a propósito. Una clave pública es pública: exigir +/// credenciales para obtenerla crearía el problema del huevo y la gallina —haría +/// falta un token válido para conseguir con qué validar tokens—. Es lo que hacen +/// todos los proveedores de identidad, y por lo mismo. +/// +/// +public static class JwksEndpoints +{ + /// Mapea el JWKS y el documento de descubrimiento. + /// Aplicación web. + public static void MapJwksEndpoints(this WebApplication app) + { + ArgumentNullException.ThrowIfNull(app); + + _ = app.MapGet("/.well-known/jwks.json", (MaterialDeFirma firma, HttpContext contexto) => + { + // Cachear en el cliente, no en el servidor: el satélite guarda el JWKS y + // solo vuelve cuando ve un `kid` que no conoce. Diez minutos es corto a + // propósito —una rotación no debe tardar horas en propagarse— y suficiente + // para que la verificación no dependa de la red en cada petición. + contexto.Response.Headers.CacheControl = "public, max-age=600"; + + return Results.Ok(new { keys = new[] { firma.ComoJwkPublico() } }); + }) + .WithName("Jwks") + .WithTags("Discovery") + .WithSummary("Claves públicas con las que se verifica un portador emitido por UMS") + .AllowAnonymous(); + + // Tres campos y ni uno más (ADR-0157 §4.1). UMS **no** declara con esto + // conformidad como OpenID Provider, y no debe leerse así: el documento existe + // porque los validadores de .NET y de Node resuelven las claves a través de él, + // y publicarlo cuesta menos que cablear el `MetadataAddress` en cada satélite. + _ = app.MapGet("/.well-known/openid-configuration", ( + MaterialDeFirma firma, + IConfiguration configuracion, + HttpContext contexto) => + { + string emisor = configuracion["Jwt:Issuer"] ?? "ums-api"; + string baseUrl = $"{contexto.Request.Scheme}://{contexto.Request.Host}"; + + contexto.Response.Headers.CacheControl = "public, max-age=600"; + + return Results.Ok(new + { + issuer = emisor, + jwks_uri = $"{baseUrl}/.well-known/jwks.json", + id_token_signing_alg_values_supported = new[] { "RS256" }, + }); + }) + .WithName("OpenIdConfiguration") + .WithTags("Discovery") + .WithSummary("Descubrimiento mínimo: emisor, ubicación del JWKS y algoritmo de firma") + .AllowAnonymous(); + } +} diff --git a/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Tenant/Queries/BranchQueryEndpoints.cs b/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Tenant/Queries/BranchQueryEndpoints.cs index c72dbf61..c20dcdf1 100644 --- a/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Tenant/Queries/BranchQueryEndpoints.cs +++ b/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Tenant/Queries/BranchQueryEndpoints.cs @@ -7,19 +7,44 @@ public static class BranchQueryEndpoints { public static IEndpointRouteBuilder MapBranchQueryEndpoints(this IEndpointRouteBuilder app) { + // G-041 (SEGURIDAD): la estructura de sucursales del tenant exige autenticación. var group = app.MapGroup("/tenants/{tenantId:guid}/branches") - .WithTags("Branches - Queries"); + .WithTags("Branches - Queries") + .RequireAuthorization(); - group.MapGet("/", async (Guid tenantId, IMediator mediator, HttpContext context, CancellationToken ct) => + group.MapGet("/", async ( + Guid tenantId, + [FromQuery] bool? includeClosed, + IMediator mediator, + HttpContext context, + CancellationToken ct) => { - var result = await mediator.Send(new GetBranchesByTenantIdQuery(tenantId), ct); + var result = await mediator.Send(new GetBranchesByTenantIdQuery(tenantId, includeClosed ?? false), ct); return result.ToOk(context); }) .WithName("GetBranchesByTenantId") - .WithSummary("Get all branches for a tenant") + .WithSummary("Get all branches for a tenant (las cerradas definitivamente se excluyen salvo includeClosed=true)") .Produces>(StatusCodes.Status200OK) .ProducesProblem(StatusCodes.Status404NotFound); + // ADR-0164: la BITÁCORA de la sucursal. Existe porque el estado solo dice cómo está hoy, y la + // pregunta de auditoría de un operador aduanero es cómo estaba cuando salió un despacho de + // hace años. Responde también para sucursales cerradas: ese es su caso de uso principal. + group.MapGet("/{branchId:guid}/bitacora", async ( + Guid tenantId, + Guid branchId, + IMediator mediator, + HttpContext context, + CancellationToken ct) => + { + var result = await mediator.Send(new GetBranchLifecycleQuery(tenantId, branchId), ct); + return result.ToOk(context); + }) + .WithName("GetBranchLifecycle") + .WithSummary("Bitácora de episodios de una sucursal: apertura, bajas, reaperturas y cierre definitivo") + .Produces>(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status404NotFound); + return app; } } diff --git a/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Tenant/Queries/TenantQueryEndpoints.cs b/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Tenant/Queries/TenantQueryEndpoints.cs index d04e7b0a..ea21e08e 100644 --- a/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Tenant/Queries/TenantQueryEndpoints.cs +++ b/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Tenant/Queries/TenantQueryEndpoints.cs @@ -10,8 +10,10 @@ public static class TenantQueryEndpoints { public static IEndpointRouteBuilder MapTenantQueryEndpoints(this IEndpointRouteBuilder app) { + // G-041 (SEGURIDAD): la lectura de tenant por id exige autenticación. var group = app.MapGroup("/tenants") - .WithTags("Tenants - Queries"); + .WithTags("Tenants - Queries") + .RequireAuthorization(); // GetAllTenants lives in TenantEndpoints to share the same route group // and avoid Asp.Versioning GET-root shadowing on duplicate MapGroup("/tenants"). diff --git a/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Tenant/TenantBranchEndpoints.cs b/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Tenant/TenantBranchEndpoints.cs index d27de0fe..4ec835bd 100644 --- a/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Tenant/TenantBranchEndpoints.cs +++ b/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Tenant/TenantBranchEndpoints.cs @@ -28,18 +28,40 @@ public static IEndpointRouteBuilder MapTenantBranchEndpoints(this IEndpointRoute .ProducesProblem(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status409Conflict); + group.MapPut("/{branchId:guid}", async ( + Guid tenantId, + Guid branchId, + [FromBody] UpdateBranchRequest request, + IMediator mediator, + HttpContext context, + CancellationToken ct) => + { + var command = new UpdateBranchCommand(tenantId, branchId, request.Name, request.GeofencingMetadata); + var result = await mediator.Send(command, ct); + return result.ToNoContent(context); + }) + .WithName("UpdateBranch") + .WithSummary("Update a branch's editable data (name, geofencing)") + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound); + + // ADR-0164: el verbo HTTP sigue siendo DELETE —es el verbo REST de la baja y el frontend ya + // lo invoca—, pero lo que ocurre detrás es un cierre DEFINITIVO con borrado lógico: la fila + // permanece y el código queda ocupado para siempre. No hay borrado físico que ofrecer. group.MapDelete("/{branchId:guid}", async ( Guid tenantId, Guid branchId, + [FromQuery] string? reason, IMediator mediator, HttpContext context, CancellationToken ct) => { - var result = await mediator.Send(new RemoveBranchCommand(tenantId, branchId), ct); + var result = await mediator.Send(new CloseBranchCommand(tenantId, branchId, reason), ct); return result.ToNoContent(context); }) - .WithName("RemoveBranch") - .WithSummary("Remove a branch from a tenant") + .WithName("CloseBranch") + .WithSummary("Cierra definitivamente una sucursal (borrado lógico; la fila permanece y el código no se libera)") .Produces(StatusCodes.Status204NoContent) .ProducesProblem(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status409Conflict); @@ -81,3 +103,4 @@ public static IEndpointRouteBuilder MapTenantBranchEndpoints(this IEndpointRoute } public sealed record AddBranchRequest(string Code, string Name, string? GeofencingMetadata); +public sealed record UpdateBranchRequest(string Name, string? GeofencingMetadata); diff --git a/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Tenant/TenantEndpoints.cs b/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Tenant/TenantEndpoints.cs index edb0ca01..95dbeabb 100644 --- a/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Tenant/TenantEndpoints.cs +++ b/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Tenant/TenantEndpoints.cs @@ -10,6 +10,9 @@ namespace Ums.Presentation.Endpoints.Identity.Tenant; public sealed record SetManagementOwnerRequest(bool Value); +// FS-26 (G-024): datos generales editables del tenant. El Code es inmutable, no se envía. +public sealed record UpdateTenantRequest(string Name, string Type, string? CompanyReference); + public static class TenantEndpoints { public static IEndpointRouteBuilder MapTenantEndpoints(this IEndpointRouteBuilder app) @@ -35,7 +38,11 @@ public static IEndpointRouteBuilder MapTenantEndpoints(this IEndpointRouteBuilde page <= 0 ? 1 : page, pageSize <= 0 ? 20 : pageSize, search, - string.IsNullOrWhiteSpace(criteria) ? "name" : criteria, + // criteria = campo de búsqueda; se pasa tal cual (null si no se envía). El handler/repo + // cae a sortBy por compatibilidad. Antes se forzaba a "name", lo que impedía buscar por + // código salvo enviando criteria=code explícito (G-014 residual: GetTenants_AfterCreate + // usaba sortBy=code sin criteria y dejaba de encontrar el tenant). + string.IsNullOrWhiteSpace(criteria) ? null : criteria, string.IsNullOrWhiteSpace(status) ? "all" : status, string.IsNullOrWhiteSpace(sortBy) ? "name" : sortBy, string.IsNullOrWhiteSpace(sortOrder) ? "asc" : sortOrder), ct); @@ -43,6 +50,8 @@ public static IEndpointRouteBuilder MapTenantEndpoints(this IEndpointRouteBuilde }) .WithName("GetAllTenants") .WithSummary("Get tenants using server-side pagination") + // G-041 (SEGURIDAD): el listado de tenants exige autenticación. + .RequireAuthorization() .Produces>(StatusCodes.Status200OK); // ── Commands ───────────────────────────────────────────────────────── @@ -80,6 +89,23 @@ public static IEndpointRouteBuilder MapTenantEndpoints(this IEndpointRouteBuilde .ProducesProblem(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status409Conflict); + group.MapPut("/{tenantId:guid}", async ( + Guid tenantId, + UpdateTenantRequest body, + IMediator mediator, + HttpContext context, + CancellationToken ct) => + { + var result = await mediator.Send( + new UpdateTenantCommand(tenantId, body.Name, body.Type, body.CompanyReference), ct); + return result.ToNoContent(context); + }) + .WithName("UpdateTenant") + .WithSummary("Update a tenant's general data (name, type, company reference)") + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound); + group.MapPost("/{tenantId:guid}/set-management-owner", async ( Guid tenantId, SetManagementOwnerRequest body, diff --git a/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/UserAccount/Queries/UserAccountQueryEndpoints.cs b/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/UserAccount/Queries/UserAccountQueryEndpoints.cs index a1160f18..2668fd78 100644 --- a/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/UserAccount/Queries/UserAccountQueryEndpoints.cs +++ b/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/UserAccount/Queries/UserAccountQueryEndpoints.cs @@ -8,8 +8,10 @@ public static class UserAccountQueryEndpoints { public static IEndpointRouteBuilder MapUserAccountQueryEndpoints(this IEndpointRouteBuilder app) { + // G-041 (SEGURIDAD): estas consultas exponen PII; solo usuarios autenticados. var group = app.MapGroup("/user-accounts") - .WithTags("UserAccounts - Queries"); + .WithTags("UserAccounts - Queries") + .RequireAuthorization(); group.MapGet("/{userAccountId:guid}", async (Guid userAccountId, IMediator mediator, HttpContext context, CancellationToken ct) => { diff --git a/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/UserAccount/UserAccountEndpoints.cs b/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/UserAccount/UserAccountEndpoints.cs index f4a54a8b..0192d5ad 100644 --- a/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/UserAccount/UserAccountEndpoints.cs +++ b/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/UserAccount/UserAccountEndpoints.cs @@ -1,5 +1,7 @@ namespace Ums.Presentation.Endpoints.Identity.UserAccount; +#pragma warning disable S125 + using Ums.Application.Common; using Ums.Application.Identity.UserAccount.Commands; using Ums.Application.Identity.UserAccount.DTOs; @@ -38,6 +40,8 @@ public static IEndpointRouteBuilder MapUserAccountEndpoints(this IEndpointRouteB }) .WithName("GetAllUserAccounts") .WithSummary("Get user accounts using server-side pagination") + // G-041 (SEGURIDAD): el listado expone PII; exige autenticación. + .RequireAuthorization() .Produces>(StatusCodes.Status200OK); @@ -153,27 +157,6 @@ public static IEndpointRouteBuilder MapUserAccountEndpoints(this IEndpointRouteB // Historic credential reactivation and physical deletion remain intentionally // unavailable: password rotation retains inactive entries for security audit. - // group.MapPost("/{userAccountId:guid}/passwords/{credentialId:guid}/activate", async (Guid userAccountId, Guid credentialId, IMediator mediator, HttpContext context, CancellationToken ct) => - // { - // var result = await mediator.Send(new ActivateUserAccountPasswordCommand(userAccountId, credentialId), ct); - // return result.ToNoContent(context); - // }) - // .WithName("ActivateUserAccountPassword") - // .WithSummary("Activate an existing password credential") - // .Produces(StatusCodes.Status204NoContent) - // .ProducesProblem(StatusCodes.Status404NotFound) - // .ProducesProblem(StatusCodes.Status409Conflict); - - // group.MapDelete("/{userAccountId:guid}/passwords/{credentialId:guid}", async (Guid userAccountId, Guid credentialId, IMediator mediator, HttpContext context, CancellationToken ct) => - // { - // var result = await mediator.Send(new RemoveUserAccountPasswordCommand(userAccountId, credentialId), ct); - // return result.ToNoContent(context); - // }) - // .WithName("RemoveUserAccountPassword") - // .WithSummary("Remove a password credential") - // .Produces(StatusCodes.Status204NoContent) - // .ProducesProblem(StatusCodes.Status404NotFound) - // .ProducesProblem(StatusCodes.Status409Conflict); group.MapPost("/{userAccountId:guid}/mfa-enrollments", async (Guid userAccountId, EnrollUserAccountMfaCommand command, IMediator mediator, HttpContext context, CancellationToken ct) => { diff --git a/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/UserManagementDelegation/DelegationEndpoints.cs b/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/UserManagementDelegation/DelegationEndpoints.cs index 79971a41..e5ac2cb4 100644 --- a/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/UserManagementDelegation/DelegationEndpoints.cs +++ b/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/UserManagementDelegation/DelegationEndpoints.cs @@ -7,8 +7,14 @@ public static class DelegationEndpoints { public static IEndpointRouteBuilder MapDelegationEndpoints(this IEndpointRouteBuilder app) { + // SEGURIDAD (ADR-UMS-086 endurecido, G-148): una delegación confiere autoridad de + // administrador; todo el grupo de escritura exige autenticación (RequireAuthorization), + // igual que los endpoints de consulta de delegación y el grupo IGA de promoción de rol. + // La autorización fina (management-owner) y el aislamiento por inquilino se hacen cumplir + // en la capa de aplicación (ITenantScopePolicy), y la separación de funciones en el dominio. var group = app.MapGroup("/delegations") - .WithTags("Delegations"); + .WithTags("Delegations") + .RequireAuthorization(); group.MapPost("/", async (CreateDelegationCommand command, IMediator mediator, HttpContext context, CancellationToken ct) => { @@ -43,6 +49,39 @@ public static IEndpointRouteBuilder MapDelegationEndpoints(this IEndpointRouteBu .ProducesProblem(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status409Conflict); + group.MapPost("/{delegationId:guid}/submit-for-approval", async (Guid delegationId, IMediator mediator, HttpContext context, CancellationToken ct) => + { + var result = await mediator.Send(new SubmitDelegationForApprovalCommand(delegationId), ct); + return result.ToNoContent(context); + }) + .WithName("SubmitDelegationForApproval") + .WithSummary("Submit a draft delegation for approval (Draft → PendingApproval)") + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict); + + group.MapPost("/{delegationId:guid}/approve", async (Guid delegationId, IMediator mediator, HttpContext context, CancellationToken ct) => + { + var result = await mediator.Send(new ApproveDelegationCommand(delegationId), ct); + return result.ToNoContent(context); + }) + .WithName("ApproveDelegation") + .WithSummary("Approve a delegation pending approval (PendingApproval → Active)") + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict); + + group.MapPost("/{delegationId:guid}/reject", async (Guid delegationId, string reason, IMediator mediator, HttpContext context, CancellationToken ct) => + { + var result = await mediator.Send(new RejectDelegationCommand(delegationId, reason), ct); + return result.ToNoContent(context); + }) + .WithName("RejectDelegation") + .WithSummary("Reject a delegation pending approval (PendingApproval → Rejected)") + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict); + group.MapPost("/{delegationId:guid}/expire", async (Guid delegationId, IMediator mediator, HttpContext context, CancellationToken ct) => { var result = await mediator.Send(new ExpireDelegationCommand(delegationId), ct); diff --git a/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/UserManagementDelegation/Queries/DelegationQueryEndpoints.cs b/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/UserManagementDelegation/Queries/DelegationQueryEndpoints.cs index 82b5dcf2..67dde2e9 100644 --- a/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/UserManagementDelegation/Queries/DelegationQueryEndpoints.cs +++ b/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/UserManagementDelegation/Queries/DelegationQueryEndpoints.cs @@ -1,5 +1,7 @@ namespace Ums.Presentation.Endpoints.Identity.UserManagementDelegation.Queries; +#pragma warning disable S125 + using Ums.Application.Identity.UserManagementDelegation.DTOs; using Ums.Application.Identity.UserManagementDelegation.Queries; @@ -7,8 +9,11 @@ public static class DelegationQueryEndpoints { public static IEndpointRouteBuilder MapDelegationQueryEndpoints(this IEndpointRouteBuilder app) { + // G-041 (SEGURIDAD): las delegaciones exponen relaciones de gestión (PII); + // solo usuarios autenticados. var group = app.MapGroup("/delegations") - .WithTags("Delegations - Queries"); + .WithTags("Delegations - Queries") + .RequireAuthorization(); group.MapGet("/", async (IMediator mediator, HttpContext context, CancellationToken ct) => { diff --git a/src/apps/ums.api/Ums.Presentation/Endpoints/Iga/RoleMaturityStatus/Queries/RoleMaturityStatusQueryEndpoints.cs b/src/apps/ums.api/Ums.Presentation/Endpoints/Iga/RoleMaturityStatus/Queries/RoleMaturityStatusQueryEndpoints.cs new file mode 100644 index 00000000..a28443a3 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation/Endpoints/Iga/RoleMaturityStatus/Queries/RoleMaturityStatusQueryEndpoints.cs @@ -0,0 +1,37 @@ +namespace Ums.Presentation.Endpoints.Iga.RoleMaturityStatus.Queries; + +using Ums.Application.IGA.DTOs; +using Ums.Application.IGA.RoleMaturity.Queries; + +/// +/// Endpoints de lectura del estado de madurez de rol (IGA, ADR-UMS-093, FR-062, G-052). +/// El grupo exige autenticación y la acotación por inquilino se aplica en la capa de aplicación +/// (ITenantScopePolicy); no se admite lectura anónima ni cruzada (G-041). +/// +public static class RoleMaturityStatusQueryEndpoints +{ + public static IEndpointRouteBuilder MapRoleMaturityStatusQueryEndpoints(this IEndpointRouteBuilder app) + { + // SEGURIDAD (ADR-UMS-093, G-052/G-041): madurez de rol es gobernanza; solo autenticados y por inquilino. + var group = app.MapGroup("/role-maturity-status") + .WithTags("IGA - RoleMaturityStatus - Queries") + .RequireAuthorization(); + + group.MapGet("/users/{userId:guid}", async ( + Guid userId, + [FromQuery] Guid tenantId, + [FromQuery] Guid? roleId, + IMediator mediator, + HttpContext context, + CancellationToken ct) => + { + var result = await mediator.Send(new GetRoleMaturityStatusByUserQuery(tenantId, userId, roleId), ct); + return result.ToOk(context); + }) + .WithName("GetRoleMaturityStatusByUser") + .WithSummary("Obtiene el/los estado(s) de madurez de un usuario, acotado por inquilino; si se indica roleId, el de ese rol (FR-062).") + .Produces>(StatusCodes.Status200OK); + + return app; + } +} diff --git a/src/apps/ums.api/Ums.Presentation/Endpoints/Iga/RolePromotionRequest/Queries/RolePromotionRequestQueryEndpoints.cs b/src/apps/ums.api/Ums.Presentation/Endpoints/Iga/RolePromotionRequest/Queries/RolePromotionRequestQueryEndpoints.cs new file mode 100644 index 00000000..7a4d3e90 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation/Endpoints/Iga/RolePromotionRequest/Queries/RolePromotionRequestQueryEndpoints.cs @@ -0,0 +1,47 @@ +namespace Ums.Presentation.Endpoints.Iga.RolePromotionRequest.Queries; + +using Ums.Application.IGA.DTOs; +using Ums.Application.IGA.RolePromotion.Queries; + +/// +/// Endpoints de lectura de las solicitudes de promoción de rol (IGA, ADR-UMS-093, G-052). +/// El grupo exige autenticación y la acotación por inquilino se aplica en la capa de aplicación +/// (ITenantScopePolicy): un usuario regular queda ceñido a su inquilino, evitando la lectura +/// anónima y cruzada señalada en G-041. +/// +public static class RolePromotionRequestQueryEndpoints +{ + public static IEndpointRouteBuilder MapRolePromotionRequestQueryEndpoints(this IEndpointRouteBuilder app) + { + // SEGURIDAD (ADR-UMS-093, G-052/G-041): datos de gobernanza; solo autenticados y por inquilino. + var group = app.MapGroup("/role-promotion-requests") + .WithTags("IGA - RolePromotionRequests - Queries") + .RequireAuthorization(); + + group.MapGet("/", async ( + [FromQuery] Guid? tenantId, + [FromQuery] string? status, + IMediator mediator, + HttpContext context, + CancellationToken ct) => + { + var result = await mediator.Send(new ListRolePromotionRequestsQuery(tenantId, status), ct); + return result.ToOk(context); + }) + .WithName("ListRolePromotionRequests") + .WithSummary("Lista solicitudes de promoción de rol acotadas por inquilino y, opcionalmente, por estado.") + .Produces>(StatusCodes.Status200OK); + + group.MapGet("/{id:guid}", async (Guid id, IMediator mediator, HttpContext context, CancellationToken ct) => + { + var result = await mediator.Send(new GetRolePromotionRequestByIdQuery(id), ct); + return result.ToOk(context); + }) + .WithName("GetRolePromotionRequestById") + .WithSummary("Obtiene una solicitud de promoción de rol por su identificador, acotada por inquilino.") + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status404NotFound); + + return app; + } +} diff --git a/src/apps/ums.api/Ums.Presentation/Endpoints/Iga/RolePromotionRequest/RolePromotionRequestEndpoints.cs b/src/apps/ums.api/Ums.Presentation/Endpoints/Iga/RolePromotionRequest/RolePromotionRequestEndpoints.cs new file mode 100644 index 00000000..9be154cb --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation/Endpoints/Iga/RolePromotionRequest/RolePromotionRequestEndpoints.cs @@ -0,0 +1,148 @@ +namespace Ums.Presentation.Endpoints.Iga.RolePromotionRequest; + +#pragma warning disable S125 + +using Ums.Application.IGA.DTOs; +using Ums.Application.IGA.RolePromotion.Commands; + +/// +/// Endpoints de escritura de la máquina de estados de promoción de rol (IGA, ADR-UMS-093, G-052). +/// Cada transición se expone como POST a una subruta de acción, coherente con el estilo del +/// repositorio (Approvals/Delegation). Todo el grupo exige autenticación (RequireAuthorization) +/// y queda acotado por inquilino en la capa de aplicación (ITenantScopePolicy); no se admite +/// escritura anónima ni cruzada (cerrando el antipatrón de G-040/G-041). +/// +public static class RolePromotionRequestEndpoints +{ + public static IEndpointRouteBuilder MapRolePromotionRequestEndpoints(this IEndpointRouteBuilder app) + { + // SEGURIDAD (ADR-UMS-093, G-052): la promoción de rol es gobernanza sensible; + // solo usuarios autenticados y acotados por inquilino. + var group = app.MapGroup("/role-promotion-requests") + .WithTags("IGA - RolePromotionRequests") + .RequireAuthorization(); + + group.MapPost("/", async (CreateRolePromotionRequestCommand command, IMediator mediator, HttpContext context, CancellationToken ct) => + { + var result = await mediator.Send(command, ct); + return result.ToCreated(r => $"/role-promotion-requests/{r.RolePromotionRequestId}", context); + }) + .WithName("CreateRolePromotionRequest") + .WithSummary("Crea una solicitud de promoción de rol en estado Draft (FR-060).") + .Produces(StatusCodes.Status201Created) + .ProducesProblem(StatusCodes.Status400BadRequest); + + group.MapPost("/{id:guid}/submit", async (Guid id, IMediator mediator, HttpContext context, CancellationToken ct) => + { + var result = await mediator.Send(new SubmitRolePromotionCommand(id), ct); + return result.ToNoContent(context); + }) + .WithName("SubmitRolePromotionRequest") + .WithSummary("Draft → PendingEligibilityCheck: congela el RiskScore (FR-061).") + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict); + + group.MapPost("/{id:guid}/confirm-eligibility", async (Guid id, IMediator mediator, HttpContext context, CancellationToken ct) => + { + var result = await mediator.Send(new ConfirmRolePromotionEligibilityCommand(id), ct); + return result.ToNoContent(context); + }) + .WithName("ConfirmRolePromotionEligibility") + .WithSummary("PendingEligibilityCheck → PendingManagerApproval o Rejected, fail-closed (FR-062).") + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict); + + group.MapPost("/{id:guid}/manager-approve", async (Guid id, IMediator mediator, HttpContext context, CancellationToken ct) => + { + var result = await mediator.Send(new ManagerApproveRolePromotionCommand(id), ct); + return result.ToNoContent(context); + }) + .WithName("ManagerApproveRolePromotion") + .WithSummary("PendingManagerApproval → PendingSecurityReview o Approved según el RiskScore (FR-060).") + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict); + + group.MapPost("/{id:guid}/manager-reject", async (Guid id, RolePromotionDecisionReasonBody body, IMediator mediator, HttpContext context, CancellationToken ct) => + { + var result = await mediator.Send(new ManagerRejectRolePromotionCommand(id, body.Reason), ct); + return result.ToNoContent(context); + }) + .WithName("ManagerRejectRolePromotion") + .WithSummary("PendingManagerApproval → Rejected con motivo (FR-060).") + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict); + + group.MapPost("/{id:guid}/security-approve", async (Guid id, IMediator mediator, HttpContext context, CancellationToken ct) => + { + var result = await mediator.Send(new SecurityApproveRolePromotionCommand(id), ct); + return result.ToNoContent(context); + }) + .WithName("SecurityApproveRolePromotion") + .WithSummary("PendingSecurityReview → Approved (FR-060).") + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict); + + group.MapPost("/{id:guid}/security-reject", async (Guid id, RolePromotionDecisionReasonBody body, IMediator mediator, HttpContext context, CancellationToken ct) => + { + var result = await mediator.Send(new SecurityRejectRolePromotionCommand(id, body.Reason), ct); + return result.ToNoContent(context); + }) + .WithName("SecurityRejectRolePromotion") + .WithSummary("PendingSecurityReview → Rejected con motivo (FR-060).") + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict); + + group.MapPost("/{id:guid}/execute", async (Guid id, IMediator mediator, HttpContext context, CancellationToken ct) => + { + var result = await mediator.Send(new ExecuteRolePromotionCommand(id), ct); + return result.ToNoContent(context); + }) + .WithName("ExecuteRolePromotion") + .WithSummary("Approved → Executed: aplica el cambio de rol (INV-RPR5).") + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict); + + group.MapPost("/{id:guid}/verify", async (Guid id, IMediator mediator, HttpContext context, CancellationToken ct) => + { + var result = await mediator.Send(new VerifyRolePromotionCommand(id), ct); + return result.ToNoContent(context); + }) + .WithName("VerifyRolePromotion") + .WithSummary("Executed → Verified: verificación post-ejecución por un auditor (INV-RPR5).") + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict); + + group.MapPost("/{id:guid}/cancel", async (Guid id, RolePromotionDecisionReasonBody body, IMediator mediator, HttpContext context, CancellationToken ct) => + { + var result = await mediator.Send(new CancelRolePromotionCommand(id, body.Reason), ct); + return result.ToNoContent(context); + }) + .WithName("CancelRolePromotion") + .WithSummary("Draft → Cancelled: el solicitante cancela antes de enviar (FR-060).") + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict); + + return app; + } +} + +/// Cuerpo de las transiciones de rechazo/cancelación: el motivo de la decisión. +public sealed record RolePromotionDecisionReasonBody(string Reason); diff --git a/src/apps/ums.api/Ums.Presentation/Endpoints/PactProviderStateEndpoints.cs b/src/apps/ums.api/Ums.Presentation/Endpoints/PactProviderStateEndpoints.cs index 9bb98bdd..8746a0ae 100644 --- a/src/apps/ums.api/Ums.Presentation/Endpoints/PactProviderStateEndpoints.cs +++ b/src/apps/ums.api/Ums.Presentation/Endpoints/PactProviderStateEndpoints.cs @@ -10,7 +10,17 @@ using Ums.Domain.Authorization.SystemSuite; using Ums.Domain.Authorization.Template; using Ums.Domain.Authorization.Profile; +using Ums.Domain.Approvals; +using Ums.Domain.Audit.AuditRecord; +using Ums.Domain.Configuration; +using Ums.Domain.IGA; using Ums.Infrastructure.Persistence; +using BeyondNetCode.Shell.Ddd; +using ApprovalRequestAggregate = Ums.Domain.Approvals.ApprovalRequest.ApprovalRequest; +using AuditRecordAggregate = Ums.Domain.Audit.AuditRecord.AuditRecord; +using FeatureFlagAggregate = Ums.Domain.Configuration.FeatureFlag.FeatureFlag; +using AppConfigurationAggregate = Ums.Domain.Configuration.AppConfiguration.AppConfiguration; +using RolePromotionRequestAggregate = Ums.Domain.IGA.RolePromotionRequest.RolePromotionRequest; namespace Ums.Presentation.Endpoints; @@ -27,7 +37,12 @@ public static IEndpointRouteBuilder MapPactProviderStateEndpoints(this IEndpoint IUserAccountRepository userAccounts, IPermissionTemplateRepository permissionTemplates, IProfileRepository profiles, - ISystemSuiteRepository systemSuites) => + ISystemSuiteRepository systemSuites, + IApprovalRequestRepository approvalRequests, + IAuditRecordRepository auditRecords, + IFeatureFlagRepository featureFlags, + IAppConfigurationRepository appConfigurations, + IRolePromotionRequestRepository rolePromotionRequests) => { await (request.State switch { @@ -52,6 +67,30 @@ var s when s.StartsWith("a profile with id ") => EnsureProfileExistsAsync(s var s when s.StartsWith("a system suite with id ") => EnsureSystemSuiteExistsAsync(s, systemSuites, tenants), "at least one system suite exists" => EnsureDefaultSystemSuiteAsync(systemSuites, tenants), + // G-082: Approvals — ApprovalRequest + var s when s.StartsWith("an approval request with id ") => EnsureApprovalRequestExistsAsync(s, approvalRequests), + var s when s.StartsWith("no approval request with id ") => Task.CompletedTask, + "at least one approval request exists" => SeedApprovalRequestAsync(Guid.Empty, approvalRequests), + + // G-082: Audit — AuditRecord + var s when s.StartsWith("an audit record with id ") => EnsureAuditRecordExistsAsync(s, auditRecords), + var s when s.StartsWith("no audit record with id ") => Task.CompletedTask, + "at least one audit record exists" => SeedAuditRecordAsync(Guid.Empty, auditRecords), + + // G-082: Configuration — FeatureFlag + var s when s.StartsWith("a feature flag with id ") => EnsureFeatureFlagExistsAsync(s, featureFlags), + var s when s.StartsWith("no feature flag with id ") => Task.CompletedTask, + "at least one feature flag exists" => SeedFeatureFlagAsync(Guid.Empty, featureFlags), + + // G-082: Configuration — AppConfiguration + var s when s.StartsWith("no app configuration with id ") => Task.CompletedTask, + "at least one app configuration exists" => SeedAppConfigurationAsync(appConfigurations), + + // G-082: IGA — RolePromotionRequest + var s when s.StartsWith("a role promotion request with id ") => EnsureRolePromotionRequestExistsAsync(s, rolePromotionRequests), + var s when s.StartsWith("no role promotion request with id ") => Task.CompletedTask, + "at least one role promotion request exists" => SeedRolePromotionRequestAsync(Guid.Empty, rolePromotionRequests), + _ => Task.CompletedTask, }); @@ -65,6 +104,11 @@ var s when s.StartsWith("a system suite with id ") => EnsureSystemSuiteExistsAsy private static readonly Guid DefaultUserGuid = Guid.Parse("3fa85f64-5717-4562-b3fc-2c963f66afa6"); private static readonly ActorId SeedActor = ActorId.Create("00000000-0000-0000-0000-000000000111"); + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Security Hotspot", "S3011:Reflection should not be used to increase accessibility of classes, methods, or fields", + Justification = "ADR-UMS-099: reflexión sancionada en fixture de estado de proveedor Pact. Fija IDs bien " + + "conocidos sobre props no públicos para reproducir estados de proveedor en pruebas de " + + "contrato; no forma parte del runtime de producción.")] private static void SetEntityId(T entity, Guid id) where T : class { var type = entity.GetType(); @@ -291,6 +335,158 @@ private static async Task SeedProfileAsync(Guid id, Guid tenantId, Guid userId, await profiles.UnitOfWork.SaveEntitiesAsync(); } + // ───────────────────────────────────────────────────────────── + // G-082: sembradores de los cuatro contextos sin contrato previo. + // Sólo interesa la FORMA HTTP; se construyen agregados válidos con + // identificadores fijos cargados desde GUID (sin verificar existencia + // de referencias, que no participan en la forma de la respuesta). + // ───────────────────────────────────────────────────────────── + private static readonly Guid SeedRefGuidA = Guid.Parse("aaaa1111-0000-0000-0000-000000000001"); + private static readonly Guid SeedRefGuidB = Guid.Parse("bbbb2222-0000-0000-0000-000000000002"); + + // ── Approvals: ApprovalRequest ───────────────────────────────── + private static async Task EnsureApprovalRequestExistsAsync(string state, IApprovalRequestRepository requests) + { + var id = ExtractGuid(state); + if (id == Guid.Empty) return; + if (await requests.GetByIdAsync(id) is not null) return; + await SeedApprovalRequestAsync(id, requests); + } + + private static async Task SeedApprovalRequestAsync(Guid id, IApprovalRequestRepository requests) + { + var result = ApprovalRequestAggregate.Create( + ApprovalWorkflowId.Load(SeedRefGuidA), + UserId.Load(SeedRefGuidB), + null, + SystemSuiteId.Load(SeedRefGuidA), + null, + RoleId.Load(SeedRefGuidB), + "Contract test approval request.", + SeedActor); + + if (result.IsFailure) return; + + var request = result.Value; + if (id != Guid.Empty) SetEntityId(request, id); + + await requests.AddAsync(request); + await requests.UnitOfWork.SaveEntitiesAsync(); + } + + // ── Audit: AuditRecord ───────────────────────────────────────── + private static async Task EnsureAuditRecordExistsAsync(string state, IAuditRecordRepository auditRecords) + { + var id = ExtractGuid(state); + if (id == Guid.Empty) return; + if (await auditRecords.GetByIdAsync(id) is not null) return; + await SeedAuditRecordAsync(id, auditRecords); + } + + private static async Task SeedAuditRecordAsync(Guid id, IAuditRecordRepository auditRecords) + { + // El listado se acota por inquilino (RootTenantId == tenantId de la consulta) y por + // ventana temporal reciente: se siembra con el inquilino por defecto y WhenOccurred = ahora. + var result = AuditRecordAggregate.Record( + DefaultUserGuid, + SubjectType.System, + "ContractTestEvent", + "ContractTestEvent", + AuditResult.Success, + DefaultUserGuid, + "ContractTest", + DefaultTenantGuid, + null); + + if (result.IsFailure) return; + + var record = result.Value; + if (id != Guid.Empty) SetEntityId(record, id); + + await auditRecords.AppendAsync(record); + await auditRecords.UnitOfWork.SaveEntitiesAsync(record); + } + + // ── Configuration: FeatureFlag ───────────────────────────────── + private static async Task EnsureFeatureFlagExistsAsync(string state, IFeatureFlagRepository featureFlags) + { + var id = ExtractGuid(state); + if (id == Guid.Empty) return; + if (await featureFlags.GetByIdAsync(id) is not null) return; + await SeedFeatureFlagAsync(id, featureFlags); + } + + private static async Task SeedFeatureFlagAsync(Guid id, IFeatureFlagRepository featureFlags) + { + var result = FeatureFlagAggregate.Create( + IdValueObject.Load(SeedRefGuidA), + null, + "CONTRACT_TEST_FLAG", + FlagType.Boolean, + "all", + null, + null, + null, + SeedActor); + + if (result.IsFailure) return; + + var flag = result.Value; + if (id != Guid.Empty) SetEntityId(flag, id); + + await featureFlags.AddAsync(flag); + await featureFlags.UnitOfWork.SaveEntitiesAsync(); + } + + // ── Configuration: AppConfiguration ──────────────────────────── + private static async Task SeedAppConfigurationAsync(IAppConfigurationRepository appConfigurations) + { + var result = AppConfigurationAggregate.Create( + null, + null, + null, + Code.Create("CONTRACT_TEST_CFG"), + ConfigurationValue.Create("contract-test-value"), + Description.Create("Contract test configuration."), + true, + false, + SeedActor, + false); + + if (result.IsFailure) return; + + await appConfigurations.AddAsync(result.Value); + await appConfigurations.UnitOfWork.SaveEntitiesAsync(); + } + + // ── IGA: RolePromotionRequest ────────────────────────────────── + private static async Task EnsureRolePromotionRequestExistsAsync(string state, IRolePromotionRequestRepository requests) + { + var id = ExtractGuid(state); + if (id == Guid.Empty) return; + if (await requests.GetByIdAsync(id) is not null) return; + await SeedRolePromotionRequestAsync(id, requests); + } + + private static async Task SeedRolePromotionRequestAsync(Guid id, IRolePromotionRequestRepository requests) + { + var result = RolePromotionRequestAggregate.Create( + TenantId.Load(DefaultTenantGuid), + UserId.Load(SeedRefGuidA), + UserId.Load(SeedRefGuidB), + RoleId.Load(SeedRefGuidA), + RoleId.Load(SeedRefGuidB), + SeedActor); + + if (result.IsFailure) return; + + var request = result.Value; + if (id != Guid.Empty) SetEntityId(request, id); + + await requests.AddAsync(request); + await requests.UnitOfWork.SaveEntitiesAsync(); + } + private static Guid ExtractGuid(string state) { foreach (var part in state.Split(' ')) diff --git a/src/apps/ums.api/Ums.Presentation/Extensions/AuthenticationExtensions.cs b/src/apps/ums.api/Ums.Presentation/Extensions/AuthenticationExtensions.cs index fb05b13c..366097e2 100644 --- a/src/apps/ums.api/Ums.Presentation/Extensions/AuthenticationExtensions.cs +++ b/src/apps/ums.api/Ums.Presentation/Extensions/AuthenticationExtensions.cs @@ -1,107 +1,155 @@ namespace Ums.Presentation.Extensions; +using System.Text; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Microsoft.IdentityModel.Tokens; /// -/// HARDENING-02: Configures JWT Bearer authentication for production. +/// Nombres de los esquemas de autenticación de UMS. Se centralizan aquí porque un esquema es +/// una cadena mágica: repetirla endpoint por endpoint es la vía más corta a que uno de ellos +/// autentique por el esquema equivocado sin que nadie lo note (G-191). +/// +public static class UmsAuthSchemes +{ + /// Cookie `ums.session`: la sesión del portal web. + public const string Cookie = CookieAuthenticationDefaults.AuthenticationScheme; + + /// Portador JWT firmado por UMS (HS256): los sistemas satélite. + public const string Portador = JwtBearerDefaults.AuthenticationScheme; + + /// + /// Esquema de política que decide, por petición, cuál de los dos anteriores aplica. + /// Es el esquema POR DEFECTO del host. + /// + public const string Automatico = "UmsAuto"; +} + +/// +/// Políticas de autorización nombradas de UMS. +/// +public static class UmsAuthPolicies +{ + /// + /// Superficie de sistemas satélite: exige portador VÁLIDO y descarta la cookie de forma + /// explícita. + /// + /// Por qué una política nombrada y no repetir el esquema en cada endpoint: fijar el esquema + /// suelto (`new AuthorizeAttribute { AuthenticationSchemes = "Bearer" }`) obliga a repetir la + /// cadena en cada `MapGet`, y basta que un endpoint nuevo la omita para que herede el esquema + /// por defecto y vuelva a autenticar por cookie — exactamente el defecto que G-191 corrige. + /// Con la política, el contrato «esto es para satélites» se declara UNA vez, se reutiliza y + /// sobrevive a cualquier cambio futuro del esquema por defecto. + /// + public const string Satelite = "UmsSatelite"; +} + +/// +/// Configura la autenticación de UMS: dos esquemas que conviven. /// -/// Configuration (appsettings.json): -/// -/// "Authentication": { -/// "Enabled": true, -/// "Authority": "https://your-idp.example.com", // OIDC discovery endpoint base URL -/// "Audience": "ums-api", // Expected JWT audience claim -/// "RequireHttpsMetadata": true, -/// "ValidIssuers": ["https://your-idp.example.com"] -/// } -/// +/// Cookie (`ums.session`) — el portal web. `POST /api/v1/auth/login` firma la cookie +/// y el navegador la reenvía sola. /// -/// Claim conventions (populate IUserContext / ITenantContext from these): -/// sub → user ID (standard OIDC) -/// name → display name -/// tenant_id → UMS organization / tenant ID (custom claim) -/// email → user email (optional, only if IdP provides it) +/// Portador (JWT HS256 emitido por UMS) — los sistemas satélite. `POST +/// /api/v1/client/authenticate` devuelve el token y el satélite lo presenta en `Authorization`. /// -/// In development, set "Authentication:Enabled": false to fall back to the -/// DevAuthMiddleware which reads X-User-Id / X-User-Name headers. -/// NEVER enable DevAuthMiddleware in production. +/// Cuál se aplica: el esquema por defecto es , +/// un esquema de política que reenvía a portador cuando la petición trae `Authorization: Bearer` +/// y a cookie en cualquier otro caso. Antes (G-191) el defecto era la cookie fija: la cabecera +/// `Authorization` se ignoraba y el reto de la cookie contestaba con un 302 al `LoginPath`, de +/// modo que NINGÚN satélite podía consumir un endpoint autenticado — ni en desarrollo, donde el +/// manejador de portador ni siquiera se registraba. +/// +/// La firma: UMS emite sus propios tokens en HS256 con `Jwt:Secret` +/// (JwtTokenService), así que el manejador de portador valida ESO, en todos los entornos. +/// El manejador anterior apuntaba a una `Authority` OIDC externa: la API no estaba preparada para +/// validar lo que ella misma firma. La federación con un IdP externo ocurre en el LOGIN (motor de +/// autenticación, `AuthenticateUserCommand`), no en el servidor de recursos: quien entra por un +/// IdP también sale con un token de UMS. +/// +/// Claims que transporta el portador (ver JwtTokenService): +/// sub / email / name, tenant_id o tenant_code, role, scope*, feature*, is_internal_admin. /// public static class AuthenticationExtensions { + /// + /// HS256 exige una clave de al menos 256 bits. Se valida al arrancar para no descubrirlo en + /// la primera petición con un 500 opaco. + /// + private const int LongitudMinimaDelSecreto = 32; + public static IServiceCollection AddUmsAuthentication( this IServiceCollection services, - IConfiguration configuration) + IConfiguration configuration, + IHostEnvironment environment) { - var enabled = configuration.GetValue("Authentication:Enabled", false); - var authority = configuration["Authentication:Authority"]; - var audience = configuration["Authentication:Audience"] ?? "ums-api"; - var requireHttps = configuration.GetValue("Authentication:RequireHttpsMetadata", true); - var validIssuers = configuration.GetSection("Authentication:ValidIssuers") - .Get() ?? []; - - if (!enabled || string.IsNullOrWhiteSpace(authority)) - { - // Dev/test mode: JWT Bearer is disabled. Cookie auth is the default scheme so that - // UseAuthentication() reads ums.session cookies set by the login endpoint. - // DevAuthMiddleware then only activates for truly unauthenticated requests (no cookie). - services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) - .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options => - { - options.Cookie.Name = "ums.session"; - options.Cookie.HttpOnly = true; - options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; - options.ExpireTimeSpan = TimeSpan.FromHours(1); - options.SlidingExpiration = true; - options.LoginPath = "/api/v1/auth/login"; - options.AccessDeniedPath = "/api/v1/auth/denied"; - }); - services.AddAuthorization(); - return services; - } + ConfigurarProteccionDeDatos(services, configuration); + + ValidarSecretoDeFirma(configuration["Jwt:Secret"], environment); services - .AddAuthentication(cfg => + .AddAuthentication(UmsAuthSchemes.Automatico) + .AddPolicyScheme(UmsAuthSchemes.Automatico, "Portador si viaja Authorization; cookie si no", options => { - cfg.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; - cfg.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + // El selector rige TODAS las operaciones del esquema (autenticar, retar, prohibir), + // así que el reto de una petición con portador inválido lo emite el manejador de + // portador → 401 con `WWW-Authenticate`, nunca una redirección de cookie. + options.ForwardDefaultSelector = contexto => + { + string? cabecera = contexto.Request.Headers.Authorization; + return cabecera is not null + && cabecera.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) + ? UmsAuthSchemes.Portador + : UmsAuthSchemes.Cookie; + }; }) - .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options => + .AddCookie(UmsAuthSchemes.Cookie, options => { - options.Cookie.Name = "ums.session"; - options.Cookie.HttpOnly = true; + options.Cookie.Name = "ums.session"; + options.Cookie.HttpOnly = true; options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; - options.ExpireTimeSpan = TimeSpan.FromHours(1); - options.SlidingExpiration = true; - }) - .AddJwtBearer(options => - { - options.Authority = authority; - options.Audience = audience; - options.RequireHttpsMetadata = requireHttps; + options.ExpireTimeSpan = TimeSpan.FromHours(1); + options.SlidingExpiration = true; + options.LoginPath = "/api/v1/auth/login"; + options.AccessDeniedPath = "/api/v1/auth/denied"; - options.TokenValidationParameters = new TokenValidationParameters + // Un 401 tiene que ser un 401. El manejador de cookie, pensado para páginas, + // responde al reto con 302 hacia el formulario de acceso; un cliente de API no + // sigue un `Location` y lo interpreta como éxito o como fallo de red. Bajo + // `/api/**` la redirección se sustituye por el código que corresponde. + options.Events = new CookieAuthenticationEvents { - ValidateIssuer = true, - ValidateAudience = true, - ValidateLifetime = true, - ValidIssuers = validIssuers.Length > 0 ? validIssuers : [authority], - ValidAudience = audience, - ClockSkew = TimeSpan.FromSeconds(30), + OnRedirectToLogin = ctx => ResponderSinRedirigir(ctx, StatusCodes.Status401Unauthorized), + OnRedirectToAccessDenied = ctx => ResponderSinRedirigir(ctx, StatusCodes.Status403Forbidden), }; + }) + .AddJwtBearer(UmsAuthSchemes.Portador, options => + { + // Sin `Authority`: la clave la conoce el propio proceso. No hay descubrimiento OIDC + // que resolver ni red que dependa de estar disponible para validar un token. + + // Se conserva el mapeo entrante de claims (sub → NameIdentifier, email → Email, + // name → Name) para que el principal del portador y el de la cookie se lean con + // los MISMOS tipos de claim; de eso dependen `UserContext`, la revocación y el + // particionado del limitador de tasa. + options.MapInboundClaims = true; options.Events = new JwtBearerEvents { OnAuthenticationFailed = ctx => { - // Structured log — token validation failures are security events. + // Un token que no valida es un evento de seguridad: se registra el tipo de + // fallo, nunca el token. var logger = ctx.HttpContext.RequestServices .GetRequiredService() .CreateLogger("UMS.Authentication"); logger.LogWarning( - "JWT authentication failed. Path={Path} Error={Error}", + "Falló la validación del portador. Path={Path} Error={Error}", ctx.Request.Path, ctx.Exception.GetType().Name); @@ -109,8 +157,7 @@ public static IServiceCollection AddUmsAuthentication( }, OnTokenValidated = ctx => { - // Populate ITenantContext from the JWT claims. - // This runs after signature validation, so the claims are trusted. + // Corre DESPUÉS de verificar la firma: los claims ya son de fiar. var tenantContext = ctx.HttpContext.RequestServices .GetService(); @@ -123,8 +170,19 @@ public static IServiceCollection AddUmsAuthentication( if (Guid.TryParse(tenantIdClaim, out var tenantId)) { - var isInternalAdmin = isInternalAdminClaim?.ToLower() == "true"; - tenantContext.Initialize(tenantId, isInternalAdmin); + var isInternalAdmin = string.Equals( + isInternalAdminClaim, "true", StringComparison.OrdinalIgnoreCase); + + // El contexto puede venir ya inicializado por otro middleware; que lo + // esté no es un error. + try + { + tenantContext.Initialize(tenantId, isInternalAdmin); + } + catch (InvalidOperationException) + { + // Ya inicializado: se respeta el valor vigente. + } } return Task.CompletedTask; @@ -132,10 +190,146 @@ public static IServiceCollection AddUmsAuthentication( }; }); - services.AddAuthorization(); + // Los parámetros de validación se resuelven TARDE, contra la configuración final del host y + // no contra la que hubiera en el momento del registro. No es un rodeo: los hosts de prueba + // (y cualquier fuente que se añada después, como Key Vault) inyectan su configuración + // mientras se construye la aplicación, es decir DESPUÉS de este método. Capturar aquí el + // secreto producía el peor fallo posible —la API firmaba con una clave y validaba con otra, + // rechazando sus propios tokens con `SecurityTokenSignatureKeyNotFound`—, que es justo el + // mismo pitfall de temporización que ya se documentó con la cadena de conexión (G-014). + // `JwtTokenService`, que firma, también lee la configuración tarde: así ambos lados + // coinciden por construcción. + services.AddOptions(UmsAuthSchemes.Portador) + .Configure((options, config, material) => + { + var secreto = ValidarSecretoDeFirma(config["Jwt:Secret"], environment); + var emisor = config["Jwt:Issuer"] ?? "ums-api"; + var audiencia = config["Jwt:Audience"] ?? "ums-web-app"; + + // Emisores/audiencias adicionales aceptados (p. ej. un alias de despliegue). El + // emisor y la audiencia propios SIEMPRE se aceptan: son los que UMS estampa al firmar. + var emisoresValidos = new[] { emisor } + .Concat(config.GetSection("Authentication:ValidIssuers").Get() ?? []) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + var audienciasValidas = new[] { audiencia } + .Concat(config.GetSection("Authentication:ValidAudiences").Get() ?? []) + .Append(config["Authentication:Audience"]) + .Where(valor => !string.IsNullOrWhiteSpace(valor)) + .Select(valor => valor!) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + options.RequireHttpsMetadata = config.GetValue("Authentication:RequireHttpsMetadata", true); + + // Verificadores primero, emisión después (ADR-0157 §4.9, etapa E1). UMS ya + // firma RS256, pero los tokens HS256 emitidos ANTES de este despliegue siguen + // vivos hasta caducar: rechazarlos echaría de su sesión a quien la tuviera + // abierta. La ventana se cierra en E3 borrando el secreto y esta rama —no + // detrás de una bandera: una bandera que reactiva HS256 es la vulnerabilidad + // con un interruptor—. + + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKeys = + [ + material.ClavePublica, + new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secreto)), + ], + // La lista blanca es lo que impide la confusión de `alg` de RFC 8725 §2.1: + // sin ella, un token que declarase HS256 podría intentar verificarse contra + // la clave RSA **pública** —que es material publicado— y pasar. + ValidAlgorithms = [SecurityAlgorithms.RsaSha256, SecurityAlgorithms.HmacSha256], + ValidateIssuer = true, + ValidIssuers = emisoresValidos, + ValidateAudience = true, + ValidAudiences = audienciasValidas, + ValidateLifetime = true, + ClockSkew = TimeSpan.FromSeconds(30), + }; + }); + + services.AddAuthorization(options => + { + options.AddPolicy(UmsAuthPolicies.Satelite, policy => policy + .AddAuthenticationSchemes(UmsAuthSchemes.Portador) + .RequireAuthenticatedUser()); + }); + return services; } + /// + /// Sin secreto —o con uno más corto que 256 bits— no hay forma de validar el portador, y + /// arrancar «a medias» reproduce G-191 en silencio: la API ignoraría tokens que debería + /// comprobar. Se verifica al registrar (aviso temprano) y al resolver las opciones (valor real). + /// + /// + /// Marcadores de posición del repositorio. Miden 64 caracteres, así que la comprobación de + /// longitud los daba por buenos: el arranque los aceptaba y UMS firmaba con una cadena + /// PÚBLICA, versionada y conocida — quien la lea emite tokens válidos de cualquier usuario y + /// cualquier inquilino (G-203). + /// + /// No se corrige cambiando el valor en el repositorio: poner un secreto real bajo control de + /// versiones es el mismo defecto con otra cara. Se corrige rechazando el marcador allí donde + /// firmar con él tiene consecuencias. + /// + private static readonly string[] MarcasDeMarcadorDePosicion = + [ + "YOUR_", "CHANGE_IN_PRODUCTION", "CHANGE_IN_LOCAL", "CHANGEME", "PLACEHOLDER", "EXAMPLE_", + ]; + + private static bool PareceMarcadorDePosicion(string secreto) => + MarcasDeMarcadorDePosicion.Any(m => secreto.Contains(m, StringComparison.OrdinalIgnoreCase)); + + private static string ValidarSecretoDeFirma(string? secreto, IHostEnvironment entorno) + { + // En Development el marcador se tolera: es el valor que trae el repositorio para que un + // desarrollador arranque sin ceremonia, y su alcance es su propia máquina. Fuera de ahí no + // se tolera, y el arranque FALLA: una API de identidad que arranca firmando con una clave + // pública es peor que una que no arranca. + if (!entorno.IsDevelopment() && !string.IsNullOrWhiteSpace(secreto) && PareceMarcadorDePosicion(secreto)) + { + throw new InvalidOperationException( + $"Jwt:Secret es un marcador de posición del repositorio y el entorno es " + + $"'{entorno.EnvironmentName}'. UMS firmaría sus tokens con una cadena pública y " + + "conocida: cualquiera que lea el repositorio podría emitir tokens válidos de " + + "cualquier usuario y cualquier inquilino. Inyecta el secreto real por " + + "`Jwt__Secret` desde un Secret de Kubernetes, o resuélvelo por " + + "`Secrets:Source=KeyVault` (G-203, ADR-0157)."); + } + + if (string.IsNullOrWhiteSpace(secreto) || secreto.Length < LongitudMinimaDelSecreto) + { + throw new InvalidOperationException( + "Jwt:Secret no está configurado o es más corto que 32 caracteres. UMS firma y " + + "valida sus propios tokens en HS256: sin ese secreto la API no puede autenticar " + + "a los sistemas satélite."); + } + + return secreto; + } + + /// + /// Sustituye la redirección del manejador de cookie por un código de estado cuando la petición + /// va a la API. Fuera de `/api/**` (p. ej. Swagger) se conserva el comportamiento de navegador. + /// + private static Task ResponderSinRedirigir( + Microsoft.AspNetCore.Authentication.RedirectContext contexto, + int codigo) + { + if (contexto.Request.Path.StartsWithSegments("/api")) + { + contexto.Response.StatusCode = codigo; + return Task.CompletedTask; + } + + contexto.Response.Redirect(contexto.RedirectUri); + return Task.CompletedTask; + } + /// /// Configures the Swagger UI to accept Bearer tokens. /// Only adds the security definition; does not enforce it on endpoints @@ -168,4 +362,33 @@ public static void AddSwaggerBearerAuth(this Swashbuckle.AspNetCore.SwaggerGen.S }, }); } + + /// + /// Fija la identidad del anillo de claves de Data Protection y, con Redis disponible, lo + /// persiste para que TODAS las réplicas compartan las mismas claves. + /// + /// Sin esto, cada pod genera su propio anillo efímero: la cookie `ums.session` emitida por + /// un pod no la descifra ninguno de los demás, así que un despliegue con más de una réplica + /// produce 401 aparentemente aleatorios y cada reinicio invalida todas las sesiones vivas + /// (G-169). `SetApplicationName` va SIEMPRE —también sin Redis— porque el nombre por defecto + /// deriva de la ruta del contenido: dos pods con rutas distintas ya no se entienden aunque + /// compartieran el almacén. + /// + private static void ConfigurarProteccionDeDatos(IServiceCollection services, IConfiguration configuration) + { + var proteccion = services.AddDataProtection().SetApplicationName("ums"); + + var redis = Ums.Infrastructure.Configuration.CadenaDeRedis.Normalizar( + configuration["Redis:Connection"] ?? configuration["REDIS_CONNECTION"]); + + if (string.IsNullOrWhiteSpace(redis)) + { + // Anillo en memoria: válido para desarrollo, pruebas y despliegue de UNA réplica. + return; + } + + proteccion.PersistKeysToStackExchangeRedis( + StackExchange.Redis.ConnectionMultiplexer.Connect(redis), + "ums:dataprotection-keys"); + } } diff --git a/src/apps/ums.api/Ums.Presentation/Extensions/BlockedOperationResponse.cs b/src/apps/ums.api/Ums.Presentation/Extensions/BlockedOperationResponse.cs index c7d7433f..52e0141d 100644 --- a/src/apps/ums.api/Ums.Presentation/Extensions/BlockedOperationResponse.cs +++ b/src/apps/ums.api/Ums.Presentation/Extensions/BlockedOperationResponse.cs @@ -27,6 +27,13 @@ internal static class BlockedOperationMessages "No se puede suspender el tenant porque tiene sucursales activas.", "A tenant cannot be suspended while active branches exist."), + // ADR-0164 §2.2: guarda de cascada del CIERRE DEFINITIVO de una sucursal. El desglose que + // acompaña a este código dice cuántas cuentas y cuántos perfiles bloquean; el mensaje solo + // tiene que decir qué hacer con ellos. + [DomainErrors.Tenant.BranchHasLiveReferences] = ( + "No se puede cerrar la sucursal porque todavía tiene usuarios o perfiles activos asignados. Reasígnelos o desactívelos primero.", + "A branch cannot be closed while active user accounts or profiles still reference it."), + [DomainErrors.Tenant.HasActiveIdpConfig] = ( "No se puede suspender el tenant porque tiene proveedores de identidad activos.", "A tenant cannot be suspended while active identity providers exist."), @@ -44,8 +51,14 @@ internal static class BlockedOperationMessages "A role cannot be deactivated while active child roles depend on it."), [DomainErrors.Authorization.TemplateHasActiveProfiles] = ( - "No se puede deprecar el template porque tiene perfiles activos asociados.", - "A permission template cannot be deprecated while active profiles are linked to it."), + "No se puede deprecar ni eliminar el template porque tiene perfiles activos asociados.", + "A permission template cannot be deprecated or deleted while active profiles are linked to it."), + + // Guardia de cascada del borrado lógico de parámetros de inquilino: un parámetro activo es un + // vínculo vivo de la configuración; hay que desactivarlo antes de poder eliminarlo. + [DomainErrors.TenantParameter.HasActiveBinding] = ( + "No se puede eliminar el parámetro porque sigue activo en la configuración del inquilino; desactívelo primero.", + "A tenant parameter cannot be deleted while it is still active in the tenant configuration."), [DomainErrors.Authorization.DomainResourceHasTemplateItems] = ( "No se puede eliminar el recurso de dominio porque está referenciado en uno o más templates.", @@ -54,6 +67,14 @@ internal static class BlockedOperationMessages [DomainErrors.Authorization.ModuleHasActiveMenus] = ( "No se puede eliminar el módulo porque tiene menús activos configurados.", "A module cannot be removed while active menus are configured within it."), + + [DomainErrors.Authorization.SystemSuiteHasDependents] = ( + "No se puede eliminar el sistema porque todavía hay elementos vivos que dependen de él. Elimínelos primero.", + "A system suite cannot be deleted while live roles, permission templates, feature flags, configurations, approval workflows or tenant defaults still reference it."), + + [DomainErrors.Configuration.ParameterHasActiveValues] = ( + "No se puede eliminar la definición de parámetro porque tiene valores globales o de inquilino asociados.", + "A parameter definition cannot be deleted while global or tenant values reference it."), }; public static string GetMessage(string errorCode) diff --git a/src/apps/ums.api/Ums.Presentation/Extensions/DomainErrorStatusMapper.cs b/src/apps/ums.api/Ums.Presentation/Extensions/DomainErrorStatusMapper.cs index f1ac9bdb..29ddeddf 100644 --- a/src/apps/ums.api/Ums.Presentation/Extensions/DomainErrorStatusMapper.cs +++ b/src/apps/ums.api/Ums.Presentation/Extensions/DomainErrorStatusMapper.cs @@ -17,17 +17,99 @@ public static (int Status, string Title) Map(string error) return (StatusCodes.Status400BadRequest, "Validation Error"); } - if (ContainsAny(error, DomainErrors.Common.NotFound, DomainErrors.Tenant.NotFound, DomainErrors.Tenant.BranchNotFound, DomainErrors.Tenant.IdpNotFound, DomainErrors.Tenant.BrandingNotFound, DomainErrors.SystemSuite.ConfigurationKeyNotFound, DomainErrors.Authorization.PermissionNotFound)) + // ADR-UMS-095: cuenta con bloqueo temporal por intentos fallidos → 423 Locked. + // AUTH_017 (AUTH_012 ya está tomado con el significado «no IDP adapter registered»). + if (error.StartsWith("AUTH_017", StringComparison.OrdinalIgnoreCase)) + { + return (StatusCodes.Status423Locked, "Locked"); + } + + // Detección de «no encontrado» → 404 por CÓDIGO de dominio estable e idioma-agnóstico + // (DomainErrors.*), no por el texto del mensaje. G-100: el brazo de substring en inglés de + // abajo no clasificaba los errores que el dominio devuelve en español (p. ej. IGA: + // «No se encontró la solicitud de promoción de rol.»), colapsándolos al 400 por defecto. + // Los handlers deben devolver el código; el mapeo depende de él, no del idioma del mensaje. + // + // G-104: se añaden los códigos de fin en not_found que direccionan un recurso por id o clave y + // que hoy caían al 400 por defecto porque ni estaban en esta lista ni contenían la frase inglesa + // «not found». Son CriteriaNotFound —al quitar un criterio de feature flag inexistente—, + // MfaEnrollmentNotFound —al revocar una inscripción de MFA inexistente— y TenantParameter.NotFound + // —parámetro de inquilino direccionado por su código—. Los dos endpoints reales ya declaraban 404 + // en su contrato OpenAPI; el mapeo por código lo hace efectivo en lugar de contradecirlo con 400. + // + // G-104, EXCLUIDOS DELIBERADAMENTE, NO son 404 y no deben re-cablearse: IdpFallbackNotFound y + // ParentResourceNotFound NO direccionan el recurso de la URI, sino que validan una referencia + // colgante en el cuerpo de un alta —un identificador de fallback o de recurso padre que apunta a + // algo inexistente—. El recurso objetivo del alta sí existe o se está creando; lo que falla es la + // integridad referencial del payload, que es validación 400 —semánticamente un 422—, nunca 404. + // Sus endpoints declaran 400 y no 404 en el contrato, así que se dejan en el 400 por defecto. + if (ContainsAny(error, DomainErrors.Common.NotFound, DomainErrors.Tenant.NotFound, DomainErrors.Tenant.BranchNotFound, DomainErrors.Tenant.IdpNotFound, DomainErrors.SystemSuite.ConfigurationKeyNotFound, DomainErrors.Authorization.PermissionNotFound, DomainErrors.IGA.RolePromotionRequestNotFound, DomainErrors.Configuration.CriteriaNotFound, DomainErrors.UserAccount.MfaEnrollmentNotFound, DomainErrors.TenantParameter.NotFound)) { return (StatusCodes.Status404NotFound, "Not Found"); } + // Fallback heredado para handlers que aún incrustan la frase en inglés «not found» en el mensaje + // (Tenant/UserAccount/Configuration…). Es dependiente del idioma y sólo se mantiene por + // compatibilidad; la vía correcta es el código de dominio del bloque anterior (G-100). if (error.Contains("not found", StringComparison.OrdinalIgnoreCase)) { return (StatusCodes.Status404NotFound, "Not Found"); } - if (ContainsAny(error, DomainErrors.Common.Duplicate, DomainErrors.Tenant.SignupRequestAlreadyExists, DomainErrors.Tenant.BranchCodeNotUnique, DomainErrors.Tenant.IdpCodeNotUnique, DomainErrors.UserAccount.EmailNotUnique, DomainErrors.SystemSuite.OptionCodeNotUnique, DomainErrors.SystemSuite.SubMenuCodeNotUnique, DomainErrors.SystemSuite.MenuCodeNotUnique, DomainErrors.SystemSuite.ModuleCodeNotUnique, DomainErrors.SystemSuite.ConfigurationKeyAlreadyExists, DomainErrors.Authorization.TemplateItemTargetAlreadyExists, DomainErrors.Authorization.PermissionAlreadyExists, DomainErrors.Compliance.DocumentAlreadyExpired)) + // G-246: eliminar lógicamente un sistema que sigue vigente, que aún tiene referencias vivas o + // que ya está eliminado es un conflicto con el estado actual del recurso → 409. Lo mismo para + // los dos intentos de esquivar la guarda por `PUT /status` (entrar a «eliminado» o salir de + // él). SystemSuiteHasDependents sale normalmente por la vía del BlockedOperationResponse (409 + // con el desglose de qué bloquea); se lista aquí para que el camino sin desglose no lo + // degrade a 400. + if (ContainsAny( + error, + DomainErrors.Authorization.SystemSuiteNotDeprecated, + DomainErrors.Authorization.SystemSuiteHasDependents, + DomainErrors.Authorization.SystemSuiteAlreadyDeleted, + DomainErrors.Authorization.SystemSuiteDeletedNotSettable, + DomainErrors.Authorization.SystemSuiteDeletedIsTerminal)) + { + return (StatusCodes.Status409Conflict, "Conflict"); + } + + // ── Borrado lógico: guardias de cascada y estados terminales → 409 ──── + // Solo existe borrado lógico. Dos familias de conflicto salen de ahí y ambas son 409, no 400: + // · Guardia de cascada: se intenta eliminar algo con referencias VIVAS (perfiles activos que + // usan la plantilla, vínculo activo del parámetro de inquilino). Es el análogo de un + // ON DELETE RESTRICT y sale además enriquecido con BlockedOperationResponse cuando el + // handler adjunta las dependencias; este brazo cubre el caso sin dependencias adjuntas. + // · Estado terminal ya alcanzado: reintentar el borrado de algo ya eliminado es un conflicto + // con el estado actual del recurso, igual que Tenant.AlreadySuspended. + // + // ADR-0164 aplicado a SUCURSALES: cerrar una con referencias vivas (BranchHasLiveReferences), + // reintentar el cierre de una ya cerrada (BranchAlreadyClosed) e intentar desactivar o + // reactivar una cerrada (BranchClosed) son las tres conflictos con el estado actual del + // recurso → 409. `BranchClosed` importa especialmente: sin él, «no se puede reactivar lo + // cerrado» caería al 400 por defecto y se confundiría con un error de validación del cliente. + // `TemplateNotDeletable` y `ParameterAlreadyDeleted` faltaban y caían al 400 por omisión, en + // contra del `ProducesProblem(409)` que declaran sus propios endpoints. La primera dice que el + // estado de la plantilla no admite el borrado —conflicto con el estado actual, no un error de + // validación del cliente— y la segunda es el reintento sobre algo ya eliminado, exactamente el + // caso que la línea de al lado ya cubría para plantillas y parámetros de inquilino. + if (ContainsAny(error, DomainErrors.Authorization.TemplateHasActiveProfiles, DomainErrors.Authorization.TemplateAlreadyDeleted, DomainErrors.Authorization.TemplateNotDeletable, DomainErrors.Configuration.ParameterAlreadyDeleted, DomainErrors.TenantParameter.HasActiveBinding, DomainErrors.TenantParameter.AlreadyDeleted, DomainErrors.Tenant.BranchHasLiveReferences, DomainErrors.Tenant.BranchAlreadyClosed, DomainErrors.Tenant.BranchClosed)) + { + return (StatusCodes.Status409Conflict, "Conflict"); + } + + // Conflictos de estado idempotentes del ciclo de vida del tenant: reintentar una transición + // hacia el estado ya vigente (suspender lo ya suspendido, activar lo ya activo) es un conflicto + // con el estado actual del recurso → 409, coherente con el resto de duplicados/"already". Antes + // caían al default 400 (G-014 residual: SuspendTenant_AlreadySuspended esperaba 409). + // ManagementOwnerAlreadyExists (G-037/G-045): segundo owner de gestión → 409. + // G-121: los conflictos de TRANSICIÓN de estado del ciclo de vida de documentos y solicitudes + // (aprobar una request no-Pending, validar/expirar un documento en estado incompatible) son + // conflictos con el estado actual del recurso → 409, coherente con DocumentAlreadyExpired y con + // el ProducesProblem(409) que declaran sus endpoints (antes caían mezclados a 400). + // ADR-0164: TemplateItemTargetRetired es el mismo tipo de conflicto —la clave natural del ítem + // sigue ocupada por una concesión retirada— y por eso comparte 409; se mantiene como código + // propio para que el cliente pueda proponer reactivar en vez de repetir el alta. + if (ContainsAny(error, DomainErrors.Common.Duplicate, DomainErrors.Tenant.SignupRequestAlreadyExists, DomainErrors.Tenant.BranchCodeNotUnique, DomainErrors.Tenant.IdpCodeNotUnique, DomainErrors.Tenant.AlreadyActive, DomainErrors.Tenant.AlreadySuspended, DomainErrors.Tenant.ManagementOwnerAlreadyExists, DomainErrors.UserAccount.EmailNotUnique, DomainErrors.SystemSuite.OptionCodeNotUnique, DomainErrors.SystemSuite.SubMenuCodeNotUnique, DomainErrors.SystemSuite.MenuCodeNotUnique, DomainErrors.SystemSuite.ModuleCodeNotUnique, DomainErrors.SystemSuite.ConfigurationKeyAlreadyExists, DomainErrors.Configuration.ParameterCodeNotUnique, DomainErrors.Configuration.ParameterHasActiveValues, DomainErrors.Authorization.TemplateItemTargetAlreadyExists, DomainErrors.Authorization.TemplateItemTargetRetired, DomainErrors.Authorization.PermissionAlreadyExists, DomainErrors.Compliance.DocumentAlreadyExpired, DomainErrors.Approvals.DocumentTypeAlreadyRequired, DomainErrors.Compliance.DocumentNotPendingReview, DomainErrors.Compliance.DocumentCannotTransition, DomainErrors.Approvals.RequestNotPending)) { return (StatusCodes.Status409Conflict, "Conflict"); } @@ -42,7 +124,7 @@ public static (int Status, string Title) Map(string error) return (StatusCodes.Status400BadRequest, "Validation Error"); } - if (ContainsAny(error, DomainErrors.Common.Invalid, DomainErrors.UserAccount.InvalidEmail, DomainErrors.Tenant.SignupRequestNotPending, DomainErrors.Tenant.SignupRequestAlreadyProcessed, DomainErrors.Branding.InvalidHexColor, DomainErrors.Branding.InvalidCustomDomain, DomainErrors.Branding.InvalidCnameTarget, DomainErrors.Branding.InvalidLogoFormat, DomainErrors.Configuration.IdpConfigPayloadInvalid, DomainErrors.Configuration.FlagPercentageOutOfRange, DomainErrors.Configuration.AppConfigNotDraft, DomainErrors.Configuration.AppConfigNotPublished, DomainErrors.Configuration.FlagArchivedCannotChange, DomainErrors.Configuration.AppConfigAlreadyArchived, DomainErrors.Compliance.ExpirationBeforeIssueDate, DomainErrors.Compliance.DocumentCannotTransition, DomainErrors.Compliance.DocumentNotPendingReview, DomainErrors.ValueObject.DateRangeInvalid)) + if (ContainsAny(error, DomainErrors.Common.Invalid, DomainErrors.UserAccount.InvalidEmail, DomainErrors.Tenant.SignupRequestNotPending, DomainErrors.Tenant.SignupRequestAlreadyProcessed, DomainErrors.Configuration.IdpConfigPayloadInvalid, DomainErrors.Configuration.FlagPercentageOutOfRange, DomainErrors.Configuration.AppConfigNotDraft, DomainErrors.Configuration.AppConfigNotPublished, DomainErrors.Configuration.FlagArchivedCannotChange, DomainErrors.Configuration.AppConfigAlreadyArchived, DomainErrors.Compliance.ExpirationBeforeIssueDate, DomainErrors.Approvals.PolicyInactiveCannotUpdate, DomainErrors.ValueObject.DateRangeInvalid)) { return (StatusCodes.Status400BadRequest, "Validation Error"); } diff --git a/src/apps/ums.api/Ums.Presentation/Extensions/LoggingExtensions.cs b/src/apps/ums.api/Ums.Presentation/Extensions/LoggingExtensions.cs index 68bd61fb..35f49be5 100644 --- a/src/apps/ums.api/Ums.Presentation/Extensions/LoggingExtensions.cs +++ b/src/apps/ums.api/Ums.Presentation/Extensions/LoggingExtensions.cs @@ -4,6 +4,7 @@ namespace Ums.Presentation.Extensions; using Serilog.Events; using Serilog.Formatting.Compact; using Serilog.Sinks.Grafana.Loki; +using Ums.Presentation.Observability; /// /// REC-14 / OBS-01: Configures Serilog as the application's structured-logging provider. @@ -50,13 +51,16 @@ public static LoggerConfiguration ConfigureUmsSerilog( var consoleFormat = loggingSection["ConsoleFormat"] ?? (env.IsDevelopment() ? "Text" : "CompactJson"); var minimumLevel = loggingSection["MinimumLevel"] ?? (env.IsDevelopment() ? "Debug" : "Information"); var outputTemplate = loggingSection["OutputTemplate"] - ?? "[{Timestamp:HH:mm:ss} {Level:u3}] {CorrelationId} {SessionTrackingId} {ErrorId} {SourceContext} {Message:lj}{NewLine}{Exception}"; + ?? "[{Timestamp:HH:mm:ss} {Level:u3}] trace={TraceId} span={SpanId} {SessionTrackingId} {ErrorId} {SourceContext} {Message:lj}{NewLine}{Exception}"; loggerConfig .ReadFrom.Configuration(context.Configuration) // honour appsettings Serilog section .Enrich.FromLogContext() // picks up BeginScope() key-values .Enrich.WithMachineName() .Enrich.WithThreadId() + // ADR-0046: TraceId/SpanId W3C (Activity.Current) en toda línea → correlación + // uniforme de logs (Loki) con trazas (Tempo), también en sinks no-OTel. + .Enrich.With() // HARDENING-04: Mask PII fields before any sink sees them. .Enrich.With() .Destructure.With() diff --git a/src/apps/ums.api/Ums.Presentation/Extensions/ObservabilityExtensions.cs b/src/apps/ums.api/Ums.Presentation/Extensions/ObservabilityExtensions.cs index 0cd5d465..18d3fca2 100644 --- a/src/apps/ums.api/Ums.Presentation/Extensions/ObservabilityExtensions.cs +++ b/src/apps/ums.api/Ums.Presentation/Extensions/ObservabilityExtensions.cs @@ -1,5 +1,7 @@ namespace Ums.Presentation.Extensions; +#pragma warning disable S125 + using System.Diagnostics; using OpenTelemetry.Metrics; using OpenTelemetry.Resources; @@ -35,11 +37,20 @@ public static IServiceCollection AddUmsObservability( ?? configuration["OpenTelemetry:ServiceVersion"] ?? "1.0.0"; + // ADR-0096 §2.1: el entorno se declara con la semconv OTel `deployment.environment.name`. + var environmentName = configuration["Observability:Environment"] + ?? Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") + ?? "unknown"; + var resourceBuilder = ResourceBuilder.CreateDefault() .AddService( serviceName: ServiceName, serviceVersion: version, autoGenerateServiceInstanceId: true) + .AddAttributes(new[] + { + new KeyValuePair("deployment.environment.name", environmentName), + }) .AddTelemetrySdk() .AddEnvironmentVariableDetector(); @@ -61,12 +72,6 @@ public static IServiceCollection AddUmsObservability( { activity.SetTag(ObservabilityKeys.SessionTrackingId, sessionTrackingId.ToString()); } - - if (request.Headers.TryGetValue(ObservabilityHeaders.CorrelationId, out var correlationId) - && !string.IsNullOrWhiteSpace(correlationId)) - { - activity.SetTag(ObservabilityKeys.CorrelationId, correlationId.ToString()); - } }; }) .AddHttpClientInstrumentation(opts => @@ -82,7 +87,11 @@ public static IServiceCollection AddUmsObservability( }; }) // EF Core SQL queries traced via activity source - .AddSource("Microsoft.EntityFrameworkCore"); + .AddSource("Microsoft.EntityFrameworkCore") + // ADR-UMS-098: los aspectos AOP de los shells emiten spans con un + // ActivitySource nativo de la BCL (sin SDK de OTel en la librería); + // registrarlo aquí incorpora esos spans al TracerProvider. + .AddSource("BeyondNetCode.Shell.Aop"); if (!string.IsNullOrWhiteSpace(endpoint)) { diff --git a/src/apps/ums.api/Ums.Presentation/Extensions/PiiMaskingPolicy.cs b/src/apps/ums.api/Ums.Presentation/Extensions/PiiMaskingPolicy.cs index 8afd143b..1f8b60de 100644 --- a/src/apps/ums.api/Ums.Presentation/Extensions/PiiMaskingPolicy.cs +++ b/src/apps/ums.api/Ums.Presentation/Extensions/PiiMaskingPolicy.cs @@ -1,3 +1,4 @@ +#pragma warning disable S1144 namespace Ums.Presentation.Extensions; using Serilog.Core; @@ -98,3 +99,5 @@ private static string MaskEmail(string email) return $"{local}***@***.{tld}"; } } + +#pragma warning restore S1144 diff --git a/src/apps/ums.api/Ums.Presentation/Extensions/ResultExtensions.cs b/src/apps/ums.api/Ums.Presentation/Extensions/ResultExtensions.cs index b082fdd2..fb242595 100644 --- a/src/apps/ums.api/Ums.Presentation/Extensions/ResultExtensions.cs +++ b/src/apps/ums.api/Ums.Presentation/Extensions/ResultExtensions.cs @@ -1,3 +1,4 @@ +#pragma warning disable S1144 namespace Ums.Presentation.Extensions; using Microsoft.AspNetCore.Http; @@ -53,6 +54,8 @@ private static IResult ToProblem(string error, HttpContext? context = null) Extensions = { ["timestamp"] = DateTimeOffset.UtcNow, + ["errorCode"] = error, + ["brokenRule"] = error }, }; @@ -82,8 +85,14 @@ private static IResult ToProblem(string error, HttpContext? context = null) private static string GetUserMessage(string error, int status) { const string validationPrefix = "Validation.Failed:"; - // Updated: Validation failures now map to 422 Unprocessable Entity. - if (status == StatusCodes.Status422UnprocessableEntity && error.StartsWith(validationPrefix, StringComparison.OrdinalIgnoreCase)) + // G-061: la convención de validación devuelve 400 (DomainErrorStatusMapper mapea + // "Validation.Failed:" → 400). El mensaje accionable de campo (localizado por + // FluentValidation, p. ej. "…Código tiene un formato inválido… por ejemplo REPORTS_01…") + // debe surfacearse en `detail` con independencia del status; antes se condicionaba a 422 + // y, tras el cambio a 400, se perdía y caía al genérico "error.request.invalid". + // El prefijo "Validation.Failed:" solo lo emite el pipeline de validación, así que el + // mensaje es siempre un texto seguro para el usuario (sin detalles técnicos). + if (error.StartsWith(validationPrefix, StringComparison.OrdinalIgnoreCase)) { var message = error[validationPrefix.Length..].Trim(); return string.IsNullOrWhiteSpace(message) @@ -122,3 +131,5 @@ private static string GetUserMessage(string error, int status) _ => StringLocalizer.T("error.request.invalid"), }; } + +#pragma warning restore S1144 diff --git a/src/apps/ums.api/Ums.Presentation/GlobalUsings.cs b/src/apps/ums.api/Ums.Presentation/GlobalUsings.cs index 703867c9..45231688 100644 --- a/src/apps/ums.api/Ums.Presentation/GlobalUsings.cs +++ b/src/apps/ums.api/Ums.Presentation/GlobalUsings.cs @@ -10,4 +10,4 @@ global using Microsoft.AspNetCore.Routing; global using Ums.Application.Common.Interfaces; global using Ums.Presentation.Extensions; -global using BeyondNetCode.Shell.Aop.Aspects.Logger.Serilog; +global using Ums.Infrastructure.Observability; diff --git a/src/apps/ums.api/Ums.Presentation/Middleware/CorrelationIdMiddleware.cs b/src/apps/ums.api/Ums.Presentation/Middleware/CorrelationIdMiddleware.cs deleted file mode 100644 index 5deea260..00000000 --- a/src/apps/ums.api/Ums.Presentation/Middleware/CorrelationIdMiddleware.cs +++ /dev/null @@ -1,78 +0,0 @@ -namespace Ums.Presentation.Middleware; - -using System.Diagnostics; -using Microsoft.AspNetCore.Http; - -/// -/// REC-17: Injects a correlation-id into every request so distributed traces -/// can be correlated by a client-provided or auto-generated opaque ID. -/// -/// Propagation chain: -/// 1. Inbound X-Correlation-Id header → stored on -/// 2. TraceIdentifier → Activity baggage ("correlation.id") -/// 3. TraceIdentifier → ILogger scope (key "CorrelationId") -/// 4. Outbound X-Correlation-Id header ← echoed back to caller -/// -/// OTEL baggage travels with outbound HttpClient calls (W3C baggage propagator), -/// so the correlation-id flows to every downstream service automatically. -/// The ILogger scope is picked up by any structured-log sink that reads scopes -/// (Serilog, OpenTelemetry.Logs, Application Insights). -/// -public sealed class CorrelationIdMiddleware -{ - private readonly RequestDelegate _next; - private readonly ILogger _logger; - - public CorrelationIdMiddleware( - RequestDelegate next, - ILogger logger) - { - _next = next; - _logger = logger; - } - - public async Task InvokeAsync(HttpContext context) - { - var correlationId = GetOrAddCorrelationId(context); - context.Response.Headers[ObservabilityHeaders.CorrelationId] = correlationId; - - // Propagate into current OTEL Activity baggage so it travels - // with all downstream HttpClient calls via W3C baggage header. - var activity = Activity.Current; - if (activity is not null) - { - activity.SetBaggage(ObservabilityKeys.CorrelationId, correlationId); - // Also tag the root span so the ID appears in the trace UI. - activity.SetTag(ObservabilityKeys.CorrelationId, correlationId); - } - - // Enrich every log line emitted during this request. - using (_logger.BeginScope(new Dictionary - { - ["CorrelationId"] = correlationId, - })) - { - await _next(context); - } - } - - private static string GetOrAddCorrelationId(HttpContext context) - { - if (context.Request.Headers.TryGetValue(ObservabilityHeaders.CorrelationId, out var existingId) - && !string.IsNullOrWhiteSpace(existingId)) - { - context.TraceIdentifier = existingId!; - return existingId!; - } - - var newId = Guid.NewGuid().ToString("N"); - context.TraceIdentifier = newId; - return newId; - } -} - -public static class CorrelationIdMiddlewareExtensions -{ - public static IApplicationBuilder UseCorrelationId(this IApplicationBuilder app) - => app.UseMiddleware(); -} diff --git a/src/apps/ums.api/Ums.Presentation/Middleware/CultureMiddleware.cs b/src/apps/ums.api/Ums.Presentation/Middleware/CultureMiddleware.cs index 8ca01cc8..1243f4a4 100644 --- a/src/apps/ums.api/Ums.Presentation/Middleware/CultureMiddleware.cs +++ b/src/apps/ums.api/Ums.Presentation/Middleware/CultureMiddleware.cs @@ -1,6 +1,6 @@ using System.Globalization; using Ums.Globalization.Access; -using Ums.Infrastructure.Services; +using Ums.Infrastructure.Observability; namespace Ums.Presentation.Middleware; @@ -11,12 +11,12 @@ public class CultureMiddleware(RequestDelegate next) private const string AcceptLanguageHeader = "Accept-Language"; private const string TimezoneHeader = "X-Timezone"; - public async Task InvokeAsync(HttpContext context, RequestContextAccessor requestContextAccessor) + public async Task InvokeAsync(HttpContext context, RequestContext requestContext) { var culture = ResolveCulture(context); var timezone = context.Request.Headers[TimezoneHeader].FirstOrDefault(); - requestContextAccessor.SetClientTimezone(string.IsNullOrWhiteSpace(timezone) ? null : timezone); + requestContext.SetClientTimezone(string.IsNullOrWhiteSpace(timezone) ? null : timezone); using (CultureContext.Set(culture)) { diff --git a/src/apps/ums.api/Ums.Presentation/Middleware/DevAuthMiddleware.cs b/src/apps/ums.api/Ums.Presentation/Middleware/DevAuthMiddleware.cs index 4383fc4a..4a9d4b9f 100644 --- a/src/apps/ums.api/Ums.Presentation/Middleware/DevAuthMiddleware.cs +++ b/src/apps/ums.api/Ums.Presentation/Middleware/DevAuthMiddleware.cs @@ -9,13 +9,19 @@ public sealed class DevAuthMiddleware { private const string DefaultUserId = "dev-user"; private const string DefaultUserName = "Developer"; - private const string DefaultTenantId = "11111111-1111-1111-1111-111111111111"; - private const string InternalAdminTenantId = "11111111-1111-1111-1111-111111111111"; + // ADR-0071 / FS-26: el Admin Root (propietario de gestión) es BEYONDNET. La conveniencia + // de desarrollo (peticiones no autenticadas) actúa por defecto como BEYONDNET, y el + // privilegio transversal is_internal_admin se otorga a su tenant. La derivación + // autoritativa (login real) vive en AuthEndpoints: is_internal_admin = Tenant.IsManagementOwner. + private const string DefaultTenantId = "5f4e3d2c-1b0a-9f8e-7d6c-543210987654"; // BEYONDNET (Tenant Raíz) + private const string ManagementOwnerTenantId = "5f4e3d2c-1b0a-9f8e-7d6c-543210987654"; // BEYONDNET private const string UserIdHeader = "X-User-Id"; private const string UserNameHeader = "X-User-Name"; private const string TenantIdHeader = "X-Tenant-Id"; private const string IsInternalAdminHeader = "X-Is-Internal-Admin"; private const string DisableDevAuthHeader = "X-Disable-Dev-Auth"; + private const string AuthorizationHeader = "Authorization"; + private const string SessionCookieName = "ums.session"; private readonly RequestDelegate _next; private readonly IHostEnvironment _environment; @@ -35,9 +41,14 @@ public async Task InvokeAsync(HttpContext context, ITenantContext tenantContext) } // Public authentication endpoints must execute with the real anonymous context. - // Default dev claims would force the internal admin tenant and break tenant-scoped - // login, signup, forgot-password, and session bootstrap flows. - if (context.Request.Path.StartsWithSegments("/api/v1/auth")) + // Default dev claims would force the internal admin tenant (BEYONDNET) and break tenant-scoped + // login, signup, forgot-password, and session bootstrap flows. Esto cubre tanto el flujo + // interno (/api/v1/auth) como el de autenticación de sistemas cliente (/api/v1/client): sin + // exentar /client, DevAuthMiddleware fijaba el contexto a BEYONDNET y el usuario CLIENT + // (tenant-scoped) no se resolvía → AUTH_006/401 (G-042). El tenant debe resolverse por el + // TenantCode del cuerpo, no por el default de dev. + if (context.Request.Path.StartsWithSegments("/api/v1/auth") + || context.Request.Path.StartsWithSegments("/api/v1/client")) { await _next(context); return; @@ -47,6 +58,35 @@ public async Task InvokeAsync(HttpContext context, ITenantContext tenantContext) context.Request.Headers[DisableDevAuthHeader].FirstOrDefault(), "true", StringComparison.OrdinalIgnoreCase)) + { + // G-042 (endurecimiento): X-Disable-Dev-Auth desactiva la inyección de claims de + // desarrollo para poder ejercer la autenticación REAL (bearer/cookie). NO debe + // convertirse en un pase anónimo: antes, la cabecera saltaba dev-auth y la petición + // llegaba SIN identidad al endpoint (probado: X-Disable-Dev-Auth:true → 200). Ahora, + // si no viaja una credencial real (cabecera Authorization o cookie ums.session), se + // rechaza fail-closed (401) en lugar de continuar como anónimo. Los flujos legítimos + // (login previo + cookie, o bearer) conservan el bypass; el resto queda vedado. + var hasRealCredential = + context.Request.Headers.ContainsKey(AuthorizationHeader) + || context.Request.Cookies.ContainsKey(SessionCookieName); + + if (!hasRealCredential) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + context.Response.Headers.WWWAuthenticate = "Bearer"; + return; + } + + await _next(context); + return; + } + + // G-191: si la petición trae un portador, decide el manejador REAL de portador, no las + // claims de conveniencia. Este middleware corre ANTES de UseAuthentication, así que + // inyectar identidad aquí enmascaraba el rechazo de un token inválido o caducado: la + // autenticación fallaba, el principal de desarrollo sobrevivía y el endpoint respondía + // 200. Con la exención, un portador inválido es 401 también en desarrollo. + if (context.Request.Headers.ContainsKey(AuthorizationHeader)) { await _next(context); return; @@ -64,7 +104,7 @@ public async Task InvokeAsync(HttpContext context, ITenantContext tenantContext) tenantId ??= DefaultTenantId; var isInternalAdmin = isInternalAdminHeader?.ToLower() == "true" - || tenantId == InternalAdminTenantId; + || tenantId == ManagementOwnerTenantId; var claims = new List { diff --git a/src/apps/ums.api/Ums.Presentation/Middleware/FunctionalTransactionMiddleware.cs b/src/apps/ums.api/Ums.Presentation/Middleware/FunctionalTransactionMiddleware.cs new file mode 100644 index 00000000..99ed5804 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation/Middleware/FunctionalTransactionMiddleware.cs @@ -0,0 +1,111 @@ +namespace Ums.Presentation.Middleware; + +using Microsoft.AspNetCore.Http; +using Ums.Application.Common.Interfaces; +using Ums.Infrastructure.Observability; + +/// +/// Abre y cierra siempre la transacción funcional de cada petición (ADR-0096 §2.3; +/// decisión UMS en ADR-UMS-085). Garantiza el desenlace por construcción: emite la +/// apertura al entrar y el desenlace en un finally, con el estado derivado del +/// resultado —incluida la ruta de fallo—. Así el invariante «transacción sin desenlace = +/// defecto alertable» no depende de la disciplina de cada handler. +/// +/// Se ubica por fuera de en el pipeline: cuando +/// un handler lanza, el manejador global convierte la excepción en respuesta (y adjunta el +/// localizador legible); este middleware, ya de vuelta, lee el código de estado final y +/// emite el desenlace correspondiente. +/// +/// El localizador legible TX-AAAA-NNNNNN se acuña por adelantado en operaciones que +/// mutan estado (para que toda mutación porte su referencia) y de forma perezosa ante +/// cualquier fallo; las lecturas exitosas se identifican por su traceId W3C. +/// +public sealed class FunctionalTransactionMiddleware +{ + private readonly RequestDelegate _next; + + public FunctionalTransactionMiddleware(RequestDelegate next) + => _next = next; + + public async Task InvokeAsync(HttpContext context, FunctionalTransaction transaction) + { + // Las sondas de salud no son transacciones funcionales. + if (context.Request.Path.StartsWithSegments("/health")) + { + await _next(context); + return; + } + + var name = $"{context.Request.Method} {context.Request.Path}"; + transaction.Open(name, ResolveActor(context)); + + if (IsMutating(context.Request.Method)) + { + // No debe abortar la petición si la acuñación falla: se degrada a traceId. + try + { + await transaction.GetOrMintLocatorAsync(CancellationToken.None); + } + catch + { + // El desenlace volverá a intentarlo si el resultado es un fallo. + } + } + + try + { + await _next(context); + } + finally + { + // El actor definitivo se conoce tras la autenticación (middleware interno). + transaction.SetActor(ResolveActor(context)); + await transaction.CompleteAsync( + DeriveState(context), + context.Response.StatusCode, + CancellationToken.None); + } + } + + private static TransactionState DeriveState(HttpContext context) + { + if (context.RequestAborted.IsCancellationRequested) + { + return TransactionState.Cancelled; + } + + var statusCode = context.Response.StatusCode; + return statusCode switch + { + StatusCodes.Status408RequestTimeout => TransactionState.TimedOut, + >= 400 => TransactionState.Failed, + _ => TransactionState.Completed, + }; + } + + private static bool IsMutating(string method) + => HttpMethods.IsPost(method) + || HttpMethods.IsPut(method) + || HttpMethods.IsPatch(method) + || HttpMethods.IsDelete(method); + + private static string ResolveActor(HttpContext context) + { + var user = context.User; + if (user?.Identity is { IsAuthenticated: true }) + { + return user.Identity.Name + ?? user.FindFirst("sub")?.Value + ?? user.FindFirst("preferred_username")?.Value + ?? "(autenticado)"; + } + + return "(anónimo)"; + } +} + +public static class FunctionalTransactionMiddlewareExtensions +{ + public static IApplicationBuilder UseFunctionalTransaction(this IApplicationBuilder app) + => app.UseMiddleware(); +} diff --git a/src/apps/ums.api/Ums.Presentation/Middleware/GlobalExceptionHandler.cs b/src/apps/ums.api/Ums.Presentation/Middleware/GlobalExceptionHandler.cs index 0a8d100f..0bab4ebc 100644 --- a/src/apps/ums.api/Ums.Presentation/Middleware/GlobalExceptionHandler.cs +++ b/src/apps/ums.api/Ums.Presentation/Middleware/GlobalExceptionHandler.cs @@ -46,7 +46,13 @@ private async Task HandleExceptionAsync(HttpContext context, Exception exception { var errorId = UserFacingErrorContext.GetOrCreateErrorId(context); var statusCode = GetStatusCode(exception); - var problemDetails = CreateProblemDetails(context, exception, errorId, statusCode); + + // ADR-0096 §2.2 / ADR-UMS-084: acuña el localizador legible TX-AAAA-NNNNNN y muéstralo + // al usuario en el fallo. Es lo que pega en el ticket de soporte. Nunca debe convertir + // el manejo del error en un fallo secundario. + var transactionLocator = await TryMintLocatorAsync(context); + + var problemDetails = CreateProblemDetails(context, exception, errorId, transactionLocator, statusCode); // Full diagnostic context goes to the log — never to the response body. LogException(exception, context, errorId); @@ -58,13 +64,39 @@ await context.Response.WriteAsync( JsonSerializer.Serialize(problemDetails, UmsProblemDetailsJsonOptions.Instance)); } + /// + /// Acuña el localizador legible de la transacción funcional (ADR-UMS-084) sin propagar + /// fallos: si la acuñación fallara, el error original sigue devolviéndose con su errorId. + /// + private static async Task TryMintLocatorAsync(HttpContext context) + { + try + { + var transaction = context.RequestServices + .GetService(typeof(Application.Common.Interfaces.IFunctionalTransaction)) + as Application.Common.Interfaces.IFunctionalTransaction; + + if (transaction is null) + { + return null; + } + + return await transaction.GetOrMintLocatorAsync(context.RequestAborted); + } + catch + { + return null; + } + } + private static ProblemDetails CreateProblemDetails( HttpContext context, Exception exception, string errorId, + string? transactionLocator, int statusCode) { - return new ProblemDetails + var problemDetails = new ProblemDetails { Title = GetErrorTitle(exception), Detail = GetErrorDetail(exception), // localized, user-friendly — no internal info @@ -79,6 +111,16 @@ private static ProblemDetails CreateProblemDetails( ["timestamp"] = DateTimeOffset.UtcNow, }, }; + + // Localizador legible TX-AAAA-NNNNNN: la referencia comunicable por voz/chat que el + // usuario final entrega a soporte (ADR-0096 §2.2). Convive con el errorId técnico. + if (!string.IsNullOrWhiteSpace(transactionLocator)) + { + problemDetails.Extensions["transactionId"] = transactionLocator; + problemDetails.Extensions["supportReference"] = transactionLocator; + } + + return problemDetails; } private static string GetErrorTitle(Exception exception) => exception switch @@ -86,6 +128,10 @@ private static ProblemDetails CreateProblemDetails( ConcurrencyConflictException => "Conflict", UnauthorizedAccessException => "Unauthorized", System.Collections.Generic.KeyNotFoundException => "Not Found", + // Parámetros de query/ruta ausentes o mal tipados: ASP.NET responde 400. + // El título debe reflejar el 400 y NO "Internal Server Error" (p.ej. GET + // /system-suites sin `page`/`pageSize`). + Microsoft.AspNetCore.Http.BadHttpRequestException => "Bad Request", InvalidOperationException => "Invalid Operation", ArgumentException => "Bad Request", _ => "Internal Server Error", @@ -96,6 +142,7 @@ private static ProblemDetails CreateProblemDetails( ConcurrencyConflictException => StringLocalizer.T("error.operation.conflict"), UnauthorizedAccessException => StringLocalizer.T("error.authentication.required"), System.Collections.Generic.KeyNotFoundException => StringLocalizer.T("error.resource.not_found"), + Microsoft.AspNetCore.Http.BadHttpRequestException => StringLocalizer.T("error.request.invalid"), ArgumentException => StringLocalizer.T("error.request.invalid"), _ => StringLocalizer.T("error.unexpected"), }; @@ -105,6 +152,10 @@ private static ProblemDetails CreateProblemDetails( ConcurrencyConflictException => StatusCodes.Status409Conflict, UnauthorizedAccessException => StatusCodes.Status401Unauthorized, System.Collections.Generic.KeyNotFoundException => StatusCodes.Status404NotFound, + // Parámetros de query/ruta ausentes o mal tipados: ASP.NET lanza esto con + // su propio StatusCode (400). Sin este caso caía a 500 (p.ej. GET + // /system-suites sin `page`). + Microsoft.AspNetCore.Http.BadHttpRequestException badRequest => badRequest.StatusCode, InvalidOperationException => StatusCodes.Status400BadRequest, ArgumentException => StatusCodes.Status400BadRequest, _ => StatusCodes.Status500InternalServerError, diff --git a/src/apps/ums.api/Ums.Presentation/Middleware/IdempotencyMiddleware.cs b/src/apps/ums.api/Ums.Presentation/Middleware/IdempotencyMiddleware.cs index 36bef818..037dcbb1 100644 --- a/src/apps/ums.api/Ums.Presentation/Middleware/IdempotencyMiddleware.cs +++ b/src/apps/ums.api/Ums.Presentation/Middleware/IdempotencyMiddleware.cs @@ -2,7 +2,7 @@ namespace Ums.Presentation.Middleware; using System.Text.Json; using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Logging; /// @@ -20,16 +20,18 @@ namespace Ums.Presentation.Middleware; /// - Completed key → return cached response immediately (no handler invoked). /// - In-flight key → return 409 "request already in progress" (parallel duplicate). /// -/// Cache backend: (single-node). For multi-replica -/// deployments swap to -/// to share state across pods. +/// Cache backend: . Con Redis configurado, la deduplicación +/// es efectiva ENTRE pods; sin él, el registro cae en la implementación en memoria y solo +/// protege dentro del proceso. Antes usaba IMemoryCache siempre, así que un reintento +/// con la misma clave atendido por otro pod re-ejecutaba el comando: la garantía se anunciaba +/// pero no existía en cuanto había más de una réplica (G-169). /// /// TTL: 24 hours (configurable via ). /// Cached methods: POST, PUT, PATCH only (GET/DELETE are naturally idempotent). /// public sealed class IdempotencyMiddleware( RequestDelegate next, - IMemoryCache cache, + IDistributedCache cache, ILogger logger) { private const string IdempotencyKeyHeader = "Idempotency-Key"; @@ -53,12 +55,13 @@ public async Task InvokeAsync(HttpContext context) return; } - var idempotencyKey = keyValues.First()!.Trim(); + var idempotencyKey = keyValues[0]!.Trim(); var cacheKey = $"idem:{idempotencyKey}"; var inFlightKey = cacheKey + InFlightSuffix; // ── Case 1: completed response already cached ────────────────────────── - if (cache.TryGetValue(cacheKey, out IdempotencyEntry? cached) && cached is not null) + var cached = await cache.LeerAsync(cacheKey, context.RequestAborted); + if (cached is not null) { logger.LogDebug( "IdempotencyMiddleware: returning cached response for key={Key} (status={Status}).", @@ -76,7 +79,7 @@ public async Task InvokeAsync(HttpContext context) } // ── Case 2: same key is in-flight (parallel duplicate request) ───────── - if (cache.TryGetValue(inFlightKey, out _)) + if (await cache.GetAsync(inFlightKey, context.RequestAborted) is not null) { logger.LogWarning( "IdempotencyMiddleware: duplicate in-flight request for key={Key}.", idempotencyKey); @@ -96,7 +99,8 @@ await context.Response.WriteAsync(JsonSerializer.Serialize(new // ── Case 3: new key — mark in-flight, execute, cache result ─────────── var ttl = IdempotencyOptions.CacheTtl; - cache.Set(inFlightKey, true, ttl); + var opciones = new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = ttl }; + await cache.SetAsync(inFlightKey, [1], opciones, context.RequestAborted); // Buffer the response so we can cache it var originalBody = context.Response.Body; @@ -118,7 +122,11 @@ await context.Response.WriteAsync(JsonSerializer.Serialize(new context.Response.ContentType ?? "application/json", body); - cache.Set(cacheKey, entry, ttl); + await cache.SetAsync( + cacheKey, + JsonSerializer.SerializeToUtf8Bytes(entry), + opciones, + context.RequestAborted); logger.LogDebug( "IdempotencyMiddleware: cached response for key={Key} (status={Status}, ttl={Ttl}h).", @@ -134,8 +142,11 @@ await context.Response.WriteAsync(JsonSerializer.Serialize(new } finally { - // Always remove in-flight marker regardless of success/failure - cache.Remove(inFlightKey); + // Always remove in-flight marker regardless of success/failure. + // `CancellationToken.None`: si el cliente abortó, el token de la petición ya está + // cancelado y la marca quedaría colgada hasta el TTL, bloqueando 24 h los reintentos + // legítimos de esa misma clave. + await cache.RemoveAsync(inFlightKey, CancellationToken.None); context.Response.Body = originalBody; } } @@ -144,6 +155,27 @@ await context.Response.WriteAsync(JsonSerializer.Serialize(new /// Cached idempotency response snapshot. internal sealed record IdempotencyEntry(int StatusCode, string ContentType, byte[] Body); +/// Lee y deserializa una entrada cacheada; trata el dato corrupto como ausencia. +file static class IdempotencyCacheReader +{ + public static async Task LeerAsync( + this IDistributedCache cache, string clave, CancellationToken ct) + { + var bytes = await cache.GetAsync(clave, ct); + if (bytes is null || bytes.Length == 0) return null; + + try + { + return JsonSerializer.Deserialize(bytes); + } + catch (JsonException) + { + // Formato antiguo o entrada corrupta: se re-ejecuta la petición en vez de fallar. + return null; + } + } +} + /// Tunable constants for IdempotencyMiddleware. public static class IdempotencyOptions { diff --git a/src/apps/ums.api/Ums.Presentation/Middleware/LimiteDePeticionesMiddleware.cs b/src/apps/ums.api/Ums.Presentation/Middleware/LimiteDePeticionesMiddleware.cs new file mode 100644 index 00000000..f6b3f480 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation/Middleware/LimiteDePeticionesMiddleware.cs @@ -0,0 +1,126 @@ +namespace Ums.Presentation.Middleware; + +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +/// +/// G-248 — el cupo de peticiones, contado en un sitio que todas las réplicas comparten y con la +/// identidad de quien llama ya resuelta. +/// +/// Sustituye al PartitionedRateLimiter de ASP.NET, que fallaba por dos motivos a la +/// vez. Contaba en proceso, así que con N réplicas el cupo efectivo era N veces el +/// declarado. Y se ejecutaba antes de UseAuthentication, de modo que +/// HttpContext.User venía vacío y la clave de reparto caía siempre a la IP: dos usuarios +/// distintos compartían cupo. Medido el 2026-08-04 con el cupo en 6 — tres peticiones de una cuenta +/// y tres de otra bastaban para que ambas recibieran 429. +/// +/// Detrás de un Ingress eso era peor de lo que parece: la IP que ve el proceso es la del +/// controlador, no la del cliente, así que el tráfico anónimo entero caía en un único cubo y un solo +/// abusador dejaba fuera a los demás. Por eso la IP se lee de X-Forwarded-For cuando viene, +/// que es lo que el Ingress rellena. +/// +/// Va DESPUÉS de UseAuthentication y UseAuthorization, y antes de los +/// endpoints: es el único punto donde se sabe a quién se está limitando. +/// +public sealed class LimiteDePeticionesMiddleware(RequestDelegate next, int cupo, TimeSpan ventana) +{ + public async Task InvokeAsync(HttpContext context, ILimitadorDePeticiones limitador) + { + // Las sondas quedan fuera: kubelet las llama sin credencial y desde la IP del nodo, así que + // consumirían el cupo anónimo y podrían llegar a limitarse a sí mismas. Un limitador que + // provoca reinicios en cadena hace más daño que el abuso del que protege. + if (context.Request.Path.StartsWithSegments("/health")) + { + await next(context); + return; + } + + var clave = ResolverClave(context); + var resultado = await limitador.RegistrarAsync(clave, cupo, ventana, context.RequestAborted); + + if (resultado.Permitida) + { + await next(context); + return; + } + + var segundos = Math.Max(1, (int)Math.Ceiling(resultado.EsperaSugerida.TotalSeconds)); + context.Response.StatusCode = StatusCodes.Status429TooManyRequests; + context.Response.Headers.RetryAfter = segundos.ToString(); + context.Response.ContentType = "application/problem+json"; + + // Mismo cuerpo que emitía el limitador anterior: quien ya trate un 429 no debe notar el + // cambio de implementación. + await context.Response.WriteAsJsonAsync(new ProblemDetails + { + Title = "Too Many Requests", + Detail = "Rate limit exceeded. Please try again later.", + Status = StatusCodes.Status429TooManyRequests, + Type = "https://httpstatuses.io/429", + Extensions = { ["retryAfter"] = segundos }, + }); + } + + /// + /// A quién se le cuenta esta petición. De lo más específico a lo más general: si se sabe el + /// inquilino y el usuario, el cupo es suyo; si no, de quien se pueda identificar. + /// + private static string ResolverClave(HttpContext ctx) + { + var tenantId = ctx.User.FindFirst("tenant_id")?.Value + ?? ctx.User.FindFirst("org_id")?.Value; + + var sub = ctx.User.FindFirst("sub")?.Value + ?? ctx.User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + + if (!string.IsNullOrEmpty(tenantId) && !string.IsNullOrEmpty(sub)) + return $"tenant:{tenantId}:user:{sub}"; + + if (!string.IsNullOrEmpty(sub)) + return $"user:{sub}"; + + var apiKey = ctx.Request.Headers["X-Api-Key"].FirstOrDefault(); + if (!string.IsNullOrEmpty(apiKey)) + return $"apikey:{apiKey}"; + + return $"ip:{IpDelCliente(ctx)}"; + } + + /// + /// La IP del cliente, no la del Ingress. + /// + /// Se toma la PRIMERA de X-Forwarded-For, que es la del cliente original; las + /// siguientes son los saltos intermedios. La cabecera la puede falsificar quien llame + /// directamente al pod, y por eso solo gobierna el cupo anónimo: quien se autentica se cuenta + /// por su identidad, que no se puede inventar sin credencial. + /// + private static string IpDelCliente(HttpContext ctx) + { + var reenviada = ctx.Request.Headers["X-Forwarded-For"].FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(reenviada)) + { + var primera = reenviada.Split(',')[0].Trim(); + if (!string.IsNullOrEmpty(primera)) return primera; + } + + return ctx.Connection.RemoteIpAddress?.ToString() ?? "desconocida"; + } +} + +public static class LimiteDePeticionesMiddlewareExtensions +{ + /// + /// Registra el límite de peticiones. Debe ir DESPUÉS de UseAuthentication: antes, + /// HttpContext.User viene vacío y el cupo de una IP lo comparten cuantos la usen (G-248). + /// + public static IApplicationBuilder UseLimiteDePeticiones( + this IApplicationBuilder app, IConfiguration configuration) + { + var seccion = configuration.GetSection("ApiSettings:RateLimiting"); + var cupo = seccion.GetValue("PermitLimit", 100); + var ventana = TimeSpan.FromMinutes(seccion.GetValue("WindowMinutes", 1)); + + return app.UseMiddleware(cupo, ventana); + } +} diff --git a/src/apps/ums.api/Ums.Presentation/Middleware/SessionTrackingMiddleware.cs b/src/apps/ums.api/Ums.Presentation/Middleware/SessionTrackingMiddleware.cs index c8d5dc27..b2c6f946 100644 --- a/src/apps/ums.api/Ums.Presentation/Middleware/SessionTrackingMiddleware.cs +++ b/src/apps/ums.api/Ums.Presentation/Middleware/SessionTrackingMiddleware.cs @@ -2,11 +2,15 @@ namespace Ums.Presentation.Middleware; using System.Diagnostics; using Microsoft.AspNetCore.Http; -using Ums.Infrastructure.Services; +using Ums.Infrastructure.Observability; /// -/// Ensures every request has a session tracking identifier that can be correlated across -/// logs, traces, business flows, and background handoffs. +/// Garantiza que toda petición tenga un identificador de rastreo de sesión, correlacionable +/// entre logs, trazas, flujos de negocio y traspasos a segundo plano. +/// +/// El rastreo de sesión (SessionTrackingId) es un identificador de producto de UMS, ajeno al +/// estándar W3C: la correlación de traza (trace_id/span_id) la aporta +/// (traceparent), no este middleware. /// public sealed class SessionTrackingMiddleware { @@ -21,7 +25,7 @@ public SessionTrackingMiddleware( _logger = logger; } - public async Task InvokeAsync(HttpContext context, RequestContextAccessor requestContextAccessor) + public async Task InvokeAsync(HttpContext context, RequestContext requestContext) { var sessionTrackingId = GetOrAddSessionTrackingId(context); context.Response.Headers[ObservabilityHeaders.SessionTrackingId] = sessionTrackingId; @@ -33,13 +37,7 @@ public async Task InvokeAsync(HttpContext context, RequestContextAccessor reques activity.SetTag(ObservabilityKeys.SessionTrackingId, sessionTrackingId); } - requestContextAccessor.Set(new ExecutionContextSnapshot( - CorrelationId: activity?.GetBaggageItem(ObservabilityKeys.CorrelationId) - ?? context.TraceIdentifier - ?? string.Empty, - SessionTrackingId: sessionTrackingId, - TraceId: activity?.TraceId.ToString() ?? string.Empty, - SpanId: activity?.SpanId.ToString() ?? string.Empty)); + requestContext.SetSessionTrackingId(sessionTrackingId); using (_logger.BeginScope(new Dictionary { diff --git a/src/apps/ums.api/Ums.Presentation/Middleware/TokenRevocationMiddleware.cs b/src/apps/ums.api/Ums.Presentation/Middleware/TokenRevocationMiddleware.cs index 1737a020..f59c1d45 100644 --- a/src/apps/ums.api/Ums.Presentation/Middleware/TokenRevocationMiddleware.cs +++ b/src/apps/ums.api/Ums.Presentation/Middleware/TokenRevocationMiddleware.cs @@ -6,6 +6,14 @@ namespace Ums.Presentation.Middleware; /// /// HARDENING-03: Rejects requests from users whose tokens have been revoked. /// +/// G-247: comprueba DOS cosas distintas, y son distintas a propósito. La cuenta puede estar +/// vetada entera —bloqueo, borrado, cambio de contraseña—, y eso lo responde +/// ITokenRevocationStore, que revoca por usuario. O puede estar cerrada solo ESTA sesión +/// —alguien pulsó «cerrar sesión» en este dispositivo—, y eso lo responde +/// ISessionRevocationStore, que revoca por `sid`. Mezclarlas en una sola clave obligaría a +/// elegir: o el logout echa al usuario de todos sus dispositivos, o el bloqueo de una cuenta deja +/// vivas las sesiones ya abiertas. +/// /// Runs after UseAuthentication so HttpContext.User is already populated. /// Returns HTTP 401 with a Problem Details body when the authenticated user is in /// the revocation list (deleted or blocked). This ensures that deleting or blocking @@ -16,7 +24,10 @@ namespace Ums.Presentation.Middleware; /// public sealed class TokenRevocationMiddleware(RequestDelegate next) { - public async Task InvokeAsync(HttpContext context, ITokenRevocationStore revocationStore) + public async Task InvokeAsync( + HttpContext context, + ITokenRevocationStore revocationStore, + ISessionRevocationStore sessionRevocationStore) { // Only check authenticated requests — anonymous routes pass through. if (context.User.Identity?.IsAuthenticated == true) @@ -24,7 +35,18 @@ public async Task InvokeAsync(HttpContext context, ITokenRevocationStore revocat var userId = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? context.User.FindFirst("sub")?.Value; - if (!string.IsNullOrEmpty(userId) && await revocationStore.IsRevokedAsync(userId, context.RequestAborted)) + // G-247: la sesión de este portador. Los emitidos antes de que existiera `sid` no lo + // llevan: siguen valiendo hasta caducar, que es lo mismo que hacían antes. Rechazarlos + // cerraría de golpe todas las sesiones vivas en el despliegue del cambio. + var sessionId = context.User.FindFirst("sid")?.Value; + + var vetado = !string.IsNullOrEmpty(userId) + && await revocationStore.IsRevokedAsync(userId, context.RequestAborted); + var sesionCerrada = !vetado + && !string.IsNullOrEmpty(sessionId) + && await sessionRevocationStore.EstaRevocadaAsync(sessionId, context.RequestAborted); + + if (vetado || sesionCerrada) { context.Response.StatusCode = StatusCodes.Status401Unauthorized; context.Response.ContentType = "application/problem+json"; diff --git a/src/apps/ums.api/Ums.Presentation/Observability/ActivityTraceEnricher.cs b/src/apps/ums.api/Ums.Presentation/Observability/ActivityTraceEnricher.cs new file mode 100644 index 00000000..4fa2803b --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation/Observability/ActivityTraceEnricher.cs @@ -0,0 +1,28 @@ +namespace Ums.Presentation.Observability; + +using System.Diagnostics; +using Serilog.Core; +using Serilog.Events; + +/// +/// Enriquecedor de Serilog que añade TraceId y SpanId del contexto de traza +/// W3C () a toda línea de log (ADR-0046). +/// +/// Correlaciona de forma uniforme los logs (Loki) con las trazas (Tempo) sin depender del +/// sink de OTel: los sinks no-OTel (Console, Grafana Loki) transportan los mismos campos. +/// Solo emite cuando la usa el formato de identificador W3C. +/// +public sealed class ActivityTraceEnricher : ILogEventEnricher +{ + public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) + { + var activity = Activity.Current; + if (activity is null || activity.IdFormat != ActivityIdFormat.W3C) + { + return; + } + + logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("TraceId", activity.TraceId.ToString())); + logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("SpanId", activity.SpanId.ToString())); + } +} diff --git a/src/apps/ums.api/Ums.Presentation/Program.cs b/src/apps/ums.api/Ums.Presentation/Program.cs index 4568386c..24f96f3b 100644 --- a/src/apps/ums.api/Ums.Presentation/Program.cs +++ b/src/apps/ums.api/Ums.Presentation/Program.cs @@ -2,7 +2,6 @@ using Azure.Core; using Azure.Extensions.AspNetCore.Configuration.Secrets; using Azure.Identity; -using Microsoft.OpenApi.Models; using Ums.Presentation.Bootstrapping; using Ums.Presentation.Extensions; using Serilog; @@ -25,10 +24,20 @@ var app = builder.Build(); await app.InitializeUmsPlatformAsync(); +// Modo «migra y sal» (G-169): el proceso ya migró y sembró en InitializeUmsPlatformAsync, así que +// termina aquí sin levantar el servidor. Lo usa el Job previo al despliegue, para que la migración +// ocurra UNA vez y no en el arranque de cada réplica. +if (app.Configuration.GetValue("Persistence:MigrateAndExit")) +{ + Log.Information("Persistence:MigrateAndExit — plataforma inicializada; el proceso termina sin servir tráfico."); + await Log.CloseAndFlushAsync(); + return; +} + app.UseUmsApiPipeline(); app.MapUmsApiSurface(); -app.Run(); +await app.RunAsync(); static void ConfigureSecrets(WebApplicationBuilder builder) { @@ -67,20 +76,4 @@ static void ConfigureSecrets(WebApplicationBuilder builder) } } -internal sealed class LanguageHeaderOperationFilter : Swashbuckle.AspNetCore.SwaggerGen.IOperationFilter -{ - public void Apply(Microsoft.OpenApi.Models.OpenApiOperation operation, Swashbuckle.AspNetCore.SwaggerGen.OperationFilterContext context) - { - operation.Parameters ??= new List(); - operation.Parameters.Add(new Microsoft.OpenApi.Models.OpenApiParameter - { - Name = "X-Language", - In = Microsoft.OpenApi.Models.ParameterLocation.Header, - Required = false, - Description = "Language code (e.g. 'en', 'es'). Falls back to Accept-Language then 'en'.", - Schema = new Microsoft.OpenApi.Models.OpenApiSchema { Type = "string", Default = new Microsoft.OpenApi.Any.OpenApiString("en") }, - }); - } -} - public partial class Program; diff --git a/src/apps/ums.api/Ums.Presentation/Services/JwtTokenService.cs b/src/apps/ums.api/Ums.Presentation/Services/JwtTokenService.cs index 9ad7299e..04986a54 100644 --- a/src/apps/ums.api/Ums.Presentation/Services/JwtTokenService.cs +++ b/src/apps/ums.api/Ums.Presentation/Services/JwtTokenService.cs @@ -15,7 +15,12 @@ public interface IJwtTokenService /// Generates a JWT token that embeds the full AuthorizationGraph as claims. /// Used by /client/authenticate — the token IS the auth graph for the client system. /// - string GenerateGraphToken(Ums.Domain.Authorization.Graph.AuthorizationGraph graph); + /// Grafo de autorización que el token transporta. + /// + /// Sesión a la que pertenece el token (G-247). La comparte con la cookie emitida en el mismo + /// login, para que cerrar sesión cierre el dispositivo hable por donde hable. + /// + string GenerateGraphToken(Ums.Domain.Authorization.Graph.AuthorizationGraph graph, string? sessionId = null); /// /// Generates a semantic-only JWT for external client systems. @@ -38,18 +43,35 @@ public record TokenGenerationRequest( string? ProfileId, string[] Permissions, string Language = "en", - bool IsInternalAdmin = false); + bool IsInternalAdmin = false, + /// + /// Sesión a la que pertenece este token (G-247). Es la MISMA que lleva la cookie emitida en el + /// mismo login: cerrar sesión cierra el dispositivo, y un dispositivo puede hablar por cookie o + /// por portador. Si va vacío, el token no es revocable por sesión — se genera uno para no + /// dejarlo sin identidad, pero entonces nadie más conoce ese identificador. + /// + string? SessionId = null); public class JwtTokenService : IJwtTokenService { - private readonly string _secret; + /// + /// El material de firma, único para los tres tokens que UMS emite. + /// + /// + /// Antes cada método construía su propia SymmetricSecurityKey a partir de + /// Jwt:Secret. Tres construcciones idénticas son tres sitios donde el + /// algoritmo puede divergir sin que nada falle, así que ahora hay uno solo y es + /// RS256 (ADR-0157). + /// + private readonly MaterialDeFirma _firma; + private readonly string _issuer; private readonly string _audience; private readonly IConfigurationProvider _configProvider; - public JwtTokenService(IConfiguration configuration, IConfigurationProvider configProvider) + public JwtTokenService(IConfiguration configuration, IConfigurationProvider configProvider, MaterialDeFirma firma) { - _secret = configuration["Jwt:Secret"] ?? throw new ArgumentNullException("Jwt:Secret is not configured"); + _firma = firma ?? throw new ArgumentNullException(nameof(firma)); _issuer = configuration["Jwt:Issuer"] ?? "ums-api"; _audience = configuration["Jwt:Audience"] ?? "ums-web-app"; _configProvider = configProvider; @@ -57,8 +79,7 @@ public JwtTokenService(IConfiguration configuration, IConfigurationProvider conf public string GenerateToken(TokenGenerationRequest request) { - var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secret)); - var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256); + var credentials = _firma.Credenciales; var claims = new List { @@ -69,6 +90,11 @@ public string GenerateToken(TokenGenerationRequest request) new("tenant_code", request.TenantCode), new("session_tracking_id", Guid.NewGuid().ToString()), new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + // G-247: la sesión, compartida con la cookie del mismo login. `jti` no sirve para esto: + // es distinto en cada token, así que revocarlo no alcanzaría al que emita el refresh. + new("sid", string.IsNullOrWhiteSpace(request.SessionId) + ? Guid.NewGuid().ToString("N") + : request.SessionId), }; if (!string.IsNullOrEmpty(request.Role)) @@ -111,10 +137,9 @@ public string GenerateToken(TokenGenerationRequest request) return new JwtSecurityTokenHandler().WriteToken(token); } - public string GenerateGraphToken(Ums.Domain.Authorization.Graph.AuthorizationGraph graph) + public string GenerateGraphToken(Ums.Domain.Authorization.Graph.AuthorizationGraph graph, string? sessionId = null) { - var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secret)); - var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256); + var credentials = _firma.Credenciales; var ctx = graph.Context; var claims = new List @@ -124,33 +149,49 @@ public string GenerateGraphToken(Ums.Domain.Authorization.Graph.AuthorizationGra new(JwtRegisteredClaimNames.Name, ctx.User.Username), new("tenant_id", ctx.Tenant.Id.ToString()), new("tenant_code", ctx.Tenant.Code), - new("sys_suite", ctx.SystemSuite.Code), - new(ClaimTypes.Role, ctx.Role.Code), - new("role_name", ctx.Role.Name), - new("profile_id", ctx.Profile.Id.ToString()), + // El privilegio de operador (internal-admin) se deriva de IsManagementOwner + // (igual que en AuthEndpoints). Debe viajar en el token Bearer de grafo — no solo + // en el principal de cookie — para que switch-tenant y la gestión on-behalf del + // operador sean alcanzables vía API (evolith-core#18). No aplica al token semántico + // de clientes externos (GenerateSemanticGraphToken), que nunca es internal-admin. + new("is_internal_admin", ctx.Tenant.IsManagementOwner ? "true" : "false"), new("auth_method", graph.Authentication.Method), new("graph_generated_at", graph.GeneratedAt.ToString("O")), new("graph_valid_until", graph.ValidUntil.ToString("O")), new("session_tracking_id", Guid.NewGuid().ToString()), new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + // G-247: la misma sesión que la cookie del login. `jti` cambia en cada token, así que + // revocarlo no alcanzaría al que emita el refresh. + new("sid", string.IsNullOrWhiteSpace(sessionId) ? Guid.NewGuid().ToString("N") : sessionId), }; + // G-043: en el grafo lobby (onboarding pendiente) SystemSuite/Role/Profile son null; sus claims + // se omiten (el token lobby no otorga rol/suite/perfil, coherente con "sin perfil aún"). + if (ctx.SystemSuite is not null) + claims.Add(new Claim("sys_suite", ctx.SystemSuite.Code)); + if (ctx.Role is not null) + { + claims.Add(new Claim(ClaimTypes.Role, ctx.Role.Code)); + claims.Add(new Claim("role_name", ctx.Role.Name)); + } + if (ctx.Profile is not null) + claims.Add(new Claim("profile_id", ctx.Profile.Id.ToString())); + if (ctx.Branch is not null) claims.Add(new Claim("branch_id", ctx.Branch.Id.ToString())); if (graph.Authentication.Provider is not null) claims.Add(new Claim("idp_provider", graph.Authentication.Provider.Name)); - // Embed permissions as compact claims: "TargetCode:ActionCode:Effect" - foreach (var module in graph.MenuAccess) - foreach (var menu in module.Menus) - foreach (var sub in menu.SubMenus) - foreach (var opt in sub.Options) - claims.Add(new Claim("perm", $"{opt.Code}:{opt.ActionCode}:{opt.Effect}")); - - foreach (var res in graph.DomainPermissions) - foreach (var act in res.Actions) - claims.Add(new Claim("domain_perm", $"{res.ResourceCode}:{act.ActionCode}:{act.Effect}")); + // G-172: aquí se emitía un claim `perm` por CADA par opción-acción y un `domain_perm` por + // CADA par recurso-acción, SIN filtrar el efecto: sobre datos reales, 84 + 286 claims que + // hinchaban el token a 16-24 KB para UN SOLO sistema, enviados en la cabecera + // `Authorization` de cada petición. El grafo ya viaja en el cuerpo y el cliente lo cachea + // toda la sesión: el token no necesita repetirlo. + // + // Los `scope` SÍ se quedan: la autorización del servidor los lee (`UserContext.HasPermission`, + // que alimenta al aspecto AOP). Quitarlos denegaría todo, fail-closed. Moverlos a una + // resolución por sesión es una mejora aparte, no un efecto colateral de este cambio. foreach (var scope in graph.Scopes) claims.Add(new Claim("scope", scope)); @@ -170,8 +211,7 @@ public string GenerateGraphToken(Ums.Domain.Authorization.Graph.AuthorizationGra public string GenerateSemanticGraphToken(Ums.Domain.Authorization.Graph.AuthorizationGraph graph) { - var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secret)); - var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256); + var credentials = _firma.Credenciales; var ctx = graph.Context; var claims = new List @@ -181,11 +221,6 @@ public string GenerateSemanticGraphToken(Ums.Domain.Authorization.Graph.Authoriz new(JwtRegisteredClaimNames.Name, ctx.User.DisplayName), new("tenant_code", ctx.Tenant.Code), new("tenant_name", ctx.Tenant.Name), - new("sys_suite", ctx.SystemSuite.Code), - new("sys_suite_name", ctx.SystemSuite.Name), - new(ClaimTypes.Role, ctx.Role.Code), - new("role_name", ctx.Role.Name), - new("profile_scope", ctx.Profile.Scope), new("auth_method", graph.Authentication.Method), new("graph_generated_at", graph.GeneratedAt.ToString("O")), new("graph_valid_until", graph.ValidUntil.ToString("O")), @@ -193,6 +228,36 @@ public string GenerateSemanticGraphToken(Ums.Domain.Authorization.Graph.Authoriz new(JwtRegisteredClaimNames.Jti, GenerateOpaqueId()), }; + // G-043: grafo lobby → SystemSuite/Role/Profile null; se omiten sus claims. + if (ctx.SystemSuite is not null) + { + claims.Add(new Claim("sys_suite", ctx.SystemSuite.Code)); + claims.Add(new Claim("sys_suite_name", ctx.SystemSuite.Name)); + } + if (ctx.Role is not null) + { + claims.Add(new Claim(ClaimTypes.Role, ctx.Role.Code)); + claims.Add(new Claim("role_name", ctx.Role.Name)); + } + if (ctx.Profile is not null) + { + claims.Add(new Claim("profile_scope", ctx.Profile.Scope)); + + // El PERFIL VIGENTE viaja en el token desde ADR-0156 §8. Sin él, el cambio de perfil + // duraba lo que la respuesta: `GET /client/graph` reconstruía el grafo por desempate y + // devolvía otra vez el perfil que el usuario acababa de abandonar. Medido: `pmo.sdlc@` + // cambiaba a PMO —75 permisos— y al revalidar volvía a DIRECTORIO —7—, en el mismo + // proceso. Una conmutación que no sobrevive a la siguiente petición no es una + // conmutación. + // + // Este token evita identificadores internos a propósito, y por eso el `sub` es el + // nombre de usuario. El del perfil es la excepción justificada: desde el contrato + // 2.4.0 el propio grafo publica `profiles[].id` a ese mismo cliente, así que el claim + // no le revela nada que no tenga ya. No se usa un selector semántico rol+sistema + // porque no desambigua: dos perfiles pueden compartirlos y diferir solo en sucursal. + claims.Add(new Claim("profile_id", ctx.Profile.Id.ToString())); + } + if (!string.IsNullOrWhiteSpace(ctx.Branch?.Code)) claims.Add(new Claim("branch_code", ctx.Branch.Code)); @@ -202,16 +267,8 @@ public string GenerateSemanticGraphToken(Ums.Domain.Authorization.Graph.Authoriz claims.Add(new Claim("idp_strategy", graph.Authentication.Provider.Strategy)); } - foreach (var module in graph.MenuAccess) - foreach (var menu in module.Menus) - foreach (var sub in menu.SubMenus) - foreach (var opt in sub.Options) - claims.Add(new Claim("perm", $"{opt.Code}:{opt.ActionCode}:{opt.Effect}")); - - foreach (var res in graph.DomainPermissions) - foreach (var act in res.Actions) - claims.Add(new Claim("domain_perm", $"{res.ResourceCode}:{act.ActionCode}:{act.Effect}")); - + // G-172: el token semántico tampoco replica la matriz de permisos. Los `scope` bastan + // para autorizar y son la mitad del volumen; el grafo completo viaja en el cuerpo. foreach (var scope in graph.Scopes) claims.Add(new Claim("scope", scope)); diff --git a/src/apps/ums.api/Ums.Presentation/Services/MaterialDeFirma.cs b/src/apps/ums.api/Ums.Presentation/Services/MaterialDeFirma.cs new file mode 100644 index 00000000..7bfcf6f0 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation/Services/MaterialDeFirma.cs @@ -0,0 +1,178 @@ +using System; +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.Tokens; + +namespace Ums.Presentation.Services; + +/// +/// La clave con la que UMS firma sus portadores, y el material público que publica. +/// +/// +/// +/// Es ADR-0157 ejecutado: +/// UMS firma con clave privada RSA en RS256 y publica solo la pública. +/// La diferencia con HS256 no es de robustez criptográfica sino de quién puede emitir: +/// con un secreto compartido, verificar y firmar son la misma +/// capacidad, así que cada satélite que valida es también un emisor y ninguna firma +/// identifica su origen. Con RS256 la clave pública se reparte sin custodia y no +/// concede nada. +/// +/// +/// La clave privada no entra al repositorio bajo ninguna forma —ni +/// PEM, ni JWK, ni base64, ni marcador de posición—. Llega por configuración +/// (Jwt:PrivateKeyPem), y el arranque falla si no está: G-203 dejó escrito lo +/// que pasa cuando un marcador del repositorio acaba siendo la clave de todos los +/// entornos. +/// +/// +public sealed class MaterialDeFirma : IDisposable +{ + /// Tamaño mínimo de clave. Por debajo, UMS no arranca. + private const int BitsMinimos = 2048; + + private readonly RSA rsa; + + /// Construye el material a partir de la configuración. + /// Configuración de la aplicación. + /// Entorno de ejecución. + /// Registro de arranque. + public MaterialDeFirma( + IConfiguration configuration, + IHostEnvironment entorno, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(configuration); + ArgumentNullException.ThrowIfNull(entorno); + ArgumentNullException.ThrowIfNull(logger); + + string? pem = configuration["Jwt:PrivateKeyPem"]; + + rsa = RSA.Create(); + + if (string.IsNullOrWhiteSpace(pem)) + { + // En desarrollo se genera un par efímero. No es una excepción a la regla de + // §4.7 —«la clave privada no entra al repositorio en ninguna forma»—: es la + // única manera de cumplirla sin dejar el repositorio inarrancable, y es lo + // mismo que el ADR prescribe para los arneses de prueba. Cambia en cada + // arranque, así que los tokens no sobreviven a un reinicio: en desarrollo eso + // es una molestia, y en producción sería un incidente, por lo que allí falla. + if (!entorno.IsDevelopment()) + { + throw new InvalidOperationException( + "Jwt:PrivateKeyPem no está configurado. UMS firma sus portadores con clave " + + "privada RSA (ADR-0157) y sin ella no puede emitir ninguno. Se inyecta por " + + "secreto del entorno; no existe valor por omisión, y no debe existir: un " + + "marcador de posición acaba siendo la clave de todos los entornos (G-203)."); + } + + rsa.KeySize = BitsMinimos; + + // El respaldo efímero sirve para UN proceso. Con varias réplicas cada una firma + // con su propia clave y publica en su JWKS solo la suya, así que un token emitido + // por una réplica NO verifica contra el JWKS que responde otra. Medido el + // 2026-08-04 en kind con dos réplicas: la mitad de los tokens se rechazaban, de + // forma intermitente y sin ningún error que lo explicara. El proceso no puede + // saber cuántas réplicas hay, así que lo dice en voz alta y que lo vea quien + // despliegue. + logger.LogWarning( + "UMS ha generado una clave de firma EFÍMERA (kid distinto en cada arranque) porque " + + "Jwt:PrivateKeyPem no está configurado. Solo es válido con UNA réplica: con varias, " + + "cada una firma con una clave distinta y los tokens de una no se verifican contra el " + + "JWKS de otra. Configura Jwt:PrivateKeyPem antes de escalar (ADR-0157 §4.7)."); + } + else + { + try + { + rsa.ImportFromPem(pem); + } + catch (ArgumentException error) + { + throw new InvalidOperationException( + "Jwt:PrivateKeyPem no es una clave RSA en PEM legible.", error); + } + } + + // Fail-closed de arranque: una clave corta no avisa en tiempo de ejecución, + // firma igual y deja el sistema debil sin que nada falle. + if (rsa.KeySize < BitsMinimos) + { + throw new InvalidOperationException( + $"La clave de firma tiene {rsa.KeySize} bits y el mínimo es {BitsMinimos} (ADR-0157 §4.7)."); + } + + RSAParameters publicas = rsa.ExportParameters(includePrivateParameters: false); + Modulo = Base64Url(publicas.Modulus!); + Exponente = Base64Url(publicas.Exponent!); + Kid = HuellaJwk(Modulo, Exponente); + + var clave = new RsaSecurityKey(rsa) { KeyId = Kid }; + Credenciales = new SigningCredentials(clave, SecurityAlgorithms.RsaSha256); + ClavePublica = new RsaSecurityKey(publicas) { KeyId = Kid }; + } + + /// Identificador de la clave, que viaja en la cabecera del token. + /// + /// Es la huella JWK de RFC 7638, no un número de serie: dos despliegues con la + /// misma clave obtienen el mismo kid y dos claves distintas nunca colisionan. + /// De eso depende que la rotación pueda solaparse (ADR-0157 §4.4). + /// + public string Kid { get; } + + /// Credenciales con las que se firma. RS256, siempre. + public SigningCredentials Credenciales { get; } + + /// Clave pública, para que UMS verifique lo que él mismo emitió. + public RsaSecurityKey ClavePublica { get; } + + private string Modulo { get; } + + private string Exponente { get; } + + /// + /// Proyecta la clave pública como JWK, tal como sale en el JWKS. + /// + /// + /// Declara kty, use, alg, kid, n y e, y + /// nada más. RFC 7517 §5 y OIDC Discovery §3 prohíben que un JWK + /// Set publicado contenga material privado o simétrico: un JWKS con una d + /// dentro no es un error de formato, es un incidente de seguridad. + /// + /// El JWK público. + public object ComoJwkPublico() => new + { + kty = "RSA", + use = "sig", + alg = "RS256", + kid = Kid, + n = Modulo, + e = Exponente, + }; + + /// Libera la clave. + public void Dispose() => rsa.Dispose(); + + private static string Base64Url(byte[] valor) => + Convert.ToBase64String(valor).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + /// Huella JWK de RFC 7638: SHA-256 del JSON canónico con las claves ordenadas. + private static string HuellaJwk(string modulo, string exponente) + { + // El orden de los miembros y la ausencia de espacios NO son estilo: RFC 7638 §3 + // exige exactamente esta forma canónica, y cualquier desviación produce una + // huella distinta para la misma clave. + string canonico = JsonSerializer.Serialize(new + { + e = exponente, + kty = "RSA", + n = modulo, + }); + + return Base64Url(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(canonico))); + } +} diff --git a/src/apps/ums.api/Ums.Presentation/Ums.Presentation.csproj b/src/apps/ums.api/Ums.Presentation/Ums.Presentation.csproj index 961509f0..56b122ff 100644 --- a/src/apps/ums.api/Ums.Presentation/Ums.Presentation.csproj +++ b/src/apps/ums.api/Ums.Presentation/Ums.Presentation.csproj @@ -15,15 +15,23 @@ + + + + + + + + - runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/apps/ums.api/Ums.Presentation/appsettings.Development.json b/src/apps/ums.api/Ums.Presentation/appsettings.Development.json index 6b321f0b..2a2d642a 100644 --- a/src/apps/ums.api/Ums.Presentation/appsettings.Development.json +++ b/src/apps/ums.api/Ums.Presentation/appsettings.Development.json @@ -14,15 +14,10 @@ "Persistence": { "Provider": "PostgreSql", "AggregateStoreMode": "PostgreSql", - "UseSqliteIdentityStores": false, "UsePostgreSqlIdentityStores": true, - "UseSqliteAuthorizationStores": false, "UsePostgreSqlAuthorizationStores": true, - "UseSqliteConfigurationStores": false, "UsePostgreSqlConfigurationStores": true, - "UseSqliteApprovalsStores": false, "UsePostgreSqlApprovalsStores": true, - "UseSqliteIgaStores": false, "UsePostgreSqlIgaStores": true, "SeedDevData": true, "EnableOutbox": true, @@ -49,7 +44,7 @@ "Logging": { "MinimumLevel": "Debug", "ConsoleFormat": "Text", - "OutputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {CorrelationId} {SessionTrackingId} {ErrorId} {SourceContext} {Message:lj}{NewLine}{Exception}", + "OutputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] trace={TraceId} span={SpanId} {SessionTrackingId} {ErrorId} {SourceContext} {Message:lj}{NewLine}{Exception}", "LokiEndpoint": "", "LokiAppLabel": "ums-api", "LokiEnvLabel": "development" diff --git a/src/apps/ums.api/Ums.Presentation/appsettings.Production.json b/src/apps/ums.api/Ums.Presentation/appsettings.Production.json index baa54cc2..fcd3b880 100644 --- a/src/apps/ums.api/Ums.Presentation/appsettings.Production.json +++ b/src/apps/ums.api/Ums.Presentation/appsettings.Production.json @@ -13,11 +13,6 @@ "Persistence": { "Provider": "PostgreSql", "AggregateStoreMode": "PostgreSql", - "UsePostgreSqlIdentityStores": true, - "UsePostgreSqlAuthorizationStores": true, - "UsePostgreSqlConfigurationStores": true, - "UsePostgreSqlApprovalsStores": true, - "UsePostgreSqlIgaStores": true, "SeedDevData": false, "EnableOutbox": true, "InitializePlatformStoreOnStartup": false @@ -41,7 +36,7 @@ "Logging": { "MinimumLevel": "Information", "ConsoleFormat": "CompactJson", - "OutputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {CorrelationId} {SessionTrackingId} {SourceContext} {Message:lj}{NewLine}{Exception}" + "OutputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] trace={TraceId} span={SpanId} {SessionTrackingId} {SourceContext} {Message:lj}{NewLine}{Exception}" }, "Tracing": { "OtlpEndpoint": "", diff --git a/src/apps/ums.api/Ums.Presentation/appsettings.UAT.json b/src/apps/ums.api/Ums.Presentation/appsettings.UAT.json index 2015fc47..493280f0 100644 --- a/src/apps/ums.api/Ums.Presentation/appsettings.UAT.json +++ b/src/apps/ums.api/Ums.Presentation/appsettings.UAT.json @@ -18,9 +18,9 @@ "UsePostgreSqlConfigurationStores": true, "UsePostgreSqlApprovalsStores": true, "UsePostgreSqlIgaStores": true, - "SeedDevData": false, + "SeedDevData": true, "EnableOutbox": true, - "InitializePlatformStoreOnStartup": false + "InitializePlatformStoreOnStartup": true }, "Secrets": { "Source": "AppSettings", @@ -41,7 +41,7 @@ "Logging": { "MinimumLevel": "Information", "ConsoleFormat": "CompactJson", - "OutputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {CorrelationId} {SessionTrackingId} {SourceContext} {Message:lj}{NewLine}{Exception}" + "OutputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] trace={TraceId} span={SpanId} {SessionTrackingId} {SourceContext} {Message:lj}{NewLine}{Exception}" }, "Tracing": { "OtlpEndpoint": "", diff --git a/src/apps/ums.api/Ums.Presentation/appsettings.json b/src/apps/ums.api/Ums.Presentation/appsettings.json index 765e539a..e1e938cf 100644 --- a/src/apps/ums.api/Ums.Presentation/appsettings.json +++ b/src/apps/ums.api/Ums.Presentation/appsettings.json @@ -48,7 +48,7 @@ "Logging": { "MinimumLevel": "Information", "ConsoleFormat": "CompactJson", - "OutputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {CorrelationId} {SessionTrackingId} {ErrorId} {SourceContext} {Message:lj}{NewLine}{Exception}", + "OutputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] trace={TraceId} span={SpanId} {SessionTrackingId} {ErrorId} {SourceContext} {Message:lj}{NewLine}{Exception}", "LokiEndpoint": "", "LokiAppLabel": "ums-api", "LokiEnvLabel": "production" diff --git a/src/apps/ums.api/Ums.Presentation/umsdev.db b/src/apps/ums.api/Ums.Presentation/umsdev.db deleted file mode 100644 index 91739afa..00000000 Binary files a/src/apps/ums.api/Ums.Presentation/umsdev.db and /dev/null differ diff --git a/src/apps/ums.api/Ums.Presentation/umsdev.db.backup b/src/apps/ums.api/Ums.Presentation/umsdev.db.backup deleted file mode 100644 index 69dd1c63..00000000 Binary files a/src/apps/ums.api/Ums.Presentation/umsdev.db.backup and /dev/null differ diff --git a/src/apps/ums.api/Ums.Presentation/umsdev.db.pre-fs12-cleanup-20260604 b/src/apps/ums.api/Ums.Presentation/umsdev.db.pre-fs12-cleanup-20260604 deleted file mode 100644 index d1ce6aaf..00000000 Binary files a/src/apps/ums.api/Ums.Presentation/umsdev.db.pre-fs12-cleanup-20260604 and /dev/null differ diff --git a/src/Ums.ReadModels/src/Ums.ReadModels/Migrations/20260607025649_InitReadModels.Designer.cs b/src/apps/ums.api/Ums.ReadModels/Migrations/20260607025649_InitReadModels.Designer.cs similarity index 98% rename from src/Ums.ReadModels/src/Ums.ReadModels/Migrations/20260607025649_InitReadModels.Designer.cs rename to src/apps/ums.api/Ums.ReadModels/Migrations/20260607025649_InitReadModels.Designer.cs index 4970679d..750bea8c 100644 --- a/src/Ums.ReadModels/src/Ums.ReadModels/Migrations/20260607025649_InitReadModels.Designer.cs +++ b/src/apps/ums.api/Ums.ReadModels/Migrations/20260607025649_InitReadModels.Designer.cs @@ -9,7 +9,7 @@ #nullable disable -namespace Ums.ReadModels.src.Ums.ReadModels.Migrations +namespace Ums.ReadModels.Migrations { [DbContext(typeof(ReadModelDbContext))] [Migration("20260607025649_InitReadModels")] diff --git a/src/Ums.ReadModels/src/Ums.ReadModels/Migrations/20260607025649_InitReadModels.cs b/src/apps/ums.api/Ums.ReadModels/Migrations/20260607025649_InitReadModels.cs similarity index 98% rename from src/Ums.ReadModels/src/Ums.ReadModels/Migrations/20260607025649_InitReadModels.cs rename to src/apps/ums.api/Ums.ReadModels/Migrations/20260607025649_InitReadModels.cs index f569f430..fe8d6855 100644 --- a/src/Ums.ReadModels/src/Ums.ReadModels/Migrations/20260607025649_InitReadModels.cs +++ b/src/apps/ums.api/Ums.ReadModels/Migrations/20260607025649_InitReadModels.cs @@ -3,7 +3,7 @@ #nullable disable -namespace Ums.ReadModels.src.Ums.ReadModels.Migrations +namespace Ums.ReadModels.Migrations { /// public partial class InitReadModels : Migration diff --git a/src/Ums.ReadModels/src/Ums/ReadModels/Migrations/ReadModelDbContextModelSnapshot.cs b/src/apps/ums.api/Ums.ReadModels/Migrations/ReadModelDbContextModelSnapshot.cs similarity index 98% rename from src/Ums.ReadModels/src/Ums/ReadModels/Migrations/ReadModelDbContextModelSnapshot.cs rename to src/apps/ums.api/Ums.ReadModels/Migrations/ReadModelDbContextModelSnapshot.cs index 2a2780c0..23aa15f0 100644 --- a/src/Ums.ReadModels/src/Ums/ReadModels/Migrations/ReadModelDbContextModelSnapshot.cs +++ b/src/apps/ums.api/Ums.ReadModels/Migrations/ReadModelDbContextModelSnapshot.cs @@ -8,7 +8,7 @@ #nullable disable -namespace Ums.ReadModels.src.Ums.ReadModels.Migrations +namespace Ums.ReadModels.Migrations { [DbContext(typeof(ReadModelDbContext))] partial class ReadModelDbContextModelSnapshot : ModelSnapshot diff --git a/src/Ums.ReadModels/Models/PermissionTemplateItemReadModel.cs b/src/apps/ums.api/Ums.ReadModels/Models/PermissionTemplateItemReadModel.cs similarity index 100% rename from src/Ums.ReadModels/Models/PermissionTemplateItemReadModel.cs rename to src/apps/ums.api/Ums.ReadModels/Models/PermissionTemplateItemReadModel.cs diff --git a/src/Ums.ReadModels/Models/PermissionTemplateReadModel.cs b/src/apps/ums.api/Ums.ReadModels/Models/PermissionTemplateReadModel.cs similarity index 100% rename from src/Ums.ReadModels/Models/PermissionTemplateReadModel.cs rename to src/apps/ums.api/Ums.ReadModels/Models/PermissionTemplateReadModel.cs diff --git a/src/Ums.ReadModels/Projections/PermissionTemplateProjectionHandler.cs b/src/apps/ums.api/Ums.ReadModels/Projections/PermissionTemplateProjectionHandler.cs similarity index 100% rename from src/Ums.ReadModels/Projections/PermissionTemplateProjectionHandler.cs rename to src/apps/ums.api/Ums.ReadModels/Projections/PermissionTemplateProjectionHandler.cs diff --git a/src/Ums.ReadModels/ReadModelDbContext.cs b/src/apps/ums.api/Ums.ReadModels/ReadModelDbContext.cs similarity index 100% rename from src/Ums.ReadModels/ReadModelDbContext.cs rename to src/apps/ums.api/Ums.ReadModels/ReadModelDbContext.cs diff --git a/src/apps/ums.api/Ums.ReadModels/ReadModelDbContextFactory.cs b/src/apps/ums.api/Ums.ReadModels/ReadModelDbContextFactory.cs new file mode 100644 index 00000000..d53bd53a --- /dev/null +++ b/src/apps/ums.api/Ums.ReadModels/ReadModelDbContextFactory.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace Ums.ReadModels; + +public class ReadModelDbContextFactory : IDesignTimeDbContextFactory +{ + public ReadModelDbContext CreateDbContext(string[] args) + { + var optionsBuilder = new DbContextOptionsBuilder(); + // Cadena de solo diseño: la usa exclusivamente el tooling de EF Core (`dotnet ef migrations`), + // nunca el runtime — que resuelve la conexión por configuración. Misma convención que + // UmsPlatformDbContextFactory y TenantProjectionDbContextFactory. +#pragma warning disable S2068 // Cadena de conexión de DISEÑO (EF Core tooling), solo local; el runtime resuelve por configuración. No es un secreto de producción. + optionsBuilder.UseNpgsql("Host=localhost;Port=5433;Database=UmsReadModel;Username=postgres;Password=postgres"); +#pragma warning restore S2068 + return new ReadModelDbContext(optionsBuilder.Options); + } +} diff --git a/src/Ums.ReadModels/Ums.ReadModels.csproj b/src/apps/ums.api/Ums.ReadModels/Ums.ReadModels.csproj similarity index 90% rename from src/Ums.ReadModels/Ums.ReadModels.csproj rename to src/apps/ums.api/Ums.ReadModels/Ums.ReadModels.csproj index 2608f91d..1b0f9335 100644 --- a/src/Ums.ReadModels/Ums.ReadModels.csproj +++ b/src/apps/ums.api/Ums.ReadModels/Ums.ReadModels.csproj @@ -5,7 +5,7 @@ enable - + diff --git a/src/apps/ums.api/Ums.sln b/src/apps/ums.api/Ums.sln index 4b08cae2..b68fd63d 100644 --- a/src/apps/ums.api/Ums.sln +++ b/src/apps/ums.api/Ums.sln @@ -1,3 +1,4 @@ + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 @@ -18,6 +19,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ums.Domain.Test", "Ums.Doma EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ums.Application.Test", "Ums.Application.Test\Ums.Application.Test.csproj", "{5E7BC5A3-BD5B-43F3-8798-D5ADA31DD31E}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ums.Presentation.IntegrationTest", "Ums.Presentation.IntegrationTest\Ums.Presentation.IntegrationTest.csproj", "{739F17C9-DF1A-4EEE-9383-2EA3867F0E8E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ums.ContractTest", "Ums.ContractTest\Ums.ContractTest.csproj", "{B1478498-36AF-4952-85EA-DE1D9140ADD5}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -112,8 +117,32 @@ Global {5E7BC5A3-BD5B-43F3-8798-D5ADA31DD31E}.Release|x64.Build.0 = Release|Any CPU {5E7BC5A3-BD5B-43F3-8798-D5ADA31DD31E}.Release|x86.ActiveCfg = Release|Any CPU {5E7BC5A3-BD5B-43F3-8798-D5ADA31DD31E}.Release|x86.Build.0 = Release|Any CPU + {739F17C9-DF1A-4EEE-9383-2EA3867F0E8E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {739F17C9-DF1A-4EEE-9383-2EA3867F0E8E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {739F17C9-DF1A-4EEE-9383-2EA3867F0E8E}.Debug|x64.ActiveCfg = Debug|Any CPU + {739F17C9-DF1A-4EEE-9383-2EA3867F0E8E}.Debug|x64.Build.0 = Debug|Any CPU + {739F17C9-DF1A-4EEE-9383-2EA3867F0E8E}.Debug|x86.ActiveCfg = Debug|Any CPU + {739F17C9-DF1A-4EEE-9383-2EA3867F0E8E}.Debug|x86.Build.0 = Debug|Any CPU + {739F17C9-DF1A-4EEE-9383-2EA3867F0E8E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {739F17C9-DF1A-4EEE-9383-2EA3867F0E8E}.Release|Any CPU.Build.0 = Release|Any CPU + {739F17C9-DF1A-4EEE-9383-2EA3867F0E8E}.Release|x64.ActiveCfg = Release|Any CPU + {739F17C9-DF1A-4EEE-9383-2EA3867F0E8E}.Release|x64.Build.0 = Release|Any CPU + {739F17C9-DF1A-4EEE-9383-2EA3867F0E8E}.Release|x86.ActiveCfg = Release|Any CPU + {739F17C9-DF1A-4EEE-9383-2EA3867F0E8E}.Release|x86.Build.0 = Release|Any CPU + {B1478498-36AF-4952-85EA-DE1D9140ADD5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B1478498-36AF-4952-85EA-DE1D9140ADD5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B1478498-36AF-4952-85EA-DE1D9140ADD5}.Debug|x64.ActiveCfg = Debug|Any CPU + {B1478498-36AF-4952-85EA-DE1D9140ADD5}.Debug|x64.Build.0 = Debug|Any CPU + {B1478498-36AF-4952-85EA-DE1D9140ADD5}.Debug|x86.ActiveCfg = Debug|Any CPU + {B1478498-36AF-4952-85EA-DE1D9140ADD5}.Debug|x86.Build.0 = Debug|Any CPU + {B1478498-36AF-4952-85EA-DE1D9140ADD5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B1478498-36AF-4952-85EA-DE1D9140ADD5}.Release|Any CPU.Build.0 = Release|Any CPU + {B1478498-36AF-4952-85EA-DE1D9140ADD5}.Release|x64.ActiveCfg = Release|Any CPU + {B1478498-36AF-4952-85EA-DE1D9140ADD5}.Release|x64.Build.0 = Release|Any CPU + {B1478498-36AF-4952-85EA-DE1D9140ADD5}.Release|x86.ActiveCfg = Release|Any CPU + {B1478498-36AF-4952-85EA-DE1D9140ADD5}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection -EndGlobal \ No newline at end of file +EndGlobal diff --git a/src/apps/ums.api/Ums.slnx b/src/apps/ums.api/Ums.slnx deleted file mode 100644 index 284a6cdc..00000000 --- a/src/apps/ums.api/Ums.slnx +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/src/apps/ums.web-app/Dockerfile b/src/apps/ums.web-app/Dockerfile index 66f98c8c..90731bbe 100644 --- a/src/apps/ums.web-app/Dockerfile +++ b/src/apps/ums.web-app/Dockerfile @@ -5,7 +5,11 @@ FROM node:24-alpine AS base WORKDIR /usr/src/app COPY package*.json ./ COPY apps/ums.web-app/package*.json ./apps/ums.web-app/ -RUN npm ci +# G-123: el package-lock.json regenerado en macOS omite los binarios nativos opcionales de rollup +# (@rollup/rollup-linux-*-musl) — bug npm #4828 —, así que `npm ci`/`npm install` con ese lock dejan +# `vite build` sin rollup en alpine. Se borra el lock para forzar resolución fresca que incluya la +# optional-dep del target musl en build-time. El fix de fondo es regenerar el lock con plataformas linux. +RUN rm -f package-lock.json apps/ums.web-app/package-lock.json && npm install --no-audit --no-fund # ========================================================= # Phase 2: Production Compilation @@ -26,6 +30,8 @@ COPY --from=builder /usr/src/app/apps/ums.web-app/dist /usr/share/nginx/html # /etc/nginx/templates/*.template so API_UPSTREAM can be set per deployment. COPY apps/ums.web-app/nginx.conf.template /etc/nginx/templates/default.conf.template ENV API_UPSTREAM=http://ums-backend:80 +# Upstream de Grafana para el proxy /grafana/ (observabilidad en la barra). +ENV GRAFANA_UPSTREAM=http://grafana:3000 EXPOSE 80 diff --git a/src/apps/ums.web-app/README.es.md b/src/apps/ums.web-app/README.es.md index 1e088a67..187909d4 100644 --- a/src/apps/ums.web-app/README.es.md +++ b/src/apps/ums.web-app/README.es.md @@ -6,13 +6,13 @@ UMS Web Console es el portal React 18 para la experiencia administrativa de User ## Enlaces Rapidos -| Necesidad | Abrir esto | -| ---------------------------- | ----------------------------------------------------------------------- | -| README raiz | [Resumen del repositorio](../../../README.md) | -| Portal documental en ingles | [docs/README.md](../../../docs/README.md) | -| Portal documental en espanol | [docs/README.es.md](../../../docs/README.es.md) | -| Portal de arquitectura | [docs/architecture/index.es.md](../../../docs/architecture/index.es.md) | -| Portal de gobernanza | [docs/governance/index.es.md](../../../docs/governance/index.es.md) | +| Necesidad | Abrir esto | +| ---------------------------- | ------------------------------------------------------------------------- | +| README raiz | [Resumen del repositorio](../../../README.md) | +| Portal documental en ingles | [docs/README.md](../../../reference/indices/index.md) | +| Portal documental en espanol | [docs/README.es.md](../../../reference/indices/index.md) | +| Portal de arquitectura | [docs/architecture/index.es.md](../../../reference/architecture/index.md) | +| Portal de gobernanza | [docs/governance/index.es.md](../../../reference/gobernanza/index.md) | ## Vista General diff --git a/src/apps/ums.web-app/README.md b/src/apps/ums.web-app/README.md index 7132dc2c..5045cff0 100644 --- a/src/apps/ums.web-app/README.md +++ b/src/apps/ums.web-app/README.md @@ -6,13 +6,13 @@ UMS Web Console is the React 18 portal for the User Management System administra ## Quick Links -| Need | Open this | -| ---------------------------- | ----------------------------------------------------------------- | -| Root README | [Repository overview](../../../README.md) | -| English documentation portal | [docs/README.md](../../../docs/README.md) | -| Spanish documentation portal | [docs/README.es.md](../../../docs/README.es.md) | -| Architecture portal | [docs/architecture/index.md](../../../docs/architecture/index.md) | -| Governance portal | [docs/governance/index.md](../../../docs/governance/index.md) | +| Need | Open this | +| ---------------------------- | ---------------------------------------------------------------------- | +| Root README | [Repository overview](../../../README.md) | +| English documentation portal | [docs/README.md](../../../reference/indices/index.md) | +| Spanish documentation portal | [docs/README.es.md](../../../reference/indices/index.md) | +| Architecture portal | [docs/architecture/index.md](../../../reference/architecture/index.md) | +| Governance portal | [docs/governance/index.md](../../../reference/gobernanza/index.md) | ## At a Glance diff --git a/src/apps/ums.web-app/e2e/authorization.spec.ts b/src/apps/ums.web-app/e2e/authorization.spec.ts index 9357306c..fda9847f 100644 --- a/src/apps/ums.web-app/e2e/authorization.spec.ts +++ b/src/apps/ums.web-app/e2e/authorization.spec.ts @@ -12,18 +12,15 @@ test.describe('Authorization Flow', () => { }); test('should navigate to tenants after login', async () => { - // TODO: Implement with real auth flow - test.skip(); + test.skip(true, 'Pendiente: requiere el flujo de autenticación real contra el backend.'); }); test('should display authorization nav items', async () => { - // TODO: Verify Tenants, Users, Delegations appear in sidebar - test.skip(); + test.skip(true, 'Pendiente: verificar Tenants, Users y Delegations en la barra lateral.'); }); test('should require authentication for protected routes', async ({ page }) => { await page.goto('/tenants'); - // TODO: Verify redirect to login or 401 response - test.skip(); + test.skip(true, 'Pendiente: verificar la redirección a login o la respuesta 401.'); }); }); diff --git a/src/apps/ums.web-app/eslint.config.js b/src/apps/ums.web-app/eslint.config.js index 5d86bde3..4cd98fd9 100644 --- a/src/apps/ums.web-app/eslint.config.js +++ b/src/apps/ums.web-app/eslint.config.js @@ -5,11 +5,25 @@ import reactRefresh from 'eslint-plugin-react-refresh'; import tseslint from 'typescript-eslint'; import prettier from 'eslint-plugin-prettier'; import eslintConfigPrettier from 'eslint-config-prettier'; +// Análisis estático serverless para TS: las reglas de SonarSource como plugin de +// ESLint (sin servidor SonarQube ni token). Contraparte de SonarAnalyzer.CSharp +// en el backend .NET. Ver G-016. +import sonarjs from 'eslint-plugin-sonarjs'; +// SAST local orientado a SEGURIDAD para TS (contraparte de SonarAnalyzer.CSharp en el +// backend). Detecta patrones inseguros: `eval`, RegExp/child_process/fs con datos no +// confiables, `Math.random()` en contexto de seguridad, etc. Ejecución 100% local. +import security from 'eslint-plugin-security'; export default tseslint.config( { ignores: ['dist'] }, { - extends: [js.configs.recommended, ...tseslint.configs.recommended, eslintConfigPrettier], + extends: [ + js.configs.recommended, + ...tseslint.configs.recommended, + sonarjs.configs.recommended, + security.configs.recommended, + eslintConfigPrettier, + ], files: ['**/*.{ts,tsx}'], languageOptions: { ecmaVersion: 2020, @@ -26,32 +40,25 @@ export default tseslint.config( // fully flattened. TypeScript strict build covers this check reliably. '@typescript-eslint/no-unused-expressions': 'off', 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], - // ── Deuda de estilo heredada (visible como warning, no bloquea CI) ────── - // Se degradan a `warn` mientras se atacan de forma incremental. Las reglas de - // CORRECTITUD (react-hooks/*, no-fallthrough, no-case-declarations) siguen como - // error. Ver la política de lint del repo. - '@typescript-eslint/no-explicit-any': 'warn', - '@typescript-eslint/no-unused-vars': 'warn', - 'no-empty': 'warn', - 'no-useless-escape': 'warn', - // Reglas OPINADAS nuevas de eslint-plugin-react-hooks v7 (perf/patrón): visibles como - // warning mientras se atacan incrementalmente. La regla CRÍTICA `rules-of-hooks` sigue - // como error. Refactorizar estos patrones en código ya probado (1476 tests) se hace por - // separado para no arriesgar regresiones. - 'react-hooks/set-state-in-effect': 'warn', - 'react-hooks/refs': 'warn', - 'react-hooks/purity': 'warn', - 'react-hooks/immutability': 'warn', - 'react-hooks/static-components': 'warn', - 'react-hooks/preserve-manual-memoization': 'warn', // Production: only allow console.error (for error boundaries) - 'no-console': ['warn', { allow: ['error'] }], + 'no-console': ['error', { allow: ['error'] }], // Require explicit return types on exported functions for API boundaries '@typescript-eslint/explicit-function-return-type': 'off', // Allow non-null assertions in test files only '@typescript-eslint/no-non-null-assertion': 'warn', // Prettier formatting rules 'prettier/prettier': 'error', + // SEGURIDAD: se desactiva `detect-object-injection` — marca cualquier acceso + // `obj[variable]`, patrón ubicuo y casi siempre falso positivo (la propia doc del + // plugin lo advierte). Se conservan las reglas de alto valor: detect-eval-with- + // expression, detect-child-process, detect-non-literal-fs-filename, detect-non- + // literal-require, detect-unsafe-regex, detect-pseudoRandomBytes, etc. + 'security/detect-object-injection': 'off', + // Debatibles en un frontend (lado cliente): se reportan como warning (visibles, + // no bloquean) mientras se revisan. Los críticos (eval, child_process, + // non-literal-require/fs) siguen como error del preset → bloquean. + 'security/detect-possible-timing-attacks': 'warn', + 'security/detect-non-literal-regexp': 'warn', }, } ); diff --git a/src/apps/ums.web-app/index.html b/src/apps/ums.web-app/index.html index 30ee93de..19fc6cc9 100644 --- a/src/apps/ums.web-app/index.html +++ b/src/apps/ums.web-app/index.html @@ -2,7 +2,7 @@ - + - + }> diff --git a/src/apps/ums.web-app/src/application/audit/hooks/use-audit-records.test.tsx b/src/apps/ums.web-app/src/application/audit/hooks/use-audit-records.test.tsx new file mode 100644 index 00000000..0bfe7ac2 --- /dev/null +++ b/src/apps/ums.web-app/src/application/audit/hooks/use-audit-records.test.tsx @@ -0,0 +1,112 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import React from 'react'; +import { useGetAuditRecords } from './use-audit-records'; +import { auditRecordService } from '@infra/audit/services/audit-record.service'; + +vi.mock('@infra/audit/services/audit-record.service', () => ({ + auditRecordService: { + getAll: vi.fn(), + }, + default: { + getAll: vi.fn(), + }, +})); + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); +} + +const mockPage = { + items: [ + { + auditRecordId: '3fa85f64-5717-4562-b3fc-2c963f66afa6', + whoActed: '3fa85f64-5717-4562-b3fc-2c963f66afa7', + subjectType: 'UserAccount', + whenOccurred: '2024-01-01T00:00:00Z', + whatChanged: 'status changed', + eventType: 'UserSuspended', + auditResult: 'Success', + affectedEntityId: '3fa85f64-5717-4562-b3fc-2c963f66afa8', + affectedEntityType: 'UserAccount', + rootTenantId: '3fa85f64-5717-4562-b3fc-2c963f66afa9', + metadata: null, + }, + ], + page: 1, + pageSize: 20, + totalItems: 1, + totalPages: 1, +}; + +describe('useGetAuditRecords', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('devuelve la página de registros en estado de éxito', async () => { + vi.mocked(auditRecordService.getAll).mockResolvedValue(mockPage); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useGetAuditRecords({ page: 1, pageSize: 20 }), { + wrapper, + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data?.items[0].eventType).toBe('UserSuspended'); + expect(result.current.data?.totalItems).toBe(1); + expect(auditRecordService.getAll).toHaveBeenCalledWith({ page: 1, pageSize: 20 }); + }); + + it('propaga los parámetros de filtro al servicio', async () => { + vi.mocked(auditRecordService.getAll).mockResolvedValue(mockPage); + + const params = { + page: 2, + pageSize: 10, + eventType: 'RoleAssigned', + actorId: '3fa85f64-5717-4562-b3fc-2c963f66afa7', + }; + const wrapper = createWrapper(); + const { result } = renderHook(() => useGetAuditRecords(params), { wrapper }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(auditRecordService.getAll).toHaveBeenCalledWith(params); + }); + + it('no invoca al servicio cuando enabled es false', async () => { + vi.mocked(auditRecordService.getAll).mockResolvedValue(mockPage); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useGetAuditRecords(undefined, false), { wrapper }); + + expect(result.current.fetchStatus).toBe('idle'); + expect(result.current.data).toBeUndefined(); + expect(auditRecordService.getAll).not.toHaveBeenCalled(); + }); + + it('expone el estado de error cuando el servicio rechaza', async () => { + vi.mocked(auditRecordService.getAll).mockRejectedValue(new Error('boom')); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useGetAuditRecords(), { wrapper }); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error?.message).toBe('boom'); + }); +}); diff --git a/src/apps/ums.web-app/src/application/authorization/decorators/RequireAccess.ts b/src/apps/ums.web-app/src/application/authorization/decorators/RequireAccess.ts index e061a7cd..c40a6fdf 100644 --- a/src/apps/ums.web-app/src/application/authorization/decorators/RequireAccess.ts +++ b/src/apps/ums.web-app/src/application/authorization/decorators/RequireAccess.ts @@ -1,35 +1,17 @@ import { useAuthStore } from '@app/stores/auth.store'; import { AccessEffect } from '@domain/authorization/schemas/authorization-graph.schema'; +import { logger } from '@app/utils/logger'; +import { findGraphAction } from '@app/authorization/utils/graph-lookup'; /** * Helper para evaluar permisos usando el store actual. */ const checkOptionAccess = (menuCode: string, optionCode: string): boolean => { - const user = useAuthStore.getState().user; - const graph = user?.authorizationGraph; + const graph = useAuthStore.getState().user?.authorizationGraph; if (!graph) return false; - for (const mod of graph.menuAccess) { - for (const menu of mod.menus) { - if (menu.code === menuCode) { - for (const sub of menu.subMenus) { - const opt = sub.options.find(o => o.code === optionCode || o.actionCode === optionCode); - if (opt) { - return opt.effect === AccessEffect.Allow; - } - } - } - for (const sub of menu.subMenus) { - if (sub.code === menuCode) { - const opt = sub.options.find(o => o.code === optionCode || o.actionCode === optionCode); - if (opt) { - return opt.effect === AccessEffect.Allow; - } - } - } - } - } - return false; + const action = findGraphAction(graph.menuAccess, menuCode, optionCode); + return action?.effect === AccessEffect.Allow; }; /** @@ -38,7 +20,7 @@ const checkOptionAccess = (menuCode: string, optionCode: string): boolean => { * del nombre del método (ej. 'createUsuario' -> 'create'). */ export function RequireOption(menuCode: string, optionCode?: string) { - return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) { + return function (_target: unknown, propertyKey: string, descriptor: PropertyDescriptor) { const originalMethod = descriptor.value; const inferredOptionCode = optionCode || @@ -47,11 +29,11 @@ export function RequireOption(menuCode: string, optionCode?: string) { .toLowerCase() .split('-')[0]; - descriptor.value = function (...args: any[]) { + descriptor.value = function (this: unknown, ...args: unknown[]) { const hasAccess = checkOptionAccess(menuCode, inferredOptionCode); if (!hasAccess) { - console.warn( + logger.warn( `[Security] Acceso denegado a la opción '${inferredOptionCode}' en el menú '${menuCode}'. Ejecución abortada.` ); // Dependiendo del caso, se podría lanzar una excepción o simplemente retornar null diff --git a/src/apps/ums.web-app/src/application/authorization/decorators/require-permission.decorator.ts b/src/apps/ums.web-app/src/application/authorization/decorators/require-permission.decorator.ts index 2bbb11f5..c6866921 100644 --- a/src/apps/ums.web-app/src/application/authorization/decorators/require-permission.decorator.ts +++ b/src/apps/ums.web-app/src/application/authorization/decorators/require-permission.decorator.ts @@ -13,10 +13,10 @@ import { useAuthStore } from '@app/stores/auth.store'; * @param actionCode The code of the action (e.g. 'VIEW', 'MANAGE') */ export function RequirePermission(resourceCode: string, actionCode: string) { - return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) { + return function (_target: unknown, propertyKey: string, descriptor: PropertyDescriptor) { const originalMethod = descriptor.value; - descriptor.value = function (...args: any[]) { + descriptor.value = function (this: unknown, ...args: unknown[]) { // In a non-React-component context, we can read the store state directly from Zustand const state = useAuthStore.getState(); diff --git a/src/apps/ums.web-app/src/application/authorization/hooks/use-access-resolution.test.ts b/src/apps/ums.web-app/src/application/authorization/hooks/use-access-resolution.test.ts index abfe8793..2c3ecb02 100644 --- a/src/apps/ums.web-app/src/application/authorization/hooks/use-access-resolution.test.ts +++ b/src/apps/ums.web-app/src/application/authorization/hooks/use-access-resolution.test.ts @@ -7,21 +7,44 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; vi.mock('@app/stores/auth.store'); describe('useAccessResolution', () => { + // Forma REAL del contrato v2.0.0 (`@ums/sdk-contracts`, MenuModule.nodes): árbol recursivo de + // `NavigationNode` con `children` y `actions`. El fixture anterior usaba la forma retirada + // (menus -> subMenus -> options), la misma que leía el código, así que la prueba confirmaba el + // error en vez de detectarlo: contra un grafo real ambos daban `undefined`. const mockGraph = { menuAccess: [ { code: 'sys', status: 'Active', - menus: [ + nodes: [ { code: 'profiles', - subMenus: [ + kind: 'Menu', + actions: [], + children: [ { code: 'profiles-list', - options: [ - { code: 'view', actionCode: 'read', effect: AccessEffect.Allow }, - { code: 'create', actionCode: 'create', effect: AccessEffect.Deny }, - { code: 'delete', actionCode: 'delete', effect: AccessEffect.NotGranted }, + kind: 'SubMenu', + actions: [], + children: [ + { + code: 'view', + kind: 'Option', + actions: [{ actionCode: 'read', effect: AccessEffect.Allow }], + children: [], + }, + { + code: 'create', + kind: 'Option', + actions: [{ actionCode: 'create', effect: AccessEffect.Deny }], + children: [], + }, + { + code: 'delete', + kind: 'Option', + actions: [{ actionCode: 'delete', effect: AccessEffect.NotGranted }], + children: [], + }, ], }, ], @@ -36,7 +59,7 @@ describe('useAccessResolution', () => { }; beforeEach(() => { - vi.mocked(useAuthStore).mockImplementation((selector: any) => + vi.mocked(useAuthStore).mockImplementation((selector: (state: never) => unknown) => selector({ user: { authorizationGraph: mockGraph }, }) diff --git a/src/apps/ums.web-app/src/application/authorization/hooks/use-access-resolution.ts b/src/apps/ums.web-app/src/application/authorization/hooks/use-access-resolution.ts index 6df41e90..13291d3f 100644 --- a/src/apps/ums.web-app/src/application/authorization/hooks/use-access-resolution.ts +++ b/src/apps/ums.web-app/src/application/authorization/hooks/use-access-resolution.ts @@ -2,11 +2,8 @@ import { useAuthStore } from '@app/stores/auth.store'; import { AccessEffect, AuthorizationGraph, - GraphMenuModule, - GraphMenu, - GraphSubMenu, - GraphMenuOption, } from '@domain/authorization/schemas/authorization-graph.schema'; +import { findGraphAction, moduleHasNode } from '@app/authorization/utils/graph-lookup'; export const useAccessResolution = () => { const user = useAuthStore(state => state.user); @@ -40,11 +37,8 @@ export const useAccessResolution = () => { const hasMenuAccess = (moduleCode: string, menuCode: string): boolean => { if (isInternalAdmin) return true; if (!graph) return false; - const mod = graph.menuAccess.find(m => m.code === moduleCode); - if (!mod) return false; - - const menu = mod.menus.find(m => m.code === menuCode); - return !!menu; + // El árbol es recursivo (contrato v2.0.0): un menú puede estar a cualquier profundidad. + return moduleHasNode(graph.menuAccess, moduleCode, menuCode); }; /** @@ -55,32 +49,8 @@ export const useAccessResolution = () => { if (isInternalAdmin) return true; if (!graph) return false; - // Search through all modules, menus, and submenus - for (const mod of graph.menuAccess) { - for (const menu of mod.menus) { - if (menu.code === menuCode) { - // If we find the menu, search its submenus for the option - for (const sub of menu.subMenus) { - const opt = sub.options.find(o => o.code === optionCode || o.actionCode === optionCode); - if (opt) { - return evaluateEffect(opt.effect); - } - } - } - - // Also check submenus to see if menuCode refers to a submenu - for (const sub of menu.subMenus) { - if (sub.code === menuCode) { - const opt = sub.options.find(o => o.code === optionCode || o.actionCode === optionCode); - if (opt) { - return evaluateEffect(opt.effect); - } - } - } - } - } - - return false; + const action = findGraphAction(graph.menuAccess, menuCode, optionCode); + return action ? evaluateEffect(action.effect) : false; }; /** diff --git a/src/apps/ums.web-app/src/application/authorization/hooks/use-graph-navigation.test.ts b/src/apps/ums.web-app/src/application/authorization/hooks/use-graph-navigation.test.ts new file mode 100644 index 00000000..3763655b --- /dev/null +++ b/src/apps/ums.web-app/src/application/authorization/hooks/use-graph-navigation.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useGraphNavigation, itemsNavegables } from './use-graph-navigation'; + +let menuAccess: unknown; + +vi.mock('@app/stores/auth.store', () => ({ + useAuthStore: (selector: (s: unknown) => unknown) => + selector({ user: { authorizationGraph: { menuAccess } } }), +})); + +const nodo = ( + code: string, + kind: string, + extra: Partial<{ + icon: string | null; + route: string | null; + actions: Array<{ actionCode: string; effect: string; source: string }>; + children: unknown[]; + }> = {} +) => ({ + code, + value: `Etiqueta ${code}`, + kind, + sortOrder: 1, + icon: extra.icon ?? null, + route: extra.route ?? null, + actions: extra.actions ?? [], + children: extra.children ?? [], +}); + +describe('useGraphNavigation', () => { + beforeEach(() => { + menuAccess = undefined; + }); + + it('devuelve vacío sin grafo', () => { + const { result } = renderHook(() => useGraphNavigation()); + expect(result.current).toEqual([]); + }); + + it('conserva la jerarquía y el icono y la ruta de cada nodo', () => { + menuAccess = [ + { + code: 'PORT', + value: 'Portafolio', + sortOrder: 2, + status: 'Active', + icon: 'layout-dashboard', + nodes: [ + nodo('DASHBOARDS', 'Menu', { + icon: 'layout-dashboard', + children: [ + nodo('DASH_EJEC', 'SubMenu', { + children: [ + nodo('PORT_DASHBOARD', 'Option', { + route: '/portafolio', + actions: [{ actionCode: 'VIEW', effect: 'Allow', source: 'Template' }], + }), + ], + }), + ], + }), + ], + }, + ]; + + const { result } = renderHook(() => useGraphNavigation()); + const menu = result.current[0].items[0]; + + // Desde el contrato 2.3.0 el módulo trae su propio icono, no solo sus nodos. + expect(result.current[0].icon).toBe('layout-dashboard'); + expect(menu.icon).toBe('layout-dashboard'); + // Un menú agrupa: no navega. + expect(menu.route).toBeNull(); + + const hoja = menu.children[0].children[0]; + expect(hoja.route).toBe('/portafolio'); + expect(hoja.allowed.has('VIEW')).toBe(true); + }); + + it('separa lo concedido de lo denegado explícitamente', () => { + menuAccess = [ + { + code: 'PRD', + value: 'Iniciativas', + sortOrder: 1, + status: 'Active', + nodes: [ + nodo('ART_CERT', 'Option', { + route: '/prds/artefactos/certificacion', + actions: [ + { actionCode: 'VIEW', effect: 'Allow', source: 'Template' }, + { actionCode: 'CERTIFY', effect: 'Deny', source: 'Override' }, + ], + }), + ], + }, + ]; + + const { result } = renderHook(() => useGraphNavigation()); + const item = result.current[0].items[0]; + + // `Deny` no es ausencia: la interfaz debe poder bloquear en vez de ocultar. + expect(item.allowed.has('VIEW')).toBe(true); + expect(item.denied.has('CERTIFY')).toBe(true); + expect(item.allowed.has('CERTIFY')).toBe(false); + }); + + it('ordena los módulos y los nodos por su sortOrder', () => { + menuAccess = [ + { code: 'B', value: 'B', sortOrder: 2, status: 'Active', icon: null, nodes: [] }, + { code: 'A', value: 'A', sortOrder: 1, status: 'Active', icon: null, nodes: [] }, + ]; + + const { result } = renderHook(() => useGraphNavigation()); + expect(result.current.map(m => m.code)).toEqual(['A', 'B']); + // Un módulo sin icono configurado llega como null, no como un icono inventado. + expect(result.current[0].icon).toBeNull(); + }); +}); + +describe('itemsNavegables', () => { + it('devuelve solo lo que tiene ruta, a cualquier profundidad', () => { + menuAccess = [ + { + code: 'PRD', + value: 'Iniciativas', + sortOrder: 1, + status: 'Active', + nodes: [ + nodo('MENU', 'Menu', { + children: [ + nodo('SUB', 'SubMenu', { + children: [nodo('HOJA', 'Option', { route: '/prds' })], + }), + ], + }), + ], + }, + ]; + + const { result } = renderHook(() => useGraphNavigation()); + const navegables = itemsNavegables(result.current); + + // Menú y submenú agrupan; solo la hoja navega. + expect(navegables.map(i => i.code)).toEqual(['HOJA']); + }); +}); diff --git a/src/apps/ums.web-app/src/application/authorization/hooks/use-graph-navigation.ts b/src/apps/ums.web-app/src/application/authorization/hooks/use-graph-navigation.ts new file mode 100644 index 00000000..cd8275e3 --- /dev/null +++ b/src/apps/ums.web-app/src/application/authorization/hooks/use-graph-navigation.ts @@ -0,0 +1,105 @@ +import { useMemo } from 'react'; +import { useAuthStore } from '@app/stores/auth.store'; +import type { GraphNavigationNode } from '@domain/authorization/schemas/authorization-graph.schema'; + +/** + * La navegación que publica el sistema, lista para pintar. + * + * Desde el contrato v2.2.0 cada nodo trae `icon` y `route` —y desde v2.3.0 el módulo trae el + * suyo—, que era lo que faltaba para que el + * cliente construyera su menú sin conocer de antemano el sistema: con solo código y etiqueta sabía + * qué texto poner, pero no qué icono ni a dónde llevar al usuario. + * + * Este hook NO decide el aspecto: aplana el árbol conservando la jerarquía y resuelve tres cosas + * que, si cada pantalla las resolviera por su cuenta, acabarían divergiendo: + * + * 1. **Qué es navegable.** Un nodo con `route` lleva a algún sitio; uno sin ella solo agrupa. + * 2. **Qué acciones concede.** Se exponen como conjunto para que un `disabled` sea una consulta + * y no un recorrido. + * 3. **Qué está bloqueado explícitamente.** `Deny` no es lo mismo que ausencia, y la interfaz + * debería poder distinguir «no puedes» de «no existe». + * + * El árbol viaja ya podado: lo que llega es lo alcanzable. No hay que filtrar por permiso otra vez. + */ +export interface NavigationItem { + code: string; + label: string; + kind: string; + icon: string | null; + route: string | null; + /** Acciones concedidas sobre el nodo. */ + allowed: ReadonlySet; + /** Acciones denegadas explícitamente: bloquear, no ocultar. */ + denied: ReadonlySet; + children: NavigationItem[]; +} + +export interface NavigationModule { + code: string; + label: string; + sortOrder: number; + /** Icono del módulo publicado por el sistema; null si no se configuró. */ + icon: string | null; + items: NavigationItem[]; +} + +const VACIO: NavigationModule[] = []; + +function mapear(nodo: GraphNavigationNode): NavigationItem { + const allowed = new Set(); + const denied = new Set(); + + for (const accion of nodo.actions ?? []) { + if (accion.effect === 'Allow') allowed.add(accion.actionCode); + else if (accion.effect === 'Deny') denied.add(accion.actionCode); + } + + return { + code: nodo.code, + label: nodo.value, + kind: nodo.kind, + icon: nodo.icon, + route: nodo.route, + allowed, + denied, + children: (nodo.children ?? []).map(mapear), + }; +} + +export function useGraphNavigation(): NavigationModule[] { + const menuAccess = useAuthStore(state => state.user?.authorizationGraph?.menuAccess); + + return useMemo(() => { + if (!menuAccess?.length) return VACIO; + + return [...menuAccess] + .sort((a, b) => a.sortOrder - b.sortOrder) + .map(modulo => ({ + code: modulo.code, + label: modulo.value, + sortOrder: modulo.sortOrder, + icon: modulo.icon ?? null, + items: [...(modulo.nodes ?? [])].sort((a, b) => a.sortOrder - b.sortOrder).map(mapear), + })); + }, [menuAccess]); +} + +/** + * Aplana la navegación en las entradas que REALMENTE navegan: las que tienen ruta. + * + * Útil para resolver qué elemento marcar como activo a partir de la ruta actual, sin que cada + * pantalla reimplemente el recorrido del árbol. + */ +export function itemsNavegables(modulos: NavigationModule[]): NavigationItem[] { + const salida: NavigationItem[] = []; + + const visitar = (items: NavigationItem[]) => { + for (const item of items) { + if (item.route) salida.push(item); + visitar(item.children); + } + }; + + modulos.forEach(m => visitar(m.items)); + return salida; +} diff --git a/src/apps/ums.web-app/src/application/authorization/hooks/use-permission-template-dashboard.test.ts b/src/apps/ums.web-app/src/application/authorization/hooks/use-permission-template-dashboard.test.ts index 7da625e6..a0b429c7 100644 --- a/src/apps/ums.web-app/src/application/authorization/hooks/use-permission-template-dashboard.test.ts +++ b/src/apps/ums.web-app/src/application/authorization/hooks/use-permission-template-dashboard.test.ts @@ -22,13 +22,13 @@ describe('usePermissionTemplateDashboard', () => { data: { items: mockTemplates, page: 1, pageSize: 20, totalItems: 2, totalPages: 1 }, isLoading: false, error: null, - } as any); + } as unknown as ReturnType); vi.mocked(usePermissionTemplateModule.useGetPermissionTemplate).mockReturnValue({ data: mockTemplates[0], isLoading: false, error: null, - } as any); + } as unknown as ReturnType); vi.mocked(useQueryStateModule.useQueryState).mockReturnValue({ searchCriteria: 'version', @@ -45,7 +45,7 @@ describe('usePermissionTemplateDashboard', () => { appliedQuery: { criteria: 'version', term: '' }, handleQuerySubmit: vi.fn(), handleResetQuery: vi.fn(), - } as any); + } as unknown as ReturnType); vi.mocked(usePaginationStateModule.usePaginationState).mockReturnValue({ page: 1, @@ -55,7 +55,7 @@ describe('usePermissionTemplateDashboard', () => { startIndex: 0, handlePageChange: vi.fn(), handlePageSizeChange: vi.fn(), - } as any); + } as unknown as ReturnType); }); it('returns initial state with empty selectedId', () => { @@ -189,7 +189,7 @@ describe('usePermissionTemplateDashboard', () => { data: undefined, isLoading: false, error: null, - } as any); + } as unknown as ReturnType); const { result } = renderHook(() => usePermissionTemplateDashboard()); expect(result.current.knownTemplates).toEqual([]); @@ -200,7 +200,7 @@ describe('usePermissionTemplateDashboard', () => { data: undefined, isLoading: false, error: null, - } as any); + } as unknown as ReturnType); const { result } = renderHook(() => usePermissionTemplateDashboard()); expect(result.current.totalItems).toBe(0); diff --git a/src/apps/ums.web-app/src/application/authorization/hooks/use-permission-template-dashboard.ts b/src/apps/ums.web-app/src/application/authorization/hooks/use-permission-template-dashboard.ts index a21a61ba..d0d09f17 100644 --- a/src/apps/ums.web-app/src/application/authorization/hooks/use-permission-template-dashboard.ts +++ b/src/apps/ums.web-app/src/application/authorization/hooks/use-permission-template-dashboard.ts @@ -17,6 +17,8 @@ export function usePermissionTemplateDashboard(tenantId?: string) { criteria: 'role', filter: 'all', sortBy: 'suite', + // Patrón estándar: la lista carga al entrar (no exige aplicar un filtro primero). + appliedFilter: true, }); const paginationState = usePaginationState({ diff --git a/src/apps/ums.web-app/src/application/authorization/hooks/use-permission-template.test.tsx b/src/apps/ums.web-app/src/application/authorization/hooks/use-permission-template.test.tsx index c584aed7..46c362ea 100644 --- a/src/apps/ums.web-app/src/application/authorization/hooks/use-permission-template.test.tsx +++ b/src/apps/ums.web-app/src/application/authorization/hooks/use-permission-template.test.tsx @@ -10,8 +10,10 @@ import { useDeprecatePermissionTemplate, useAddTemplateItem, useRemoveTemplateItem, + useApplyTemplateItemEffect, } from './use-permission-template'; import permissionTemplateService from '@infra/authorization/services/permission-template.service'; +import type { PermissionTemplateItem } from '@domain/authorization/models/permission-template.model'; vi.mock('@infra/authorization/services/permission-template.service', () => ({ permissionTemplateService: { @@ -22,6 +24,8 @@ vi.mock('@infra/authorization/services/permission-template.service', () => ({ deprecate: vi.fn(), addItem: vi.fn(), removeItem: vi.fn(), + setItemEffect: vi.fn(), + activateItem: vi.fn(), }, default: { getAll: vi.fn(), @@ -31,6 +35,8 @@ vi.mock('@infra/authorization/services/permission-template.service', () => ({ deprecate: vi.fn(), addItem: vi.fn(), removeItem: vi.fn(), + setItemEffect: vi.fn(), + activateItem: vi.fn(), }, })); @@ -105,7 +111,7 @@ describe('use-permission-template hooks', () => { it('useGetPermissionTemplate returns null on 404', async () => { const error = new Error('Not Found'); - (error as any).response = { status: 404 }; + (error as Error & { response?: { status: number } }).response = { status: 404 }; vi.mocked(permissionTemplateService.getById).mockRejectedValue(error); const wrapper = createWrapper(); @@ -191,7 +197,9 @@ describe('use-permission-template hooks', () => { expect(permissionTemplateService.addItem).toHaveBeenCalledWith('t1', expect.any(Object)); }); - it('useRemoveTemplateItem calls service successfully', async () => { + // El DELETE conserva ruta y contrato, pero desde ADR-0164 retira la fila en vez de borrarla. + // El hook sigue existiendo para dar de baja la concesión entera; ya no para expresar «Neutral». + it('useRemoveTemplateItem retira el ítem contra el mismo endpoint', async () => { vi.mocked(permissionTemplateService.removeItem).mockResolvedValue(); const wrapper = createWrapper(); @@ -208,3 +216,146 @@ describe('use-permission-template hooks', () => { expect(permissionTemplateService.removeItem).toHaveBeenCalledWith('t1', 'item1'); }); }); + +// ─── ADR-0164: Neutral no borra, y lo retirado se reactiva ─────────────────── + +describe('useApplyTemplateItemEffect', () => { + const target = { + targetType: 'SystemSuite' as const, + targetId: 'suite-1', + actionId: 'action-1', + }; + + const item = (overrides: Partial): PermissionTemplateItem => ({ + itemId: 'item-1', + targetType: 'SystemSuite', + targetId: 'suite-1', + targetName: 'Suite', + actionId: 'action-1', + actionName: 'Leer', + isAllowed: false, + isDenied: false, + isActive: true, + ...overrides, + }); + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(permissionTemplateService.addItem).mockResolvedValue(); + vi.mocked(permissionTemplateService.removeItem).mockResolvedValue(); + vi.mocked(permissionTemplateService.setItemEffect).mockResolvedValue(); + vi.mocked(permissionTemplateService.activateItem).mockResolvedValue(); + }); + + it('pone Neutral con el verbo de efecto y no borra el ítem', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useApplyTemplateItemEffect('t1'), { wrapper }); + + await act(async () => { + await result.current.applyEffect({ + effect: 'Neutral', + item: item({ isAllowed: true }), + target, + }); + }); + + expect(permissionTemplateService.setItemEffect).toHaveBeenCalledWith('t1', 'item-1', 'Neutral'); + expect(permissionTemplateService.removeItem).not.toHaveBeenCalled(); + }); + + it('reactiva el ítem retirado en vez de volver a darlo de alta (evita el 409)', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useApplyTemplateItemEffect('t1'), { wrapper }); + + await act(async () => { + await result.current.applyEffect({ + effect: 'Allow', + item: item({ isActive: false, isAllowed: true }), + target, + }); + }); + + expect(permissionTemplateService.activateItem).toHaveBeenCalledWith('t1', 'item-1'); + expect(permissionTemplateService.addItem).not.toHaveBeenCalled(); + // El efecto guardado ya era Allow: reactivar basta, no hace falta reescribirlo. + expect(permissionTemplateService.setItemEffect).not.toHaveBeenCalled(); + }); + + it('al reactivar corrige el efecto si el guardado no es el pedido', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useApplyTemplateItemEffect('t1'), { wrapper }); + + await act(async () => { + await result.current.applyEffect({ + effect: 'Deny', + item: item({ isActive: false, isAllowed: true }), + target, + }); + }); + + expect(permissionTemplateService.activateItem).toHaveBeenCalledWith('t1', 'item-1'); + expect(permissionTemplateService.setItemEffect).toHaveBeenCalledWith('t1', 'item-1', 'Deny'); + }); + + it('no toca nada si se pide Neutral sobre un ítem ya retirado', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useApplyTemplateItemEffect('t1'), { wrapper }); + + let wrote = true; + await act(async () => { + wrote = await result.current.applyEffect({ + effect: 'Neutral', + item: item({ isActive: false, isAllowed: true }), + target, + }); + }); + + expect(wrote).toBe(false); + expect(permissionTemplateService.setItemEffect).not.toHaveBeenCalled(); + expect(permissionTemplateService.activateItem).not.toHaveBeenCalled(); + expect(permissionTemplateService.removeItem).not.toHaveBeenCalled(); + }); + + it('crea la fila cuando la casilla nunca tuvo ítem', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useApplyTemplateItemEffect('t1'), { wrapper }); + + await act(async () => { + await result.current.applyEffect({ effect: 'Allow', item: undefined, target }); + }); + + expect(permissionTemplateService.addItem).toHaveBeenCalledWith('t1', { + ...target, + isAllowed: true, + isDenied: false, + }); + }); + + it('no crea fila para pedir Neutral sobre una casilla sin ítem', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useApplyTemplateItemEffect('t1'), { wrapper }); + + await act(async () => { + await result.current.applyEffect({ effect: 'Neutral', item: undefined, target }); + }); + + expect(permissionTemplateService.addItem).not.toHaveBeenCalled(); + }); + + it('no reescribe el efecto que ya está vigente', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useApplyTemplateItemEffect('t1'), { wrapper }); + + let wrote = true; + await act(async () => { + wrote = await result.current.applyEffect({ + effect: 'Allow', + item: item({ isAllowed: true }), + target, + }); + }); + + expect(wrote).toBe(false); + expect(permissionTemplateService.setItemEffect).not.toHaveBeenCalled(); + }); +}); diff --git a/src/apps/ums.web-app/src/application/authorization/hooks/use-permission-template.ts b/src/apps/ums.web-app/src/application/authorization/hooks/use-permission-template.ts index 15869fc4..d4ccbfb7 100644 --- a/src/apps/ums.web-app/src/application/authorization/hooks/use-permission-template.ts +++ b/src/apps/ums.web-app/src/application/authorization/hooks/use-permission-template.ts @@ -1,11 +1,17 @@ +import { useCallback } from 'react'; import { useQuery } from '@tanstack/react-query'; import permissionTemplateService from '@infra/authorization/services/permission-template.service'; import { useNotifiedMutation } from '@app/hooks/use-notified-mutation'; import { type CreatePermissionTemplatePayload, type AddTemplateItemPayload, + type ExclusiveArcTarget, + type PermissionEffect, + type PermissionTemplateItem, type PermissionTemplatePage, type PermissionTemplateDetail, + isRetiredItem, + itemStoredEffect, } from '@domain/authorization/models/permission-template.model'; import { getHttpStatus, getRetryOptions } from '@app/utils/error-utils'; import { CONTEXT_QUERY_CONFIG } from '@app/shared/config/query.config'; @@ -142,18 +148,24 @@ export const useAddTemplateItem = (templateId: string) => }), }); +/** + * Retira el ítem: el DELETE conserva ruta y contrato pero ya es el verbo lógico (ADR-0164), así que + * la fila sobrevive con `isActive: false` y sigue ocupando su clave (objetivo + acción). No sirve + * para expresar «Neutral» —para eso está `useApplyTemplateItemEffect`— sino para dar de baja la + * concesión entera. + */ export const useRemoveTemplateItem = (templateId: string) => useNotifiedMutation({ mutationFn: (itemId: string) => permissionTemplateService.removeItem(templateId, itemId), invalidateKeys: [['permission-templates', templateId]], successNotif: () => ({ - title: 'Permiso Eliminado', - message: 'El ítem fue removido de la plantilla.', + title: 'Permiso Retirado', + message: 'El ítem quedó retirado de la plantilla y ya no concede nada.', type: 'warning' as const, }), errorNotif: () => ({ - title: 'Error al Eliminar Permiso', - message: 'No se pudo remover el ítem de permiso.', + title: 'Error al Retirar Permiso', + message: 'No se pudo retirar el ítem de permiso.', }), }); @@ -191,3 +203,75 @@ export const useDeactivateTemplateItem = (templateId: string) => }), errorNotif: () => ({ title: 'Error al Desactivar', message: 'No se pudo desactivar el ítem.' }), }); + +// ─── Aplicación de efecto sobre una casilla (ADR-0164) ──────────────────────── + +/** Clave de la concesión: a qué objeto y con qué acción se refiere la casilla que se edita. */ +export interface TemplateItemTarget { + targetType: ExclusiveArcTarget; + targetId: string; + actionId: string; +} + +export interface ApplyTemplateItemEffectArgs { + /** Efecto que pide el usuario en la pantalla. */ + effect: PermissionEffect; + /** Fila existente para esa clave, vigente o retirada; `undefined` si nunca se creó. */ + item?: PermissionTemplateItem | null; + /** Datos del alta, usados solo cuando hay que crear la fila. */ + target: TemplateItemTarget; +} + +/** + * Traduce «quiero Allow/Deny/Neutral en esta casilla» a los verbos del backend, en un único sitio. + * + * Existe porque desde ADR-0164 la aritmética dejó de ser obvia: + * - `Neutral` NO es borrar. El ítem sobrevive; se le fija el efecto Neutral (PUT .../effect). + * - Sobre una fila retirada no se puede volver a hacer alta: la clave (objetivo, acción) sigue + * ocupada y el backend responde 409. Se reactiva (POST .../activate) y, si el efecto guardado + * no coincide con el pedido, se corrige. + * - Si el efecto pedido ya es el vigente no se escribe nada, para no emitir un toast que mienta. + * + * Devuelve `true` si hubo escritura, para que el llamador sepa si tiene algo que anunciar. + */ +export const useApplyTemplateItemEffect = (templateId: string) => { + const addItem = useAddTemplateItem(templateId); + const setEffect = useSetTemplateItemEffect(templateId); + const activateItem = useActivateTemplateItem(templateId); + + const applyEffect = useCallback( + async ({ effect, item, target }: ApplyTemplateItemEffectArgs): Promise => { + if (!item) { + // No hay fila: solo se crea si el usuario pide un efecto real. «Neutral» es la ausencia + // de opinión, y para eso basta con no tener fila. + if (effect === 'Neutral') return false; + await addItem.mutateAsync({ + ...target, + isAllowed: effect === 'Allow', + isDenied: effect === 'Deny', + }); + return true; + } + + if (isRetiredItem(item)) { + // Una fila retirada ya no concede nada: pedirle Neutral no cambiaría el estado efectivo. + if (effect === 'Neutral') return false; + await activateItem.mutateAsync(item.itemId); + if (itemStoredEffect(item) !== effect) { + await setEffect.mutateAsync({ itemId: item.itemId, effect }); + } + return true; + } + + if (itemStoredEffect(item) === effect) return false; + await setEffect.mutateAsync({ itemId: item.itemId, effect }); + return true; + }, + [addItem, setEffect, activateItem] + ); + + return { + applyEffect, + isPending: addItem.isPending || setEffect.isPending || activateItem.isPending, + }; +}; diff --git a/src/apps/ums.web-app/src/application/authorization/hooks/use-profile.test.tsx b/src/apps/ums.web-app/src/application/authorization/hooks/use-profile.test.tsx index 11b5339d..9edacd96 100644 --- a/src/apps/ums.web-app/src/application/authorization/hooks/use-profile.test.tsx +++ b/src/apps/ums.web-app/src/application/authorization/hooks/use-profile.test.tsx @@ -99,7 +99,7 @@ describe('use-profile hooks', () => { it('useGetProfile returns null on 404', async () => { const error = new Error('Not Found'); - (error as any).response = { status: 404 }; + (error as Error & { response?: { status: number } }).response = { status: 404 }; vi.mocked(profileService.getById).mockRejectedValue(error); const wrapper = createWrapper(); diff --git a/src/apps/ums.web-app/src/application/authorization/hooks/use-role.test.ts b/src/apps/ums.web-app/src/application/authorization/hooks/use-role.test.ts index 69e537db..8ce4098f 100644 --- a/src/apps/ums.web-app/src/application/authorization/hooks/use-role.test.ts +++ b/src/apps/ums.web-app/src/application/authorization/hooks/use-role.test.ts @@ -26,7 +26,7 @@ describe('use-role hooks', () => { data: [], isLoading: false, error: null, - } as any); + } as unknown as ReturnType); vi.mocked(useI18nModule.useI18n).mockReturnValue({ notifRoleCreated: 'Role Created', @@ -41,7 +41,7 @@ describe('use-role hooks', () => { notifRoleStatusChangedMsg: 'Role status changed', notifRoleStatusFailed: 'Status Failed', notifRoleStatusFailedMsg: 'Failed to change status', - } as any); + } as unknown as ReturnType); }); describe('useRolesBySystemSuite', () => { diff --git a/src/apps/ums.web-app/src/application/authorization/hooks/use-system-settings.ts b/src/apps/ums.web-app/src/application/authorization/hooks/use-system-settings.ts new file mode 100644 index 00000000..c344edfa --- /dev/null +++ b/src/apps/ums.web-app/src/application/authorization/hooks/use-system-settings.ts @@ -0,0 +1,86 @@ +import { useMemo } from 'react'; +import { useAuthStore } from '@app/stores/auth.store'; + +/** + * Ajustes del sistema que el grafo publica para inicializar la aplicación (G-178). + * + * El servidor los entrega agrupados por espacio de nombres —`settings.brand.logo_url`, + * `settings.theme.primary`, `settings.ui.home_route`…— y solo incluye los marcados como visibles. + * + * Este hook NO valida ni completa lo que falte con datos inventados: devuelve `undefined` y deja + * que cada consumidor decida su respaldo. Un sistema sin branding configurado debe verse como el + * producto por defecto, no como un producto a medio pintar. + */ +export interface SystemSettings { + brand: { + displayName?: string; + shortName?: string; + /** Descriptor bajo el nombre comercial; sustituye al subtítulo del producto. */ + tagline?: string; + logoUrl?: string; + iconUrl?: string; + }; + theme: { + primary?: string; + accent?: string; + mode?: 'light' | 'dark' | 'system'; + }; + ui: { + layout?: string; + homeRoute?: string; + density?: string; + }; + locale: { + language?: string; + timezone?: string; + currency?: string; + }; + /** Acceso crudo para espacios de nombres que este hook aún no tipa. */ + raw: Record>; +} + +const VACIO: Record> = {}; + +export function useSystemSettings(): SystemSettings { + const settings = useAuthStore( + state => + (state.user?.authorizationGraph?.settings as Record>) ?? VACIO + ); + + return useMemo(() => { + const grupo = (nombre: string) => settings[nombre] ?? {}; + const brand = grupo('brand'); + const theme = grupo('theme'); + const ui = grupo('ui'); + const locale = grupo('locale'); + + const modo = theme.mode; + + return { + brand: { + displayName: brand.display_name, + shortName: brand.short_name, + tagline: brand.tagline, + logoUrl: brand.logo_url, + iconUrl: brand.icon_url, + }, + theme: { + primary: theme.primary, + accent: theme.accent, + // Cualquier otro valor se ignora: un modo desconocido no debe dejar la interfaz a medias. + mode: modo === 'light' || modo === 'dark' || modo === 'system' ? modo : undefined, + }, + ui: { + layout: ui.layout, + homeRoute: ui.home_route, + density: ui.density, + }, + locale: { + language: locale.language, + timezone: locale.timezone, + currency: locale.currency, + }, + raw: settings, + }; + }, [settings]); +} diff --git a/src/apps/ums.web-app/src/application/authorization/hooks/use-system-suite-dashboard.test.ts b/src/apps/ums.web-app/src/application/authorization/hooks/use-system-suite-dashboard.test.ts index 4a76c356..175d4773 100644 --- a/src/apps/ums.web-app/src/application/authorization/hooks/use-system-suite-dashboard.test.ts +++ b/src/apps/ums.web-app/src/application/authorization/hooks/use-system-suite-dashboard.test.ts @@ -27,7 +27,7 @@ describe('useSystemSuiteDashboard', () => { data: { items: mockSystemSuites, page: 1, pageSize: 20, totalItems: 2, totalPages: 1 }, isLoading: false, error: null, - } as any); + } as unknown as ReturnType); vi.mocked(useLocalOverridesModule.useLocalOverrides).mockReturnValue({ items: mockSystemSuites, @@ -55,7 +55,7 @@ describe('useSystemSuiteDashboard', () => { appliedQuery: { criteria: 'name', term: '' }, handleQuerySubmit: vi.fn(), handleResetQuery: vi.fn(), - } as any); + } as unknown as ReturnType); vi.mocked(usePaginationStateModule.usePaginationState).mockReturnValue({ page: 1, @@ -65,7 +65,7 @@ describe('useSystemSuiteDashboard', () => { startIndex: 0, handlePageChange: vi.fn(), handlePageSizeChange: vi.fn(), - } as any); + } as unknown as ReturnType); }); it('auto-selects first suite when data loads and no selection exists', () => { @@ -133,9 +133,13 @@ describe('useSystemSuiteDashboard', () => { startIndex: 0, handlePageChange: vi.fn(), handlePageSizeChange: vi.fn(), - } as any); + } as unknown as ReturnType); - renderHook(() => useSystemSuiteDashboard()); + const { result } = renderHook(() => useSystemSuiteDashboard()); + + // El nombre del test prometía esta comprobación y no la hacía: se renderizaba el hook y no + // se afirmaba nada. La suite auto-seleccionada es la primera de la lista sembrada. + expect(result.current.selectedId).toBe(mockSystemSuites[0].systemSuiteId); }); it('handleSelectSystemSuite selects a suite when not editing', () => { @@ -231,7 +235,7 @@ describe('useSystemSuiteDashboard', () => { startIndex: 0, handlePageChange: vi.fn(), handlePageSizeChange: vi.fn(), - } as any); + } as unknown as ReturnType); vi.mocked(useQueryStateModule.useQueryState).mockReturnValue({ searchCriteria: 'name', @@ -248,7 +252,7 @@ describe('useSystemSuiteDashboard', () => { appliedQuery: { criteria: 'name', term: '' }, handleQuerySubmit: vi.fn(), handleResetQuery, - } as any); + } as unknown as ReturnType); const { result } = renderHook(() => useSystemSuiteDashboard()); diff --git a/src/apps/ums.web-app/src/application/authorization/hooks/use-system-suite-dashboard.ts b/src/apps/ums.web-app/src/application/authorization/hooks/use-system-suite-dashboard.ts index 736dcd6f..701b6a63 100644 --- a/src/apps/ums.web-app/src/application/authorization/hooks/use-system-suite-dashboard.ts +++ b/src/apps/ums.web-app/src/application/authorization/hooks/use-system-suite-dashboard.ts @@ -1,3 +1,6 @@ +/* eslint-disable react-hooks/set-state-in-effect -- Selección inicial: el primer elemento solo se conoce tras la respuesta del servidor, así que + no hay render que pueda derivarlo. + Patrón intencional del código heredado; la regla sigue activa en el resto del repo. */ import React, { useState, useEffect, useCallback } from 'react'; import { useGetAllSystemSuites } from '@app/authorization/hooks/use-system-suite'; import { useLocalOverrides } from '@app/hooks/use-local-overrides'; diff --git a/src/apps/ums.web-app/src/application/authorization/hooks/use-system-suite.test.tsx b/src/apps/ums.web-app/src/application/authorization/hooks/use-system-suite.test.tsx index a23ef92f..cdad7144 100644 --- a/src/apps/ums.web-app/src/application/authorization/hooks/use-system-suite.test.tsx +++ b/src/apps/ums.web-app/src/application/authorization/hooks/use-system-suite.test.tsx @@ -11,12 +11,13 @@ import { useRemoveModule, useActivateModule, useDeactivateModule, - useAddMenu, - useRemoveMenu, - useAddSubMenu, - useRemoveSubMenu, - useAddOption, - useRemoveOption, + useAddNode, + useUpdateNode, + useRemoveNode, + useSetNodeStatus, + useLinkNodeAction, + useUnlinkNodeAction, + useSetNodeMetadata, useRegisterAction, useRemoveAction, useAddDomainResource, @@ -34,12 +35,13 @@ vi.mock('@infra/authorization/services/system-suite.service', () => ({ removeModule: vi.fn(), activateModule: vi.fn(), deactivateModule: vi.fn(), - addMenu: vi.fn(), - removeMenu: vi.fn(), - addSubMenu: vi.fn(), - removeSubMenu: vi.fn(), - addOption: vi.fn(), - removeOption: vi.fn(), + addNode: vi.fn(), + updateNode: vi.fn(), + removeNode: vi.fn(), + setNodeStatus: vi.fn(), + linkNodeAction: vi.fn(), + unlinkNodeAction: vi.fn(), + setNodeMetadata: vi.fn(), registerAction: vi.fn(), removeAction: vi.fn(), addDomainResource: vi.fn(), @@ -54,12 +56,13 @@ vi.mock('@infra/authorization/services/system-suite.service', () => ({ removeModule: vi.fn(), activateModule: vi.fn(), deactivateModule: vi.fn(), - addMenu: vi.fn(), - removeMenu: vi.fn(), - addSubMenu: vi.fn(), - removeSubMenu: vi.fn(), - addOption: vi.fn(), - removeOption: vi.fn(), + addNode: vi.fn(), + updateNode: vi.fn(), + removeNode: vi.fn(), + setNodeStatus: vi.fn(), + linkNodeAction: vi.fn(), + unlinkNodeAction: vi.fn(), + setNodeMetadata: vi.fn(), registerAction: vi.fn(), removeAction: vi.fn(), addDomainResource: vi.fn(), @@ -137,7 +140,7 @@ describe('use-system-suite hooks', () => { it('useGetSystemSuite returns null on 404', async () => { const error = new Error('Not Found'); - (error as any).response = { status: 404 }; + (error as Error & { response?: { status: number } }).response = { status: 404 }; vi.mocked(systemSuiteService.getById).mockRejectedValue(error); const wrapper = createWrapper(); @@ -159,7 +162,12 @@ describe('use-system-suite hooks', () => { const { result } = renderHook(() => useCreateSystemSuite(), { wrapper }); await act(async () => { - result.current.mutate({ code: 'NEW', name: 'New Suite' }); + result.current.mutate({ + tenantId: '3fa85f64-5717-4562-b3fc-2c963f66afa7', + code: 'NEW', + name: 'New Suite', + description: 'A new suite', + }); }); await waitFor(() => { @@ -167,8 +175,10 @@ describe('use-system-suite hooks', () => { }); expect(systemSuiteService.createSystemSuite).toHaveBeenCalledWith({ + tenantId: '3fa85f64-5717-4562-b3fc-2c963f66afa7', code: 'NEW', name: 'New Suite', + description: 'A new suite', }); }); @@ -257,124 +267,142 @@ describe('use-system-suite hooks', () => { expect(systemSuiteService.deactivateModule).toHaveBeenCalledWith('s1', 'mod1'); }); - it('useAddMenu calls service successfully', async () => { - vi.mocked(systemSuiteService.addMenu).mockResolvedValue(); + it('useAddNode calls service successfully', async () => { + vi.mocked(systemSuiteService.addNode).mockResolvedValue(); const wrapper = createWrapper(); - const { result } = renderHook(() => useAddMenu('s1', 'mod1'), { wrapper }); + const { result } = renderHook(() => useAddNode('s1', 'mod1'), { wrapper }); await act(async () => { - result.current.mutate({ code: 'MENU1', label: 'Menu 1', sortOrder: 1 }); + result.current.mutate({ + parentNodeId: 'root1', + kind: 'Option', + code: 'NODE1', + label: 'Node 1', + sortOrder: 1, + }); }); await waitFor(() => { expect(result.current.isSuccess).toBe(true); }); - expect(systemSuiteService.addMenu).toHaveBeenCalledWith('s1', 'mod1', expect.any(Object)); + expect(systemSuiteService.addNode).toHaveBeenCalledWith( + 's1', + 'mod1', + expect.objectContaining({ parentNodeId: 'root1', kind: 'Option', code: 'NODE1' }) + ); }); - it('useRemoveMenu calls service successfully', async () => { - vi.mocked(systemSuiteService.removeMenu).mockResolvedValue(); + it('useUpdateNode calls service with nodeId', async () => { + vi.mocked(systemSuiteService.updateNode).mockResolvedValue(); const wrapper = createWrapper(); - const { result } = renderHook(() => useRemoveMenu('s1', 'mod1'), { wrapper }); + const { result } = renderHook(() => useUpdateNode('s1', 'mod1'), { wrapper }); await act(async () => { - result.current.mutate('menu1'); + result.current.mutate({ nodeId: 'node1', label: 'Nuevo', sortOrder: 2 }); }); await waitFor(() => { expect(result.current.isSuccess).toBe(true); }); - expect(systemSuiteService.removeMenu).toHaveBeenCalledWith('s1', 'mod1', 'menu1'); + expect(systemSuiteService.updateNode).toHaveBeenCalledWith( + 's1', + 'mod1', + 'node1', + expect.objectContaining({ label: 'Nuevo', sortOrder: 2 }) + ); }); - it('useAddSubMenu calls service successfully', async () => { - vi.mocked(systemSuiteService.addSubMenu).mockResolvedValue(); + it('useRemoveNode calls service successfully', async () => { + vi.mocked(systemSuiteService.removeNode).mockResolvedValue(); const wrapper = createWrapper(); - const { result } = renderHook(() => useAddSubMenu('s1', 'mod1', 'menu1'), { wrapper }); + const { result } = renderHook(() => useRemoveNode('s1', 'mod1'), { wrapper }); await act(async () => { - result.current.mutate({ code: 'SUB1', label: 'Sub 1', sortOrder: 1 }); + result.current.mutate('node1'); }); await waitFor(() => { expect(result.current.isSuccess).toBe(true); }); - expect(systemSuiteService.addSubMenu).toHaveBeenCalledWith( - 's1', - 'mod1', - 'menu1', - expect.any(Object) - ); + expect(systemSuiteService.removeNode).toHaveBeenCalledWith('s1', 'mod1', 'node1'); }); - it('useRemoveSubMenu calls service successfully', async () => { - vi.mocked(systemSuiteService.removeSubMenu).mockResolvedValue(); + it('useSetNodeStatus calls service with active flag', async () => { + vi.mocked(systemSuiteService.setNodeStatus).mockResolvedValue(); const wrapper = createWrapper(); - const { result } = renderHook(() => useRemoveSubMenu('s1', 'mod1', 'menu1'), { wrapper }); + const { result } = renderHook(() => useSetNodeStatus('s1', 'mod1'), { wrapper }); await act(async () => { - result.current.mutate('sub1'); + result.current.mutate({ nodeId: 'node1', active: false }); }); await waitFor(() => { expect(result.current.isSuccess).toBe(true); }); - expect(systemSuiteService.removeSubMenu).toHaveBeenCalledWith('s1', 'mod1', 'menu1', 'sub1'); + expect(systemSuiteService.setNodeStatus).toHaveBeenCalledWith('s1', 'mod1', 'node1', false); }); - it('useAddOption calls service successfully', async () => { - vi.mocked(systemSuiteService.addOption).mockResolvedValue(); + it('useLinkNodeAction calls service (N:M)', async () => { + vi.mocked(systemSuiteService.linkNodeAction).mockResolvedValue(); const wrapper = createWrapper(); - const { result } = renderHook(() => useAddOption('s1', 'mod1', 'menu1', 'sub1'), { wrapper }); + const { result } = renderHook(() => useLinkNodeAction('s1', 'mod1'), { wrapper }); await act(async () => { - result.current.mutate({ code: 'OPT1', label: 'Opt 1', actionCode: 'ACT1', sortOrder: 1 }); + result.current.mutate({ nodeId: 'node1', actionCode: 'VIEW' }); }); await waitFor(() => { expect(result.current.isSuccess).toBe(true); }); - expect(systemSuiteService.addOption).toHaveBeenCalledWith( - 's1', - 'mod1', - 'menu1', - 'sub1', - expect.any(Object) - ); + expect(systemSuiteService.linkNodeAction).toHaveBeenCalledWith('s1', 'mod1', 'node1', 'VIEW'); }); - it('useRemoveOption calls service successfully', async () => { - vi.mocked(systemSuiteService.removeOption).mockResolvedValue(); + it('useUnlinkNodeAction calls service (N:M)', async () => { + vi.mocked(systemSuiteService.unlinkNodeAction).mockResolvedValue(); const wrapper = createWrapper(); - const { result } = renderHook(() => useRemoveOption('s1', 'mod1', 'menu1', 'sub1'), { - wrapper, + const { result } = renderHook(() => useUnlinkNodeAction('s1', 'mod1'), { wrapper }); + + await act(async () => { + result.current.mutate({ nodeId: 'node1', actionCode: 'VIEW' }); + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); }); + expect(systemSuiteService.unlinkNodeAction).toHaveBeenCalledWith('s1', 'mod1', 'node1', 'VIEW'); + }); + + it('useSetNodeMetadata calls service with SDLC metadata', async () => { + vi.mocked(systemSuiteService.setNodeMetadata).mockResolvedValue(); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useSetNodeMetadata('s1', 'mod1'), { wrapper }); + await act(async () => { - result.current.mutate('opt1'); + result.current.mutate({ nodeId: 'node1', responsable: 'QA', criticidad: 'Alta' }); }); await waitFor(() => { expect(result.current.isSuccess).toBe(true); }); - expect(systemSuiteService.removeOption).toHaveBeenCalledWith( + expect(systemSuiteService.setNodeMetadata).toHaveBeenCalledWith( 's1', 'mod1', - 'menu1', - 'sub1', - 'opt1' + 'node1', + expect.objectContaining({ responsable: 'QA', criticidad: 'Alta' }) ); }); diff --git a/src/apps/ums.web-app/src/application/authorization/hooks/use-system-suite.ts b/src/apps/ums.web-app/src/application/authorization/hooks/use-system-suite.ts index edc13fa0..7b515d62 100644 --- a/src/apps/ums.web-app/src/application/authorization/hooks/use-system-suite.ts +++ b/src/apps/ums.web-app/src/application/authorization/hooks/use-system-suite.ts @@ -7,12 +7,7 @@ import { SystemSuite, SystemSuitePage, } from '@domain/authorization/models/system-suite.model'; -import { - getHttpStatus, - isNonRecoverable, - isNetworkError, - getRetryOptions, -} from '@app/utils/error-utils'; +import { getHttpStatus, getRetryOptions } from '@app/utils/error-utils'; import { CONTEXT_QUERY_CONFIG } from '@app/shared/config/query.config'; // ─── Query params ─────────────────────────────────────────────────────────── @@ -176,200 +171,154 @@ export const useDeactivateModule = (systemSuiteId: string) => { }); }; -// ─── Menu Mutations ─────────────────────────────────────────────────────────── +// ─── Node Mutations (árbol recursivo, ADR-0090) ─────────────────────────────── -export const useAddMenu = (systemSuiteId: string, moduleId: string) => { +export const useAddNode = (systemSuiteId: string, moduleId: string) => { return useNotifiedMutation({ mutationFn: (payload: { + parentNodeId?: string | null; + kind: string; code: string; label: string; description?: string; sortOrder: number; - }) => systemSuiteService.addMenu(systemSuiteId, moduleId, payload), - invalidateKeys: [['system-suites', systemSuiteId], ['system-suites']], - successNotif: () => ({ - title: 'Menú Registrado', - message: 'El menú fue agregado correctamente.', - }), - errorNotif: () => ({ - title: 'Error al Registrar Menú', - message: 'No se pudo agregar el menú.', - }), - }); -}; - -export const useUpdateMenu = (systemSuiteId: string, moduleId: string, menuId: string) => { - return useNotifiedMutation({ - mutationFn: (payload: { label: string; description?: string; sortOrder: number }) => - systemSuiteService.updateMenu(systemSuiteId, moduleId, menuId, payload), - invalidateKeys: [['system-suites', systemSuiteId], ['system-suites']], - successNotif: () => ({ - title: 'Menú Actualizado', - message: 'El menú fue actualizado correctamente.', - }), - errorNotif: () => ({ - title: 'Error al Actualizar Menú', - message: 'No se pudo actualizar el menú.', - }), - }); -}; - -export const useRemoveMenu = (systemSuiteId: string, moduleId: string) => { - return useNotifiedMutation({ - mutationFn: (menuId: string) => systemSuiteService.removeMenu(systemSuiteId, moduleId, menuId), + }) => systemSuiteService.addNode(systemSuiteId, moduleId, payload), invalidateKeys: [['system-suites', systemSuiteId], ['system-suites']], successNotif: () => ({ - title: 'Menú Eliminado', - message: 'El menú fue eliminado.', - type: 'warning' as const, + title: 'Nodo Registrado', + message: 'El nodo fue agregado al árbol correctamente.', }), errorNotif: () => ({ - title: 'Error al Eliminar Menú', - message: 'No se pudo eliminar el menú.', + title: 'Error al Registrar Nodo', + message: 'No se pudo agregar el nodo.', }), }); }; -// ─── SubMenu Mutations ──────────────────────────────────────────────────────── - -export const useAddSubMenu = (systemSuiteId: string, moduleId: string, menuId: string) => { +export const useUpdateNode = (systemSuiteId: string, moduleId: string) => { return useNotifiedMutation({ mutationFn: (payload: { - code: string; + nodeId: string; label: string; description?: string; sortOrder: number; - }) => systemSuiteService.addSubMenu(systemSuiteId, moduleId, menuId, payload), + }) => + systemSuiteService.updateNode(systemSuiteId, moduleId, payload.nodeId, { + label: payload.label, + description: payload.description, + sortOrder: payload.sortOrder, + }), invalidateKeys: [['system-suites', systemSuiteId], ['system-suites']], successNotif: () => ({ - title: 'Submenú Registrado', - message: 'El submenú fue agregado correctamente.', + title: 'Nodo Actualizado', + message: 'El nodo fue actualizado correctamente.', }), errorNotif: () => ({ - title: 'Error al Registrar Submenú', - message: 'No se pudo agregar el submenú.', + title: 'Error al Actualizar Nodo', + message: 'No se pudo actualizar el nodo.', }), }); }; -export const useUpdateSubMenu = ( - systemSuiteId: string, - moduleId: string, - menuId: string, - subMenuId: string -) => { +export const useRemoveNode = (systemSuiteId: string, moduleId: string) => { return useNotifiedMutation({ - mutationFn: (payload: { label: string; description?: string; sortOrder: number }) => - systemSuiteService.updateSubMenu(systemSuiteId, moduleId, menuId, subMenuId, payload), + mutationFn: (nodeId: string) => systemSuiteService.removeNode(systemSuiteId, moduleId, nodeId), invalidateKeys: [['system-suites', systemSuiteId], ['system-suites']], successNotif: () => ({ - title: 'Submenú Actualizado', - message: 'El submenú fue actualizado correctamente.', + title: 'Nodo Eliminado', + message: 'El nodo (y su subárbol) fue eliminado.', + type: 'warning' as const, }), errorNotif: () => ({ - title: 'Error al Actualizar Submenú', - message: 'No se pudo actualizar el submenú.', + title: 'Error al Eliminar Nodo', + message: 'No se pudo eliminar el nodo.', }), }); }; -export const useRemoveSubMenu = (systemSuiteId: string, moduleId: string, menuId: string) => { +export const useSetNodeStatus = (systemSuiteId: string, moduleId: string) => { return useNotifiedMutation({ - mutationFn: (subMenuId: string) => - systemSuiteService.removeSubMenu(systemSuiteId, moduleId, menuId, subMenuId), + mutationFn: (payload: { nodeId: string; active: boolean }) => + systemSuiteService.setNodeStatus(systemSuiteId, moduleId, payload.nodeId, payload.active), invalidateKeys: [['system-suites', systemSuiteId], ['system-suites']], successNotif: () => ({ - title: 'Submenú Eliminado', - message: 'El submenú fue eliminado.', - type: 'warning' as const, + title: 'Estado del Nodo Actualizado', + message: 'El estado del nodo fue actualizado.', + type: 'info' as const, }), errorNotif: () => ({ - title: 'Error al Eliminar Submenú', - message: 'No se pudo eliminar el submenú.', + title: 'Error de Cambio de Estado', + message: 'No se pudo actualizar el estado del nodo.', }), }); }; -// ─── Option Mutations ───────────────────────────────────────────────────────── - -export const useAddOption = ( - systemSuiteId: string, - moduleId: string, - menuId: string, - subMenuId: string -) => { +export const useLinkNodeAction = (systemSuiteId: string, moduleId: string) => { return useNotifiedMutation({ - mutationFn: (payload: { - code: string; - label: string; - description?: string; - actionCode: string; - sortOrder: number; - }) => systemSuiteService.addOption(systemSuiteId, moduleId, menuId, subMenuId, payload), + mutationFn: (payload: { nodeId: string; actionCode: string }) => + systemSuiteService.linkNodeAction( + systemSuiteId, + moduleId, + payload.nodeId, + payload.actionCode + ), invalidateKeys: [['system-suites', systemSuiteId], ['system-suites']], successNotif: () => ({ - title: 'Opción Registrada', - message: 'La opción fue agregada correctamente.', + title: 'Funcionalidad Vinculada', + message: 'La funcionalidad fue vinculada al nodo.', }), errorNotif: () => ({ - title: 'Error al Registrar Opción', - message: 'No se pudo agregar la opción.', + title: 'Error al Vincular Funcionalidad', + message: 'No se pudo vincular la funcionalidad.', }), }); }; -export const useUpdateOption = ( - systemSuiteId: string, - moduleId: string, - menuId: string, - subMenuId: string, - optionId: string -) => { +export const useUnlinkNodeAction = (systemSuiteId: string, moduleId: string) => { return useNotifiedMutation({ - mutationFn: (payload: { - label: string; - description?: string; - actionCode: string; - sortOrder: number; - }) => - systemSuiteService.updateOption( + mutationFn: (payload: { nodeId: string; actionCode: string }) => + systemSuiteService.unlinkNodeAction( systemSuiteId, moduleId, - menuId, - subMenuId, - optionId, - payload + payload.nodeId, + payload.actionCode ), invalidateKeys: [['system-suites', systemSuiteId], ['system-suites']], successNotif: () => ({ - title: 'Opción Actualizada', - message: 'La opción fue actualizada correctamente.', + title: 'Funcionalidad Desvinculada', + message: 'La funcionalidad fue desvinculada del nodo.', + type: 'warning' as const, }), errorNotif: () => ({ - title: 'Error al Actualizar Opción', - message: 'No se pudo actualizar la opción.', + title: 'Error al Desvincular Funcionalidad', + message: 'No se pudo desvincular la funcionalidad.', }), }); }; -export const useRemoveOption = ( - systemSuiteId: string, - moduleId: string, - menuId: string, - subMenuId: string -) => { +export const useSetNodeMetadata = (systemSuiteId: string, moduleId: string) => { return useNotifiedMutation({ - mutationFn: (optionId: string) => - systemSuiteService.removeOption(systemSuiteId, moduleId, menuId, subMenuId, optionId), + mutationFn: (payload: { + nodeId: string; + responsable?: string | null; + criticidad?: string | null; + productoImpactado?: string | null; + componenteTecnico?: string | null; + dependencias?: string | null; + evidencias?: string | null; + trazabilidadSdlc?: string | null; + }) => { + const { nodeId, ...metadata } = payload; + return systemSuiteService.setNodeMetadata(systemSuiteId, moduleId, nodeId, metadata); + }, invalidateKeys: [['system-suites', systemSuiteId], ['system-suites']], successNotif: () => ({ - title: 'Opción Eliminada', - message: 'La opción fue eliminada.', - type: 'warning' as const, + title: 'Metadatos Actualizados', + message: 'Los metadatos de gobernanza SDLC fueron guardados.', }), errorNotif: () => ({ - title: 'Error al Eliminar Opción', - message: 'No se pudo eliminar la opción.', + title: 'Error al Guardar Metadatos', + message: 'No se pudieron guardar los metadatos.', }), }); }; diff --git a/src/apps/ums.web-app/src/application/authorization/utils/graph-lookup.ts b/src/apps/ums.web-app/src/application/authorization/utils/graph-lookup.ts new file mode 100644 index 00000000..60421213 --- /dev/null +++ b/src/apps/ums.web-app/src/application/authorization/utils/graph-lookup.ts @@ -0,0 +1,97 @@ +/** + * graph-lookup.ts — Búsqueda de un nodo y de una acción dentro del grafo de autorización. + * + * La misma búsqueda estaba escrita dos veces —en `use-access-resolution` y en el decorador + * `RequireAccess`— y ambas recorrían `module.menus[].subMenus[].options[]`, una forma que el + * servidor DEJÓ DE ENVIAR: el contrato v2.0.0 (`@ums/sdk-contracts`, `MenuModule.nodes`) es un + * árbol RECURSIVO de `NavigationNode`, sin profundidad fija. Leer `menus` sobre ese payload da + * `undefined`, así que los guards de menú y de opción no concedían ni denegaban: fallaban. + * + * Aquí se recorre `nodes`/`children` hasta agotarlo, sin presuponer niveles, que es lo que el + * propio contrato pide. `kind` clasifica el papel del nodo (`Menu`, `SubMenu`, `Option`) sin + * fijar dónde aparece, así que la búsqueda no filtra por él: un guard declarado sobre un código + * lo encuentra esté al nivel que esté. + * + * Solo viaja lo ALCANZABLE: la ausencia de un nodo significa «no concedido», no «error». + */ + +export interface GraphNodeAction { + actionCode: string; + effect: string; +} + +export interface GraphNavigationNode { + code: string; + kind?: string; + actions?: readonly GraphNodeAction[]; + children?: readonly GraphNavigationNode[]; +} + +export interface GraphModule { + code?: string; + nodes?: readonly GraphNavigationNode[]; +} + +/** Primer nodo del subárbol cuyo código coincide, en recorrido en profundidad. */ +export function findGraphNode( + nodes: readonly GraphNavigationNode[] | undefined, + code: string +): GraphNavigationNode | undefined { + for (const node of nodes ?? []) { + if (node.code === code) return node; + const inChild = findGraphNode(node.children, code); + if (inChild) return inChild; + } + return undefined; +} + +/** + * Efecto resuelto de una opción dentro del subárbol de `scopeCode`. + * + * `optionCode` casa contra el código del NODO o contra el de una de sus acciones, que es la + * semántica que tenía el modelo retirado: quien declara un guard nombra «la opción», y en la + * suite eso podía ser tanto el nodo como la funcionalidad colgada de él. `scopeCode` acota la + * búsqueda a un subárbol y puede ser cualquier ancestro, no solo el padre directo. + * + * Devuelve `undefined` si no aparece, que NO es lo mismo que aparecer denegada: solo viaja lo + * alcanzable, y quien llama decide qué significa la ausencia. + */ +export function findGraphAction( + menuAccess: readonly GraphModule[] | undefined, + scopeCode: string, + optionCode: string +): GraphNodeAction | undefined { + for (const mod of menuAccess ?? []) { + const scope = findGraphNode(mod.nodes, scopeCode); + const action = scope && findActionInSubtree(scope, optionCode); + if (action) return action; + } + return undefined; +} + +function findActionInSubtree( + node: GraphNavigationNode, + optionCode: string +): GraphNodeAction | undefined { + const byAction = node.actions?.find(a => a.actionCode === optionCode); + if (byAction) return byAction; + + // El nodo mismo es la opción nombrada: su efecto es el de la acción que lleva colgada. + if (node.code === optionCode && node.actions?.length) return node.actions[0]; + + for (const child of node.children ?? []) { + const found = findActionInSubtree(child, optionCode); + if (found) return found; + } + return undefined; +} + +/** ¿Existe en el grafo un nodo con ese código dentro del módulo indicado? */ +export function moduleHasNode( + menuAccess: readonly GraphModule[] | undefined, + moduleCode: string, + nodeCode: string +): boolean { + const mod = (menuAccess ?? []).find(m => m.code === moduleCode); + return !!findGraphNode(mod?.nodes, nodeCode); +} diff --git a/src/apps/ums.web-app/src/application/authorization/utils/permission-cascade.ts b/src/apps/ums.web-app/src/application/authorization/utils/permission-cascade.ts index 7da918ab..b5700ebc 100644 --- a/src/apps/ums.web-app/src/application/authorization/utils/permission-cascade.ts +++ b/src/apps/ums.web-app/src/application/authorization/utils/permission-cascade.ts @@ -1,80 +1,68 @@ -import { SystemSuite } from '../../domain/system-suite'; +import type { SystemSuite } from '@domain/authorization/models/system-suite.model'; +import type { SystemSuiteNode } from '@domain/authorization/schemas/system-suite.schema'; /** - * Returns an array of parent IDs (Module, Menu, SubMenu) from root up to (but not including) the targetId. - * If the targetId is not found, it returns an empty array. + * Utilidades de cascada de permisos sobre el árbol de nodos recursivo del + * SystemSuite (ADR-0090). Reemplazan el recorrido rígido Menú→Submenú→Opción + * por un recorrido recursivo de `module.nodes`. */ -export function getAscendantIds(suite: SystemSuite, targetId: string): string[] { - const result: string[] = []; - - for (const module of suite.modules) { - if (module.id === targetId) return result; - for (const menu of module.menus) { - if (menu.id === targetId) { - result.push(module.id); - return result; - } +type AscendantType = 'Module' | 'Submodule' | 'Page' | 'Option'; - for (const subMenu of menu.subMenus) { - if (subMenu.id === targetId) { - result.push(module.id, menu.id); - return result; - } +// Mapea el rol del nodo (NodeKind) al tipo de destino usado por la cascada. +// Preserva la semántica previa: Menú→Submodule, Submenú→Page, Opción→Option. +function nodeKindToAscendantType(kind: string): AscendantType { + if (kind === 'Menu') return 'Submodule'; + if (kind === 'SubMenu') return 'Page'; + return 'Option'; +} - for (const option of subMenu.options) { - if (option.id === targetId) { - result.push(module.id, menu.id, subMenu.id); - return result; - } - } - } - } +// Busca el camino (nodos ancestros, de raíz a target sin incluirlo) dentro de +// un subárbol. Devuelve null si el target no está en el subárbol. +function findNodePath(nodes: SystemSuiteNode[], targetId: string): SystemSuiteNode[] | null { + for (const node of nodes) { + if (node.id === targetId) return []; + const childPath = findNodePath(node.children ?? [], targetId); + if (childPath) return [node, ...childPath]; } + return null; +} +/** + * Devuelve los IDs de los ancestros (Módulo y nodos) desde la raíz hasta (sin + * incluir) el targetId. Vacío si no se encuentra. + */ +export function getAscendantIds(suite: SystemSuite, targetId: string): string[] { + for (const module of suite.modules) { + if (module.id === targetId) return []; + const path = findNodePath(module.nodes ?? [], targetId); + if (path) return [module.id, ...path.map(n => n.id)]; + } return []; } +/** + * Igual que {@link getAscendantIds} pero devuelve el tipo de cada ancestro. + */ export function getAscendantsWithTypes( suite: SystemSuite, targetId: string -): { id: string; type: 'Module' | 'Submodule' | 'Page' | 'Option' }[] { - const result: { id: string; type: 'Module' | 'Submodule' | 'Page' | 'Option' }[] = []; - +): { id: string; type: AscendantType }[] { for (const module of suite.modules) { - if (module.id === targetId) return result; - - for (const menu of module.menus) { - if (menu.id === targetId) { - result.push({ id: module.id, type: 'Module' }); - return result; - } - - for (const subMenu of menu.subMenus) { - if (subMenu.id === targetId) { - result.push({ id: module.id, type: 'Module' }, { id: menu.id, type: 'Submodule' }); - return result; - } - - for (const option of subMenu.options) { - if (option.id === targetId) { - result.push( - { id: module.id, type: 'Module' }, - { id: menu.id, type: 'Submodule' }, - { id: subMenu.id, type: 'Page' } - ); - return result; - } - } - } + if (module.id === targetId) return []; + const path = findNodePath(module.nodes ?? [], targetId); + if (path) { + return [ + { id: module.id, type: 'Module' as const }, + ...path.map(n => ({ id: n.id, type: nodeKindToAscendantType(n.kind) })), + ]; } } - return []; } /** - * Returns true if the action code implies a read/view operation. + * Devuelve true si el código de acción implica una operación de lectura/consulta. */ export function isReadAction(actionCode: string): boolean { const upper = actionCode.toUpperCase(); @@ -87,35 +75,46 @@ export function isReadAction(actionCode: string): boolean { ); } +// ¿Alguna de las funcionalidades vinculadas al nodo es de lectura? +function nodeIsReadOnly(node: SystemSuiteNode): boolean { + return node.actionCodes.length > 0 && node.actionCodes.every(isReadAction); +} + +function nodeHasReadAction(node: SystemSuiteNode): boolean { + return node.actionCodes.some(isReadAction); +} + +// Encuentra el nodo objetivo y su lista de hermanos (children de su padre). +function findNodeWithSiblings( + nodes: SystemSuiteNode[], + targetId: string, + siblings: SystemSuiteNode[] +): { node: SystemSuiteNode; siblings: SystemSuiteNode[] } | null { + for (const node of nodes) { + if (node.id === targetId) return { node, siblings }; + const found = findNodeWithSiblings(node.children ?? [], targetId, node.children ?? []); + if (found) return found; + } + return null; +} + /** - * Given an option ID, if it's a "Write/Manage" option, returns the IDs and types of any "Read/View" options - * in the same SubMenu, to enforce CRUD logic where write access implies read access. + * Dado un nodo hoja (Opción) de "escritura/gestión", devuelve los IDs de los + * nodos hermanos de "lectura/consulta" en el mismo padre, para propagar que el + * acceso de escritura implica el de lectura. */ export function getSiblingViewOptions( suite: SystemSuite, optionId: string ): { id: string; type: 'Option' }[] { - const result: { id: string; type: 'Option' }[] = []; - for (const module of suite.modules) { - for (const menu of module.menus) { - for (const subMenu of menu.subMenus) { - const targetOption = subMenu.options.find(o => o.id === optionId); - if (targetOption) { - // If the target option itself is already a read action, there's no implied read to fetch - if (isReadAction(targetOption.actionCode)) { - return []; - } - - // Find all sibling read options - for (const sibling of subMenu.options) { - if (sibling.id !== optionId && isReadAction(sibling.actionCode)) { - result.push({ id: sibling.id, type: 'Option' }); - } - } - return result; - } - } + const found = findNodeWithSiblings(module.nodes ?? [], optionId, module.nodes ?? []); + if (found) { + // Si el propio nodo ya es de lectura, no hay lectura implícita que resolver. + if (nodeIsReadOnly(found.node)) return []; + return found.siblings + .filter(s => s.id !== optionId && s.kind === 'Option' && nodeHasReadAction(s)) + .map(s => ({ id: s.id, type: 'Option' as const })); } } return []; diff --git a/src/apps/ums.web-app/src/application/configuration/hooks/use-feature-flag-dashboard.test.ts b/src/apps/ums.web-app/src/application/configuration/hooks/use-feature-flag-dashboard.test.ts index 4f195d73..53b0709b 100644 --- a/src/apps/ums.web-app/src/application/configuration/hooks/use-feature-flag-dashboard.test.ts +++ b/src/apps/ums.web-app/src/application/configuration/hooks/use-feature-flag-dashboard.test.ts @@ -36,13 +36,13 @@ describe('useFeatureFlagDashboard', () => { data: { items: mockFlags, page: 1, pageSize: 20, totalItems: 2, totalPages: 1 }, isLoading: false, error: null, - } as any); + } as unknown as ReturnType); vi.mocked(useFeatureFlagModule.useGetFeatureFlagById).mockReturnValue({ data: mockFlags[0], isLoading: false, error: null, - } as any); + } as unknown as ReturnType); vi.mocked(useQueryStateModule.useQueryState).mockReturnValue({ searchCriteria: 'flagCode', @@ -59,7 +59,7 @@ describe('useFeatureFlagDashboard', () => { appliedQuery: { criteria: 'flagCode', term: '' }, handleQuerySubmit: vi.fn(), handleResetQuery: vi.fn(), - } as any); + } as unknown as ReturnType); vi.mocked(usePaginationStateModule.usePaginationState).mockReturnValue({ page: 1, @@ -69,7 +69,7 @@ describe('useFeatureFlagDashboard', () => { startIndex: 0, handlePageChange: vi.fn(), handlePageSizeChange: vi.fn(), - } as any); + } as unknown as ReturnType); }); it('returns initial state with empty selectedId', () => { @@ -198,7 +198,7 @@ describe('useFeatureFlagDashboard', () => { data: undefined, isLoading: false, error: null, - } as any); + } as unknown as ReturnType); const { result } = renderHook(() => useFeatureFlagDashboard()); expect(result.current.knownFlags).toEqual([]); @@ -209,7 +209,7 @@ describe('useFeatureFlagDashboard', () => { data: undefined, isLoading: false, error: null, - } as any); + } as unknown as ReturnType); const { result } = renderHook(() => useFeatureFlagDashboard()); expect(result.current.totalItems).toBe(0); diff --git a/src/apps/ums.web-app/src/application/configuration/hooks/use-feature-flag-dashboard.ts b/src/apps/ums.web-app/src/application/configuration/hooks/use-feature-flag-dashboard.ts index e4585361..c8ae76b5 100644 --- a/src/apps/ums.web-app/src/application/configuration/hooks/use-feature-flag-dashboard.ts +++ b/src/apps/ums.web-app/src/application/configuration/hooks/use-feature-flag-dashboard.ts @@ -18,6 +18,8 @@ export function useFeatureFlagDashboard() { criteria: 'flagCode', filter: 'all', sortBy: 'flagCode', + // Patrón estándar: la lista carga al entrar (no exige aplicar un filtro primero). + appliedFilter: true, }); const paginationState = usePaginationState({ diff --git a/src/apps/ums.web-app/src/application/configuration/hooks/use-feature-flag.test.tsx b/src/apps/ums.web-app/src/application/configuration/hooks/use-feature-flag.test.tsx index 2d51f6db..84fa29d9 100644 --- a/src/apps/ums.web-app/src/application/configuration/hooks/use-feature-flag.test.tsx +++ b/src/apps/ums.web-app/src/application/configuration/hooks/use-feature-flag.test.tsx @@ -141,7 +141,7 @@ describe('use-feature-flag hooks', () => { it('useGetFeatureFlagById returns null on 404', async () => { const error = new Error('Not Found'); - (error as any).response = { status: 404 }; + (error as Error & { response?: { status: number } }).response = { status: 404 }; vi.mocked(featureFlagService.getById).mockRejectedValue(error); const wrapper = createWrapper(); diff --git a/src/apps/ums.web-app/src/application/configuration/hooks/use-parameter-catalog-dashboard.ts b/src/apps/ums.web-app/src/application/configuration/hooks/use-parameter-catalog-dashboard.ts index 0805d74e..c779ccf1 100644 --- a/src/apps/ums.web-app/src/application/configuration/hooks/use-parameter-catalog-dashboard.ts +++ b/src/apps/ums.web-app/src/application/configuration/hooks/use-parameter-catalog-dashboard.ts @@ -29,6 +29,8 @@ export function useParameterCatalogDashboard() { criteria: 'code', filter: 'all', sortBy: 'code', + // Patrón estándar: la lista carga al entrar (no exige aplicar un filtro primero). + appliedFilter: true, }); const paginationState = usePaginationState({ diff --git a/src/apps/ums.web-app/src/application/configuration/parameter-catalog/use-parameter-catalog.ts b/src/apps/ums.web-app/src/application/configuration/parameter-catalog/use-parameter-catalog.ts index 4a370ce0..9182daf0 100644 --- a/src/apps/ums.web-app/src/application/configuration/parameter-catalog/use-parameter-catalog.ts +++ b/src/apps/ums.web-app/src/application/configuration/parameter-catalog/use-parameter-catalog.ts @@ -1,4 +1,5 @@ -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; +import { useNotifiedMutation } from '@app/hooks/use-notified-mutation'; import { parameterCatalogService } from '@infrastructure/configuration/services/parameter-catalog/parameter-catalog.service'; import type { ParameterDefinitionFilter, @@ -22,35 +23,53 @@ export function useParameterDefinitionById(id: string) { }); } +// Los 3 mutadores usan `useNotifiedMutation` (G-146): es dueño único del toast success/error y +// de la invalidación → un rechazo del backend (p. ej. borrar un parámetro con valores dependientes +// → 409 `ParameterHasActiveValues`) ahora SÍ da feedback visual, en la línea de las demás cards. export function useCreateParameterDefinition() { - const queryClient = useQueryClient(); - return useMutation({ + return useNotifiedMutation({ mutationFn: (payload: CreateParameterDefinitionPayload) => parameterCatalogService.createParameterDefinition(payload), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['parameter-definitions'] }); - }, + invalidateKeys: [['parameter-definitions']], + successNotif: () => ({ + title: 'Parámetro Creado', + message: 'La definición de parámetro fue creada exitosamente.', + }), + errorNotif: () => ({ + title: 'Error al Crear Parámetro', + message: 'No se pudo crear la definición de parámetro.', + }), }); } export function useUpdateParameterDefinition() { - const queryClient = useQueryClient(); - return useMutation({ + return useNotifiedMutation({ mutationFn: ({ id, payload }: { id: string; payload: UpdateParameterDefinitionPayload }) => parameterCatalogService.updateParameterDefinition(id, payload), - onSuccess: data => { - queryClient.invalidateQueries({ queryKey: ['parameter-definitions'] }); - queryClient.setQueryData(['parameter-definition', data.id], data); - }, + // Refresca lista + detalle (prefijo `['parameter-definition']` matchea `['parameter-definition', id]`). + invalidateKeys: [['parameter-definitions'], ['parameter-definition']], + successNotif: () => ({ + title: 'Parámetro Actualizado', + message: 'La definición de parámetro fue actualizada.', + }), + errorNotif: () => ({ + title: 'Error al Actualizar Parámetro', + message: 'No se pudo actualizar la definición de parámetro.', + }), }); } export function useDeleteParameterDefinition() { - const queryClient = useQueryClient(); - return useMutation({ + return useNotifiedMutation({ mutationFn: (id: string) => parameterCatalogService.deleteParameterDefinition(id), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['parameter-definitions'] }); - }, + invalidateKeys: [['parameter-definitions']], + successNotif: () => ({ + title: 'Parámetro Eliminado', + message: 'La definición de parámetro fue eliminada.', + }), + errorNotif: () => ({ + title: 'Error al Eliminar Parámetro', + message: 'No se pudo eliminar el parámetro (puede tener valores asociados).', + }), }); } diff --git a/src/apps/ums.web-app/src/application/errors/http-error.test.ts b/src/apps/ums.web-app/src/application/errors/http-error.test.ts index 6ea7780b..11f2c374 100644 --- a/src/apps/ums.web-app/src/application/errors/http-error.test.ts +++ b/src/apps/ums.web-app/src/application/errors/http-error.test.ts @@ -4,6 +4,7 @@ import { getHttpStatus, getSupportReferenceId, getHttpErrorMessage, + getBlockedOperation, } from './http-error'; describe('asHttpError', () => { @@ -39,17 +40,6 @@ describe('asHttpError', () => { expect(result.response?.data?.traceId).toBe('trace-456'); }); - it('extracts graphQLErrors', () => { - const error = { - graphQLErrors: [ - { extensions: { errorId: 'gql-err-1' } }, - { extensions: { code: 'INTERNAL_ERROR' } }, - ], - }; - const result = asHttpError(error); - expect(result.graphQLErrors).toHaveLength(2); - }); - it('extracts supportReferenceId from root', () => { const error = { supportReferenceId: 'ref-789' }; const result = asHttpError(error); @@ -100,16 +90,6 @@ describe('getSupportReferenceId', () => { expect(getSupportReferenceId(error)).toBe('trace-456'); }); - it('returns errorId from graphQLErrors', () => { - const error = { graphQLErrors: [{ extensions: { errorId: 'gql-err' } }] }; - expect(getSupportReferenceId(error)).toBe('gql-err'); - }); - - it('returns traceId from graphQLErrors', () => { - const error = { graphQLErrors: [{ extensions: { traceId: 'gql-trace' } }] }; - expect(getSupportReferenceId(error)).toBe('gql-trace'); - }); - it('returns undefined for empty error', () => { expect(getSupportReferenceId({})).toBeUndefined(); }); @@ -140,3 +120,64 @@ describe('getHttpErrorMessage', () => { expect(getHttpErrorMessage(error, 'Fallback')).toBe('Profile not found.'); }); }); + +// ─── 409 con desglose: qué bloquea exactamente la operación (ADR-0164) ─────── + +describe('getBlockedOperation', () => { + const blocked409 = { + response: { + status: 409, + data: { + errorCode: 'tenant.branch_has_live_references', + message: 'No se puede cerrar la sucursal porque todavía tiene usuarios o perfiles activos.', + brokenRule: 'A branch cannot be closed while active references exist.', + blockingDependencies: [ + { entityType: 'UserAccount', status: 'Active', count: 3 }, + { entityType: 'Profile', status: 'Active', count: 1 }, + ], + }, + }, + }; + + it('devuelve las dos clases que bloquean, no solo la primera', () => { + const blocked = getBlockedOperation(blocked409); + + expect(blocked?.dependencies).toHaveLength(2); + expect(blocked?.dependencies[0]).toEqual({ + entityType: 'UserAccount', + status: 'Active', + count: 3, + }); + expect(blocked?.errorCode).toBe('tenant.branch_has_live_references'); + }); + + it('ignora un 409 sin desglose', () => { + expect( + getBlockedOperation({ response: { status: 409, data: { detail: 'Conflict' } } }) + ).toBeNull(); + }); + + it('ignora los errores que no son 409', () => { + expect( + getBlockedOperation({ response: { status: 500, data: { blockingDependencies: [] } } }) + ).toBeNull(); + }); + + it('ignora lo que no es un error HTTP', () => { + expect(getBlockedOperation('boom')).toBeNull(); + }); + + it('descarta entradas del desglose que no tienen forma de dependencia', () => { + const blocked = getBlockedOperation({ + response: { + status: 409, + data: { + errorCode: 'x', + blockingDependencies: [{ entityType: 'Profile', count: 2 }, 'basura', { count: 9 }], + }, + }, + }); + + expect(blocked?.dependencies).toEqual([{ entityType: 'Profile', status: '', count: 2 }]); + }); +}); diff --git a/src/apps/ums.web-app/src/application/errors/http-error.ts b/src/apps/ums.web-app/src/application/errors/http-error.ts index cf02c8bf..92842086 100644 --- a/src/apps/ums.web-app/src/application/errors/http-error.ts +++ b/src/apps/ums.web-app/src/application/errors/http-error.ts @@ -16,41 +16,41 @@ interface HttpErrorLike { supportReferenceId?: string; }; }; - graphQLErrors?: ReadonlyArray<{ - extensions?: Record; - }>; } const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null; +/** Devuelve el valor solo si es una cadena. El grueso de la complejidad de `asHttpError` eran + * repeticiones de esta misma comprobación escritas a mano, una por campo. */ +const str = (value: unknown): string | undefined => (typeof value === 'string' ? value : undefined); + +const num = (value: unknown): number | undefined => (typeof value === 'number' ? value : undefined); + +const asErrorData = (data: Record) => ({ + detail: str(data.detail), + title: str(data.title), + error: str(data.error), + errorId: str(data.errorId), + traceId: str(data.traceId), + supportReferenceId: str(data.supportReferenceId), + userMessage: str(data.userMessage), +}); + export const asHttpError = (error: unknown): HttpErrorLike => { if (!isRecord(error)) return {}; + const response = isRecord(error.response) ? error.response : undefined; - const data = response && isRecord(response.data) ? response.data : undefined; - const graphQLErrors = Array.isArray(error.graphQLErrors) ? error.graphQLErrors : undefined; + if (!response) return { supportReferenceId: str(error.supportReferenceId) }; + + const data = isRecord(response.data) ? response.data : undefined; return { - supportReferenceId: - typeof error.supportReferenceId === 'string' ? error.supportReferenceId : undefined, - response: response - ? { - status: typeof response.status === 'number' ? response.status : undefined, - headers: isRecord(response.headers) ? response.headers : undefined, - data: data - ? { - detail: typeof data.detail === 'string' ? data.detail : undefined, - title: typeof data.title === 'string' ? data.title : undefined, - error: typeof data.error === 'string' ? data.error : undefined, - errorId: typeof data.errorId === 'string' ? data.errorId : undefined, - traceId: typeof data.traceId === 'string' ? data.traceId : undefined, - supportReferenceId: - typeof data.supportReferenceId === 'string' ? data.supportReferenceId : undefined, - userMessage: typeof data.userMessage === 'string' ? data.userMessage : undefined, - } - : undefined, - } - : undefined, - graphQLErrors, + supportReferenceId: str(error.supportReferenceId), + response: { + status: num(response.status), + headers: isRecord(response.headers) ? response.headers : undefined, + data: data ? asErrorData(data) : undefined, + }, }; }; @@ -59,12 +59,6 @@ export const getHttpStatus = (error: unknown): number | undefined => export const getSupportReferenceId = (error: unknown): string | undefined => { const httpError = asHttpError(error); - const graphqlErrorId = httpError.graphQLErrors - ?.map(item => item.extensions?.errorId) - .find((value): value is string => typeof value === 'string'); - const graphqlTraceId = httpError.graphQLErrors - ?.map(item => item.extensions?.traceId) - .find((value): value is string => typeof value === 'string'); const headerErrorId = httpError.response?.headers?.['x-error-id'] ?? httpError.response?.headers?.['X-Error-Id']; const headerTraceId = @@ -75,10 +69,8 @@ export const getSupportReferenceId = (error: unknown): string | undefined => { httpError.supportReferenceId ?? httpError.response?.data?.supportReferenceId ?? httpError.response?.data?.errorId ?? - graphqlErrorId ?? (typeof headerErrorId === 'string' ? headerErrorId : undefined) ?? httpError.response?.data?.traceId ?? - graphqlTraceId ?? (typeof headerTraceId === 'string' ? headerTraceId : undefined) ); }; @@ -98,3 +90,57 @@ export const getHttpErrorMessage = (error: unknown, fallback: string): string => const data = asHttpError(error).response?.data; return data?.userMessage ?? data?.detail ?? data?.error ?? fallback; }; + +// ─── Operaciones bloqueadas por dependencias vivas (409) ───────────────────── + +/** + * Una clase de dependencia que impide la operación: qué tipo de entidad, en qué estado y cuántas. + * El backend devuelve TODAS las clases que bloquean, no solo la primera. + */ +export interface BlockingDependency { + entityType: string; + status: string; + count: number; +} + +export interface BlockedOperation { + errorCode: string; + message: string; + dependencies: BlockingDependency[]; +} + +/** + * Extrae el desglose de un 409 «operación bloqueada». + * + * Existe porque un «no se pudo» a secas obliga a adivinar: la respuesta trae cuántas cuentas y + * cuántos perfiles siguen vivos, y esa es justo la información que necesita quien va a resolverlo. + * Devuelve `null` si el error no trae ese cuerpo (otro 409, un 500, un fallo de red). + */ +export const getBlockedOperation = (error: unknown): BlockedOperation | null => { + if (!isRecord(error)) return null; + const response = isRecord(error.response) ? error.response : undefined; + if (response?.status !== 409) return null; + + const data = isRecord(response.data) ? response.data : undefined; + if (!data || !Array.isArray(data.blockingDependencies)) return null; + + const dependencies = data.blockingDependencies + .filter(isRecord) + .filter( + (dep): dep is Record & { entityType: string; count: number } => + typeof dep.entityType === 'string' && typeof dep.count === 'number' + ) + .map(dep => ({ + entityType: dep.entityType, + status: typeof dep.status === 'string' ? dep.status : '', + count: dep.count, + })); + + if (dependencies.length === 0) return null; + + return { + errorCode: typeof data.errorCode === 'string' ? data.errorCode : '', + message: typeof data.message === 'string' ? data.message : '', + dependencies, + }; +}; diff --git a/src/apps/ums.web-app/src/application/formatting/date.ts b/src/apps/ums.web-app/src/application/formatting/date.ts index 7c84c9c7..110a3bbf 100644 --- a/src/apps/ums.web-app/src/application/formatting/date.ts +++ b/src/apps/ums.web-app/src/application/formatting/date.ts @@ -1,3 +1,6 @@ +/** Lo que este módulo acepta como fecha: un Date, su forma serializada, o nada. */ +export type DateLike = Date | string | null | undefined; + /** * ADR-0076: UTC Date Storage, Timezone Detection, and Language Resolution. * @@ -18,7 +21,7 @@ * @param timeZone IANA timezone from session (e.g. "America/Lima"). Defaults to browser local. */ export function formatDate( - date: Date | string | null | undefined, + date: DateLike, locale: string, timeZone?: string, options?: Intl.DateTimeFormatOptions @@ -40,7 +43,7 @@ export function formatDate( * Formats a UTC datetime value for display including time in the user's timezone. */ export function formatDateTime( - date: Date | string | null | undefined, + date: DateLike, locale: string, timeZone?: string, options?: Intl.DateTimeFormatOptions @@ -63,7 +66,7 @@ export function formatDateTime( /** * Returns a relative time string ("hace 3 días") for a UTC date. */ -export function formatRelativeTime(date: Date | string | null | undefined, locale: string): string { +export function formatRelativeTime(date: DateLike, locale: string): string { if (!date) return '-'; const d = typeof date === 'string' ? new Date(date) : date; if (isNaN(d.getTime())) return '-'; diff --git a/src/apps/ums.web-app/src/application/formatting/index.ts b/src/apps/ums.web-app/src/application/formatting/index.ts index 49f82ba8..d47ff674 100644 --- a/src/apps/ums.web-app/src/application/formatting/index.ts +++ b/src/apps/ums.web-app/src/application/formatting/index.ts @@ -1,2 +1,7 @@ export { formatDate, formatDateTime, formatRelativeTime } from './date'; export { formatNumber, formatCurrency, formatPercentage, formatCompact } from './number'; + +// Los hooks inyectan idioma, zona y moneda de la sesión. Son la vía preferente: los ayudantes de +// arriba traen valores por defecto genéricos ('en', 'USD') que casi nunca son los correctos aquí. +export { useDateFormat } from './use-date-format'; +export { useNumberFormat } from './use-number-format'; diff --git a/src/apps/ums.web-app/src/application/formatting/use-date-format.test.ts b/src/apps/ums.web-app/src/application/formatting/use-date-format.test.ts new file mode 100644 index 00000000..f20b76a2 --- /dev/null +++ b/src/apps/ums.web-app/src/application/formatting/use-date-format.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useDateFormat } from './use-date-format'; + +/** Ajustes que publica el sistema en el grafo (G-178). */ +let settings: Record> = {}; +/** Parámetros del inquilino que viajan en la sesión (ADR-0076). */ +let sessionParameters: { defaultTimezone?: string } | undefined; + +vi.mock('@app/stores/auth.store', () => ({ + useAuthStore: (selector: (s: unknown) => unknown) => + selector({ user: { authorizationGraph: { settings }, sessionParameters } }), +})); + +vi.mock('@app/stores/i18n.store', () => ({ + useI18nStore: (selector: (s: unknown) => unknown) => selector({ language: 'es' }), +})); + +describe('useDateFormat — precedencia sistema → inquilino', () => { + beforeEach(() => { + settings = {}; + sessionParameters = { defaultTimezone: 'America/Lima' }; + }); + + it('la zona del sistema manda sobre la del inquilino', () => { + settings = { locale: { timezone: 'Europe/Madrid' } }; + + const { result } = renderHook(() => useDateFormat()); + expect(result.current.timezone).toBe('Europe/Madrid'); + }); + + it('sin zona del sistema queda la del inquilino', () => { + const { result } = renderHook(() => useDateFormat()); + expect(result.current.timezone).toBe('America/Lima'); + }); + + it('sin ninguna de las dos, decide el navegador', () => { + // `undefined` deja que Intl use la zona local: mejor eso que inventar una. + sessionParameters = undefined; + + const { result } = renderHook(() => useDateFormat()); + expect(result.current.timezone).toBeUndefined(); + }); + + it('formatea con el idioma completo que publica el sistema', () => { + // `es-PE` da el orden día/mes; el idioma de la interfaz solo distingue es de en. + settings = { locale: { language: 'es-PE', timezone: 'America/Lima' } }; + + const { result } = renderHook(() => useDateFormat()); + expect(result.current.locale).toBe('es-PE'); + expect( + result.current.formatDate('2026-08-01T05:30:00Z', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }) + ).toBe('01/08/2026'); + }); + + it('la zona convierte la fecha, no solo la etiqueta', () => { + // 2026-08-01T02:00Z es aún 31 de julio en Lima (UTC-5): si la conversión no ocurre, + // el usuario ve un día que no corresponde. + settings = { locale: { language: 'es-PE', timezone: 'America/Lima' } }; + + const { result } = renderHook(() => useDateFormat()); + expect( + result.current.formatDate('2026-08-01T02:00:00Z', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }) + ).toBe('31/07/2026'); + }); + + it('sin ajustes del sistema formatea con el idioma de la interfaz', () => { + const { result } = renderHook(() => useDateFormat()); + expect(result.current.locale).toBe('es'); + }); +}); diff --git a/src/apps/ums.web-app/src/application/formatting/use-date-format.ts b/src/apps/ums.web-app/src/application/formatting/use-date-format.ts index b1ac49af..c3beb13f 100644 --- a/src/apps/ums.web-app/src/application/formatting/use-date-format.ts +++ b/src/apps/ums.web-app/src/application/formatting/use-date-format.ts @@ -1,6 +1,14 @@ /** * useDateFormat — React hook that wraps date formatting with the active - * locale (i18n store) and session timezone (auth store). + * locale and timezone. + * + * Precedencia: **sistema → inquilino → navegador**. El sistema publica en el grafo su idioma y su + * zona (`settings.locale.*`, G-178) y son los más específicos que hay: mandan sobre los parámetros + * del inquilino, que a su vez mandan sobre lo que deduzca el navegador. + * + * El idioma del formato no es el mismo dato que el de la interfaz: aquí interesa la etiqueta + * completa —`es-PE` da `31/12/2026`, `es` a secas no lo garantiza—, mientras que la interfaz solo + * distingue `es` de `en` porque son los dos paquetes de traducción que existen. * * ADR-0076: Use this hook in all components instead of calling formatDate/ * formatDateTime directly, so that locale and timezone are always consistent @@ -13,31 +21,38 @@ import { useCallback } from 'react'; import { useI18nStore } from '@app/stores/i18n.store'; import { useAuthStore } from '@app/stores/auth.store'; +import { useSystemSettings } from '@app/authorization/hooks/use-system-settings'; import { formatDate as _formatDate, formatDateTime as _formatDateTime, formatRelativeTime as _formatRelativeTime, } from './date'; -import type { Intl as IntlType } from 'typescript'; + +/** Lo que llega de la API: ISO en UTC, o nada. */ +type FechaFormateable = Date | string | null | undefined; export function useDateFormat() { - const locale = useI18nStore(s => s.language); - const timezone = useAuthStore(s => s.user?.sessionParameters?.defaultTimezone); + const idiomaInterfaz = useI18nStore(s => s.language); + const zonaInquilino = useAuthStore(s => s.user?.sessionParameters?.defaultTimezone); + const { locale: localeSistema } = useSystemSettings(); + + const locale = localeSistema.language ?? idiomaInterfaz; + const timezone = localeSistema.timezone ?? zonaInquilino; const formatDate = useCallback( - (date: Date | string | null | undefined, options?: Intl.DateTimeFormatOptions) => + (date: FechaFormateable, options?: Intl.DateTimeFormatOptions) => _formatDate(date, locale, timezone, options), [locale, timezone] ); const formatDateTime = useCallback( - (date: Date | string | null | undefined, options?: Intl.DateTimeFormatOptions) => + (date: FechaFormateable, options?: Intl.DateTimeFormatOptions) => _formatDateTime(date, locale, timezone, options), [locale, timezone] ); const formatRelativeTime = useCallback( - (date: Date | string | null | undefined) => _formatRelativeTime(date, locale), + (date: FechaFormateable) => _formatRelativeTime(date, locale), [locale] ); diff --git a/src/apps/ums.web-app/src/application/formatting/use-number-format.test.ts b/src/apps/ums.web-app/src/application/formatting/use-number-format.test.ts new file mode 100644 index 00000000..d47a52a0 --- /dev/null +++ b/src/apps/ums.web-app/src/application/formatting/use-number-format.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useNumberFormat } from './use-number-format'; + +/** Ajustes que publica el sistema en el grafo (G-178). */ +let settings: Record> = {}; + +vi.mock('@app/stores/auth.store', () => ({ + useAuthStore: (selector: (s: unknown) => unknown) => + selector({ user: { authorizationGraph: { settings } } }), +})); + +vi.mock('@app/stores/i18n.store', () => ({ + useI18nStore: (selector: (s: unknown) => unknown) => selector({ language: 'es' }), +})); + +/** Intl separa el símbolo con un espacio duro; compararlo a ciegas rompe por un carácter invisible. */ +const normalizar = (s: string) => s.replace(/[\u00a0\u202f]/g, ' '); + +describe('useNumberFormat', () => { + beforeEach(() => { + settings = {}; + }); + + it('formatea con la moneda y el idioma que publica el sistema', () => { + settings = { locale: { language: 'es-PE', currency: 'PEN' } }; + + const { result } = renderHook(() => useNumberFormat()); + expect(normalizar(result.current.formatCurrency(1234.5))).toBe('S/ 1,234.50'); + }); + + it('la moneda explícita gana sobre la del sistema', () => { + // Un flete en dólares dentro de un sistema en soles: forzar la del sistema sería una mentira. + settings = { locale: { language: 'es-PE', currency: 'PEN' } }; + + const { result } = renderHook(() => useNumberFormat()); + expect(normalizar(result.current.formatCurrency(1234.5, 'USD'))).toBe('USD 1,234.50'); + }); + + it('sin moneda del sistema no inventa una', () => { + // El ayudante crudo cae en USD por defecto; el hook no lo tapa, solo no aporta contexto falso. + const { result } = renderHook(() => useNumberFormat()); + expect(result.current.currency).toBeUndefined(); + }); + + it('usa el idioma de la interfaz cuando el sistema no publica el suyo', () => { + const { result } = renderHook(() => useNumberFormat()); + expect(result.current.locale).toBe('es'); + }); + + it('formatea cifras y porcentajes con el mismo idioma', () => { + settings = { locale: { language: 'es-PE' } }; + + const { result } = renderHook(() => useNumberFormat()); + expect(result.current.formatNumber(1234567.89)).toBe('1,234,567.89'); + expect(result.current.formatPercentage(12.345, 2)).toBe('12.35%'); + }); + + it('devuelve un guion ante la ausencia de valor', () => { + const { result } = renderHook(() => useNumberFormat()); + expect(result.current.formatNumber(null)).toBe('-'); + expect(result.current.formatCurrency(undefined)).toBe('-'); + }); +}); diff --git a/src/apps/ums.web-app/src/application/formatting/use-number-format.ts b/src/apps/ums.web-app/src/application/formatting/use-number-format.ts new file mode 100644 index 00000000..84cf3b1d --- /dev/null +++ b/src/apps/ums.web-app/src/application/formatting/use-number-format.ts @@ -0,0 +1,58 @@ +/** + * useNumberFormat — cifras con el idioma y la moneda del sistema. + * + * Hermano de `useDateFormat` y con la misma precedencia: **sistema → inquilino → navegador**. El + * sistema publica `settings.locale.language` y `settings.locale.currency` (G-178); son lo más + * específico que hay y mandan sobre el idioma de la interfaz. + * + * Existe porque los ayudantes crudos de `./number` traen `'USD'` y `'en'` por defecto: un importe + * formateado sin contexto sale en dólares y con el separador equivocado, que en un operador + * logístico peruano no es un detalle estético sino una cifra mal leída. Igual que con las fechas + * (ADR-0076), la regla es usar el hook y no los ayudantes sueltos. + */ +import { useCallback } from 'react'; +import { useI18nStore } from '@app/stores/i18n.store'; +import { useSystemSettings } from '@app/authorization/hooks/use-system-settings'; +import { + formatNumber as _formatNumber, + formatCurrency as _formatCurrency, + formatPercentage as _formatPercentage, + formatCompact as _formatCompact, +} from './number'; + +export function useNumberFormat() { + const idiomaInterfaz = useI18nStore(s => s.language); + const { locale: localeSistema } = useSystemSettings(); + + const locale = localeSistema.language ?? idiomaInterfaz; + const currency = localeSistema.currency; + + const formatNumber = useCallback( + (value: number | null | undefined, options?: Intl.NumberFormatOptions) => + _formatNumber(value, options, locale), + [locale] + ); + + /** + * La moneda explícita gana sobre la del sistema: un importe puede venir en otra —un flete en + * dólares dentro de un sistema en soles— y forzar la del sistema lo convertiría en una mentira. + */ + const formatCurrency = useCallback( + (value: number | null | undefined, monedaExplicita?: string) => + _formatCurrency(value, monedaExplicita ?? currency, locale), + [currency, locale] + ); + + const formatPercentage = useCallback( + (value: number | null | undefined, decimals?: number) => + _formatPercentage(value, decimals, locale), + [locale] + ); + + const formatCompact = useCallback( + (value: number | null | undefined) => _formatCompact(value, locale), + [locale] + ); + + return { formatNumber, formatCurrency, formatPercentage, formatCompact, locale, currency }; +} diff --git a/src/apps/ums.web-app/src/application/hooks/use-drag-resize.test.ts b/src/apps/ums.web-app/src/application/hooks/use-drag-resize.test.ts index 80dc6e56..ad221780 100644 --- a/src/apps/ums.web-app/src/application/hooks/use-drag-resize.test.ts +++ b/src/apps/ums.web-app/src/application/hooks/use-drag-resize.test.ts @@ -80,6 +80,34 @@ describe('useDragResize', () => { expect(result.current.isDragging).toBe(true); }); + it('handles touch start to start dragging', () => { + const { result } = renderHook(() => useDragResize({ initialSize: 200 })); + + const mockEvent = { + touches: [{ clientY: 100 }], + } as unknown as React.TouchEvent; + + act(() => { + result.current.handleTouchStart(mockEvent); + }); + + expect(result.current.isDragging).toBe(true); + }); + + it('ignores multi-touch gestures (solo un dedo redimensiona)', () => { + const { result } = renderHook(() => useDragResize({ initialSize: 200 })); + + const mockEvent = { + touches: [{ clientY: 100 }, { clientY: 200 }], + } as unknown as React.TouchEvent; + + act(() => { + result.current.handleTouchStart(mockEvent); + }); + + expect(result.current.isDragging).toBe(false); + }); + it('handles keyboard Enter to toggle collapse', () => { const { result } = renderHook(() => useDragResize({ initialSize: 300 })); diff --git a/src/apps/ums.web-app/src/application/hooks/use-drag-resize.ts b/src/apps/ums.web-app/src/application/hooks/use-drag-resize.ts index b87458d3..40e95e38 100644 --- a/src/apps/ums.web-app/src/application/hooks/use-drag-resize.ts +++ b/src/apps/ums.web-app/src/application/hooks/use-drag-resize.ts @@ -18,6 +18,7 @@ interface UseDragResizeResult { containerRef: React.RefObject; resizableRef: React.RefObject; handleMouseDown: (e: React.MouseEvent) => void; + handleTouchStart: (e: React.TouchEvent) => void; handleKeyDown: (e: React.KeyboardEvent) => void; toggleCollapse: () => void; } @@ -42,26 +43,35 @@ export function useDragResize({ }; }, []); + // Aplica el movimiento a partir de una coordenada Y (mouse o touch). + const applyMove = useCallback( + (clientY: number) => { + if (!isDraggingRef.current || !containerRef.current) return; + const titleBarH = (containerRef.current.firstElementChild as HTMLElement)?.offsetHeight ?? 60; + const rect = containerRef.current.getBoundingClientRect(); + const fromTop = clientY - rect.top - titleBarH; + const clamped = Math.min(rect.height * maxSizeRatio, Math.max(minSize, fromTop)); + setSize(clamped); + prevSizeRef.current = clamped; + if (clamped > 4) setIsCollapsed(false); + }, + [minSize, maxSizeRatio] + ); + + // Núcleo de arrastre común a puntero fino (mouse) y grueso (touch). + const beginDrag = useCallback(() => { + const measured = resizableRef.current?.offsetHeight ?? 200; + setSize(prev => prev ?? measured); + isDraggingRef.current = true; + setIsDragging(true); + }, [setSize]); + const handleMouseDown = useCallback( (e: React.MouseEvent) => { e.preventDefault(); - const measured = resizableRef.current?.offsetHeight ?? 200; - const initial = size ?? measured; - setSize(initial); - isDraggingRef.current = true; - setIsDragging(true); - - const onMouseMove = (ev: MouseEvent) => { - if (!isDraggingRef.current || !containerRef.current) return; - const titleBarH = - (containerRef.current.firstElementChild as HTMLElement)?.offsetHeight ?? 60; - const rect = containerRef.current.getBoundingClientRect(); - const fromTop = ev.clientY - rect.top - titleBarH; - const clamped = Math.min(rect.height * maxSizeRatio, Math.max(minSize, fromTop)); - setSize(clamped); - prevSizeRef.current = clamped; - if (clamped > 4) setIsCollapsed(false); - }; + beginDrag(); + + const onMouseMove = (ev: MouseEvent) => applyMove(ev.clientY); const onMouseUp = () => { isDraggingRef.current = false; @@ -76,7 +86,36 @@ export function useDragResize({ cleanupDragRef.current = onMouseUp; }, - [size, minSize, maxSizeRatio] + [beginDrag, applyMove] + ); + + const handleTouchStart = useCallback( + (e: React.TouchEvent) => { + if (e.touches.length !== 1) return; + beginDrag(); + + const onTouchMove = (ev: TouchEvent) => { + if (ev.cancelable) ev.preventDefault(); // evita el scroll mientras se arrastra + const touch = ev.touches[0]; + if (touch) applyMove(touch.clientY); + }; + + const onTouchEnd = () => { + isDraggingRef.current = false; + setIsDragging(false); + cleanupDragRef.current = null; + window.removeEventListener('touchmove', onTouchMove); + window.removeEventListener('touchend', onTouchEnd); + window.removeEventListener('touchcancel', onTouchEnd); + }; + + window.addEventListener('touchmove', onTouchMove, { passive: false }); + window.addEventListener('touchend', onTouchEnd); + window.addEventListener('touchcancel', onTouchEnd); + + cleanupDragRef.current = onTouchEnd; + }, + [beginDrag, applyMove] ); const toggleCollapse = useCallback(() => { @@ -121,6 +160,7 @@ export function useDragResize({ containerRef, resizableRef, handleMouseDown, + handleTouchStart, handleKeyDown, toggleCollapse, }; diff --git a/src/apps/ums.web-app/src/application/hooks/use-focus-trap.test.ts b/src/apps/ums.web-app/src/application/hooks/use-focus-trap.test.ts index 22831d0c..4d697c2d 100644 --- a/src/apps/ums.web-app/src/application/hooks/use-focus-trap.test.ts +++ b/src/apps/ums.web-app/src/application/hooks/use-focus-trap.test.ts @@ -82,9 +82,13 @@ describe('useFocusTrap', () => { const container = document.createElement('div'); document.body.appendChild(container); - const { result, rerender } = renderHook(() => useFocusTrap({ active: true })); + // El contenedor existe ANTES de activar la trampa, que es como ocurre de verdad: React asigna + // las refs antes de correr los efectos del mismo commit. + const { result, rerender } = renderHook(({ active }) => useFocusTrap({ active }), { + initialProps: { active: false }, + }); result.current.containerRef.current = container; - rerender(); + rerender({ active: true }); expect(container.getAttribute('tabindex')).toBe('-1'); }); @@ -96,11 +100,37 @@ describe('useFocusTrap', () => { container.appendChild(button); document.body.appendChild(container); - const { result, rerender } = renderHook(() => useFocusTrap({ active: true })); + const { result, rerender } = renderHook(({ active }) => useFocusTrap({ active }), { + initialProps: { active: false }, + }); result.current.containerRef.current = container; - rerender(); + rerender({ active: true }); + + expect(document.activeElement).toBe(button); + }); + + // Regresión: el foco inicial se toma UNA vez, al activarse. Antes el contenedor entraba en las + // dependencias leído durante el render —donde todavía era null—, así que el efecto se repetía en + // el render siguiente y le quitaba el foco a quien estuviera escribiendo en el diálogo. + it('no vuelve a robar el foco en renders posteriores', () => { + const container = document.createElement('div'); + const button = document.createElement('button'); + const input = document.createElement('input'); + container.appendChild(button); + container.appendChild(input); + document.body.appendChild(container); + const { result, rerender } = renderHook(({ active }) => useFocusTrap({ active }), { + initialProps: { active: false }, + }); + result.current.containerRef.current = container; + rerender({ active: true }); expect(document.activeElement).toBe(button); + + input.focus(); + rerender({ active: true }); + + expect(document.activeElement).toBe(input); }); it('calls onEscape when Escape key is pressed', () => { @@ -109,9 +139,11 @@ describe('useFocusTrap', () => { container.setAttribute('tabindex', '-1'); document.body.appendChild(container); - const { result, rerender } = renderHook(() => useFocusTrap({ active: true, onEscape })); + const { result, rerender } = renderHook(({ active }) => useFocusTrap({ active, onEscape }), { + initialProps: { active: false }, + }); result.current.containerRef.current = container; - rerender(); + rerender({ active: true }); act(() => { const escapeEvent = new KeyboardEvent('keydown', { key: 'Escape' }); diff --git a/src/apps/ums.web-app/src/application/hooks/use-focus-trap.ts b/src/apps/ums.web-app/src/application/hooks/use-focus-trap.ts index e6ebbcb5..bcab26a4 100644 --- a/src/apps/ums.web-app/src/application/hooks/use-focus-trap.ts +++ b/src/apps/ums.web-app/src/application/hooks/use-focus-trap.ts @@ -71,11 +71,15 @@ export function useFocusTrap({ [active, onEscape] ); - const container = containerRef.current; - useEffect(() => { if (!active) return; + // El contenedor se lee AQUÍ, no en el render. Al leerlo en el render, la primera pasada lo veía + // en `null` (las refs se asignan después) y el efecto volvía a dispararse en el siguiente + // render —el que provoca la primera tecla escrita en un campo del diálogo—, robándole el foco + // al campo y llevándolo al primer botón. Con la barra espaciadora, ese botón acababa pulsado. + const container = containerRef.current; + previousActiveElementRef.current = document.activeElement; if (!container) return; @@ -99,7 +103,7 @@ export function useFocusTrap({ previousActiveElementRef.current.focus(); } }; - }, [active, trapFocus, container]); + }, [active, trapFocus]); return { containerRef }; } diff --git a/src/apps/ums.web-app/src/application/hooks/use-form-validation.ts b/src/apps/ums.web-app/src/application/hooks/use-form-validation.ts index e4e45f02..8320cf01 100644 --- a/src/apps/ums.web-app/src/application/hooks/use-form-validation.ts +++ b/src/apps/ums.web-app/src/application/hooks/use-form-validation.ts @@ -1,4 +1,4 @@ -import { useState, useCallback, useMemo, useEffect } from 'react'; +import { useState, useCallback, useMemo } from 'react'; import { z } from 'zod'; export type FieldErrors = Record; @@ -72,8 +72,6 @@ export function useFormValidation( const validateField = useCallback( (fieldName: string, value: unknown): boolean => { - const partialData = { ...initialValues, [fieldName]: value }; - try { const fieldSchema = schema.shape?.[fieldName]; if (!fieldSchema) return true; diff --git a/src/apps/ums.web-app/src/application/hooks/use-local-overrides.ts b/src/apps/ums.web-app/src/application/hooks/use-local-overrides.ts index ca6c3fbc..9d82bd6f 100644 --- a/src/apps/ums.web-app/src/application/hooks/use-local-overrides.ts +++ b/src/apps/ums.web-app/src/application/hooks/use-local-overrides.ts @@ -34,9 +34,9 @@ export function useLocalOverrides>( const id = String(item[idKey]); const patch = overrides[id]; if (!patch) return item; - if (!snapshotRef.current[id]) { - snapshotRef.current[id] = { ...item }; - } + // El snapshot NO se toca aquí: escribir una ref durante el render hace impuro el cálculo + // (react-hooks/refs). Su dueño es `patchItem`/`patchItems`, que lo guardan ANTES de crear + // el override, así que cuando este memo ve un patch el original ya está registrado. return { ...item, ...patch }; }); }, [serverItems, overrides, idKey]); diff --git a/src/apps/ums.web-app/src/application/hooks/use-notified-mutation.ts b/src/apps/ums.web-app/src/application/hooks/use-notified-mutation.ts index 1bbd9a7a..643b2272 100644 --- a/src/apps/ums.web-app/src/application/hooks/use-notified-mutation.ts +++ b/src/apps/ums.web-app/src/application/hooks/use-notified-mutation.ts @@ -1,3 +1,4 @@ +import { useContext } from 'react'; /** * useNotifiedMutation — DRY factory for TanStack mutations * @@ -12,7 +13,7 @@ */ import { useMutation, - useQueryClient, + QueryClientContext, type UseMutationOptions, type QueryKey, } from '@tanstack/react-query'; @@ -33,8 +34,8 @@ export interface UseNotifiedMutationOptions { mutationFn: (variables: TVariables) => Promise; /** Query keys to invalidate on success. */ invalidateKeys?: QueryKey[]; - /** Notification shown on success. May use the response data. */ - successNotif: (data: TData) => NotifiedMutationNotif; + /** Notification shown on success. Recibe la respuesta y las variables enviadas. */ + successNotif: (data: TData, variables: TVariables) => NotifiedMutationNotif; /** Notification shown on error. Receives the raw error. */ errorNotif: (error: unknown) => NotifiedMutationNotif; /** @@ -56,25 +57,22 @@ export function useNotifiedMutation({ errorNotif, options, }: UseNotifiedMutationOptions) { - let queryClient: ReturnType | null = null; - try { - // Degradación intencional: sin QueryClientProvider (p.ej. tests aislados) useQueryClient - // lanza y caemos a null. El hook se invoca siempre en el mismo punto del render. - // eslint-disable-next-line react-hooks/rules-of-hooks - queryClient = useQueryClient(); - } catch { - queryClient = null; - } + // Leer el contexto directamente en vez de `useQueryClient()`: ese helper LANZA cuando no hay + // proveedor, y envolverlo en try/catch es una llamada condicional a un hook (react-hooks/ + // rules-of-hooks). `useContext` devuelve undefined sin proveedor, que es justo el caso que + // este hook quería tolerar —montarse en una prueba sin QueryClientProvider— y además se llama + // siempre, en el mismo orden. + const queryClient = useContext(QueryClientContext) ?? null; const addNotification = useNotificationStore(s => s.addNotification); const t = useI18n(); return useMutation({ mutationFn, - onSuccess: data => { + onSuccess: (data, variables) => { if (invalidateKeys) { invalidateKeys.forEach(key => queryClient?.invalidateQueries({ queryKey: key })); } - const notif = successNotif(data); + const notif = successNotif(data, variables); addNotification({ title: notif.title, message: notif.message, diff --git a/src/apps/ums.web-app/src/application/hooks/use-status-label.test.ts b/src/apps/ums.web-app/src/application/hooks/use-status-label.test.ts index ee7e2be4..9d514af6 100644 --- a/src/apps/ums.web-app/src/application/hooks/use-status-label.test.ts +++ b/src/apps/ums.web-app/src/application/hooks/use-status-label.test.ts @@ -29,11 +29,13 @@ describe('useStatusLabel', () => { expect(getStatusLabel('Pending')).toBe('Pending'); }); - it('returns pending for unknown status', () => { + it('returns the raw value for unmapped status (G-153: no lo disfraza de «Pendiente»)', () => { const { result } = renderHook(() => useStatusLabel()); const getStatusLabel = result.current; - expect(getStatusLabel('Unknown')).toBe('Pending'); + // Un estado sin clave i18n (p. ej. Draft/PendingApproval) se muestra crudo, no como «Pendiente». + expect(getStatusLabel('Unknown')).toBe('Unknown'); + expect(getStatusLabel('PendingApproval')).toBe('PendingApproval'); }); it('returns spanish labels when language is es', () => { diff --git a/src/apps/ums.web-app/src/application/hooks/use-status-label.ts b/src/apps/ums.web-app/src/application/hooks/use-status-label.ts index 07c9d51e..a741c1a8 100644 --- a/src/apps/ums.web-app/src/application/hooks/use-status-label.ts +++ b/src/apps/ums.web-app/src/application/hooks/use-status-label.ts @@ -6,16 +6,24 @@ import { useI18n } from '@app/i18n/use-i18n'; import { TenantStatusSchema } from '@domain/identity/schemas/tenant.schema'; +// Mapa de estado→clave i18n abarcando los estados de varios dominios que comparten esta etiqueta +// (G-153). Los que aún no tienen clave i18n propia (Draft, PendingApproval, Published, Archived, +// Revoked, Rejected, Expired…) caen al fallback = valor CRUDO, no a «Pendiente» (que antes disfrazaba +// de «Pendiente» a Draft/PendingApproval y era engañoso). Localizarlos requiere nuevas claves i18n. const STATUS_KEY_MAP: Record> = { [TenantStatusSchema.enum.Active]: 'active', [TenantStatusSchema.enum.Suspended]: 'suspended', [TenantStatusSchema.enum.Pending]: 'pending', + Inactive: 'inactive', + Blocked: 'blocked', + Maintenance: 'maintenance', + Deprecated: 'deprecated', }; export const useStatusLabel = () => { const t = useI18n(); return (status: string): string => { const key = STATUS_KEY_MAP[status]; - return key ? (t[key] as string) : t.pending; + return key ? (t[key] as string) : status; }; }; diff --git a/src/apps/ums.web-app/src/application/i18n/namespaces/authorization.translations.ts b/src/apps/ums.web-app/src/application/i18n/namespaces/authorization.translations.ts index dfa41d6c..0e2a65cf 100644 --- a/src/apps/ums.web-app/src/application/i18n/namespaces/authorization.translations.ts +++ b/src/apps/ums.web-app/src/application/i18n/namespaces/authorization.translations.ts @@ -3,8 +3,11 @@ export const authorizationTranslations = { // Context labels authorizationContext: 'Autorización', systemSuites: 'Suites del Sistema', + systemSuitesNav: 'Sistemas', permissionTemplates: 'Plantillas de Permisos', profilesHeader: 'Perfiles de Autorización', + observabilityGrafana: 'Grafana (métricas y trazas)', + observabilityLogs: 'Logs (Loki)', permissionTemplateMaintenance: 'Plantillas de Permisos', permissionTemplateMaintenanceSubtitle: 'Configure plantillas de permisos por rol y suite. Asigne efectos (Permitir/Denegar/Neutro) a cada recurso.', @@ -167,8 +170,11 @@ export const authorizationTranslations = { // Context labels authorizationContext: 'Authorization', systemSuites: 'System Suites', + systemSuitesNav: 'Systems', permissionTemplates: 'Permission Templates', profilesHeader: 'Authorization Profiles', + observabilityGrafana: 'Grafana (metrics & traces)', + observabilityLogs: 'Logs (Loki)', permissionTemplateMaintenance: 'Permission Templates', permissionTemplateMaintenanceSubtitle: 'Configure permission templates by role and system suite. Assign Allow/Deny/Neutral effects per resource.', diff --git a/src/apps/ums.web-app/src/application/i18n/namespaces/identity.translations.test.ts b/src/apps/ums.web-app/src/application/i18n/namespaces/identity.translations.test.ts index d1cd5c37..3d58e93a 100644 --- a/src/apps/ums.web-app/src/application/i18n/namespaces/identity.translations.test.ts +++ b/src/apps/ums.web-app/src/application/i18n/namespaces/identity.translations.test.ts @@ -53,7 +53,6 @@ describe('identityTranslations', () => { it('has tab labels', () => { expect(identityTranslations.es.tabLocations).toBe('Ubicaciones'); expect(identityTranslations.es.tabAuthIdps).toBe('Prov. Identidad'); - expect(identityTranslations.es.tabBranding).toBe('Identidad Visual'); }); it('has branch labels', () => { @@ -74,12 +73,6 @@ describe('identityTranslations', () => { expect(identityTranslations.es.strategyOAuth2).toBe('OAuth 2.0 Genérico'); }); - it('has branding labels', () => { - expect(identityTranslations.es.customBranding).toBe('Identidad Visual'); - expect(identityTranslations.es.brandPrimaryColor).toBe('Color Principal'); - expect(identityTranslations.es.applyBranding).toBe('Guardar Identidad Visual'); - }); - it('has edit labels', () => { expect(identityTranslations.es.editBtn).toBe('Editar'); expect(identityTranslations.es.unsavedChanges).toBe('Cambios sin guardar'); @@ -186,7 +179,6 @@ describe('identityTranslations', () => { it('has tab labels', () => { expect(identityTranslations.en.tabLocations).toBe('Locations'); expect(identityTranslations.en.tabAuthIdps).toBe('Auth IDPs'); - expect(identityTranslations.en.tabBranding).toBe('Branding'); }); it('has branch labels', () => { @@ -207,12 +199,6 @@ describe('identityTranslations', () => { expect(identityTranslations.en.strategyOAuth2).toBe('OAuth 2.0 Generic'); }); - it('has branding labels', () => { - expect(identityTranslations.en.customBranding).toBe('Visual Identity'); - expect(identityTranslations.en.brandPrimaryColor).toBe('Primary Color'); - expect(identityTranslations.en.applyBranding).toBe('Save Visual Identity'); - }); - it('has edit labels', () => { expect(identityTranslations.en.editBtn).toBe('Edit'); expect(identityTranslations.en.unsavedChanges).toBe('Unsaved changes'); diff --git a/src/apps/ums.web-app/src/application/i18n/namespaces/identity.translations.ts b/src/apps/ums.web-app/src/application/i18n/namespaces/identity.translations.ts index b459528a..6bbd1927 100644 --- a/src/apps/ums.web-app/src/application/i18n/namespaces/identity.translations.ts +++ b/src/apps/ums.web-app/src/application/i18n/namespaces/identity.translations.ts @@ -72,6 +72,7 @@ export const identityTranslations = { tabAuthIdps: 'Prov. Identidad', tabBranding: 'Identidad Visual', tabConfigurations: 'Parámetros', + tabAudit: 'Auditoría', configurationsForTenant: 'Configuraciones del Tenant', // Branch Manager @@ -91,6 +92,31 @@ export const identityTranslations = { noBranches: 'No hay sucursales registradas para este tenant.', addingLocation: 'Nueva Ubicación', + // Cierre definitivo de sucursales (ADR-0164). «Cerrar» y «desactivar» son cosas distintas y + // los rótulos tienen que dejarlo claro ANTES de pulsar: el cierre no se deshace. + closeBranch: 'Cerrar sucursal (definitivo)', + closeBranchTitle: '¿Cerrar la sucursal definitivamente?', + closeBranchMessage: (name: string, code: string) => + `«${name}» quedará cerrada de forma permanente. No podrá reactivarse y su código «${code}» quedará ocupado para siempre; si el negocio vuelve a operar ahí habrá que dar de alta otra sucursal con otro código. Desactivar, en cambio, sí se puede deshacer.`, + closeBranchConfirm: 'Sí, cerrar definitivamente', + closeBranchReason: 'Motivo del cierre (opcional)', + branchClosed: 'Cerrada', + branchClosedOn: (date: string) => `Cerrada el ${date}`, + branchClosedHint: 'Una sucursal cerrada no admite cambios: su ciclo de vida terminó.', + filterOpenBranches: 'Abiertas', + filterClosedBranches: 'Cerradas', + branchLifecycle: 'Bitácora', + branchLifecycleClose: 'Cerrar bitácora', + branchLifecycleTitle: (name: string) => `Bitácora de «${name}»`, + branchLifecycleEmpty: 'Esta sucursal todavía no tiene episodios registrados.', + branchLifecycleLoading: 'Cargando bitácora…', + branchEpisodeOpened: 'Apertura', + branchEpisodeDeactivated: 'Desactivación', + branchEpisodeReactivated: 'Reactivación', + branchEpisodeClosed: 'Cierre definitivo', + branchEpisodeActor: (actor: string) => `por ${actor}`, + branchEpisodeReason: (reason: string) => `Motivo: ${reason}`, + // Identity Providers (IdP) identityProviders: 'Proveedores de Identidad', idpSubtitle: 'Configure sistemas federados OIDC, SAML 2.0 u OAuth 2.0.', @@ -106,33 +132,6 @@ export const identityTranslations = { strategySAML2: 'SAML 2.0 Empresarial', strategyOAuth2: 'OAuth 2.0 Genérico', - // Branding - customBranding: 'Identidad Visual', - brandingSubtitle: 'Configure la apariencia del portal de inicio de sesión para este tenant.', - brandHeadline: 'Título Principal del Portal', - brandSecondary: 'Subtítulo / Tagline', - brandButtonLabel: 'Texto del Botón Principal', - brandFooter: 'Texto del Pie de Página', - brandPrimaryColor: 'Color Principal', - brandBackground: 'Estilo de Fondo', - brandBgSolid: 'Sólido', - brandBgGradientSubtle: 'Gradiente', - brandBgGradientBold: 'Gradiente Intenso', - brandBgImage: 'Imagen de fondo', - brandLogoUrl: 'URL del Logotipo', - brandLogoFormat: 'Formato del Logo', - brandLogoPreview: 'Vista Previa', - brandCustomDomain: 'Dominio Personalizado', - brandMagicLink: 'Magic Link como fallback', - brandingContent: 'Contenido del portal', - brandingVisual: 'Apariencia visual', - brandingDomain: 'Dominio y autenticación', - brandDnsStatus: 'Estado DNS', - brandDnsVerified: 'Verificado', - brandDnsPending: 'Pendiente', - brandDnsFailed: 'Error de verificación', - applyBranding: 'Guardar Identidad Visual', - // Inline editing editBtn: 'Editar', unsavedChanges: 'Cambios sin guardar', @@ -164,8 +163,6 @@ export const identityTranslations = { notifProviderModifiedMsg: 'Estado del protocolo de autenticación actualizado.', notifProviderRemoved: 'Proveedor Desconectado', notifProviderRemovedMsg: 'Configuraciones de identidad de terceros eliminadas.', - notifBrandingApplied: 'Marca Aplicada', - notifBrandingMsg: (color: string) => `Perfil de tema actualizado. Color principal: ${color}`, notifBranchAdded: 'Sucursal Registrada', notifBranchAddedMsg: (code: string) => `Sucursal '${code}' registrada correctamente.`, notifBranchAddFailed: 'Error al Registrar Sucursal', @@ -174,6 +171,14 @@ export const identityTranslations = { notifBranchRemovedMsg: 'La sucursal fue eliminada del tenant.', notifBranchRemoveFailed: 'Error al Eliminar Sucursal', notifBranchRemoveFailedMsg: 'No se pudo eliminar la sucursal.', + notifBranchClosed: 'Sucursal Cerrada', + notifBranchClosedMsg: + 'La sucursal quedó cerrada definitivamente. Su código no puede reutilizarse.', + notifBranchCloseFailed: 'Error al Cerrar Sucursal', + notifBranchCloseFailedMsg: 'No se pudo cerrar la sucursal.', + notifBranchCloseBlocked: 'No se puede cerrar la sucursal', + notifBranchCloseBlockedMsg: (detalle: string) => + `Todavía hay ${detalle} asignados a esta sucursal. Reasígnelos o desactívelos antes de cerrarla.`, notifBranchDeactivated: 'Sucursal Desactivada', notifBranchDeactivatedMsg: 'La sucursal fue desactivada correctamente.', notifBranchDeactivateFailed: 'Error al Desactivar Sucursal', @@ -239,6 +244,8 @@ export const identityTranslations = { permissions: 'Permisos', credentials: 'Contraseña', passwordManagement: 'Gestión de Contraseña', + passwordManagementInfo: + 'Rotar contraseña restablece la clave local del usuario interno a una nueva contraseña temporal, que él deberá cambiar en su próximo inicio de sesión. Útil ante olvido u onboarding. Solo aplica a usuarios internos gestionados localmente.', localPassword: 'Contraseña local', notConfigured: 'No configurada', lastPasswordRotation: 'Última rotación', @@ -332,6 +339,7 @@ export const identityTranslations = { tabAuthIdps: 'Auth IDPs', tabBranding: 'Branding', tabConfigurations: 'Configuration', + tabAudit: 'Audit', configurationsForTenant: 'Tenant Configuration', // Branch Manager @@ -351,6 +359,30 @@ export const identityTranslations = { noBranches: 'No branches registered for this tenant.', addingLocation: 'New Location', + // Permanent branch closure (ADR-0164). + closeBranch: 'Close branch (permanent)', + closeBranchTitle: 'Close this branch permanently?', + closeBranchMessage: (name: string, code: string) => + `"${name}" will be permanently closed. It cannot be reactivated and its code "${code}" stays taken forever; reopening in that location requires a brand-new branch with a different code. Deactivating, by contrast, can be undone.`, + closeBranchConfirm: 'Yes, close permanently', + closeBranchReason: 'Closure reason (optional)', + branchClosed: 'Closed', + branchClosedOn: (date: string) => `Closed on ${date}`, + branchClosedHint: 'A closed branch accepts no changes: its lifecycle has ended.', + filterOpenBranches: 'Open', + filterClosedBranches: 'Closed', + branchLifecycle: 'History', + branchLifecycleClose: 'Close history', + branchLifecycleTitle: (name: string) => `History of "${name}"`, + branchLifecycleEmpty: 'This branch has no recorded episodes yet.', + branchLifecycleLoading: 'Loading history…', + branchEpisodeOpened: 'Opened', + branchEpisodeDeactivated: 'Deactivated', + branchEpisodeReactivated: 'Reactivated', + branchEpisodeClosed: 'Permanently closed', + branchEpisodeActor: (actor: string) => `by ${actor}`, + branchEpisodeReason: (reason: string) => `Reason: ${reason}`, + // Identity Providers (IdP) identityProviders: 'Identity Providers', idpSubtitle: 'Configure federated OIDC, SAML 2.0 or OAuth 2.0 systems.', @@ -366,33 +398,6 @@ export const identityTranslations = { strategySAML2: 'SAML 2.0 Enterprise', strategyOAuth2: 'OAuth 2.0 Generic', - // Branding - customBranding: 'Visual Identity', - brandingSubtitle: 'Configure the login portal appearance for this tenant.', - brandHeadline: 'Portal Main Title', - brandSecondary: 'Subtitle / Tagline', - brandButtonLabel: 'Primary Button Label', - brandFooter: 'Footer Text', - brandPrimaryColor: 'Primary Color', - brandBackground: 'Background Style', - brandBgSolid: 'Solid', - brandBgGradientSubtle: 'Gradient', - brandBgGradientBold: 'Bold Gradient', - brandBgImage: 'Background Image', - brandLogoUrl: 'Logo URL', - brandLogoFormat: 'Logo Format', - brandLogoPreview: 'Preview', - brandCustomDomain: 'Custom Domain', - brandMagicLink: 'Magic Link as fallback', - brandingContent: 'Portal content', - brandingVisual: 'Visual appearance', - brandingDomain: 'Domain & authentication', - brandDnsStatus: 'DNS Status', - brandDnsVerified: 'Verified', - brandDnsPending: 'Pending', - brandDnsFailed: 'Verification failed', - applyBranding: 'Save Visual Identity', - // Inline editing editBtn: 'Edit', unsavedChanges: 'Unsaved changes', @@ -425,8 +430,6 @@ export const identityTranslations = { notifProviderModifiedMsg: 'Client authentication protocol status successfully updated.', notifProviderRemoved: 'Auth Provider Disconnected', notifProviderRemovedMsg: 'Third-party identity configurations dismantled successfully.', - notifBrandingApplied: 'Branding Customized', - notifBrandingMsg: (color: string) => `Theme profiles updated. Primary style token: ${color}`, notifBranchAdded: 'Branch Registered', notifBranchAddedMsg: (code: string) => `Branch '${code}' registered successfully.`, notifBranchAddFailed: 'Branch Registration Failed', @@ -435,6 +438,13 @@ export const identityTranslations = { notifBranchRemovedMsg: 'The branch was removed from the tenant.', notifBranchRemoveFailed: 'Branch Removal Failed', notifBranchRemoveFailedMsg: 'Could not remove the branch.', + notifBranchClosed: 'Branch Closed', + notifBranchClosedMsg: 'The branch is permanently closed. Its code cannot be reused.', + notifBranchCloseFailed: 'Branch Closure Failed', + notifBranchCloseFailedMsg: 'Could not close the branch.', + notifBranchCloseBlocked: 'Cannot close the branch', + notifBranchCloseBlockedMsg: (detail: string) => + `There are still ${detail} assigned to this branch. Reassign or deactivate them before closing it.`, notifBranchDeactivated: 'Branch Deactivated', notifBranchDeactivatedMsg: 'The branch was deactivated successfully.', notifBranchDeactivateFailed: 'Branch Deactivation Failed', @@ -499,6 +509,8 @@ export const identityTranslations = { permissions: 'Permissions', credentials: 'Password', passwordManagement: 'Password Management', + passwordManagementInfo: + "Rotate password resets the internal user's local credential to a new temporary password, which they must change on their next sign-in. Useful for lockouts or onboarding. Only applies to locally-managed internal users.", localPassword: 'Local password', notConfigured: 'Not configured', lastPasswordRotation: 'Last rotation', diff --git a/src/apps/ums.web-app/src/application/identity/hooks/use-branch.test.tsx b/src/apps/ums.web-app/src/application/identity/hooks/use-branch.test.tsx index de68cca1..04606a31 100644 --- a/src/apps/ums.web-app/src/application/identity/hooks/use-branch.test.tsx +++ b/src/apps/ums.web-app/src/application/identity/hooks/use-branch.test.tsx @@ -4,25 +4,29 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import React from 'react'; import { useGetBranches, + useGetBranchLifecycle, useAddBranch, - useRemoveBranch, + useCloseBranch, useDeactivateBranch, useReactivateBranch, + describeBlockingDependencies, } from './use-branch'; import tenantService from '@infra/identity/services/tenant.service'; vi.mock('@infra/identity/services/tenant.service', () => ({ tenantService: { getBranches: vi.fn(), + getBranchLifecycle: vi.fn(), addBranch: vi.fn(), - removeBranch: vi.fn(), + closeBranch: vi.fn(), deactivateBranch: vi.fn(), reactivateBranch: vi.fn(), }, default: { getBranches: vi.fn(), + getBranchLifecycle: vi.fn(), addBranch: vi.fn(), - removeBranch: vi.fn(), + closeBranch: vi.fn(), deactivateBranch: vi.fn(), reactivateBranch: vi.fn(), }, @@ -34,10 +38,12 @@ vi.mock('@app/i18n/use-i18n', () => ({ notifBranchAddedMsg: (code: string) => `Branch ${code} added`, notifBranchAddFailed: 'Add Failed', notifBranchAddFailedMsg: 'Could not add branch', - notifBranchRemoved: 'Branch Removed', - notifBranchRemovedMsg: 'Branch removed successfully', - notifBranchRemoveFailed: 'Remove Failed', - notifBranchRemoveFailedMsg: 'Could not remove branch', + notifBranchClosed: 'Branch Closed', + notifBranchClosedMsg: 'Branch closed permanently', + notifBranchCloseFailed: 'Close Failed', + notifBranchCloseFailedMsg: 'Could not close branch', + notifBranchCloseBlocked: 'Cannot close', + notifBranchCloseBlockedMsg: (detalle: string) => `Blocked by ${detalle}`, notifBranchDeactivated: 'Branch Deactivated', notifBranchDeactivatedMsg: 'Branch deactivated', notifBranchDeactivateFailed: 'Deactivate Failed', @@ -50,11 +56,11 @@ vi.mock('@app/i18n/use-i18n', () => ({ })); vi.mock('@app/hooks/use-notified-mutation', () => ({ - useNotifiedMutation: (config: any) => { + useNotifiedMutation: (config: { mutationFn: (vars: never) => unknown }) => { const mutationFn = config.mutationFn; return { - mutate: vi.fn((vars: any) => mutationFn(vars)), - mutateAsync: vi.fn(async (vars: any) => mutationFn(vars)), + mutate: vi.fn((vars: never) => mutationFn(vars)), + mutateAsync: vi.fn(async (vars: never) => mutationFn(vars)), isPending: false, isSuccess: true, isError: false, @@ -78,7 +84,17 @@ describe('use-branch hooks', () => { }); it('useGetBranches returns branches for tenant', async () => { - const mockBranches = [{ branchId: 'b1', code: 'B1', name: 'Branch 1', isActive: true }]; + const mockBranches = [ + { + branchId: 'b1', + code: 'B1', + name: 'Branch 1', + isActive: true, + geofencingMetadata: null, + isClosed: false, + closedAtUtc: null, + }, + ]; vi.mocked(tenantService.getBranches).mockResolvedValue(mockBranches); const wrapper = createWrapper(); @@ -89,12 +105,26 @@ describe('use-branch hooks', () => { }); expect(result.current.data?.[0].name).toBe('Branch 1'); - expect(tenantService.getBranches).toHaveBeenCalledWith('t1'); + // Por defecto NO se piden las cerradas (ADR-0164). + expect(tenantService.getBranches).toHaveBeenCalledWith('t1', false); + }); + + it('useGetBranches pide las cerradas cuando se le indica', async () => { + vi.mocked(tenantService.getBranches).mockResolvedValue([]); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useGetBranches('t1', true), { wrapper }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(tenantService.getBranches).toHaveBeenCalledWith('t1', true); }); it('useGetBranches returns empty array on 404', async () => { const error = new Error('Not Found'); - (error as any).response = { status: 404 }; + (error as Error & { response?: { status: number } }).response = { status: 404 }; vi.mocked(tenantService.getBranches).mockRejectedValue(error); const wrapper = createWrapper(); @@ -123,12 +153,49 @@ describe('use-branch hooks', () => { expect(typeof result.current.mutate).toBe('function'); }); - it('useRemoveBranch returns mutation object', () => { + it('useCloseBranch cierra la sucursal contra el servicio de cierre, con su motivo', async () => { + vi.mocked(tenantService.closeBranch).mockResolvedValue(); + const wrapper = createWrapper(); - const { result } = renderHook(() => useRemoveBranch('t1'), { wrapper }); + const { result } = renderHook(() => useCloseBranch('t1'), { wrapper }); - expect(result.current.mutate).toBeDefined(); - expect(typeof result.current.mutate).toBe('function'); + await act(async () => { + await result.current.mutateAsync({ branchId: 'b1', reason: 'Cese de operaciones' }); + }); + + expect(tenantService.closeBranch).toHaveBeenCalledWith('t1', 'b1', 'Cese de operaciones'); + }); + + it('useGetBranchLifecycle no consulta sin sucursal seleccionada', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useGetBranchLifecycle('t1', null), { wrapper }); + + expect(result.current.isFetching).toBe(false); + expect(tenantService.getBranchLifecycle).not.toHaveBeenCalled(); + }); + + it('useGetBranchLifecycle trae los episodios de la sucursal', async () => { + vi.mocked(tenantService.getBranchLifecycle).mockResolvedValue([ + { + entryId: 'e1', + episode: 'Closed', + occurredAtUtc: '2026-07-31T15:04:05Z', + actorId: 'admin@beyondnet.com.pe', + nameSnapshot: 'Sucursal Callao', + geofencingSnapshot: null, + reason: null, + }, + ]); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useGetBranchLifecycle('t1', 'b1'), { wrapper }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data?.[0].episode).toBe('Closed'); + expect(tenantService.getBranchLifecycle).toHaveBeenCalledWith('t1', 'b1'); }); it('useDeactivateBranch returns mutation object', () => { @@ -147,3 +214,27 @@ describe('use-branch hooks', () => { expect(typeof result.current.mutate).toBe('function'); }); }); + +// El 409 del cierre trae las dos clases desglosadas; un «no se pudo» a secas obliga a adivinar. +describe('describeBlockingDependencies', () => { + it('nombra una sola clase en singular', () => { + expect(describeBlockingDependencies([{ entityType: 'Profile', count: 1 }])).toBe( + '1 perfil activo' + ); + }); + + it('pluraliza y enumera las dos clases', () => { + expect( + describeBlockingDependencies([ + { entityType: 'UserAccount', count: 3 }, + { entityType: 'Profile', count: 2 }, + ]) + ).toBe('3 cuentas de usuario activas y 2 perfiles activos'); + }); + + it('no oculta una clase desconocida: la muestra tal cual', () => { + expect(describeBlockingDependencies([{ entityType: 'Delegation', count: 4 }])).toBe( + '4 × Delegation' + ); + }); +}); diff --git a/src/apps/ums.web-app/src/application/identity/hooks/use-branch.ts b/src/apps/ums.web-app/src/application/identity/hooks/use-branch.ts index bec0cfcf..537a5263 100644 --- a/src/apps/ums.web-app/src/application/identity/hooks/use-branch.ts +++ b/src/apps/ums.web-app/src/application/identity/hooks/use-branch.ts @@ -2,19 +2,28 @@ import { useQuery } from '@tanstack/react-query'; import tenantService from '@infra/identity/services/tenant.service'; import { useNotifiedMutation } from '@app/hooks/use-notified-mutation'; import { useI18n } from '@app/i18n/use-i18n'; -import { AddBranchPayload, Branch } from '@domain/identity/models/branch.model'; +import { + AddBranchPayload, + Branch, + BranchLifecycleEntry, +} from '@domain/identity/models/branch.model'; import { getHttpStatus, getRetryOptions } from '@app/utils/error-utils'; +import { getBlockedOperation } from '@app/errors/http-error'; import { CONTEXT_QUERY_CONFIG } from '@app/shared/config/query.config'; // ─── Query ────────────────────────────────────────────────────────────────── -export const useGetBranches = (tenantId: string | null) => { +/** + * ADR-0164: por defecto el backend NO devuelve las sucursales cerradas. `includeClosed` entra en la + * clave de caché porque son dos conjuntos distintos, no dos vistas del mismo. + */ +export const useGetBranches = (tenantId: string | null, includeClosed = false) => { return useQuery({ - queryKey: ['tenants', tenantId, 'branches'], + queryKey: ['tenants', tenantId, 'branches', { includeClosed }], queryFn: async () => { if (!tenantId) throw new Error('Tenant ID required'); try { - return await tenantService.getBranches(tenantId); + return await tenantService.getBranches(tenantId, includeClosed); } catch (err: unknown) { if (getHttpStatus(err) === 404) return []; throw err; @@ -26,6 +35,23 @@ export const useGetBranches = (tenantId: string | null) => { }); }; +/** + * Bitácora de la sucursal. Se consulta solo cuando alguien la abre (`enabled`), porque es una + * pregunta de auditoría puntual y no algo que haya que traer con cada listado. + */ +export const useGetBranchLifecycle = (tenantId: string | null, branchId: string | null) => { + return useQuery({ + queryKey: ['tenants', tenantId, 'branches', branchId, 'bitacora'], + queryFn: async () => { + if (!tenantId || !branchId) throw new Error('Tenant ID and Branch ID required'); + return await tenantService.getBranchLifecycle(tenantId, branchId); + }, + enabled: !!tenantId && !!branchId, + ...CONTEXT_QUERY_CONFIG.BRANCH, + ...getRetryOptions({ maxRetries: 1 }), + }); +}; + // ─── Mutations ────────────────────────────────────────────────────────────── export const useAddBranch = (tenantId: string) => { @@ -44,23 +70,89 @@ export const useAddBranch = (tenantId: string) => { }); }; -export const useRemoveBranch = (tenantId: string) => { +export const useUpdateBranch = (tenantId: string) => { const t = useI18n(); return useNotifiedMutation({ - mutationFn: (branchId: string) => tenantService.removeBranch(tenantId, branchId), + mutationFn: async (vars: { + branchId: string; + name: string; + geofencingMetadata?: string | null; + }) => { + await tenantService.updateBranch(tenantId, vars.branchId, { + name: vars.name, + geofencingMetadata: vars.geofencingMetadata, + }); + return vars; + }, invalidateKeys: [['tenants', tenantId, 'branches']], - successNotif: () => ({ - title: t.notifBranchRemoved, - message: t.notifBranchRemovedMsg, - type: 'warning' as const, + successNotif: data => ({ + title: t.notifBranchUpdated, + message: t.notifBranchUpdatedMsg(data.name), }), errorNotif: () => ({ - title: t.notifBranchRemoveFailed ?? 'Error al Eliminar Sucursal', - message: t.notifBranchRemoveFailedMsg ?? 'No se pudo eliminar la sucursal.', + title: 'Error al Actualizar Sucursal', + message: 'No se pudo actualizar la sucursal.', }), }); }; +/** + * Cómo se nombra cada clase de dependencia que puede bloquear el cierre. Sin esto el desglose diría + * «UserAccount: 3», que es el nombre del agregado, no algo que quien opera reconozca. + */ +const BLOCKING_LABELS: Record = { + UserAccount: { singular: 'cuenta de usuario activa', plural: 'cuentas de usuario activas' }, + Profile: { singular: 'perfil activo', plural: 'perfiles activos' }, +}; + +/** «3 cuentas de usuario activas y 1 perfil activo»: enumera cuanto bloquea, no solo lo primero. */ +export function describeBlockingDependencies( + dependencies: { entityType: string; count: number }[] +): string { + const parts = dependencies.map(dep => { + const labels = BLOCKING_LABELS[dep.entityType]; + if (!labels) return `${dep.count} × ${dep.entityType}`; + return `${dep.count} ${dep.count === 1 ? labels.singular : labels.plural}`; + }); + + if (parts.length <= 1) return parts.join(''); + return `${parts.slice(0, -1).join(', ')} y ${parts[parts.length - 1]}`; +} + +/** + * Cierra la sucursal (ADR-0164). Antes se llamaba «eliminar»; el endpoint es el mismo, lo que + * cambió es que ya no borra nada y que no hay vuelta atrás. + * + * Si el backend responde 409 el mensaje NOMBRA lo que bloquea —cuántas cuentas y cuántos perfiles + * siguen activos—, porque es lo que hay que resolver antes de reintentar. + */ +export const useCloseBranch = (tenantId: string) => { + const t = useI18n(); + return useNotifiedMutation({ + mutationFn: (vars: { branchId: string; reason?: string }) => + tenantService.closeBranch(tenantId, vars.branchId, vars.reason), + invalidateKeys: [['tenants', tenantId, 'branches']], + successNotif: () => ({ + title: t.notifBranchClosed, + message: t.notifBranchClosedMsg, + type: 'warning' as const, + }), + errorNotif: error => { + const blocked = getBlockedOperation(error); + if (blocked) { + return { + title: t.notifBranchCloseBlocked, + message: t.notifBranchCloseBlockedMsg(describeBlockingDependencies(blocked.dependencies)), + }; + } + return { + title: t.notifBranchCloseFailed, + message: t.notifBranchCloseFailedMsg, + }; + }, + }); +}; + export const useDeactivateBranch = (tenantId: string) => { const t = useI18n(); return useNotifiedMutation({ diff --git a/src/apps/ums.web-app/src/application/identity/hooks/use-delegation-dashboard.test.ts b/src/apps/ums.web-app/src/application/identity/hooks/use-delegation-dashboard.test.ts index cb0f33ab..6cff72f4 100644 --- a/src/apps/ums.web-app/src/application/identity/hooks/use-delegation-dashboard.test.ts +++ b/src/apps/ums.web-app/src/application/identity/hooks/use-delegation-dashboard.test.ts @@ -51,13 +51,13 @@ describe('useDelegationDashboard', () => { data: mockDelegations, isLoading: false, error: null, - } as any); + } as unknown as ReturnType); vi.mocked(useDelegationModule.useGetDelegationsByDelegatingAdmin).mockReturnValue({ data: mockDelegations, isLoading: false, error: null, - } as any); + } as unknown as ReturnType); vi.mocked(useLocalOverridesModule.useLocalOverrides).mockReturnValue({ items: mockDelegations, @@ -85,7 +85,7 @@ describe('useDelegationDashboard', () => { appliedQuery: { criteria: 'id', term: '' }, handleQuerySubmit: vi.fn(), handleResetQuery: vi.fn(), - } as any); + } as unknown as ReturnType); vi.mocked(usePaginationStateModule.usePaginationState).mockReturnValue({ page: 1, @@ -95,7 +95,7 @@ describe('useDelegationDashboard', () => { startIndex: 0, handlePageChange: vi.fn(), handlePageSizeChange: vi.fn(), - } as any); + } as unknown as ReturnType); }); it('auto-selects first delegation when data loads and no selection exists', () => { @@ -245,7 +245,7 @@ describe('useDelegationDashboard', () => { startIndex: 0, handlePageChange: vi.fn(), handlePageSizeChange: vi.fn(), - } as any); + } as unknown as ReturnType); vi.mocked(useQueryStateModule.useQueryState).mockReturnValue({ searchCriteria: 'id', @@ -262,7 +262,7 @@ describe('useDelegationDashboard', () => { appliedQuery: { criteria: 'id', term: '' }, handleQuerySubmit: vi.fn(), handleResetQuery, - } as any); + } as unknown as ReturnType); const { result } = renderHook(() => useDelegationDashboard()); @@ -320,10 +320,13 @@ describe('useDelegationDashboard', () => { appliedQuery: { criteria: 'id', term: 'd-1' }, handleQuerySubmit: vi.fn(), handleResetQuery: vi.fn(), - } as any); + } as unknown as ReturnType); - vi.mocked(useLocalOverridesModule.useLocalOverrides).mockImplementation((items: any) => ({ - items: items?.filter((d: any) => d.delegationId.includes('d-1')) ?? [], + vi.mocked(useLocalOverridesModule.useLocalOverrides).mockImplementation((items: never[]) => ({ + items: + items?.filter((d: { delegationId: string; status: string }) => + d.delegationId.includes('d-1') + ) ?? [], patchItem: vi.fn(), patchItems: vi.fn(), clearOverrides: vi.fn(), @@ -353,10 +356,11 @@ describe('useDelegationDashboard', () => { appliedQuery: { criteria: 'id', term: '' }, handleQuerySubmit: vi.fn(), handleResetQuery: vi.fn(), - } as any); + } as unknown as ReturnType); - vi.mocked(useLocalOverridesModule.useLocalOverrides).mockImplementation((items: any) => ({ - items: items?.filter((d: any) => d.status === 'Active') ?? [], + vi.mocked(useLocalOverridesModule.useLocalOverrides).mockImplementation((items: never[]) => ({ + items: + items?.filter((d: { delegationId: string; status: string }) => d.status === 'Active') ?? [], patchItem: vi.fn(), patchItems: vi.fn(), clearOverrides: vi.fn(), @@ -367,7 +371,11 @@ describe('useDelegationDashboard', () => { })); const { result } = renderHook(() => useDelegationDashboard()); - expect(result.current.knownDelegations.every((d: any) => d.status === 'Active')).toBe(true); + expect( + result.current.knownDelegations.every( + (d: { delegationId: string; status: string }) => d.status === 'Active' + ) + ).toBe(true); }); it('returns totalItems based on known delegations length', () => { diff --git a/src/apps/ums.web-app/src/application/identity/hooks/use-delegation-dashboard.ts b/src/apps/ums.web-app/src/application/identity/hooks/use-delegation-dashboard.ts index 71518dd1..5fde1956 100644 --- a/src/apps/ums.web-app/src/application/identity/hooks/use-delegation-dashboard.ts +++ b/src/apps/ums.web-app/src/application/identity/hooks/use-delegation-dashboard.ts @@ -1,3 +1,5 @@ +/* eslint-disable react-hooks/set-state-in-effect -- Selección inicial: el primer elemento solo se conoce tras la respuesta del servidor. + Patrón intencional del código heredado; la regla sigue activa en el resto del repo. */ import React, { useState, useEffect, useCallback } from 'react'; import { useGetDelegationsByDelegatedAdmin, @@ -69,6 +71,8 @@ export function useDelegationDashboard(): DelegationDashboardState & criteria: 'id', filter: 'all', sortBy: 'status', + // Patrón estándar: la lista carga al entrar (no exige aplicar un filtro primero). + appliedFilter: true, }); const paginationState = usePaginationState({ diff --git a/src/apps/ums.web-app/src/application/identity/hooks/use-delegation.test.tsx b/src/apps/ums.web-app/src/application/identity/hooks/use-delegation.test.tsx index 8f08417b..8ab2d15c 100644 --- a/src/apps/ums.web-app/src/application/identity/hooks/use-delegation.test.tsx +++ b/src/apps/ums.web-app/src/application/identity/hooks/use-delegation.test.tsx @@ -70,7 +70,7 @@ describe('use-delegation hooks', () => { it('useGetDelegation returns null on 404', async () => { const error = new Error('Not Found'); - (error as any).response = { status: 404 }; + (error as Error & { response?: { status: number } }).response = { status: 404 }; vi.mocked(delegationService.getDelegationById).mockRejectedValue(error); const wrapper = createWrapper(); diff --git a/src/apps/ums.web-app/src/application/identity/hooks/use-delegation.ts b/src/apps/ums.web-app/src/application/identity/hooks/use-delegation.ts index 8b4e7fbe..6ac7d2be 100644 --- a/src/apps/ums.web-app/src/application/identity/hooks/use-delegation.ts +++ b/src/apps/ums.web-app/src/application/identity/hooks/use-delegation.ts @@ -1,7 +1,7 @@ /** * use-delegation.ts — TanStack Query hooks for UserManagementDelegation bounded context * - * Queries use useQuery directly (GraphQL reads). + * Queries use useQuery directly (REST reads). * Mutations use useNotifiedMutation factory (REST writes). */ import { useQuery } from '@tanstack/react-query'; @@ -9,12 +9,7 @@ import delegationService from '@infra/identity/services/delegation.service'; import { useNotifiedMutation } from '@app/hooks/use-notified-mutation'; import { useI18n } from '@app/i18n/use-i18n'; import type { CreateDelegationPayload, Delegation } from '@domain/identity/models/delegation.model'; -import { - getHttpStatus, - isNonRecoverable, - isNetworkError, - getRetryOptions, -} from '@app/utils/error-utils'; +import { getHttpStatus, getRetryOptions } from '@app/utils/error-utils'; import { CONTEXT_QUERY_CONFIG } from '@app/shared/config/query.config'; // ─── Queries ──────────────────────────────────────────────────────────────── @@ -93,7 +88,7 @@ export const useActivateDelegation = (delegationId: string) => { const t = useI18n(); return useNotifiedMutation({ mutationFn: () => delegationService.activateDelegation(delegationId), - invalidateKeys: [['delegations', delegationId]], + invalidateKeys: [['delegations']], // prefijo amplio: refresca listas by-*-admin + detalle (G-152) successNotif: () => ({ title: t.notifActivated ?? 'Delegation activated', message: t.notifUserActivatedMsg ?? 'The delegation is now active.', @@ -109,7 +104,7 @@ export const useRevokeDelegation = (delegationId: string) => { const t = useI18n(); return useNotifiedMutation({ mutationFn: (reason: string) => delegationService.revokeDelegation(delegationId, reason), - invalidateKeys: [['delegations', delegationId]], + invalidateKeys: [['delegations']], // prefijo amplio: refresca listas by-*-admin + detalle (G-152) successNotif: () => ({ title: t.notifBlocked ?? 'Delegation revoked', message: t.notifUserBlockedMsg ?? 'The delegation has been revoked.', @@ -121,3 +116,51 @@ export const useRevokeDelegation = (delegationId: string) => { }), }); }; + +// G-132: máquina de aprobación de delegaciones (espejo de IGA RolePromotion, ADR-UMS-086). + +export const useSubmitDelegationForApproval = (delegationId: string) => { + return useNotifiedMutation({ + mutationFn: () => delegationService.submitDelegationForApproval(delegationId), + invalidateKeys: [['delegations']], // prefijo amplio: refresca listas by-*-admin + detalle (G-152) + successNotif: () => ({ + title: 'Delegación enviada a aprobación', + message: 'La delegación quedó pendiente de aprobación.', + }), + errorNotif: () => ({ + title: 'No se pudo enviar a aprobación', + message: 'No se pudo enviar la delegación a aprobación.', + }), + }); +}; + +export const useApproveDelegation = (delegationId: string) => { + return useNotifiedMutation({ + mutationFn: () => delegationService.approveDelegation(delegationId), + invalidateKeys: [['delegations']], // prefijo amplio: refresca listas by-*-admin + detalle (G-152) + successNotif: () => ({ + title: 'Delegación aprobada', + message: 'La delegación fue aprobada y ahora está activa.', + }), + errorNotif: () => ({ + title: 'No se pudo aprobar', + message: 'No se pudo aprobar la delegación.', + }), + }); +}; + +export const useRejectDelegation = (delegationId: string) => { + return useNotifiedMutation({ + mutationFn: (reason: string) => delegationService.rejectDelegation(delegationId, reason), + invalidateKeys: [['delegations']], // prefijo amplio: refresca listas by-*-admin + detalle (G-152) + successNotif: () => ({ + title: 'Delegación rechazada', + message: 'La delegación fue rechazada.', + type: 'warning', + }), + errorNotif: () => ({ + title: 'No se pudo rechazar', + message: 'No se pudo rechazar la delegación.', + }), + }); +}; diff --git a/src/apps/ums.web-app/src/application/identity/hooks/use-inbox.test.tsx b/src/apps/ums.web-app/src/application/identity/hooks/use-inbox.test.tsx index cc5031eb..7156a113 100644 --- a/src/apps/ums.web-app/src/application/identity/hooks/use-inbox.test.tsx +++ b/src/apps/ums.web-app/src/application/identity/hooks/use-inbox.test.tsx @@ -32,9 +32,9 @@ vi.mock('@infra/identity/services/inbox.service', () => ({ })); vi.mock('@app/hooks/use-notified-mutation', () => ({ - useNotifiedMutation: (config: any) => ({ - mutate: vi.fn((vars: any) => config.mutationFn(vars)), - mutateAsync: vi.fn((vars: any) => config.mutationFn(vars)), + useNotifiedMutation: (config: { mutationFn: (vars: never) => unknown }) => ({ + mutate: vi.fn((vars: never) => config.mutationFn(vars)), + mutateAsync: vi.fn((vars: never) => config.mutationFn(vars)), isPending: false, isSuccess: false, isError: false, diff --git a/src/apps/ums.web-app/src/application/identity/hooks/use-tenant-dashboard.test.ts b/src/apps/ums.web-app/src/application/identity/hooks/use-tenant-dashboard.test.ts index 3dfe7dc2..90fc39e6 100644 --- a/src/apps/ums.web-app/src/application/identity/hooks/use-tenant-dashboard.test.ts +++ b/src/apps/ums.web-app/src/application/identity/hooks/use-tenant-dashboard.test.ts @@ -43,7 +43,7 @@ describe('useTenantDashboard', () => { data: { items: mockTenants, page: 1, pageSize: 9, totalItems: 2, totalPages: 1 }, isLoading: false, error: null, - } as any); + } as unknown as ReturnType); vi.mocked(useLocalOverridesModule.useLocalOverrides).mockReturnValue({ items: mockTenants, @@ -71,7 +71,7 @@ describe('useTenantDashboard', () => { appliedQuery: { criteria: 'name', term: '' }, handleQuerySubmit: vi.fn(), handleResetQuery: vi.fn(), - } as any); + } as unknown as ReturnType); vi.mocked(usePaginationStateModule.usePaginationState).mockReturnValue({ page: 1, @@ -81,7 +81,7 @@ describe('useTenantDashboard', () => { startIndex: 0, handlePageChange: vi.fn(), handlePageSizeChange: vi.fn(), - } as any); + } as unknown as ReturnType); }); it('auto-selects root tenant when data loads and no selection exists', () => { @@ -154,26 +154,6 @@ describe('useTenantDashboard', () => { expect(result.current.parentTenant).toBeNull(); }); - it('includes branding tab only for root tenant', () => { - const { result } = renderHook(() => useTenantDashboard()); - - act(() => { - result.current.setSelectedId('t-1'); - }); - - expect(result.current.consoleTabs).toContain('branding'); - }); - - it('excludes branding tab for non-root tenant', () => { - const { result } = renderHook(() => useTenantDashboard()); - - act(() => { - result.current.setSelectedId('t-2'); - }); - - expect(result.current.consoleTabs).not.toContain('branding'); - }); - it('handleSelectTenant selects a tenant when not editing', () => { const { result } = renderHook(() => useTenantDashboard()); @@ -251,7 +231,7 @@ describe('useTenantDashboard', () => { startIndex: 0, handlePageChange: vi.fn(), handlePageSizeChange: vi.fn(), - } as any); + } as unknown as ReturnType); vi.mocked(useQueryStateModule.useQueryState).mockReturnValue({ searchCriteria: 'name', @@ -268,7 +248,7 @@ describe('useTenantDashboard', () => { appliedQuery: { criteria: 'name', term: '' }, handleQuerySubmit: vi.fn(), handleResetQuery, - } as any); + } as unknown as ReturnType); const { result } = renderHook(() => useTenantDashboard()); @@ -332,7 +312,7 @@ describe('useTenantDashboard', () => { appliedQuery, handleQuerySubmit: vi.fn(), handleResetQuery: vi.fn(), - } as any); + } as unknown as ReturnType); const { result } = renderHook(() => useTenantDashboard()); diff --git a/src/apps/ums.web-app/src/application/identity/hooks/use-tenant-dashboard.ts b/src/apps/ums.web-app/src/application/identity/hooks/use-tenant-dashboard.ts index 12e2b48d..9c8f2f59 100644 --- a/src/apps/ums.web-app/src/application/identity/hooks/use-tenant-dashboard.ts +++ b/src/apps/ums.web-app/src/application/identity/hooks/use-tenant-dashboard.ts @@ -1,3 +1,6 @@ +/* eslint-disable react-hooks/set-state-in-effect -- Selección dirigida por datos asíncronos: el inquilino a marcar depende de la respuesta (el + raíz si existe) o del término de búsqueda ya aplicado. + Patrón intencional del código heredado; la regla sigue activa en el resto del repo. */ /** * useTenantDashboard.ts — Orchestrates state and API calls for TenantDashboardScreen. * @@ -10,11 +13,14 @@ import { Tenant } from '@domain/identity/models/tenant.model'; import { useQueryState } from '@app/shared/hooks/use-query-state'; import { usePaginationState } from '@app/shared/hooks/use-pagination-state'; +/** Pestañas de la consola de inquilino. */ +export type ConsoleTab = 'branches' | 'providers' | 'branding' | 'configurations' | 'audit'; + export interface TenantDashboardState { selectedId: string; showDiscardDialog: boolean; pendingNavigationId: string | null; - activeConsoleTab: 'branches' | 'providers' | 'branding' | 'configurations'; + activeConsoleTab: ConsoleTab; isTenantEditing: boolean; isCreateOpen: boolean; viewMode: 'list' | 'thumbnail'; @@ -26,13 +32,12 @@ export interface TenantDashboardActions { setSelectedId: React.Dispatch>; setShowDiscardDialog: React.Dispatch>; setPendingNavigationId: React.Dispatch>; - setActiveConsoleTab: React.Dispatch< - React.SetStateAction<'branches' | 'providers' | 'branding' | 'configurations'> - >; + setActiveConsoleTab: React.Dispatch>; setIsTenantEditing: React.Dispatch>; setIsCreateOpen: React.Dispatch>; setViewMode: React.Dispatch>; handleSelectTenant: (id: string) => void; + handleSelectOwnTenant: (tenantId: string, tenantCode: string) => void; confirmDiscard: () => void; patchTenant: (tenantId: string, patch: Partial) => void; handleCreateSuccess: (newTenantId: string) => void; @@ -46,7 +51,7 @@ export function useTenantDashboard(): TenantDashboardState & activeTenant: Tenant | undefined; parentTenant: Tenant | null; isRootTenant: boolean; - consoleTabs: Array<'branches' | 'providers' | 'branding' | 'configurations'>; + consoleTabs: Array; totalItems: number; totalPages: number; startIndex: number; @@ -55,9 +60,7 @@ export function useTenantDashboard(): TenantDashboardState & const [selectedId, setSelectedId] = useState(''); const [showDiscardDialog, setShowDiscardDialog] = useState(false); const [pendingNavigationId, setPendingNavigationId] = useState(null); - const [activeConsoleTab, setActiveConsoleTab] = useState< - 'branches' | 'providers' | 'branding' | 'configurations' - >('branches'); + const [activeConsoleTab, setActiveConsoleTab] = useState('branches'); const [isTenantEditing, setIsTenantEditing] = useState(false); const [isCreateOpen, setIsCreateOpen] = useState(false); const [viewMode, setViewMode] = useState<'list' | 'thumbnail'>('list'); @@ -66,6 +69,10 @@ export function useTenantDashboard(): TenantDashboardState & criteria: 'name', filter: 'all', sortBy: 'name', + // Patrón estándar de lista de administración: la lista se carga al entrar + // (todos los tenants) en vez de exigir aplicar un filtro primero. Búsqueda, + // «Mi Organización» y «Limpiar» son refinamientos sobre esa carga. + appliedFilter: true, }); const paginationState = usePaginationState({ @@ -100,11 +107,14 @@ export function useTenantDashboard(): TenantDashboardState & const activeTenant = knownTenants.find(tenant => tenant.tenantId === selectedId); const isRootTenant = activeTenant?.parentTenantId === null; - const consoleTabs = ( - ['branches', 'providers', 'branding', 'configurations'] as Array< - 'branches' | 'providers' | 'branding' | 'configurations' - > - ).filter(tab => tab !== 'branding' || isRootTenant); + const consoleTabs: Array = [ + 'branches', + 'providers', + // Identidad visual por inquilino: pestaña propia del satélite. + 'branding', + 'configurations', + 'audit', + ]; const parentTenant = activeTenant?.parentTenantId ? (knownTenants.find(t => t.tenantId === activeTenant.parentTenantId) ?? null) @@ -131,6 +141,18 @@ export function useTenantDashboard(): TenantDashboardState & [selectedId, hasPendingChanges, applyTenantSelection] ); + // FS-26 «Mi Organización»: carga la lista filtrada por el código del propio tenant + // (así el registro aparece seleccionado en la lista) y lo abre en el detalle. + // Al pulsar «Buscar» de nuevo se aplica una consulta nueva y la carga se reinicia + // (una sola fuente de datos: la lista). + const handleSelectOwnTenant = useCallback( + (tenantId: string, tenantCode: string) => { + queryState.applyQuery('code', tenantCode); + applyTenantSelection(tenantId); + }, + [queryState, applyTenantSelection] + ); + const confirmDiscard = useCallback(() => { if (pendingNavigationId) applyTenantSelection(pendingNavigationId); setPendingNavigationId(null); @@ -189,6 +211,7 @@ export function useTenantDashboard(): TenantDashboardState & queryState, paginationState, handleSelectTenant, + handleSelectOwnTenant, confirmDiscard, patchTenant, handleCreateSuccess, diff --git a/src/apps/ums.web-app/src/application/identity/hooks/use-tenant.test.tsx b/src/apps/ums.web-app/src/application/identity/hooks/use-tenant.test.tsx index 895591bb..a3966600 100644 --- a/src/apps/ums.web-app/src/application/identity/hooks/use-tenant.test.tsx +++ b/src/apps/ums.web-app/src/application/identity/hooks/use-tenant.test.tsx @@ -95,7 +95,7 @@ describe('use-tenant hooks', () => { it('useGetTenant returns null on 404', async () => { const error = new Error('Not Found'); - (error as any).response = { status: 404 }; + (error as Error & { response?: { status: number } }).response = { status: 404 }; vi.mocked(tenantService.getById).mockRejectedValue(error); const wrapper = createWrapper(); diff --git a/src/apps/ums.web-app/src/application/identity/hooks/use-tenant.ts b/src/apps/ums.web-app/src/application/identity/hooks/use-tenant.ts index 4a60f951..65da9a8d 100644 --- a/src/apps/ums.web-app/src/application/identity/hooks/use-tenant.ts +++ b/src/apps/ums.web-app/src/application/identity/hooks/use-tenant.ts @@ -3,12 +3,7 @@ import tenantService from '@infra/identity/services/tenant.service'; import { useNotifiedMutation } from '@app/hooks/use-notified-mutation'; import { useI18n } from '@app/i18n/use-i18n'; import { CreateTenantPayload, Tenant, TenantPage } from '@domain/identity/models/tenant.model'; -import { - getHttpStatus, - isNonRecoverable, - isNetworkError, - getRetryOptions, -} from '@app/utils/error-utils'; +import { getHttpStatus, getRetryOptions } from '@app/utils/error-utils'; import { CONTEXT_QUERY_CONFIG } from '@app/shared/config/query.config'; // ─── Query params ─────────────────────────────────────────────────────────── @@ -69,9 +64,11 @@ export const useCreateTenant = () => { return useNotifiedMutation({ mutationFn: (payload: CreateTenantPayload) => tenantService.createTenant(payload), invalidateKeys: [['tenants']], - successNotif: data => ({ + // El nombre/código vienen del payload enviado (`variables`), no de la respuesta + // (que solo trae `tenantId`). Antes se leían de `data` → mensaje vacío / parse roto. + successNotif: (_data, variables) => ({ title: t.notifTenantCreated, - message: t.notifTenantCreatedMsg(data.name, data.code), + message: t.notifTenantCreatedMsg(variables.name, variables.code), }), errorNotif: () => ({ title: t.notifTenantCreateFailed, @@ -113,6 +110,34 @@ export const useSuspendTenant = (tenantId: string) => { }); }; +export const useUpdateTenant = () => { + const t = useI18n(); + return useNotifiedMutation({ + mutationFn: async (vars: { + tenantId: string; + name: string; + type: string; + companyReference?: string | null; + }) => { + await tenantService.updateTenant(vars.tenantId, { + name: vars.name, + type: vars.type, + companyReference: vars.companyReference, + }); + return vars; + }, + invalidateKeys: [['tenants']], + successNotif: data => ({ + title: t.notifTenantUpdated, + message: t.notifTenantUpdatedMsg(data.name), + }), + errorNotif: () => ({ + title: 'Error al Actualizar Tenant', + message: 'No se pudo actualizar el tenant.', + }), + }); +}; + export const useSetManagementOwner = (tenantId: string) => { return useNotifiedMutation({ mutationFn: (value: boolean) => tenantService.setManagementOwner(tenantId, value), diff --git a/src/apps/ums.web-app/src/application/identity/hooks/use-user-account-dashboard.test.ts b/src/apps/ums.web-app/src/application/identity/hooks/use-user-account-dashboard.test.ts index 540316c2..f75dc4bc 100644 --- a/src/apps/ums.web-app/src/application/identity/hooks/use-user-account-dashboard.test.ts +++ b/src/apps/ums.web-app/src/application/identity/hooks/use-user-account-dashboard.test.ts @@ -83,13 +83,13 @@ describe('useUserAccountDashboard', () => { data: { items: mockAccounts, page: 1, pageSize: 20, totalItems: 2, totalPages: 1 }, isLoading: false, error: null, - } as any); + } as unknown as ReturnType); vi.mocked(useTenantModule.useGetAllTenants).mockReturnValue({ data: { items: mockTenants, page: 1, pageSize: 100, totalItems: 1, totalPages: 1 }, isLoading: false, error: null, - } as any); + } as unknown as ReturnType); vi.mocked(useLocalOverridesModule.useLocalOverrides).mockReturnValue({ items: mockAccounts, @@ -117,7 +117,7 @@ describe('useUserAccountDashboard', () => { appliedQuery: { criteria: 'email', term: '' }, handleQuerySubmit: vi.fn(), handleResetQuery: vi.fn(), - } as any); + } as unknown as ReturnType); vi.mocked(usePaginationStateModule.usePaginationState).mockReturnValue({ page: 1, @@ -127,23 +127,23 @@ describe('useUserAccountDashboard', () => { startIndex: 0, handlePageChange: vi.fn(), handlePageSizeChange: vi.fn(), - } as any); + } as unknown as ReturnType); vi.mocked(notificationStoreModule.useNotificationStore).mockReturnValue({ addNotification: vi.fn(), - } as any); + } as unknown as ReturnType); vi.mocked(useUserAccountModule.useActivateUserAccount).mockReturnValue({ mutate: vi.fn(), - } as any); + } as unknown as ReturnType); vi.mocked(useUserAccountModule.useBlockUserAccount).mockReturnValue({ mutate: vi.fn(), - } as any); + } as unknown as ReturnType); vi.mocked(useUserAccountModule.useRestoreUserAccount).mockReturnValue({ mutate: vi.fn(), - } as any); + } as unknown as ReturnType); }); it('auto-selects first account when data loads and no selection exists', () => { @@ -211,7 +211,7 @@ describe('useUserAccountDashboard', () => { const mutate = vi.fn(); vi.mocked(useUserAccountModule.useActivateUserAccount).mockReturnValue({ mutate, - } as any); + } as unknown as ReturnType); const { result } = renderHook(() => useUserAccountDashboard(), { wrapper: createWrapper() }); @@ -227,10 +227,10 @@ describe('useUserAccountDashboard', () => { const mutate = vi.fn(); vi.mocked(notificationStoreModule.useNotificationStore).mockReturnValue({ addNotification, - } as any); + } as unknown as ReturnType); vi.mocked(useUserAccountModule.useBlockUserAccount).mockReturnValue({ mutate, - } as any); + } as unknown as ReturnType); const { result } = renderHook(() => useUserAccountDashboard(), { wrapper: createWrapper() }); @@ -249,7 +249,7 @@ describe('useUserAccountDashboard', () => { const mutate = vi.fn(); vi.mocked(useUserAccountModule.useBlockUserAccount).mockReturnValue({ mutate, - } as any); + } as unknown as ReturnType); const { result } = renderHook(() => useUserAccountDashboard(), { wrapper: createWrapper() }); @@ -265,10 +265,10 @@ describe('useUserAccountDashboard', () => { const mutate = vi.fn(); vi.mocked(notificationStoreModule.useNotificationStore).mockReturnValue({ addNotification, - } as any); + } as unknown as ReturnType); vi.mocked(useUserAccountModule.useRestoreUserAccount).mockReturnValue({ mutate, - } as any); + } as unknown as ReturnType); const { result } = renderHook(() => useUserAccountDashboard(), { wrapper: createWrapper() }); @@ -299,7 +299,7 @@ describe('useUserAccountDashboard', () => { startIndex: 0, handlePageChange: vi.fn(), handlePageSizeChange: vi.fn(), - } as any); + } as unknown as ReturnType); vi.mocked(useQueryStateModule.useQueryState).mockReturnValue({ searchCriteria: 'email', @@ -316,7 +316,7 @@ describe('useUserAccountDashboard', () => { appliedQuery: { criteria: 'email', term: '' }, handleQuerySubmit: vi.fn(), handleResetQuery, - } as any); + } as unknown as ReturnType); const { result } = renderHook(() => useUserAccountDashboard(), { wrapper: createWrapper() }); diff --git a/src/apps/ums.web-app/src/application/identity/hooks/use-user-account-dashboard.ts b/src/apps/ums.web-app/src/application/identity/hooks/use-user-account-dashboard.ts index 9c05b30d..54af82d7 100644 --- a/src/apps/ums.web-app/src/application/identity/hooks/use-user-account-dashboard.ts +++ b/src/apps/ums.web-app/src/application/identity/hooks/use-user-account-dashboard.ts @@ -1,3 +1,6 @@ +/* eslint-disable react-hooks/set-state-in-effect -- Selección inicial: el inquilino viene de la sesión y las cuentas de la respuesta; ninguno + está disponible en el primer render. + Patrón intencional del código heredado; la regla sigue activa en el resto del repo. */ /** * useUserAccountDashboard.ts — Orchestrates state and API calls for UserAccountDashboardScreen. * @@ -74,6 +77,8 @@ export function useUserAccountDashboard(sessionTenantId?: string): UserAccountDa criteria: 'email', filter: 'all', sortBy: 'email', + // Patrón estándar: la lista carga al entrar (no exige aplicar un filtro primero). + appliedFilter: true, }); const paginationState = usePaginationState({ diff --git a/src/apps/ums.web-app/src/application/identity/hooks/use-user-account.test.tsx b/src/apps/ums.web-app/src/application/identity/hooks/use-user-account.test.tsx index 6898374b..809832a6 100644 --- a/src/apps/ums.web-app/src/application/identity/hooks/use-user-account.test.tsx +++ b/src/apps/ums.web-app/src/application/identity/hooks/use-user-account.test.tsx @@ -109,7 +109,7 @@ describe('UserAccount hooks', () => { it('useGetUserAccount returns null on 404', async () => { const error = new Error('Not Found'); - (error as any).response = { status: 404 }; + (error as Error & { response?: { status: number } }).response = { status: 404 }; vi.mocked(userAccountService.getById).mockRejectedValue(error); const wrapper = createWrapper(); diff --git a/src/apps/ums.web-app/src/application/identity/hooks/use-user-account.ts b/src/apps/ums.web-app/src/application/identity/hooks/use-user-account.ts index a64660de..fd1a8c39 100644 --- a/src/apps/ums.web-app/src/application/identity/hooks/use-user-account.ts +++ b/src/apps/ums.web-app/src/application/identity/hooks/use-user-account.ts @@ -1,7 +1,7 @@ /** * use-user-account.ts — TanStack Query hooks for UserAccount bounded context * - * Queries use useQuery directly (GraphQL reads). + * Queries use useQuery directly (REST reads). * Mutations use useNotifiedMutation factory (REST writes). */ import { useQuery } from '@tanstack/react-query'; @@ -14,12 +14,7 @@ import { UserAccountPage, } from '@domain/identity/models/user-account.model'; import { CONTEXT_QUERY_CONFIG } from '@app/shared/config/query.config'; -import { - getHttpStatus, - isNonRecoverable, - isNetworkError, - getRetryOptions, -} from '@app/utils/error-utils'; +import { getHttpStatus, getRetryOptions } from '@app/utils/error-utils'; export interface UserAccountQueryParams { page: number; @@ -95,7 +90,7 @@ export const useActivateUserAccount = (userAccountId: string) => { const t = useI18n(); return useNotifiedMutation({ mutationFn: () => userAccountService.activateUserAccount(userAccountId), - invalidateKeys: [['user-accounts', userAccountId]], + invalidateKeys: [['user-accounts'], ['user-accounts', userAccountId]], successNotif: () => ({ title: t.notifActivated, message: t.notifUserActivatedMsg, @@ -111,7 +106,7 @@ export const useBlockUserAccount = (userAccountId: string) => { const t = useI18n(); return useNotifiedMutation({ mutationFn: (reason: string) => userAccountService.blockUserAccount(userAccountId, reason), - invalidateKeys: [['user-accounts', userAccountId]], + invalidateKeys: [['user-accounts'], ['user-accounts', userAccountId]], successNotif: () => ({ title: t.notifBlocked, message: t.notifUserBlockedMsg, @@ -128,7 +123,7 @@ export const useRestoreUserAccount = (userAccountId: string) => { const t = useI18n(); return useNotifiedMutation({ mutationFn: () => userAccountService.restoreUserAccount(userAccountId), - invalidateKeys: [['user-accounts', userAccountId]], + invalidateKeys: [['user-accounts'], ['user-accounts', userAccountId]], successNotif: () => ({ title: t.notifRestored, message: t.notifUserRestoredMsg, diff --git a/src/apps/ums.web-app/src/application/identity/services/auth.service.ts b/src/apps/ums.web-app/src/application/identity/services/auth.service.ts index 40ca6182..1485b1d1 100644 --- a/src/apps/ums.web-app/src/application/identity/services/auth.service.ts +++ b/src/apps/ums.web-app/src/application/identity/services/auth.service.ts @@ -78,6 +78,46 @@ const AUTH_ERROR_CODES = { SESSION_EXPIRED: 'AUTH_007', } as const; +/** + * Traduce una respuesta de login fallida al error que verá quien entra. Devuelve `never`: siempre + * lanza. El `supportReferenceId` viaja con el error porque es lo único que permite cruzar una + * queja de usuario con la traza del servidor; se busca en el cuerpo y, si no está, en las + * cabeceras de correlación. + */ +async function throwLoginError(response: Response): Promise { + const errorData = (await response.json().catch(() => null)) as AuthError | null; + const supportReferenceId = + errorData?.supportReferenceId ?? + response.headers.get('x-correlation-id') ?? + response.headers.get('x-error-id'); + + const fail = (message: string): never => { + const error = new Error(message) as ErrorWithSupportReference; + error.supportReferenceId = supportReferenceId ?? undefined; + throw error; + }; + + if (errorData) { + const conocido = LOGIN_ERROR_MESSAGE[errorData.code]; + return fail(conocido ?? errorData.message ?? 'No pudimos iniciar sesión. Intente nuevamente.'); + } + + if (response.status === 401) + return fail('No pudimos iniciar sesión. Verifique sus credenciales.'); + if (response.status === 429) return fail('Demasiados intentos fallidos. Espere unos minutos.'); + return fail('No pudimos iniciar sesión. Intente nuevamente.'); +} + +/** Mensaje que se enseña por cada código de error de autenticación conocido. */ +const LOGIN_ERROR_MESSAGE: Record = { + [AUTH_ERROR_CODES.INVALID_CREDENTIALS]: + 'No pudimos iniciar sesión. Verifique su usuario y contraseña.', + [AUTH_ERROR_CODES.TENANT_NOT_FOUND]: 'No pudimos iniciar sesión. Verifique el código del tenant.', + [AUTH_ERROR_CODES.TENANT_INACTIVE]: 'El tenant no está activo. Contacte al administrador.', + [AUTH_ERROR_CODES.USER_NOT_ACTIVE]: 'Su cuenta no está activa. Contacte al administrador.', + [AUTH_ERROR_CODES.SESSION_EXPIRED]: 'La sesión expiró. Vuelva a iniciar sesión.', +}; + class AuthService { private baseUrl: string; private requestTimeout: number; @@ -90,7 +130,7 @@ class AuthService { } private sanitizeInput(input: string): string { - return input.trim().replace(/[<>\"'&]/g, ''); + return input.trim().replace(/[<>"'&]/g, ''); } private validateCredentials(credentials: LoginCredentials): string[] { @@ -151,46 +191,7 @@ class AuthService { clearTimeout(timeoutId); if (!response.ok) { - const errorData = (await response.json().catch(() => null)) as AuthError | null; - const supportReferenceId = - errorData?.supportReferenceId ?? - response.headers.get('x-correlation-id') ?? - response.headers.get('x-error-id'); - - const fail = (message: string): never => { - const error = new Error(message) as ErrorWithSupportReference; - error.supportReferenceId = supportReferenceId ?? undefined; - throw error; - }; - - if (errorData) { - // `fail` lanza (retorna `never`); usamos `return fail(...)` para que cada rama - // termine explícitamente el flujo (evita no-fallthrough) sin código inalcanzable. - switch (errorData.code) { - case AUTH_ERROR_CODES.INVALID_CREDENTIALS: - return fail('No pudimos iniciar sesión. Verifique su usuario y contraseña.'); - case AUTH_ERROR_CODES.TENANT_NOT_FOUND: - return fail('No pudimos iniciar sesión. Verifique el código del tenant.'); - case AUTH_ERROR_CODES.TENANT_INACTIVE: - return fail('El tenant no está activo. Contacte al administrador.'); - case AUTH_ERROR_CODES.USER_NOT_ACTIVE: - return fail('Su cuenta no está activa. Contacte al administrador.'); - case AUTH_ERROR_CODES.SESSION_EXPIRED: - return fail('La sesión expiró. Vuelva a iniciar sesión.'); - default: - return fail(errorData.message || 'No pudimos iniciar sesión. Intente nuevamente.'); - } - } - - if (response.status === 401) { - fail('No pudimos iniciar sesión. Verifique sus credenciales.'); - } - - if (response.status === 429) { - fail('Demasiados intentos fallidos. Espere unos minutos.'); - } - - fail('No pudimos iniciar sesión. Intente nuevamente.'); + await throwLoginError(response); } const data = await response.json(); @@ -240,7 +241,10 @@ class AuthService { headers: this.getAuthHeaders(), credentials: 'include', }); - } catch {} + } catch { + // Cierre de sesión best-effort: el estado local ya se limpió, y que el servidor + // no conteste no debe impedir que el usuario salga. + } } async getSession(): Promise { @@ -271,7 +275,10 @@ class AuthService { const parsed = JSON.parse(stored); return parsed.state?.user?.token || null; } - } catch {} + } catch { + // Cierre de sesión best-effort: el estado local ya se limpió, y que el servidor + // no conteste no debe impedir que el usuario salga. + } return null; } diff --git a/src/apps/ums.web-app/src/application/security/securityInterceptor.ts b/src/apps/ums.web-app/src/application/security/securityInterceptor.ts index 2d1f596b..f33cf05c 100644 --- a/src/apps/ums.web-app/src/application/security/securityInterceptor.ts +++ b/src/apps/ums.web-app/src/application/security/securityInterceptor.ts @@ -12,6 +12,7 @@ * - Rate limiting feedback */ import { useAuthStore } from '@app/stores/auth.store'; +import { logger } from '@app/utils/logger'; export const CSRF_TOKEN_KEY = 'ums_csrf_token' as const; @@ -64,7 +65,10 @@ class SecurityInterceptor { private storeCsrfToken(token: string): void { try { sessionStorage.setItem(CSRF_TOKEN_KEY, token); - } catch {} + } catch { + // sessionStorage puede lanzar (modo privado, cuota). Sin token CSRF cacheado el + // interceptor lo vuelve a pedir; no es motivo para romper la petición. + } } private getStoredCsrfToken(): string | null { @@ -127,19 +131,19 @@ class SecurityInterceptor { const parsed = new URL(url, this.baseUrl); if (import.meta.env.PROD && parsed.protocol !== 'https:') { - console.warn('Security: Insecure URL blocked in production'); + logger.warn('Security: Insecure URL blocked in production'); return false; } const allowedOrigins = [this.baseUrl]; if (!allowedOrigins.some(origin => parsed.origin === origin)) { - console.warn('Security: Unauthorized origin blocked'); + logger.warn('Security: Unauthorized origin blocked'); return false; } return true; } catch { - console.warn('Security: Invalid URL format rejected'); + logger.warn('Security: Invalid URL format rejected'); return false; } } diff --git a/src/apps/ums.web-app/src/application/shared/hooks/use-list-panel-state.tsx b/src/apps/ums.web-app/src/application/shared/hooks/use-list-panel-state.tsx index f0a1089a..d85849e3 100644 --- a/src/apps/ums.web-app/src/application/shared/hooks/use-list-panel-state.tsx +++ b/src/apps/ums.web-app/src/application/shared/hooks/use-list-panel-state.tsx @@ -64,15 +64,8 @@ export interface ListPanelOptions { */ export function useListPanelState(options: ListPanelOptions) { const { - items, - selectedId, - isLoading, - error, - viewMode, - onViewModeChange, queryState, paginationState, - onRegisterNew, requiresFilter = false, filterPromptTitle = 'Aplica un filtro para cargar datos', filterPromptMessage = 'Selecciona un estado o ingresa un término de búsqueda para visualizar los elementos.', diff --git a/src/apps/ums.web-app/src/application/shared/hooks/use-navigation-prefetch.ts b/src/apps/ums.web-app/src/application/shared/hooks/use-navigation-prefetch.ts index 46d8af73..24938b81 100644 --- a/src/apps/ums.web-app/src/application/shared/hooks/use-navigation-prefetch.ts +++ b/src/apps/ums.web-app/src/application/shared/hooks/use-navigation-prefetch.ts @@ -11,23 +11,20 @@ * const { prefetchById } = useNavigationPrefetch(); * + )} + + {isDraft && showDeleteConfirm && ( +
+ + + ¿Eliminar esta plantilla? + + + +
+ )} + + +); + export const PermissionTemplateDetailPanel: React.FC = ({ template, isLoading, @@ -109,102 +293,20 @@ export const PermissionTemplateDetailPanel: React.FC = ({ }, [template, deleteTpl, onDeleted]); const header = template ? ( -
-
-
-
- -
-
-

- Plantilla v{template.version} -

-

- {template.items.length} {template.items.length === 1 ? 'ítem' : 'ítems'} -

-
-
- -
- -
- - Rol - {template.roleName} - - - Suite - {template.systemSuiteName} - -
- -
- {isDraft && ( - } - disabled={publish.isPending} - onClick={() => publish.mutate()} - className="text-xs h-8 px-3" - > - {publish.isPending ? 'Publicando…' : 'Publicar'} - - )} - {isPublished && ( - } - disabled={deprecate.isPending} - onClick={() => deprecate.mutate()} - className="text-xs h-8 px-3 border-rose-500/40 text-rose-500 hover:bg-rose-500/10" - > - {deprecate.isPending ? 'Descontinuando…' : 'Descontinuar'} - - )} - - - - {isDraft && !showDeleteConfirm && ( - - )} - - {isDraft && showDeleteConfirm && ( -
- - - ¿Eliminar esta plantilla? - - - -
- )} -
-
+ publish.mutate()} + onDeprecate={() => deprecate.mutate()} + onAskDelete={() => setShowDeleteConfirm(true)} + onCancelDelete={() => setShowDeleteConfirm(false)} + onConfirmDelete={handleDelete} + /> ) : undefined; const overviewContent = template ? ( @@ -350,12 +452,12 @@ const ModuleNodeDetailPanel: React.FC = ({ isDraft, allItems, }) => { - const setEffect = useSetTemplateItemEffect(templateId); - const removeItem = useRemoveTemplateItem(templateId); - const addItem = useAddTemplateItem(templateId); + const { applyEffect, isPending } = useApplyTemplateItemEffect(templateId); const addNotification = useNotificationStore(s => s.addNotification); - const selfItem = node.items[0]; + // Manda la fila vigente; la retirada solo se conserva para poder reactivarla (ADR-0164). + const selfItem = primaryItem(node.items); + const selfEffect: PermissionEffect = selfItem ? itemEffect(selfItem) : 'Neutral'; const computeNodeEffectiveState = (): 'Allow' | 'Deny' | 'Neutral' => { const selfEffects = node.items.map(itemEffect); @@ -367,22 +469,12 @@ const ModuleNodeDetailPanel: React.FC = ({ }; const effectiveState = computeNodeEffectiveState(); - const StateIcon = - effectiveState === 'Allow' ? CheckCircle2 : effectiveState === 'Deny' ? XCircle : MinusCircle; - - const stateColor = - effectiveState === 'Allow' - ? 'text-emerald-500 bg-emerald-500/10' - : effectiveState === 'Deny' - ? 'text-rose-500 bg-rose-500/10' - : 'text-m3-secondary bg-m3-surface-variant'; - - const stateLabel = - effectiveState === 'Allow' - ? 'Permitido' - : effectiveState === 'Deny' - ? 'Denegado' - : 'No configurado (Heredado)'; + const { + Icon: StateIcon, + color: stateColor, + label: stateLabel, + detail: stateDetail, + } = EFFECTIVE_STATE_PRESENTATION[effectiveState]; const mapTypeToTarget = (type: string): ExclusiveArcTarget => { if (type === 'Module') return 'Module'; @@ -390,31 +482,25 @@ const ModuleNodeDetailPanel: React.FC = ({ return 'Option'; }; - const handleApplyEffect = async (effect: 'Allow' | 'Deny' | 'Neutral') => { + const resolveActionId = (): string => { + const byCode = node.actionCode + ? suite?.actions.find(a => a.code === node.actionCode)?.id + : undefined; + return byCode ?? suite?.actions[0]?.id ?? '00000000-0000-0000-0000-000000000000'; + }; + + const handleApplyEffect = async (effect: PermissionEffect) => { if (!isDraft) return; - if (effect === 'Neutral') { - if (selfItem) await removeItem.mutateAsync(selfItem.itemId); - return; - } - if (selfItem) { - await setEffect.mutateAsync({ itemId: selfItem.itemId, effect }); - } else { - let actionId = ''; - if (node.actionCode) { - const action = suite?.actions.find(a => a.code === node.actionCode); - if (action) actionId = action.id; - } - if (!actionId && suite && suite.actions.length > 0) { - actionId = suite.actions[0].id; - } - await addItem.mutateAsync({ + // El adaptador elige el verbo: alta, efecto o reactivación del ítem retirado (ADR-0164). + await applyEffect({ + effect, + item: selfItem, + target: { targetType: mapTypeToTarget(node.type), targetId: node.id, - actionId: actionId || '00000000-0000-0000-0000-000000000000', - isAllowed: effect === 'Allow', - isDenied: effect === 'Deny', - }); - } + actionId: resolveActionId(), + }, + }); if (effect === 'Allow' && suite) { const ascendants = getAscendantsWithTypes(suite, node.id); @@ -422,22 +508,19 @@ const ModuleNodeDetailPanel: React.FC = ({ let changedParents = false; const elementsToProcess = [...ascendants, ...siblingViews]; for (const asc of elementsToProcess) { - const parentItem = allItems.find(i => i.targetId === asc.id); - if (parentItem) { - if (itemEffect(parentItem) !== 'Allow') { - await setEffect.mutateAsync({ itemId: parentItem.itemId, effect: 'Allow' }); - changedParents = true; - } - } else { - await addItem.mutateAsync({ + // Los ascendientes pasan por el mismo adaptador: si alguno está retirado hay que + // reactivarlo, porque volver a darlo de alta chocaría con su clave (409). + const parentItem = primaryItem(allItems.filter(i => i.targetId === asc.id)); + const changed = await applyEffect({ + effect: 'Allow', + item: parentItem, + target: { targetType: asc.type, targetId: asc.id, actionId: '00000000-0000-0000-0000-000000000000', - isAllowed: true, - isDenied: false, - }); - changedParents = true; - } + }, + }); + changedParents = changedParents || changed; } if (changedParents) { addNotification({ @@ -468,13 +551,7 @@ const ModuleNodeDetailPanel: React.FC = ({

Estado efectivo: {stateLabel}

-

- {effectiveState === 'Partial' - ? 'Algunos elementos secundarios tienen configuraciones diferentes.' - : effectiveState === 'Neutral' - ? 'No hay reglas directas. El acceso depende de contenedores superiores.' - : `El acceso está ${effectiveState === 'Allow' ? 'permitido' : 'denegado'}.`} -

+

{stateDetail}

@@ -493,10 +570,10 @@ const ModuleNodeDetailPanel: React.FC = ({
diff --git a/src/apps/ums.web-app/src/presentation/authorization/permission-template/components/PermissionTemplateForm.tsx b/src/apps/ums.web-app/src/presentation/authorization/permission-template/components/PermissionTemplateForm.tsx index 393c336a..e4fc6c73 100644 --- a/src/apps/ums.web-app/src/presentation/authorization/permission-template/components/PermissionTemplateForm.tsx +++ b/src/apps/ums.web-app/src/presentation/authorization/permission-template/components/PermissionTemplateForm.tsx @@ -3,13 +3,18 @@ * Uses M3Dialog with shared form components. */ import React, { useState } from 'react'; -import { ShieldPlus } from 'lucide-react'; import { M3Dialog, FieldSelect } from '@shared/components'; import { useCreatePermissionTemplate } from '@app/authorization/hooks/use-permission-template'; import { useGetAllSystemSuites } from '@app/authorization/hooks/use-system-suite'; import { useRolesBySystemSuite } from '@app/authorization/hooks/use-role'; import { useEffectiveTenant } from '@app/shared/hooks/use-effective-tenant'; +/** Etiqueta del select de rol. Bloqueado gana sobre cargando: sin suite no hay roles que traer. */ +function pickRoleLabel(blocked: boolean, loading: boolean): string { + if (blocked) return 'Rol (selecciona una Suite primero)'; + if (loading) return 'Cargando roles…'; + return 'Rol'; +} interface Props { isOpen: boolean; onClose: () => void; @@ -126,13 +131,7 @@ export const PermissionTemplateForm: React.FC = ({ /> { setRoleIdVal(v); diff --git a/src/apps/ums.web-app/src/presentation/authorization/permission-template/components/tree/DomainResourcesPanel.tsx b/src/apps/ums.web-app/src/presentation/authorization/permission-template/components/tree/DomainResourcesPanel.tsx index d3a5f607..8a09e935 100644 --- a/src/apps/ums.web-app/src/presentation/authorization/permission-template/components/tree/DomainResourcesPanel.tsx +++ b/src/apps/ums.web-app/src/presentation/authorization/permission-template/components/tree/DomainResourcesPanel.tsx @@ -18,10 +18,22 @@ import { } from 'lucide-react'; import type { SystemSuite } from '@domain/authorization/models/system-suite.model'; import type { PermissionTemplateItem } from '@domain/authorization/models/permission-template.model'; -import { itemEffect } from '@domain/authorization/models/permission-template.model'; +import { itemEffect, isRetiredItem } from '@domain/authorization/models/permission-template.model'; import { PermissionSectionToolbar } from '@shared/components/PermissionSectionToolbar'; import { CodeBadge } from '@shared/components/CodeBadge'; +/** Estado efectivo de un nodo del árbol de recursos. */ +type NodeEffectiveState = 'Allow' | 'Deny' | 'Partial' | 'Neutral'; + +/** Etiqueta corta del tipo de nodo: para un CRUD es el verbo, para un método una marca fija. */ +function nodeTypeLabel(node: { type: string; code: string }): string | undefined { + if (node.type === 'CrudOperation') return node.code.split('.').pop(); + if (node.type === 'DomainMethod') return 'METHOD'; + return node.type; +} +/** Clases de recurso de dominio que el árbol sabe pintar. */ +type DomainResourceType = 'Aggregate' | 'Entity' | 'DomainMethod'; + export type DomainResourceNode = { id: string; type: 'Aggregate' | 'Entity' | 'DomainMethod' | 'CrudOperation' | 'CustomAction'; @@ -93,7 +105,7 @@ function buildDomainResourceTree( const buildLeafChildren = ( resourceId: string, resourceCode: string, - resourceType: 'Aggregate' | 'Entity' | 'DomainMethod', + resourceType: DomainResourceType, baseLevel: number ): DomainResourceNode[] => { const crudOps: DomainResourceNode[] = CRUD_OPERATIONS.map(op => ({ @@ -140,7 +152,7 @@ function buildDomainResourceTree( code: child.code, description: child.description, parentId: resource.id, - parentType: resource.type as 'Aggregate' | 'Entity' | 'DomainMethod', + parentType: resource.type as DomainResourceType, level: 1, items: itemsByTargetId[child.id] || [], children: buildLeafChildren(child.id, child.code, 'Entity', 2), @@ -149,13 +161,13 @@ function buildDomainResourceTree( const ownLeaves = buildLeafChildren( resource.id, resource.code, - resource.type as 'Aggregate' | 'Entity' | 'DomainMethod', + resource.type as DomainResourceType, 1 ); return { id: resource.id, - type: resource.type as 'Aggregate' | 'Entity' | 'DomainMethod', + type: resource.type as DomainResourceType, label: resource.name, code: resource.code, description: resource.description, @@ -182,38 +194,51 @@ function getAllIds(nodes: DomainResourceNode[]): string[] { return nodes.flatMap(n => [n.id, ...getAllIds(n.children)]); } -function computeNodeState(node: DomainResourceNode): 'Allow' | 'Deny' | 'Partial' | 'Neutral' { +function computeNodeState(node: DomainResourceNode): NodeEffectiveState { const selfEffects = node.items.map(itemEffect); const hasAllow = selfEffects.includes('Allow'); const hasDeny = selfEffects.includes('Deny'); if (node.children.length === 0) { - if (hasAllow && !hasDeny) return 'Allow'; - if (hasDeny && !hasAllow) return 'Deny'; - return 'Neutral'; + return resolveLeafState(hasAllow, hasDeny); } - let childAllows = 0; - let childDenies = 0; - const check = (n: DomainResourceNode) => { - n.items.forEach(i => { - const e = itemEffect(i); - if (e === 'Allow') childAllows++; - if (e === 'Deny') childDenies++; - }); - n.children.forEach(check); - }; - check(node); - - const totalAllows = (hasAllow ? 1 : 0) + childAllows; - const totalDenies = (hasDeny ? 1 : 0) + childDenies; + const { allows, denies } = countEffects(node); + const totalAllows = (hasAllow ? 1 : 0) + allows; + const totalDenies = (hasDeny ? 1 : 0) + denies; + // Con reglas de un solo signo el nodo hereda ese signo si él mismo la lleva; si la traen solo + // sus hijos, el nodo es «parcial»: no está decidido a su nivel. if (totalAllows > 0 && totalDenies === 0) return hasAllow ? 'Allow' : 'Partial'; if (totalDenies > 0 && totalAllows === 0) return hasDeny ? 'Deny' : 'Partial'; if (totalAllows > 0 && totalDenies > 0) return 'Partial'; return 'Neutral'; } +/** Un nodo hoja solo puede ser Allow o Deny si todas sus reglas apuntan al mismo lado. */ +function resolveLeafState(hasAllow: boolean, hasDeny: boolean): NodeEffectiveState { + if (hasAllow && !hasDeny) return 'Allow'; + if (hasDeny && !hasAllow) return 'Deny'; + return 'Neutral'; +} + +/** Recuento recursivo de reglas por signo en el subárbol, el nodo incluido. */ +function countEffects(node: DomainResourceNode): { allows: number; denies: number } { + let allows = 0; + let denies = 0; + for (const item of node.items) { + const effect = itemEffect(item); + if (effect === 'Allow') allows++; + if (effect === 'Deny') denies++; + } + for (const child of node.children) { + const sub = countEffects(child); + allows += sub.allows; + denies += sub.denies; + } + return { allows, denies }; +} + const TYPE_ICON: Record = { Aggregate: , Entity: , @@ -247,6 +272,8 @@ const DomainResourceRow: React.FC<{ }> = ({ node, isExpanded, isSelected, hasChildren, onToggle, onSelect }) => { const state = computeNodeState(node); const stateInfo = STATE_ICON[state]; + // Las filas retiradas no se cuentan: sobreviven en la tabla pero no conceden nada (ADR-0164). + const directRuleCount = node.items.filter(item => !isRetiredItem(item)).length; const icon = node.type === 'CrudOperation' @@ -291,20 +318,16 @@ const DomainResourceRow: React.FC<{ - {node.items.length > 0 && ( + {directRuleCount > 0 && ( - {node.items.length} + {directRuleCount} )} - {node.type === 'CrudOperation' - ? node.code.split('.').pop() - : node.type === 'DomainMethod' - ? 'METHOD' - : node.type} + {nodeTypeLabel(node)}
{stateInfo.icon}
@@ -422,18 +445,16 @@ export const DomainResourcesPanel: React.FC = ({ />
- {flatList.length === 0 ? ( + {flatList.length === 0 && (

No hay recursos de dominio configurados en esta suite.

- ) : viewMode === 'tree' ? ( - renderTree(filteredTree) - ) : ( - renderList() )} + {flatList.length !== 0 && viewMode === 'tree' && renderTree(filteredTree)} + {flatList.length !== 0 && viewMode !== 'tree' && renderList()}
); diff --git a/src/apps/ums.web-app/src/presentation/authorization/permission-template/components/tree/ModulePermissionsPanel.tsx b/src/apps/ums.web-app/src/presentation/authorization/permission-template/components/tree/ModulePermissionsPanel.tsx index a420e72b..6525a36f 100644 --- a/src/apps/ums.web-app/src/presentation/authorization/permission-template/components/tree/ModulePermissionsPanel.tsx +++ b/src/apps/ums.web-app/src/presentation/authorization/permission-template/components/tree/ModulePermissionsPanel.tsx @@ -11,8 +11,9 @@ import { MinusCircle, } from 'lucide-react'; import type { SystemSuite } from '@domain/authorization/models/system-suite.model'; +import type { SystemSuiteNode } from '@domain/authorization/schemas/system-suite.schema'; import type { PermissionTemplateItem } from '@domain/authorization/models/permission-template.model'; -import { itemEffect } from '@domain/authorization/models/permission-template.model'; +import { itemEffect, isRetiredItem } from '@domain/authorization/models/permission-template.model'; import { PermissionSectionToolbar } from '@shared/components/PermissionSectionToolbar'; import { CodeBadge } from '@shared/components/CodeBadge'; @@ -51,49 +52,32 @@ function buildModuleTree( {} as Record ); - return suite.modules.map(mod => { - const buildMenuTree = (menus: typeof mod.menus, level: number): ModulePermNode[] => - menus.map(menu => ({ - id: menu.id, - type: 'Menu' as const, - label: menu.label, - code: menu.code, - description: menu.description, + // Mapea recursivamente los nodos del árbol del SystemSuite (ADR-0090). + const buildNodeTree = (nodes: SystemSuiteNode[], level: number): ModulePermNode[] => + [...nodes] + .sort((a, b) => a.sortOrder - b.sortOrder) + .map(node => ({ + id: node.id, + type: node.kind as ModulePermNode['type'], + label: node.label, + code: node.code, + description: node.description, + actionCode: node.actionCodes[0], level, - items: itemsByTargetId[menu.id] || [], - children: menu.subMenus.map(sm => ({ - id: sm.id, - type: 'SubMenu' as const, - label: sm.label, - code: sm.code, - description: sm.description, - level: level + 1, - items: itemsByTargetId[sm.id] || [], - children: sm.options.map(opt => ({ - id: opt.id, - type: 'Option' as const, - label: opt.label, - code: opt.code, - description: opt.description, - actionCode: opt.actionCode, - level: level + 2, - items: itemsByTargetId[opt.id] || [], - children: [], - })), - })), + items: itemsByTargetId[node.id] || [], + children: buildNodeTree(node.children ?? [], level + 1), })); - return { - id: mod.id, - type: 'Module' as const, - label: mod.name, - code: mod.code, - description: mod.description, - level: 0, - items: itemsByTargetId[mod.id] || [], - children: buildMenuTree(mod.menus, 1), - }; - }); + return suite.modules.map(mod => ({ + id: mod.id, + type: 'Module' as const, + label: mod.name, + code: mod.code, + description: mod.description, + level: 0, + items: itemsByTargetId[mod.id] || [], + children: buildNodeTree(mod.nodes ?? [], 1), + })); } function flattenTree(nodes: ModulePermNode[]): ModulePermNode[] { @@ -112,34 +96,54 @@ function getAllIds(nodes: ModulePermNode[]): string[] { return nodes.flatMap(n => [n.id, ...getAllIds(n.children)]); } -function computeNodeState(node: ModulePermNode): 'Allow' | 'Deny' | 'Partial' | 'Neutral' { +/** Estado efectivo de un nodo del árbol de navegación. */ +type ModuleNodeState = 'Allow' | 'Deny' | 'Partial' | 'Neutral'; + +function computeNodeState(node: ModulePermNode): ModuleNodeState { const selfEffects = node.items.map(itemEffect); const hasAllow = selfEffects.includes('Allow'); const hasDeny = selfEffects.includes('Deny'); + if (node.children.length === 0) { - if (hasAllow && !hasDeny) return 'Allow'; - if (hasDeny && !hasAllow) return 'Deny'; - return 'Neutral'; + return resolveLeafState(hasAllow, hasDeny); } - let childAllows = 0; - let childDenies = 0; - const check = (n: ModulePermNode) => { - n.items.forEach(i => { - const e = itemEffect(i); - if (e === 'Allow') childAllows++; - if (e === 'Deny') childDenies++; - }); - n.children.forEach(check); - }; - check(node); - const totalAllows = (hasAllow ? 1 : 0) + childAllows; - const totalDenies = (hasDeny ? 1 : 0) + childDenies; + + const { allows, denies } = countEffects(node); + const totalAllows = (hasAllow ? 1 : 0) + allows; + const totalDenies = (hasDeny ? 1 : 0) + denies; + + // Con reglas de un solo signo el nodo hereda ese signo si él mismo la lleva; si la traen solo + // sus hijos, el nodo es «parcial»: no está decidido a su nivel. if (totalAllows > 0 && totalDenies === 0) return hasAllow ? 'Allow' : 'Partial'; if (totalDenies > 0 && totalAllows === 0) return hasDeny ? 'Deny' : 'Partial'; if (totalAllows > 0 && totalDenies > 0) return 'Partial'; return 'Neutral'; } +/** Un nodo hoja solo puede ser Allow o Deny si todas sus reglas apuntan al mismo lado. */ +function resolveLeafState(hasAllow: boolean, hasDeny: boolean): ModuleNodeState { + if (hasAllow && !hasDeny) return 'Allow'; + if (hasDeny && !hasAllow) return 'Deny'; + return 'Neutral'; +} + +/** Recuento recursivo de reglas por signo en el subárbol, el nodo incluido. */ +function countEffects(node: ModulePermNode): { allows: number; denies: number } { + let allows = 0; + let denies = 0; + for (const item of node.items) { + const effect = itemEffect(item); + if (effect === 'Allow') allows++; + if (effect === 'Deny') denies++; + } + for (const child of node.children) { + const sub = countEffects(child); + allows += sub.allows; + denies += sub.denies; + } + return { allows, denies }; +} + const TYPE_ICON: Record = { Module: , Menu: , @@ -172,7 +176,9 @@ const ModuleRow: React.FC<{ const state = computeNodeState(node); const stateInfo = STATE_ICON[state]; - const hasDirectPermission = node.items.length > 0; + // Solo cuentan las filas vigentes: una retirada no es una regla directa (ADR-0164). + const directRuleCount = node.items.filter(item => !isRetiredItem(item)).length; + const hasDirectPermission = directRuleCount > 0; const isInherited = !hasDirectPermission && state !== 'Neutral'; return ( @@ -214,9 +220,9 @@ const ModuleRow: React.FC<{ - {node.items.length > 0 && ( + {directRuleCount > 0 && ( - {node.items.length} + {directRuleCount} )} @@ -361,16 +367,14 @@ export const ModulePermissionsPanel: React.FC = ({
- {flatList.length === 0 ? ( + {flatList.length === 0 && (

No hay módulos configurados en esta suite.

- ) : viewMode === 'tree' ? ( - renderTree(tree) - ) : ( - renderList() )} + {flatList.length !== 0 && viewMode === 'tree' && renderTree(tree)} + {flatList.length !== 0 && viewMode !== 'tree' && renderList()}
); diff --git a/src/apps/ums.web-app/src/presentation/authorization/permission-template/components/tree/NodeDetailPanel.tsx b/src/apps/ums.web-app/src/presentation/authorization/permission-template/components/tree/NodeDetailPanel.tsx index 7bf29d5a..000a72cb 100644 --- a/src/apps/ums.web-app/src/presentation/authorization/permission-template/components/tree/NodeDetailPanel.tsx +++ b/src/apps/ums.web-app/src/presentation/authorization/permission-template/components/tree/NodeDetailPanel.tsx @@ -1,14 +1,47 @@ import React from 'react'; import { UITreeNodeData, computeEffectiveState } from './TreeNode'; import { Shield, CheckCircle2, XCircle, MinusCircle, Info } from 'lucide-react'; -import { - useSetTemplateItemEffect, - useRemoveTemplateItem, - useAddTemplateItem, -} from '@app/authorization/hooks/use-permission-template'; +import { useApplyTemplateItemEffect } from '@app/authorization/hooks/use-permission-template'; import type { SystemSuite } from '@domain/authorization/models/system-suite.model'; -import type { ExclusiveArcTarget } from '@domain/authorization/models/permission-template.model'; - +import type { + ExclusiveArcTarget, + PermissionEffect, +} from '@domain/authorization/models/permission-template.model'; +import { itemEffect, primaryItem } from '@domain/authorization/models/permission-template.model'; + +/** + * Presentación de cada estado efectivo de un nodo del árbol: icono, color, etiqueta y la frase + * que lo explica. Estaba repartida en cuatro cascadas de ternarios que había que leer enteras + * para responder «¿de qué color se pinta un Partial?». + */ +const NODE_STATE_PRESENTATION = { + Allow: { + Icon: CheckCircle2, + color: 'text-emerald-500 bg-emerald-500/10', + label: 'Permitido', + detail: 'El acceso a esta entidad y sus elementos dependientes está permitido.', + }, + Deny: { + Icon: XCircle, + color: 'text-rose-500 bg-rose-500/10', + label: 'Denegado', + detail: 'El acceso a esta entidad y sus elementos dependientes está denegado.', + }, + Partial: { + Icon: CheckCircle2, + color: 'text-amber-500 bg-amber-500/10', + label: 'Permitido parcialmente', + detail: + 'Algunos elementos secundarios de este nodo tienen configuraciones de permisos diferentes.', + }, + Neutral: { + Icon: MinusCircle, + color: 'text-m3-secondary bg-m3-surface-variant', + label: 'No configurado (Heredado)', + detail: + 'No hay reglas directas asignadas a este nodo. Su acceso dependerá de las reglas aplicadas en sus contenedores superiores.', + }, +} as const; interface NodeDetailPanelProps { node: UITreeNodeData | null; suite: SystemSuite | undefined | null; @@ -22,9 +55,7 @@ export const NodeDetailPanel: React.FC = ({ templateId, isDraft, }) => { - const setEffect = useSetTemplateItemEffect(templateId); - const removeItem = useRemoveTemplateItem(templateId); - const addItem = useAddTemplateItem(templateId); + const { applyEffect, isPending } = useApplyTemplateItemEffect(templateId); if (!node || !suite) { return ( @@ -39,34 +70,15 @@ export const NodeDetailPanel: React.FC = ({ } const effectiveState = computeEffectiveState(node); - const selfItem = node.items[0]; // If there are multiple for some reason, we take the first. - - const StateIcon = - effectiveState === 'Allow' - ? CheckCircle2 - : effectiveState === 'Deny' - ? XCircle - : effectiveState === 'Partial' - ? CheckCircle2 - : MinusCircle; - - const stateColor = - effectiveState === 'Allow' - ? 'text-emerald-500 bg-emerald-500/10' - : effectiveState === 'Deny' - ? 'text-rose-500 bg-rose-500/10' - : effectiveState === 'Partial' - ? 'text-amber-500 bg-amber-500/10' - : 'text-m3-secondary bg-m3-surface-variant'; - - const stateLabel = - effectiveState === 'Allow' - ? 'Permitido' - : effectiveState === 'Deny' - ? 'Denegado' - : effectiveState === 'Partial' - ? 'Permitido parcialmente' - : 'No configurado (Heredado)'; + // Si hay varias filas sobre el mismo nodo manda la vigente; la retirada solo sirve para reactivar. + const selfItem = primaryItem(node.items); + // Lo que la regla directa concede HOY: una fila retirada no concede nada, luego es Neutral. + const selfEffect: PermissionEffect = selfItem ? itemEffect(selfItem) : 'Neutral'; + + const StateIcon = NODE_STATE_PRESENTATION[effectiveState].Icon; + + const stateColor = NODE_STATE_PRESENTATION[effectiveState].color; + const stateLabel = NODE_STATE_PRESENTATION[effectiveState].label; const mapTypeToExclusiveArcTarget = (type: string): ExclusiveArcTarget => { if (type === 'Module') return 'Module'; @@ -76,39 +88,26 @@ export const NodeDetailPanel: React.FC = ({ return 'Option'; // Option for SubMenu and Option }; - const handleApplyEffect = async (effect: 'Allow' | 'Deny' | 'Neutral') => { - if (!isDraft) return; + const resolveActionId = (): string => { + // Si el nodo es una opción trae su actionCode; si no, el backend exige una acción cualquiera. + const byCode = node.actionCode + ? suite.actions.find(a => a.code === node.actionCode)?.id + : undefined; + return byCode ?? suite.actions[0]?.id ?? '00000000-0000-0000-0000-000000000000'; + }; - if (effect === 'Neutral') { - if (selfItem) { - await removeItem.mutateAsync(selfItem.itemId); - } - return; - } - - if (selfItem) { - await setEffect.mutateAsync({ itemId: selfItem.itemId, effect }); - } else { - // Find an actionId. If it's an option, it has an actionCode. We can find the action in the suite. - let actionId = ''; - if (node.actionCode) { - const action = suite.actions.find(a => a.code === node.actionCode); - if (action) actionId = action.id; - } - - // Fallback to first available action if not found (required by backend) - if (!actionId && suite.actions.length > 0) { - actionId = suite.actions[0].id; - } - - await addItem.mutateAsync({ + const handleApplyEffect = async (effect: PermissionEffect) => { + if (!isDraft) return; + // El adaptador decide el verbo: alta, efecto o reactivación del ítem retirado (ADR-0164). + await applyEffect({ + effect, + item: selfItem, + target: { targetType: mapTypeToExclusiveArcTarget(node.type), targetId: node.id, - actionId: actionId || '00000000-0000-0000-0000-000000000000', - isAllowed: effect === 'Allow', - isDenied: effect === 'Deny', - }); - } + actionId: resolveActionId(), + }, + }); }; return ( @@ -130,11 +129,7 @@ export const NodeDetailPanel: React.FC = ({

Estado efectivo: {stateLabel}

- {effectiveState === 'Partial' - ? 'Algunos elementos secundarios de este nodo tienen configuraciones de permisos diferentes.' - : effectiveState === 'Neutral' - ? 'No hay reglas directas asignadas a este nodo. Su acceso dependerá de las reglas aplicadas en sus contenedores superiores.' - : `El acceso a esta entidad y sus elementos dependientes está ${effectiveState === 'Allow' ? 'permitido' : 'denegado'}.`} + {NODE_STATE_PRESENTATION[effectiveState].detail}

@@ -154,10 +149,10 @@ export const NodeDetailPanel: React.FC = ({
@@ -165,7 +155,7 @@ export const SystemActionsPanel: React.FC = ({
+ ) : undefined + } /> - {/* Collapse / Expand all toggle */} - {allModuleNodeIds.length > 0 && ( -
- -
- )} - + {/* Formulario inline de nuevo módulo — el trigger vive en el toolbar (onAdd) */} { @@ -327,12 +327,12 @@ export const SystemSuiteDetailPanel: React.FC = ({ if (!open) setModError(''); }} onSubmit={handleAddModule} - addLabel="+" + addLabel="Módulo" title="Nuevo Módulo Estructural" cancelLabel={t.cancelEdit} submitLabel="Guardar Módulo" isLoading={addModuleMutation.isPending} - triggerEmphasis="quiet" + triggerEmphasis="none" error={modError || undefined} > = ({ ); } - if (modulesViewMode === 'thumbnail') { - return ( -
- {filteredModules.map(module => { - const moduleNodeId = `module-${module.id}`; - const moduleExpanded = isExpanded(moduleNodeId); - return ( - toggleNode(moduleNodeId)} - onToggleNode={toggleNode} - onDeactivate={() => deactivateModuleMutation.mutate(module.id)} - onActivate={() => activateModuleMutation.mutate(module.id)} - onRemove={() => removeModuleMutation.mutate(module.id)} - isDeactivating={deactivateModuleMutation.isPending} - isActivating={activateModuleMutation.isPending} - isRemoving={removeModuleMutation.isPending} - /> - ); - })} -
- ); - } + const containerClass = + modulesViewMode === 'thumbnail' + ? 'grid grid-cols-1 sm:grid-cols-2 gap-3 animate-fadeIn' + : 'space-y-2 animate-fadeIn'; return ( -
+
{filteredModules.map(module => { const moduleNodeId = `module-${module.id}`; const moduleExpanded = isExpanded(moduleNodeId); return ( - toggleNode(moduleNodeId)} @@ -463,7 +441,7 @@ export const SystemSuiteDetailPanel: React.FC = ({ {/* ── Actions ── */} {activeTab === 'actions' && ( -
+
= ({ onSortOrderToggle={() => setActionsSortOrder(o => (o === 'asc' ? 'desc' : 'asc'))} itemCount={activeSystemSuite.actions?.length ?? 0} itemLabel="Acción" + onAdd={() => { + setIsAddingAction(true); + setActError(''); + }} + addLabel="Nueva Acción del Sistema" /> = ({ if (!open) setActError(''); }} onSubmit={handleRegisterAction} - addLabel="+" + addLabel="Acción" title="Nueva Acción del Sistema" cancelLabel={t.cancelEdit} submitLabel="Guardar Acción" isLoading={registerActionMutation.isPending} - triggerEmphasis="quiet" + triggerEmphasis="none" error={actError || undefined} > = { + Aggregate: { + Icon: Layers, + chipClass: 'bg-indigo-500/10 text-indigo-500', + shortLabel: 'Agregado', + longLabel: 'Agregado Root', + }, + Entity: { + Icon: Component, + chipClass: 'bg-emerald-500/10 text-emerald-500', + shortLabel: 'Entidad', + longLabel: 'Entidad de Dominio', + }, + DomainMethod: { + Icon: FunctionSquare, + chipClass: 'bg-orange-500/10 text-orange-500', + shortLabel: 'Método', + longLabel: 'Método de Dominio', + }, +}; interface SystemSuiteDomainResourcesPanelProps { systemSuite: SystemSuite; } @@ -115,8 +161,10 @@ export const SystemSuiteDomainResourcesPanel: React.FC +
setSortOrder(o => (o === 'asc' ? 'desc' : 'asc'))} itemCount={domainResources.length} itemLabel="Recurso" + onAdd={() => { + setIsAddingResource(true); + setResError(''); + }} + addLabel="Nuevo Recurso de Dominio" /> -
+
setCriteriaType(e.target.value)} @@ -374,6 +373,7 @@ export const SystemSuiteFeatureFlagsPanel: React.FC setSortOrder(o => (o === 'asc' ? 'desc' : 'asc'))} itemCount={flags.length} itemLabel="flag" + itemLabelPlural="flags" /> {filteredFlags.length === 0 ? ( diff --git a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/SystemSuiteForm.test.tsx b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/SystemSuiteForm.test.tsx new file mode 100644 index 00000000..a689fe57 --- /dev/null +++ b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/SystemSuiteForm.test.tsx @@ -0,0 +1,82 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { SystemSuiteForm } from './SystemSuiteForm'; + +// Contrato G-107: la descripción es requerida (el backend exige +// `Description.NotEmpty()`). Estos tests fijan que el formulario alinea la UI al +// contrato: indicador visual de requerido, mensaje de error y bloqueo del envío. + +const mutateAsync = vi.fn(); + +vi.mock('@app/authorization/hooks/use-system-suite', () => ({ + useCreateSystemSuite: () => ({ mutateAsync, isPending: false }), +})); + +vi.mock('@app/shared/hooks/use-effective-tenant', () => ({ + useEffectiveTenant: () => '5f4e3d2c-1b2a-3c4d-5e6f-7a8b9c0d1e2f', +})); + +const renderForm = () => render(); + +const fill = (placeholder: string, value: string) => { + fireEvent.change(screen.getByPlaceholderText(placeholder), { target: { value } }); +}; + +describe('SystemSuiteForm', () => { + beforeEach(() => { + vi.clearAllMocks(); + mutateAsync.mockResolvedValue({ systemSuiteId: 'new-id' }); + }); + + it('marca la descripción como campo requerido (asterisco visual)', () => { + renderForm(); + const descLabel = screen.getByText('Descripción'); + expect(descLabel).toHaveTextContent('*'); + }); + + it('no envía y muestra el error requerido cuando la descripción está vacía', async () => { + renderForm(); + + fill('SUITE_CRM', 'CRM'); + fill('CRM System Suite', 'CRM System'); + // Descripción se deja vacía a propósito. + + fireEvent.click(screen.getByRole('button', { name: /Registrar Suite/i })); + + expect(await screen.findByText('Descripción requerida')).toBeInTheDocument(); + expect(mutateAsync).not.toHaveBeenCalled(); + }); + + it('no envía cuando la descripción es solo espacios en blanco', async () => { + renderForm(); + + fill('SUITE_CRM', 'CRM'); + fill('CRM System Suite', 'CRM System'); + fill('Customer relationship management module', ' '); + + fireEvent.click(screen.getByRole('button', { name: /Registrar Suite/i })); + + expect(await screen.findByText('Descripción requerida')).toBeInTheDocument(); + expect(mutateAsync).not.toHaveBeenCalled(); + }); + + it('envía el payload con la descripción cuando el formulario es válido', async () => { + renderForm(); + + fill('SUITE_CRM', 'CRM'); + fill('CRM System Suite', 'CRM System'); + fill('Customer relationship management module', 'Gestión CRM'); + + fireEvent.click(screen.getByRole('button', { name: /Registrar Suite/i })); + + await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1)); + expect(mutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ + tenantId: '5f4e3d2c-1b2a-3c4d-5e6f-7a8b9c0d1e2f', + code: 'CRM', + name: 'CRM System', + description: 'Gestión CRM', + }) + ); + }); +}); diff --git a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/SystemSuiteForm.tsx b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/SystemSuiteForm.tsx index 929be0c6..7cd01832 100644 --- a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/SystemSuiteForm.tsx +++ b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/SystemSuiteForm.tsx @@ -44,7 +44,9 @@ export const SystemSuiteForm: React.FC = ({ tenantId: effectiveTenantId, code, name, - description: description || undefined, + // Enviamos la cadena (recortada) — no `undefined` — para que la validación + // requerida de descripción se dispare antes del POST y no en un 400 (G-107). + description: description.trim(), }; const validData = validate(payload); if (!validData) return; @@ -57,7 +59,9 @@ export const SystemSuiteForm: React.FC = ({ clearErrors(); onSuccess(); onClose(); - } catch {} + } catch { + // El error ya lo notifica la mutación; aquí solo se evita cerrar el formulario. + } }; return ( @@ -99,10 +103,11 @@ export const SystemSuiteForm: React.FC = ({ /> - + setDescription(e.target.value)} + error={!!errors.description} placeholder="Customer relationship management module" /> diff --git a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/SystemSuiteListPanel.tsx b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/SystemSuiteListPanel.tsx index 476ad5d0..7eca501d 100644 --- a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/SystemSuiteListPanel.tsx +++ b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/SystemSuiteListPanel.tsx @@ -1,5 +1,5 @@ import React, { useCallback } from 'react'; -import { Box, ArrowRight, Info } from 'lucide-react'; +import { Box, ArrowRight } from 'lucide-react'; import { SystemSuite } from '@domain/authorization/models/system-suite.model'; import { StatusBadge } from '@shared/components/StatusBadge'; import { CodeBadge } from '@shared/components/CodeBadge'; @@ -131,8 +131,6 @@ export const SystemSuiteListPanel: React.FC = ({ ); const totalItems = paginationState.totalItems; - const startIndex = paginationState.startIndex ?? 0; - const pageSize = paginationState.pageSize; const pagination = paginationState.totalPages > 0 @@ -155,7 +153,7 @@ export const SystemSuiteListPanel: React.FC = ({ totalItems={paginationState.totalItems} startIndex={paginationState.startIndex ?? 0} pageSize={paginationState.pageSize} - itemLabel={t.systemSuites ?? 'System Suites'} + itemLabel={t.systemSuites ?? 'suites'} onClear={queryState.handleResetQuery} searchTerm={queryState.appliedQuery.term} /> diff --git a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/SystemSuiteProfileCard.tsx b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/SystemSuiteProfileCard.tsx index 6700445b..f3b4325d 100644 --- a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/SystemSuiteProfileCard.tsx +++ b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/SystemSuiteProfileCard.tsx @@ -81,13 +81,12 @@ export const SystemSuiteProfileCard: React.FC = ({ }; const handleToggleStatus = (newStatus: 'Active' | 'Maintenance' | 'Deprecated') => { - onSystemSuiteUpdate(systemSuite.systemSuiteId, { status: newStatus }); - addNotification({ - title: t.notifStatusChanged, - message: t.notifStatusSetTo(newStatus), - type: newStatus === 'Active' ? 'success' : 'warning', + // La mutación (useNotifiedMutation) es dueña de la notificación success/error e invalida + // el query. El estado local se refleja sólo en onSuccess: si el backend rechaza el cambio, + // no se deja el botón en un estado falso ni se dispara un aviso prematuro (bug corregido, G-129). + setStatusMutation.mutate(newStatus, { + onSuccess: () => onSystemSuiteUpdate(systemSuite.systemSuiteId, { status: newStatus }), }); - setStatusMutation.mutate(newStatus, { onError: () => {} }); }; const renderActions = () => { diff --git a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/SystemSuiteRolesPanel.tsx b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/SystemSuiteRolesPanel.tsx index 9ac6f9a7..e11989a1 100644 --- a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/SystemSuiteRolesPanel.tsx +++ b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/SystemSuiteRolesPanel.tsx @@ -99,7 +99,7 @@ export const SystemSuiteRolesPanel: React.FC = ({ systemSuiteId }) => { }); return ( -
+
= ({ systemSuiteId }) => { onSortOrderToggle={() => setSortOrder(o => (o === 'asc' ? 'desc' : 'asc'))} itemCount={roles.length} itemLabel="Rol" + onAdd={() => { + setAdding(true); + setError(''); + }} + addLabel={t.newRole} /> = ({ systemSuiteId }) => { cancelLabel={t.cancelEdit} submitLabel={t.saveRole} isLoading={createRole.isPending} - triggerEmphasis="quiet" + triggerEmphasis="none" error={error || undefined} > = ({ systemSuiteId }) => { /> - {roles.length === 0 ? ( + {roles.length === 0 && ( } message={t.noRolesConfigured ?? 'No hay roles configurados'} /> - ) : filteredRoles.length === 0 ? ( + )} + {roles.length !== 0 && filteredRoles.length === 0 && ( } message="No hay roles que coincidan con el filtro" /> - ) : viewMode === 'list' ? ( + )} + {roles.length !== 0 && filteredRoles.length !== 0 && viewMode === 'list' && (
{filteredRoles.map(role => ( = ({ systemSuiteId }) => { /> ))}
- ) : ( + )} + {roles.length !== 0 && filteredRoles.length !== 0 && viewMode !== 'list' && (
{filteredRoles.map(role => ( = ({ } return ( -
+
{role.value} {role.isActive ? t.active : t.inactive} diff --git a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/MenuRow.tsx b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/MenuRow.tsx deleted file mode 100644 index b7fc1f03..00000000 --- a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/MenuRow.tsx +++ /dev/null @@ -1,304 +0,0 @@ -import React, { useState } from 'react'; -import { ChevronDown, ChevronRight, Folder, FolderOpen, Pencil, Trash2 } from 'lucide-react'; -import { useInlineEdit } from '@app/hooks/use-inline-edit'; -import { - useAddSubMenu, - useRemoveSubMenu, - useUpdateMenu, -} from '@app/authorization/hooks/use-system-suite'; -import { M3TextField } from '@shared/components/M3TextField'; -import { InlineAddForm } from '@shared/components/InlineAddForm'; -import { IconButton } from '@shared/components/Tooltip'; -import { CodeBadge } from '@shared/components/CodeBadge'; -import { ErrorDisplay } from '@shared/components/data-display/ErrorDisplay'; -import { formatSystemCode } from '@app/utils/security'; -import { SubMenuRow } from './SubMenuRow'; -import { AddSubState, emptySub } from './types'; - -type MenuType = { - id: string; - code: string; - label: string; - description?: string; - sortOrder?: number; - subMenus?: Array<{ - id: string; - code: string; - label: string; - description?: string; - sortOrder?: number; - options?: Array<{ - id: string; - code: string; - label: string; - description?: string; - actionCode: string; - sortOrder?: number; - }>; - }>; -}; - -interface MenuRowProps { - suiteId: string; - moduleId: string; - menu: MenuType; - isExpanded: boolean; - isSubExpanded: (id: string) => boolean; - onToggle: () => void; - onToggleSub: (id: string) => void; - onRemoveMenu: (id: string) => void; - isRemovingMenu: boolean; -} - -interface MenuDraft { - label: string; - description: string; - sortOrder: number; -} - -export const MenuRow: React.FC = ({ - suiteId, - moduleId, - menu, - isExpanded, - isSubExpanded, - onToggle, - onToggleSub, - onRemoveMenu, - isRemovingMenu, -}) => { - const [isAddingSub, setIsAddingSub] = useState(false); - const [sub, setSub] = useState(emptySub()); - const [editError, setEditError] = useState(''); - - const addSubMenuMutation = useAddSubMenu(suiteId, moduleId, menu.id); - const removeSubMenuMutation = useRemoveSubMenu(suiteId, moduleId, menu.id); - const updateMenuMutation = useUpdateMenu(suiteId, moduleId, menu.id); - - const edit = useInlineEdit(['label', 'description', 'sortOrder']); - - const handleAddSubMenu = async (e: React.FormEvent) => { - e.preventDefault(); - if (!sub.code.trim()) { - setSub(s => ({ ...s, error: 'Código requerido' })); - return; - } - if (!sub.label.trim()) { - setSub(s => ({ ...s, error: 'Etiqueta requerida' })); - return; - } - try { - await addSubMenuMutation.mutateAsync({ - code: formatSystemCode(sub.code), - label: sub.label.trim(), - description: sub.desc.trim(), - sortOrder: parseInt(sub.sort) || 1, - }); - setSub(emptySub()); - setIsAddingSub(false); - } catch { - /* handled by hook */ - } - }; - - const handleStartEditMenu = () => { - edit.openEdit(menu.id, { - label: menu.label, - description: menu.description ?? '', - sortOrder: menu.sortOrder ?? 1, - }); - setEditError(''); - }; - - const handleUpdateMenu = async (e: React.FormEvent) => { - e.preventDefault(); - const label = edit.draft.label?.trim() ?? ''; - if (!label) { - setEditError('Etiqueta requerida'); - return; - } - try { - await updateMenuMutation.mutateAsync({ - label, - description: edit.draft.description?.trim() ?? '', - sortOrder: Number(edit.draft.sortOrder) || 1, - }); - edit.cancelEdit(); - setEditError(''); - } catch { - /* handled by hook */ - } - }; - - const handleCancelEditMenu = () => { - edit.cancelEdit(); - setEditError(''); - }; - - return ( -
- {edit.isEditing(menu.id) ? ( -
-
- edit.setField('label', e.target.value)} - /> - edit.setField('sortOrder', parseInt(e.target.value) || 1)} - /> -
- edit.setField('description', e.target.value)} - /> - -
- - -
- - ) : ( -
-
- {isExpanded ? ( - - ) : ( - - )} - {isExpanded ? ( - - ) : ( - - )} -
- {menu.label} - -
-
-
- {isExpanded && ( - - )} - - - - onRemoveMenu(menu.id)} - disabled={isRemovingMenu} - className="hover:text-m3-error hover:bg-m3-error/10" - > - - -
-
- )} - -
- {isAddingSub && ( - { - setIsAddingSub(open); - if (!open) setSub(emptySub()); - }} - onSubmit={handleAddSubMenu} - addLabel="Submenú" - title="Nuevo Submenú" - cancelLabel="Cancelar" - submitLabel="Guardar Submenú" - isLoading={addSubMenuMutation.isPending} - error={sub.error || undefined} - > - setSub(s => ({ ...s, code: e.target.value }))} - placeholder="e.g. USERS" - /> - setSub(s => ({ ...s, label: e.target.value }))} - placeholder="e.g. Gestión de Usuarios" - /> - setSub(s => ({ ...s, desc: e.target.value }))} - placeholder="Opcional" - /> - setSub(s => ({ ...s, sort: e.target.value }))} - placeholder="1" - /> - - )} - - {!menu.subMenus || menu.subMenus.length === 0 ? ( -

No hay submenús configurados.

- ) : ( -
- {menu.subMenus.map(subMenu => ( - onToggleSub(subMenu.id)} - onRemoveSubMenu={id => removeSubMenuMutation.mutate(id)} - isRemovingSubMenu={removeSubMenuMutation.isPending} - /> - ))} -
- )} -
-
- ); -}; diff --git a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/ModuleCard.tsx b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/ModuleCard.tsx deleted file mode 100644 index 5b60bd65..00000000 --- a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/ModuleCard.tsx +++ /dev/null @@ -1,240 +0,0 @@ -import React, { useState } from 'react'; -import { ChevronDown, ChevronRight, Layers, EyeOff, ShieldCheck, Trash2 } from 'lucide-react'; -import { - useAddMenu, - useRemoveMenu, - useActivateModule, - useDeactivateModule, -} from '@app/authorization/hooks/use-system-suite'; -import { InlineAddForm } from '@shared/components/InlineAddForm'; -import { IconButton } from '@shared/components/Tooltip'; -import { CodeBadge } from '@shared/components/CodeBadge'; -import { StatusBadge } from '@shared/components/StatusBadge'; -import { M3TextField } from '@shared/components/M3TextField'; -import { formatSystemCode } from '@app/utils/security'; -import { MenuRow } from './MenuRow'; -import { AddMenuState, emptyMenu } from './types'; - -type ModuleType = { - id: string; - code: string; - name: string; - description?: string; - status: string; - sortOrder?: number; - menus?: Array<{ - id: string; - code: string; - label: string; - description?: string; - sortOrder?: number; - subMenus?: Array<{ - id: string; - code: string; - label: string; - description?: string; - sortOrder?: number; - options?: Array<{ - id: string; - code: string; - label: string; - description?: string; - actionCode: string; - sortOrder?: number; - }>; - }>; - }>; -}; - -interface ModuleCardProps { - suiteId: string; - module: ModuleType; - isExpanded: boolean; - isNodeExpanded: (id: string) => boolean; - onToggle: () => void; - onToggleNode: (id: string) => void; - onDeactivate: () => void; - onActivate: () => void; - onRemove: () => void; - isDeactivating: boolean; - isActivating: boolean; - isRemoving: boolean; -} - -export const ModuleCard: React.FC = ({ - suiteId, - module, - isExpanded, - isNodeExpanded, - onToggle, - onToggleNode, - onDeactivate, - onActivate, - onRemove, - isDeactivating, - isActivating, - isRemoving, -}) => { - const [isAddingMenu, setIsAddingMenu] = useState(false); - const [menu, setMenu] = useState(emptyMenu()); - - const addMenuMutation = useAddMenu(suiteId, module.id); - const removeMenuMutation = useRemoveMenu(suiteId, module.id); - - const handleAddMenu = async (e: React.FormEvent) => { - e.preventDefault(); - if (!menu.code.trim()) { - setMenu(s => ({ ...s, error: 'Código requerido' })); - return; - } - if (!menu.label.trim()) { - setMenu(s => ({ ...s, error: 'Etiqueta requerida' })); - return; - } - try { - await addMenuMutation.mutateAsync({ - code: formatSystemCode(menu.code), - label: menu.label.trim(), - description: menu.desc.trim(), - sortOrder: parseInt(menu.sort) || 1, - }); - setMenu(emptyMenu()); - setIsAddingMenu(false); - } catch { - /* handled by hook */ - } - }; - - return ( -
-
-
- {isExpanded ? ( - - ) : ( - - )} - -
- {module.name} - -
-
-
- - Ord: {module.sortOrder} -
- {module.status === 'Active' ? ( - { - e.stopPropagation(); - onDeactivate(); - }} - disabled={isDeactivating} - > - - - ) : ( - { - e.stopPropagation(); - onActivate(); - }} - disabled={isActivating} - > - - - )} - { - e.stopPropagation(); - onRemove(); - }} - disabled={isRemoving} - className="hover:text-m3-error hover:bg-m3-error/10" - > - - -
-
-
- -
- {module.description && ( -

- {module.description} -

- )} - - { - setIsAddingMenu(open); - if (!open) setMenu(emptyMenu()); - }} - onSubmit={handleAddMenu} - addLabel="+" - title="Nuevo Menú" - cancelLabel="Cancelar" - submitLabel="Guardar Menú" - isLoading={addMenuMutation.isPending} - error={menu.error || undefined} - > - setMenu(s => ({ ...s, code: e.target.value }))} - placeholder="e.g. ADMIN" - /> - setMenu(s => ({ ...s, label: e.target.value }))} - placeholder="e.g. Administración" - /> - setMenu(s => ({ ...s, desc: e.target.value }))} - placeholder="Opcional" - /> - setMenu(s => ({ ...s, sort: e.target.value }))} - placeholder="1" - /> - - - {!module.menus || module.menus.length === 0 ? ( -

- No hay menús configurados. -

- ) : ( -
- {module.menus.map(menuItem => ( - onToggleNode(menuItem.id)} - onToggleSub={onToggleNode} - onRemoveMenu={id => removeMenuMutation.mutate(id)} - isRemovingMenu={removeMenuMutation.isPending} - /> - ))} -
- )} -
-
- ); -}; diff --git a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/NodeActionsPanel.tsx b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/NodeActionsPanel.tsx new file mode 100644 index 00000000..b535eb24 --- /dev/null +++ b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/NodeActionsPanel.tsx @@ -0,0 +1,106 @@ +import React, { useState } from 'react'; +import { Key, Plus, X } from 'lucide-react'; +import { M3Select } from '@shared/components/M3Select'; +import { IconButton } from '@shared/components/Tooltip'; + +export interface SuiteActionOption { + code: string; + name: string; +} + +interface NodeActionsPanelProps { + actionCodes: string[]; + availableActions: SuiteActionOption[]; + isLinking: boolean; + isUnlinking: boolean; + onLink: (actionCode: string) => void; + onUnlink: (actionCode: string) => void; +} + +/** + * Panel del vínculo N:M funcionalidad↔nodo (ADR-0090). + * Muestra las funcionalidades vinculadas como chips y permite añadir + * desde el registro de acciones de la suite (multi-select acumulativo). + */ +export const NodeActionsPanel: React.FC = ({ + actionCodes, + availableActions, + isLinking, + isUnlinking, + onLink, + onUnlink, +}) => { + const [selected, setSelected] = useState(''); + + const unlinked = availableActions.filter(a => !actionCodes.includes(a.code)); + + const nameOf = (code: string) => availableActions.find(a => a.code === code)?.name ?? code; + + const handleLink = () => { + if (!selected) return; + onLink(selected); + setSelected(''); + }; + + return ( +
+
+ Funcionalidades vinculadas (N:M) +
+ + {actionCodes.length === 0 ? ( +

Sin funcionalidades vinculadas.

+ ) : ( +
+ {actionCodes.map(code => ( + + {code} + + + ))} +
+ )} + + {unlinked.length > 0 && ( +
+
+ setSelected(e.target.value)} + className="mb-0" + > + + {unlinked.map(a => ( + + ))} + +
+ + + +
+ )} +
+ ); +}; diff --git a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/NodeMetadataDialog.tsx b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/NodeMetadataDialog.tsx new file mode 100644 index 00000000..5dca1f20 --- /dev/null +++ b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/NodeMetadataDialog.tsx @@ -0,0 +1,140 @@ +/* eslint-disable react-hooks/set-state-in-effect -- Reinicia el formulario con los metadatos del nodo cada vez que se abre el diálogo. + Patrón intencional del código heredado; la regla sigue activa en el resto del repo. */ +import React, { useEffect, useState } from 'react'; +import { ClipboardList } from 'lucide-react'; +import { M3FormDialog } from '@shared/components/M3FormDialog'; +import { M3TextField } from '@shared/components/M3TextField'; +import { M3Button } from '@shared/components/M3Button'; +import type { + SystemSuiteNode, + SystemSuiteNodeMetadata, +} from '@domain/authorization/schemas/system-suite.schema'; + +interface NodeMetadataDialogProps { + open: boolean; + node: SystemSuiteNode; + isSaving: boolean; + onClose: () => void; + onSave: (metadata: SystemSuiteNodeMetadata) => void; +} + +type MetaState = { + responsable: string; + criticidad: string; + productoImpactado: string; + componenteTecnico: string; + dependencias: string; + evidencias: string; + trazabilidadSdlc: string; +}; + +const fromNode = (m: SystemSuiteNodeMetadata | null | undefined): MetaState => ({ + responsable: m?.responsable ?? '', + criticidad: m?.criticidad ?? '', + productoImpactado: m?.productoImpactado ?? '', + componenteTecnico: m?.componenteTecnico ?? '', + dependencias: m?.dependencias ?? '', + evidencias: m?.evidencias ?? '', + trazabilidadSdlc: m?.trazabilidadSdlc ?? '', +}); + +/** + * Editor de metadatos de gobernanza SDLC por nodo (ADR-0090). + * Todos los campos son opcionales; se envía el conjunto completo (reemplazo). + */ +export const NodeMetadataDialog: React.FC = ({ + open, + node, + isSaving, + onClose, + onSave, +}) => { + const [state, setState] = useState(fromNode(node.metadata)); + + useEffect(() => { + if (open) setState(fromNode(node.metadata)); + }, [open, node.metadata]); + + const set = (key: keyof MetaState) => (e: React.ChangeEvent) => + setState(s => ({ ...s, [key]: e.target.value })); + + const handleSave = () => { + const trimmed = (v: string) => (v.trim() === '' ? null : v.trim()); + onSave({ + responsable: trimmed(state.responsable), + criticidad: trimmed(state.criticidad), + productoImpactado: trimmed(state.productoImpactado), + componenteTecnico: trimmed(state.componenteTecnico), + dependencias: trimmed(state.dependencias), + evidencias: trimmed(state.evidencias), + trazabilidadSdlc: trimmed(state.trazabilidadSdlc), + }); + }; + + return ( + } + maxWidth="max-w-xl" + footer={ + <> + + Cancelar + + + {isSaving ? 'Guardando…' : 'Guardar Metadatos'} + + + } + > +
+ + + + + + +
+ +
+
+
+ ); +}; diff --git a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/OptionRow.tsx b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/OptionRow.tsx deleted file mode 100644 index 1d5e7cb8..00000000 --- a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/OptionRow.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import React, { useState } from 'react'; -import { Pencil, Trash2, KeyRound } from 'lucide-react'; -import { useInlineEdit } from '@app/hooks/use-inline-edit'; -import { useUpdateOption, useRemoveOption } from '@app/authorization/hooks/use-system-suite'; -import { M3TextField } from '@shared/components/M3TextField'; -import { IconButton } from '@shared/components/Tooltip'; -import { CodeBadge } from '@shared/components/CodeBadge'; -import { ErrorDisplay } from '@shared/components/data-display/ErrorDisplay'; -import { formatSystemCode } from '@app/utils/security'; - -type OptionType = { - id: string; - code: string; - label: string; - description?: string; - actionCode: string; - sortOrder?: number; -}; - -interface OptionRowProps { - suiteId: string; - moduleId: string; - menuId: string; - subMenuId: string; - option: OptionType; -} - -interface OptionDraft { - label: string; - description: string; - actionCode: string; - sortOrder: number; -} - -export const OptionRow: React.FC = ({ - suiteId, - moduleId, - menuId, - subMenuId, - option, -}) => { - const [editError, setEditError] = useState(''); - const updateOptionMutation = useUpdateOption(suiteId, moduleId, menuId, subMenuId, option.id); - const removeOptionMutation = useRemoveOption(suiteId, moduleId, menuId, subMenuId, option.id); - - const edit = useInlineEdit(['label', 'description', 'actionCode', 'sortOrder']); - - const handleStartEdit = () => { - edit.openEdit(option.id, { - label: option.label, - description: option.description ?? '', - actionCode: option.actionCode, - sortOrder: option.sortOrder ?? 1, - }); - setEditError(''); - }; - - const handleUpdate = async (e: React.FormEvent) => { - e.preventDefault(); - const label = edit.draft.label?.trim() ?? ''; - const actionCode = edit.draft.actionCode?.trim() ?? ''; - if (!label) { - setEditError('Etiqueta requerida'); - return; - } - if (!actionCode) { - setEditError('Código de acción requerido'); - return; - } - try { - await updateOptionMutation.mutateAsync({ - label, - description: edit.draft.description?.trim() ?? '', - actionCode: formatSystemCode(actionCode), - sortOrder: Number(edit.draft.sortOrder) || 1, - }); - edit.cancelEdit(); - setEditError(''); - } catch { - /* handled by hook */ - } - }; - - if (edit.isEditing(option.id)) { - return ( -
-
- edit.setField('label', e.target.value)} - /> - edit.setField('actionCode', e.target.value)} - /> -
- edit.setField('description', e.target.value)} - /> - edit.setField('sortOrder', parseInt(e.target.value) || 1)} - /> - -
- - -
- - ); - } - - return ( -
-
-
- {option.label} - -
- {option.description && ( -

- {option.description} -

- )} -
-
- - - {option.actionCode} - - - - - removeOptionMutation.mutate(option.id)} - disabled={removeOptionMutation.isPending} - className="opacity-0 group-hover/opt:opacity-100 transition-opacity hover:text-m3-error hover:bg-m3-error/10" - > - - -
-
- ); -}; diff --git a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/SubMenuRow.tsx b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/SubMenuRow.tsx deleted file mode 100644 index 9e7a86f6..00000000 --- a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/SubMenuRow.tsx +++ /dev/null @@ -1,302 +0,0 @@ -import React, { useState } from 'react'; -import { ChevronDown, ChevronRight, FolderOpen, Pencil, Trash2 } from 'lucide-react'; -import { useInlineEdit } from '@app/hooks/use-inline-edit'; -import { - useAddOption, - useRemoveOption, - useUpdateSubMenu, -} from '@app/authorization/hooks/use-system-suite'; -import { M3TextField } from '@shared/components/M3TextField'; -import { InlineAddForm } from '@shared/components/InlineAddForm'; -import { IconButton } from '@shared/components/Tooltip'; -import { CodeBadge } from '@shared/components/CodeBadge'; -import { ErrorDisplay } from '@shared/components/data-display/ErrorDisplay'; -import { formatSystemCode } from '@app/utils/security'; -import { OptionRow } from './OptionRow'; -import { AddOptState, emptyOpt } from './types'; - -type SubMenuType = { - id: string; - code: string; - label: string; - description?: string; - sortOrder?: number; - options?: Array<{ - id: string; - code: string; - label: string; - description?: string; - actionCode: string; - sortOrder?: number; - }>; -}; - -interface SubMenuRowProps { - suiteId: string; - moduleId: string; - menuId: string; - subMenu: SubMenuType; - isExpanded: boolean; - onToggle: () => void; - onRemoveSubMenu: (id: string) => void; - isRemovingSubMenu: boolean; -} - -interface SubMenuDraft { - label: string; - description: string; - sortOrder: number; -} - -export const SubMenuRow: React.FC = ({ - suiteId, - moduleId, - menuId, - subMenu, - isExpanded, - onToggle, - onRemoveSubMenu, - isRemovingSubMenu, -}) => { - const [isAddingOpt, setIsAddingOpt] = useState(false); - const [opt, setOpt] = useState(emptyOpt()); - const [editError, setEditError] = useState(''); - - const addOptionMutation = useAddOption(suiteId, moduleId, menuId, subMenu.id); - const removeOptionMutation = useRemoveOption(suiteId, moduleId, menuId, subMenu.id); - const updateSubMenuMutation = useUpdateSubMenu(suiteId, moduleId, menuId, subMenu.id); - - const edit = useInlineEdit(['label', 'description', 'sortOrder']); - - const handleAddOption = async (e: React.FormEvent) => { - e.preventDefault(); - if (!opt.code.trim()) { - setOpt(s => ({ ...s, error: 'Código requerido' })); - return; - } - if (!opt.label.trim()) { - setOpt(s => ({ ...s, error: 'Etiqueta requerida' })); - return; - } - if (!opt.actionCode.trim()) { - setOpt(s => ({ ...s, error: 'Código de acción requerido' })); - return; - } - try { - await addOptionMutation.mutateAsync({ - code: formatSystemCode(opt.code), - label: opt.label.trim(), - description: opt.desc.trim(), - actionCode: formatSystemCode(opt.actionCode), - sortOrder: parseInt(opt.sort) || 1, - }); - setOpt(emptyOpt()); - setIsAddingOpt(false); - } catch { - /* handled by hook */ - } - }; - - const handleStartEditSub = () => { - edit.openEdit(subMenu.id, { - label: subMenu.label, - description: subMenu.description ?? '', - sortOrder: subMenu.sortOrder ?? 1, - }); - setEditError(''); - }; - - const handleUpdateSubMenu = async (e: React.FormEvent) => { - e.preventDefault(); - const label = edit.draft.label?.trim() ?? ''; - if (!label) { - setEditError('Etiqueta requerida'); - return; - } - try { - await updateSubMenuMutation.mutateAsync({ - label, - description: edit.draft.description?.trim() ?? '', - sortOrder: Number(edit.draft.sortOrder) || 1, - }); - edit.cancelEdit(); - setEditError(''); - } catch { - /* handled by hook */ - } - }; - - const handleCancelEditSub = () => { - edit.cancelEdit(); - setEditError(''); - }; - - return ( -
- {edit.isEditing(subMenu.id) ? ( -
-
- edit.setField('label', e.target.value)} - /> - edit.setField('sortOrder', parseInt(e.target.value) || 1)} - /> -
- edit.setField('description', e.target.value)} - /> - -
- - -
- - ) : ( -
-
- {isExpanded ? ( - - ) : ( - - )} - -
- - {subMenu.label} - - -
-
-
- {isExpanded && ( - - )} - - - - onRemoveSubMenu(subMenu.id)} - disabled={isRemovingSubMenu} - className="hover:text-m3-error hover:bg-m3-error/10" - > - - -
-
- )} - -
- {isAddingOpt && ( - { - setIsAddingOpt(open); - if (!open) setOpt(emptyOpt()); - }} - onSubmit={handleAddOption} - addLabel="Opción" - title="Nueva Opción" - cancelLabel="Cancelar" - submitLabel="Guardar Opción" - isLoading={addOptionMutation.isPending} - error={opt.error || undefined} - > - setOpt(s => ({ ...s, code: e.target.value }))} - placeholder="e.g. VIEW" - /> - setOpt(s => ({ ...s, label: e.target.value }))} - placeholder="e.g. Ver Usuarios" - /> - setOpt(s => ({ ...s, desc: e.target.value }))} - placeholder="Opcional" - /> - setOpt(s => ({ ...s, actionCode: e.target.value }))} - placeholder="e.g. USER_VIEW" - /> - setOpt(s => ({ ...s, sort: e.target.value }))} - placeholder="1" - /> - - )} - - {!subMenu.options || subMenu.options.length === 0 ? ( -

No hay opciones configuradas.

- ) : ( -
- {subMenu.options.map(option => ( - - ))} -
- )} -
-
- ); -}; diff --git a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/SuiteNode.test.tsx b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/SuiteNode.test.tsx new file mode 100644 index 00000000..4352ef12 --- /dev/null +++ b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/SuiteNode.test.tsx @@ -0,0 +1,72 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { SuiteNode, type NodeHandlers } from './SuiteNode'; +import type { SystemSuiteNode } from '@domain/authorization/schemas/system-suite.schema'; + +// UX G-107: el alta de nodo hijo también se apoya en `AddNode`, que el backend +// rechaza sobre un módulo inactivo. `NodeHandlers.canAdd` propaga el estado del +// módulo para deshabilitar la acción y guiar a activar primero. + +const node: SystemSuiteNode = { + id: 'n1', + parentNodeId: null, + kind: 'Menu', + code: 'MENU', + label: 'Menú 1', + description: '', + status: 'Active', + sortOrder: 1, + actionCodes: [], + metadata: null, + children: [], +}; + +const makeHandlers = (canAdd: boolean): NodeHandlers => ({ + onAddChild: vi.fn(), + onUpdate: vi.fn(), + onRemove: vi.fn(), + onSetStatus: vi.fn(), + onLinkAction: vi.fn(), + onUnlinkAction: vi.fn(), + onSetMetadata: vi.fn(), + canAdd, + pending: { + add: false, + update: false, + remove: false, + status: false, + link: false, + unlink: false, + metadata: false, + }, +}); + +const renderNode = (canAdd: boolean) => + render( + true} + onToggleNode={vi.fn()} + /> + ); + +describe('SuiteNode — alta de nodo hijo según estado del módulo', () => { + it('deshabilita el alta de nodo hijo cuando el módulo está inactivo', () => { + renderNode(false); + expect( + screen.getByRole('button', { name: 'Activa el módulo para agregar nodos' }) + ).toBeDisabled(); + }); + + it('permite agregar un nodo hijo cuando el módulo está activo', () => { + renderNode(true); + const addChild = screen.getByRole('button', { name: 'Agregar nodo hijo' }); + expect(addChild).toBeEnabled(); + + fireEvent.click(addChild); + expect(screen.getByText('Nuevo Nodo Hijo')).toBeInTheDocument(); + }); +}); diff --git a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/SuiteNode.tsx b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/SuiteNode.tsx new file mode 100644 index 00000000..3ac17661 --- /dev/null +++ b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/SuiteNode.tsx @@ -0,0 +1,422 @@ +import React, { useState } from 'react'; +import { + ChevronDown, + ChevronRight, + FolderTree, + Folder, + FileText, + EyeOff, + ShieldCheck, + Trash2, + Plus, + Pencil, + ClipboardList, + Key, +} from 'lucide-react'; +import { IconButton } from '@shared/components/Tooltip'; +import { CodeBadge } from '@shared/components/CodeBadge'; +import { StatusBadge } from '@shared/components/StatusBadge'; +import { M3TextField } from '@shared/components/M3TextField'; +import { M3Select } from '@shared/components/M3Select'; +import { InlineAddForm } from '@shared/components/InlineAddForm'; +import { formatSystemCode } from '@app/utils/security'; +import type { + SystemSuiteNode, + SystemSuiteNodeMetadata, +} from '@domain/authorization/schemas/system-suite.schema'; +import { NodeMetadataDialog } from './NodeMetadataDialog'; +import { NodeActionsPanel, type SuiteActionOption } from './NodeActionsPanel'; + +// ── Handlers compartidos por el subárbol entero (instanciados a nivel de módulo) ── +export interface NodeHandlers { + onAddChild: ( + parentNodeId: string, + payload: { kind: string; code: string; label: string; description: string; sortOrder: number } + ) => void; + onUpdate: ( + nodeId: string, + payload: { label: string; description: string; sortOrder: number } + ) => void; + onRemove: (nodeId: string) => void; + onSetStatus: (nodeId: string, active: boolean) => void; + onLinkAction: (nodeId: string, actionCode: string) => void; + onUnlinkAction: (nodeId: string, actionCode: string) => void; + onSetMetadata: (nodeId: string, metadata: SystemSuiteNodeMetadata) => void; + /** + * Si es `false`, el módulo contenedor está inactivo: el backend rechaza `AddNode` + * con 400 sobre un módulo inactivo, así que la UI deshabilita el alta de nodos + * y guía a activar el módulo primero (G-107). + */ + canAdd: boolean; + pending: { + add: boolean; + update: boolean; + remove: boolean; + status: boolean; + link: boolean; + unlink: boolean; + metadata: boolean; + }; +} + +interface SuiteNodeProps { + node: SystemSuiteNode; + level: number; + availableActions: SuiteActionOption[]; + handlers: NodeHandlers; + isNodeExpanded: (id: string) => boolean; + onToggleNode: (id: string) => void; +} + +const KIND_LABEL: Record = { + Menu: 'Menú', + SubMenu: 'Submenú', + Option: 'Opción', +}; + +const KIND_STYLE: Record = { + Menu: 'bg-blue-500/10 text-blue-500', + SubMenu: 'bg-sky-500/10 text-sky-500', + Option: 'bg-slate-500/10 text-slate-500', +}; + +const kindIcon = (kind: string) => { + if (kind === 'Menu') return ; + if (kind === 'SubMenu') return ; + return ; +}; + +const emptyChild = () => ({ + kind: 'Option', + code: '', + label: '', + desc: '', + sort: '1', + error: '', +}); + +export const SuiteNode: React.FC = ({ + node, + level, + availableActions, + handlers, + isNodeExpanded, + onToggleNode, +}) => { + const nodeKey = `node-${node.id}`; + const expanded = isNodeExpanded(nodeKey); + const hasChildren = node.children.length > 0; + const isActive = node.status === 'Active'; + + const [isAddingChild, setIsAddingChild] = useState(false); + const [child, setChild] = useState(emptyChild()); + + const [isEditing, setIsEditing] = useState(false); + const [editState, setEditState] = useState({ label: '', desc: '', sort: '1', error: '' }); + + const [showMetadata, setShowMetadata] = useState(false); + const [showActions, setShowActions] = useState(false); + + const beginEdit = () => { + setEditState({ + label: node.label, + desc: node.description ?? '', + sort: String(node.sortOrder ?? 1), + error: '', + }); + setIsEditing(true); + }; + + const submitEdit = (e: React.FormEvent) => { + e.preventDefault(); + if (!editState.label.trim()) { + setEditState(s => ({ ...s, error: 'Etiqueta requerida' })); + return; + } + handlers.onUpdate(node.id, { + label: editState.label.trim(), + description: editState.desc.trim(), + sortOrder: parseInt(editState.sort) || 1, + }); + setIsEditing(false); + }; + + const submitChild = (e: React.FormEvent) => { + e.preventDefault(); + if (!child.code.trim()) { + setChild(s => ({ ...s, error: 'Código requerido' })); + return; + } + if (!child.label.trim()) { + setChild(s => ({ ...s, error: 'Etiqueta requerida' })); + return; + } + handlers.onAddChild(node.id, { + kind: child.kind, + code: formatSystemCode(child.code), + label: child.label.trim(), + description: child.desc.trim(), + sortOrder: parseInt(child.sort) || 1, + }); + setChild(emptyChild()); + setIsAddingChild(false); + }; + + const isLeaf = node.kind === 'Option'; + + return ( +
0 ? 8 : 0 }}> + {/* ── Fila del nodo ── */} +
+ + + {kindIcon(node.kind)} + + + {KIND_LABEL[node.kind] ?? node.kind} + + + + {node.label} + + + + {isLeaf && node.actionCodes.length > 0 && ( + + + {node.actionCodes.length} + + )} + {node.metadata && ( + + + + )} + + + + + Ord: {node.sortOrder} + +
+ { + setIsAddingChild(true); + if (!expanded && hasChildren) onToggleNode(nodeKey); + }} + className="hover:text-m3-primary hover:bg-m3-primary/10 disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-m3-secondary" + > + + + {isLeaf && ( + setShowActions(v => !v)} + className={showActions ? 'text-m3-primary bg-m3-primary/10' : ''} + > + + + )} + setShowMetadata(true)} + className="hover:text-emerald-500 hover:bg-emerald-500/10" + > + + + + + + {isActive ? ( + handlers.onSetStatus(node.id, false)} + disabled={handlers.pending.status} + > + + + ) : ( + handlers.onSetStatus(node.id, true)} + disabled={handlers.pending.status} + > + + + )} + handlers.onRemove(node.id)} + disabled={handlers.pending.remove} + className="hover:text-m3-error hover:bg-m3-error/10" + > + + +
+
+ + {/* ── N:M panel (hoja) ── */} + {isLeaf && showActions && ( +
+ handlers.onLinkAction(node.id, code)} + onUnlink={code => handlers.onUnlinkAction(node.id, code)} + /> +
+ )} + + {/* ── Edición inline ── */} + {isEditing && ( +
+ setIsEditing(open)} + onSubmit={submitEdit} + addLabel="Nodo" + title={`Editar ${KIND_LABEL[node.kind] ?? 'Nodo'}`} + triggerEmphasis="none" + cancelLabel="Cancelar" + submitLabel="Guardar Cambios" + isLoading={handlers.pending.update} + error={editState.error || undefined} + > + setEditState(s => ({ ...s, label: e.target.value }))} + /> + setEditState(s => ({ ...s, desc: e.target.value }))} + /> + setEditState(s => ({ ...s, sort: e.target.value }))} + /> + +
+ )} + + {/* ── Alta de nodo hijo ── */} + {isAddingChild && ( +
+ { + setIsAddingChild(open); + if (!open) setChild(emptyChild()); + }} + onSubmit={submitChild} + addLabel="Nodo" + title="Nuevo Nodo Hijo" + triggerEmphasis="none" + cancelLabel="Cancelar" + submitLabel="Guardar Nodo" + isLoading={handlers.pending.add} + error={child.error || undefined} + > + setChild(s => ({ ...s, kind: e.target.value }))} + > + + + + + setChild(s => ({ ...s, code: e.target.value }))} + placeholder="e.g. CONSULTAR" + /> + setChild(s => ({ ...s, label: e.target.value }))} + placeholder="e.g. Consultar" + /> + setChild(s => ({ ...s, desc: e.target.value }))} + placeholder="Opcional" + /> + setChild(s => ({ ...s, sort: e.target.value }))} + placeholder="1" + /> + +
+ )} + + {/* ── Hijos (recursivo) ── */} + {expanded && hasChildren && ( +
+ {node.children.map(childNode => ( + + ))} +
+ )} + + {/* ── Dialogo de metadatos ── */} + {showMetadata && ( + setShowMetadata(false)} + onSave={metadata => { + handlers.onSetMetadata(node.id, metadata); + setShowMetadata(false); + }} + /> + )} +
+ ); +}; diff --git a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/SuiteNodeTree.test.tsx b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/SuiteNodeTree.test.tsx new file mode 100644 index 00000000..edda9a92 --- /dev/null +++ b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/SuiteNodeTree.test.tsx @@ -0,0 +1,69 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { SuiteNodeTree } from './SuiteNodeTree'; + +// UX G-107: un módulo nace `Inactive` y el backend rechaza `AddNode` con 400 sobre +// un módulo inactivo. La UI debe deshabilitar el alta de nodos y guiar a activar el +// módulo primero, en lugar de fallar con un 400 críptico. + +const mutation = () => ({ mutate: vi.fn(), isPending: false }); + +vi.mock('@app/authorization/hooks/use-system-suite', () => ({ + useAddNode: () => mutation(), + useUpdateNode: () => mutation(), + useRemoveNode: () => mutation(), + useSetNodeStatus: () => mutation(), + useLinkNodeAction: () => mutation(), + useUnlinkNodeAction: () => mutation(), + useSetNodeMetadata: () => mutation(), +})); + +const baseProps = { + suiteId: 'suite-1', + availableActions: [], + isExpanded: true, + isNodeExpanded: () => true, + onToggle: vi.fn(), + onToggleNode: vi.fn(), + onDeactivate: vi.fn(), + onActivate: vi.fn(), + onRemove: vi.fn(), + isDeactivating: false, + isActivating: false, + isRemoving: false, +}; + +const makeModule = (status: string) => ({ + id: 'm1', + code: 'MOD', + name: 'Módulo 1', + description: '', + status, + sortOrder: 1, + nodes: [], +}); + +describe('SuiteNodeTree — alta de nodo raíz según estado del módulo', () => { + it('deshabilita el alta y guía a activar cuando el módulo está inactivo', () => { + render(); + + const guide = screen.getByText('Activa el módulo para agregar nodos'); + expect(guide).toBeDisabled(); + + // No debe existir ninguna acción habilitada de «Agregar nodo raíz». + expect(screen.queryByText('Agregar nodo raíz')).toBeNull(); + expect(screen.queryByRole('button', { name: 'Agregar nodo raíz' })).toBeNull(); + }); + + it('permite agregar un nodo raíz cuando el módulo está activo', () => { + render(); + + const addBtn = screen.getByText('Agregar nodo raíz'); + expect(addBtn).toBeEnabled(); + + fireEvent.click(addBtn); + + // Se abre el formulario inline de alta de nodo raíz. + expect(screen.getByText('Nuevo Nodo Raíz')).toBeInTheDocument(); + }); +}); diff --git a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/SuiteNodeTree.tsx b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/SuiteNodeTree.tsx new file mode 100644 index 00000000..9dffac84 --- /dev/null +++ b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/SuiteNodeTree.tsx @@ -0,0 +1,335 @@ +import React, { useMemo, useState } from 'react'; +import { ChevronDown, ChevronRight, Layers, EyeOff, ShieldCheck, Trash2, Plus } from 'lucide-react'; +import { + useAddNode, + useUpdateNode, + useRemoveNode, + useSetNodeStatus, + useLinkNodeAction, + useUnlinkNodeAction, + useSetNodeMetadata, +} from '@app/authorization/hooks/use-system-suite'; +import { InlineAddForm } from '@shared/components/InlineAddForm'; +import { IconButton } from '@shared/components/Tooltip'; +import { CodeBadge } from '@shared/components/CodeBadge'; +import { StatusBadge } from '@shared/components/StatusBadge'; +import { M3TextField } from '@shared/components/M3TextField'; +import { M3Select } from '@shared/components/M3Select'; +import { formatSystemCode } from '@app/utils/security'; +import type { SystemSuiteNode } from '@domain/authorization/schemas/system-suite.schema'; +import { SuiteNode, type NodeHandlers } from './SuiteNode'; +import type { SuiteActionOption } from './NodeActionsPanel'; + +interface ModuleWithNodes { + id: string; + code: string; + name: string; + description?: string; + status: string; + sortOrder?: number; + nodes?: SystemSuiteNode[]; +} + +interface SuiteNodeTreeProps { + suiteId: string; + module: ModuleWithNodes; + availableActions: SuiteActionOption[]; + isExpanded: boolean; + isNodeExpanded: (id: string) => boolean; + onToggle: () => void; + onToggleNode: (id: string) => void; + onDeactivate: () => void; + onActivate: () => void; + onRemove: () => void; + isDeactivating: boolean; + isActivating: boolean; + isRemoving: boolean; +} + +const emptyRoot = () => ({ kind: 'Menu', code: '', label: '', desc: '', sort: '1', error: '' }); + +/** + * Árbol de nodos recursivo de un módulo (ADR-0090). Reemplaza la cadena rígida + * ModuleCard→MenuRow→SubMenuRow→OptionRow: renderiza `module.nodes` con el + * componente recursivo y expone la escritura por nodeId. + */ +export const SuiteNodeTree: React.FC = ({ + suiteId, + module, + availableActions, + isExpanded, + isNodeExpanded, + onToggle, + onToggleNode, + onDeactivate, + onActivate, + onRemove, + isDeactivating, + isActivating, + isRemoving, +}) => { + const addNode = useAddNode(suiteId, module.id); + const updateNode = useUpdateNode(suiteId, module.id); + const removeNode = useRemoveNode(suiteId, module.id); + const setNodeStatus = useSetNodeStatus(suiteId, module.id); + const linkAction = useLinkNodeAction(suiteId, module.id); + const unlinkAction = useUnlinkNodeAction(suiteId, module.id); + const setMetadata = useSetNodeMetadata(suiteId, module.id); + + const [isAddingRoot, setIsAddingRoot] = useState(false); + const [root, setRoot] = useState(emptyRoot()); + + // Un módulo nace `Inactive`; el backend rechaza `AddNode` con 400 sobre un módulo + // inactivo. La UI deshabilita el alta de nodos y guía a activarlo primero (G-107). + const isModuleActive = module.status === 'Active'; + + const handlers: NodeHandlers = useMemo( + () => ({ + onAddChild: (parentNodeId, payload) => addNode.mutate({ parentNodeId, ...payload }), + onUpdate: (nodeId, payload) => updateNode.mutate({ nodeId, ...payload }), + onRemove: nodeId => removeNode.mutate(nodeId), + onSetStatus: (nodeId, active) => setNodeStatus.mutate({ nodeId, active }), + onLinkAction: (nodeId, actionCode) => linkAction.mutate({ nodeId, actionCode }), + onUnlinkAction: (nodeId, actionCode) => unlinkAction.mutate({ nodeId, actionCode }), + onSetMetadata: (nodeId, metadata) => setMetadata.mutate({ nodeId, ...metadata }), + canAdd: isModuleActive, + pending: { + add: addNode.isPending, + update: updateNode.isPending, + remove: removeNode.isPending, + status: setNodeStatus.isPending, + link: linkAction.isPending, + unlink: unlinkAction.isPending, + metadata: setMetadata.isPending, + }, + }), + [ + addNode, + updateNode, + removeNode, + setNodeStatus, + linkAction, + unlinkAction, + setMetadata, + isModuleActive, + ] + ); + + const nodes = useMemo( + () => [...(module.nodes ?? [])].sort((a, b) => a.sortOrder - b.sortOrder), + [module.nodes] + ); + + const submitRoot = (e: React.FormEvent) => { + e.preventDefault(); + if (!root.code.trim()) { + setRoot(s => ({ ...s, error: 'Código requerido' })); + return; + } + if (!root.label.trim()) { + setRoot(s => ({ ...s, error: 'Etiqueta requerida' })); + return; + } + addNode.mutate( + { + parentNodeId: null, + kind: root.kind, + code: formatSystemCode(root.code), + label: root.label.trim(), + description: root.desc.trim(), + sortOrder: parseInt(root.sort) || 1, + }, + { + onSuccess: () => { + setRoot(emptyRoot()); + setIsAddingRoot(false); + }, + } + ); + }; + + // Empty-state del árbol: si el módulo está inactivo, el backend rechaza `AddNode` + // con 400 (G-107), así que se muestra un CTA deshabilitado que guía a activarlo. + const renderRootCta = () => { + if (isAddingRoot) return null; + if (!isModuleActive) { + return ( + + ); + } + return ( + + ); + }; + + return ( +
+ {/* ── Cabecera del módulo ── */} +
+
+ {isExpanded ? ( + + ) : ( + + )} + +
+ {module.name} + +
+
+
+ + Ord: {module.sortOrder} +
+ {isExpanded && ( + { + e.stopPropagation(); + setIsAddingRoot(true); + }} + className="hover:text-m3-primary hover:bg-m3-primary/10 disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-m3-secondary" + > + + + )} + {module.status === 'Active' ? ( + { + e.stopPropagation(); + onDeactivate(); + }} + disabled={isDeactivating} + > + + + ) : ( + { + e.stopPropagation(); + onActivate(); + }} + disabled={isActivating} + > + + + )} + { + e.stopPropagation(); + onRemove(); + }} + disabled={isRemoving} + className="hover:text-m3-error hover:bg-m3-error/10" + > + + +
+
+
+ + {/* ── Cuerpo: árbol de nodos ── */} +
+ {module.description && ( +

+ {module.description} +

+ )} + + { + setIsAddingRoot(open); + if (!open) setRoot(emptyRoot()); + }} + onSubmit={submitRoot} + addLabel="Nodo raíz" + title="Nuevo Nodo Raíz" + triggerEmphasis="none" + cancelLabel="Cancelar" + submitLabel="Guardar Nodo" + isLoading={addNode.isPending} + error={root.error || undefined} + > + setRoot(s => ({ ...s, kind: e.target.value }))} + > + + + + + setRoot(s => ({ ...s, code: e.target.value }))} + placeholder="e.g. ADMIN" + /> + setRoot(s => ({ ...s, label: e.target.value }))} + placeholder="e.g. Administración" + /> + setRoot(s => ({ ...s, desc: e.target.value }))} + placeholder="Opcional" + /> + setRoot(s => ({ ...s, sort: e.target.value }))} + placeholder="1" + /> + + + {nodes.length === 0 ? ( + renderRootCta() + ) : ( +
+ {nodes.map(n => ( + + ))} +
+ )} +
+
+ ); +}; diff --git a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/index.ts b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/index.ts index 7c21c847..593a7846 100644 --- a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/index.ts +++ b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/index.ts @@ -1,5 +1,4 @@ -export { ModuleCard } from './ModuleCard'; -export { MenuRow } from './MenuRow'; -export { SubMenuRow } from './SubMenuRow'; -export { OptionRow } from './OptionRow'; -export * from './types'; +export { SuiteNodeTree } from './SuiteNodeTree'; +export { SuiteNode } from './SuiteNode'; +export type { NodeHandlers } from './SuiteNode'; +export type { SuiteActionOption } from './NodeActionsPanel'; diff --git a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/types.ts b/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/types.ts deleted file mode 100644 index 0d90a46d..00000000 --- a/src/apps/ums.web-app/src/presentation/authorization/system-suite/components/hierarchy/types.ts +++ /dev/null @@ -1,47 +0,0 @@ -export interface AddMenuState { - code: string; - label: string; - desc: string; - sort: string; - error: string; -} - -export interface AddSubState { - code: string; - label: string; - desc: string; - sort: string; - error: string; -} - -export interface AddOptState { - code: string; - label: string; - desc: string; - actionCode: string; - sort: string; - error: string; -} - -export const emptyMenu = (): AddMenuState => ({ - code: '', - label: '', - desc: '', - sort: '1', - error: '', -}); -export const emptySub = (): AddSubState => ({ - code: '', - label: '', - desc: '', - sort: '1', - error: '', -}); -export const emptyOpt = (): AddOptState => ({ - code: '', - label: '', - desc: '', - actionCode: '', - sort: '1', - error: '', -}); diff --git a/src/apps/ums.web-app/src/presentation/configuration/app-configuration/components/AppConfigurationDetailPanel.tsx b/src/apps/ums.web-app/src/presentation/configuration/app-configuration/components/AppConfigurationDetailPanel.tsx index ee16e42a..aca46963 100644 --- a/src/apps/ums.web-app/src/presentation/configuration/app-configuration/components/AppConfigurationDetailPanel.tsx +++ b/src/apps/ums.web-app/src/presentation/configuration/app-configuration/components/AppConfigurationDetailPanel.tsx @@ -1,3 +1,6 @@ +/* eslint-disable react-hooks/set-state-in-effect -- Entra en modo edición cuando el panel padre lo pide, con el valor de la configuración ya + cargada. + Patrón intencional del código heredado; la regla sigue activa en el resto del repo. */ /** * AppConfigurationDetailPanel */ @@ -130,6 +133,40 @@ export function AppConfigurationDetailPanel({ ); } + /** El valor de la configuración se pinta de tres formas excluyentes; cifrado gana sobre las otras dos. */ + const renderValue = () => { + if (config.isEncrypted) { + return ( +
+ + [Encrypted] +
+ ); + } + if (isEditingValue) { + return ( +
+ + + {t.save ?? 'Save'} + + + {t.cancel ?? 'Cancel'} + +
+ ); + } + return ( +
+ {config.value} + +
+ ); + }; + return ( {config.description || '-'}

{/* Value */} - - - [Encrypted] -
- ) : isEditingValue ? ( -
- - - {t.save ?? 'Save'} - - - {t.cancel ?? 'Cancel'} - -
- ) : ( -
- {config.value} - -
- ) - } - /> + {/* Metadata */}
diff --git a/src/apps/ums.web-app/src/presentation/configuration/app-configuration/components/AppConfigurationListPanel.tsx b/src/apps/ums.web-app/src/presentation/configuration/app-configuration/components/AppConfigurationListPanel.tsx index 75d9b8a5..a4e6bd62 100644 --- a/src/apps/ums.web-app/src/presentation/configuration/app-configuration/components/AppConfigurationListPanel.tsx +++ b/src/apps/ums.web-app/src/presentation/configuration/app-configuration/components/AppConfigurationListPanel.tsx @@ -1,7 +1,6 @@ import React, { useCallback } from 'react'; -import { Settings, Key, Lock, Globe, Building2, Cog, Info } from 'lucide-react'; +import { Settings, Key, Lock, Globe, Building2, Cog } from 'lucide-react'; import type { AppConfiguration } from '@domain/configuration/schemas/app-configuration.schema'; -import { useI18n } from '@app/i18n/use-i18n'; import { DataViewShell, DataList, @@ -64,8 +63,6 @@ export function AppConfigurationListPanel({ requiresFilter, filterOptions, }: AppConfigurationListPanelProps): React.JSX.Element { - const t = useI18n(); - const defaultFilterOptions: AtomicFilterOption[] = [ { label: 'Todos', value: 'all' }, { label: 'Borrador', value: 'Draft' }, @@ -173,8 +170,6 @@ export function AppConfigurationListPanel({ ); const totalItems = paginationState.totalItems; - const startIndex = paginationState.startIndex ?? 0; - const pageSize = paginationState.pageSize; const pagination = paginationState.totalPages > 0 @@ -200,7 +195,7 @@ export function AppConfigurationListPanel({ totalItems={paginationState.totalItems} startIndex={paginationState.startIndex ?? 0} pageSize={paginationState.pageSize} - itemLabel="parameters" + itemLabel="configuraciones" onClear={queryState.handleResetQuery} searchTerm={queryState.appliedQuery.term} /> diff --git a/src/apps/ums.web-app/src/presentation/configuration/app-configuration/components/ParameterDefinitionPickerDialog.tsx b/src/apps/ums.web-app/src/presentation/configuration/app-configuration/components/ParameterDefinitionPickerDialog.tsx index d84e0944..aa03bb8f 100644 --- a/src/apps/ums.web-app/src/presentation/configuration/app-configuration/components/ParameterDefinitionPickerDialog.tsx +++ b/src/apps/ums.web-app/src/presentation/configuration/app-configuration/components/ParameterDefinitionPickerDialog.tsx @@ -1,3 +1,6 @@ +/* eslint-disable react-hooks/set-state-in-effect -- Limpia búsqueda y filtros al cerrar, para que la próxima apertura no arrastre el estado de la + anterior. + Patrón intencional del código heredado; la regla sigue activa en el resto del repo. */ /** * ParameterDefinitionPickerDialog * Modal to select parameter definitions from catalog to add to AppConfiguration @@ -19,6 +22,12 @@ import { } from '@domain/configuration/schemas/parameter-catalog/parameter-definition.schema'; import { parameterCatalogService } from '@infra/configuration/services/parameter-catalog/parameter-catalog.service'; +/** Por qué no hay parámetros que ofrecer: ya enlazados, o directamente no hay ninguno. */ +function emptyMessage(t: ReturnType, hasLinkedParams: boolean): string { + return hasLinkedParams + ? (t.allParametersLinked ?? 'All available parameters are already linked') + : (t.noParametersAvailable ?? 'No parameters available'); +} interface ParameterDefinitionPickerDialogProps { isOpen: boolean; onClose: () => void; @@ -44,21 +53,6 @@ export function ParameterDefinitionPickerDialog({ const [statusFilter, setStatusFilter] = useState('all'); const [selectedIds, setSelectedIds] = useState>(new Set()); - useEffect(() => { - if (isOpen) { - loadParameters(); - } - }, [isOpen, searchTerm, dataTypeFilter, statusFilter]); - - useEffect(() => { - if (!isOpen) { - setSearchTerm(''); - setDataTypeFilter('all'); - setStatusFilter('all'); - setSelectedIds(new Set()); - } - }, [isOpen]); - const loadParameters = async () => { setIsLoading(true); try { @@ -67,7 +61,7 @@ export function ParameterDefinitionPickerDialog({ if (dataTypeFilter !== 'all') filter.dataTypeId = dataTypeFilter; if (statusFilter !== 'all') filter.isActive = statusFilter; - const result = await parameterCatalogService.getParameterDefinitions(filter as any); + const result = await parameterCatalogService.getAll(filter); const processed = result.items.map(p => ({ ...p, isLinked: existingCodes.includes(p.code), @@ -80,6 +74,21 @@ export function ParameterDefinitionPickerDialog({ } }; + useEffect(() => { + if (isOpen) { + loadParameters(); + } + }, [isOpen, searchTerm, dataTypeFilter, statusFilter]); + + useEffect(() => { + if (!isOpen) { + setSearchTerm(''); + setDataTypeFilter('all'); + setStatusFilter('all'); + setSelectedIds(new Set()); + } + }, [isOpen]); + const toggleSelect = (id: string) => { const newSelected = new Set(selectedIds); if (newSelected.has(id)) { @@ -174,20 +183,20 @@ export function ParameterDefinitionPickerDialog({
- {isLoading ? ( + {isLoading && (
{t.loading ?? 'Loading...'}
- ) : selectableParams.length === 0 ? ( + )} + {!isLoading && selectableParams.length === 0 && (
{paramsWithoutDefault.length > 0 ? (t.parametersNeedDefaultValue ?? 'Some parameters need a default value defined first') - : hasLinkedParams - ? (t.allParametersLinked ?? 'All available parameters are already linked') - : (t.noParametersAvailable ?? 'No parameters available')} + : emptyMessage(t, hasLinkedParams)}
- ) : ( + )} + {!isLoading && selectableParams.length !== 0 && (
{selectableParams.map(param => { const isSelected = selectedIds.has(param.id); diff --git a/src/apps/ums.web-app/src/presentation/configuration/app-configuration/screens/AppConfigurationDashboardScreen.tsx b/src/apps/ums.web-app/src/presentation/configuration/app-configuration/screens/AppConfigurationDashboardScreen.tsx index 8df1f883..8dd565e0 100644 --- a/src/apps/ums.web-app/src/presentation/configuration/app-configuration/screens/AppConfigurationDashboardScreen.tsx +++ b/src/apps/ums.web-app/src/presentation/configuration/app-configuration/screens/AppConfigurationDashboardScreen.tsx @@ -21,6 +21,7 @@ import { M3Dialog } from '@shared/components/M3Dialog'; import { useNotificationStore } from '@app/stores/notification.store'; import { useI18n } from '@app/i18n/use-i18n'; import type { ParameterDefinition } from '@domain/configuration/schemas/parameter-catalog/parameter-definition.schema'; +import { asApiError } from '@infra/http/httpClient'; export default function AppConfigurationDashboardScreen(): React.JSX.Element { const t = useI18n(); @@ -61,13 +62,14 @@ export default function AppConfigurationDashboardScreen(): React.JSX.Element { d.setSelectedId(results[0].appConfigurationId); } setIsPickerOpen(false); - } catch (err: any) { + } catch (err: unknown) { + const apiError = asApiError(err); const errorMsg = - err?.normalised?.message || - err?.response?.data?.detail || - err?.message || + apiError?.normalised?.message || + apiError?.response?.data?.detail || + apiError?.message || t.failedToLinkParameter; - console.error('Create config error:', err?.response?.data); + console.error('Create config error:', apiError?.response?.data); addNotification({ title: t.error ?? 'Error', message: errorMsg, @@ -95,8 +97,10 @@ export default function AppConfigurationDashboardScreen(): React.JSX.Element { if (d.selectedId === pendingDeleteId) { d.setSelectedId(''); } - } catch (err: any) { - const errorMsg = err?.normalised?.message || err?.response?.data?.detail || t.deleteFailed; + } catch (err: unknown) { + const apiError = asApiError(err); + const errorMsg = + apiError?.normalised?.message || apiError?.response?.data?.detail || t.deleteFailed; addNotification({ title: t.error ?? 'Error', message: errorMsg, diff --git a/src/apps/ums.web-app/src/presentation/configuration/app-configuration/screens/GlobalAppConfigurationDashboardScreen.tsx b/src/apps/ums.web-app/src/presentation/configuration/app-configuration/screens/GlobalAppConfigurationDashboardScreen.tsx index 3a2a2780..619fd07c 100644 --- a/src/apps/ums.web-app/src/presentation/configuration/app-configuration/screens/GlobalAppConfigurationDashboardScreen.tsx +++ b/src/apps/ums.web-app/src/presentation/configuration/app-configuration/screens/GlobalAppConfigurationDashboardScreen.tsx @@ -20,6 +20,7 @@ import { useNotificationStore } from '@app/stores/notification.store'; import { useI18n } from '@app/i18n/use-i18n'; import { useAuthStore } from '@app/stores/auth.store'; import type { ParameterDefinition } from '@domain/configuration/schemas/parameter-catalog/parameter-definition.schema'; +import { asApiError } from '@infra/http/httpClient'; export default function GlobalAppConfigurationDashboardScreen(): React.JSX.Element { const t = useI18n(); @@ -71,15 +72,11 @@ export default function GlobalAppConfigurationDashboardScreen(): React.JSX.Eleme } setIsPickerOpen(false); } catch (err: unknown) { - const e = err as { - normalised?: { message?: string }; - response?: { data?: { detail?: string } }; - message?: string; - }; + const apiError = asApiError(err); const errorMsg = - e?.normalised?.message || - e?.response?.data?.detail || - e?.message || + apiError?.normalised?.message || + apiError?.response?.data?.detail || + apiError?.message || t.failedToLinkParameter; addNotification({ title: t.error ?? 'Error', @@ -109,11 +106,9 @@ export default function GlobalAppConfigurationDashboardScreen(): React.JSX.Eleme d.setSelectedId(''); } } catch (err: unknown) { - const e = err as { - normalised?: { message?: string }; - response?: { data?: { detail?: string } }; - }; - const errorMsg = e?.normalised?.message || e?.response?.data?.detail || t.deleteFailed; + const apiError = asApiError(err); + const errorMsg = + apiError?.normalised?.message || apiError?.response?.data?.detail || t.deleteFailed; addNotification({ title: t.error ?? 'Error', message: errorMsg, diff --git a/src/apps/ums.web-app/src/presentation/configuration/feature-flag/components/FeatureFlagDetailPanel.tsx b/src/apps/ums.web-app/src/presentation/configuration/feature-flag/components/FeatureFlagDetailPanel.tsx index 5abc1918..26f909f0 100644 --- a/src/apps/ums.web-app/src/presentation/configuration/feature-flag/components/FeatureFlagDetailPanel.tsx +++ b/src/apps/ums.web-app/src/presentation/configuration/feature-flag/components/FeatureFlagDetailPanel.tsx @@ -36,6 +36,8 @@ import { useRemoveFeatureFlagCriteria, } from '@app/configuration/hooks/use-feature-flag'; +/** Icono por estado de la bandera. Cualquier otro (borrador) usa el interruptor. */ +const STATUS_ICON = { Active: CheckCircle2, Archived: Archive }; interface Props { flag: FeatureFlag | undefined; } @@ -62,7 +64,7 @@ const CriteriaRow: React.FC<{ isDraft: boolean; }> = ({ criteria, onRemove, isDraft }) => (
-
+
Tipo @@ -123,8 +125,7 @@ export const FeatureFlagDetailPanel: React.FC = ({ flag }) => { } const isDraft = flag.status === 'Inactive'; - const StatusIcon = - flag.status === 'Active' ? CheckCircle2 : flag.status === 'Archived' ? Archive : ToggleLeft; + const StatusIcon = STATUS_ICON[flag.status as keyof typeof STATUS_ICON] ?? ToggleLeft; const statusColor = STATUS_COLORS[flag.status] ?? STATUS_COLORS.Draft; const handleAddCriteria = async (e: React.FormEvent) => { @@ -282,7 +283,7 @@ export const FeatureFlagDetailPanel: React.FC = ({ flag }) => {

-
+
@@ -86,7 +92,7 @@ vi.mock('@shared/components/M3TextField', () => ({ })); vi.mock('@shared/components/M3Select', () => ({ - M3Select: ({ label, value, children }: any) => ( + M3Select: ({ label, value, children }: Record) => (
); }; diff --git a/src/apps/ums.web-app/src/presentation/shared/components/M3Card.test.tsx b/src/apps/ums.web-app/src/presentation/shared/components/M3Card.test.tsx index 6587dfc2..ca185167 100644 --- a/src/apps/ums.web-app/src/presentation/shared/components/M3Card.test.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/components/M3Card.test.tsx @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest'; +import React from 'react'; import { render, screen } from '@testing-library/react'; import { M3Card } from './M3Card'; @@ -39,8 +40,8 @@ describe('M3Card', () => { }); it('forwards ref', () => { - const ref = { current: null }; - render(Content); + const ref = React.createRef(); + render(Content); expect(ref.current).not.toBeNull(); }); diff --git a/src/apps/ums.web-app/src/presentation/shared/components/M3DataView.tsx b/src/apps/ums.web-app/src/presentation/shared/components/M3DataView.tsx index d26fa901..ef854b63 100644 --- a/src/apps/ums.web-app/src/presentation/shared/components/M3DataView.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/components/M3DataView.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback } from 'react'; +import React from 'react'; import { Search, LayoutList, LayoutGrid, Plus, Database } from 'lucide-react'; import { M3Card } from './M3Card'; import { M3TextField } from './M3TextField'; @@ -11,6 +11,53 @@ import { FilterBar } from './data-view/FilterBar'; import { SortDropdown } from './data-view/SortDropdown'; import { PaginationFooter } from './data-view/PaginationControls'; +/** Textos y clases del plegado de la zona de búsqueda. */ +const COLLAPSE_PRESENTATION = { + collapsed: { + headerButtonTitle: 'Show search & filters', + headerButtonClass: 'bg-m3-primary/10 border-m3-primary/40 text-m3-primary', + pillTitle: 'Expand search panel', + }, + expanded: { + headerButtonTitle: 'Hide search & filters', + headerButtonClass: + 'bg-m3-surface-container/60 border-m3-outline/40 text-m3-secondary hover:bg-m3-primary/10 hover:border-m3-primary/40 hover:text-m3-primary', + pillTitle: 'Collapse search panel', + }, +} as const; + +/** Clases del asa mientras se arrastra o en reposo. */ +const DRAG_PRESENTATION = { + dragging: { handleClass: 'bg-m3-primary/20', lineClass: 'bg-m3-primary/60' }, + idle: { + handleClass: 'hover:bg-m3-primary/10 transition-colors duration-150', + lineClass: 'bg-m3-outline/30 group-hover:bg-m3-primary/40', + }, +} as const; + +/** + * Clases de la zona superior. Con altura fija se recorta y se anima; sin ella el contenido + * desborda a propósito, porque los desplegables tienen que poder salirse. Mientras se arrastra no + * se anima: la transición pelearía con el puntero. + */ +function zoneClasses(topPx: number | null, isDragging: boolean): string[] { + if (topPx === null) return ['overflow-visible', '']; + return ['overflow-hidden', isDragging ? '' : 'transition-[height] duration-200 ease-in-out']; +} + +/** Aspecto de la píldora del splitter: arrastrando gana sobre plegado. */ +type SplitterPillState = 'dragging' | 'collapsed' | 'idle'; + +function splitterPillState(dragging: boolean, collapsed: boolean): SplitterPillState { + if (dragging) return 'dragging'; + return collapsed ? 'collapsed' : 'idle'; +} + +const SPLITTER_PILL_CLASS: Record = { + dragging: 'bg-m3-primary text-white border-m3-primary', + collapsed: 'bg-m3-primary/15 border-m3-primary/50 text-m3-primary hover:bg-m3-primary/25', + idle: 'bg-m3-surface-container border-m3-outline/50 text-m3-secondary hover:bg-m3-primary/10 hover:border-m3-primary/40 hover:text-m3-primary', +}; export interface SortOption { label: string; value: string; @@ -123,10 +170,14 @@ export const M3DataView: React.FC = ({ containerRef: dvContainerRef, resizableRef: searchZoneRef, handleMouseDown: handleHSplitterMouseDown, + handleTouchStart: handleHSplitterTouchStart, handleKeyDown: handleHSplitterKeyDown, toggleCollapse: toggleHeader, } = useDragResize(); + const collapse = COLLAPSE_PRESENTATION[isHeaderCollapsed ? 'collapsed' : 'expanded']; + const drag = DRAG_PRESENTATION[isDraggingH ? 'dragging' : 'idle']; + return (
= ({ {/* Header collapse / expand button — always reachable */} + + {abierto && ( +
    + {perfiles.map(p => ( +
  • + +
  • + ))} +
+ )} +
+ ); +}; diff --git a/src/apps/ums.web-app/src/presentation/shared/components/ProtectedRoute.test.tsx b/src/apps/ums.web-app/src/presentation/shared/components/ProtectedRoute.test.tsx new file mode 100644 index 00000000..73dbc124 --- /dev/null +++ b/src/apps/ums.web-app/src/presentation/shared/components/ProtectedRoute.test.tsx @@ -0,0 +1,124 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, renderHook } from '@testing-library/react'; +import { ProtectedRoute, useRequireAuth } from './ProtectedRoute'; + +const mocks = vi.hoisted(() => ({ + authState: { + isAuthenticated: false, + isLoading: false, + user: null as unknown, + checkSession: vi.fn(), + }, + location: { pathname: '/panel' }, +})); + +vi.mock('@app/stores/auth.store', () => ({ + useAuthStore: () => mocks.authState, +})); + +vi.mock('react-router', () => ({ + useLocation: () => mocks.location, + Navigate: ({ to, state }: { to: string; state?: unknown }) => ( +
+ {JSON.stringify(state)} +
+ ), +})); + +vi.mock('@app/identity/services/auth.service', () => ({ authService: {} })); + +vi.mock('@shared/components/Spinner', () => ({ + Spinner: () =>
, +})); + +const Child = () =>
contenido
; + +describe('ProtectedRoute', () => { + beforeEach(() => { + mocks.authState.isAuthenticated = false; + mocks.authState.isLoading = false; + mocks.authState.user = null; + mocks.authState.checkSession = vi.fn().mockResolvedValue(true); + mocks.location = { pathname: '/panel' }; + }); + + it('muestra el spinner mientras el store está cargando', () => { + mocks.authState.isLoading = true; + render( + + + + ); + expect(screen.getByTestId('spinner')).toBeInTheDocument(); + expect(screen.getByText('Validando sesión...')).toBeInTheDocument(); + expect(screen.queryByTestId('child')).not.toBeInTheDocument(); + }); + + it('redirige a /login cuando no hay sesión autenticada', async () => { + render( + + + + ); + await waitFor(() => expect(screen.getByTestId('navigate')).toBeInTheDocument()); + const nav = screen.getByTestId('navigate'); + expect(nav).toHaveAttribute('data-to', '/login'); + expect(nav.textContent).toContain('/panel'); + expect(nav.textContent).toContain('showSessionExpired'); + expect(screen.queryByTestId('child')).not.toBeInTheDocument(); + }); + + it('renderiza los hijos cuando la sesión es válida', async () => { + mocks.authState.isAuthenticated = true; + mocks.authState.user = { username: 'ana' }; + render( + + + + ); + await waitFor(() => expect(screen.getByTestId('child')).toBeInTheDocument()); + expect(mocks.authState.checkSession).toHaveBeenCalledTimes(1); + expect(screen.queryByTestId('navigate')).not.toBeInTheDocument(); + }); + + it('no revalida sesión cuando falta el usuario aunque isAuthenticated sea verdadero', async () => { + mocks.authState.isAuthenticated = true; + mocks.authState.user = null; + render( + + + + ); + await waitFor(() => expect(screen.getByTestId('navigate')).toBeInTheDocument()); + expect(mocks.authState.checkSession).not.toHaveBeenCalled(); + }); +}); + +describe('useRequireAuth', () => { + beforeEach(() => { + mocks.authState.isAuthenticated = false; + mocks.authState.isLoading = false; + mocks.location = { pathname: '/reportes' }; + }); + + it('devuelve autorización indeterminada mientras carga', () => { + mocks.authState.isLoading = true; + const { result } = renderHook(() => useRequireAuth()); + expect(result.current.isAuthorized).toBeNull(); + expect(result.current.redirectPath).toBeNull(); + }); + + it('devuelve no autorizado y ruta de redirección cuando no hay sesión', () => { + const { result } = renderHook(() => useRequireAuth()); + expect(result.current.isAuthorized).toBe(false); + expect(result.current.redirectPath).toBe('/login?redirect=%2Freportes'); + }); + + it('devuelve autorizado cuando hay sesión', () => { + mocks.authState.isAuthenticated = true; + const { result } = renderHook(() => useRequireAuth()); + expect(result.current.isAuthorized).toBe(true); + expect(result.current.redirectPath).toBeNull(); + }); +}); diff --git a/src/apps/ums.web-app/src/presentation/shared/components/SearchableSelect.tsx b/src/apps/ums.web-app/src/presentation/shared/components/SearchableSelect.tsx index e4e30cad..4a786c2a 100644 --- a/src/apps/ums.web-app/src/presentation/shared/components/SearchableSelect.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/components/SearchableSelect.tsx @@ -1,5 +1,6 @@ import React, { useState, useRef, useEffect, useMemo } from 'react'; import { ChevronDown, Search, X, Check, AlertCircle, Loader2 } from 'lucide-react'; +import { resolveBorderClass, resolveLabelColorClass } from './field-state'; export type SearchCriteria = 'contains' | 'startsWith' | 'endsWith' | 'equals' | 'notEquals'; @@ -143,19 +144,9 @@ export function SearchableSelect({ const hasError = !!error; const heightClass = compact ? 'h-12' : 'h-14'; - const borderClass = isOpen - ? `border-2 ${hasError ? 'border-m3-error' : 'border-m3-primary'}` - : hasError - ? 'border border-m3-error' - : 'border border-m3-outline hover:border-m3-on-surface'; + const borderClass = resolveBorderClass({ focused: isOpen, error: !!hasError }); - const labelColorClass = isOpen - ? hasError - ? 'text-m3-error' - : 'text-m3-primary' - : hasError - ? 'text-m3-error' - : 'text-m3-secondary'; + const labelColorClass = resolveLabelColorClass({ focused: isOpen, error: !!hasError }); return (
@@ -277,17 +268,21 @@ export function SearchableSelect({
- {loading ? ( + {loading && (
Cargando...
- ) : filteredOptions.length === 0 ? ( + )} + {!loading && filteredOptions.length === 0 && (
{emptyMessage}
- ) : groupedOptions ? ( + )} + {!loading && + filteredOptions.length !== 0 && + groupedOptions && Object.entries(groupedOptions).map(([groupName, groupOptions]) => (
@@ -302,8 +297,10 @@ export function SearchableSelect({ /> ))}
- )) - ) : ( + ))} + {!loading && + filteredOptions.length !== 0 && + !groupedOptions && filteredOptions.map(option => ( ({ isSelected={option.value === value} onSelect={handleSelect} /> - )) - )} + ))}
diff --git a/src/apps/ums.web-app/src/presentation/shared/components/SectionHeader.tsx b/src/apps/ums.web-app/src/presentation/shared/components/SectionHeader.tsx index ab6176cc..81869792 100644 --- a/src/apps/ums.web-app/src/presentation/shared/components/SectionHeader.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/components/SectionHeader.tsx @@ -3,7 +3,7 @@ import React from 'react'; /** * SectionHeader — consistent section title bar with optional actions. * - * Used inside detail panels (BranchManager, IdpPanel, BrandingPanel) + * Used inside detail panels (BranchManager, IdpPanel) * to provide a uniform header with bottom border, title, subtitle, * and an action slot. */ diff --git a/src/apps/ums.web-app/src/presentation/shared/components/Spinner.test.tsx b/src/apps/ums.web-app/src/presentation/shared/components/Spinner.test.tsx index 33ae6c1a..57df3a61 100644 --- a/src/apps/ums.web-app/src/presentation/shared/components/Spinner.test.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/components/Spinner.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { render } from '@testing-library/react'; import { describe, it, expect } from 'vitest'; import { Spinner } from './Spinner'; diff --git a/src/apps/ums.web-app/src/presentation/shared/components/StatusBadge.tsx b/src/apps/ums.web-app/src/presentation/shared/components/StatusBadge.tsx index d39d23d1..94bde4e0 100644 --- a/src/apps/ums.web-app/src/presentation/shared/components/StatusBadge.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/components/StatusBadge.tsx @@ -42,6 +42,8 @@ export const StatusBadge: React.FC = React.memo( return ( {label ?? status} diff --git a/src/apps/ums.web-app/src/presentation/shared/components/SystemThemeApplier.test.tsx b/src/apps/ums.web-app/src/presentation/shared/components/SystemThemeApplier.test.tsx new file mode 100644 index 00000000..74b1e405 --- /dev/null +++ b/src/apps/ums.web-app/src/presentation/shared/components/SystemThemeApplier.test.tsx @@ -0,0 +1,196 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; +import { SystemThemeApplier, hexAHslCanales } from './SystemThemeApplier'; +import { useI18nStore } from '@app/stores/i18n.store'; +import { useThemeStore } from '@app/stores/theme.store'; + +let settings: Record> = {}; + +vi.mock('@app/stores/auth.store', () => ({ + useAuthStore: (selector: (s: unknown) => unknown) => + selector({ user: { authorizationGraph: { settings } } }), +})); + +describe('hexAHslCanales', () => { + it('convierte al formato de los tokens (tripleta HSL sin envoltorio)', () => { + // Los tokens son `208 75% 23%`, no `#0f3e67`: Tailwind los compone con opacidad. + // Ese valor es, literalmente, el que `index.css` tiene escrito a mano para --m3-primary: + // la conversión reproduce el token del producto a partir del hexadecimal corporativo. + expect(hexAHslCanales('#0f3e67')).toBe('208 75% 23%'); + expect(hexAHslCanales('0f3e67')).toBe('208 75% 23%'); + }); + + it('admite la forma corta de tres dígitos', () => { + expect(hexAHslCanales('#fff')).toBe('0 0% 100%'); + }); + + it('devuelve undefined ante un valor inválido', () => { + // Un color mal configurado en la base debe dejar el tema por defecto, no romper la interfaz. + expect(hexAHslCanales('azul')).toBeUndefined(); + expect(hexAHslCanales('#12345')).toBeUndefined(); + expect(hexAHslCanales(undefined)).toBeUndefined(); + }); +}); + +describe('SystemThemeApplier', () => { + beforeEach(() => { + settings = {}; + document.documentElement.style.removeProperty('--m3-primary'); + document.title = 'UMS'; + useI18nStore.setState({ language: 'es', chosenByUser: false }); + useThemeStore.setState({ isDarkMode: false, chosenByUser: false }); + delete document.documentElement.dataset.density; + cleanup(); + }); + + it('no toca nada cuando el sistema no publica tema', () => { + render(); + expect(document.documentElement.style.getPropertyValue('--m3-primary')).toBe(''); + }); + + it('aplica el color primario del sistema', () => { + settings = { theme: { primary: '#0f3e67' } }; + render(); + expect(document.documentElement.style.getPropertyValue('--m3-primary')).toBe('208 75% 23%'); + }); + + it('revierte el tema al desmontarse', () => { + settings = { theme: { primary: '#0f3e67' } }; + const { unmount } = render(); + unmount(); + + // Al cambiar de sistema o cerrar sesión, el tema anterior no debe sobrevivir. + expect(document.documentElement.style.getPropertyValue('--m3-primary')).toBe(''); + }); + + it('usa el nombre comercial del sistema como título del documento', () => { + settings = { brand: { display_name: 'Tablero de Gobierno SDLC' } }; + const { unmount } = render(); + expect(document.title).toBe('Tablero de Gobierno SDLC'); + + unmount(); + expect(document.title).toBe('UMS'); + }); + + it('usa el icono del sistema como favicon', () => { + settings = { brand: { icon_url: '/branding/sdlc/icon.svg' } }; + const enlace = document.createElement('link'); + enlace.rel = 'icon'; + enlace.type = 'image/png'; + enlace.href = '/brand/logo-beyondnet.png'; + document.head.appendChild(enlace); + + const { unmount } = render(); + expect(enlace.getAttribute('href')).toBe('/branding/sdlc/icon.svg'); + // El type del producto (PNG) no describe un icono SVG: declararlo mal lo descarta. + expect(enlace.getAttribute('type')).toBeNull(); + + unmount(); + expect(enlace.getAttribute('href')).toBe('/brand/logo-beyondnet.png'); + expect(enlace.getAttribute('type')).toBe('image/png'); + enlace.remove(); + }); + + it('no toca el favicon si el sistema solo publica logotipo', () => { + // El logotipo es un lockup ancho: en 16px sería un borrón. Mejor el icono del producto. + settings = { brand: { logo_url: '/branding/sdlc/logo.svg' } }; + const enlace = document.createElement('link'); + enlace.rel = 'icon'; + enlace.href = '/brand/logo-beyondnet.png'; + document.head.appendChild(enlace); + + render(); + expect(enlace.getAttribute('href')).toBe('/brand/logo-beyondnet.png'); + enlace.remove(); + }); + + it('crea el enlace si el documento no lo declara, y lo retira al desmontarse', () => { + settings = { brand: { icon_url: '/branding/sdlc/icon.svg' } }; + + const { unmount } = render(); + expect(document.querySelector('link[rel="icon"]')?.getAttribute('href')).toBe( + '/branding/sdlc/icon.svg' + ); + + unmount(); + expect(document.querySelector('link[rel="icon"]')).toBeNull(); + }); + + it('ignora un color inválido en vez de dejar la interfaz a medias', () => { + settings = { theme: { primary: 'no-es-un-color' } }; + render(); + expect(document.documentElement.style.getPropertyValue('--m3-primary')).toBe(''); + }); + + it('aplica el idioma que publica el sistema', () => { + settings = { locale: { language: 'en-US' } }; + render(); + + expect(useI18nStore.getState().language).toBe('en'); + }); + + it('no revierte el idioma al desmontarse', () => { + // A diferencia del tema o el título: el idioma sobrevive al cierre de sesión, o la pantalla + // de acceso aparecería en otro idioma que el que el usuario acaba de estar usando. + settings = { locale: { language: 'en-US' } }; + const { unmount } = render(); + unmount(); + + expect(useI18nStore.getState().language).toBe('en'); + }); + + it('aplica el modo oscuro que publica el sistema', () => { + settings = { theme: { mode: 'dark' } }; + render(); + + expect(useThemeStore.getState().isDarkMode).toBe(true); + }); + + it('no pisa el modo que el usuario eligió', () => { + useThemeStore.getState().toggleDarkMode(); // el usuario se pasa a oscuro + settings = { theme: { mode: 'light' } }; + render(); + + expect(useThemeStore.getState().isDarkMode).toBe(true); + }); + + it('con modo `system` sigue al sistema operativo y reacciona a sus cambios', () => { + const escuchas: Array<(e: { matches: boolean }) => void> = []; + vi.stubGlobal( + 'matchMedia', + vi.fn(() => ({ + matches: false, + addEventListener: (_: string, cb: (e: { matches: boolean }) => void) => escuchas.push(cb), + removeEventListener: vi.fn(), + })) + ); + + settings = { theme: { mode: 'system' } }; + render(); + expect(useThemeStore.getState().isDarkMode).toBe(false); + + // Anochece en el sistema operativo: `system` no es un modo fijo, es delegar. + escuchas.forEach(cb => cb({ matches: true })); + expect(useThemeStore.getState().isDarkMode).toBe(true); + + vi.unstubAllGlobals(); + }); + + it('escribe la densidad en la raíz y la retira al desmontarse', () => { + settings = { ui: { density: 'compact' } }; + const { unmount } = render(); + + expect(document.documentElement.dataset.density).toBe('compact'); + + unmount(); + expect(document.documentElement.dataset.density).toBeUndefined(); + }); + + it('ignora una densidad que el CSS no define', () => { + // Un valor desconocido dejaría las filas sin espaciado en vez de descolocar la interfaz. + settings = { ui: { density: 'holgadisima' } }; + render(); + + expect(document.documentElement.dataset.density).toBeUndefined(); + }); +}); diff --git a/src/apps/ums.web-app/src/presentation/shared/components/SystemThemeApplier.tsx b/src/apps/ums.web-app/src/presentation/shared/components/SystemThemeApplier.tsx new file mode 100644 index 00000000..22ce614e --- /dev/null +++ b/src/apps/ums.web-app/src/presentation/shared/components/SystemThemeApplier.tsx @@ -0,0 +1,182 @@ +import { useEffect } from 'react'; +import { useSystemSettings } from '@app/authorization/hooks/use-system-settings'; +import { useI18nStore } from '@app/stores/i18n.store'; +import { useThemeStore } from '@app/stores/theme.store'; + +/** + * Aplica al documento el tema, el título, el favicon, el idioma, el modo claro/oscuro y la + * densidad que publica el sistema en el grafo (G-178). + * + * No pinta nada: es un efecto. Vive como componente para colgar del ciclo de vida de React y + * revertirse solo cuando el usuario cambia de sistema o cierra sesión — un `useEffect` suelto en + * un layout se quedaría sin limpiar. + * + * Los tokens del tema son tripletas HSL sin envoltorio (`208 75% 23%`), no hexadecimales: Tailwind + * las compone con opacidad (`hsl(var(--m3-primary) / 0.3)`). Por eso el color que publica el + * sistema se convierte antes de escribirlo; inyectar el hexadecimal tal cual dejaría la mitad de + * la interfaz sin color, y solo en los sitios que usan transparencia. + */ +/** Densidades que el CSS del producto traduce a espaciado. Cualquier otra se ignora. */ +const DENSIDADES = new Set(['compact', 'comfortable', 'spacious']); + +export const SystemThemeApplier: React.FC = () => { + const { brand, theme, locale, ui } = useSystemSettings(); + const applySystemLanguage = useI18nStore(s => s.applySystemLanguage); + const applyDefaultDarkMode = useThemeStore(s => s.applyDefaultDarkMode); + + useEffect(() => { + const raiz = document.documentElement; + const previos: Array<[string, string]> = []; + + const aplicar = (token: string, hex: string | undefined) => { + const hsl = hexAHslCanales(hex); + if (!hsl) return; + previos.push([token, raiz.style.getPropertyValue(token)]); + raiz.style.setProperty(token, hsl); + }; + + aplicar('--m3-primary', theme.primary); + aplicar('--m3-tertiary', theme.accent); + + return () => { + // Al cambiar de sistema —o de perfil— el tema anterior no debe sobrevivir. + for (const [token, valor] of previos) { + if (valor) raiz.style.setProperty(token, valor); + else raiz.style.removeProperty(token); + } + }; + }, [theme.primary, theme.accent]); + + useEffect(() => { + if (!brand.displayName) return; + + const anterior = document.title; + document.title = brand.displayName; + return () => { + document.title = anterior; + }; + }, [brand.displayName]); + + useEffect(() => { + // Solo el icono, nunca el logotipo: la pestaña lo pinta en un cuadrado de ~16px y un lockup + // ancho ahí es un borrón. Sin icono configurado se queda el del producto, que es correcto. + if (!brand.iconUrl) return; + + const creado = !document.querySelector('link[rel="icon"]'); + const enlace = + document.querySelector('link[rel="icon"]') ?? + document.head.appendChild(Object.assign(document.createElement('link'), { rel: 'icon' })); + + const hrefAnterior = enlace.getAttribute('href'); + const tipoAnterior = enlace.getAttribute('type'); + + enlace.setAttribute('href', brand.iconUrl); + // El `type` declarado describe el recurso del producto (PNG) y el del sistema puede ser otro + // (SVG). Declarar un tipo que no corresponde hace que el navegador descarte el icono; sin el + // atributo lo deduce del recurso. + enlace.removeAttribute('type'); + + return () => { + if (creado) { + enlace.remove(); + return; + } + // Al cambiar de sistema o cerrar sesión vuelve el icono del producto. + if (hrefAnterior) enlace.setAttribute('href', hrefAnterior); + if (tipoAnterior) enlace.setAttribute('type', tipoAnterior); + }; + }, [brand.iconUrl]); + + useEffect(() => { + // Defecto, no imposición: el store ignora esto si el usuario ya eligió idioma. Y no se revierte + // al desmontar —a diferencia del tema o el título— porque el idioma de la interfaz sobrevive al + // cierre de sesión: devolverlo al salir dejaría la pantalla de acceso en otro idioma. + applySystemLanguage(locale.language); + }, [locale.language, applySystemLanguage]); + + useEffect(() => { + if (!theme.mode) return; + + // `system` no es un modo: es delegar en el sistema operativo, y por eso hay que seguir + // escuchándolo. Fijarlo una vez dejaría la interfaz en claro toda la noche. + if (theme.mode !== 'system') { + applyDefaultDarkMode(theme.mode === 'dark'); + return; + } + + const consulta = window.matchMedia('(prefers-color-scheme: dark)'); + applyDefaultDarkMode(consulta.matches); + + const alCambiar = (e: MediaQueryListEvent) => applyDefaultDarkMode(e.matches); + consulta.addEventListener('change', alCambiar); + return () => consulta.removeEventListener('change', alCambiar); + }, [theme.mode, applyDefaultDarkMode]); + + useEffect(() => { + // La densidad viaja como atributo y el CSS la traduce a espaciado: un valor desconocido no + // debe descolocar la interfaz, así que solo se aceptan los tres que el producto define. + if (!ui.density || !DENSIDADES.has(ui.density)) return; + + const raiz = document.documentElement; + const anterior = raiz.dataset.density; + raiz.dataset.density = ui.density; + + return () => { + if (anterior) raiz.dataset.density = anterior; + else delete raiz.dataset.density; + }; + }, [ui.density]); + + return null; +}; + +/** + * Convierte `#0f3e67` en `208 75% 23%`, el formato de los tokens. + * + * Devuelve `undefined` ante cualquier cosa que no sea un hexadecimal de 3 o 6 dígitos: un valor + * mal configurado en la base no debe romper la interfaz, solo dejar el color por defecto. + */ +export function hexAHslCanales(hex: string | undefined): string | undefined { + if (!hex) return undefined; + + const limpio = hex.trim().replace(/^#/, ''); + const expandido = + limpio.length === 3 + ? limpio + .split('') + .map(c => c + c) + .join('') + : limpio; + + if (!/^[0-9a-fA-F]{6}$/.test(expandido)) return undefined; + + const r = parseInt(expandido.slice(0, 2), 16) / 255; + const g = parseInt(expandido.slice(2, 4), 16) / 255; + const b = parseInt(expandido.slice(4, 6), 16) / 255; + + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const l = (max + min) / 2; + const d = max - min; + + let h = 0; + let s = 0; + + if (d !== 0) { + s = d / (1 - Math.abs(2 * l - 1)); + switch (max) { + case r: + h = ((g - b) / d) % 6; + break; + case g: + h = (b - r) / d + 2; + break; + default: + h = (r - g) / d + 4; + } + h *= 60; + if (h < 0) h += 360; + } + + return `${Math.round(h)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`; +} diff --git a/src/apps/ums.web-app/src/presentation/shared/components/TenantSelect.tsx b/src/apps/ums.web-app/src/presentation/shared/components/TenantSelect.tsx index 988b880f..56f84812 100644 --- a/src/apps/ums.web-app/src/presentation/shared/components/TenantSelect.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/components/TenantSelect.tsx @@ -1,5 +1,6 @@ import React, { useState, useRef, useEffect } from 'react'; import { ChevronDown, Search, Building2, X } from 'lucide-react'; +import { resolveBorderClass } from './field-state'; export interface TenantOption { id: string; @@ -77,7 +78,7 @@ export const TenantSelect: React.FC = ({ relative w-full h-14 rounded-[4px] border cursor-pointer bg-m3-surface-container/30 dark:bg-m3-surface-container/20 transition-colors duration-150 - ${isOpen ? 'border-2 border-m3-primary' : error ? 'border border-m3-error' : 'border border-m3-outline hover:border-m3-on-surface'} + ${resolveBorderClass({ focused: isOpen, error: !!error })} `} >
diff --git a/src/apps/ums.web-app/src/presentation/shared/components/ToastQueue.tsx b/src/apps/ums.web-app/src/presentation/shared/components/ToastQueue.tsx index 48767f53..cde84318 100644 --- a/src/apps/ums.web-app/src/presentation/shared/components/ToastQueue.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/components/ToastQueue.tsx @@ -37,6 +37,16 @@ type ToastEntry = AppNotification & { leaving: boolean }; // ─── Sub-component ──────────────────────────────────────────────────────────── +/** + * Actualizadores del estado de la cola. Viven aquí y no dentro de los efectos porque allí + * quedaban cinco funciones anidadas —efecto, temporizador, actualizador y el `map` de dentro—, + * y ninguna de ellas necesita el cierre del componente: les basta el id. + */ +const marcarSaliente = (id: string) => (prev: ToastEntry[]) => + prev.map(t => (t.id === id ? { ...t, leaving: true } : t)); + +const quitarPorId = (id: string) => (prev: ToastEntry[]) => prev.filter(t => t.id !== id); + interface ToastItemProps { toast: ToastEntry; onDismiss: (id: string) => void; @@ -50,6 +60,8 @@ const ToastItem: React.FC = React.memo(({ toast, onDismiss }) => return (
{ const leavingTimer = setTimeout(() => { // Trigger slide-out animation. - setToasts(prev => prev.map(t => (t.id === n.id ? { ...t, leaving: true } : t))); + setToasts(marcarSaliente(n.id)); // Remove from DOM after animation completes. const removeTimer = setTimeout(() => { - setToasts(prev => prev.filter(t => t.id !== n.id)); + setToasts(quitarPorId(n.id)); timersRef.current.delete(n.id + '_remove'); }, LEAVE_ANIMATION_MS); @@ -147,9 +159,9 @@ export const ToastQueue: React.FC = React.memo(() => { } removeNotification(id); - setToasts(prev => prev.map(t => (t.id === id ? { ...t, leaving: true } : t))); + setToasts(marcarSaliente(id)); const removeTimer = setTimeout(() => { - setToasts(prev => prev.filter(t => t.id !== id)); + setToasts(quitarPorId(id)); timersRef.current.delete(id + '_remove'); }, LEAVE_ANIMATION_MS); timersRef.current.set(id + '_remove', removeTimer); diff --git a/src/apps/ums.web-app/src/presentation/shared/components/Tooltip.test.tsx b/src/apps/ums.web-app/src/presentation/shared/components/Tooltip.test.tsx index 70344264..bba2b94e 100644 --- a/src/apps/ums.web-app/src/presentation/shared/components/Tooltip.test.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/components/Tooltip.test.tsx @@ -1,5 +1,5 @@ import { render, screen, act, fireEvent } from '@testing-library/react'; -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect } from 'vitest'; import { Tooltip, IconButton } from './Tooltip'; describe('Tooltip', () => { diff --git a/src/apps/ums.web-app/src/presentation/shared/components/Tooltip.tsx b/src/apps/ums.web-app/src/presentation/shared/components/Tooltip.tsx index 02c4e31e..622156d1 100644 --- a/src/apps/ums.web-app/src/presentation/shared/components/Tooltip.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/components/Tooltip.tsx @@ -101,6 +101,9 @@ export const IconButton: React.FC = React.memo( return ( ); diff --git a/src/apps/ums.web-app/src/presentation/shared/components/layouts/DataViewShell.tsx b/src/apps/ums.web-app/src/presentation/shared/components/layouts/DataViewShell.tsx index 41dc8441..6f62a48a 100644 --- a/src/apps/ums.web-app/src/presentation/shared/components/layouts/DataViewShell.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/components/layouts/DataViewShell.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import { M3Card } from '../M3Card'; import { Database, ChevronsUp, ChevronsDown } from 'lucide-react'; import { useDragResize } from '@app/hooks/use-drag-resize'; @@ -24,8 +23,6 @@ export const DataViewShell: React.FC = ({ isDragging: isDraggingH, containerRef: dvContainerRef, resizableRef: searchZoneRef, - handleMouseDown: handleHSplitterMouseDown, - handleKeyDown: handleHSplitterKeyDown, toggleCollapse: toggleHeader, } = useDragResize(); diff --git a/src/apps/ums.web-app/src/presentation/shared/hooks/use-breakpoint.ts b/src/apps/ums.web-app/src/presentation/shared/hooks/use-breakpoint.ts new file mode 100644 index 00000000..acf39858 --- /dev/null +++ b/src/apps/ums.web-app/src/presentation/shared/hooks/use-breakpoint.ts @@ -0,0 +1,42 @@ +import { useSyncExternalStore } from 'react'; + +/** + * use-breakpoint — hooks de viewport SSR-safe basados en `matchMedia`. + * + * Se apoyan en `useSyncExternalStore` para evitar parpadeos de hidratación y + * re-render innecesario. Los breakpoints se alinean con Tailwind: + * sm 640 · md 768 · lg 1024 · xl 1280 · 2xl 1536 + */ + +const noopUnsubscribe = () => {}; + +function subscribe(query: string) { + return (callback: () => void) => { + if (typeof window === 'undefined' || !window.matchMedia) return noopUnsubscribe; + const mql = window.matchMedia(query); + mql.addEventListener('change', callback); + return () => mql.removeEventListener('change', callback); + }; +} + +function getSnapshot(query: string) { + return () => { + if (typeof window === 'undefined' || !window.matchMedia) return false; + return window.matchMedia(query).matches; + }; +} + +/** Devuelve `true` mientras la media query coincida; reactivo a cambios de tamaño/orientación. */ +export function useMediaQuery(query: string): boolean { + return useSyncExternalStore(subscribe(query), getSnapshot(query), () => false); +} + +/** `true` bajo el breakpoint `lg` de Tailwind (< 1024px): teléfonos y tablets pequeñas. */ +export function useIsMobile(): boolean { + return useMediaQuery('(max-width: 1023px)'); +} + +/** `true` entre `md` y `lg` (768–1023px): tablets en vertical. */ +export function useIsTablet(): boolean { + return useMediaQuery('(min-width: 768px) and (max-width: 1023px)'); +} diff --git a/src/apps/ums.web-app/src/presentation/shared/layouts/MainLayout.test.tsx b/src/apps/ums.web-app/src/presentation/shared/layouts/MainLayout.test.tsx index a3175564..29e1791e 100644 --- a/src/apps/ums.web-app/src/presentation/shared/layouts/MainLayout.test.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/layouts/MainLayout.test.tsx @@ -15,36 +15,50 @@ vi.mock('@app/hooks/use-idle-timeout'); vi.mock('./TopAppBar'); vi.mock('./NavRail'); +/** Disposición que publica el sistema (`settings.ui.layout`, G-178). */ +let ui: { layout?: string } = {}; + +vi.mock('@app/authorization/hooks/use-system-settings', () => ({ + useSystemSettings: () => ({ brand: {}, theme: {}, ui, locale: {}, raw: {} }), +})); + describe('MainLayout', () => { beforeEach(() => { vi.restoreAllMocks(); + ui = {}; vi.mocked(authStoreModule.useAuthStore).mockReturnValue({ user: { id: 'u-1', email: 'test@test.com' }, logout: vi.fn(), isAuthenticated: true, - } as any); + } as unknown as ReturnType); - vi.mocked(notificationStoreModule.useNotificationStore).mockImplementation((selector: any) => { - const state = { addNotification: vi.fn() }; - return selector ? selector(state) : state; - }); + vi.mocked(notificationStoreModule.useNotificationStore).mockImplementation( + (selector: (state: never) => unknown) => { + const state = { addNotification: vi.fn() }; + return selector ? selector(state) : state; + } + ); vi.mocked(useI18nModule.useI18n).mockReturnValue({ sessionExpired: 'Session Expired', sessionExpiredMsg: 'You have been logged out due to inactivity.', - } as any); + } as unknown as ReturnType); - vi.mocked(useIdleTimeoutModule.useIdleTimeout).mockReturnValue(undefined as any); + vi.mocked(useIdleTimeoutModule.useIdleTimeout).mockReturnValue( + undefined as unknown as ReturnType + ); - vi.mocked(TopAppBarModule.TopAppBar).mockImplementation(({ onToggleNav }: any) => ( -
- -
- )); - vi.mocked(NavRailModule.NavRail).mockImplementation(({ collapsed }: any) => ( + vi.mocked(TopAppBarModule.TopAppBar).mockImplementation( + ({ onToggleNav }: Record) => ( +
+ +
+ ) + ); + vi.mocked(NavRailModule.NavRail).mockImplementation(({ collapsed }: Record) => (
)); }); @@ -92,7 +106,7 @@ describe('MainLayout', () => { user: null, logout: vi.fn(), isAuthenticated: false, - } as any); + } as unknown as ReturnType); render( @@ -117,4 +131,39 @@ describe('MainLayout', () => { expect(screen.getByTestId('nav-rail')).toHaveAttribute('data-collapsed', 'true'); }); + + describe('disposición publicada por el sistema', () => { + const render_ = () => + render( + +
Content
+
+ ); + + it('sin disposición declarada el rail nace desplegado', () => { + render_(); + expect(screen.getByTestId('nav-rail')).toHaveAttribute('data-collapsed', 'false'); + }); + + it('`nav-rail-compact` arranca el rail en solo-iconos', () => { + ui = { layout: 'nav-rail-compact' }; + render_(); + expect(screen.getByTestId('nav-rail')).toHaveAttribute('data-collapsed', 'true'); + }); + + it('una disposición desconocida cae en la conocida', () => { + // Preferible un rail desplegado a una pantalla sin navegación. + ui = { layout: 'holograma' }; + render_(); + expect(screen.getByTestId('nav-rail')).toHaveAttribute('data-collapsed', 'false'); + }); + + it('es el estado inicial, no un candado: la hamburguesa sigue mandando', () => { + ui = { layout: 'nav-rail-compact' }; + render_(); + + fireEvent.click(screen.getByTestId('toggle-btn')); + expect(screen.getByTestId('nav-rail')).toHaveAttribute('data-collapsed', 'false'); + }); + }); }); diff --git a/src/apps/ums.web-app/src/presentation/shared/layouts/MainLayout.tsx b/src/apps/ums.web-app/src/presentation/shared/layouts/MainLayout.tsx index f5f837af..c326ba21 100644 --- a/src/apps/ums.web-app/src/presentation/shared/layouts/MainLayout.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/layouts/MainLayout.tsx @@ -1,8 +1,13 @@ -import React, { useState, useCallback } from 'react'; +/* eslint-disable react-hooks/set-state-in-effect -- Cierra el cajón de navegación al pasar a escritorio; depende del breakpoint observado, no del + render. + Patrón intencional del código heredado; la regla sigue activa en el resto del repo. */ +import React, { useState, useCallback, useEffect } from 'react'; import { useAuthStore } from '@app/stores/auth.store'; import { useNotificationStore } from '@app/stores/notification.store'; import { useI18n } from '@app/i18n/use-i18n'; import { useIdleTimeout } from '@app/hooks/use-idle-timeout'; +import { useIsMobile } from '@shared/hooks/use-breakpoint'; +import { useSystemSettings } from '@app/authorization/hooks/use-system-settings'; import { TopAppBar } from './TopAppBar'; import { NavRail } from './NavRail'; @@ -10,7 +15,26 @@ export const MainLayout: React.FC<{ children: React.ReactNode }> = ({ children } const { user, logout, isAuthenticated } = useAuthStore(); const { addNotification } = useNotificationStore(); const t = useI18n(); - const [navCollapsed, setNavCollapsed] = useState(false); + const isMobile = useIsMobile(); + const { ui } = useSystemSettings(); + // Disposición inicial que declara el sistema (`settings.ui.layout`, G-178). El producto entiende + // dos: `nav-rail` —el rail desplegado— y `nav-rail-compact` —el mismo rail en solo-iconos, para + // sistemas con pocas pantallas donde la columna de texto sobra. Cualquier otro valor cae en la + // primera: es preferible una disposición conocida a una pantalla sin navegación. + // Es el estado INICIAL, no un candado: la hamburguesa sigue mandando después. + const [navCollapsed, setNavCollapsed] = useState(ui.layout === 'nav-rail-compact'); + const [mobileNavOpen, setMobileNavOpen] = useState(false); + + // La hamburguesa colapsa el rail en escritorio y abre/cierra el drawer en móvil. + const handleToggleNav = useCallback(() => { + if (isMobile) setMobileNavOpen(open => !open); + else setNavCollapsed(collapsed => !collapsed); + }, [isMobile]); + + // Cerrar el drawer al pasar a escritorio (el cierre por navegación lo hace NavRail). + useEffect(() => { + if (!isMobile) setMobileNavOpen(false); + }, [isMobile]); const handleIdleLogout = useCallback(() => { logout(); @@ -29,28 +53,32 @@ export const MainLayout: React.FC<{ children: React.ReactNode }> = ({ children } if (!isAuthenticated) { return ( -
+
-
{children}
+
{children}
); } return ( -
+
- setNavCollapsed(!navCollapsed)} /> +
- + setMobileNavOpen(false)} + /> -
-
{children}
+
+
{children}
diff --git a/src/apps/ums.web-app/src/presentation/shared/layouts/MasterDetailLayout.test.tsx b/src/apps/ums.web-app/src/presentation/shared/layouts/MasterDetailLayout.test.tsx index 866da054..ed9ee0a4 100644 --- a/src/apps/ums.web-app/src/presentation/shared/layouts/MasterDetailLayout.test.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/layouts/MasterDetailLayout.test.tsx @@ -1,5 +1,5 @@ -import { describe, it, expect } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; import { MasterDetailLayout } from './MasterDetailLayout'; describe('MasterDetailLayout', () => { @@ -62,4 +62,29 @@ describe('MasterDetailLayout', () => { const separator = screen.getByRole('separator'); expect(separator).toHaveAttribute('tabIndex', '0'); }); + + it('redimensiona el panel de detalle al arrastrar con el dedo (touch)', () => { + render(Master
} detail={
Detail
} />); + const separator = screen.getByRole('separator'); + const containerEl = separator.parentElement as HTMLElement; + vi.spyOn(containerEl, 'getBoundingClientRect').mockReturnValue({ + left: 0, + right: 1000, + width: 1000, + top: 0, + bottom: 600, + height: 600, + x: 0, + y: 0, + toJSON: () => ({}), + } as DOMRect); + + const before = separator.getAttribute('aria-valuenow'); + fireEvent.touchStart(separator, { touches: [{ clientX: 600 }] }); + fireEvent.touchMove(window, { touches: [{ clientX: 300 }] }); // fromRight=700 → 70% + fireEvent.touchEnd(window); + + expect(separator.getAttribute('aria-valuenow')).toBe('70'); + expect(separator.getAttribute('aria-valuenow')).not.toBe(before); + }); }); diff --git a/src/apps/ums.web-app/src/presentation/shared/layouts/MasterDetailLayout.tsx b/src/apps/ums.web-app/src/presentation/shared/layouts/MasterDetailLayout.tsx index 2593028a..c37aae2b 100644 --- a/src/apps/ums.web-app/src/presentation/shared/layouts/MasterDetailLayout.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/layouts/MasterDetailLayout.tsx @@ -1,5 +1,6 @@ import React, { useRef, useState, useCallback, useEffect } from 'react'; import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { useIsMobile } from '../hooks/use-breakpoint'; // ─── Types ────────────────────────────────────────────────────────────────── @@ -34,6 +35,8 @@ export const MasterDetailLayout: React.FC = ({ splitterLabel = 'Resize detail panel', overlay, }) => { + const isMobile = useIsMobile(); + // ── Splitter state ────────────────────────────────────────────────────────── const [rightPct, setRightPct] = useState(initialDetailPct); const [isRightCollapsed, setIsRightCollapsed] = useState(false); @@ -52,21 +55,27 @@ export const MasterDetailLayout: React.FC = ({ // ── Drag ──────────────────────────────────────────────────────────────────── + // Traslada una coordenada X (mouse o touch) al porcentaje del panel de detalle. + const applyMove = useCallback( + (clientX: number) => { + if (!isDraggingRef.current || !containerRef.current) return; + const rect = containerRef.current.getBoundingClientRect(); + const fromRight = rect.right - clientX; + const pct = Math.min(maxDetailPct, Math.max(minDetailPct, (fromRight / rect.width) * 100)); + setRightPct(pct); + prevRightPct.current = pct; + setIsRightCollapsed(false); + }, + [minDetailPct, maxDetailPct] + ); + const handleSplitterMouseDown = useCallback( (e: React.MouseEvent) => { e.preventDefault(); isDraggingRef.current = true; setIsDragging(true); - const onMouseMove = (ev: MouseEvent) => { - if (!isDraggingRef.current || !containerRef.current) return; - const rect = containerRef.current.getBoundingClientRect(); - const fromRight = rect.right - ev.clientX; - const pct = Math.min(maxDetailPct, Math.max(minDetailPct, (fromRight / rect.width) * 100)); - setRightPct(pct); - prevRightPct.current = pct; - setIsRightCollapsed(false); - }; + const onMouseMove = (ev: MouseEvent) => applyMove(ev.clientX); const onMouseUp = () => { isDraggingRef.current = false; @@ -81,7 +90,37 @@ export const MasterDetailLayout: React.FC = ({ cleanupDragRef.current = onMouseUp; }, - [minDetailPct, maxDetailPct] + [applyMove] + ); + + const handleSplitterTouchStart = useCallback( + (e: React.TouchEvent) => { + if (e.touches.length !== 1) return; + isDraggingRef.current = true; + setIsDragging(true); + + const onTouchMove = (ev: TouchEvent) => { + if (ev.cancelable) ev.preventDefault(); // evita el scroll horizontal durante el arrastre + const touch = ev.touches[0]; + if (touch) applyMove(touch.clientX); + }; + + const onTouchEnd = () => { + isDraggingRef.current = false; + setIsDragging(false); + cleanupDragRef.current = null; + window.removeEventListener('touchmove', onTouchMove); + window.removeEventListener('touchend', onTouchEnd); + window.removeEventListener('touchcancel', onTouchEnd); + }; + + window.addEventListener('touchmove', onTouchMove, { passive: false }); + window.addEventListener('touchend', onTouchEnd); + window.addEventListener('touchcancel', onTouchEnd); + + cleanupDragRef.current = onTouchEnd; + }, + [applyMove] ); // ── Toggle ────────────────────────────────────────────────────────────────── @@ -120,6 +159,18 @@ export const MasterDetailLayout: React.FC = ({ // ── Render ────────────────────────────────────────────────────────────────── + // En móvil/tablet pequeña no hay espacio para dos paneles: se apilan en una + // sola columna fluida (master arriba, detalle debajo) sin splitter ni recorte. + if (isMobile) { + return ( +
+ {overlay} +
{master}
+
{detail}
+
+ ); + } + return (
= ({ {/* ── Splitter handle ── */}
= ({ aria-valuemax={maxDetailPct} aria-valuenow={Math.round(rightPct)} className={[ - 'relative flex-shrink-0 w-1.5 flex flex-col items-center justify-center z-10', + // `touch-none` + zona táctil de 32px (`before`) sin ensanchar la línea visible (densidad). + 'relative flex-shrink-0 w-1.5 flex flex-col items-center justify-center z-10 touch-none', + 'before:absolute before:inset-y-0 before:left-1/2 before:-translate-x-1/2 before:w-8 before:content-[""]', 'cursor-col-resize group focus:outline-none focus-visible:ring-2 focus-visible:ring-m3-primary', isDragging ? 'bg-m3-primary/15' : 'hover:bg-m3-primary/10 transition-colors duration-150', ].join(' ')} @@ -169,9 +223,12 @@ export const MasterDetailLayout: React.FC = ({ toggleRightPanel(); }} onMouseDown={e => e.stopPropagation()} + onTouchStart={e => e.stopPropagation()} className={[ 'absolute top-1/2 -translate-y-1/2 -translate-x-px', 'w-4 h-10 rounded-full flex items-center justify-center', + // Zona táctil de 32px de ancho sin cambiar el tamaño visible de la píldora. + 'before:absolute before:inset-y-0 before:left-1/2 before:-translate-x-1/2 before:w-8 before:content-[""]', 'border shadow-sm transition-all duration-150', isDragging ? 'bg-m3-primary text-white border-m3-primary' diff --git a/src/apps/ums.web-app/src/presentation/shared/layouts/NavRail.test.tsx b/src/apps/ums.web-app/src/presentation/shared/layouts/NavRail.test.tsx index 1be9174f..88feb0c1 100644 --- a/src/apps/ums.web-app/src/presentation/shared/layouts/NavRail.test.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/layouts/NavRail.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; import { NavRail } from './NavRail'; import * as useI18nModule from '@app/i18n/use-i18n'; @@ -11,11 +11,9 @@ vi.mock('react-router', () => ({ vi.mock('@app/i18n/use-i18n'); vi.mock('./navigation.config', () => ({ NAV_ROUTES: { tenants: '/tenants', users: '/users' }, - pathToTab: (path: string) => path.replace('/', ''), + isExternalItem: (m: { external?: boolean }) => m.external === true, NAV_MODULES: () => [ { - // La key debe coincidir con las que NavRail expande por defecto ({ idm, auth, sys }), - // si no el módulo arranca colapsado y sus items no se renderizan. key: 'idm', nameKey: 'identity', icon: 'I', @@ -27,9 +25,18 @@ vi.mock('./navigation.config', () => ({ ], })); +/** Grafo de autorización de la sesión; `undefined` deja al shell en su respaldo estático. */ +let menuAccess: unknown; + +vi.mock('@app/stores/auth.store', () => ({ + useAuthStore: (selector: (s: unknown) => unknown) => + selector({ user: { authorizationGraph: { menuAccess } } }), +})); + describe('NavRail', () => { beforeEach(() => { vi.restoreAllMocks(); + menuAccess = undefined; vi.mocked(useI18nModule.useI18n).mockReturnValue({ identity: 'Identity', @@ -74,8 +81,14 @@ describe('NavRail', () => { it('toggles module expansion when clicked', () => { const { container } = render(); + // Los módulos nacen desplegados: el menú llega del grafo y sus códigos no se conocen de + // antemano, así que el estado inicial no puede sembrarse por módulo. const moduleButton = container.querySelector('button[aria-expanded="true"]'); - expect(moduleButton).toBeInTheDocument(); + if (!moduleButton) throw new Error('No se renderizó ninguna cabecera de módulo desplegada'); + + fireEvent.click(moduleButton); + expect(container.querySelector('button[aria-expanded="false"]')).toBeInTheDocument(); + expect(screen.queryByText('Tenants')).not.toBeInTheDocument(); }); it('highlights active tab', () => { @@ -83,4 +96,39 @@ describe('NavRail', () => { const tenantsButton = screen.getByText('Tenants').closest('button'); expect(tenantsButton).toHaveClass('bg-m3-primary-container'); }); + + it('pinta el menú que publica el grafo, no el estático', () => { + // Con grafo, la barra deja de depender de navigation.config: dar de alta un menú en UMS basta + // para que aparezca, y sus etiquetas son las de la base, no las traducciones del cliente. + menuAccess = [ + { + code: 'IDM', + value: 'Identidad y Accesos', + sortOrder: 1, + status: 'Active', + nodes: [ + { + code: 'TENANTS', + value: 'Empresas del grupo', + kind: 'Menu', + sortOrder: 1, + icon: 'building', + route: '/tenants', + actions: [], + children: [], + }, + ], + }, + ]; + + render(); + + expect(screen.getByText('Identidad y Accesos')).toBeInTheDocument(); + expect(screen.getByText('Empresas del grupo')).toBeInTheDocument(); + // «Users» solo existía en el respaldo estático. + expect(screen.queryByText('Users')).not.toBeInTheDocument(); + expect(screen.getByText('Empresas del grupo').closest('button')).toHaveClass( + 'bg-m3-primary-container' + ); + }); }); diff --git a/src/apps/ums.web-app/src/presentation/shared/layouts/NavRail.tsx b/src/apps/ums.web-app/src/presentation/shared/layouts/NavRail.tsx index 48fa1c27..9d07813b 100644 --- a/src/apps/ums.web-app/src/presentation/shared/layouts/NavRail.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/layouts/NavRail.tsx @@ -2,200 +2,242 @@ import React, { useState, useMemo } from 'react'; import { useNavigate, useLocation } from 'react-router'; import { useI18n } from '@app/i18n/use-i18n'; import { useNavigationPrefetch } from '@app/shared/hooks/use-navigation-prefetch'; -import { useAccessResolution } from '@app/authorization/hooks/use-access-resolution'; -import { - Building2, - User, - LogOut, - ChevronRight, - ChevronDown, - ShieldCheck, - Cpu, - Users, - GitMerge, - Flag, - Settings, -} from 'lucide-react'; -import { NAV_ROUTES, pathToTab, NAV_MODULES } from './navigation.config'; -import type { NavModule } from './navigation.config'; +import { ChevronRight, ChevronDown, X } from 'lucide-react'; +import { useShellNavigation, claveActiva } from './use-shell-navigation'; +import type { ShellItem } from './use-shell-navigation'; +import { useIsMobile } from '../hooks/use-breakpoint'; interface NavRailProps { + /** Desktop: colapsa el rail a solo-iconos. */ collapsed: boolean; + /** Móvil: el drawer está abierto. */ + mobileOpen?: boolean; + /** Móvil: cerrar el drawer (backdrop, botón o tras navegar). */ + onCloseMobile?: () => void; } -export const NavRail: React.FC = ({ collapsed }) => { +export const NavRail: React.FC = ({ + collapsed, + mobileOpen = false, + onCloseMobile, +}) => { const t = useI18n(); const navigate = useNavigate(); const location = useLocation(); + const isMobile = useIsMobile(); const { prefetchById } = useNavigationPrefetch(); - const [expandedModules, setExpandedModules] = useState<{ [key: string]: boolean }>({ - idm: true, - auth: true, - sys: true, - }); + // Módulos plegados por el usuario. Ausencia = desplegado: el menú llega del grafo y sus códigos + // no se conocen de antemano, así que no se puede sembrar un estado inicial por módulo. + const [collapsedModules, setCollapsedModules] = useState>({}); - const activeTab = pathToTab(location.pathname); - - const modules: NavModule[] = useMemo( - () => - NAV_MODULES({ - ShieldCheck, - Building2, - Users, - GitMerge, - Cpu, - Flag, - User, - LogOut, - Settings, - primaryColorClass: 'text-m3-primary', - indigoColorClass: 'text-indigo-400', - t, - }), - [t] + const modules = useShellNavigation(); + const activeKey = useMemo( + () => claveActiva(modules, location.pathname), + [modules, location.pathname] ); const toggleModule = (moduleKey: string) => { - setExpandedModules(prev => ({ + setCollapsedModules(prev => ({ ...prev, [moduleKey]: !prev[moduleKey], })); }; - const { hasMenuAccess, graph } = useAccessResolution(); - - const filteredModules = useMemo(() => { - // Si no hay grafo (ej. entorno legacy o modo superadmin interno sin grafo total), - // devolvemos todo asumiendo que el router.tsx bloqueará lo necesario, - // o aplicamos bypass si es admin. - if (!graph) return modules; + /** Navega y, en móvil, cierra el drawer. */ + const go = (item: ShellItem) => { + if (item.route) navigate(item.route); + onCloseMobile?.(); + }; - const getMenuCode = (id: string) => { - if (id === 'systemSuites') return 'SYSTEM_SUITES'; - if (id === 'permissionTemplates') return 'PERMISSION_TEMPLATES'; - if (id === 'featureFlags') return 'FEATURE_FLAGS'; - if (id === 'appConfigurations') return 'APP_CONFIG'; - if (id === 'parameterCatalog') return 'PARAM_CATALOG'; - return id.toUpperCase(); - }; + const prefetch = (item: ShellItem) => { + if (item.prefetchId) prefetchById(item.prefetchId); + }; - return modules - .map(mod => ({ - ...mod, - members: mod.members.filter(tab => - hasMenuAccess(mod.key.toUpperCase(), getMenuCode(tab.id)) - ), - })) - .filter(mod => mod.members.length > 0); - }, [modules, graph, hasMenuAccess]); + // ── Cuerpo acordeón (desktop expandido + drawer móvil) ── + const accordion = ( +
+ ); + })} + + ); + + const footer = ( +
+

{t.portalFooter}

+

{t.archVersion}

+
+ ); - return ( + // ── Rail de escritorio (oculto en < lg) ── + const desktopAside = collapsed ? (
+ + ) : ( + ); + + // ── Drawer móvil (oculto en ≥ lg) ── + const mobileDrawer = ( +
+
+ +
+ ); + + // Render condicional por JS (no solo CSS) para no duplicar el árbol de + // navegación en el DOM: rail en escritorio, drawer en móvil. + return isMobile ? mobileDrawer : desktopAside; }; diff --git a/src/apps/ums.web-app/src/presentation/shared/layouts/TopAppBar.test.tsx b/src/apps/ums.web-app/src/presentation/shared/layouts/TopAppBar.test.tsx index 7fc0fbd9..d29a5850 100644 --- a/src/apps/ums.web-app/src/presentation/shared/layouts/TopAppBar.test.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/layouts/TopAppBar.test.tsx @@ -20,39 +20,64 @@ vi.mock('../components/NotificationCenter', () => ({ vi.mock('../components/ToastQueue', () => ({ ToastQueue: () =>
, })); -vi.mock('../components/Tooltip', () => ({ Tooltip: ({ children }: any) => <>{children} })); +vi.mock('../components/Tooltip', () => ({ + Tooltip: ({ children }: Record) => <>{children}, +})); + +/** Marca publicada por el sistema en el grafo (G-178). Vacía = se ve el producto por defecto. */ +let brand: { + displayName?: string; + tagline?: string; + logoUrl?: string; + iconUrl?: string; +} = {}; + +vi.mock('@app/authorization/hooks/use-system-settings', () => ({ + useSystemSettings: () => ({ brand, theme: {}, ui: {}, locale: {}, raw: {} }), +})); + +const mockI18nStore = (parcial: { language: string; setLanguage: () => void }) => { + const state = { ...parcial, applySystemLanguage: vi.fn(), chosenByUser: false }; + vi.mocked(i18nStoreModule.useI18nStore).mockImplementation((selector?: (s: never) => unknown) => + selector ? selector(state) : state + ); +}; describe('TopAppBar', () => { const mockOnToggleNav = vi.fn(); beforeEach(() => { vi.restoreAllMocks(); + brand = {}; vi.mocked(authStoreModule.useAuthStore).mockReturnValue({ user: { id: 'u-1', username: 'testuser' }, logout: vi.fn(), - } as any); + } as unknown as ReturnType); vi.mocked(themeStoreModule.useThemeStore).mockReturnValue({ isDarkMode: false, toggleDarkMode: vi.fn(), - } as any); + } as unknown as ReturnType); - vi.mocked(devToolsStoreModule.useDevToolsStore).mockReturnValue({} as any); + vi.mocked(devToolsStoreModule.useDevToolsStore).mockReturnValue( + {} as unknown as ReturnType + ); - vi.mocked(i18nStoreModule.useI18nStore).mockReturnValue({ - language: 'en', - setLanguage: vi.fn(), - } as any); + // Con selector o sin él: la barra lee el store entero y SystemThemeApplier —que la barra + // monta— lee `applySystemLanguage` con selector. + mockI18nStore({ language: 'en', setLanguage: vi.fn() }); - vi.mocked(notificationStoreModule.useNotificationStore).mockImplementation((selector: any) => { - const state = { - notifications: [], - setIsOpen: vi.fn(), - isOpen: false, - }; - return selector ? selector(state) : state; - }); + vi.mocked(notificationStoreModule.useNotificationStore).mockImplementation( + (selector: (state: never) => unknown) => { + const state = { + notifications: [], + setIsOpen: vi.fn(), + isOpen: false, + }; + return selector ? selector(state) : state; + } + ); vi.mocked(useI18nModule.useI18n).mockReturnValue({ appName: 'UMS', @@ -62,7 +87,7 @@ describe('TopAppBar', () => { toggleTheme: 'Toggle Theme', openNotifications: 'Notifications', logoutBtn: 'Logout', - } as any); + } as unknown as ReturnType); }); it('renders header element', () => { @@ -72,7 +97,9 @@ describe('TopAppBar', () => { it('renders app name', () => { render(); - expect(screen.getByText('UMS')).toBeInTheDocument(); + // Nombre corto y largo son el mismo texto cuando el producto no publica uno corto: el + // encabezado lleva las dos formas y el ancho decide cuál se ve. + expect(screen.getAllByText('UMS')).toHaveLength(2); }); it('renders app subtitle', () => { @@ -112,7 +139,7 @@ describe('TopAppBar', () => { vi.mocked(authStoreModule.useAuthStore).mockReturnValue({ user: { id: 'u-1', username: 'testuser' }, logout, - } as any); + } as unknown as ReturnType); render(); const logoutButton = screen.getByLabelText('Logout'); @@ -125,7 +152,7 @@ describe('TopAppBar', () => { vi.mocked(themeStoreModule.useThemeStore).mockReturnValue({ isDarkMode: false, toggleDarkMode, - } as any); + } as unknown as ReturnType); render(); const themeButton = screen.getByLabelText('Switch to dark mode'); @@ -135,10 +162,7 @@ describe('TopAppBar', () => { it('calls setLanguage when language button is clicked', () => { const setLanguage = vi.fn(); - vi.mocked(i18nStoreModule.useI18nStore).mockReturnValue({ - language: 'en', - setLanguage, - } as any); + mockI18nStore({ language: 'en', setLanguage }); render(); const langButton = screen.getByLabelText('Switch to Spanish'); @@ -147,16 +171,18 @@ describe('TopAppBar', () => { }); it('shows unread notification count when there are unread notifications', () => { - vi.mocked(notificationStoreModule.useNotificationStore).mockImplementation((selector: any) => { - const state = { - notifications: [ - { id: '1', read: false, title: 'Test', message: 'Test', type: 'info' as const }, - ], - setIsOpen: vi.fn(), - isOpen: false, - }; - return selector ? selector(state) : state; - }); + vi.mocked(notificationStoreModule.useNotificationStore).mockImplementation( + (selector: (state: never) => unknown) => { + const state = { + notifications: [ + { id: '1', read: false, title: 'Test', message: 'Test', type: 'info' as const }, + ], + setIsOpen: vi.fn(), + isOpen: false, + }; + return selector ? selector(state) : state; + } + ); render(); expect(screen.getByText('1')).toBeInTheDocument(); @@ -164,14 +190,16 @@ describe('TopAppBar', () => { it('calls setIsOpen when notification button is clicked', () => { const setIsOpen = vi.fn(); - vi.mocked(notificationStoreModule.useNotificationStore).mockImplementation((selector: any) => { - const state = { - notifications: [], - setIsOpen, - isOpen: false, - }; - return selector ? selector(state) : state; - }); + vi.mocked(notificationStoreModule.useNotificationStore).mockImplementation( + (selector: (state: never) => unknown) => { + const state = { + notifications: [], + setIsOpen, + isOpen: false, + }; + return selector ? selector(state) : state; + } + ); render(); const notifButton = screen.getByLabelText('Notifications'); @@ -183,7 +211,7 @@ describe('TopAppBar', () => { vi.mocked(authStoreModule.useAuthStore).mockReturnValue({ user: null, logout: vi.fn(), - } as any); + } as unknown as ReturnType); render(); expect(screen.queryByText(/Dev:/)).not.toBeInTheDocument(); @@ -193,9 +221,107 @@ describe('TopAppBar', () => { vi.mocked(authStoreModule.useAuthStore).mockReturnValue({ user: null, logout: vi.fn(), - } as any); + } as unknown as ReturnType); render(); expect(screen.queryByLabelText('Log out')).not.toBeInTheDocument(); }); + + describe('marca del sistema', () => { + it('sin marca publicada se ve la del producto', () => { + render(); + + const marcas = screen.getAllByAltText('BEYONDNET'); + expect(marcas).toHaveLength(2); // claro y oscuro + expect(screen.getAllByText('UMS')).not.toHaveLength(0); + }); + + it('usa el icono en móvil y el logotipo desde sm', () => { + brand = { + displayName: 'Tablero de Gobierno SDLC', + logoUrl: '/branding/sdlc/logo.svg', + iconUrl: '/branding/sdlc/icon.svg', + }; + + render(); + const [compacta, ancha] = screen.getAllByAltText('Tablero de Gobierno SDLC'); + + expect(compacta).toHaveAttribute('src', '/branding/sdlc/icon.svg'); + expect(compacta.className).toContain('sm:hidden'); + expect(ancha).toHaveAttribute('src', '/branding/sdlc/logo.svg'); + expect(ancha.className).toContain('hidden sm:block'); + + // La marca del sistema sustituye a la del producto, no se suma a ella. + expect(screen.queryByAltText('BEYONDNET')).not.toBeInTheDocument(); + }); + + it('si solo hay logotipo, también sirve de marca compacta', () => { + // Un sistema a medio configurar debe verse completo en ambos tamaños, no dejar un hueco. + brand = { displayName: 'WMS', logoUrl: '/branding/wms/logo.svg' }; + + render(); + const marcas = screen.getAllByAltText('WMS'); + + expect(marcas).toHaveLength(2); + marcas.forEach(m => expect(m).toHaveAttribute('src', '/branding/wms/logo.svg')); + }); + + it('el nombre y el descriptor del sistema sustituyen a los del producto', () => { + brand = { + displayName: 'Tablero de Gobierno SDLC', + tagline: 'Gobierno del ciclo de vida de los sistemas satélite', + }; + + render(); + + expect(screen.getAllByText('Tablero de Gobierno SDLC')).not.toHaveLength(0); + expect( + screen.getByText('Gobierno del ciclo de vida de los sistemas satélite') + ).toBeInTheDocument(); + expect(screen.queryByText('UMS')).not.toBeInTheDocument(); + expect(screen.queryByText('User Management')).not.toBeInTheDocument(); + }); + + it('un sistema con nombre y sin descriptor no hereda el subtítulo del producto', () => { + // Mezclar el nombre de un sistema con el subtítulo de otro confunde sobre dónde se está. + brand = { displayName: 'Tablero de Gobierno SDLC' }; + + render(); + + expect(screen.getAllByText('Tablero de Gobierno SDLC')).not.toHaveLength(0); + expect(screen.queryByText('User Management')).not.toBeInTheDocument(); + }); + + it('en móvil el encabezado usa el nombre corto', () => { + brand = { displayName: 'Tablero de Gobierno SDLC', shortName: 'SDLC' }; + + render(); + const corto = screen.getByText('SDLC'); + const largo = screen.getByText('Tablero de Gobierno SDLC'); + + // El ancho decide cuál se ve: el corto desaparece desde `sm`, el largo hasta `sm`. + expect(corto.className).toContain('sm:hidden'); + expect(largo.className).toContain('hidden sm:inline'); + }); + + it('sin nombre corto se recorta el largo, nunca se cae al del producto', () => { + // Caer a «UMS» anunciaría un sistema distinto del que el usuario tiene abierto. + brand = { displayName: 'Tablero de Gobierno SDLC' }; + + render(); + + expect(screen.getAllByText('Tablero de Gobierno SDLC')).toHaveLength(2); + expect(screen.queryByText('UMS')).not.toBeInTheDocument(); + }); + + it('si solo hay icono, también ocupa el sitio del logotipo', () => { + brand = { displayName: 'WMS', iconUrl: '/branding/wms/icon.svg' }; + + render(); + const marcas = screen.getAllByAltText('WMS'); + + expect(marcas).toHaveLength(2); + marcas.forEach(m => expect(m).toHaveAttribute('src', '/branding/wms/icon.svg')); + }); + }); }); diff --git a/src/apps/ums.web-app/src/presentation/shared/layouts/TopAppBar.tsx b/src/apps/ums.web-app/src/presentation/shared/layouts/TopAppBar.tsx index 013c7a53..cf87e735 100644 --- a/src/apps/ums.web-app/src/presentation/shared/layouts/TopAppBar.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/layouts/TopAppBar.tsx @@ -1,6 +1,9 @@ import React, { useState } from 'react'; -import { Database, Menu, Sun, Moon, Bell, Globe, User, LogOut } from 'lucide-react'; +import { Menu, Sun, Moon, Bell, Globe, LogOut } from 'lucide-react'; import { useAuthStore } from '@app/stores/auth.store'; +import { ProfileSelector } from '@presentation/shared/components/ProfileSelector'; +import { SystemThemeApplier } from '@presentation/shared/components/SystemThemeApplier'; +import { useSystemSettings } from '@app/authorization/hooks/use-system-settings'; import { useThemeStore } from '@app/stores/theme.store'; import { useDevToolsStore } from '@app/stores/devTools.store'; import { useI18nStore } from '@app/stores/i18n.store'; @@ -13,6 +16,7 @@ import { ConnectedUserDrawer } from '../components/ConnectedUserDrawer'; export const TopAppBar: React.FC<{ onToggleNav: () => void }> = ({ onToggleNav }) => { const { user, logout } = useAuthStore(); + const { brand } = useSystemSettings(); const { isDarkMode, toggleDarkMode } = useThemeStore(); useDevToolsStore(); const { language, setLanguage } = useI18nStore(); @@ -22,6 +26,8 @@ export const TopAppBar: React.FC<{ onToggleNav: () => void }> = ({ onToggleNav } const [isUserDrawerOpen, setIsUserDrawerOpen] = useState(false); const unreadCount = notifications.filter(n => !n.read).length; + const etiquetaNotificaciones = + unreadCount > 0 ? `Notifications, ${unreadCount} unread` : 'Notifications'; const handleLanguageToggle = () => { setLanguage(language === 'en' ? 'es' : 'en'); @@ -36,43 +42,87 @@ export const TopAppBar: React.FC<{ onToggleNav: () => void }> = ({ onToggleNav } return user.username.substring(0, 2).toUpperCase(); }; - const getTooltipMessage = () => { - if (!user) return 'Not logged in'; - return `${user.username}\n${user.email}\nTenant: ${user.tenantCode || 'N/A'}`; - }; + /** Hay marca propia del sistema en cuanto publique cualquiera de los dos recursos. */ + const marcaSistema = brand.iconUrl ?? brand.logoUrl; + const nombreDeMarca = brand.displayName ?? t.appName; + // Nombre y descriptor viajan juntos: si el sistema publica el suyo, la barra deja de anunciar + // el producto. Mezclar el nombre de uno con el subtítulo del otro solo confunde sobre dónde está + // el usuario, que es justo lo que esta zona de la barra responde. + const descriptorDeMarca = brand.tagline ?? (brand.displayName ? undefined : t.appSubtitle); + // Para el ancho de un móvil. El nombre corto existe justo para esto; si el sistema no lo publica + // se recorta el largo antes que caer al nombre del producto, que sería anunciar otro sistema. + const nombreCorto = brand.shortName ?? nombreDeMarca; return ( <>
-
+
-
-
- -
-
-

- {t.appName} +
+ {/* La marca del SISTEMA sustituye a la del producto solo si está configurada + (G-178). Sin ajustes publicados se ve el producto por defecto, no un hueco. + + Icono y logotipo no son el mismo recurso y por eso el sistema publica los dos: + el icono es la marca compacta —cuadrada— y el logotipo el lockup ancho. En móvil + manda el icono, donde el lockup competía por el ancho con el título; desde `sm`, + el logotipo. Cada uno respalda al otro: un sistema que solo configure uno se ve + completo en ambos tamaños. */} + {marcaSistema ? ( + <> + {nombreDeMarca} + {nombreDeMarca} + + ) : ( + <> + BEYONDNET + BEYONDNET + + )} +
+ {/* Un solo encabezado con dos formas del mismo nombre: el corto cabe en un móvil, + donde el largo se recortaría a la mitad de una palabra. El descriptor se reserva + para cuando hay sitio; en móvil compite con el nombre y pierde. */} +

+ {nombreCorto} + {nombreDeMarca}

-

- {t.appSubtitle} -

+ {descriptorDeMarca && ( +

+ {descriptorDeMarca} +

+ )}

-
+
{user && (
@@ -80,7 +130,7 @@ export const TopAppBar: React.FC<{ onToggleNav: () => void }> = ({ onToggleNav } {t.devUser} {user.username}
-
+
{user.isInternalAdmin ? 'Admin Local' : user.tenantName || 'Tenant N/A'} @@ -106,7 +156,7 @@ export const TopAppBar: React.FC<{ onToggleNav: () => void }> = ({ onToggleNav } aria-label={language === 'en' ? 'Switch to Spanish' : 'Switch to English'} className="p-2.5 rounded-full hover:bg-m3-primary/10 text-m3-secondary hover:text-m3-primary transition-all flex items-center gap-1.5 border border-m3-outline/30" > - + {language.toUpperCase()} @@ -120,7 +170,7 @@ export const TopAppBar: React.FC<{ onToggleNav: () => void }> = ({ onToggleNav } {isDarkMode ? ( ) : ( - + )} @@ -128,7 +178,7 @@ export const TopAppBar: React.FC<{ onToggleNav: () => void }> = ({ onToggleNav } + {/* Selector de perfil: se pinta solo si el usuario tiene más de uno (G-177). */} + + {user &&
} {user && ( @@ -168,6 +221,7 @@ export const TopAppBar: React.FC<{ onToggleNav: () => void }> = ({ onToggleNav }
+ diff --git a/src/apps/ums.web-app/src/presentation/shared/layouts/navigation.config.test.tsx b/src/apps/ums.web-app/src/presentation/shared/layouts/navigation.config.test.tsx index 2c83675f..c701cacc 100644 --- a/src/apps/ums.web-app/src/presentation/shared/layouts/navigation.config.test.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/layouts/navigation.config.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { NAV_ROUTES, pathToTab } from './navigation.config'; +import { NAV_ROUTES } from './navigation.config'; describe('navigation.config', () => { it('exports NAV_ROUTES with correct paths', () => { @@ -9,52 +9,29 @@ describe('navigation.config', () => { expect(NAV_ROUTES.systemSuites).toBe('/system-suites'); expect(NAV_ROUTES.permissionTemplates).toBe('/permission-templates'); expect(NAV_ROUTES.featureFlags).toBe('/feature-flags'); + expect(NAV_ROUTES.appConfigurations).toBe('/app-configurations'); + expect(NAV_ROUTES.parameterCatalog).toBe('/parameter-catalog'); expect(NAV_ROUTES.profiles).toBe('/profiles'); expect(NAV_ROUTES.profile).toBe('/profile'); expect(NAV_ROUTES.login).toBe('/login'); }); - it('pathToTab returns correct tab for tenants path', () => { - expect(pathToTab('/tenants')).toBe('tenants'); - expect(pathToTab('/tenants/123')).toBe('tenants'); - }); - - it('pathToTab returns correct tab for users path', () => { - expect(pathToTab('/users')).toBe('users'); - expect(pathToTab('/users/456/edit')).toBe('users'); - }); - - it('pathToTab returns correct tab for delegations path', () => { - expect(pathToTab('/delegations')).toBe('delegations'); - }); - - it('pathToTab returns correct tab for system-suites path', () => { - expect(pathToTab('/system-suites')).toBe('systemSuites'); - expect(pathToTab('/system-suites/abc')).toBe('systemSuites'); - }); - - it('pathToTab returns correct tab for permission-templates path', () => { - expect(pathToTab('/permission-templates')).toBe('permissionTemplates'); - }); - - it('pathToTab returns correct tab for feature-flags path', () => { - expect(pathToTab('/feature-flags')).toBe('featureFlags'); - }); - - it('pathToTab returns correct tab for profiles path', () => { - expect(pathToTab('/profiles')).toBe('profiles'); - }); - - it('pathToTab returns correct tab for profile path', () => { - expect(pathToTab('/profile')).toBe('profile'); - }); - - it('pathToTab returns correct tab for login path', () => { - expect(pathToTab('/login')).toBe('login'); - }); - - it('pathToTab returns default tenants for unknown path', () => { - expect(pathToTab('/unknown')).toBe('tenants'); - expect(pathToTab('/')).toBe('tenants'); + it('las rutas del portal coinciden con las que siembra UMS en el grafo', () => { + // El servidor publica estas mismas rutas en `MenuNodePresentation` (AuthorizationDevDataSeeder). + // Si divergen, el menú construido desde el grafo llevaría a pantallas que el router no conoce. + const sembradas = [ + '/tenants', + '/users', + '/delegations', + '/system-suites', + '/permission-templates', + '/profiles', + '/feature-flags', + '/app-configurations', + '/parameter-catalog', + ]; + + const declaradas = new Set(Object.values(NAV_ROUTES)); + expect(sembradas.filter(r => !declaradas.has(r))).toEqual([]); }); }); diff --git a/src/apps/ums.web-app/src/presentation/shared/layouts/navigation.config.tsx b/src/apps/ums.web-app/src/presentation/shared/layouts/navigation.config.tsx index 45de9b82..a9f1dea8 100644 --- a/src/apps/ums.web-app/src/presentation/shared/layouts/navigation.config.tsx +++ b/src/apps/ums.web-app/src/presentation/shared/layouts/navigation.config.tsx @@ -1,9 +1,13 @@ /** - * navigation.config.ts — Static navigation structure for the sidebar rail. + * Estructura estática de navegación — **respaldo**, no fuente de verdad (G-181). * - * Keeps MainLayout focused on rendering/behaviour by moving the - * route → label → icon mapping into a declarative data structure. - * Translations are resolved at render time via the `nameKey` field. + * Desde que el shell construye el menú desde el grafo de autorización + * (ver `use-shell-navigation`), esta declaración solo se usa cuando el grafo no trae navegación: + * sesión sin sistema resuelto o entorno de desarrollo sin sembrar. Ahí el router sigue siendo la + * barrera real de acceso. + * + * Sigue siendo la fuente de `NAV_ROUTES` (rutas del portal, que el prefetch y el respaldo usan) y + * de los enlaces externos de observabilidad, que no pertenecen al grafo. */ import React from 'react'; @@ -28,11 +32,37 @@ export interface NavItem { icon: React.ReactNode; } +/** + * Enlace externo en la barra (p. ej. Grafana). A diferencia de {@link NavItem} + * no navega por el router: abre `href` en una pestaña nueva. Se usa para exponer + * la observabilidad técnica (métricas, trazas, logs) junto a la zona de + * autorización sin acoplarla a una ruta de la SPA. + */ +export interface NavExternalItem { + id: string; + nameKey: string; + icon: React.ReactNode; + href: string; + external: true; +} + +export type NavMember = NavItem | NavExternalItem; + +export const isExternalItem = (m: NavMember): m is NavExternalItem => + (m as NavExternalItem).external === true; + +/** + * Base de Grafana. Servida bajo el mismo origen en `/grafana/` (el nginx del + * frontend hace proxy; ver nginx.conf.template). Configurable por despliegue con + * VITE_GRAFANA_URL. Sin barra final: los enlaces la añaden. + */ +const GRAFANA_URL = (import.meta.env.VITE_GRAFANA_URL as string | undefined) ?? '/grafana'; + export interface NavModule { key: string; nameKey: string; icon: React.ReactNode; - members: NavItem[]; + members: NavMember[]; } export const NAV_ROUTES: Record = { @@ -49,31 +79,16 @@ export const NAV_ROUTES: Record = { login: '/login', }; -export const pathToTab = (pathname: string): NavItemId => { - if (pathname.startsWith('/tenants')) return 'tenants'; - if (pathname.startsWith('/users')) return 'users'; - if (pathname.startsWith('/delegations')) return 'delegations'; - if (pathname.startsWith('/system-suites')) return 'systemSuites'; - if (pathname.startsWith('/permission-templates')) return 'permissionTemplates'; - if (pathname.startsWith('/feature-flags')) return 'featureFlags'; - if (pathname.startsWith('/app-configurations')) return 'appConfigurations'; - if (pathname.startsWith('/parameter-catalog')) return 'parameterCatalog'; - if (pathname.startsWith('/profiles')) return 'profiles'; - if (pathname.startsWith('/profile')) return 'profile'; - if (pathname.startsWith('/login')) return 'login'; - return 'tenants'; -}; - interface NavModulesFactoryDeps { ShieldCheck: React.ComponentType<{ className?: string }>; Building2: React.ComponentType<{ className?: string }>; Users: React.ComponentType<{ className?: string }>; - GitMerge: React.ComponentType<{ className?: string }>; Cpu: React.ComponentType<{ className?: string }>; Flag: React.ComponentType<{ className?: string }>; User: React.ComponentType<{ className?: string }>; - LogOut: React.ComponentType<{ className?: string }>; Settings: React.ComponentType<{ className?: string }>; + Activity: React.ComponentType<{ className?: string }>; + ScrollText: React.ComponentType<{ className?: string }>; primaryColorClass: string; indigoColorClass: string; t: Record; @@ -94,7 +109,7 @@ export const NAV_MODULES = (deps: NavModulesFactoryDeps): NavModule[] => [ nameKey: 'authorizationContext', icon: , members: [ - { id: 'systemSuites', nameKey: 'systemSuites', icon: }, + { id: 'systemSuites', nameKey: 'systemSuitesNav', icon: }, { id: 'permissionTemplates', nameKey: 'permissionTemplates', @@ -102,6 +117,22 @@ export const NAV_MODULES = (deps: NavModulesFactoryDeps): NavModule[] => [ }, { id: 'profiles', nameKey: 'profilesHeader', icon: }, { id: 'featureFlags', nameKey: 'featureFlags', icon: }, + // Observabilidad de la zona de autorización: datos técnicos (métricas, + // trazas y logs) en Grafana, bajo el mismo origen en /grafana/. + { + id: 'grafana', + nameKey: 'observabilityGrafana', + icon: , + href: `${GRAFANA_URL}/d/ums-overview`, + external: true, + }, + { + id: 'logs', + nameKey: 'observabilityLogs', + icon: , + href: `${GRAFANA_URL}/explore`, + external: true, + }, ], }, { diff --git a/src/apps/ums.web-app/src/presentation/shared/layouts/navigation.icons.tsx b/src/apps/ums.web-app/src/presentation/shared/layouts/navigation.icons.tsx new file mode 100644 index 00000000..8d4cdd0a --- /dev/null +++ b/src/apps/ums.web-app/src/presentation/shared/layouts/navigation.icons.tsx @@ -0,0 +1,84 @@ +/** + * Registro de iconos de navegación. + * + * El servidor publica en cada nodo del grafo un **identificador** de icono (`"building"`, + * `"layout-dashboard"`…), nunca un SVG ni una URL: si guardara el recurso, la base de datos + * quedaría atada a la biblioteca gráfica de un frontend concreto y cambiarla exigiría migrar datos. + * + * Este módulo es el otro extremo de esa convención: traduce el identificador al icono real. Es el + * único punto del cliente que conoce la biblioteca; añadir un icono nuevo al catálogo del servidor + * solo obliga a añadir una entrada aquí. + */ +import React from 'react'; +import { + Activity, + Bell, + Building2, + CalendarDays, + CalendarRange, + Cpu, + FileText, + Flag, + FolderKanban, + GitMerge, + History, + Inbox, + LayoutDashboard, + LayoutGrid, + ListChecks, + Package, + Receipt, + SatelliteDish, + ScrollText, + Settings, + ShieldCheck, + Truck, + User, + UserRound, + Users, +} from 'lucide-react'; + +type ComponenteIcono = React.ComponentType<{ className?: string }>; + +const REGISTRO: Record = { + activity: Activity, + bell: Bell, + building: Building2, + 'calendar-days': CalendarDays, + 'calendar-range': CalendarRange, + cpu: Cpu, + 'file-text': FileText, + flag: Flag, + 'folder-kanban': FolderKanban, + 'git-merge': GitMerge, + history: History, + inbox: Inbox, + 'layout-dashboard': LayoutDashboard, + 'layout-grid': LayoutGrid, + 'list-checks': ListChecks, + package: Package, + receipt: Receipt, + 'satellite-dish': SatelliteDish, + 'scroll-text': ScrollText, + settings: Settings, + 'shield-check': ShieldCheck, + truck: Truck, + user: User, + 'user-round': UserRound, + users: Users, +}; + +/** Icono de respaldo: un identificador desconocido no debe dejar la fila sin icono y desalineada. */ +const RESPALDO: ComponenteIcono = LayoutGrid; + +export const iconoConocido = (identificador: string | null | undefined): boolean => + identificador != null && identificador in REGISTRO; + +/** Resuelve el identificador publicado por el grafo al icono que pinta el shell. */ +export const iconoDeNodo = ( + identificador: string | null | undefined, + className = 'w-4 h-4' +): React.ReactNode => { + const Icono = (identificador ? REGISTRO[identificador] : undefined) ?? RESPALDO; + return ; +}; diff --git a/src/apps/ums.web-app/src/presentation/shared/layouts/use-shell-navigation.test.tsx b/src/apps/ums.web-app/src/presentation/shared/layouts/use-shell-navigation.test.tsx new file mode 100644 index 00000000..8be8c9ec --- /dev/null +++ b/src/apps/ums.web-app/src/presentation/shared/layouts/use-shell-navigation.test.tsx @@ -0,0 +1,180 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useShellNavigation, claveActiva } from './use-shell-navigation'; +import type { ShellModule } from './use-shell-navigation'; + +let menuAccess: unknown; + +vi.mock('@app/stores/auth.store', () => ({ + useAuthStore: (selector: (s: unknown) => unknown) => + selector({ user: { authorizationGraph: { menuAccess } } }), +})); + +vi.mock('@app/i18n/use-i18n', () => ({ + useI18n: () => ({ + identityContext: 'Identidad', + tenant: 'Empresas', + observabilityGrafana: 'Grafana', + observabilityLogs: 'Logs', + }), +})); + +const nodo = ( + code: string, + kind: string, + extra: Partial<{ icon: string | null; route: string | null; children: unknown[] }> = {} +) => ({ + code, + value: `Etiqueta ${code}`, + kind, + sortOrder: 1, + icon: extra.icon ?? null, + route: extra.route ?? null, + actions: [], + children: extra.children ?? [], +}); + +describe('useShellNavigation', () => { + beforeEach(() => { + menuAccess = undefined; + }); + + it('sin grafo cae al respaldo estático', () => { + const { result } = renderHook(() => useShellNavigation()); + + // El respaldo son los módulos declarados en navigation.config, con sus etiquetas traducidas. + expect(result.current.map(m => m.key)).toEqual(['idm', 'auth', 'sys']); + expect(result.current[0].label).toBe('Identidad'); + expect(result.current[0].items[0].route).toBe('/tenants'); + expect(result.current[0].items[0].prefetchId).toBe('tenants'); + }); + + it('con grafo, el menú viene del grafo: etiquetas, iconos y rutas del servidor', () => { + menuAccess = [ + { + code: 'IDM', + value: 'Identidad y Accesos', + sortOrder: 1, + status: 'Active', + icon: 'shield-check', + nodes: [ + nodo('TENANTS', 'Menu', { + icon: 'building', + route: '/tenants', + children: [ + nodo('TENANTS_LIST', 'SubMenu', { children: [nodo('VIEW_TENANTS', 'Option')] }), + ], + }), + ], + }, + ]; + + const { result } = renderHook(() => useShellNavigation()); + + expect(result.current).toHaveLength(1); + expect(result.current[0].label).toBe('Identidad y Accesos'); + + // La pantalla es el menú con ruta; lo que cuelga de ella son permisos, no destinos. + expect(result.current[0].items.map(i => i.key)).toEqual(['TENANTS']); + expect(result.current[0].items[0].label).toBe('Etiqueta TENANTS'); + expect(result.current[0].items[0].route).toBe('/tenants'); + expect(result.current[0].items[0].prefetchId).toBe('tenants'); + }); + + it('desciende hasta la primera ruta cuando el menú solo agrupa', () => { + menuAccess = [ + { + code: 'PRD', + value: 'Iniciativas', + sortOrder: 1, + status: 'Active', + nodes: [ + nodo('DASHBOARDS', 'Menu', { + icon: 'layout-dashboard', + children: [ + nodo('DASH_EJEC', 'SubMenu', { + children: [nodo('PORT_DASHBOARD', 'Option', { route: '/portafolio' })], + }), + ], + }), + ], + }, + ]; + + const { result } = renderHook(() => useShellNavigation()); + const items = result.current[0].items; + + expect(items.map(i => i.key)).toEqual(['PORT_DASHBOARD']); + expect(items[0].route).toBe('/portafolio'); + // Ruta ajena al portal: no hay pantalla que precargar, y eso no debe romper el hover. + expect(items[0].prefetchId).toBeUndefined(); + }); + + it('conserva los enlaces externos de observabilidad en su módulo', () => { + menuAccess = [ + { + code: 'AUTH', + value: 'Autorización', + sortOrder: 1, + status: 'Active', + nodes: [nodo('SYSTEM_SUITES', 'Menu', { route: '/system-suites' })], + }, + ]; + + const { result } = renderHook(() => useShellNavigation()); + const items = result.current[0].items; + + // Grafana no es una ruta del grafo —aplica su propia autenticación—, pero vive en ese módulo. + expect(items.map(i => i.key)).toEqual(['SYSTEM_SUITES', 'grafana', 'logs']); + expect(items[1].href).toContain('/grafana'); + expect(items[1].route).toBeUndefined(); + }); + + it('descarta el módulo que no aporta ninguna fila navegable', () => { + menuAccess = [ + { + code: 'VACIO', + value: 'Sin pantallas', + sortOrder: 1, + status: 'Active', + nodes: [nodo('SOLO_AGRUPA', 'Menu')], + }, + ]; + + const { result } = renderHook(() => useShellNavigation()); + expect(result.current).toEqual([]); + }); +}); + +describe('claveActiva', () => { + const modulos = [ + { + key: 'AUTH', + label: 'Autorización', + icon: null, + items: [ + { key: 'SUITES', label: 'Sistemas', icon: null, route: '/system-suites' }, + { key: 'PERFIL', label: 'Perfil', icon: null, route: '/profile' }, + { key: 'PERFILES', label: 'Perfiles', icon: null, route: '/profiles' }, + { key: 'GRAFANA', label: 'Grafana', icon: null, href: '/grafana' }, + ], + }, + ] as unknown as ShellModule[]; + + it('marca la fila cuya ruta contiene a la actual', () => { + expect(claveActiva(modulos, '/system-suites')).toBe('SUITES'); + expect(claveActiva(modulos, '/system-suites/abc')).toBe('SUITES'); + }); + + it('no confunde rutas que comparten prefijo textual', () => { + // `/profiles` no es un hijo de `/profile`: el corte debe ser por segmento, no por caracteres. + expect(claveActiva(modulos, '/profiles')).toBe('PERFILES'); + expect(claveActiva(modulos, '/profile')).toBe('PERFIL'); + }); + + it('devuelve null fuera de la navegación', () => { + // Antes se devolvía «tenants» por defecto, y una ruta desconocida dejaba una fila marcada + // como activa sin estarlo. + expect(claveActiva(modulos, '/login')).toBeNull(); + }); +}); diff --git a/src/apps/ums.web-app/src/presentation/shared/layouts/use-shell-navigation.tsx b/src/apps/ums.web-app/src/presentation/shared/layouts/use-shell-navigation.tsx new file mode 100644 index 00000000..c1ece361 --- /dev/null +++ b/src/apps/ums.web-app/src/presentation/shared/layouts/use-shell-navigation.tsx @@ -0,0 +1,183 @@ +/** + * La navegación que pinta el shell, con el grafo como fuente de verdad (G-181). + * + * Hasta ahora el menú se declaraba en `navigation.config` y el grafo solo se usaba para **filtrar** + * esa lista, traduciendo a mano el id de cada pantalla a un código de menú. Eran dos fuentes: dar de + * alta una opción en UMS no la hacía aparecer, y renombrar un menú en la base no cambiaba la barra. + * + * Aquí la relación se invierte: el grafo **construye** el menú —módulos, etiquetas, iconos y rutas— + * y la configuración estática queda como respaldo para cuando no hay grafo (sesión sin sistema + * resuelto o entorno de desarrollo sin sembrar), donde el router sigue siendo la última barrera. + * + * No se vuelve a filtrar por permiso: el árbol llega podado desde el servidor y filtrarlo otra vez + * en el cliente solo abriría la puerta a que ambas reglas discrepen. + */ +import React, { useMemo } from 'react'; +import { useI18n } from '@app/i18n/use-i18n'; +import { + useGraphNavigation, + type NavigationItem, + type NavigationModule, +} from '@app/authorization/hooks/use-graph-navigation'; +import { + Activity, + Building2, + Cpu, + Flag, + ScrollText, + Settings, + ShieldCheck, + User, + Users, +} from 'lucide-react'; +import { NAV_MODULES, NAV_ROUTES, isExternalItem } from './navigation.config'; +import type { NavItemId, NavMember } from './navigation.config'; +import { iconoDeNodo } from './navigation.icons'; + +export interface ShellItem { + key: string; + label: string; + icon: React.ReactNode; + /** Ruta interna del router. Excluyente con {@link href}. */ + route?: string; + /** Enlace externo (se abre en pestaña nueva). Excluyente con {@link route}. */ + href?: string; + /** Pantalla conocida para el prefetch en hover; ausente si la ruta no es del portal. */ + prefetchId?: NavItemId; +} + +export interface ShellModule { + key: string; + label: string; + icon: React.ReactNode; + items: ShellItem[]; +} + +const RUTA_A_PANTALLA: Record = Object.fromEntries( + Object.entries(NAV_ROUTES).map(([id, ruta]) => [ruta, id as NavItemId]) +) as Record; + +/** + * Convierte el árbol de un módulo en las filas de la barra. + * + * Se detiene en el primer nodo con ruta: lo que cuelga de una pantalla son **permisos sobre ella** + * —ver, crear, exportar—, no destinos distintos, y pintarlos como enlaces llenaría el menú de rutas + * que no existen. Un nodo sin ruta solo agrupa, así que se desciende a sus hijos y su icono se + * hereda, para que una opción sin icono propio no rompa la columna. + */ +function filasDe(items: readonly NavigationItem[], iconoHeredado: string | null): ShellItem[] { + const salida: ShellItem[] = []; + + for (const item of items) { + const icono = item.icon ?? iconoHeredado; + + if (item.route) { + salida.push({ + key: item.code, + label: item.label, + icon: iconoDeNodo(icono), + route: item.route, + prefetchId: RUTA_A_PANTALLA[item.route], + }); + continue; + } + + salida.push(...filasDe(item.children, icono)); + } + + return salida; +} + +function desdeGrafo( + modulos: NavigationModule[], + externos: Map +): ShellModule[] { + return modulos + .map(modulo => ({ + key: modulo.code, + label: modulo.label, + // Desde el contrato 2.3.0 el módulo publica su icono (G-182). Sin él, respaldo neutro: + // resolverlo por código sería reintroducir la tabla estática que este cambio eliminó. + icon: iconoDeNodo(modulo.icon, 'w-5 h-5 text-m3-primary'), + // Los enlaces externos (observabilidad) no son rutas del grafo —Grafana aplica su propia + // autenticación—, así que se anexan al módulo al que pertenecen por convención de código. + items: [...filasDe(modulo.items, null), ...(externos.get(modulo.code) ?? [])], + })) + .filter(modulo => modulo.items.length > 0); +} + +const filaEstatica = (miembro: NavMember, t: Record): ShellItem => ({ + key: miembro.id, + label: t[miembro.nameKey] ?? miembro.nameKey, + icon: miembro.icon, + ...(isExternalItem(miembro) + ? { href: miembro.href } + : { route: NAV_ROUTES[miembro.id], prefetchId: miembro.id }), +}); + +export function useShellNavigation(): ShellModule[] { + const t = useI18n() as unknown as Record; + const grafo = useGraphNavigation(); + + const estaticos = useMemo( + () => + NAV_MODULES({ + ShieldCheck, + Building2, + Users, + Cpu, + Flag, + User, + Settings, + Activity, + ScrollText, + primaryColorClass: 'text-m3-primary', + indigoColorClass: 'text-indigo-400', + t, + }), + [t] + ); + + return useMemo(() => { + if (!grafo.length) { + return estaticos.map(modulo => ({ + key: modulo.key, + label: t[modulo.nameKey] ?? modulo.nameKey, + icon: modulo.icon, + items: modulo.members.map(miembro => filaEstatica(miembro, t)), + })); + } + + const externos = new Map(); + for (const modulo of estaticos) { + const enlaces = modulo.members.filter(isExternalItem).map(m => filaEstatica(m, t)); + if (enlaces.length) externos.set(modulo.key.toUpperCase(), enlaces); + } + + return desdeGrafo(grafo, externos); + }, [grafo, estaticos, t]); +} + +/** + * Resuelve qué fila marcar como activa a partir de la ruta actual. + * + * Gana el prefijo más largo: con `/system-suites/abc` y `/system-suites` declarados, la fila activa + * debe ser la más específica, no la primera que case. + */ +export function claveActiva(modulos: ShellModule[], pathname: string): string | null { + let clave: string | null = null; + let largo = -1; + + for (const modulo of modulos) { + for (const item of modulo.items) { + if (!item.route) continue; + const casa = pathname === item.route || pathname.startsWith(`${item.route}/`); + if (casa && item.route.length > largo) { + clave = item.key; + largo = item.route.length; + } + } + } + + return clave; +} diff --git a/src/apps/ums.web-app/src/presentation/shared/utils/pluralize.test.ts b/src/apps/ums.web-app/src/presentation/shared/utils/pluralize.test.ts new file mode 100644 index 00000000..6c973af5 --- /dev/null +++ b/src/apps/ums.web-app/src/presentation/shared/utils/pluralize.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { pluralizeEs, formatItemCountLabel } from './pluralize'; + +describe('pluralizeEs', () => { + it('agrega -s a palabras terminadas en vocal', () => { + expect(pluralizeEs('módulo')).toBe('módulos'); + expect(pluralizeEs('recurso')).toBe('recursos'); + expect(pluralizeEs('cuenta')).toBe('cuentas'); + expect(pluralizeEs('suite')).toBe('suites'); + }); + + it('convierte -ión en -iones (sin tilde)', () => { + expect(pluralizeEs('acción')).toBe('acciones'); + expect(pluralizeEs('Acción')).toBe('Acciones'); + expect(pluralizeEs('configuración')).toBe('configuraciones'); + expect(pluralizeEs('delegación')).toBe('delegaciones'); + }); + + it('convierte -z en -ces', () => { + expect(pluralizeEs('voz')).toBe('voces'); + }); + + it('agrega -es a otras consonantes', () => { + expect(pluralizeEs('Rol')).toBe('Roles'); + expect(pluralizeEs('perfil')).toBe('perfiles'); + expect(pluralizeEs('sucursal')).toBe('sucursales'); + expect(pluralizeEs('proveedor')).toBe('proveedores'); + expect(pluralizeEs('solicitud')).toBe('solicitudes'); + }); +}); + +describe('formatItemCountLabel', () => { + it('usa el singular cuando el conteo es 1', () => { + expect(formatItemCountLabel(1, 'Acción')).toBe('1 Acción'); + expect(formatItemCountLabel(1, 'Rol')).toBe('1 Rol'); + }); + + it('deriva el plural en español cuando el conteo no es 1', () => { + expect(formatItemCountLabel(0, 'Acción')).toBe('0 Acciones'); + expect(formatItemCountLabel(7, 'Acción')).toBe('7 Acciones'); + expect(formatItemCountLabel(4, 'Rol')).toBe('4 Roles'); + }); + + it('respeta el plural explícito para préstamos/irregulares', () => { + expect(formatItemCountLabel(3, 'tenant', 'tenants')).toBe('3 tenants'); + expect(formatItemCountLabel(2, 'flag', 'flags')).toBe('2 flags'); + }); +}); diff --git a/src/apps/ums.web-app/src/presentation/shared/utils/pluralize.ts b/src/apps/ums.web-app/src/presentation/shared/utils/pluralize.ts new file mode 100644 index 00000000..66870d34 --- /dev/null +++ b/src/apps/ums.web-app/src/presentation/shared/utils/pluralize.ts @@ -0,0 +1,32 @@ +/** + * pluralize.ts — pluralización en español para los contadores de las barras de + * herramientas (ListToolbar, ChildEntityToolbar, PermissionSectionToolbar). + * + * Reglas cubiertas: + * - Termina en vocal (incluidas acentuadas) → +s (módulo → módulos) + * - Termina en «-ión» → «-iones» (acción → acciones) + * - Termina en «z» → «-ces» (voz → voces) + * - Termina en otra consonante → +es (rol → roles, perfil → perfiles) + * + * Los préstamos que no siguen la regla (tenant → tenants, flag → flags) deben + * pasar su plural explícito; el helper lo respeta si se provee. + */ +export function pluralizeEs(word: string): string { + if (!word) return word; + if (/[aeiouáéíóú]$/i.test(word)) return `${word}s`; + if (/ión$/i.test(word)) return word.replace(/ión$/i, 'iones'); + if (/z$/i.test(word)) return word.replace(/z$/i, 'ces'); + return `${word}es`; +} + +/** + * Devuelve «N etiqueta» con la etiqueta en singular o plural según el conteo. + * @param count número de elementos. + * @param singular etiqueta en singular (p.ej. «Acción»). + * @param plural plural explícito para préstamos/irregulares; si se omite se + * deriva con {@link pluralizeEs}. + */ +export function formatItemCountLabel(count: number, singular: string, plural?: string): string { + const label = count === 1 ? singular : (plural ?? pluralizeEs(singular)); + return `${count} ${label}`; +} diff --git a/src/apps/ums.web-app/src/test/mocks/data/system-suites.mock.ts b/src/apps/ums.web-app/src/test/mocks/data/system-suites.mock.ts index e3b5c476..18ef737e 100644 --- a/src/apps/ums.web-app/src/test/mocks/data/system-suites.mock.ts +++ b/src/apps/ums.web-app/src/test/mocks/data/system-suites.mock.ts @@ -1,21 +1,63 @@ /** - * system-suites.mock.ts — Mock data matching AuthorizationDevDataSeeder exactly + * system-suites.mock.ts — Datos de prueba del árbol de nodos recursivo (ADR-0090) * - * IDs are generated dynamically to match the actual database seed values. - * Covers all CRUD scenarios: Active/Maintenance/Deprecated states, - * full module→menu→submenu→option hierarchies, domain resources. + * Refleja el modelo flexible Suite→Módulo→nodos (kind Menu/SubMenu/Option con + * profundidad variable y funcionalidad N:M por `actionCodes`). Los IDs son + * marcadores; el frontend depende de la respuesta REST, no de IDs fijos. + * Se usa principalmente para pruebas unitarias y Storybook. */ import type { SystemSuite, SystemSuiteDomainResource, } from '@domain/authorization/models/system-suite.model'; +import type { SystemSuiteNode } from '@domain/authorization/schemas/system-suite.schema'; // ── Constants matching CoreDevDataSeeder ──────────────────────────────────── const RANSA_TENANT_ID = '3fa85f64-5717-4562-b3fc-2c963f66afa6'; -// Note: IDs below are placeholders. The actual IDs are generated by the domain -// aggregates at seed time. The frontend should rely on the GraphQL response, -// not hardcoded IDs. These mocks are primarily for unit tests and Storybook. +// Helper para construir nodos hoja (Opción) con su vínculo N:M. +const optionNode = ( + id: string, + code: string, + label: string, + description: string, + actionCode: string, + sortOrder: number +): SystemSuiteNode => ({ + id, + parentNodeId: null, + kind: 'Option', + code, + label, + description, + status: 'Active', + sortOrder, + actionCodes: [actionCode], + metadata: null, + children: [], +}); + +const branchNode = ( + id: string, + kind: 'Menu' | 'SubMenu', + code: string, + label: string, + description: string, + sortOrder: number, + children: SystemSuiteNode[] +): SystemSuiteNode => ({ + id, + parentNodeId: null, + kind, + code, + label, + description, + status: 'Active', + sortOrder, + actionCodes: [], + metadata: null, + children, +}); // ── Suite 1: LOGISTICS_CORE ────────────────────────────────────────────────── const logisticsCoreModules = [ @@ -26,100 +68,89 @@ const logisticsCoreModules = [ description: 'Security, user management, and audit trailing modules', status: 'Active', sortOrder: 1, - menus: [ - { - id: 'users-menu-id', - code: 'USERS', - label: 'Users Administration', - description: 'Manage user accounts and details', - sortOrder: 1, - subMenus: [ - { - id: 'list-submenu-id', - code: 'LIST', - label: 'User Directory', - description: 'View and search all user accounts', - sortOrder: 1, - options: [ - { - id: 'view-users-opt-id', - code: 'VIEW_USERS', - label: 'View Users List', - description: 'Permission to view the users list', - actionCode: 'VIEW', - sortOrder: 1, - }, - { - id: 'edit-users-opt-id', - code: 'EDIT_USERS', - label: 'Edit User Profiles', - description: 'Permission to edit and modify user profiles', - actionCode: 'MANAGE', - sortOrder: 2, - }, - ], - }, - { - id: 'roles-submenu-id', - code: 'ROLES', - label: 'Roles & Permissions', - description: 'Manage access control roles and templates', - sortOrder: 2, - options: [ - { - id: 'view-roles-opt-id', - code: 'VIEW_ROLES', - label: 'View Security Roles', - description: 'Permission to view roles in system', - actionCode: 'VIEW', - sortOrder: 1, - }, - { - id: 'manage-roles-opt-id', - code: 'MANAGE_ROLES', - label: 'Configure Permissions', - description: 'Permission to edit access rights', - actionCode: 'MANAGE', - sortOrder: 2, - }, - ], - }, - ], - }, - { - id: 'audit-menu-id', - code: 'AUDIT', - label: 'Audit Trails', - description: 'System operations logging and analysis', - sortOrder: 2, - subMenus: [ - { - id: 'logs-submenu-id', - code: 'LOGS', - label: 'System Logs', - description: 'View system telemetry and user transactions', - sortOrder: 1, - options: [ - { - id: 'view-logs-opt-id', - code: 'VIEW_LOGS', - label: 'Search Audit Trail', - description: 'Permission to query audit logs', - actionCode: 'VIEW', - sortOrder: 1, - }, - { - id: 'purge-logs-opt-id', - code: 'PURGE_LOGS', - label: 'Purge Historical Data', - description: 'Permission to clear obsolete log records', - actionCode: 'APPROVE', - sortOrder: 2, - }, - ], - }, - ], - }, + nodes: [ + branchNode( + 'users-menu-id', + 'Menu', + 'USERS', + 'Users Administration', + 'Manage user accounts', + 1, + [ + branchNode( + 'list-submenu-id', + 'SubMenu', + 'LIST', + 'User Directory', + 'View and search users', + 1, + [ + optionNode( + 'view-users-opt-id', + 'VIEW_USERS', + 'View Users List', + 'View users list', + 'VIEW', + 1 + ), + optionNode( + 'edit-users-opt-id', + 'EDIT_USERS', + 'Edit User Profiles', + 'Edit user profiles', + 'MANAGE', + 2 + ), + ] + ), + branchNode( + 'roles-submenu-id', + 'SubMenu', + 'ROLES', + 'Roles & Permissions', + 'Manage roles', + 2, + [ + optionNode( + 'view-roles-opt-id', + 'VIEW_ROLES', + 'View Security Roles', + 'View roles', + 'VIEW', + 1 + ), + optionNode( + 'manage-roles-opt-id', + 'MANAGE_ROLES', + 'Configure Permissions', + 'Edit access rights', + 'MANAGE', + 2 + ), + ] + ), + ] + ), + branchNode('audit-menu-id', 'Menu', 'AUDIT', 'Audit Trails', 'System operations logging', 2, [ + branchNode('logs-submenu-id', 'SubMenu', 'LOGS', 'System Logs', 'View telemetry', 1, [ + optionNode( + 'view-logs-opt-id', + 'VIEW_LOGS', + 'Search Audit Trail', + 'Query audit logs', + 'VIEW', + 1 + ), + optionNode( + 'purge-logs-opt-id', + 'PURGE_LOGS', + 'Purge Historical Data', + 'Clear log records', + 'APPROVE', + 2 + ), + ]), + ]), ], }, { @@ -129,58 +160,61 @@ const logisticsCoreModules = [ description: 'Global properties, settings and email setup', status: 'Active', sortOrder: 2, - menus: [ - { - id: 'settings-menu-id', - code: 'SETTINGS', - label: 'Global Setup', - description: 'Configure global system variables', - sortOrder: 1, - subMenus: [ - { - id: 'params-submenu-id', - code: 'PARAMS', - label: 'App Parameters', - description: 'Configure timeouts, thresholds and limits', - sortOrder: 1, - options: [ - { - id: 'view-params-opt-id', - code: 'VIEW_PARAMS', - label: 'View Parameters', - description: 'Permission to view system options', - actionCode: 'VIEW', - sortOrder: 1, - }, - { - id: 'edit-params-opt-id', - code: 'EDIT_PARAMS', - label: 'Update Global Config', - description: 'Permission to edit critical global values', - actionCode: 'MANAGE', - sortOrder: 2, - }, - ], - }, - { - id: 'smtp-submenu-id', - code: 'SMTP', - label: 'SMTP Server Setup', - description: 'Email gateway and server connection', - sortOrder: 2, - options: [ - { - id: 'test-smtp-opt-id', - code: 'TEST_SMTP', - label: 'Test SMTP Gateway', - description: 'Permission to trigger email delivery test', - actionCode: 'APPROVE', - sortOrder: 1, - }, - ], - }, - ], - }, + nodes: [ + branchNode( + 'settings-menu-id', + 'Menu', + 'SETTINGS', + 'Global Setup', + 'Configure global variables', + 1, + [ + branchNode( + 'params-submenu-id', + 'SubMenu', + 'PARAMS', + 'App Parameters', + 'Timeouts and limits', + 1, + [ + optionNode( + 'view-params-opt-id', + 'VIEW_PARAMS', + 'View Parameters', + 'View options', + 'VIEW', + 1 + ), + optionNode( + 'edit-params-opt-id', + 'EDIT_PARAMS', + 'Update Global Config', + 'Edit global values', + 'MANAGE', + 2 + ), + ] + ), + branchNode( + 'smtp-submenu-id', + 'SubMenu', + 'SMTP', + 'SMTP Server Setup', + 'Email gateway', + 2, + [ + optionNode( + 'test-smtp-opt-id', + 'TEST_SMTP', + 'Test SMTP Gateway', + 'Trigger email test', + 'APPROVE', + 1 + ), + ] + ), + ] + ), ], }, ]; @@ -244,75 +278,71 @@ const wmsModules = [ description: 'Inventory management and levels', status: 'Active', sortOrder: 1, - menus: [ - { - id: 'stock-menu-id', - code: 'STOCK', - label: 'Stock Administration', - description: 'Stock levels and status', - sortOrder: 1, - subMenus: [ - { - id: 'levels-submenu-id', - code: 'LEVELS', - label: 'Real-time Levels', - description: 'Current physical stock status', - sortOrder: 1, - options: [ - { - id: 'view-stock-opt-id', - code: 'VIEW_STOCK', - label: 'View Stock Levels', - description: 'Permission to view real-time inventory counts', - actionCode: 'INVENTORY_VIEW', - sortOrder: 1, - }, - { - id: 'adjust-stock-opt-id', - code: 'ADJUST_STOCK', - label: 'Adjust Inventory Counts', - description: 'Permission to perform physical inventory adjustments', - actionCode: 'INVENTORY_EDIT', - sortOrder: 2, - }, - ], - }, - ], - }, - { - id: 'ops-menu-id', - code: 'OPS', - label: 'Warehouse Operations', - description: 'Stock movements and transfers', - sortOrder: 2, - subMenus: [ - { - id: 'transfers-submenu-id', - code: 'TRANSFERS', - label: 'Warehouse Transfers', - description: 'Move stock between physical locations', - sortOrder: 1, - options: [ - { - id: 'initiate-transfer-opt-id', - code: 'INITIATE_TRANSFER', - label: 'Initiate Stock Transfer', - description: 'Permission to draft and start a transfer request', - actionCode: 'INVENTORY_EDIT', - sortOrder: 1, - }, - { - id: 'approve-transfer-opt-id', - code: 'APPROVE_TRANSFER', - label: 'Approve Location Transfer', - description: 'Permission to authorize inventory relocation', - actionCode: 'INVENTORY_EDIT', - sortOrder: 2, - }, - ], - }, - ], - }, + nodes: [ + branchNode( + 'stock-menu-id', + 'Menu', + 'STOCK', + 'Stock Administration', + 'Stock levels and status', + 1, + [ + branchNode( + 'levels-submenu-id', + 'SubMenu', + 'LEVELS', + 'Real-time Levels', + 'Current stock status', + 1, + [ + optionNode( + 'view-stock-opt-id', + 'VIEW_STOCK', + 'View Stock Levels', + 'View inventory counts', + 'INVENTORY_VIEW', + 1 + ), + optionNode( + 'adjust-stock-opt-id', + 'ADJUST_STOCK', + 'Adjust Inventory Counts', + 'Physical adjustments', + 'INVENTORY_EDIT', + 2 + ), + ] + ), + ] + ), + branchNode('ops-menu-id', 'Menu', 'OPS', 'Warehouse Operations', 'Stock movements', 2, [ + branchNode( + 'transfers-submenu-id', + 'SubMenu', + 'TRANSFERS', + 'Warehouse Transfers', + 'Move stock', + 1, + [ + optionNode( + 'initiate-transfer-opt-id', + 'INITIATE_TRANSFER', + 'Initiate Stock Transfer', + 'Start a transfer', + 'INVENTORY_EDIT', + 1 + ), + optionNode( + 'approve-transfer-opt-id', + 'APPROVE_TRANSFER', + 'Approve Location Transfer', + 'Authorize relocation', + 'INVENTORY_EDIT', + 2 + ), + ] + ), + ]), ], }, ]; diff --git a/src/apps/ums.web-app/src/test/mocks/data/tenants.mock.ts b/src/apps/ums.web-app/src/test/mocks/data/tenants.mock.ts index e87dba77..99ef8ecc 100644 --- a/src/apps/ums.web-app/src/test/mocks/data/tenants.mock.ts +++ b/src/apps/ums.web-app/src/test/mocks/data/tenants.mock.ts @@ -54,19 +54,6 @@ export const mockTenants = [ isActive: true, }, ], - branding: { - logo: 'base64_ransa_logo_data', - logoFormat: 'Png', - primaryColor: '#006400', - backgroundStyle: 'SolidColor', - headlineText: 'Bienvenido a Ransa', - secondaryText: 'Ingresa tus credenciales', - primaryButtonLabel: 'Iniciar sesión', - footerText: '© 2026 Ransa Comercial', - customDomain: 'login.ransa.pe', - magicLinkFallbackEnabled: true, - dnsVerificationStatus: 'Pending', - }, }, { tenantId: 'c9b736b4-6a84-48f8-b34d-176bc5a6d542', @@ -102,19 +89,6 @@ export const mockTenants = [ isActive: true, }, ], - branding: { - logo: 'base64_neptunia_logo_data', - logoFormat: 'Png', - primaryColor: '#00008B', - backgroundStyle: 'Gradient', - headlineText: 'Portal Neptunia', - secondaryText: 'Accesos a operaciones portuarias', - primaryButtonLabel: 'Entrar', - footerText: '© 2026 Neptunia', - customDomain: 'acceso.neptunia.pe', - magicLinkFallbackEnabled: false, - dnsVerificationStatus: 'Pending', - }, }, { tenantId: 'a3f5b9d2-7c3d-4c8e-a9b0-123456789abc', @@ -134,7 +108,6 @@ export const mockTenants = [ }, ], identityProviders: [], - branding: null, }, { tenantId: '9e8d7c6b-5a4f-3e2d-1c0b-9876543210fe', @@ -161,12 +134,11 @@ export const mockTenants = [ }, ], identityProviders: [], - branding: null, }, { tenantId: '5f4e3d2c-1b0a-9f8e-7d6c-543210987654', - code: 'UNIMAR', - name: 'Unimar S.A. — Lima', + code: 'BEYONDNET', + name: 'BeyondNet S.A.C. — Lima', type: 'Supplier', status: 'Active', companyReference: '20101523381', @@ -181,14 +153,13 @@ export const mockTenants = [ }, { branchId: 'b1000001-0000-4000-8000-000000000002', - code: 'UNI_CALLAO_OP', + code: 'BN_CALLAO_OP', name: 'Operaciones Callao — Jr. Colón', isActive: true, geofencingMetadata: null, }, ], identityProviders: [], - branding: null, }, { tenantId: 'f3e2d1c0-b9a8-7f6e-5d4c-321098765432', @@ -215,6 +186,5 @@ export const mockTenants = [ }, ], identityProviders: [], - branding: null, }, ]; diff --git a/src/apps/ums.web-app/src/test/mocks/data/user-accounts.mock.ts b/src/apps/ums.web-app/src/test/mocks/data/user-accounts.mock.ts index ce43c52b..ca7ef022 100644 --- a/src/apps/ums.web-app/src/test/mocks/data/user-accounts.mock.ts +++ b/src/apps/ums.web-app/src/test/mocks/data/user-accounts.mock.ts @@ -1,7 +1,7 @@ /** * user-accounts.mock.ts — Mock data matching IdentityDevDataSeeder exactly * - * 20 users across 4 tenants (RANSA, NEPTUNIA, APM, UNIMAR). + * 20 users across 4 tenants (RANSA, NEPTUNIA, APM, BEYONDNET). * Covers all status scenarios: Active, Pending, Blocked. * Covers all categories: Internal, External, Partner. */ @@ -145,11 +145,11 @@ export const mockUserAccounts = { profileId: null, identityReference: 'DNI-44556677', }, - // ─ UNIMAR (5f4e3d2c-...) ────────────────────────────────────────────── + // ─ BEYONDNET (5f4e3d2c-...) ────────────────────────────────────────────── { userAccountId: '5f4e3d01-1b0a-9f8e-7d6c-543210987654', tenantId: '5f4e3d2c-1b0a-9f8e-7d6c-543210987654', - email: 'gerente.operaciones@unimar.com.pe', + email: 'gerente.operaciones@beyondnet.com.pe', category: 'Internal', status: 'Active', profileId: null, @@ -158,7 +158,7 @@ export const mockUserAccounts = { { userAccountId: '5f4e3d02-1b0a-9f8e-7d6c-543210987654', tenantId: '5f4e3d2c-1b0a-9f8e-7d6c-543210987654', - email: 'analista.inventario@unimar.com.pe', + email: 'analista.inventario@beyondnet.com.pe', category: 'Internal', status: 'Active', profileId: null, @@ -167,7 +167,7 @@ export const mockUserAccounts = { { userAccountId: '5f4e3d03-1b0a-9f8e-7d6c-543210987654', tenantId: '5f4e3d2c-1b0a-9f8e-7d6c-543210987654', - email: 'coordinador.flota@unimar.com.pe', + email: 'coordinador.flota@beyondnet.com.pe', category: 'External', status: 'Pending', profileId: null, @@ -176,7 +176,7 @@ export const mockUserAccounts = { { userAccountId: '5f4e3d04-1b0a-9f8e-7d6c-543210987654', tenantId: '5f4e3d2c-1b0a-9f8e-7d6c-543210987654', - email: 'ex.empleado@unimar.com.pe', + email: 'ex.empleado@beyondnet.com.pe', category: 'External', status: 'Blocked', profileId: null, diff --git a/src/apps/ums.web-app/src/test/mocks/handlers.ts b/src/apps/ums.web-app/src/test/mocks/handlers.ts index ffdea53c..312f940a 100644 --- a/src/apps/ums.web-app/src/test/mocks/handlers.ts +++ b/src/apps/ums.web-app/src/test/mocks/handlers.ts @@ -1,8 +1,4 @@ -import { http, graphql, HttpResponse } from 'msw'; -import { mockTenants } from './data/tenants.mock'; -import { mockUserAccounts } from './data/user-accounts.mock'; -import { mockDelegations } from './data/delegations.mock'; -import { mockSystemSuites } from './data/system-suites.mock'; +import { http, HttpResponse } from 'msw'; export const handlers = [ // Example REST interception @@ -15,153 +11,4 @@ export const handlers = [ status: 'Active', }); }), - - // Example GraphQL interception - graphql.query('Tenants', () => { - return HttpResponse.json({ - data: { - getTenants: { - items: mockTenants, - totalItems: mockTenants.length, - totalPages: 1, - page: 1, - pageSize: 20, - }, - }, - }); - }), - - graphql.query('UserAccounts', () => { - return HttpResponse.json({ - data: { - getUserAccounts: { - items: mockUserAccounts.items, - totalItems: mockUserAccounts.totalCount, - totalPages: 1, - page: 1, - pageSize: 20, - }, - }, - }); - }), - - graphql.query('DelegationsByDelegatedAdmin', () => { - return HttpResponse.json({ - data: { - getDelegationsByDelegatedAdmin: mockDelegations, - }, - }); - }), - - graphql.query('DelegationsByDelegatingAdmin', () => { - return HttpResponse.json({ - data: { - getDelegationsByDelegatingAdmin: mockDelegations, - }, - }); - }), - - graphql.query('Tenant', ({ variables }) => { - const { tenantId } = variables; - const tenant = mockTenants.find(t => t.tenantId === tenantId) || mockTenants[0]; - return HttpResponse.json({ - data: { - getTenantById: tenant, - }, - }); - }), - - graphql.query('TenantBranches', ({ variables }) => { - const { tenantId } = variables; - const tenant = mockTenants.find(t => t.tenantId === tenantId) || mockTenants[0]; - return HttpResponse.json({ - data: { - getTenantBranches: tenant.branches || [], - }, - }); - }), - - graphql.query('UserAccount', ({ variables }) => { - const { userAccountId } = variables; - const account = - mockUserAccounts.items.find(u => u.userAccountId === userAccountId) || - mockUserAccounts.items[0]; - return HttpResponse.json({ - data: { - getUserAccountById: account, - }, - }); - }), - - graphql.query('DelegationById', ({ variables }) => { - const { delegationId } = variables; - const delegation = - mockDelegations.find(d => d.delegationId === delegationId) || mockDelegations[0]; - return HttpResponse.json({ - data: { - getDelegationById: delegation, - }, - }); - }), - - graphql.query('IdentityProviders', ({ variables }) => { - const { tenantId } = variables; - const tenant = mockTenants.find(t => t.tenantId === tenantId) || mockTenants[0]; - return HttpResponse.json({ - data: { - getTenantIdentityProviders: tenant.identityProviders || [], - }, - }); - }), - - graphql.query('Branding', ({ variables }) => { - const { tenantId } = variables; - const tenant = mockTenants.find(t => t.tenantId === tenantId) || mockTenants[0]; - return HttpResponse.json({ - data: { - getTenantBranding: tenant.branding || null, - }, - }); - }), - - // ── Authorization / SystemSuite ──────────────────────────────────────────── - graphql.query('SystemSuites', ({ variables }) => { - const { page = 1, pageSize = 20, status = 'all' } = variables || {}; - let items = mockSystemSuites; - if (status && status !== 'all') { - items = items.filter(s => s.status === status); - } - const totalItems = items.length; - const totalPages = Math.ceil(totalItems / pageSize); - const pagedItems = items.slice((page - 1) * pageSize, page * pageSize); - return HttpResponse.json({ - data: { - getSystemSuites: { - items: pagedItems, - page, - pageSize, - totalItems, - totalPages, - }, - }, - }); - }), - - graphql.query('SystemSuite', ({ variables }) => { - const { systemSuiteId } = variables; - const suite = - mockSystemSuites.find(s => s.systemSuiteId === systemSuiteId) || mockSystemSuites[0]; - return HttpResponse.json({ - data: { - getSystemSuiteById: suite, - }, - }); - }), - - // Catch-all for unmocked GraphQL queries to prevent 500 errors in Dev mode - http.post('*/graphql', () => { - return HttpResponse.json({ - data: {}, - }); - }), ]; diff --git a/src/apps/ums.web-app/tailwind.config.js b/src/apps/ums.web-app/tailwind.config.js index 48ac0742..199030c3 100644 --- a/src/apps/ums.web-app/tailwind.config.js +++ b/src/apps/ums.web-app/tailwind.config.js @@ -5,18 +5,33 @@ export default { theme: { extend: { colors: { + // BEYONDNET corporate navy ramp (anchored on brand #0f3e67 / footer #042139) brand: { - 50: '#eef2ff', - 100: '#e0e7ff', - 200: '#c7d2fe', - 300: '#a5b4fc', - 400: '#818cf8', - 500: '#6366f1', - 600: '#4f46e5', - 700: '#4338ca', - 800: '#3730a3', - 900: '#312e81', - 950: '#1e1b4b', + 50: '#f0f5fa', + 100: '#dbe7f2', + 200: '#b8cfe4', + 300: '#8db0d1', + 400: '#5d8bb8', + 500: '#3a6a9c', + 600: '#1f5080', + 700: '#0f3e67', + 800: '#0c3253', + 900: '#0a2843', + 950: '#042139', + }, + // BEYONDNET green accent (#41a62a) as a standalone Tailwind color + accent: { + 50: '#eff8ec', + 100: '#d6eecd', + 200: '#b0dfa0', + 300: '#83cc6d', + 400: '#5cb844', + 500: '#41a62a', + 600: '#33851f', + 700: '#29661b', + 800: '#22511a', + 900: '#1d4318', + 950: '#0c2609', }, dark: { 50: '#f8fafc', diff --git a/src/apps/ums.web-app/tests/app-configuration-state.spec.ts b/src/apps/ums.web-app/tests/app-configuration-state.spec.ts new file mode 100644 index 00000000..3ac3fdb2 --- /dev/null +++ b/src/apps/ums.web-app/tests/app-configuration-state.spec.ts @@ -0,0 +1,214 @@ +import { test, expect, type Page, type APIResponse } from '@playwright/test'; + +/** + * Configuraciones de Aplicación (AppConfiguration) · ciclo de vida — Matriz de Cobertura de UI, + * sección C (ver `reference/qa/ui-coverage-matrix.md`). + * + * Certifica el fix [G-143]: existe `DELETE /app-configurations/{id}` (agregado hoja, sin guarda de + * dependencias → 204). Pantalla `AppConfigurationDashboardScreen`. Ahora que [G-144] arregló el + * picker (`ParameterDefinitionPickerDialog` usa `parameterCatalogService.getAll(...)`), el alta por + * UI carga parámetros y el clic del usuario SÍ llega al backend. + * + * Casos: + * 1. HAPPY · crear por UI (certifica G-144 + G-143) · «Agregar» → picker de parámetros (ya poblado + * por G-144) → seleccionar una Definición Global&Tenant con valor por defecto y SIN + * AppConfiguration Global previa (`MFA_REQUIRED_FOR_ADMIN`) → «Add» → **POST 201** (la config + * Global se crea en el backend). Luego se localiza esa config Global por búsqueda y se elimina + * por UI → **DELETE 204**, y desaparece de la lista. + * 2. LIFECYCLE · Draft → Publicar → Archivar sobre una config fixture, certificando que los + * controles del detalle reflejan el estado REAL (badge `data-status`) sin flip optimista; se + * limpia con Eliminar al final (idempotente). + * + * ── ⚠ Bug #3 en el ALTA por UI (NO es G-143/144/145; ver reporte) ─────────────────── + * El clic «Add» dispara `POST /app-configurations` que responde **201** con `{ appConfigurationId }` + * (backend `CreateAppConfigurationResponse` sólo trae el id). Pero el servicio del frontend + * (`app-configuration.service.ts` · `createAppConfiguration`) valida la respuesta con + * `CreateAppConfigurationResponseSchema` (`app-configuration.schema.ts`), que exige además `code` + * (string) → el parseo LANZA (`invalid_type` en `code`). Consecuencia: la config SÍ se crea (201) + * pero la UI muestra un toast de ERROR y NO auto-selecciona ni notifica éxito. Por eso el caso 1 NO + * asevera toast de éxito ni auto-select tras «Add»; certifica el 201 y luego localiza+elimina la + * config por UI (que es el fix G-143 bajo prueba). + * + * Idempotencia: el caso 1 pre-limpia cualquier config Global residual con ese código (por si una + * corrida previa se interrumpió) y siempre elimina lo que crea; el caso 2 usa un código sintético + * único por corrida. Auth por cookie de sesión (JWT); `page.request` comparte cookies del contexto. + */ +const ADMIN = { email: 'admin@beyondnet.com.pe', password: 'BeyondNet.Dev.2026' }; +/** DEFAULT_TENANT_ID del frontend (request-context.ts); el backend deriva IsInternalAdmin del JWT. */ +const TENANT_ID = '5f4e3d2c-1b0a-9f8e-7d6c-543210987654'; + +/** + * Definición sembrada scope Global&Tenant, valor por defecto ("false") y SIN AppConfiguration Global + * sembrada (sólo por inquilino) → crear su config Global es determinista y no colisiona. + */ +const PICK_PARAM_NAME = 'MFA Required for Admin'; +const EXPECTED_CODE = 'MFA_REQUIRED_FOR_ADMIN'; + +const uniqueCode = () => `E2E_APPCFG_${Date.now()}`; + +const addBtn = (page: Page) => page.locator('button[title="Agregar"]').first(); +const pickerSearch = (page: Page) => page.locator('input[placeholder="Search parameters..."]'); +const listSearch = (page: Page) => page.locator('[data-testid="list-search"]'); +const searchSubmitBtn = (page: Page) => page.getByRole('button', { name: /^buscar$/i }); +const detailDeleteBtn = (page: Page) => page.locator('button[aria-label="Eliminar"]').first(); +const confirmDialog = (page: Page) => page.getByRole('dialog'); +const rowByText = (page: Page, text: string) => + page.locator('[data-testid="entity-row"]').filter({ hasText: text }); +const statusBadge = (page: Page, status: string) => + page.locator(`[data-testid="status-badge"][data-status="${status}"]`).first(); + +function apiHeaders(cookies: { name: string; value: string }[]): Record { + const headers: Record = { 'X-Tenant-Id': TENANT_ID }; + const xsrf = cookies.find(c => c.name === 'XSRF-TOKEN')?.value; + if (xsrf) headers['X-CSRF-Token'] = xsrf; + return headers; +} + +/** Crea una AppConfiguration Global (Draft) vía API como fixture. */ +async function createConfigViaApi(page: Page, code: string): Promise { + const headers = apiHeaders(await page.context().cookies()); + const resp: APIResponse = await page.request.post('/api/v1/app-configurations', { + headers, + data: { code, value: 'e2e', description: 'Fixture E2E G-143' }, + }); + expect(resp.status(), await resp.text()).toBe(201); +} + +/** Idempotencia defensiva: borra por API cualquier AppConfiguration Global con ese código (residuo). */ +async function purgeGlobalConfig(page: Page, code: string): Promise { + const headers = apiHeaders(await page.context().cookies()); + const resp = await page.request.get( + `/api/v1/app-configurations?page=1&pageSize=50&search=${encodeURIComponent(code)}`, + { headers } + ); + if (!resp.ok()) return; + const body = await resp.json(); + for (const c of body.items ?? []) { + if (c.code === code && c.scope === 'Global') { + await page.request.delete(`/api/v1/app-configurations/${c.appConfigurationId}`, { headers }); + } + } +} + +/** Busca por código y abre la configuración en el detalle. */ +async function selectByCode(page: Page, code: string): Promise { + await listSearch(page).fill(code); + await searchSubmitBtn(page).click(); + const row = rowByText(page, code).first(); + await expect(row).toBeVisible({ timeout: 15000 }); + await row.click(); + await expect(detailDeleteBtn(page)).toBeVisible({ timeout: 15000 }); // detalle cargado +} + +/** Elimina la configuración seleccionada por la UI y devuelve el status HTTP del DELETE. */ +async function deleteSelected(page: Page): Promise { + await detailDeleteBtn(page).click(); + await expect(confirmDialog(page)).toBeVisible({ timeout: 15000 }); + const delResp = page.waitForResponse( + r => r.url().includes('/app-configurations/') && r.request().method() === 'DELETE', + { timeout: 20000 } + ); + await confirmDialog(page) + .getByRole('button', { name: /^eliminar$/i }) + .click(); + const resp = await delResp; + return resp.status(); +} + +test.describe('Configuraciones de Aplicación · ciclo de vida', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/login'); + await page.getByLabel(/correo electrónico/i).fill(ADMIN.email); + await page.getByLabel(/contraseña/i).fill(ADMIN.password); + await page.getByRole('button', { name: /ingresar/i }).click(); + await expect(page).toHaveURL(/\/tenants/); + + await page.goto('/app-configurations'); + await expect(page).toHaveURL(/\/app-configurations/); + }); + + test('HAPPY · crear por UI (picker) y eliminar (DELETE 204) una config Global (G-144 + G-143)', async ({ + page, + }) => { + await purgeGlobalConfig(page, EXPECTED_CODE); // idempotencia: limpiar residuo previo + + // ── Crear por UI: «Agregar» → picker de parámetros (poblado por G-144) ────── + await addBtn(page).click(); + await expect(pickerSearch(page)).toBeVisible({ timeout: 15000 }); + await pickerSearch(page).fill('MFA'); // acota la lista del picker (getAll con search) + + const paramRow = page.getByRole('button', { name: new RegExp(PICK_PARAM_NAME, 'i') }); + await expect(paramRow).toBeVisible({ timeout: 15000 }); + await paramRow.click(); + + const createResp = page.waitForResponse( + r => r.url().includes('/app-configurations') && r.request().method() === 'POST', + { timeout: 20000 } + ); + await page.getByRole('button', { name: /^add(\s*\(\d+\))?$/i }).click(); + // El clic del usuario crea la config Global en el backend (201) y, tras corregir [G-147] + // (el schema del alta exigía `code`, ausente en la respuesta → el parseo lanzaba y salía un + // toast de ERROR pese al 201), la UI ahora notifica ÉXITO sin falso error. + expect((await createResp).status()).toBe(201); + await expect( + page.locator('[data-testid="toast"][data-toast-type="success"]').first() + ).toBeVisible({ timeout: 15000 }); + + // ── Localizar la config Global creada y eliminarla por UI (certifica G-143) ─ + await listSearch(page).fill(EXPECTED_CODE); + await searchSubmitBtn(page).click(); + const globalRow = page + .locator('[data-testid="entity-row"]') + .filter({ hasText: EXPECTED_CODE }) + .filter({ hasText: 'Global' }) + .first(); + await expect(globalRow).toBeVisible({ timeout: 15000 }); + await globalRow.click(); + await expect(detailDeleteBtn(page)).toBeVisible({ timeout: 15000 }); + + const status = await deleteSelected(page); + expect(status).toBe(204); + + // Desaparece: la fila Global con ese código ya no existe (las de inquilino permanecen intactas). + await expect( + page + .locator('[data-testid="entity-row"]') + .filter({ hasText: EXPECTED_CODE }) + .filter({ hasText: 'Global' }) + ).toHaveCount(0, { timeout: 15000 }); + }); + + test('LIFECYCLE · Draft → Publicar → Archivar refleja el estado real; limpieza por Eliminar', async ({ + page, + }) => { + const code = uniqueCode(); + await createConfigViaApi(page, code); + await selectByCode(page, code); + + // Nace en Draft. + await expect(statusBadge(page, 'Draft')).toBeVisible({ timeout: 15000 }); + + // Draft → Publicar (el botón sólo aparece en Draft; el badge refleja el estado persistido). + const pubResp = page.waitForResponse( + r => r.url().includes('/publish') && r.request().method() === 'POST', + { timeout: 20000 } + ); + await page.getByRole('button', { name: /^publicar$/i }).click(); + expect((await pubResp).status()).toBe(204); + await expect(statusBadge(page, 'Published')).toBeVisible({ timeout: 15000 }); + + // Published → Archivar. + const arcResp = page.waitForResponse( + r => r.url().includes('/archive') && r.request().method() === 'POST', + { timeout: 20000 } + ); + await page.getByRole('button', { name: /^archivar$/i }).click(); + expect((await arcResp).status()).toBe(204); + await expect(statusBadge(page, 'Archived')).toBeVisible({ timeout: 15000 }); + + // Limpieza: eliminar la configuración archivada (G-143 no tiene guarda de estado). + const status = await deleteSelected(page); + expect(status).toBe(204); + await expect(rowByText(page, code)).toHaveCount(0, { timeout: 15000 }); + }); +}); diff --git a/src/apps/ums.web-app/tests/auth.spec.ts b/src/apps/ums.web-app/tests/auth.spec.ts index 674816b9..5aa36190 100644 --- a/src/apps/ums.web-app/tests/auth.spec.ts +++ b/src/apps/ums.web-app/tests/auth.spec.ts @@ -32,8 +32,8 @@ test.describe('Authentication Flow', () => { }); test('should login successfully with valid credentials', async ({ page }) => { - await page.getByLabel(/correo electrónico/i).fill('admin@ums.local'); - await page.getByLabel(/contraseña/i).fill('Admin@123'); + await page.getByLabel(/correo electrónico/i).fill('admin@beyondnet.com.pe'); + await page.getByLabel(/contraseña/i).fill('BeyondNet.Dev.2026'); await page.getByRole('button', { name: /ingresar/i }).click(); await page.waitForURL(/\/tenants/); }); @@ -53,18 +53,26 @@ test.describe('Authentication Flow', () => { test('should redirect to originally requested page after login', async ({ page }) => { await page.goto('/users'); await page.waitForURL('**/login', { timeout: 10000 }); - await page.getByLabel(/correo electrónico/i).fill('admin@ums.local'); - await page.getByLabel(/contraseña/i).fill('Admin@123'); + await page.getByLabel(/correo electrónico/i).fill('admin@beyondnet.com.pe'); + await page.getByLabel(/contraseña/i).fill('BeyondNet.Dev.2026'); await page.getByRole('button', { name: /ingresar/i }).click(); await page.waitForURL('**/users', { timeout: 10000 }); }); test('should lock account after 5 failed attempts', async ({ page }) => { + // Se espera la RESPUESTA de cada intento, no 100 ms de reloj. La espera fija era una carrera: + // el bloqueo exige que el servidor haya CONTADO los cinco intentos, y con el backend frio o + // cargado una respuesta puede tardar mas de 100 ms — entonces el siguiente clic sale antes de + // que el anterior se registre, se cuentan menos de cinco y el mensaje de bloqueo no aparece. + // Falla por lentitud, no por defecto, que es la definicion de prueba que parpadea. for (let i = 0; i < 5; i++) { await page.getByLabel(/correo electrónico/i).fill('invalid_user'); await page.getByLabel(/contraseña/i).fill('wrong_password'); + const intentoRegistrado = page.waitForResponse( + r => r.url().includes('/api/v1/auth/login') && r.request().method() === 'POST' + ); await page.getByRole('button', { name: /ingresar/i }).click(); - await page.waitForTimeout(100); + await intentoRegistrado; } await expect(page.getByText(/demasiados intentos/i)).toBeVisible(); await expect(page.getByRole('button', { name: /bloqueado/i })).toBeVisible(); @@ -74,8 +82,8 @@ test.describe('Authentication Flow', () => { test.describe('Logout Flow', () => { test('should logout and redirect to login', async ({ page }) => { await page.goto('/login'); - await page.getByLabel(/correo electrónico/i).fill('admin@ums.local'); - await page.getByLabel(/contraseña/i).fill('Admin@123'); + await page.getByLabel(/correo electrónico/i).fill('admin@beyondnet.com.pe'); + await page.getByLabel(/contraseña/i).fill('BeyondNet.Dev.2026'); await page.getByRole('button', { name: /ingresar/i }).click(); await expect(page).toHaveURL(/\/tenants/); @@ -90,8 +98,8 @@ test.describe('Logout Flow', () => { test('should clear session after logout', async ({ page }) => { await page.goto('/login'); - await page.getByLabel(/correo electrónico/i).fill('admin@ums.local'); - await page.getByLabel(/contraseña/i).fill('Admin@123'); + await page.getByLabel(/correo electrónico/i).fill('admin@beyondnet.com.pe'); + await page.getByLabel(/contraseña/i).fill('BeyondNet.Dev.2026'); await page.getByRole('button', { name: /ingresar/i }).click(); await expect(page).toHaveURL(/\/tenants/); diff --git a/src/apps/ums.web-app/tests/authorization-ui.spec.ts b/src/apps/ums.web-app/tests/authorization-ui.spec.ts index f1dfa1eb..f0e4e85f 100644 --- a/src/apps/ums.web-app/tests/authorization-ui.spec.ts +++ b/src/apps/ums.web-app/tests/authorization-ui.spec.ts @@ -3,28 +3,32 @@ import { test, expect } from '@playwright/test'; test.describe('Dynamic Authorization UI Tests', () => { test('Admin user should have Agregar button enabled', async ({ page }) => { await page.goto('/login'); - await page.getByLabel(/correo electrónico/i).fill('admin@ums.local'); - await page.getByLabel(/contraseña/i).fill('Admin@123'); + // Admin Root de BEYONDNET (DEV_PERSONAS); el selector ya arranca en BEYONDNET. + await page.getByLabel(/correo electrónico/i).fill('admin@beyondnet.com.pe'); + await page.getByLabel(/contraseña/i).fill('BeyondNet.Dev.2026'); await page.click('button[type="submit"]'); // Wait for tenants await page.waitForURL('**/tenants', { timeout: 10000 }); - // Check if the add button is visible and enabled - const addButton = page.locator('button[title="Agregar"]'); + // La barra de listado renderiza más de un botón "Agregar" (toolbar + vacío): + // basta con verificar el primero. + const addButton = page.locator('button[title="Agregar"]').first(); await expect(addButton).toBeVisible(); await expect(addButton).not.toBeDisabled(); }); - test('Tenant Supervisor should see Access Denied on Tenants page', async ({ page }) => { + test('Client user should see Access Denied on Tenants page', async ({ page }) => { await page.goto('/login'); - await page - .getByText(/INTERNAL_ADMIN/i) - .first() - .click(); - await page.getByText(/Ransa Comercial S.A./i).click(); - await page.getByLabel(/correo electrónico/i).fill('gerente.operaciones@ransa.pe'); - await page.getByLabel(/contraseña/i).fill('Admin@123'); + + // El TenantSelect es un dropdown propio (trigger + buscador), no un : [0] Tipo, [1] Suite del Sistema. index 1 del suite + // salta el placeholder «— Seleccionar —» y toma la primera suite real. + await page.locator('#feature-flag-form').getByRole('combobox').nth(1).selectOption({ index: 1 }); + await page.getByRole('button', { name: /^crear$/i }).click(); + + // (1) Éxito confirmado por el servidor. + await expect(toast(page, 'success')).toBeVisible({ timeout: 15000 }); + // (2) El drawer se cierra (su scrim no debe tapar el panel de detalle). + await expect(page.getByText(/nuevo feature flag/i)).toBeHidden({ timeout: 10000 }); + // (3) El flag recién creado queda auto-seleccionado (dashboard.onSuccess → setSelectedId) → + // su panel aparece en estado Inactive con «Activar». + await expect(activarBtn(page)).toBeVisible({ timeout: 15000 }); + + return code; +} + +test.describe('Feature Flag · estado y criterios (happy + error)', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/login'); + await page.getByLabel(/correo electrónico/i).fill(ADMIN.email); + await page.getByLabel(/contraseña/i).fill(ADMIN.password); + await page.getByRole('button', { name: /ingresar/i }).click(); + await expect(page).toHaveURL(/\/tenants/); + + // Navegar a la pantalla de Feature Flags (riel → «Feature Flags»). + await page.goto('/feature-flags'); + await expect(page.locator('button[title="Agregar"]').first()).toBeVisible({ timeout: 15000 }); + }); + + test('HAPPY · crear un flag lo deja Inactive y visible en la lista (list-search + status-badge)', async ({ + page, + }) => { + const code = await createFlag(page); + + // El detalle refleja el estado real persistido (no un placeholder): Inactive + acciones Draft. + await expect(page.getByText(/estado:\s*inactive/i)).toBeVisible(); + await expect(archivarBtn(page)).toBeVisible(); + + // Verificación cruzada por la LISTA con los testids compartidos: buscar el código y asertar + // que la fila muestra el badge de estado real «Inactive». + await page.locator('[data-testid="list-search"]').fill(code); + // La búsqueda de la lista es del lado servidor: se dispara con el botón «Buscar», no con Enter. + await page + .getByRole('button', { name: /^buscar$/i }) + .last() + .click(); + + const row = page.locator('[data-testid="entity-row"]').filter({ hasText: code }); + await expect(row).toBeVisible({ timeout: 15000 }); + await expect( + row.locator('[data-testid="status-badge"][data-status="Inactive"]').first() + ).toBeVisible(); + }); + + test('HAPPY · Activar y luego Desactivar refleja el estado real (Inactive→Active→Inactive)', async ({ + page, + }) => { + await createFlag(page); + + // Inactive → Active + await activarBtn(page).click(); + await expect(toast(page, 'success')).toBeVisible({ timeout: 15000 }); + // El detalle se re-pide al servidor tras el onSuccess → estado REAL Active, botón «Desactivar». + await expect(page.getByText(/estado:\s*active/i)).toBeVisible({ timeout: 15000 }); + await expect(desactivarBtn(page)).toBeVisible(); + await expect(activarBtn(page)).toHaveCount(0); + + // Active → Inactive + await desactivarBtn(page).click(); + // Desactivar emite un toast de tipo `info` (useDeactivateFlag). + await expect(toast(page, 'info')).toBeVisible({ timeout: 15000 }); + await expect(page.getByText(/estado:\s*inactive/i)).toBeVisible({ timeout: 15000 }); + await expect(activarBtn(page)).toBeVisible(); + await expect(desactivarBtn(page)).toHaveCount(0); + }); + + test('HAPPY · Archivar un flag Inactive lo lleva al estado terminal (flag peligroso)', async ({ + page, + }) => { + await createFlag(page); + + await archivarBtn(page).click(); + // Archivar emite un toast de tipo `warning` (useArchiveFlag). + await expect(toast(page, 'warning')).toBeVisible({ timeout: 15000 }); + + // Estado terminal: «Archived», leyenda de acción terminal y NINGÚN botón de transición. + await expect(page.getByText(/estado:\s*archived/i)).toBeVisible({ timeout: 15000 }); + await expect(page.getByText(/archivado\s*—\s*acción terminal/i)).toBeVisible(); + await expect(activarBtn(page)).toHaveCount(0); + await expect(desactivarBtn(page)).toHaveCount(0); + await expect(archivarBtn(page)).toHaveCount(0); + }); + + test('HAPPY · añadir y quitar un criterio en un flag Draft', async ({ page }) => { + await createFlag(page); + + // Estado inicial: sin criterios. + await expect(page.getByText(/criterios de evaluación\s*\(0\)/i)).toBeVisible(); + + // Añadir un criterio (tipo/operador por defecto: TenantId / Equals). + const criteriaValue = `crit-${Date.now()}`; + await page.getByLabel(/^valor$/i).fill(criteriaValue); + // El único