UDT-006: Middleware de Autorización (RBAC enforcement) #10
42
database/migrations/V007__add_admin_permissions_udt006.sql
Normal file
42
database/migrations/V007__add_admin_permissions_udt006.sql
Normal 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
|
||||
@@ -1,6 +1,7 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SIGCM2.Api.Authorization;
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Permisos.Assign;
|
||||
using SIGCM2.Application.Permisos.Dtos;
|
||||
@@ -9,9 +10,13 @@ using SIGCM2.Application.Permisos.List;
|
||||
|
||||
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]
|
||||
[Route("api/v1")]
|
||||
[Authorize(Roles = "admin")]
|
||||
[Authorize] // JWT required on all methods; per-method [RequirePermission] handles authz
|
||||
public sealed class PermisosController : ControllerBase
|
||||
{
|
||||
private readonly IDispatcher _dispatcher;
|
||||
@@ -28,8 +33,9 @@ public sealed class PermisosController : ControllerBase
|
||||
_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")]
|
||||
[RequirePermission("administracion:permisos:ver")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<PermisoDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
@@ -39,8 +45,9 @@ public sealed class PermisosController : ControllerBase
|
||||
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")]
|
||||
[RequirePermission("administracion:roles_permisos:gestionar", "administracion:permisos:ver")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<PermisoDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
@@ -64,9 +71,10 @@ public sealed class PermisosController : ControllerBase
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
[HttpPut("roles/{codigo}/permisos")]
|
||||
[RequirePermission("administracion:roles_permisos:gestionar")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<PermisoDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SIGCM2.Api.Authorization;
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Roles.Create;
|
||||
using SIGCM2.Application.Roles.Deactivate;
|
||||
@@ -13,7 +14,7 @@ namespace SIGCM2.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/roles")]
|
||||
[Authorize(Roles = "admin")]
|
||||
[RequirePermission("administracion:roles:gestionar")]
|
||||
public sealed class RolesController : ControllerBase
|
||||
{
|
||||
private readonly IDispatcher _dispatcher;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SIGCM2.Api.Authorization;
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Usuarios.Create;
|
||||
|
||||
@@ -8,7 +9,7 @@ namespace SIGCM2.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/users")]
|
||||
[Authorize(Roles = "admin")]
|
||||
[RequirePermission("administracion:usuarios:gestionar")]
|
||||
public sealed class UsuariosController : ControllerBase
|
||||
{
|
||||
private readonly IDispatcher _dispatcher;
|
||||
|
||||
@@ -130,7 +130,7 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
|
||||
// ── GET /api/v1/permisos — catalog ───────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetPermisos_WithAdmin_Returns200With18Items()
|
||||
public async Task GetPermisos_WithAdmin_Returns200With21Items()
|
||||
{
|
||||
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
||||
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);
|
||||
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]
|
||||
@@ -181,7 +182,7 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
|
||||
// ── GET /api/v1/roles/{codigo}/permisos ──────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetRolPermisos_AdminRol_Returns200With18Items()
|
||||
public async Task GetRolPermisos_AdminRol_Returns200With21Items()
|
||||
{
|
||||
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
||||
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);
|
||||
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]
|
||||
@@ -424,4 +426,63 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,4 +350,51 @@ public sealed class RolesEndpointTests : IAsyncLifetime
|
||||
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!");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -74,12 +74,12 @@ public class PermisoRepositoryTests : IAsyncLifetime
|
||||
// ── ListAsync ────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_Returns18CanonicalSeeds()
|
||||
public async Task ListAsync_Returns21CanonicalSeeds()
|
||||
{
|
||||
var list = await _repository.ListAsync();
|
||||
|
||||
// V005 seeds exactly 18 canonical permisos
|
||||
Assert.Equal(18, list.Count);
|
||||
// V005 seeds 18 canonical permisos + V007 (UDT-006) adds 3 admin permisos = 21 total
|
||||
Assert.Equal(21, list.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -174,12 +174,12 @@ public class RolPermisoRepositoryTests : IAsyncLifetime
|
||||
// ── GetByRolCodigoAsync ──────────────────────────────────────────────────
|
||||
|
||||
[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");
|
||||
|
||||
Assert.Equal(18, permisos.Count);
|
||||
Assert.Equal(21, permisos.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -106,7 +106,11 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
||||
('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: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)
|
||||
ON t.Codigo = s.Codigo
|
||||
WHEN NOT MATCHED BY TARGET THEN
|
||||
@@ -142,6 +146,10 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
||||
('admin', 'administracion:tarifarios:gestionar'),
|
||||
('admin', 'administracion:medios:gestionar'),
|
||||
('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:modificar'),
|
||||
('cajero', 'ventas:contado:cobrar'),
|
||||
|
||||
Reference in New Issue
Block a user