550 lines
15 KiB
Markdown
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)
|