dotnet-cqrs/docs/core-features/dynamic-queries/alter-queryable-services.md

15 KiB

Alter Queryable Services

Security filtering and tenant isolation for dynamic queries.

Overview

IAlterQueryableService<TSource, TDestination> provides middleware to modify queries before execution. This enables:

  • Security filters - Apply user-specific or role-based filters
  • Tenant isolation - Multi-tenant data separation
  • Soft delete filtering - Automatically exclude deleted records
  • Row-level security - Restrict access based on ownership
  • Data privacy - Filter sensitive data per user permissions
  • Audit filters - Automatically track query context

IAlterQueryableService Interface

public interface IAlterQueryableService<TSource, TDestination>
{
    IQueryable<TSource> AlterQueryable(IQueryable<TSource> queryable);
}

Execution Order:

  1. Get base queryable from provider
  2. Apply all registered IAlterQueryableService instances (in order)
  3. Apply dynamic query filters
  4. Execute query

Tenant Isolation

Basic Tenant Filter

public interface ITenantEntity
{
    int TenantId { get; }
}

public class Product : ITenantEntity
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public int TenantId { get; set; }
}

public class TenantFilterService : IAlterQueryableService<Product, ProductDto>
{
    private readonly ITenantContext _tenantContext;

    public TenantFilterService(ITenantContext tenantContext)
    {
        _tenantContext = tenantContext;
    }

    public IQueryable<Product> AlterQueryable(IQueryable<Product> queryable)
    {
        var tenantId = _tenantContext.TenantId;
        return queryable.Where(p => p.TenantId == tenantId);
    }
}

Registration

builder.Services.AddDynamicQuery<Product, ProductDto>()
    .AddDynamicQueryWithProvider<Product, ProductQueryableProvider>()
    .AddAlterQueryable<Product, ProductDto, TenantFilterService>();

Generic Tenant Filter

public class TenantFilterService<TSource, TDestination> : IAlterQueryableService<TSource, TDestination>
    where TSource : ITenantEntity
{
    private readonly ITenantContext _tenantContext;

    public TenantFilterService(ITenantContext tenantContext)
    {
        _tenantContext = tenantContext;
    }

    public IQueryable<TSource> AlterQueryable(IQueryable<TSource> queryable)
    {
        var tenantId = _tenantContext.TenantId;
        return queryable.Where(e => e.TenantId == tenantId);
    }
}

// Register for multiple entities
builder.Services.AddAlterQueryable<Product, ProductDto, TenantFilterService<Product, ProductDto>>();
builder.Services.AddAlterQueryable<Order, OrderDto, TenantFilterService<Order, OrderDto>>();

User-Specific Filtering

Current User's Data

public class UserOrderFilterService : IAlterQueryableService<Order, OrderDto>
{
    private readonly IHttpContextAccessor _httpContextAccessor;

    public UserOrderFilterService(IHttpContextAccessor httpContextAccessor)
    {
        _httpContextAccessor = httpContextAccessor;
    }

    public IQueryable<Order> AlterQueryable(IQueryable<Order> queryable)
    {
        var userId = _httpContextAccessor.HttpContext?.User
            .FindFirst(ClaimTypes.NameIdentifier)?.Value;

        if (string.IsNullOrEmpty(userId))
        {
            // No authenticated user - return empty results
            return queryable.Where(o => false);
        }

        // Admins see all orders
        if (_httpContextAccessor.HttpContext.User.IsInRole("Admin"))
        {
            return queryable;
        }

        // Regular users see only their orders
        return queryable.Where(o => o.UserId == userId);
    }
}

Team/Organization Access

public class TeamDocumentFilterService : IAlterQueryableService<Document, DocumentDto>
{
    private readonly IHttpContextAccessor _httpContextAccessor;
    private readonly IUserService _userService;

    public TeamDocumentFilterService(
        IHttpContextAccessor httpContextAccessor,
        IUserService userService)
    {
        _httpContextAccessor = httpContextAccessor;
        _userService = userService;
    }

    public IQueryable<Document> AlterQueryable(IQueryable<Document> queryable)
    {
        var userId = _httpContextAccessor.HttpContext?.User
            .FindFirst(ClaimTypes.NameIdentifier)?.Value;

        if (string.IsNullOrEmpty(userId))
            return queryable.Where(d => false);

        var userTeamIds = _userService.GetUserTeamIds(userId);

        return queryable.Where(d =>
            d.OwnerId == userId ||                    // User owns document
            userTeamIds.Contains(d.TeamId) ||         // User's team has access
            d.IsPublic);                              // Public documents
    }
}

Soft Delete Filtering

Automatic Soft Delete Filter

public interface ISoftDeletable
{
    DateTime? DeletedAt { get; }
}

public class SoftDeleteFilterService<TSource, TDestination> : IAlterQueryableService<TSource, TDestination>
    where TSource : ISoftDeletable
{
    public IQueryable<TSource> AlterQueryable(IQueryable<TSource> queryable)
    {
        return queryable.Where(e => e.DeletedAt == null);
    }
}

