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
| Level | When | Example |
|---|---|---|
| Entry Point | Blazor component | Check if user is authenticated |
| Command Authorization | Before handler | Check if user can perform action |
| Domain Validation | In aggregate | Check business rules |
| Row-Level Security | In queries | Filter 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
- Authentication Overview - Auth flows and setup
- Command Validators & Authorizers - Validator patterns
- Multi-Tenancy Best Practices - Tenant isolation