Skip to main content

Authorization Flow & Patterns

How PearDrop handles authorization across commands, queries, and Blazor components using built-in requirements and patterns.

Architecture Overview

PearDrop uses MediatR.Behaviors.Authorization for a two-tier authorization system:

Tier 1: Command/Query Authorization (Server)

Every command and query passes through IRequestAuthorizer<TRequest> before execution.

Client Request → API Endpoint

MediatR Pipeline Begins

IRequestAuthorizer<TCommand/Query> ← Authorization (BLOCKS if denied)
↓ (pass)
IValidator<TCommand/Query> ← Validation (BLOCKS if invalid)
↓ (pass)
ICommandHandler<TCommand> ← Business Logic
IQueryHandler<TQuery> ← Read Logic

Tier 2: Blazor Component Authorization (Client)

Blazor pages/components use [Authorize] and [RequireResource(...)] attributes to control rendering.


Built-in Requirements

PearDrop provides these authorization requirements out of the box:

MustBeAuthenticatedRequirement

Ensures user has valid authentication (not anonymous).

public override void BuildIgnorablePolicy(CreateTaskCommand request)
{
this.RequireAuthorization(new MustBeAuthenticatedRequirement());
}

MustNotBeAuthenticatedRequirement

Ensures user is not authenticated (guest-only operations).

public override void BuildIgnorablePolicy(AuthenticateUserCommand request)
{
this.RequireAuthorization(new MustNotBeAuthenticatedRequirement());
}

MustBeInRoleRequirement

Ensures user has a specific role. System root users bypass all role checks.

public override void BuildIgnorablePolicy(DeleteUserCommand request)
{
this.RequireAuthorization(new MustBeInRoleRequirement("admin"));
}

MustBeActiveUserRequirement

Ensures user's account is active (not disabled/locked).

public override void BuildIgnorablePolicy(UpdateProfileCommand request)
{
this.RequireAuthorization(new MustBeActiveUserRequirement());
}

Command Authorization

Commands are write operations that always require authorization.

Commands are write operations that always require authorization.

Folder Structure: Infrastructure/Domain/{AggregateContext}/CommandAuthorizers/

Base Class: AbstractIgnorableRequestAuthorizer<TCommand>

Basic Pattern

using PearDrop.AuthorizationRequirements;
using PearDrop.Domain;
using Microsoft.Extensions.Options;

namespace YourApp.Infrastructure.Domain.TaskAggregate.CommandAuthorizers;

public sealed class CreateTaskCommandAuthorizer(
IOptions<Settings.CoreSettings> coreSettings)
: AbstractIgnorableRequestAuthorizer<CreateTaskCommand>(coreSettings)
{
public override void BuildIgnorablePolicy(CreateTaskCommand request)
{
// Any authenticated user can create a task
this.RequireAuthorization(new MustBeAuthenticatedRequirement());
}
}

Advanced Pattern: Context-Aware Authorization

public sealed class UpdateTaskCommandAuthorizer(
IOptions<Settings.CoreSettings> coreSettings,
IHttpContextAccessor httpContextAccessor)
: AbstractIgnorableRequestAuthorizer<UpdateTaskCommand>(coreSettings)
{
public override void BuildIgnorablePolicy(UpdateTaskCommand request)
{
var user = httpContextAccessor.HttpContext!.ToSystemUser();

if (user.HasNoValue)
{
this.RequireAuthorization(new MustBeAuthenticatedRequirement());
return;
}

// Task owner OR admin can update
var currentUserId = user.Value.Id;
if (request.TaskOwnerId == currentUserId)
{
return; // Authorization succeeds
}

// Non-owner must be admin
this.RequireAuthorization(new MustBeInRoleRequirement("admin"));
}
}

Registration

internal static IServiceCollection AddCommandAuthorizers(this IServiceCollection services)
{
services.AddScoped<IAuthorizationHandler<CreateTaskCommand>, CreateTaskCommandAuthorizer>();
services.AddScoped<IAuthorizationHandler<UpdateTaskCommand>, UpdateTaskCommandAuthorizer>();

return services;
}

Register as IAuthorizationHandler<TCommand> so MediatR discovers and executes them.


Query Authorization

Queries are read operations that may expose sensitive data. Always authorize before loading data.

Folder Structure: Infrastructure/Queries/QueryAuthorizers/

Base Class: AbstractIgnorableRequestAuthorizer<TQuery>

Basic Pattern

namespace YourApp.Infrastructure.Queries.QueryAuthorizers;

public sealed class GetTasksByOwnerQueryAuthorizer(
IOptions<Settings.CoreSettings> coreSettings)
: AbstractIgnorableRequestAuthorizer<GetTasksByOwnerQuery>(coreSettings)
{
public override void BuildIgnorablePolicy(GetTasksByOwnerQuery request)
{
// Only authenticated users can view their own tasks
this.RequireAuthorization(new MustBeAuthenticatedRequirement());
}
}

