From 4866c4f21f17ab3414df3ef7de45bd7a963d952d Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 16:27:36 -0300 Subject: [PATCH] feat(api): ForbiddenProblemDetailsHandler 403 shape [UDT-006] --- .../ForbiddenProblemDetailsHandler.cs | 58 +++++++++++++++++++ src/api/SIGCM2.Api/Program.cs | 1 + 2 files changed, 59 insertions(+) create mode 100644 src/api/SIGCM2.Api/Authorization/ForbiddenProblemDetailsHandler.cs diff --git a/src/api/SIGCM2.Api/Authorization/ForbiddenProblemDetailsHandler.cs b/src/api/SIGCM2.Api/Authorization/ForbiddenProblemDetailsHandler.cs new file mode 100644 index 0000000..23d2eab --- /dev/null +++ b/src/api/SIGCM2.Api/Authorization/ForbiddenProblemDetailsHandler.cs @@ -0,0 +1,58 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization.Policy; + +namespace SIGCM2.Api.Authorization; + +/// +/// Custom IAuthorizationMiddlewareResultHandler that emits a structured ProblemDetails +/// response for 403 Forbidden outcomes (authenticated user, missing permission). +/// +/// For 401 Unauthorized and successful outcomes, delegates to the default handler +/// so the existing JWT Bearer challenge flow is unaffected (REQ-B-07). +/// +/// Registered as singleton in Program.cs — depends only on framework services. +/// +public sealed class ForbiddenProblemDetailsHandler : IAuthorizationMiddlewareResultHandler +{ + private static readonly AuthorizationMiddlewareResultHandler DefaultHandler = new(); + + private static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + public async Task HandleAsync( + RequestDelegate next, + HttpContext context, + AuthorizationPolicy policy, + PolicyAuthorizationResult authorizeResult) + { + // Only intercept 403s for authenticated users. + // If the user is not authenticated, the 401 challenge is handled by JwtBearer (REQ-B-07). + if (authorizeResult.Forbidden && context.User.Identity?.IsAuthenticated == true) + { + var requiredPermission = context.Items["RequiredPermission"] as string; + + context.Response.StatusCode = StatusCodes.Status403Forbidden; + context.Response.ContentType = "application/problem+json; charset=utf-8"; + + var problem = new + { + type = "https://sigcm2.local/errors/forbidden", + title = "Acceso denegado", + status = 403, + detail = "No tenés el permiso requerido para ejecutar esta acción.", + permisoRequerido = requiredPermission, + }; + + await context.Response.WriteAsync( + JsonSerializer.Serialize(problem, SerializerOptions)); + + return; + } + + // Delegate 401 challenges and successful outcomes to the default handler + await DefaultHandler.HandleAsync(next, context, policy, authorizeResult); + } +} diff --git a/src/api/SIGCM2.Api/Program.cs b/src/api/SIGCM2.Api/Program.cs index 545deed..313fe60 100644 --- a/src/api/SIGCM2.Api/Program.cs +++ b/src/api/SIGCM2.Api/Program.cs @@ -26,6 +26,7 @@ builder.Services.AddInfrastructure(builder.Configuration); // Authorization — handler lives in Api layer; DO NOT move to Infrastructure DI builder.Services.AddAuthorization(); builder.Services.AddScoped(); +builder.Services.AddSingleton(); // Controllers with exception filter builder.Services.AddControllers(opts =>