UDT-006: Middleware de Autorización (RBAC enforcement) #10

Merged
dmolinari merged 9 commits from feature/UDT-006 into main 2026-04-15 20:15:18 +00:00
10 changed files with 238 additions and 20 deletions
Showing only changes of commit 0218d8d371 - Show all commits

View File

@@ -0,0 +1,42 @@
-- V007__add_admin_permissions_udt006.sql
-- Agrega 3 permisos administrativos requeridos por UDT-006 (middleware de autorización RBAC).
-- Los 3 nuevos permisos se asignan al rol 'admin' inmediatamente.
-- Convención RBAC: cada permiso nuevo → asignar explícitamente a admin en la misma migración.
-- Run on: SIGCM2 (prod) and SIGCM2_Test (integration tests)
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- Agregar los 3 permisos nuevos al catálogo (idempotente via MERGE)
MERGE dbo.Permiso AS t
USING (VALUES
('administracion:roles:gestionar', N'Gestionar roles del sistema', N'Crear, editar y desactivar roles RBAC', 'administracion'),
('administracion:roles_permisos:gestionar', N'Gestionar asignación de permisos', N'Asignar y revocar permisos por rol', 'administracion'),
('administracion:permisos:ver', N'Ver catálogo de permisos', N'Consultar el listado de permisos del sistema', 'administracion')
) AS s (Codigo, Nombre, Descripcion, Modulo)
ON t.Codigo = s.Codigo
WHEN NOT MATCHED BY TARGET THEN
INSERT (Codigo, Nombre, Descripcion, Modulo)
VALUES (s.Codigo, s.Nombre, s.Descripcion, s.Modulo);
GO
-- Asignar los 3 nuevos permisos al rol 'admin' (idempotente via MERGE)
MERGE dbo.RolPermiso AS t
USING (
SELECT r.Id AS RolId, p.Id AS PermisoId
FROM (VALUES
('admin', 'administracion:roles:gestionar'),
('admin', 'administracion:roles_permisos:gestionar'),
('admin', 'administracion:permisos:ver')
) AS x (RolCodigo, PermisoCodigo)
JOIN dbo.Rol r ON r.Codigo = x.RolCodigo
JOIN dbo.Permiso p ON p.Codigo = x.PermisoCodigo
) AS s ON t.RolId = s.RolId AND t.PermisoId = s.PermisoId
WHEN NOT MATCHED BY TARGET THEN
INSERT (RolId, PermisoId) VALUES (s.RolId, s.PermisoId);
GO
PRINT 'V007: 3 permisos administracion:roles|roles-permisos|permisos agregados al catalogo y asignados a admin.';
GO

View File

