diff --git a/src/api/SIGCM2.Api/Authorization/PermissionAuthorizationHandler.cs b/src/api/SIGCM2.Api/Authorization/PermissionAuthorizationHandler.cs
new file mode 100644
index 0000000..0efb172
--- /dev/null
+++ b/src/api/SIGCM2.Api/Authorization/PermissionAuthorizationHandler.cs
@@ -0,0 +1,71 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using SIGCM2.Application.Abstractions.Persistence;
+
+namespace SIGCM2.Api.Authorization;
+
+///
+/// Authorization handler for .
+/// Reads the "rol" claim from the authenticated user, queries
+/// for the role's assigned permissions, and succeeds if at least one matches (OR semantics).
+/// No caching — UDT-006 design decision D1: always authoritative from DB.
+///
+public sealed class PermissionAuthorizationHandler
+ : AuthorizationHandler
+{
+ private readonly IRolPermisoRepository _rolPermisoRepo;
+ private readonly ILogger _logger;
+
+ public PermissionAuthorizationHandler(
+ IRolPermisoRepository rolPermisoRepo,
+ ILogger logger)
+ {
+ _rolPermisoRepo = rolPermisoRepo;
+ _logger = logger;
+ }
+
+ protected override async Task HandleRequirementAsync(
+ AuthorizationHandlerContext context,
+ RequirePermissionAttribute requirement)
+ {
+ // 1. Must be authenticated — defense-in-depth (AuthorizeAttribute already requires it)
+ if (context.User?.Identity?.IsAuthenticated != true)
+ {
+ return; // implicit Fail — nothing Succeeded
+ }
+
+ // 2. Extract "rol" claim — JwtBearer is configured with RoleClaimType="rol"
+ var rolCodigo = context.User.FindFirst("rol")?.Value;
+ if (string.IsNullOrWhiteSpace(rolCodigo))
+ {
+ _logger.LogWarning(
+ "Authorization failed — token missing 'rol' claim for user {User}",
+ context.User.Identity?.Name);
+ context.Fail(new AuthorizationFailureReason(this, "missing_rol_claim"));
+ return;
+ }
+
+ // 3. Load permissions assigned to this role — no cache (UDT-006 D1)
+ var permisos = await _rolPermisoRepo.GetByRolCodigoAsync(rolCodigo);
+ var permisoCodes = permisos.Select(p => p.Codigo).ToHashSet(StringComparer.Ordinal);
+
+ // 4. OR semantics — any single match is enough
+ var matched = requirement.PermissionCodes
+ .FirstOrDefault(code => permisoCodes.Contains(code));
+
+ if (matched is not null)
+ {
+ context.Succeed(requirement);
+ return;
+ }
+
+ // 5. Stash required permission for ForbiddenProblemDetailsHandler (Batch 3)
+ if (context.Resource is HttpContext httpContext)
+ {
+ httpContext.Items["RequiredPermission"] = requirement.PermissionCodes[0];
+ }
+
+ context.Fail(new AuthorizationFailureReason(this,
+ $"missing_permission:{string.Join('|', requirement.PermissionCodes)}"));
+ }
+}
diff --git a/src/api/SIGCM2.Api/Authorization/RequirePermissionAttribute.cs b/src/api/SIGCM2.Api/Authorization/RequirePermissionAttribute.cs
new file mode 100644
index 0000000..b62a48c
--- /dev/null
+++ b/src/api/SIGCM2.Api/Authorization/RequirePermissionAttribute.cs
@@ -0,0 +1,35 @@
+using Microsoft.AspNetCore.Authorization;
+
+namespace SIGCM2.Api.Authorization;
+
+///
+/// Authorization attribute that requires the authenticated user to have at least ONE
+/// of the declared permission codes assigned to their role (OR semantics).
+/// Implements IAuthorizationRequirementData (.NET 8+) so ASP.NET Core builds the policy
+/// on-the-fly from GetRequirements() — no AddPolicy() registration needed.
+///
+///
+/// // Single permission
+/// [RequirePermission("administracion:usuarios:gestionar")]
+///
+/// // Multiple — OR semantics: any single match grants access
+/// [RequirePermission("ventas:contado:crear", "ventas:ctacte:crear")]
+///
+[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false)]
+public sealed class RequirePermissionAttribute
+ : AuthorizeAttribute, IAuthorizationRequirement, IAuthorizationRequirementData
+{
+ /// Permission codes required (OR semantics — at least one must match).
+ public string[] PermissionCodes { get; }
+
+ public RequirePermissionAttribute(params string[] permissionCodes)
+ {
+ if (permissionCodes is null || permissionCodes.Length == 0)
+ throw new ArgumentException("At least one permission code is required.", nameof(permissionCodes));
+
+ PermissionCodes = permissionCodes;
+ }
+
+ ///
+ public IEnumerable GetRequirements() => new[] { this };
+}
diff --git a/src/api/SIGCM2.Api/Program.cs b/src/api/SIGCM2.Api/Program.cs
index dfe90f5..545deed 100644
--- a/src/api/SIGCM2.Api/Program.cs
+++ b/src/api/SIGCM2.Api/Program.cs
@@ -1,5 +1,7 @@
+using Microsoft.AspNetCore.Authorization;
using Serilog;
using Scalar.AspNetCore;
+using SIGCM2.Api.Authorization;
using SIGCM2.Application;
using SIGCM2.Infrastructure;
using SIGCM2.Api.Filters;
@@ -21,6 +23,10 @@ builder.Host.UseSerilog((ctx, lc) => lc
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);
+// Authorization — handler lives in Api layer; DO NOT move to Infrastructure DI
+builder.Services.AddAuthorization();
+builder.Services.AddScoped();
+
// Controllers with exception filter
builder.Services.AddControllers(opts =>
{
diff --git a/tests/SIGCM2.Api.Tests/Authorization/PermissionAuthorizationHandlerTests.cs b/tests/SIGCM2.Api.Tests/Authorization/PermissionAuthorizationHandlerTests.cs
new file mode 100644
index 0000000..408bcb7
--- /dev/null
+++ b/tests/SIGCM2.Api.Tests/Authorization/PermissionAuthorizationHandlerTests.cs
@@ -0,0 +1,246 @@
+using System.Security.Claims;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging.Abstractions;
+using NSubstitute;
+using SIGCM2.Api.Authorization;
+using SIGCM2.Application.Abstractions.Persistence;
+using SIGCM2.Domain.Entities;
+
+namespace SIGCM2.Api.Tests.Authorization;
+
+///
+/// Unit tests for PermissionAuthorizationHandler — SUITE-B-01 (UDT-006).
+/// Tests isolated from DB: IRolPermisoRepository is mocked via NSubstitute.
+///
+public sealed class PermissionAuthorizationHandlerTests
+{
+ private readonly IRolPermisoRepository _rolPermisoRepo = Substitute.For();
+ private readonly PermissionAuthorizationHandler _handler;
+
+ public PermissionAuthorizationHandlerTests()
+ {
+ _handler = new PermissionAuthorizationHandler(
+ _rolPermisoRepo,
+ NullLogger.Instance);
+ }
+
+ // ── Helpers ──────────────────────────────────────────────────────────────
+
+ private static ClaimsPrincipal AuthenticatedUserWithRol(string rolValue)
+ {
+ var identity = new ClaimsIdentity(
+ new[] { new Claim("rol", rolValue) },
+ authenticationType: "TestAuth");
+ return new ClaimsPrincipal(identity);
+ }
+
+ private static ClaimsPrincipal AuthenticatedUserWithoutRolClaim()
+ {
+ var identity = new ClaimsIdentity(
+ new[] { new Claim(ClaimTypes.Name, "someuser") },
+ authenticationType: "TestAuth");
+ return new ClaimsPrincipal(identity);
+ }
+
+ private static ClaimsPrincipal AnonymousUser()
+ {
+ return new ClaimsPrincipal(new ClaimsIdentity()); // not authenticated
+ }
+
+ private static Permiso MakePermiso(int id, string codigo) =>
+ Permiso.ForRead(id, codigo, codigo, null, codigo.Split(':')[0], true, DateTime.UtcNow);
+
+ private static AuthorizationHandlerContext MakeContext(
+ ClaimsPrincipal user,
+ RequirePermissionAttribute requirement,
+ HttpContext? httpContext = null)
+ {
+ var ctx = httpContext ?? new DefaultHttpContext();
+ return new AuthorizationHandlerContext(
+ requirements: new[] { requirement },
+ user: user,
+ resource: ctx);
+ }
+
+ // ── B-01-01: Usuario con permiso requerido → Succeed ─────────────────────
+
+ [Fact]
+ public async Task HandleAsync_Succeeds_WhenUserHasRequiredPermission()
+ {
+ // Arrange
+ var user = AuthenticatedUserWithRol("admin");
+ var requirement = new RequirePermissionAttribute("administracion:usuarios:gestionar");
+
+ _rolPermisoRepo.GetByRolCodigoAsync("admin", Arg.Any())
+ .Returns(new List { MakePermiso(1, "administracion:usuarios:gestionar") }
+ .AsReadOnly() as IReadOnlyList);
+
+ var context = MakeContext(user, requirement);
+
+ // Act
+ await _handler.HandleAsync(context);
+
+ // Assert
+ Assert.True(context.HasSucceeded);
+ }
+
+ // ── B-01-02: Usuario sin el permiso requerido → Fail ──────────────────────
+
+ [Fact]
+ public async Task HandleAsync_Fails_WhenUserLacksPermission()
+ {
+ // Arrange
+ var user = AuthenticatedUserWithRol("cajero");
+ var requirement = new RequirePermissionAttribute("administracion:usuarios:gestionar");
+
+ _rolPermisoRepo.GetByRolCodigoAsync("cajero", Arg.Any())
+ .Returns(new List { MakePermiso(10, "ventas:contado:crear") }
+ .AsReadOnly() as IReadOnlyList);
+
+ var context = MakeContext(user, requirement);
+
+ // Act
+ await _handler.HandleAsync(context);
+
+ // Assert
+ Assert.False(context.HasSucceeded);
+ }
+
+ // ── B-01-03: Multi-permiso OR — tiene uno de los dos → Succeed ────────────
+
+ [Fact]
+ public async Task HandleAsync_Succeeds_WhenAnyOfMultiplePermissions_OR()
+ {
+ // Arrange
+ var user = AuthenticatedUserWithRol("cajero");
+ var requirement = new RequirePermissionAttribute(
+ "ventas:contado:crear",
+ "administracion:usuarios:gestionar");
+
+ _rolPermisoRepo.GetByRolCodigoAsync("cajero", Arg.Any())
+ .Returns(new List
+ {
+ MakePermiso(10, "ventas:contado:crear"),
+ MakePermiso(11, "ventas:contado:cobrar"),
+ }.AsReadOnly() as IReadOnlyList);
+
+ var context = MakeContext(user, requirement);
+
+ // Act
+ await _handler.HandleAsync(context);
+
+ // Assert
+ Assert.True(context.HasSucceeded);
+ }
+
+ // ── B-01-04: Multi-permiso OR — no tiene ninguno → Fail ───────────────────
+
+ [Fact]
+ public async Task HandleAsync_Fails_WhenNoneOfMultiplePermissions()
+ {
+ // Arrange
+ var user = AuthenticatedUserWithRol("cajero");
+ var requirement = new RequirePermissionAttribute(
+ "administracion:usuarios:gestionar",
+ "administracion:roles:gestionar");
+
+ _rolPermisoRepo.GetByRolCodigoAsync("cajero", Arg.Any())
+ .Returns(new List { MakePermiso(10, "ventas:contado:crear") }
+ .AsReadOnly() as IReadOnlyList);
+
+ var context = MakeContext(user, requirement);
+
+ // Act
+ await _handler.HandleAsync(context);
+
+ // Assert
+ Assert.False(context.HasSucceeded);
+ }
+
+ // ── B-01-05: Claim "rol" ausente → Fail; repo nunca llamado ───────────────
+
+ [Fact]
+ public async Task HandleAsync_Fails_WhenRolClaimMissing()
+ {
+ // Arrange
+ var user = AuthenticatedUserWithoutRolClaim();
+ var requirement = new RequirePermissionAttribute("administracion:usuarios:gestionar");
+ var context = MakeContext(user, requirement);
+
+ // Act
+ await _handler.HandleAsync(context);
+
+ // Assert
+ Assert.False(context.HasSucceeded);
+ // Repo must NOT be called when claim is absent
+ await _rolPermisoRepo.DidNotReceive()
+ .GetByRolCodigoAsync(Arg.Any(), Arg.Any());
+ }
+
+ // ── B-01-06: Repo devuelve lista vacía → Fail ─────────────────────────────
+
+ [Fact]
+ public async Task HandleAsync_Fails_WhenRoleHasNoPermissions()
+ {
+ // Arrange
+ var user = AuthenticatedUserWithRol("reportes");
+ var requirement = new RequirePermissionAttribute("administracion:usuarios:gestionar");
+
+ _rolPermisoRepo.GetByRolCodigoAsync("reportes", Arg.Any())
+ .Returns(new List().AsReadOnly() as IReadOnlyList);
+
+ var context = MakeContext(user, requirement);
+
+ // Act
+ await _handler.HandleAsync(context);
+
+ // Assert
+ Assert.False(context.HasSucceeded);
+ }
+
+ // ── B-01-07: Rol no existe en RolPermiso (mismo caso que lista vacía) ──────
+
+ [Fact]
+ public async Task HandleAsync_Fails_WhenRoleDoesNotExistInRolPermisoTable()
+ {
+ // Arrange
+ var user = AuthenticatedUserWithRol("rol_fantasma");
+ var requirement = new RequirePermissionAttribute("administracion:usuarios:gestionar");
+
+ _rolPermisoRepo.GetByRolCodigoAsync("rol_fantasma", Arg.Any())
+ .Returns(new List().AsReadOnly() as IReadOnlyList);
+
+ var context = MakeContext(user, requirement);
+
+ // Act
+ await _handler.HandleAsync(context);
+
+ // Assert
+ Assert.False(context.HasSucceeded);
+ }
+
+ // ── B-01-08: Fail stashea RequiredPermission en HttpContext.Items ──────────
+
+ [Fact]
+ public async Task HandleAsync_StashesRequiredPermission_InHttpContextItems_OnFail()
+ {
+ // Arrange
+ var user = AuthenticatedUserWithRol("cajero");
+ var requirement = new RequirePermissionAttribute("administracion:usuarios:gestionar");
+
+ _rolPermisoRepo.GetByRolCodigoAsync("cajero", Arg.Any())
+ .Returns(new List { MakePermiso(10, "ventas:contado:crear") }
+ .AsReadOnly() as IReadOnlyList);
+
+ var httpContext = new DefaultHttpContext();
+ var context = MakeContext(user, requirement, httpContext);
+
+ // Act
+ await _handler.HandleAsync(context);
+
+ // Assert — context.Resource is the HttpContext where items are stashed
+ Assert.False(context.HasSucceeded);
+ Assert.Equal("administracion:usuarios:gestionar", httpContext.Items["RequiredPermission"]);
+ }
+}