// Registration
builder.Services.AddAlterQueryable<Product, ProductDto, SoftDeleteFilterService<Product, ProductDto>>();

Soft Delete with Admin Override

public class SoftDeleteFilterService<TSource, TDestination> : IAlterQueryableService<TSource, TDestination>
    where TSource : ISoftDeletable
{
    private readonly IHttpContextAccessor _httpContextAccessor;

    public SoftDeleteFilterService(IHttpContextAccessor httpContextAccessor)
    {
        _httpContextAccessor = httpContextAccessor;
    }

    public IQueryable<TSource> AlterQueryable(IQueryable<TSource> queryable)
    {
        // Admins see soft-deleted items
        if (_httpContextAccessor.HttpContext?.User.IsInRole("Admin") == true)
        {
            return queryable;
        }

        // Regular users don't see soft-deleted items
        return queryable.Where(e => e.DeletedAt == null);
    }
}

Row-Level Security

Hierarchical Access

public class HierarchicalAccessFilterService : IAlterQueryableService<Employee, EmployeeDto>
{
    private readonly IHttpContextAccessor _httpContextAccessor;
    private readonly IEmployeeRepository _employeeRepository;

    public HierarchicalAccessFilterService(
        IHttpContextAccessor httpContextAccessor,
        IEmployeeRepository employeeRepository)
    {
        _httpContextAccessor = httpContextAccessor;
        _employeeRepository = employeeRepository;
    }

    public IQueryable<Employee> AlterQueryable(IQueryable<Employee> queryable)
    {
        var currentUserId = _httpContextAccessor.HttpContext?.User
            .FindFirst(ClaimTypes.NameIdentifier)?.Value;

        if (string.IsNullOrEmpty(currentUserId))
            return queryable.Where(e => false);

        // Get all subordinate IDs for the current user
        var subordinateIds = _employeeRepository.GetSubordinateIds(currentUserId);

        // User can see themselves and their subordinates
        return queryable.Where(e =>
            e.UserId == currentUserId ||
            subordinateIds.Contains(e.Id));
    }
}

Data Privacy Filters

Privacy Level Filtering

public class PrivacyFilterService : IAlterQueryableService<UserProfile, UserProfileDto>
{
    private readonly IHttpContextAccessor _httpContextAccessor;

    public PrivacyFilterService(IHttpContextAccessor httpContextAccessor)
    {
        _httpContextAccessor = httpContextAccessor;
    }

    public IQueryable<UserProfile> AlterQueryable(IQueryable<UserProfile> queryable)
    {
        var currentUserId = _httpContextAccessor.HttpContext?.User
            .FindFirst(ClaimTypes.NameIdentifier)?.Value;

        if (string.IsNullOrEmpty(currentUserId))
        {
            // Unauthenticated users see only public profiles
            return queryable.Where(p => p.PrivacyLevel == PrivacyLevel.Public);
        }

        // Authenticated users see public and friends-only profiles
        return queryable.Where(p =>
            p.PrivacyLevel == PrivacyLevel.Public ||
            (p.PrivacyLevel == PrivacyLevel.FriendsOnly && p.UserId == currentUserId) ||
            p.UserId == currentUserId); // Users always see their own profile
    }
}

Multiple Filters

Chaining Multiple Services

// Service 1: Tenant isolation
public class TenantFilterService : IAlterQueryableService<Product, ProductDto>
{
    private readonly ITenantContext _tenantContext;

    public IQueryable<Product> AlterQueryable(IQueryable<Product> queryable)
    {
        return queryable.Where(p => p.TenantId == _tenantContext.TenantId);
    }
}

// Service 2: Soft delete filtering
public class SoftDeleteFilterService : IAlterQueryableService<Product, ProductDto>
{
    public IQueryable<Product> AlterQueryable(IQueryable<Product> queryable)
    {
        return queryable.Where(p => p.DeletedAt == null);
    }
}

// Service 3: Active items only
public class ActiveFilterService : IAlterQueryableService<Product, ProductDto>
{
    public IQueryable<Product> AlterQueryable(IQueryable<Product> queryable)
    {
        return queryable.Where(p => p.IsActive);
    }
}

// Registration - executed in order
builder.Services.AddDynamicQuery<Product, ProductDto>()
    .AddDynamicQueryWithProvider<Product, ProductQueryableProvider>()
    .AddAlterQueryable<Product, ProductDto, TenantFilterService>()
    .AddAlterQueryable<Product, ProductDto, SoftDeleteFilterService>()
    .AddAlterQueryable<Product, ProductDto, ActiveFilterService>();

// Resulting query:
// FROM Products
// WHERE TenantId = @tenantId
//   AND DeletedAt IS NULL
//   AND IsActive = true
//   AND [user-specified filters]

Conditional Filters

Feature Flag Filter

public class FeatureFlagFilterService : IAlterQueryableService<Product, ProductDto>
{
    private readonly IFeatureFlagService _featureFlagService;

