From 7d4dc4d2bb75f873f6320f2362462c3c87b38e96 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 21:43:38 -0300 Subject: [PATCH] feat(api): PUT /api/v1/users/{id}/permisos/overrides + excepciones domain + ExceptionFilter [UDT-009] --- .../Controllers/UsuariosController.cs | 29 +++++++ src/api/SIGCM2.Api/Filters/ExceptionFilter.cs | 29 +++++++ .../SIGCM2.Application/DependencyInjection.cs | 1 + .../UpdateUsuarioPermisosOverridesCommand.cs | 7 ++ ...eUsuarioPermisosOverridesCommandHandler.cs | 81 +++++++++++++++++++ .../Exceptions/GrantDenyOverlapException.cs | 16 ++++ .../InvalidPermisoCodesException.cs | 16 ++++ 7 files changed, 179 insertions(+) create mode 100644 src/api/SIGCM2.Application/Usuarios/Permisos/UpdateUsuarioPermisosOverridesCommand.cs create mode 100644 src/api/SIGCM2.Application/Usuarios/Permisos/UpdateUsuarioPermisosOverridesCommandHandler.cs create mode 100644 src/api/SIGCM2.Domain/Exceptions/GrantDenyOverlapException.cs create mode 100644 src/api/SIGCM2.Domain/Exceptions/InvalidPermisoCodesException.cs diff --git a/src/api/SIGCM2.Api/Controllers/UsuariosController.cs b/src/api/SIGCM2.Api/Controllers/UsuariosController.cs index d86e562..15d27fd 100644 --- a/src/api/SIGCM2.Api/Controllers/UsuariosController.cs +++ b/src/api/SIGCM2.Api/Controllers/UsuariosController.cs @@ -246,6 +246,30 @@ public sealed class UsuariosController : ControllerBase return Ok(MapToPermisosResponse(result)); } + /// + /// Replaces the grant/deny override sets for a usuario. + /// Requires administracion:usuarios:gestionar. + /// + [HttpPut("{id:int}/permisos/overrides")] + [RequirePermission("administracion:usuarios:gestionar")] + [ProducesResponseType(typeof(UsuarioPermisosResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task UpdatePermisosOverrides( + [FromRoute] int id, + [FromBody] UpdatePermisosOverridesRequest request) + { + var command = new UpdateUsuarioPermisosOverridesCommand( + Id: id, + Grant: request.Grant ?? [], + Deny: request.Deny ?? []); + + var result = await _dispatcher.Send(command); + return Ok(MapToPermisosResponse(result)); + } + private static UsuarioPermisosResponse MapToPermisosResponse(UsuarioPermisosDto dto) => new( RolPermisos: dto.RolPermisos, @@ -266,6 +290,11 @@ public sealed record PermisosOverridesShape( IReadOnlyList Grant, IReadOnlyList Deny); +/// UDT-009: PUT permisos/overrides request body. +public sealed record UpdatePermisosOverridesRequest( + IReadOnlyList? Grant, + IReadOnlyList? Deny); + /// Create user request body — nullable to catch missing field scenarios. public sealed record CreateUsuarioRequest( string? Username, diff --git a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs index f583aed..f566bf3 100644 --- a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs +++ b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs @@ -169,6 +169,35 @@ public sealed class ExceptionFilter : IExceptionFilter context.ExceptionHandled = true; break; + // UDT-009: permiso override validation errors + case InvalidPermisoCodesException ipce: + context.Result = new ObjectResult(new Microsoft.AspNetCore.Mvc.ProblemDetails + { + Type = "about:blank", + Title = "invalid-permiso-codes", + Status = 400, + Extensions = { ["invalidCodes"] = ipce.InvalidCodes } + }) + { + StatusCode = StatusCodes.Status400BadRequest + }; + context.ExceptionHandled = true; + break; + + case GrantDenyOverlapException gdoe: + context.Result = new ObjectResult(new Microsoft.AspNetCore.Mvc.ProblemDetails + { + Type = "about:blank", + Title = "grant-deny-overlap", + Status = 400, + Extensions = { ["overlap"] = gdoe.Overlap } + }) + { + StatusCode = StatusCodes.Status400BadRequest + }; + context.ExceptionHandled = true; + break; + case ValidationException validationEx: var errors = validationEx.Errors .GroupBy(e => e.PropertyName) diff --git a/src/api/SIGCM2.Application/DependencyInjection.cs b/src/api/SIGCM2.Application/DependencyInjection.cs index 6c5db6a..d71d9c2 100644 --- a/src/api/SIGCM2.Application/DependencyInjection.cs +++ b/src/api/SIGCM2.Application/DependencyInjection.cs @@ -60,6 +60,7 @@ public static class DependencyInjection // Usuarios/Permisos (UDT-009) services.AddScoped, GetUsuarioPermisosQueryHandler>(); + services.AddScoped, UpdateUsuarioPermisosOverridesCommandHandler>(); // FluentValidation validators (scans entire Application assembly) services.AddValidatorsFromAssemblyContaining(); diff --git a/src/api/SIGCM2.Application/Usuarios/Permisos/UpdateUsuarioPermisosOverridesCommand.cs b/src/api/SIGCM2.Application/Usuarios/Permisos/UpdateUsuarioPermisosOverridesCommand.cs new file mode 100644 index 0000000..bd4953f --- /dev/null +++ b/src/api/SIGCM2.Application/Usuarios/Permisos/UpdateUsuarioPermisosOverridesCommand.cs @@ -0,0 +1,7 @@ +namespace SIGCM2.Application.Usuarios.Permisos; + +/// UDT-009: Command to replace the grant/deny override sets for a usuario. +public sealed record UpdateUsuarioPermisosOverridesCommand( + int Id, + IReadOnlyList Grant, + IReadOnlyList Deny); diff --git a/src/api/SIGCM2.Application/Usuarios/Permisos/UpdateUsuarioPermisosOverridesCommandHandler.cs b/src/api/SIGCM2.Application/Usuarios/Permisos/UpdateUsuarioPermisosOverridesCommandHandler.cs new file mode 100644 index 0000000..29a90e3 --- /dev/null +++ b/src/api/SIGCM2.Application/Usuarios/Permisos/UpdateUsuarioPermisosOverridesCommandHandler.cs @@ -0,0 +1,81 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Common; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Usuarios.Permisos; + +/// +/// UDT-009: Handles PUT /api/v1/users/{id}/permisos/overrides. +/// Validates overlap and catalog existence, persists new overrides, returns updated effective set. +/// +public sealed class UpdateUsuarioPermisosOverridesCommandHandler + : ICommandHandler +{ + private readonly IUsuarioRepository _usuarioRepo; + private readonly IRolPermisoRepository _rolPermisoRepo; + private readonly IPermisoRepository _permisoRepo; + + public UpdateUsuarioPermisosOverridesCommandHandler( + IUsuarioRepository usuarioRepo, + IRolPermisoRepository rolPermisoRepo, + IPermisoRepository permisoRepo) + { + _usuarioRepo = usuarioRepo; + _rolPermisoRepo = rolPermisoRepo; + _permisoRepo = permisoRepo; + } + + public async Task Handle(UpdateUsuarioPermisosOverridesCommand command) + { + var grant = command.Grant ?? []; + var deny = command.Deny ?? []; + + // 1. Overlap check — grant ∩ deny → 400 + var overlap = grant.Intersect(deny, StringComparer.Ordinal).ToArray(); + if (overlap.Length > 0) + throw new GrantDenyOverlapException(overlap); + + // 2. Catalog existence check + var allCodes = grant.Concat(deny).Distinct(StringComparer.Ordinal).ToArray(); + if (allCodes.Length > 0) + { + var existentes = await _permisoRepo.GetByCodigosAsync(allCodes); + var existSet = existentes.Select(p => p.Codigo).ToHashSet(StringComparer.Ordinal); + var faltantes = allCodes.Where(c => !existSet.Contains(c)).ToArray(); + if (faltantes.Length > 0) + throw new InvalidPermisoCodesException(faltantes); + } + + // 3. Load usuario + var usuario = await _usuarioRepo.GetByIdAsync(command.Id) + ?? throw new UsuarioNotFoundException(command.Id); + + // 4. Persist — use WithPermisosJson to get updated FechaModificacion + var newOverrides = new PermisosOverride(grant, deny); + var updated = usuario.WithPermisosJson(newOverrides.ToJson()); + await _usuarioRepo.UpdatePermisosJsonAsync( + updated.Id, + updated.PermisosJson, + updated.FechaModificacion!.Value); + + // 5. Return updated effective set + var rolPermisoEntities = await _rolPermisoRepo.GetByRolCodigoAsync(updated.Rol); + var rolPermisos = rolPermisoEntities + .Select(p => p.Codigo) + .OrderBy(c => c, StringComparer.Ordinal) + .ToArray(); + + var effective = PermisoResolver.Resolve(rolPermisos, newOverrides) + .OrderBy(c => c, StringComparer.Ordinal) + .ToArray(); + + return new UsuarioPermisosDto( + UsuarioId: updated.Id, + Rol: updated.Rol, + RolPermisos: rolPermisos, + Grant: grant, + Deny: deny, + Effective: effective); + } +} diff --git a/src/api/SIGCM2.Domain/Exceptions/GrantDenyOverlapException.cs b/src/api/SIGCM2.Domain/Exceptions/GrantDenyOverlapException.cs new file mode 100644 index 0000000..5e2ac12 --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/GrantDenyOverlapException.cs @@ -0,0 +1,16 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// UDT-009: Thrown when the same code appears in both grant and deny arrays. +/// Maps to 400 { title: "grant-deny-overlap", overlap: [...] }. +/// +public sealed class GrantDenyOverlapException : Exception +{ + public IReadOnlyList Overlap { get; } + + public GrantDenyOverlapException(IReadOnlyList overlap) + : base($"Los siguientes códigos aparecen en grant y deny simultáneamente: {string.Join(", ", overlap)}") + { + Overlap = overlap; + } +} diff --git a/src/api/SIGCM2.Domain/Exceptions/InvalidPermisoCodesException.cs b/src/api/SIGCM2.Domain/Exceptions/InvalidPermisoCodesException.cs new file mode 100644 index 0000000..048569d --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/InvalidPermisoCodesException.cs @@ -0,0 +1,16 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// UDT-009: Thrown when grant or deny arrays contain codes not in the Permiso catalog. +/// Maps to 400 { title: "invalid-permiso-codes", invalidCodes: [...] }. +/// +public sealed class InvalidPermisoCodesException : Exception +{ + public IReadOnlyList InvalidCodes { get; } + + public InvalidPermisoCodesException(IReadOnlyList codes) + : base($"Códigos de permiso inexistentes en el catálogo: {string.Join(", ", codes)}") + { + InvalidCodes = codes; + } +}