feat(enforcement): add MediatR permission authorization behavior

Phase 2 of permission management — enforcement in MerchantService:

- PermissionConstants: maps JWT "permission" claim strings to
  StaffPermissions bitmask via FromClaims() method
- IRequirePermission: marker interface for commands needing permission
  check (StaffPermissions RequiredPermission property)
- PermissionAuthorizationBehavior: MediatR pipeline behavior that reads
  permission claims from HttpContext.User, converts to bitmask, validates
  against IRequirePermission.RequiredPermission. Skips non-annotated commands.
- Registered in MediatR pipeline after Validator, before Transaction
- Annotated 3 staff commands with ManageStaff permission:
  InviteStaffCommand, CreateActiveStaffCommand, UpdateStaffCommand
- Added HttpContextAccessor DI registration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ho Ngoc Hai
2026-03-26 06:47:01 +07:00
parent 4849b7b6fc
commit 2d738aeefa
5 changed files with 141 additions and 5 deletions

View File

@@ -0,0 +1,18 @@
using MerchantService.Domain.AggregatesModel.MerchantStaffAggregate;
namespace MerchantService.API.Application.Behaviors;
/// <summary>
/// EN: Marker interface for commands that require a specific StaffPermission.
/// Commands implementing this will be checked by PermissionAuthorizationBehavior.
/// VI: Marker interface cho commands yêu cầu StaffPermission cụ thể.
/// Commands implement interface này sẽ được kiểm tra bởi PermissionAuthorizationBehavior.
/// </summary>
public interface IRequirePermission
{
/// <summary>
/// EN: The permission required to execute this command.
/// VI: Permission cần thiết để thực thi command này.
/// </summary>
StaffPermissions RequiredPermission { get; }
}

View File

@@ -0,0 +1,72 @@
using MediatR;
using MerchantService.Domain.AggregatesModel.MerchantStaffAggregate;
namespace MerchantService.API.Application.Behaviors;
/// <summary>
/// EN: MediatR pipeline behavior that checks permission claims from JWT token.
/// Only applies to commands implementing IRequirePermission.
/// Reads "permission" claims from HttpContext.User and converts to StaffPermissions bitmask.
/// VI: MediatR pipeline behavior kiểm tra permission claims từ JWT token.
/// Chỉ áp dụng cho commands implement IRequirePermission.
/// Đọc "permission" claims từ HttpContext.User và chuyển sang StaffPermissions bitmask.
/// </summary>
public class PermissionAuthorizationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly ILogger<PermissionAuthorizationBehavior<TRequest, TResponse>> _logger;
public PermissionAuthorizationBehavior(
IHttpContextAccessor httpContextAccessor,
ILogger<PermissionAuthorizationBehavior<TRequest, TResponse>> logger)
{
_httpContextAccessor = httpContextAccessor;
_logger = logger;
}
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
// EN: Skip if command doesn't require permission check
// VI: Bỏ qua nếu command không yêu cầu kiểm tra permission
if (request is not IRequirePermission permissionRequest)
return await next();
var required = permissionRequest.RequiredPermission;
if (required == StaffPermissions.None)
return await next();
var user = _httpContextAccessor.HttpContext?.User;
if (user?.Identity?.IsAuthenticated != true)
{
_logger.LogWarning("EN: Permission check failed — user not authenticated for {Command}",
typeof(TRequest).Name);
throw new UnauthorizedAccessException("User not authenticated / Người dùng chưa xác thực");
}
// EN: Extract permission claims from JWT and convert to bitmask
// VI: Trích xuất permission claims từ JWT và chuyển sang bitmask
var permClaims = user.Claims
.Where(c => c.Type == PermissionConstants.ClaimType)
.Select(c => c.Value)
.ToList();
var granted = PermissionConstants.FromClaims(permClaims);
// EN: Check if user has "All" permission or the specific required permission
// VI: Kiểm tra user có "All" permission hoặc permission cụ thể cần thiết
if (granted == StaffPermissions.All || (granted & required) == required)
return await next();
_logger.LogWarning(
"EN: Permission denied — {Command} requires {Required}, user has {Granted}. " +
"VI: Từ chối quyền — {Command} yêu cầu {Required}, user có {Granted}.",
typeof(TRequest).Name, required, granted);
throw new UnauthorizedAccessException(
$"Permission denied: requires {required} / Từ chối quyền: yêu cầu {required}");
}
}

View File

