Table of Contents

Authorization

Arc.Core provides authorization capabilities through attributes that protect your commands and queries. This allows you to control access based on authentication status and user roles.

Overview

Authorization in Arc.Core is attribute-based and supports:

  • Authentication Requirements - Require users to be authenticated
  • Role-Based Authorization - Restrict access to specific roles
  • Anonymous Access - Explicitly allow unauthenticated access
  • Flexible Application - Apply at class or method level

Authorization Attributes

Arc.Core provides authorization through attributes:

  • [Authorize] - Requires authentication and optionally specifies roles or policies
  • [Roles] - Convenience attribute for role-based authorization
  • [AllowAnonymous] - Explicitly allows unauthenticated access

Basic Usage

Requiring Authentication

Require users to be authenticated without specifying roles:

using Cratis.Arc.Commands;
using Cratis.Arc.Authorization;

[Authorize]
public record UpdateProfile(string Name, string Email) : ICommand;

public class UpdateProfileHandler : ICommandHandler<UpdateProfile>
{
    public Task<CommandResult> Handle(UpdateProfile command, CommandContext context)
    {
        // Only authenticated users can reach this handler
        return Task.FromResult(CommandResult.Success);
    }
}

Role-Based Authorization

Restrict access to specific roles:

// Using Authorize attribute
[Authorize(Roles = "Admin")]
public record DeleteUser(Guid UserId) : ICommand;

// Using Roles attribute (more readable for multiple roles)
[Roles("Admin", "Manager")]
public record ApproveRequest(Guid RequestId) : ICommand;

Anonymous Access

Explicitly allow anonymous access (useful when you have a fallback policy requiring authentication):

[AllowAnonymous]
public record GetPublicData() : IQuery<PublicDataDto>;

Applying Authorization

Authorization attributes can be applied at different levels:

Class-Level Authorization

Apply to all commands or queries in a type:

[Authorize]
public record UpdateSettings(string Key, string Value) : ICommand;

[Roles("Admin")]
public record DeleteAccount(Guid AccountId) : ICommand;

Handler-Level Authorization

Apply authorization to handler classes (less common but supported):

[Authorize]
public class SecureCommandHandler : ICommandHandler<SecureCommand>
{
    public Task<CommandResult> Handle(SecureCommand command, CommandContext context)
    {
        // Requires authentication
    }
}

Role-Based Scenarios

Single Role Requirement

// User must have the "Admin" role
[Roles("Admin")]
public record CreateAdmin(string Username) : ICommand;

Multiple Role Requirement (OR Logic)

Users need at least one of the specified roles:

// User must have either "Admin" OR "Manager" role
[Roles("Admin", "Manager")]
public record ViewAuditLog() : IQuery<AuditLogDto>;

Combining with Standard Authorize

You can mix [Authorize] and [Roles] if needed:

// Requires authentication via specific scheme AND a role
[Authorize(AuthenticationSchemes = "Bearer")]
[Roles("Admin")]
public record SecureAdminCommand(string Data) : ICommand;

AllowAnonymous Attribute

The [AllowAnonymous] attribute explicitly allows unauthenticated access:

[AllowAnonymous]
public record GetPublicCatalog() : IQuery<CatalogDto>;

public class GetPublicCatalogHandler : IQueryHandler<GetPublicCatalog, CatalogDto>
{
    public Task<CatalogDto> Handle(GetPublicCatalog query, QueryContext context)
    {
        // Anyone can access this, even without authentication
        return Task.FromResult(new CatalogDto());
    }
}

Authorization Inheritance Rules

Authorization attributes follow specific inheritance rules:

Scenario Result
[Authorize] on class Requires authentication for all operations
[Roles] on class Requires specified roles for all operations
[AllowAnonymous] on class Allows anonymous access for all operations
Method attribute overrides class Method-level attribute takes precedence
Both [Authorize] and [AllowAnonymous] on same target Error - throws AmbiguousAuthorizationLevel

Examples

// Class-level authorization applies to all operations
[Authorize]
public record UserOperations
{
    // Inherits [Authorize] from the record
    public record GetProfile(Guid UserId) : IQuery<ProfileDto>;
    
    // Overrides with more specific role requirement
    [Roles("Admin")]
    public record DeleteProfile(Guid UserId) : ICommand;
}

// Class-level anonymous access
[AllowAnonymous]
public record PublicQueries
{
    // Inherits [AllowAnonymous] from record
    public record GetProducts() : IQuery<ProductDto[]>;
    
    // Override to require authentication for specific operation
    [Authorize]
    public record GetUserProducts() : IQuery<ProductDto[]>;
}

// ERROR: This will throw AmbiguousAuthorizationLevel at startup
[AllowAnonymous]
[Authorize]  // Cannot have both!
public record InvalidCommand : ICommand;

Working with Claims

When a request is authenticated, the ClaimsPrincipal is available through the command or query context:

public class GetUserDataHandler : IQueryHandler<GetUserData, UserDataDto>
{
    public Task<UserDataDto> Handle(GetUserData query, QueryContext context)
    {
        // Access the authenticated user's claims
        var userId = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
        var userName = context.User.FindFirst(ClaimTypes.Name)?.Value;
        var roles = context.User.Claims
            .Where(c => c.Type == ClaimTypes.Role)
            .Select(c => c.Value)
            .ToArray();

        // Use claims for authorization logic
        return Task.FromResult(new UserDataDto(userId, userName, roles));
    }
}

Authorization Results

