--- task_id: sprint_2_story_2_task_3 story: sprint_2_story_2 status: not_started estimated_hours: 3 created_date: 2025-11-05 assignee: Backend Team --- # Task 3: Add Multi-Tenant Isolation **Story**: Story 2 - Audit Log Core Features (Phase 2) **Estimated**: 3 hours ## Description Ensure audit logs are properly isolated by TenantId to prevent cross-tenant data access. Implement global query filters and verify with comprehensive tests. ## Acceptance Criteria - [ ] Global query filter applied to AuditLog entity - [ ] TenantId automatically set on audit log creation - [ ] Cross-tenant queries blocked - [ ] Integration tests verify isolation - [ ] Security audit passed ## Implementation Details **Already Implemented in Story 1!** The `AuditLogInterceptor` already sets TenantId: ```csharp var auditLog = new AuditLog { TenantId = _tenantContext.TenantId, // ... }; ``` **This Task: Add Global Query Filter and Comprehensive Testing** 1. **Update EF Configuration**: `colaflow-api/src/ColaFlow.Infrastructure/Data/Configurations/AuditLogConfiguration.cs` ```csharp public void Configure(EntityTypeBuilder builder) { // ... existing configuration ... // Multi-tenant global query filter // This will be dynamically replaced by TenantContext at runtime builder.HasQueryFilter(a => a.TenantId == Guid.Empty); } ``` 2. **Apply Global Filter in DbContext**: `colaflow-api/src/ColaFlow.Infrastructure/Data/ColaFlowDbContext.cs` ```csharp protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); // Apply tenant filter to AuditLog var tenantId = _tenantContext.TenantId; modelBuilder.Entity().HasQueryFilter(a => a.TenantId == tenantId); } ``` 3. **Verify Repository Filtering**: `colaflow-api/src/ColaFlow.Infrastructure/Repositories/AuditLogRepository.cs` ```csharp public async Task> GetByEntityAsync(string entityType, Guid entityId) { var tenantId = _tenantContext.TenantId; // Query automatically filtered by global query filter // Explicit TenantId check added for defense-in-depth return await _context.AuditLogs .Where(a => a.TenantId == tenantId && a.EntityType == entityType && a.EntityId == entityId) .OrderByDescending(a => a.Timestamp) .AsNoTracking() .ToListAsync(); } ``` **Defense-in-Depth Strategy**: 1. **Layer 1**: TenantId automatically set by Interceptor 2. **Layer 2**: Global Query Filter in EF Core 3. **Layer 3**: Explicit TenantId checks in Repository 4. **Layer 4**: Integration tests verify isolation ## Testing **Integration Tests**: `colaflow-api/tests/ColaFlow.Application.IntegrationTests/AuditLog/MultiTenantIsolationTests.cs` ```csharp public class AuditLogMultiTenantIsolationTests : IntegrationTestBase { [Fact] public async Task GetByEntityAsync_ShouldOnlyReturnCurrentTenantAuditLogs() { // Arrange - Create audit logs for two tenants var tenant1Id = Guid.NewGuid(); var tenant2Id = Guid.NewGuid(); var entityId = Guid.NewGuid(); // Tenant 1 creates a project SetCurrentTenant(tenant1Id); var log1 = new AuditLog { Id = Guid.NewGuid(), TenantId = tenant1Id, EntityType = "Project", EntityId = entityId, Action = AuditAction.Create, Timestamp = DateTime.UtcNow }; await Context.AuditLogs.AddAsync(log1); await Context.SaveChangesAsync(); // Tenant 2 tries to access (should fail) SetCurrentTenant(tenant2Id); var repository = new AuditLogRepository(Context, TenantContext); // Act var result = await repository.GetByEntityAsync("Project", entityId); // Assert Assert.Empty(result); // Tenant 2 should NOT see Tenant 1's audit logs } [Fact] public async Task CreateProject_ShouldSetTenantIdAutomatically() { // Arrange var tenantId = Guid.NewGuid(); SetCurrentTenant(tenantId); // Act var projectId = await Mediator.Send(new CreateProjectCommand { Name = "Test Project", Key = "TEST" }); // Assert var auditLogs = await Context.AuditLogs .IgnoreQueryFilters() // Bypass filter to check actual data .Where(a => a.EntityId == projectId) .ToListAsync(); Assert.All(auditLogs, log => Assert.Equal(tenantId, log.TenantId)); } [Fact] public async Task DirectDatabaseQuery_ShouldRespectGlobalQueryFilter() { // Arrange var tenant1Id = Guid.NewGuid(); var tenant2Id = Guid.NewGuid(); // Create logs for both tenants (using IgnoreQueryFilters) await Context.AuditLogs.AddAsync(new AuditLog { Id = Guid.NewGuid(), TenantId = tenant1Id, /* ... */ }); await Context.AuditLogs.AddAsync(new AuditLog { Id = Guid.NewGuid(), TenantId = tenant2Id, /* ... */ }); await Context.SaveChangesAsync(); // Act - Set current tenant to tenant1 SetCurrentTenant(tenant1Id); var logs = await Context.AuditLogs.ToListAsync(); // Assert - Should only see tenant1 logs Assert.All(logs, log => Assert.Equal(tenant1Id, log.TenantId)); } [Fact] public async Task BypassFilter_ShouldWorkWithIgnoreQueryFilters() { // For admin/system operations that need to see all tenants var allLogs = await Context.AuditLogs .IgnoreQueryFilters() .ToListAsync(); Assert.True(allLogs.Count > 0); } } ``` **Security Test Cases**: 1. Cross-tenant read blocked 2. TenantId automatically set on creation 3. Global query filter applied 4. IgnoreQueryFilters works for admin operations 5. Repository explicit filtering works ## Technical Notes - Use `IgnoreQueryFilters()` for admin/system operations only - Always combine global filter with explicit TenantId checks (defense-in-depth) - Test with real database, not in-memory (to verify SQL generation) - Run security tests in CI/CD pipeline --- **Created**: 2025-11-05 by Backend Agent