Role-Restricted Queries

public sealed class GetAllUserAccountsQueryAuthorizer(
IOptions<Settings.CoreSettings> coreSettings)
: AbstractIgnorableRequestAuthorizer<GetAllUserAccountsQuery>(coreSettings)
{
public override void BuildIgnorablePolicy(GetAllUserAccountsQuery request)
{
// Only admins can view all user accounts
this.RequireAuthorization(new MustBeAuthenticatedRequirement());
this.RequireAuthorization(new MustBeInRoleRequirement("admin"));
}
}

Registration

internal static IServiceCollection AddQueryAuthorizers(this IServiceCollection services)
{
services.AddScoped<IAuthorizationHandler<GetTasksByOwnerQuery>, GetTasksByOwnerQueryAuthorizer>();
services.AddScoped<IAuthorizationHandler<GetAllUserAccountsQuery>, GetAllUserAccountsQueryAuthorizer>();

return services;
}

Blazor Component Authorization

Protect pages and components from unauthorized access.

Basic Authentication Check

@page "/tasks"
@attribute [Authorize] <!-- Requires authenticated user -->

@if (authState?.User.Identity?.IsAuthenticated == true)
{
<h1>My Tasks</h1>
// Content here
}
else
{
<p>Please log in.</p>
}

Resource-Based Authorization

Use [RequireResource(...)] for fine-grained feature access:

@page "/admin/users"
@attribute [RequireResource("user-management")]

<h1>User Management</h1>
<!-- Admin-only content -->

The Resource → Role mapping is maintained in the Auth module. Users see the page only if their roles have the specified resource assigned.


Decision Tree: Which Authorization to Use

Is this a write operation (state change)?

├─→ YES (Command):
│ └─→ Create IAuthorizationHandler<TCommand> in CommandAuthorizers/
│ ├─→ Requires authentication? → use MustBeAuthenticatedRequirement
│ ├─→ Must be unauthenticated? → use MustNotBeAuthenticatedRequirement
│ ├─→ Requires specific role? → use MustBeInRoleRequirement
│ ├─→ Requires active account? → use MustBeActiveUserRequirement
│ └─→ Context-aware (ownership check)? → use HttpContextAccessor in authorizer

├─→ NO (Read operation):
│ └─→ Create IAuthorizationHandler<TQuery> in Queries/QueryAuthorizers/
│ ├─→ Prevent info disclosure? → use MustBeAuthenticatedRequirement
│ ├─→ Restrict to role? → use MustBeInRoleRequirement
│ ├─→ Owner-only read? → use HttpContextAccessor to check request.UserId
│ └─→ Multiple requirements (AND)? → stack RequireAuthorization() calls

└─→ Is this a Blazor page/component?
└─→ Basic auth required? → @attribute [Authorize]
└─→ Feature/resource access? → @attribute [RequireResource("feature-id")]

Single-Tenant Development Mode

In development, you can disable authorization checks per-command via appsettings.Development.json:

{
"PearDrop": {
"IgnoreAuthorizations": [
"YourApp.Infrastructure.Domain.TaskAggregate.CreateTaskCommand",
"YourApp.Infrastructure.Domain.TaskAggregate.UpdateTaskCommand"
]
}
}

When a command's fully-qualified name is in IgnoreAuthorizations:

  • The authorizer runs but succeeds automatically
  • Useful for: Initial setup, testing, rapid iteration

Important: Remove from production configs.


Security Best Practices

1. Validate All Inputs on Server

Never trust client-side validation:

✅ GOOD - Server validates everything
[HttpPost("api/notes")]
public async Task<IActionResult> CreateNote([FromBody] CreateNoteCommand command)
{
// Handler has FluentValidation validator
var result = await commandRunner.ExecuteAsync(command);

if (!result.IsSuccess)
return BadRequest(result.Error);
}

❌ BAD - No server-side validation
public async Task CreateNote(CreateNoteCommand command)
{
// If client sends malformed data, no validation happens
var note = NoteAggregate.Create(command.Title, command.Content, userId);
}

2. Check Permissions Before Loading Data

Authorization should happen BEFORE querying:

✅ GOOD - Authorize first, then query
public async Task<QueryResult<NoteDetailDto>> Handle(GetNoteDetailsQuery query, CancellationToken ct)
{
// Check if user can see this note
var authResult = await authorizationService.AuthorizeAsync(
principal,
query,
"CanViewNote");

if (!authResult.Succeeded)
return QueryResult<NoteDetailDto>.Failed(); // Don't proceed

// Safe to query
var note = await readModels.Notes
.Where(n => n.Id == query.NoteId)
.FirstOrDefaultAsync(ct);
}

