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'),