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;
+ }
+}