❌ BAD - Query first, then check
public async Task<NoteDetailDto> GetNoteDetails(Guid noteId)
{
var note = await dbContext.Notes.FindAsync(noteId); // Load first

if (note.OwnerId != userId) // Check AFTER loading
throw new UnauthorizedAccessException();
}

3. Hash Sensitive Data

Never store plain-text passwords or API keys:

✅ GOOD - Hash passwords
public sealed class UserAggregate
{
public static UserAggregate CreateWithPassword(string email, string plainPassword, string userId)
{
var hashedPassword = BCrypt.Net.BCrypt.HashPassword(plainPassword);

return new UserAggregate
{
Email = email,
PasswordHash = hashedPassword // Store hash, never plaintext
};
}

public bool VerifyPassword(string plainPassword)
{
return BCrypt.Net.BCrypt.Verify(plainPassword, this.PasswordHash);
}
}

❌ BAD - Store plaintext
var user = new User
{
Email = email,
Password = plainPassword // NEVER do this!
};

4. Don't Expose Internal IDs in URLs

Use GUIDs instead of sequential IDs:

✅ GOOD - GUIDs (cannot enumerate)
GET /api/notes/550e8400-e29b-41d4-a716-446655440000

❌ BAD - Sequential IDs
GET /api/notes/1
GET /api/notes/2
GET /api/notes/3 // Attacker can guess all IDs

5. Return Minimal Error Information for Auth Failures

Don't leak information in error messages:

✅ GOOD - Generic error
if (!authResult.Succeeded)
{
return Unauthorized("Invalid credentials"); // Doesn't distinguish between invalid user/password
}

❌ BAD - Specific errors
if (user == null)
return BadRequest("No user found with that email"); // Confirms email exists

if (!user.VerifyPassword(password))
return BadRequest("Password is incorrect"); // Confirms password doesn't match

6. Log Security Events (But Not Sensitive Data)

Track important security events:

✅ GOOD - Log meaningful events without leaking data
public class LoginHandler
{
private readonly ILogger<LoginHandler> logger;

public async Task<CommandResult> Handle(LoginCommand command, CancellationToken ct)
{
logger.LogInformation("Login attempt from IP {IpAddress}", ipAddress);

var result = await Authenticate(command);

if (!result.IsSuccess)
{
logger.LogWarning("Failed login attempt from IP {IpAddress}", ipAddress);
}
else
{
logger.LogInformation("Successful login for user {UserId}", userId);
}
}
}

❌ BAD - Logging sensitive data
logger.LogError("Login failed: email={Email}, password={Password}, ipAddress={IpAddress}");

7. Use Parameterized Queries

EF Core does this by default:

✅ GOOD - EF Core parameterizes automatically
var notes = await dbContext.Notes
.Where(n => n.UserId == userId) // userId is parameterized
.ToListAsync();

// Generated SQL: WHERE UserId = @p0

❌ BAD - String concatenation (SQL injection)
var query = $"SELECT * FROM Notes WHERE UserId = '{userId}'";
var notes = dbContext.Notes.FromSqlRaw(query).ToList();

Authorization Levels

LevelWhenExample
Entry PointBlazor componentCheck if user is authenticated
Command AuthorizationBefore handlerCheck if user can perform action
Domain ValidationIn aggregateCheck business rules
Row-Level SecurityIn queriesFilter by tenant/owner
// Entry point (Blazor component)
[Authorize] // ← Requires authentication
public partial class CreateNotePage
{
// Can only render if authenticated
}

// Command authorization
[Authorize(Policy = "CanCreateNote")] // ← Check specific permission
public sealed class CreateNoteCommandAuthorizer : IAuthorizationHandler { }

// Domain validation
public ResultMonad Archive()
{
if (this.OwnerId != currentUserId)
return ResultMonad.Failure("Cannot archive note owned by someone else");
}

// Row-level security (queries)
var notes = await readModels.Notes
.Where(n => n.UserId == currentUserId) // ← Only own notes
.ToListAsync();

Security Checklist

Before deploying:

  • All inputs validated on server
  • All sensitive operations require authorization
  • Authorization checked BEFORE loading/modifying data
  • Passwords hashed with proper algorithm (bcrypt, PBKDF2, argon2)
  • No plaintext passwords or API keys in code
  • Sensitive data (PII, tokens) not logged
  • Using parameterized queries (not string concatenation)
  • HTTPS enabled in production
  • Error messages generic (no information leakage)
  • Rate limiting on login/password endpoints
  • Security headers configured (HSTS, CSP, etc.)

See Also