@@ -2,6 +2,7 @@
// VI: Commands quản lý nhân viên.
using MediatR;
using MerchantService.API.Application.Behaviors;
using MerchantService.Domain.AggregatesModel.MerchantAggregate;
using MerchantService.Domain.AggregatesModel.MerchantStaffAggregate;
using MerchantService.Domain.Exceptions;
@@ -11,11 +12,12 @@ namespace MerchantService.API.Application.Commands.Staff;
#region Invite Staff Command
/// <summary>
/// EN: Command to invite a new staff member.
/// VI: Command để mời nhân viên mới.
/// EN: Command to invite a new staff member. Requires ManageStaff permission.
/// VI: Command để mời nhân viên mới. Yêu cầu quyền ManageStaff.
/// </summary>
public record InviteStaffCommand : IRequest<InviteStaffResult>
public record InviteStaffCommand : IRequest<InviteStaffResult>, IRequirePermission
{
public StaffPermissions RequiredPermission => StaffPermissions.ManageStaff;
/// <summary>
/// EN: Owner's user ID extracted from JWT claims by the controller.
/// VI: User ID của chủ merchant được lấy từ JWT claims bởi controller.
@@ -96,8 +98,9 @@ public class InviteStaffCommandHandler : IRequestHandler<InviteStaffCommand, Inv
/// EN: Command to create a staff member directly as Active (when owner creates IAM account for them).
/// VI: Command để tạo nhân viên trực tiếp Active (khi chủ DN tạo tài khoản IAM cho họ).
/// </summary>
public record CreateActiveStaffCommand : IRequest<InviteStaffResult>
public record CreateActiveStaffCommand : IRequest<InviteStaffResult>, IRequirePermission
{
public StaffPermissions RequiredPermission => StaffPermissions.ManageStaff;
/// <summary>
/// EN: Owner's user ID extracted from JWT claims by the controller.
/// VI: User ID của chủ merchant được lấy từ JWT claims bởi controller.
@@ -190,8 +193,9 @@ public class CreateActiveStaffCommandHandler : IRequestHandler<CreateActiveStaff
/// EN: Command to update a staff member.
/// VI: Command để cập nhật nhân viên.
/// </summary>
public record UpdateStaffCommand : IRequest<bool>
public record UpdateStaffCommand : IRequest<bool>, IRequirePermission
{
public StaffPermissions RequiredPermission => StaffPermissions.ManageStaff;
/// <summary>
/// EN: Owner's user ID extracted from JWT claims by the controller.
/// VI: User ID của chủ merchant được lấy từ JWT claims bởi controller.

View File

@@ -32,12 +32,17 @@ try
cfg.RegisterServicesFromAssemblyContaining<Program>();
cfg.AddOpenBehavior(typeof(LoggingBehavior<,>));
cfg.AddOpenBehavior(typeof(ValidatorBehavior<,>));
cfg.AddOpenBehavior(typeof(PermissionAuthorizationBehavior<,>));
cfg.AddOpenBehavior(typeof(TransactionBehavior<,>));
});
// EN: Add FluentValidation / VI: Thêm FluentValidation
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
// EN: Required for PermissionAuthorizationBehavior to read JWT claims
// VI: Cần thiết cho PermissionAuthorizationBehavior đọc JWT claims
builder.Services.AddHttpContextAccessor();
// EN: Add controllers / VI: Thêm controllers
builder.Services.AddControllers();

View File

@@ -0,0 +1,37 @@
namespace MerchantService.Domain.AggregatesModel.MerchantStaffAggregate;
/// <summary>
/// EN: Maps permission claim strings (from IAM role_claims) to StaffPermissions bitmask.
/// VI: Map chuỗi permission claim (từ IAM role_claims) sang StaffPermissions bitmask.
/// </summary>
public static class PermissionConstants
{
public const string ClaimType = "permission";
private static readonly Dictionary<string, StaffPermissions> Map = new(StringComparer.OrdinalIgnoreCase)
{
["ViewSales"] = StaffPermissions.ViewSales,
["ProcessPayment"] = StaffPermissions.ProcessPayment,
["RefundOrder"] = StaffPermissions.RefundOrder,
["ManageInventory"] = StaffPermissions.ManageInventory,
["ViewReports"] = StaffPermissions.ViewReports,
["ManageStaff"] = StaffPermissions.ManageStaff,
["ManageSettings"] = StaffPermissions.ManageSettings,
["All"] = StaffPermissions.All,
};
/// <summary>
/// EN: Convert JWT permission claim values to StaffPermissions bitmask.
/// VI: Chuyển đổi giá trị permission claim từ JWT sang StaffPermissions bitmask.
/// </summary>
public static StaffPermissions FromClaims(IEnumerable<string> claimValues)
{
var result = StaffPermissions.None;
foreach (var val in claimValues)
{
if (Map.TryGetValue(val, out var perm))
result |= perm;
}
return result;
}
}