15 KiB
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:
- Get base queryable from provider
- Apply all registered
IAlterQueryableServiceinstances (in order) - Apply dynamic query filters
- 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);
}
}