feat(api): PUT /api/v1/users/{id}/permisos/overrides + excepciones domain + ExceptionFilter [UDT-009]
This commit is contained in:
@@ -246,6 +246,30 @@ public sealed class UsuariosController : ControllerBase
|
||||
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)
|
||||
=> new(
|
||||
RolPermisos: dto.RolPermisos,
|
||||
@@ -266,6 +290,11 @@ public sealed record PermisosOverridesShape(
|
||||
IReadOnlyList<string> Grant,
|
||||
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>
|
||||
public sealed record CreateUsuarioRequest(
|
||||
string? Username,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -60,6 +60,7 @@ public static class DependencyInjection
|
||||
|
||||
// Usuarios/Permisos (UDT-009)
|
||||
services.AddScoped<ICommandHandler<GetUsuarioPermisosQuery, UsuarioPermisosDto>, GetUsuarioPermisosQueryHandler>();
|
||||
services.AddScoped<ICommandHandler<UpdateUsuarioPermisosOverridesCommand, UsuarioPermisosDto>, UpdateUsuarioPermisosOverridesCommandHandler>();
|
||||
|
||||
// FluentValidation validators (scans entire Application assembly)
|
||||
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user