Skip to content

Authorization

Model-bound queries support authorization through standard ASP.NET Core authorization attributes as well as the convenient [Roles] attribute provided by the Arc.

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();
}

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.

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 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);
}

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();
}

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();
}
}

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();
}

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);
}

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();
}
}

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();
}

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 (userId is not null /* && user owns the account being accessed */)
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}

Use [AllowAnonymous] to allow public access to specific query methods. This attribute bypasses all authorization requirements, including class-level [Authorize] attributes and role requirements.

The authorization system evaluates attributes in the following order:

  1. Method-level [AllowAnonymous] - If present on the method, allows anonymous access immediately
  2. Method-level [Authorize] or [Roles] - If present on the method, these take precedence over class-level attributes
  3. Class-level [AllowAnonymous] - If present on the class (and no method-level authorization), allows anonymous access
  4. Class-level [Authorize] or [Roles] - Applied when no method-level attributes are specified

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();
}

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();
}
  • 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
  1. Apply authorization at the appropriate level - Use class-level for broad protection, method-level for specific requirements
  2. Use the [Roles] attribute - More convenient than the standard [Authorize(Roles = "...")] syntax
  3. Implement defense in depth - Combine multiple authorization layers when appropriate
  4. Consider user context - Use IHttpContextAccessor to access current user information for dynamic authorization
  5. Test authorization - Ensure unauthorized users cannot access protected queries
  6. Use policies for complex logic - Implement custom authorization policies for domain-specific rules
  7. Be explicit about public access - Use [AllowAnonymous] to clearly indicate intentionally public methods
  8. Log authorization failures - Monitor and log unauthorized access attempts
  9. 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. The proxy generator automatically creates TypeScript types that respect your authorization constraints, helping prevent unauthorized client-side calls.