# Alter Queryable Services Security filtering and tenant isolation for dynamic queries. ## Overview `IAlterQueryableService` 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 { IQueryable AlterQueryable(IQueryable 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 { private readonly ITenantContext _tenantContext; public TenantFilterService(ITenantContext tenantContext) { _tenantContext = tenantContext; } public IQueryable AlterQueryable(IQueryable queryable) { var tenantId = _tenantContext.TenantId; return queryable.Where(p => p.TenantId == tenantId); } } ``` ### Registration ```csharp builder.Services.AddDynamicQuery() .AddDynamicQueryWithProvider() .AddAlterQueryable(); ``` ### Generic Tenant Filter ```csharp public class TenantFilterService : IAlterQueryableService where TSource : ITenantEntity { private readonly ITenantContext _tenantContext; public TenantFilterService(ITenantContext tenantContext) { _tenantContext = tenantContext; } public IQueryable AlterQueryable(IQueryable queryable) { var tenantId = _tenantContext.TenantId; return queryable.Where(e => e.TenantId == tenantId); } } // Register for multiple entities builder.Services.AddAlterQueryable>(); builder.Services.AddAlterQueryable>(); ``` ## User-Specific Filtering ### Current User's Data ```csharp public class UserOrderFilterService : IAlterQueryableService { private readonly IHttpContextAccessor _httpContextAccessor; public UserOrderFilterService(IHttpContextAccessor httpContextAccessor) { _httpContextAccessor = httpContextAccessor; } public IQueryable AlterQueryable(IQueryable 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 { private readonly IHttpContextAccessor _httpContextAccessor; private readonly IUserService _userService; public TeamDocumentFilterService( IHttpContextAccessor httpContextAccessor, IUserService userService) { _httpContextAccessor = httpContextAccessor; _userService = userService; } public IQueryable AlterQueryable(IQueryable 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 : IAlterQueryableService where TSource : ISoftDeletable { public IQueryable AlterQueryable(IQueryable queryable) { return queryable.Where(e => e.DeletedAt == null); } } // Registration builder.Services.AddAlterQueryable>(); ``` ### Soft Delete with Admin Override ```csharp public class SoftDeleteFilterService : IAlterQueryableService where TSource : ISoftDeletable { private readonly IHttpContextAccessor _httpContextAccessor; public SoftDeleteFilterService(IHttpContextAccessor httpContextAccessor) { _httpContextAccessor = httpContextAccessor; } public IQueryable AlterQueryable(IQueryable 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 { private readonly IHttpContextAccessor _httpContextAccessor; private readonly IEmployeeRepository _employeeRepository; public HierarchicalAccessFilterService( IHttpContextAccessor httpContextAccessor, IEmployeeRepository employeeRepository) { _httpContextAccessor = httpContextAccessor; _employeeRepository = employeeRepository; } public IQueryable AlterQueryable(IQueryable 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 { private readonly IHttpContextAccessor _httpContextAccessor; public PrivacyFilterService(IHttpContextAccessor httpContextAccessor) { _httpContextAccessor = httpContextAccessor; } public IQueryable AlterQueryable(IQueryable 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 { private readonly ITenantContext _tenantContext; public IQueryable AlterQueryable(IQueryable queryable) { return queryable.Where(p => p.TenantId == _tenantContext.TenantId); } } // Service 2: Soft delete filtering public class SoftDeleteFilterService : IAlterQueryableService { public IQueryable AlterQueryable(IQueryable queryable) { return queryable.Where(p => p.DeletedAt == null); } } // Service 3: Active items only public class ActiveFilterService : IAlterQueryableService { public IQueryable AlterQueryable(IQueryable queryable) { return queryable.Where(p => p.IsActive); } } // Registration - executed in order builder.Services.AddDynamicQuery() .AddDynamicQueryWithProvider() .AddAlterQueryable() .AddAlterQueryable() .AddAlterQueryable(); // 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 { private readonly IFeatureFlagService _featureFlagService; public FeatureFlagFilterService(IFeatureFlagService featureFlagService) { _featureFlagService = featureFlagService; } public IQueryable AlterQueryable(IQueryable 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 { private readonly IWebHostEnvironment _environment; public EnvironmentFilterService(IWebHostEnvironment environment) { _environment = environment; } public IQueryable AlterQueryable(IQueryable 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 _mockTenantContext; private readonly TenantFilterService _service; public TenantFilterServiceTests() { _mockTenantContext = new Mock(); _service = new TenantFilterService(_mockTenantContext.Object); } [Fact] public void AlterQueryable_FiltersByTenantId() { // Arrange _mockTenantContext.Setup(c => c.TenantId).Returns(123); var products = new List { 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(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 AlterQueryable(IQueryable 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 AlterQueryable(IQueryable 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 : IAlterQueryableService where TSource : ISecureEntity { protected readonly IHttpContextAccessor _httpContextAccessor; protected SecurityFilterService(IHttpContextAccessor httpContextAccessor) { _httpContextAccessor = httpContextAccessor; } public IQueryable AlterQueryable(IQueryable 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 ApplyUserFilters(IQueryable queryable, ClaimsPrincipal user); } // Usage public class ProductSecurityFilterService : SecurityFilterService { public ProductSecurityFilterService(IHttpContextAccessor httpContextAccessor) : base(httpContextAccessor) { } protected override IQueryable ApplyUserFilters(IQueryable 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)