    public FeatureFlagFilterService(IFeatureFlagService featureFlagService)
    {
        _featureFlagService = featureFlagService;
    }

    public IQueryable<Product> AlterQueryable(IQueryable<Product> queryable)
    {
        // If beta features are disabled, hide beta products
        if (!_featureFlagService.IsEnabled("BetaProducts"))
        {
            return queryable.Where(p => !p.IsBeta);
        }

        return queryable;
    }
}

Environment-Specific Filter

public class EnvironmentFilterService : IAlterQueryableService<Product, ProductDto>
{
    private readonly IWebHostEnvironment _environment;

    public EnvironmentFilterService(IWebHostEnvironment environment)
    {
        _environment = environment;
    }

    public IQueryable<Product> AlterQueryable(IQueryable<Product> queryable)
    {
        // In production, hide test products
        if (_environment.IsProduction())
        {
            return queryable.Where(p => !p.IsTestData);
        }

        return queryable;
    }
}

Testing Alter Queryable Services

Unit Tests

public class TenantFilterServiceTests
{
    private readonly Mock<ITenantContext> _mockTenantContext;
    private readonly TenantFilterService _service;

    public TenantFilterServiceTests()
    {
        _mockTenantContext = new Mock<ITenantContext>();
        _service = new TenantFilterService(_mockTenantContext.Object);
    }

    [Fact]
    public void AlterQueryable_FiltersByTenantId()
    {
        // Arrange
        _mockTenantContext.Setup(c => c.TenantId).Returns(123);

        var products = new List<Product>
        {
            new() { Id = 1, Name = "Product 1", TenantId = 123 },
            new() { Id = 2, Name = "Product 2", TenantId = 456 },
            new() { Id = 3, Name = "Product 3", TenantId = 123 }
        }.AsQueryable();

        // Act
        var result = _service.AlterQueryable(products).ToList();

        // Assert
        Assert.Equal(2, result.Count);
        Assert.All(result, p => Assert.Equal(123, p.TenantId));
    }
}

Performance Considerations

Indexing for Filters

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Product>(entity =>
    {
        // Index columns used in AlterQueryableService
        entity.HasIndex(e => e.TenantId);
        entity.HasIndex(e => e.DeletedAt);
        entity.HasIndex(e => e.IsActive);

        // Composite index for multiple filters
        entity.HasIndex(e => new { e.TenantId, e.DeletedAt, e.IsActive });
    });
}

Avoid N+1 Queries

// ❌ Bad - Multiple database calls
public IQueryable<Document> AlterQueryable(IQueryable<Document> queryable)
{
    var userId = GetCurrentUserId();

    foreach (var teamId in GetUserTeamIds(userId)) // Multiple DB calls
    {
        queryable = queryable.Where(d => d.TeamId == teamId);
    }

    return queryable;
}

// ✅ Good - Single database call
public IQueryable<Document> AlterQueryable(IQueryable<Document> queryable)
{
    var userId = GetCurrentUserId();
    var teamIds = GetUserTeamIds(userId).ToList(); // Materialize once

    return queryable.Where(d => teamIds.Contains(d.TeamId));
}

Best Practices

DO

  • Apply security filters in IAlterQueryableService
  • Index columns used in alter queryable filters
  • Test alter queryable services independently
  • Use generic implementations for common patterns
  • Chain multiple services for separation of concerns
  • Return queryable.Where(x => false) for unauthorized access

DON'T

  • Don't perform synchronous I/O (ToList, Count, etc.)
  • Don't skip security filters for "trusted" users
  • Don't modify the queryable parameter directly
  • Don't throw exceptions for access denied (return empty results)
  • Don't perform complex joins in alter queryable
  • Don't bypass filters based on user input

Common Patterns

Pattern: Base Security Service

public abstract class SecurityFilterService<TSource, TDestination> : IAlterQueryableService<TSource, TDestination>
    where TSource : ISecureEntity
{
    protected readonly IHttpContextAccessor _httpContextAccessor;

    protected SecurityFilterService(IHttpContextAccessor httpContextAccessor)
    {
        _httpContextAccessor = httpContextAccessor;
    }

    public IQueryable<TSource> AlterQueryable(IQueryable<TSource> queryable)
    {
        var user = _httpContextAccessor.HttpContext?.User;

        if (user == null || !user.Identity.IsAuthenticated)
            return queryable.Where(e => false);

        if (user.IsInRole("Admin"))
            return queryable;

        return ApplyUserFilters(queryable, user);
    }

    protected abstract IQueryable<TSource> ApplyUserFilters(IQueryable<TSource> queryable, ClaimsPrincipal user);
}

// Usage
public class ProductSecurityFilterService : SecurityFilterService<Product, ProductDto>
{
    public ProductSecurityFilterService(IHttpContextAccessor httpContextAccessor)
        : base(httpContextAccessor)
    {
    }

    protected override IQueryable<Product> ApplyUserFilters(IQueryable<Product> queryable, ClaimsPrincipal user)
    {
        var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
        return queryable.Where(p => p.CreatedBy == userId);
    }
}

See Also