@@ -1,6 +1,7 @@
using FluentValidation; using FluentValidation;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using SIGCM2.Api.Authorization;
using SIGCM2.Application.Abstractions; using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Permisos.Assign; using SIGCM2.Application.Permisos.Assign;
using SIGCM2.Application.Permisos.Dtos; using SIGCM2.Application.Permisos.Dtos;
@@ -9,9 +10,13 @@ using SIGCM2.Application.Permisos.List;
namespace SIGCM2.Api.Controllers; namespace SIGCM2.Api.Controllers;
/// <summary>
/// Permisos controller — granular permission per method (UDT-006).
/// [Authorize] at class level requires a valid JWT; each method declares its specific permission.
/// </summary>
[ApiController] [ApiController]
[Route("api/v1")] [Route("api/v1")]
[Authorize(Roles = "admin")] [Authorize] // JWT required on all methods; per-method [RequirePermission] handles authz
public sealed class PermisosController : ControllerBase public sealed class PermisosController : ControllerBase
{ {
private readonly IDispatcher _dispatcher; private readonly IDispatcher _dispatcher;
@@ -28,8 +33,9 @@ public sealed class PermisosController : ControllerBase
_getRolPermisosValidator = getRolPermisosValidator; _getRolPermisosValidator = getRolPermisosValidator;
} }
/// <summary>Lists all permisos in the canonical catalog. Requires admin role.</summary> /// <summary>Lists all permisos in the canonical catalog.</summary>
[HttpGet("permisos")] [HttpGet("permisos")]
[RequirePermission("administracion:permisos:ver")]
[ProducesResponseType(typeof(IReadOnlyList<PermisoDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(IReadOnlyList<PermisoDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status403Forbidden)]
@@ -39,8 +45,9 @@ public sealed class PermisosController : ControllerBase
return Ok(result); return Ok(result);
} }
/// <summary>Gets all permisos assigned to a rol. Requires admin role.</summary> /// <summary>Gets all permisos assigned to a rol.</summary>
[HttpGet("roles/{codigo}/permisos")] [HttpGet("roles/{codigo}/permisos")]
[RequirePermission("administracion:roles_permisos:gestionar", "administracion:permisos:ver")]
[ProducesResponseType(typeof(IReadOnlyList<PermisoDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(IReadOnlyList<PermisoDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status401Unauthorized)]
@@ -64,9 +71,10 @@ public sealed class PermisosController : ControllerBase
/// <summary> /// <summary>
/// Replace-set: replaces the full permiso assignment for a rol. /// Replace-set: replaces the full permiso assignment for a rol.
/// Returns the updated permiso set (200). Requires admin role. /// Returns the updated permiso set (200).
/// </summary> /// </summary>
[HttpPut("roles/{codigo}/permisos")] [HttpPut("roles/{codigo}/permisos")]
[RequirePermission("administracion:roles_permisos:gestionar")]
[ProducesResponseType(typeof(IReadOnlyList<PermisoDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(IReadOnlyList<PermisoDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status401Unauthorized)]

View File

@@ -1,6 +1,7 @@
using FluentValidation; using FluentValidation;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using SIGCM2.Api.Authorization;
using SIGCM2.Application.Abstractions; using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Roles.Create; using SIGCM2.Application.Roles.Create;
using SIGCM2.Application.Roles.Deactivate; using SIGCM2.Application.Roles.Deactivate;
@@ -13,7 +14,7 @@ namespace SIGCM2.Api.Controllers;
[ApiController] [ApiController]
[Route("api/v1/roles")] [Route("api/v1/roles")]
[Authorize(Roles = "admin")] [RequirePermission("administracion:roles:gestionar")]
public sealed class RolesController : ControllerBase public sealed class RolesController : ControllerBase
{ {
private readonly IDispatcher _dispatcher; private readonly IDispatcher _dispatcher;

View File

@@ -1,6 +1,7 @@
using FluentValidation; using FluentValidation;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using SIGCM2.Api.Authorization;
using SIGCM2.Application.Abstractions; using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Usuarios.Create; using SIGCM2.Application.Usuarios.Create;
@@ -8,7 +9,7 @@ namespace SIGCM2.Api.Controllers;
[ApiController] [ApiController]
[Route("api/v1/users")] [Route("api/v1/users")]
[Authorize(Roles = "admin")] [RequirePermission("administracion:usuarios:gestionar")]
public sealed class UsuariosController : ControllerBase public sealed class UsuariosController : ControllerBase
{ {
private readonly IDispatcher _dispatcher; private readonly IDispatcher _dispatcher;

View File

@@ -130,7 +130,7 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
// ── GET /api/v1/permisos — catalog ─────────────────────────────────────── // ── GET /api/v1/permisos — catalog ───────────────────────────────────────
[Fact] [Fact]
public async Task GetPermisos_WithAdmin_Returns200With18Items() public async Task GetPermisos_WithAdmin_Returns200With21Items()
{ {
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword); var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
using var req = BuildRequest(HttpMethod.Get, "/api/v1/permisos", bearerToken: token); using var req = BuildRequest(HttpMethod.Get, "/api/v1/permisos", bearerToken: token);
@@ -138,7 +138,8 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
Assert.Equal(HttpStatusCode.OK, resp.StatusCode); Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
var list = await resp.Content.ReadFromJsonAsync<JsonElement>(); var list = await resp.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(18, list.GetArrayLength()); // V007 (UDT-006) adds 3 new admin permisos → 21 total
Assert.Equal(21, list.GetArrayLength());
} }
[Fact] [Fact]
@@ -181,7 +182,7 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
// ── GET /api/v1/roles/{codigo}/permisos ────────────────────────────────── // ── GET /api/v1/roles/{codigo}/permisos ──────────────────────────────────
[Fact] [Fact]
public async Task GetRolPermisos_AdminRol_Returns200With18Items() public async Task GetRolPermisos_AdminRol_Returns200With21Items()
{ {
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword); var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
using var req = BuildRequest(HttpMethod.Get, "/api/v1/roles/admin/permisos", bearerToken: token); using var req = BuildRequest(HttpMethod.Get, "/api/v1/roles/admin/permisos", bearerToken: token);
@@ -189,7 +190,8 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
Assert.Equal(HttpStatusCode.OK, resp.StatusCode); Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
var list = await resp.Content.ReadFromJsonAsync<JsonElement>(); var list = await resp.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(18, list.GetArrayLength()); // V007 (UDT-006) adds 3 new admin permisos → 21 total
Assert.Equal(21, list.GetArrayLength());
} }
[Fact] [Fact]
@@ -424,4 +426,63 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
await DeleteUsuarioIfExistsAsync(username); await DeleteUsuarioIfExistsAsync(username);
} }
} }
// ── UDT-006: 403 ProblemDetails shape ─────────────────────────────────────
[Fact]
public async Task GetPermisos_WithCajeroToken_Returns403WithProblemDetailsShape()
{
const string username = "udt006_permisos_403_cajero";
try
{
var token = await CreateNonAdminUserAndGetTokenAsync(username);
using var req = BuildRequest(HttpMethod.Get, "/api/v1/permisos", bearerToken: token);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.Forbidden, resp.StatusCode);
Assert.Contains("problem+json", resp.Content.Headers.ContentType?.MediaType ?? "");
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(403, json.GetProperty("status").GetInt32());
Assert.Equal("Acceso denegado", json.GetProperty("title").GetString());
Assert.True(json.TryGetProperty("permisoRequerido", out var perm),
"Response must contain 'permisoRequerido'");
// GET /permisos migra a administracion:permisos:ver
Assert.Equal("administracion:permisos:ver", perm.GetString());
}
finally
{
await DeleteUsuarioIfExistsAsync(username);
}
}
[Fact]
public async Task PutRolPermisos_WithCajeroToken_Returns403WithProblemDetailsShape()
{
const string username = "udt006_put_permisos_403";
try
{
var token = await CreateNonAdminUserAndGetTokenAsync(username);
using var req = BuildRequest(
HttpMethod.Put,
"/api/v1/roles/cajero/permisos",
new { codigos = new[] { "ventas:contado:crear" } },
token);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.Forbidden, resp.StatusCode);
Assert.Contains("problem+json", resp.Content.Headers.ContentType?.MediaType ?? "");
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(403, json.GetProperty("status").GetInt32());
Assert.True(json.TryGetProperty("permisoRequerido", out var perm),
"Response must contain 'permisoRequerido'");
// PUT /roles/{c}/permisos migra a administracion:roles_permisos:gestionar
Assert.Equal("administracion:roles_permisos:gestionar", perm.GetString());
}
finally
{
await DeleteUsuarioIfExistsAsync(username);
}
}
} }

