Listing Ownership and Authorisation — Policies and Resource Guards

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;
    };
}
Note: Injecting 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.
Tip: Resource-based authorization handlers should be registered as singletons (not scoped) because they have no mutable state — they only read from the user context and resource passed to them. Scoped registration works too but is unnecessary overhead. The IAuthorizationService itself handles the scoping correctly regardless of how the handler is registered.
Warning: The rate limiting policy above limits listing creation requests — but a determined user can still have more than 3 active listings by creating them before the hourly window resets. For a hard cap on simultaneous active listings, implement the check in the 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.

🧠 Test Yourself

A Moderator user calls DELETE /api/listings/{id} for a listing they did not create. The DeleteListingCommandHandler calls IAuthorizationService.AuthorizeAsync with ListingOwnerRequirement. Does this succeed?