UDT-009: Overrides de PermisosJson por usuario — cierre módulo Auth #12

Merged
dmolinari merged 14 commits from feature/UDT-009 into main 2026-04-16 13:12:23 +00:00
7 changed files with 179 additions and 0 deletions
Showing only changes of commit 7d4dc4d2bb - Show all commits

View File

@@ -246,6 +246,30 @@ public sealed class UsuariosController : ControllerBase
return Ok(MapToPermisosResponse(result)); return Ok(MapToPermisosResponse(result));
} }
/// <summary>
/// Replaces the grant/deny override sets for a usuario.
/// Requires administracion:usuarios:gestionar.
/// </summary>
[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<IActionResult> UpdatePermisosOverrides(
[FromRoute] int id,
[FromBody] UpdatePermisosOverridesRequest request)
{
var command = new UpdateUsuarioPermisosOverridesCommand(
Id: id,
Grant: request.Grant ?? [],
Deny: request.Deny ?? []);
var result = await _dispatcher.Send<UpdateUsuarioPermisosOverridesCommand, UsuarioPermisosDto>(command);
return Ok(MapToPermisosResponse(result));
}
private static UsuarioPermisosResponse MapToPermisosResponse(UsuarioPermisosDto dto) private static UsuarioPermisosResponse MapToPermisosResponse(UsuarioPermisosDto dto)
=> new( => new(
RolPermisos: dto.RolPermisos, RolPermisos: dto.RolPermisos,
@@ -266,6 +290,11 @@ public sealed record PermisosOverridesShape(
IReadOnlyList<string> Grant, IReadOnlyList<string> Grant,
IReadOnlyList<string> Deny); IReadOnlyList<string> Deny);
/// <summary>UDT-009: PUT permisos/overrides request body.</summary>
public sealed record UpdatePermisosOverridesRequest(
IReadOnlyList<string>? Grant,
IReadOnlyList<string>? Deny);
/// <summary>Create user request body — nullable to catch missing field scenarios.</summary> /// <summary>Create user request body — nullable to catch missing field scenarios.</summary>
public sealed record CreateUsuarioRequest( public sealed record CreateUsuarioRequest(
string? Username, string? Username,

View File

@@ -169,6 +169,35 @@ public sealed class ExceptionFilter : IExceptionFilter
context.ExceptionHandled = true; context.ExceptionHandled = true;
break; 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: case ValidationException validationEx:
var errors = validationEx.Errors var errors = validationEx.Errors
.GroupBy(e => e.PropertyName) .GroupBy(e => e.PropertyName)

View File

@@ -60,6 +60,7 @@ public static class DependencyInjection
// Usuarios/Permisos (UDT-009) // Usuarios/Permisos (UDT-009)
services.AddScoped<ICommandHandler<GetUsuarioPermisosQuery, UsuarioPermisosDto>, GetUsuarioPermisosQueryHandler>(); services.AddScoped<ICommandHandler<GetUsuarioPermisosQuery, UsuarioPermisosDto>, GetUsuarioPermisosQueryHandler>();
services.AddScoped<ICommandHandler<UpdateUsuarioPermisosOverridesCommand, UsuarioPermisosDto>, UpdateUsuarioPermisosOverridesCommandHandler>();
// FluentValidation validators (scans entire Application assembly) // FluentValidation validators (scans entire Application assembly)
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>(); services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();

View File

@@ -0,0 +1,7 @@
namespace SIGCM2.Application.Usuarios.Permisos;
/// <summary>UDT-009: Command to replace the grant/deny override sets for a usuario.</summary>
public sealed record UpdateUsuarioPermisosOverridesCommand(
int Id,
IReadOnlyList<string> Grant,
IReadOnlyList<string> Deny);

View File

@@ -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;
/// <summary>
/// UDT-009: Handles PUT /api/v1/users/{id}/permisos/overrides.
/// Validates overlap and catalog existence, persists new overrides, returns updated effective set.
/// </summary>
public sealed class UpdateUsuarioPermisosOverridesCommandHandler
: ICommandHandler<UpdateUsuarioPermisosOverridesCommand, UsuarioPermisosDto>
{
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<UsuarioPermisosDto> 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);
}
}

View File

@@ -0,0 +1,16 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// UDT-009: Thrown when the same code appears in both grant and deny arrays.
/// Maps to 400 { title: "grant-deny-overlap", overlap: [...] }.
/// </summary>
public sealed class GrantDenyOverlapException : Exception
{
public IReadOnlyList<string> Overlap { get; }
public GrantDenyOverlapException(IReadOnlyList<string> overlap)
: base($"Los siguientes códigos aparecen en grant y deny simultáneamente: {string.Join(", ", overlap)}")
{
Overlap = overlap;
}
}

View File

@@ -0,0 +1,16 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// UDT-009: Thrown when grant or deny arrays contain codes not in the Permiso catalog.
/// Maps to 400 { title: "invalid-permiso-codes", invalidCodes: [...] }.
/// </summary>
public sealed class InvalidPermisoCodesException : Exception
{
public IReadOnlyList<string> InvalidCodes { get; }
public InvalidPermisoCodesException(IReadOnlyList<string> codes)
: base($"Códigos de permiso inexistentes en el catálogo: {string.Join(", ", codes)}")
{
InvalidCodes = codes;
}
}