Resource-based authorisation goes beyond role checks — it determines whether a specific user can perform an action on a specific resource instance. Any authenticated seller can publish their own listing, but not someone else’s. This resource-level check cannot be done in a policy alone (policies don’t know which listing instance is involved). ASP.NET Core’s IAuthorizationService handles this with custom requirements and handlers that receive both the user and the resource being accessed.
Resource-Based Authorisation
// ── Application/Common/Auth/ListingOwnerRequirement.cs ────────────────────
public class ListingOwnerRequirement : IAuthorizationRequirement { }
// ── Infrastructure/Auth/ListingOwnerHandler.cs ────────────────────────────
public class ListingOwnerHandler
: AuthorizationHandler<ListingOwnerRequirement, Listing>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
ListingOwnerRequirement requirement,
Listing resource)
{
var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier);
// Owner can always act on their own listing
if (resource.OwnerId == userId)
{
context.Succeed(requirement);
return Task.CompletedTask;
}
// Moderators and Admins can also act on any listing
if (context.User.IsInRole("Moderator") || context.User.IsInRole("Admin"))
{
context.Succeed(requirement);
}
return Task.CompletedTask;
// Not calling Succeed = failure (403 returned by framework)
}
}
// ── Register in Program.cs ────────────────────────────────────────────────
builder.Services.AddSingleton<IAuthorizationHandler, ListingOwnerHandler>();
// ── Using resource-based auth in a command handler ────────────────────────
public class PublishListingCommandHandler
: IRequestHandler<PublishListingCommand>
{
private readonly IListingRepository _repo;
private readonly IUnitOfWork _uow;
private readonly IPublisher _publisher;
private readonly IAuthorizationService _auth;
private readonly IHttpContextAccessor _http;
public async Task Handle(PublishListingCommand cmd, CancellationToken ct)
{
var listing = await _repo.GetByIdAsync(cmd.ListingId, ct)
?? throw new NotFoundException($"Listing {cmd.ListingId} not found.");
// Resource-based auth check — before any domain operation
var authResult = await _auth.AuthorizeAsync(
_http.HttpContext!.User,
listing,
new ListingOwnerRequirement());
if (!authResult.Succeeded)
throw new ForbiddenException("You do not have permission to publish this listing.");
listing.Publish();
await _uow.SaveChangesAsync(ct);
foreach (var evt in listing.DomainEvents)
await _publisher.Publish(evt, ct);
listing.ClearDomainEvents();
}
}
// ── Rate limiting: free sellers limited to 3 active listings ──────────────
public class ListingCreationRateLimitPolicy
: IRateLimiterPolicy<string>
{
public RateLimitPartition<string> GetPartition(HttpContext ctx)
{
var userId = ctx.User.FindFirstValue(ClaimTypes.NameIdentifier);
var isVerif = ctx.User.FindFirstValue("isVerifiedSeller") == "true";
if (isVerif)
// Verified sellers: no rate limit
return RateLimitPartition.GetNoLimiter("verified");
// Free sellers: 3 requests per hour
return RateLimitPartition.GetSlidingWindowLimiter(
userId ?? "anon",
_ => new SlidingWindowRateLimiterOptions
{
Window = TimeSpan.FromHours(1),
PermitLimit = 3,
SegmentsPerWindow = 4,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 0,
});
}
public Func<OnRejectedContext, CancellationToken, ValueTask> OnRejected
=> (ctx, _) =>
{
ctx.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
return ValueTask.CompletedTask;
};
}
IHttpContextAccessor into a MediatR handler is a pragmatic compromise in Clean Architecture — it couples the Application layer to the HTTP context, which is a Presentation concern. A cleaner approach is to have the controller extract the current user and pass it as part of the command (cmd.RequestingUserId) or inject ICurrentUserService (which wraps IHttpContextAccessor behind an interface). The command/query approach is preferred because it makes handlers fully testable without needing an HTTP context.IAuthorizationService itself handles the scoping correctly regardless of how the handler is registered.CreateListingCommandHandler itself: query the count of the user’s active listings and throw a domain exception if they have reached the limit. Rate limiting (requests per hour) and business rule limiting (max active listings) serve different purposes and should both be implemented.Common Mistakes
Mistake 1 — Ownership check with string comparison before loading the resource
❌ Wrong — reading OwnerId from the request parameter instead of loading the entity; an attacker can spoof the OwnerId in the request.
✅ Correct — always load the listing from the database; check the database’s OwnerId against the JWT’s user ID; never trust client-provided ownership claims.
Mistake 2 — Not handling the Moderator/Admin bypass in the ownership handler
❌ Wrong — ownership handler only allows the owner; moderators cannot remove harmful listings even though they should be able to.
✅ Correct — handler succeeds for both the resource owner AND users in Moderator/Admin roles.