View File

@@ -350,4 +350,51 @@ public sealed class RolesEndpointTests : IAsyncLifetime
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode); Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
} }
// ── UDT-006: 403 ProblemDetails shape ────────────────────────────────────
[Fact]
public async Task GetRoles_WithCajeroToken_Returns403WithProblemDetailsShape()
{
const string username = "udt006_roles_403_cajero";
try
{
var token = await CreateCajeroTokenAsync(username);
var resp = await _client.SendAsync(BuildRequest(HttpMethod.Get, Endpoint, bearerToken: token));
Assert.Equal(HttpStatusCode.Forbidden, resp.StatusCode);
Assert.Contains("problem+json", resp.Content.Headers.ContentType?.MediaType ?? "");
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(403, json.GetProperty("status").GetInt32());
Assert.Equal("Acceso denegado", json.GetProperty("title").GetString());
Assert.True(json.TryGetProperty("permisoRequerido", out var perm),
"Response must contain 'permisoRequerido'");
// RolesController migra a administracion:roles:gestionar
Assert.Equal("administracion:roles:gestionar", perm.GetString());
}
finally
{
await DeleteUsuarioIfExistsAsync(username);
}
}
// Helper: create cajero user via SQL and return token
private async Task<string> CreateCajeroTokenAsync(string username)
{
var adminToken = await GetBearerTokenAsync(AdminUsername, AdminPassword);
using var mkUser = BuildRequest(HttpMethod.Post, "/api/v1/users", new
{
username,
password = "Secure1234!",
nombre = "Cajero",
apellido = "Test",
email = (string?)null,
rol = "cajero"
}, adminToken);
var mkResp = await _client.SendAsync(mkUser);
if (mkResp.StatusCode != HttpStatusCode.Created && mkResp.StatusCode != HttpStatusCode.Conflict)
Assert.Fail($"Seed cajero failed: {mkResp.StatusCode}");
return await GetBearerTokenAsync(username, "Secure1234!");
}
} }

