From 0218d8d37142d59390be068d4696369eadfeed26 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 16:34:32 -0300 Subject: [PATCH] feat(api): migrar controllers admin a RequirePermission [UDT-006] --- .../V007__add_admin_permissions_udt006.sql | 42 +++++++++++ .../Controllers/PermisosController.cs | 16 +++-- .../SIGCM2.Api/Controllers/RolesController.cs | 3 +- .../Controllers/UsuariosController.cs | 3 +- .../Permisos/PermisosEndpointTests.cs | 69 +++++++++++++++++-- .../Roles/RolesEndpointTests.cs | 47 +++++++++++++ .../Usuarios/CreateUsuarioEndpointTests.cs | 50 ++++++++++++++ .../Integration/PermisoRepositoryTests.cs | 6 +- .../Integration/RolPermisoRepositoryTests.cs | 6 +- tests/SIGCM2.TestSupport/SqlTestFixture.cs | 16 +++-- 10 files changed, 238 insertions(+), 20 deletions(-) create mode 100644 database/migrations/V007__add_admin_permissions_udt006.sql diff --git a/database/migrations/V007__add_admin_permissions_udt006.sql b/database/migrations/V007__add_admin_permissions_udt006.sql new file mode 100644 index 0000000..9816f50 --- /dev/null +++ b/database/migrations/V007__add_admin_permissions_udt006.sql @@ -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 diff --git a/src/api/SIGCM2.Api/Controllers/PermisosController.cs b/src/api/SIGCM2.Api/Controllers/PermisosController.cs index 13bc6c4..5d1a857 100644 --- a/src/api/SIGCM2.Api/Controllers/PermisosController.cs +++ b/src/api/SIGCM2.Api/Controllers/PermisosController.cs @@ -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; +/// +/// Permisos controller — granular permission per method (UDT-006). +/// [Authorize] at class level requires a valid JWT; each method declares its specific permission. +/// [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; } - /// Lists all permisos in the canonical catalog. Requires admin role. + /// Lists all permisos in the canonical catalog. [HttpGet("permisos")] + [RequirePermission("administracion:permisos:ver")] [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status403Forbidden)] @@ -39,8 +45,9 @@ public sealed class PermisosController : ControllerBase return Ok(result); } - /// Gets all permisos assigned to a rol. Requires admin role. + /// Gets all permisos assigned to a rol. [HttpGet("roles/{codigo}/permisos")] + [RequirePermission("administracion:roles_permisos:gestionar", "administracion:permisos:ver")] [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] @@ -64,9 +71,10 @@ public sealed class PermisosController : ControllerBase /// /// 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). /// [HttpPut("roles/{codigo}/permisos")] + [RequirePermission("administracion:roles_permisos:gestionar")] [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] diff --git a/src/api/SIGCM2.Api/Controllers/RolesController.cs b/src/api/SIGCM2.Api/Controllers/RolesController.cs index 6329828..bf48e3d 100644 --- a/src/api/SIGCM2.Api/Controllers/RolesController.cs +++ b/src/api/SIGCM2.Api/Controllers/RolesController.cs @@ -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; diff --git a/src/api/SIGCM2.Api/Controllers/UsuariosController.cs b/src/api/SIGCM2.Api/Controllers/UsuariosController.cs index 37a1e65..f9279b7 100644 --- a/src/api/SIGCM2.Api/Controllers/UsuariosController.cs +++ b/src/api/SIGCM2.Api/Controllers/UsuariosController.cs @@ -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; diff --git a/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs b/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs index ac2fd0c..efdbbca 100644 --- a/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs +++ b/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs @@ -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(); - 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(); - 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(); + 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(); + 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); + } + } } diff --git a/tests/SIGCM2.Api.Tests/Roles/RolesEndpointTests.cs b/tests/SIGCM2.Api.Tests/Roles/RolesEndpointTests.cs index f32011d..856b011 100644 --- a/tests/SIGCM2.Api.Tests/Roles/RolesEndpointTests.cs +++ b/tests/SIGCM2.Api.Tests/Roles/RolesEndpointTests.cs @@ -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(); + 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 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!"); + } + } diff --git a/tests/SIGCM2.Api.Tests/Usuarios/CreateUsuarioEndpointTests.cs b/tests/SIGCM2.Api.Tests/Usuarios/CreateUsuarioEndpointTests.cs index 1fa0570..9d89b3a 100644 --- a/tests/SIGCM2.Api.Tests/Usuarios/CreateUsuarioEndpointTests.cs +++ b/tests/SIGCM2.Api.Tests/Usuarios/CreateUsuarioEndpointTests.cs @@ -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(); + 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 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 // --------------------------------------------------------------------------- diff --git a/tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs index cb54042..d3b7653 100644 --- a/tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs +++ b/tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs @@ -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] diff --git a/tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs index f38e405..acf0335 100644 --- a/tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs +++ b/tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs @@ -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] diff --git a/tests/SIGCM2.TestSupport/SqlTestFixture.cs b/tests/SIGCM2.TestSupport/SqlTestFixture.cs index dc8b062..b2cbd33 100644 --- a/tests/SIGCM2.TestSupport/SqlTestFixture.cs +++ b/tests/SIGCM2.TestSupport/SqlTestFixture.cs @@ -103,10 +103,14 @@ public sealed class SqlTestFixture : IAsyncLifetime ('productores:deuda:ver', N'Ver deuda propia 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'), - ('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: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'), + -- 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'),