When authorization fails, Arc.Core automatically returns appropriate HTTP status codes:

Scenario HTTP Status Code Description
Not Authenticated 401 Unauthorized User is not authenticated
Not Authorized 403 Forbidden User is authenticated but doesn't have required permissions

Custom Authorization Logic

For more complex authorization scenarios, implement custom logic in your handlers:

[Authorize]
public record UpdateOrder(Guid OrderId, string Data) : ICommand;

public class UpdateOrderHandler(IOrderRepository orders) 
    : ICommandHandler<UpdateOrder>
{
    public async Task<CommandResult> Handle(
        UpdateOrder command, 
        CommandContext context)
    {
        var userId = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
        var order = await orders.GetById(command.OrderId);

        // Custom authorization: user can only update their own orders
        if (order.UserId != userId)
        {
            return CommandResult.Forbidden(
                context.CorrelationId, 
                "You can only update your own orders");
        }

        // Process the update
        order.Update(command.Data);
        await orders.Save(order);

        return CommandResult.Success;
    }
}

Policy-Based Authorization

While Arc.Core focuses on attribute-based authorization, you can implement policy-based logic in your handlers:

[Authorize]
public record ApproveExpense(Guid ExpenseId) : ICommand;

public class ApproveExpenseHandler(
    IExpenseRepository expenses,
    IAuthorizationService authService) 
    : ICommandHandler<ApproveExpense>
{
    public async Task<CommandResult> Handle(
        ApproveExpense command, 
        CommandContext context)
    {
        var expense = await expenses.GetById(command.ExpenseId);

        // Custom policy: managers can approve up to $1000, directors unlimited
        var isManager = context.User.IsInRole("Manager");
        var isDirector = context.User.IsInRole("Director");

        if (expense.Amount > 1000 && !isDirector)
        {
            return CommandResult.Forbidden(
                context.CorrelationId,
                "Only directors can approve expenses over $1000");
        }

        if (!isManager && !isDirector)
        {
            return CommandResult.Forbidden(
                context.CorrelationId,
                "Only managers and directors can approve expenses");
        }

        // Process approval
        expense.Approve(context.User.Identity?.Name ?? "Unknown");
        await expenses.Save(expense);

        return CommandResult.Success;
    }
}

Best Practices

Secure by Default

Apply authorization at the broadest scope possible and override only when necessary:

// Good: Secure by default, explicit opt-out
[Authorize]
public record SecureOperations
{
    public record CreateResource() : ICommand;
    public record UpdateResource(Guid Id) : ICommand;
    
    // Explicitly allow anonymous for specific operation
    [AllowAnonymous]
    public record GetPublicResources() : IQuery<ResourceDto[]>;
}

Use Roles Attribute for Clarity

Use [Roles] for better readability when specifying multiple roles:

// More readable
[Roles("Admin", "Manager", "Supervisor")]
public record ReviewApplication(Guid ApplicationId) : ICommand;

// Less readable
[Authorize(Roles = "Admin,Manager,Supervisor")]
public record ReviewApplication(Guid ApplicationId) : ICommand;

Avoid Ambiguous Authorization

Never apply both [Authorize] and [AllowAnonymous] to the same target:

// ERROR: Will throw AmbiguousAuthorizationLevel
[Authorize]
[AllowAnonymous]
public record AmbiguousCommand : ICommand;

Document Authorization Requirements

Add XML documentation to clarify authorization requirements:

/// <summary>
/// Deletes a user account. Requires Admin role.
/// </summary>
[Roles("Admin")]
public record DeleteUser(Guid UserId) : ICommand;

Validate Claims in Handlers

For complex authorization logic, validate claims within handlers:

public Task<CommandResult> Handle(SecureCommand command, CommandContext context)
{
    var userId = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
    if (string.IsNullOrEmpty(userId))
    {
        return Task.FromResult(
            CommandResult.Unauthorized(context.CorrelationId));
    }

    // Continue with business logic
}

Integration with Authentication

Authorization works hand-in-hand with authentication. See the Authentication documentation for how to implement custom authentication handlers that provide the claims used by authorization.

Testing Authorization

When testing authorization, ensure your test setup includes proper claims:

public class SecureCommandTests
{
    [Fact]
    public async Task should_allow_admin_users()
    {
        var handler = new SecureCommandHandler();
        var context = new CommandContext
        {
            User = new ClaimsPrincipal(new ClaimsIdentity(new[]
            {
                new Claim(ClaimTypes.NameIdentifier, "user-123"),
                new Claim(ClaimTypes.Role, "Admin")
            }, "Test"))
        };

        var result = await handler.Handle(new SecureCommand(), context);

        result.IsSuccess.ShouldBeTrue();
    }

    [Fact]
    public async Task should_deny_non_admin_users()
    {
        var handler = new SecureCommandHandler();
        var context = new CommandContext
        {
            User = new ClaimsPrincipal(new ClaimsIdentity(new[]
            {
                new Claim(ClaimTypes.NameIdentifier, "user-123"),
                new Claim(ClaimTypes.Role, "User")
            }, "Test"))
        };

        var result = await handler.Handle(new SecureCommand(), context);

        result.IsSuccess.ShouldBeFalse();
    }
}

Next Steps

  • Authentication - Implement custom authentication handlers
  • Getting Started - Learn more about Arc.Core basics
  • Identity - Integrate with Arc's identity system
  • Commands - Learn about command patterns and authorization
  • Queries - Discover query features with authorization