View File

@@ -388,6 +388,56 @@ public sealed class CreateUsuarioEndpointTests : IAsyncLifetime
} }
} }
// ---------------------------------------------------------------------------
// UDT-006 Scenario: 403 con ProblemDetails shape — token cajero sin permiso administracion:usuarios:gestionar
// ---------------------------------------------------------------------------
[Fact]
public async Task CreateUsuario_WithCajeroRole_Returns403WithProblemDetailsShape()
{
const string username = "udt006_403_shape_test";
try
{
var token = await CreateCajeroTokenAsync(username);
using var request = BuildRequest(HttpMethod.Post, Endpoint, ValidCreateBody("shape_target"), token);
var response = await _client.SendAsync(request);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
// Content-Type must be application/problem+json
Assert.Contains("problem+json", response.Content.Headers.ContentType?.MediaType ?? "");
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(403, json.GetProperty("status").GetInt32());
Assert.Equal("Acceso denegado", json.GetProperty("title").GetString());
Assert.True(json.TryGetProperty("permisoRequerido", out var perm),
"Response must contain 'permisoRequerido'");
Assert.Equal("administracion:usuarios:gestionar", perm.GetString());
}
finally
{
await DeleteUsuarioAsync(username);
}
}
// Helper: create a cajero user and return its token
private async Task<string> CreateCajeroTokenAsync(string username)
{
var adminToken = await GetBearerTokenAsync(AdminUsername, AdminPassword);
using var mkUser = BuildRequest(HttpMethod.Post, Endpoint, new
{
username,
password = "Secure1234!",
nombre = "Cajero",
apellido = "Test",
email = (string?)null,
rol = "cajero"
}, adminToken);
var mkResp = await _client.SendAsync(mkUser);
if (mkResp.StatusCode != HttpStatusCode.Created && mkResp.StatusCode != HttpStatusCode.Conflict)
Assert.Fail($"Seed cajero failed: {mkResp.StatusCode}");
return await GetBearerTokenAsync(username, "Secure1234!");
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Scenario 7 (UDT-004 Phase 5.3): 400 — rol existe pero está inactivo // Scenario 7 (UDT-004 Phase 5.3): 400 — rol existe pero está inactivo
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -74,12 +74,12 @@ public class PermisoRepositoryTests : IAsyncLifetime
// ── ListAsync ──────────────────────────────────────────────────────────── // ── ListAsync ────────────────────────────────────────────────────────────
[Fact] [Fact]
public async Task ListAsync_Returns18CanonicalSeeds() public async Task ListAsync_Returns21CanonicalSeeds()
{ {
var list = await _repository.ListAsync(); var list = await _repository.ListAsync();
// V005 seeds exactly 18 canonical permisos // V005 seeds 18 canonical permisos + V007 (UDT-006) adds 3 admin permisos = 21 total
Assert.Equal(18, list.Count); Assert.Equal(21, list.Count);
} }
[Fact] [Fact]

View File

@@ -174,12 +174,12 @@ public class RolPermisoRepositoryTests : IAsyncLifetime
// ── GetByRolCodigoAsync ────────────────────────────────────────────────── // ── GetByRolCodigoAsync ──────────────────────────────────────────────────
[Fact] [Fact]
public async Task GetByRolCodigoAsync_Admin_Returns18Permisos() public async Task GetByRolCodigoAsync_Admin_Returns21Permisos()
{ {
// admin has all 18 permisos assigned in V006 seed // admin has 18 permisos from V006 + 3 new admin permisos from V007 (UDT-006) = 21 total
var permisos = await _repository.GetByRolCodigoAsync("admin"); var permisos = await _repository.GetByRolCodigoAsync("admin");
Assert.Equal(18, permisos.Count); Assert.Equal(21, permisos.Count);
} }
[Fact] [Fact]

View File

@@ -103,10 +103,14 @@ public sealed class SqlTestFixture : IAsyncLifetime
('productores:deuda:ver', N'Ver deuda propia de productores', NULL, 'productores'), ('productores:deuda:ver', N'Ver deuda propia de productores', NULL, 'productores'),
('productores:pendientes:crear', N'Cargar pendientes de productores', NULL, 'productores'), ('productores:pendientes:crear', N'Cargar pendientes de productores', NULL, 'productores'),
('productores:deuda:bypass', N'Bypass de deuda de productores', NULL, 'productores'), ('productores:deuda:bypass', N'Bypass de deuda de productores', NULL, 'productores'),
('administracion:usuarios:gestionar', N'Gestionar usuarios del sistema', N'Crear, editar y desactivar usuarios', 'administracion'), ('administracion:usuarios:gestionar', N'Gestionar usuarios del sistema', N'Crear, editar y desactivar usuarios', 'administracion'),
('administracion:tarifarios:gestionar', N'Gestionar tarifarios', N'Crear y modificar tarifarios de publicidad', 'administracion'), ('administracion:tarifarios:gestionar', N'Gestionar tarifarios', N'Crear y modificar tarifarios de publicidad', 'administracion'),
('administracion:medios:gestionar', N'Gestionar medios publicitarios', N'Alta y configuracion de medios', 'administracion'), ('administracion:medios:gestionar', N'Gestionar medios publicitarios', N'Alta y configuracion de medios', 'administracion'),
('administracion:auditoria:ver', N'Ver logs de auditoria', N'Acceso al dashboard de auditoria', 'administracion') ('administracion:auditoria:ver', N'Ver logs de auditoria', N'Acceso al dashboard de auditoria', 'administracion'),
-- V007 (UDT-006): permisos administrativos RBAC
('administracion:roles:gestionar', N'Gestionar roles del sistema', N'Crear, editar y desactivar roles RBAC', 'administracion'),
('administracion:roles_permisos:gestionar', N'Gestionar asignacion de permisos', N'Asignar y revocar permisos por rol', 'administracion'),
('administracion:permisos:ver', N'Ver catalogo de permisos', N'Consultar el listado de permisos del sistema', 'administracion')
) AS s (Codigo, Nombre, Descripcion, Modulo) ) AS s (Codigo, Nombre, Descripcion, Modulo)
ON t.Codigo = s.Codigo ON t.Codigo = s.Codigo
WHEN NOT MATCHED BY TARGET THEN WHEN NOT MATCHED BY TARGET THEN
@@ -142,6 +146,10 @@ public sealed class SqlTestFixture : IAsyncLifetime
('admin', 'administracion:tarifarios:gestionar'), ('admin', 'administracion:tarifarios:gestionar'),
('admin', 'administracion:medios:gestionar'), ('admin', 'administracion:medios:gestionar'),
('admin', 'administracion:auditoria:ver'), ('admin', 'administracion:auditoria:ver'),
-- V007 (UDT-006): permisos administrativos RBAC para admin
('admin', 'administracion:roles:gestionar'),
('admin', 'administracion:roles_permisos:gestionar'),
('admin', 'administracion:permisos:ver'),
('cajero', 'ventas:contado:crear'), ('cajero', 'ventas:contado:crear'),
('cajero', 'ventas:contado:modificar'), ('cajero', 'ventas:contado:modificar'),
('cajero', 'ventas:contado:cobrar'), ('cajero', 'ventas:contado:cobrar'),