Authorization
Model-bound queries support authorization through standard ASP.NET Core authorization attributes as well as the convenient [Roles] attribute provided by the Arc.
Using the Authorize Attribute
You can secure query methods using the standard [Authorize] attribute:
[ReadModel]
public record DebitAccount(AccountId Id, AccountName Name, CustomerId Owner, decimal Balance)
{
[Authorize]
public static IEnumerable<DebitAccount> GetAllAccounts(IMongoCollection<DebitAccount> collection) =>
collection.Find(_ => true).ToList();
[Authorize(Roles = "Admin,Manager")]
public static IEnumerable<DebitAccount> GetSensitiveAccounts(IMongoCollection<DebitAccount> collection) =>
collection.Find(a => a.Balance > 100000).ToList();
}
Using the Roles Attribute
The Arc provides a more convenient [Roles] attribute for cleaner syntax when specifying multiple roles:
[ReadModel]
public record DebitAccount(AccountId Id, AccountName Name, CustomerId Owner, decimal Balance)
{
[Roles("Admin", "Auditor")]
public static IEnumerable<DebitAccount> GetAdminAccounts(IMongoCollection<DebitAccount> collection) =>
collection.Find(_ => true).ToList();
[Roles("Manager")]
public static IEnumerable<DebitAccount> GetManagerAccounts(IMongoCollection<DebitAccount> collection) =>
collection.Find(a => a.Owner != CustomerId.Empty).ToList();
}
The user needs to have at least one of the specified roles to execute the query.
Read Model-Level Authorization
You can apply authorization at the read model level to protect all query methods:
[ReadModel]
[Roles("User")] // All methods require at least "User" role
public record DebitAccount(AccountId Id, AccountName Name, CustomerId Owner, decimal Balance)
{
public static IEnumerable<DebitAccount> GetAllAccounts(IMongoCollection<DebitAccount> collection) =>
collection.Find(_ => true).ToList();
[Roles("Admin")] // Override read model-level authorization
public static IEnumerable<DebitAccount> GetAdminOnlyAccounts(IMongoCollection<DebitAccount> collection) =>
collection.Find(a => a.Balance < 0).ToList();
}
Method-Level Authorization Override
Method-level authorization attributes override class-level ones:
[ReadModel]
[Authorize] // Require authentication for all methods
public record DebitAccount(AccountId Id, AccountName Name, CustomerId Owner, decimal Balance)
{
// Inherits class-level [Authorize] - requires authentication
public static IEnumerable<DebitAccount> GetUserAccounts(IMongoCollection<DebitAccount> collection) =>
collection.Find(_ => true).ToList();
[Roles("Admin", "Manager")] // Overrides class-level, requires specific roles
public static IEnumerable<DebitAccount> GetPrivilegedAccounts(IMongoCollection<DebitAccount> collection) =>
collection.Find(a => a.Balance > 50000).ToList();
[AllowAnonymous] // Completely overrides class-level authorization
public static int GetTotalAccountCount(IMongoCollection<DebitAccount> collection) =>
(int)collection.CountDocuments(_ => true);
}
Policy-Based Authorization
For more complex authorization scenarios, you can use policy-based authorization:
[ReadModel]
public record DebitAccount(AccountId Id, AccountName Name, CustomerId Owner, decimal Balance)
{
[Authorize(Policy = "RequireAccountAccess")]
public static DebitAccount GetAccountById(
AccountId id,
IMongoCollection<DebitAccount> collection) =>
collection.Find(a => a.Id == id).FirstOrDefault();
[Authorize(Policy = "RequireHighValueAccess")]
public static IEnumerable<DebitAccount> GetHighValueAccounts(
IMongoCollection<DebitAccount> collection) =>
collection.Find(a => a.Balance > 1000000).ToList();
}
Context-Dependent Authorization
Access user context within query methods for dynamic authorization:
[ReadModel]
public record DebitAccount(AccountId Id, AccountName Name, CustomerId Owner, decimal Balance)
{
[Authorize]
public static IEnumerable<DebitAccount> GetMyAccounts(
IMongoCollection<DebitAccount> collection,
IHttpContextAccessor httpContextAccessor)
{
var userId = httpContextAccessor.HttpContext?.User?.FindFirst("sub")?.Value;
if (string.IsNullOrEmpty(userId))
return Enumerable.Empty<DebitAccount>();
var customerId = new CustomerId(Guid.Parse(userId));
return collection.Find(a => a.Owner == customerId).ToList();
}
[Authorize]
public static DebitAccount? GetAccountIfOwned(
AccountId accountId,
IMongoCollection<DebitAccount> collection,
IHttpContextAccessor httpContextAccessor)
{
var userId = httpContextAccessor.HttpContext?.User?.FindFirst("sub")?.Value;
if (string.IsNullOrEmpty(userId))
return null;
var customerId = new CustomerId(Guid.Parse(userId));
return collection.Find(a => a.Id == accountId && a.Owner == customerId).FirstOrDefault();
}
}
Role Hierarchies
Implement role hierarchies with custom authorization:
[ReadModel]
public record DebitAccount(AccountId Id, AccountName Name, CustomerId Owner, decimal Balance)
{
[Roles("User")] // Basic users can see their own accounts
public static IEnumerable<DebitAccount> GetBasicAccounts(IMongoCollection<DebitAccount> collection) =>
collection.Find(a => a.Balance >= 0).ToList();
[Roles("Manager", "Admin")] // Managers and admins can see more
public static IEnumerable<DebitAccount> GetManagerAccounts(IMongoCollection<DebitAccount> collection) =>
collection.Find(a => a.Balance > -1000).ToList();
[Roles("Admin")] // Only admins can see all accounts including severely overdrawn
public static IEnumerable<DebitAccount> GetAllAccountsIncludingProblematic(IMongoCollection<DebitAccount> collection) =>
collection.Find(_ => true).ToList();
}
Observable Query Authorization
Authorization also applies to observable queries:
[ReadModel]
public record DebitAccount(AccountId Id, AccountName Name, CustomerId Owner, decimal Balance)
{
[Authorize]
public static ISubject<IEnumerable<DebitAccount>> GetAccountsObservable(
IMongoCollection<DebitAccount> collection) =>
collection.Observe();
[Roles("Admin")]
public static ISubject<IEnumerable<DebitAccount>> GetAdminAccountsObservable(
IMongoCollection<DebitAccount> collection) =>
collection.Observe(a => a.Balance < 0);
}
Authorization with Query Parameters
Combine authorization with parameter-based filtering:
[ReadModel]
public record DebitAccount(AccountId Id, AccountName Name, CustomerId Owner, decimal Balance)
{
[Authorize]
public static IEnumerable<DebitAccount> GetAccountsByOwner(
CustomerId ownerId,
IMongoCollection<DebitAccount> collection,
IHttpContextAccessor httpContextAccessor)
{
var currentUserId = httpContextAccessor.HttpContext?.User?.FindFirst("sub")?.Value;
var isAdmin = httpContextAccessor.HttpContext?.User?.IsInRole("Admin") == true;
// Users can only see their own accounts unless they're admin
if (!isAdmin && currentUserId != ownerId.Value.ToString())
{
return Enumerable.Empty<DebitAccount>();
}
return collection.Find(a => a.Owner == ownerId).ToList();
}
}
Custom Authorization Attributes
Create custom authorization attributes for domain-specific logic:
public class RequireAccountOwnershipAttribute : AuthorizeAttribute
{
public RequireAccountOwnershipAttribute() : base("RequireAccountOwnership") { }
}
[ReadModel]
public record DebitAccount(AccountId Id, AccountName Name, CustomerId Owner, decimal Balance)
{
[RequireAccountOwnership]
public static DebitAccount GetAccountDetails(
AccountId id,
IMongoCollection<DebitAccount> collection) =>
collection.Find(a => a.Id == id).FirstOrDefault();
}
Authorization Results
When authorization fails, the query pipeline automatically returns an unauthorized result. The query method will not be executed:
// In your policy handler or middleware
public class AccountOwnershipHandler : AuthorizationHandler<AccountOwnershipRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
AccountOwnershipRequirement requirement)
{
var userId = context.User.FindFirst("sub")?.Value;
// Check if user owns the account being accessed
if (/* ownership check logic */)
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
Anonymous Access
Use [AllowAnonymous] to allow public access to specific query methods. This attribute bypasses all authorization requirements, including class-level [Authorize] attributes and role requirements.
How AllowAnonymous Works
The authorization system evaluates attributes in the following order:
- Method-level
[AllowAnonymous]- If present on the method, allows anonymous access immediately - Method-level
[Authorize]or[Roles]- If present on the method, these take precedence over class-level attributes - Class-level
[AllowAnonymous]- If present on the class (and no method-level authorization), allows anonymous access - Class-level
[Authorize]or[Roles]- Applied when no method-level attributes are specified
Method-Level AllowAnonymous
Override class-level authorization for specific query methods:
[ReadModel]
[Authorize] // Require authentication by default
public record DebitAccount(AccountId Id, AccountName Name, CustomerId Owner, decimal Balance)
{
[AllowAnonymous] // Override class-level authorization for public data
public static int GetTotalAccountCount(IMongoCollection<DebitAccount> collection) =>
(int)collection.CountDocuments(_ => true);
[AllowAnonymous]
public static decimal GetAverageBalance(IMongoCollection<DebitAccount> collection)
{
var accounts = collection.Find(_ => true).ToList();
return accounts.Count > 0 ? accounts.Average(a => a.Balance) : 0;
}
// This method requires authentication (inherits from class)
public static IEnumerable<DebitAccount> GetAllAccounts(IMongoCollection<DebitAccount> collection) =>
collection.Find(_ => true).ToList();
}
Class-Level AllowAnonymous
Apply [AllowAnonymous] at the class level to make all query methods publicly accessible by default:
[ReadModel]
[AllowAnonymous] // All methods are publicly accessible by default
public record PublicStatistics(string Category, int Count)
{
public static IEnumerable<PublicStatistics> GetAllStatistics(
IMongoCollection<PublicStatistics> collection) =>
collection.Find(_ => true).ToList();
public static PublicStatistics? GetByCategory(
string category,
IMongoCollection<PublicStatistics> collection) =>
collection.Find(s => s.Category == category).FirstOrDefault();
[Authorize] // Override class-level: this specific method requires authentication
public static IEnumerable<PublicStatistics> GetSensitiveStatistics(
IMongoCollection<PublicStatistics> collection) =>
collection.Find(s => s.Category.StartsWith("Internal")).ToList();
}
Common Use Cases for AllowAnonymous
- Public statistics or counts - Aggregate data that doesn't expose sensitive information
- Product catalogs - Public product listings for e-commerce sites
- Public content - Blog posts, articles, or documentation
- Health checks - System status information for monitoring
- Search endpoints - Public search functionality
Best Practices
- Apply authorization at the appropriate level - Use class-level for broad protection, method-level for specific requirements
- Use the
[Roles]attribute - More convenient than the standard[Authorize(Roles = "...")]syntax - Implement defense in depth - Combine multiple authorization layers when appropriate
- Consider user context - Use
IHttpContextAccessorto access current user information for dynamic authorization - Test authorization - Ensure unauthorized users cannot access protected queries
- Use policies for complex logic - Implement custom authorization policies for domain-specific rules
- Be explicit about public access - Use
[AllowAnonymous]to clearly indicate intentionally public methods - Log authorization failures - Monitor and log unauthorized access attempts
- Keep authorization simple - Complex authorization logic should be in services, not query methods
Note: Authorization is evaluated before the query method is called. If authorization fails, the query will not be executed and the result will indicate the authorization failure.
Note: The proxy generator automatically creates TypeScript types that respect your authorization constraints, helping prevent unauthorized client-side calls.