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

550 lines
15 KiB
Markdown

# 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
```csharp
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
```csharp
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
```csharp
builder.Services.AddDynamicQuery<Product, ProductDto>()
.AddDynamicQueryWithProvider<Product, ProductQueryableProvider>()
.AddAlterQueryable<Product, ProductDto, TenantFilterService>();
```
### Generic Tenant Filter
```csharp
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
```csharp
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
```csharp
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
```csharp
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
```csharp
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
```csharp
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
```csharp
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
```csharp
// 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
```csharp
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
```csharp
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
```csharp
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
```csharp
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
```csharp
// ❌ 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
```csharp
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
- [Dynamic Queries Overview](README.md)
- [Queryable Providers](queryable-providers.md)
- [Interceptors](interceptors.md)
- [Query Authorization](../queries/query-authorization.md)
- [Best Practices: Security](../../best-practices/security.md)