Table of Contents

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 */index.md)
        {
            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:

  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

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

  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.