fix(backend): Add explicit TenantId validation to Epic/Story/Task Query/Command Handlers
CRITICAL SECURITY FIX: Implemented Defense in Depth security pattern by adding explicit TenantId verification to all Epic/Story/Task Query and Command Handlers. Security Impact: - BEFORE: Relied solely on EF Core global query filters (single layer) - AFTER: Explicit TenantId validation + EF Core filters (defense in depth) This ensures that even if EF Core query filters are accidentally disabled or bypassed, tenant isolation is still maintained at the application layer. Changes: Query Handlers (6 handlers): - GetEpicByIdQueryHandler: Added ITenantContext injection + explicit TenantId check - GetStoryByIdQueryHandler: Added ITenantContext injection + explicit TenantId check - GetTaskByIdQueryHandler: Added ITenantContext injection + explicit TenantId check - GetEpicsByProjectIdQueryHandler: Verify Project.TenantId before querying Epics - GetStoriesByEpicIdQueryHandler: Verify Epic.TenantId before querying Stories - GetTasksByStoryIdQueryHandler: Verify Story.TenantId before querying Tasks Command Handlers (5 handlers): - UpdateEpicCommandHandler: Verify Project.TenantId before updating - UpdateStoryCommandHandler: Verify Project.TenantId before updating - UpdateTaskCommandHandler: Verify Project.TenantId before updating - DeleteStoryCommandHandler: Verify Project.TenantId before deleting - DeleteTaskCommandHandler: Verify Project.TenantId before deleting Unit Tests: - Updated 5 unit test files to mock ITenantContext - All 32 unit tests passing - All 7 multi-tenant isolation integration tests passing Defense Layers (Security in Depth): Layer 1: EF Core global query filters (database level) Layer 2: Application-layer explicit TenantId validation (handler level) Layer 3: Integration tests verifying tenant isolation (test level) Test Results: - Unit Tests: 32/32 PASSING - Integration Tests: 7/7 PASSING (multi-tenant isolation) This fix addresses a critical security vulnerability where we relied on a single layer of defense (EF Core query filters) for tenant data isolation. Now we have multiple layers ensuring no cross-tenant data leaks can occur. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -11,14 +11,19 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Commands.DeleteStory;
|
||||
/// </summary>
|
||||
public sealed class DeleteStoryCommandHandler(
|
||||
IProjectRepository projectRepository,
|
||||
IUnitOfWork unitOfWork)
|
||||
IUnitOfWork unitOfWork,
|
||||
ITenantContext tenantContext)
|
||||
: IRequestHandler<DeleteStoryCommand, Unit>
|
||||
{
|
||||
private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
||||
private readonly IUnitOfWork _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
|
||||
private readonly ITenantContext _tenantContext = tenantContext ?? throw new ArgumentNullException(nameof(tenantContext));
|
||||
|
||||
public async Task<Unit> Handle(DeleteStoryCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Get current tenant ID (Defense in Depth - Layer 2)
|
||||
var currentTenantId = _tenantContext.GetCurrentTenantId();
|
||||
|
||||
// Get the project with story (Global Query Filter ensures tenant isolation)
|
||||
var storyId = StoryId.From(request.StoryId);
|
||||
var project = await _projectRepository.GetProjectWithStoryAsync(storyId, cancellationToken);
|
||||
@@ -26,6 +31,10 @@ public sealed class DeleteStoryCommandHandler(
|
||||
if (project == null)
|
||||
throw new NotFoundException("Story", request.StoryId);
|
||||
|
||||
// CRITICAL SECURITY: Explicit TenantId validation (Defense in Depth)
|
||||
if (project.TenantId.Value != currentTenantId)
|
||||
throw new NotFoundException("Story", request.StoryId);
|
||||
|
||||
// Find the epic containing the story
|
||||
var epic = project.Epics.FirstOrDefault(e => e.Stories.Any(s => s.Id.Value == request.StoryId));
|
||||
if (epic == null)
|
||||
|
||||
@@ -12,14 +12,19 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Commands.DeleteTask;
|
||||
/// </summary>
|
||||
public sealed class DeleteTaskCommandHandler(
|
||||
IProjectRepository projectRepository,
|
||||
IUnitOfWork unitOfWork)
|
||||
IUnitOfWork unitOfWork,
|
||||
ITenantContext tenantContext)
|
||||
: IRequestHandler<DeleteTaskCommand, Unit>
|
||||
{
|
||||
private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
||||
private readonly IUnitOfWork _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
|
||||
private readonly ITenantContext _tenantContext = tenantContext ?? throw new ArgumentNullException(nameof(tenantContext));
|
||||
|
||||
public async Task<Unit> Handle(DeleteTaskCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Get current tenant ID (Defense in Depth - Layer 2)
|
||||
var currentTenantId = _tenantContext.GetCurrentTenantId();
|
||||
|
||||
// Get the project containing the task (Global Query Filter ensures tenant isolation)
|
||||
var taskId = TaskId.From(request.TaskId);
|
||||
var project = await _projectRepository.GetProjectWithTaskAsync(taskId, cancellationToken);
|
||||
@@ -27,6 +32,10 @@ public sealed class DeleteTaskCommandHandler(
|
||||
if (project == null)
|
||||
throw new NotFoundException("Task", request.TaskId);
|
||||
|
||||
// CRITICAL SECURITY: Explicit TenantId validation (Defense in Depth)
|
||||
if (project.TenantId.Value != currentTenantId)
|
||||
throw new NotFoundException("Task", request.TaskId);
|
||||
|
||||
// Find the story containing the task
|
||||
Story? parentStory = null;
|
||||
foreach (var epic in project.Epics)
|
||||
|
||||
@@ -12,14 +12,19 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Commands.UpdateEpic;
|
||||
/// </summary>
|
||||
public sealed class UpdateEpicCommandHandler(
|
||||
IProjectRepository projectRepository,
|
||||
IUnitOfWork unitOfWork)
|
||||
IUnitOfWork unitOfWork,
|
||||
ITenantContext tenantContext)
|
||||
: IRequestHandler<UpdateEpicCommand, EpicDto>
|
||||
{
|
||||
private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
||||
private readonly IUnitOfWork _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
|
||||
private readonly ITenantContext _tenantContext = tenantContext ?? throw new ArgumentNullException(nameof(tenantContext));
|
||||
|
||||
public async Task<EpicDto> Handle(UpdateEpicCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Get current tenant ID (Defense in Depth - Layer 2)
|
||||
var currentTenantId = _tenantContext.GetCurrentTenantId();
|
||||
|
||||
// Get the project containing the epic (Global Query Filter ensures tenant isolation)
|
||||
var epicId = EpicId.From(request.EpicId);
|
||||
var project = await _projectRepository.GetProjectWithEpicAsync(epicId, cancellationToken);
|
||||
@@ -27,6 +32,10 @@ public sealed class UpdateEpicCommandHandler(
|
||||
if (project == null)
|
||||
throw new NotFoundException("Epic", request.EpicId);
|
||||
|
||||
// CRITICAL SECURITY: Explicit TenantId validation (Defense in Depth)
|
||||
if (project.TenantId.Value != currentTenantId)
|
||||
throw new NotFoundException("Epic", request.EpicId);
|
||||
|
||||
// Find the epic
|
||||
var epic = project.Epics.FirstOrDefault(e => e.Id == epicId);
|
||||
if (epic == null)
|
||||
|
||||
@@ -12,14 +12,19 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Commands.UpdateStory;
|
||||
/// </summary>
|
||||
public sealed class UpdateStoryCommandHandler(
|
||||
IProjectRepository projectRepository,
|
||||
IUnitOfWork unitOfWork)
|
||||
IUnitOfWork unitOfWork,
|
||||
ITenantContext tenantContext)
|
||||
: IRequestHandler<UpdateStoryCommand, StoryDto>
|
||||
{
|
||||
private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
||||
private readonly IUnitOfWork _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
|
||||
private readonly ITenantContext _tenantContext = tenantContext ?? throw new ArgumentNullException(nameof(tenantContext));
|
||||
|
||||
public async Task<StoryDto> Handle(UpdateStoryCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Get current tenant ID (Defense in Depth - Layer 2)
|
||||
var currentTenantId = _tenantContext.GetCurrentTenantId();
|
||||
|
||||
// Get the project with story (Global Query Filter ensures tenant isolation)
|
||||
var storyId = StoryId.From(request.StoryId);
|
||||
var project = await _projectRepository.GetProjectWithStoryAsync(storyId, cancellationToken);
|
||||
@@ -27,6 +32,10 @@ public sealed class UpdateStoryCommandHandler(
|
||||
if (project == null)
|
||||
throw new NotFoundException("Story", request.StoryId);
|
||||
|
||||
// CRITICAL SECURITY: Explicit TenantId validation (Defense in Depth)
|
||||
if (project.TenantId.Value != currentTenantId)
|
||||
throw new NotFoundException("Story", request.StoryId);
|
||||
|
||||
// Find the story
|
||||
var story = project.Epics
|
||||
.SelectMany(e => e.Stories)
|
||||
|
||||
@@ -13,14 +13,19 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Commands.UpdateTask;
|
||||
/// </summary>
|
||||
public sealed class UpdateTaskCommandHandler(
|
||||
IProjectRepository projectRepository,
|
||||
IUnitOfWork unitOfWork)
|
||||
IUnitOfWork unitOfWork,
|
||||
ITenantContext tenantContext)
|
||||
: IRequestHandler<UpdateTaskCommand, TaskDto>
|
||||
{
|
||||
private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
||||
private readonly IUnitOfWork _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
|
||||
private readonly ITenantContext _tenantContext = tenantContext ?? throw new ArgumentNullException(nameof(tenantContext));
|
||||
|
||||
public async Task<TaskDto> Handle(UpdateTaskCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Get current tenant ID (Defense in Depth - Layer 2)
|
||||
var currentTenantId = _tenantContext.GetCurrentTenantId();
|
||||
|
||||
// Get the project containing the task (Global Query Filter ensures tenant isolation)
|
||||
var taskId = TaskId.From(request.TaskId);
|
||||
var project = await _projectRepository.GetProjectWithTaskAsync(taskId, cancellationToken);
|
||||
@@ -28,6 +33,10 @@ public sealed class UpdateTaskCommandHandler(
|
||||
if (project == null)
|
||||
throw new NotFoundException("Task", request.TaskId);
|
||||
|
||||
// CRITICAL SECURITY: Explicit TenantId validation (Defense in Depth)
|
||||
if (project.TenantId.Value != currentTenantId)
|
||||
throw new NotFoundException("Task", request.TaskId);
|
||||
|
||||
// Find the task within the project aggregate
|
||||
WorkTask? task = null;
|
||||
foreach (var epic in project.Epics)
|
||||
|
||||
@@ -11,13 +11,18 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Queries.GetEpicById;
|
||||
/// Handler for GetEpicByIdQuery
|
||||
/// </summary>
|
||||
public sealed class GetEpicByIdQueryHandler(
|
||||
IProjectRepository projectRepository)
|
||||
IProjectRepository projectRepository,
|
||||
ITenantContext tenantContext)
|
||||
: IRequestHandler<GetEpicByIdQuery, EpicDto>
|
||||
{
|
||||
private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
||||
private readonly ITenantContext _tenantContext = tenantContext ?? throw new ArgumentNullException(nameof(tenantContext));
|
||||
|
||||
public async Task<EpicDto> Handle(GetEpicByIdQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Get current tenant ID (Defense in Depth - Layer 2)
|
||||
var currentTenantId = _tenantContext.GetCurrentTenantId();
|
||||
|
||||
// Use read-only method for query (AsNoTracking for better performance)
|
||||
var epicId = EpicId.From(request.EpicId);
|
||||
var epic = await _projectRepository.GetEpicByIdReadOnlyAsync(epicId, cancellationToken);
|
||||
@@ -25,6 +30,12 @@ public sealed class GetEpicByIdQueryHandler(
|
||||
if (epic == null)
|
||||
throw new NotFoundException("Epic", request.EpicId);
|
||||
|
||||
// CRITICAL SECURITY: Explicit TenantId validation (Defense in Depth)
|
||||
// Even though EF Core global query filters should prevent cross-tenant access,
|
||||
// we explicitly verify tenant ownership to ensure defense in depth.
|
||||
if (epic.TenantId.Value != currentTenantId)
|
||||
throw new NotFoundException("Epic", request.EpicId);
|
||||
|
||||
return new EpicDto
|
||||
{
|
||||
Id = epic.Id.Value,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using MediatR;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.DTOs;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Exceptions;
|
||||
@@ -9,15 +10,31 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Queries.GetEpicsByProje
|
||||
/// <summary>
|
||||
/// Handler for GetEpicsByProjectIdQuery
|
||||
/// </summary>
|
||||
public sealed class GetEpicsByProjectIdQueryHandler(IProjectRepository projectRepository)
|
||||
public sealed class GetEpicsByProjectIdQueryHandler(
|
||||
IProjectRepository projectRepository,
|
||||
ITenantContext tenantContext)
|
||||
: IRequestHandler<GetEpicsByProjectIdQuery, List<EpicDto>>
|
||||
{
|
||||
private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
||||
private readonly ITenantContext _tenantContext = tenantContext ?? throw new ArgumentNullException(nameof(tenantContext));
|
||||
|
||||
public async Task<List<EpicDto>> Handle(GetEpicsByProjectIdQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Use read-only method for query (AsNoTracking for better performance)
|
||||
// Get current tenant ID (Defense in Depth - Layer 2)
|
||||
var currentTenantId = _tenantContext.GetCurrentTenantId();
|
||||
|
||||
// CRITICAL SECURITY: Verify Project belongs to current tenant before querying epics
|
||||
var projectId = ProjectId.From(request.ProjectId);
|
||||
var project = await _projectRepository.GetByIdAsync(projectId, cancellationToken);
|
||||
|
||||
if (project == null)
|
||||
throw new NotFoundException("Project", request.ProjectId);
|
||||
|
||||
// Explicit TenantId validation (Defense in Depth)
|
||||
if (project.TenantId.Value != currentTenantId)
|
||||
throw new NotFoundException("Project", request.ProjectId);
|
||||
|
||||
// Now fetch epics (already filtered by EF Core, but we verified project ownership)
|
||||
var epics = await _projectRepository.GetEpicsByProjectIdAsync(projectId, cancellationToken);
|
||||
|
||||
return epics.Select(epic => new EpicDto
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using MediatR;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.DTOs;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Exceptions;
|
||||
@@ -9,15 +10,31 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Queries.GetStoriesByEpi
|
||||
/// <summary>
|
||||
/// Handler for GetStoriesByEpicIdQuery
|
||||
/// </summary>
|
||||
public sealed class GetStoriesByEpicIdQueryHandler(IProjectRepository projectRepository)
|
||||
public sealed class GetStoriesByEpicIdQueryHandler(
|
||||
IProjectRepository projectRepository,
|
||||
ITenantContext tenantContext)
|
||||
: IRequestHandler<GetStoriesByEpicIdQuery, List<StoryDto>>
|
||||
{
|
||||
private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
||||
private readonly ITenantContext _tenantContext = tenantContext ?? throw new ArgumentNullException(nameof(tenantContext));
|
||||
|
||||
public async Task<List<StoryDto>> Handle(GetStoriesByEpicIdQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Use read-only method for query (AsNoTracking for better performance)
|
||||
// Get current tenant ID (Defense in Depth - Layer 2)
|
||||
var currentTenantId = _tenantContext.GetCurrentTenantId();
|
||||
|
||||
// CRITICAL SECURITY: Verify Epic belongs to current tenant before querying stories
|
||||
var epicId = EpicId.From(request.EpicId);
|
||||
var epic = await _projectRepository.GetEpicByIdReadOnlyAsync(epicId, cancellationToken);
|
||||
|
||||
if (epic == null)
|
||||
throw new NotFoundException("Epic", request.EpicId);
|
||||
|
||||
// Explicit TenantId validation (Defense in Depth)
|
||||
if (epic.TenantId.Value != currentTenantId)
|
||||
throw new NotFoundException("Epic", request.EpicId);
|
||||
|
||||
// Now fetch stories (already filtered by EF Core, but we verified epic ownership)
|
||||
var stories = await _projectRepository.GetStoriesByEpicIdAsync(epicId, cancellationToken);
|
||||
|
||||
// Map stories to DTOs
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using MediatR;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.DTOs;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Exceptions;
|
||||
@@ -9,13 +10,19 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Queries.GetStoryById;
|
||||
/// <summary>
|
||||
/// Handler for GetStoryByIdQuery
|
||||
/// </summary>
|
||||
public sealed class GetStoryByIdQueryHandler(IProjectRepository projectRepository)
|
||||
public sealed class GetStoryByIdQueryHandler(
|
||||
IProjectRepository projectRepository,
|
||||
ITenantContext tenantContext)
|
||||
: IRequestHandler<GetStoryByIdQuery, StoryDto>
|
||||
{
|
||||
private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
||||
private readonly ITenantContext _tenantContext = tenantContext ?? throw new ArgumentNullException(nameof(tenantContext));
|
||||
|
||||
public async Task<StoryDto> Handle(GetStoryByIdQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Get current tenant ID (Defense in Depth - Layer 2)
|
||||
var currentTenantId = _tenantContext.GetCurrentTenantId();
|
||||
|
||||
// Use read-only method for query (AsNoTracking for better performance)
|
||||
var storyId = StoryId.From(request.StoryId);
|
||||
var story = await _projectRepository.GetStoryByIdReadOnlyAsync(storyId, cancellationToken);
|
||||
@@ -23,6 +30,10 @@ public sealed class GetStoryByIdQueryHandler(IProjectRepository projectRepositor
|
||||
if (story == null)
|
||||
throw new NotFoundException("Story", request.StoryId);
|
||||
|
||||
// CRITICAL SECURITY: Explicit TenantId validation (Defense in Depth)
|
||||
if (story.TenantId.Value != currentTenantId)
|
||||
throw new NotFoundException("Story", request.StoryId);
|
||||
|
||||
// Map to DTO
|
||||
return new StoryDto
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using MediatR;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.DTOs;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Exceptions;
|
||||
@@ -10,13 +11,19 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Queries.GetTaskById;
|
||||
/// <summary>
|
||||
/// Handler for GetTaskByIdQuery
|
||||
/// </summary>
|
||||
public sealed class GetTaskByIdQueryHandler(IProjectRepository projectRepository)
|
||||
public sealed class GetTaskByIdQueryHandler(
|
||||
IProjectRepository projectRepository,
|
||||
ITenantContext tenantContext)
|
||||
: IRequestHandler<GetTaskByIdQuery, TaskDto>
|
||||
{
|
||||
private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
||||
private readonly ITenantContext _tenantContext = tenantContext ?? throw new ArgumentNullException(nameof(tenantContext));
|
||||
|
||||
public async Task<TaskDto> Handle(GetTaskByIdQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Get current tenant ID (Defense in Depth - Layer 2)
|
||||
var currentTenantId = _tenantContext.GetCurrentTenantId();
|
||||
|
||||
// Use read-only method for query (AsNoTracking for better performance)
|
||||
var taskId = TaskId.From(request.TaskId);
|
||||
var task = await _projectRepository.GetTaskByIdReadOnlyAsync(taskId, cancellationToken);
|
||||
@@ -24,6 +31,10 @@ public sealed class GetTaskByIdQueryHandler(IProjectRepository projectRepository
|
||||
if (task == null)
|
||||
throw new NotFoundException("Task", request.TaskId);
|
||||
|
||||
// CRITICAL SECURITY: Explicit TenantId validation (Defense in Depth)
|
||||
if (task.TenantId.Value != currentTenantId)
|
||||
throw new NotFoundException("Task", request.TaskId);
|
||||
|
||||
// Map to DTO
|
||||
return new TaskDto
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using MediatR;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.DTOs;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Exceptions;
|
||||
@@ -9,15 +10,31 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Queries.GetTasksByStory
|
||||
/// <summary>
|
||||
/// Handler for GetTasksByStoryIdQuery
|
||||
/// </summary>
|
||||
public sealed class GetTasksByStoryIdQueryHandler(IProjectRepository projectRepository)
|
||||
public sealed class GetTasksByStoryIdQueryHandler(
|
||||
IProjectRepository projectRepository,
|
||||
ITenantContext tenantContext)
|
||||
: IRequestHandler<GetTasksByStoryIdQuery, List<TaskDto>>
|
||||
{
|
||||
private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
||||
private readonly ITenantContext _tenantContext = tenantContext ?? throw new ArgumentNullException(nameof(tenantContext));
|
||||
|
||||
public async Task<List<TaskDto>> Handle(GetTasksByStoryIdQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Use read-only method for query (AsNoTracking for better performance)
|
||||
// Get current tenant ID (Defense in Depth - Layer 2)
|
||||
var currentTenantId = _tenantContext.GetCurrentTenantId();
|
||||
|
||||
// CRITICAL SECURITY: Verify Story belongs to current tenant before querying tasks
|
||||
var storyId = StoryId.From(request.StoryId);
|
||||
var story = await _projectRepository.GetStoryByIdReadOnlyAsync(storyId, cancellationToken);
|
||||
|
||||
if (story == null)
|
||||
throw new NotFoundException("Story", request.StoryId);
|
||||
|
||||
// Explicit TenantId validation (Defense in Depth)
|
||||
if (story.TenantId.Value != currentTenantId)
|
||||
throw new NotFoundException("Story", request.StoryId);
|
||||
|
||||
// Now fetch tasks (already filtered by EF Core, but we verified story ownership)
|
||||
var tasks = await _projectRepository.GetTasksByStoryIdAsync(storyId, cancellationToken);
|
||||
|
||||
// Map tasks to DTOs
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.Commands.DeleteStory;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
|
||||
@@ -12,13 +13,19 @@ public class DeleteStoryCommandHandlerTests
|
||||
{
|
||||
private readonly Mock<IProjectRepository> _projectRepositoryMock;
|
||||
private readonly Mock<IUnitOfWork> _unitOfWorkMock;
|
||||
private readonly Mock<ITenantContext> _tenantContextMock;
|
||||
private readonly DeleteStoryCommandHandler _handler;
|
||||
private readonly Guid _tenantId;
|
||||
|
||||
public DeleteStoryCommandHandlerTests()
|
||||
{
|
||||
_projectRepositoryMock = new Mock<IProjectRepository>();
|
||||
_unitOfWorkMock = new Mock<IUnitOfWork>();
|
||||
_handler = new DeleteStoryCommandHandler(_projectRepositoryMock.Object, _unitOfWorkMock.Object);
|
||||
_tenantContextMock = new Mock<ITenantContext>();
|
||||
_tenantId = Guid.NewGuid();
|
||||
|
||||
_tenantContextMock.Setup(x => x.GetCurrentTenantId()).Returns(_tenantId);
|
||||
_handler = new DeleteStoryCommandHandler(_projectRepositoryMock.Object, _unitOfWorkMock.Object, _tenantContextMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -26,7 +33,7 @@ public class DeleteStoryCommandHandlerTests
|
||||
{
|
||||
// Arrange
|
||||
var userId = UserId.Create();
|
||||
var project = Project.Create(TenantId.Create(Guid.NewGuid()), "Test Project", "Description", "TST", userId);
|
||||
var project = Project.Create(TenantId.Create(_tenantId), "Test Project", "Description", "TST", userId);
|
||||
var epic = project.CreateEpic("Test Epic", "Epic Description", userId);
|
||||
var story = epic.CreateStory("Story to Delete", "Description", TaskPriority.Medium, userId);
|
||||
var storyId = story.Id;
|
||||
@@ -70,7 +77,7 @@ public class DeleteStoryCommandHandlerTests
|
||||
{
|
||||
// Arrange
|
||||
var userId = UserId.Create();
|
||||
var project = Project.Create(TenantId.Create(Guid.NewGuid()), "Test Project", "Description", "TST", userId);
|
||||
var project = Project.Create(TenantId.Create(_tenantId), "Test Project", "Description", "TST", userId);
|
||||
var epic = project.CreateEpic("Test Epic", "Epic Description", userId);
|
||||
var story = epic.CreateStory("Story with Tasks", "Description", TaskPriority.Medium, userId);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.Commands.DeleteTask;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
|
||||
@@ -12,13 +13,19 @@ public class DeleteTaskCommandHandlerTests
|
||||
{
|
||||
private readonly Mock<IProjectRepository> _projectRepositoryMock;
|
||||
private readonly Mock<IUnitOfWork> _unitOfWorkMock;
|
||||
private readonly Mock<ITenantContext> _tenantContextMock;
|
||||
private readonly DeleteTaskCommandHandler _handler;
|
||||
private readonly Guid _tenantId;
|
||||
|
||||
public DeleteTaskCommandHandlerTests()
|
||||
{
|
||||
_projectRepositoryMock = new Mock<IProjectRepository>();
|
||||
_unitOfWorkMock = new Mock<IUnitOfWork>();
|
||||
_handler = new DeleteTaskCommandHandler(_projectRepositoryMock.Object, _unitOfWorkMock.Object);
|
||||
_tenantContextMock = new Mock<ITenantContext>();
|
||||
_tenantId = Guid.NewGuid();
|
||||
|
||||
_tenantContextMock.Setup(x => x.GetCurrentTenantId()).Returns(_tenantId);
|
||||
_handler = new DeleteTaskCommandHandler(_projectRepositoryMock.Object, _unitOfWorkMock.Object, _tenantContextMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -26,7 +33,7 @@ public class DeleteTaskCommandHandlerTests
|
||||
{
|
||||
// Arrange
|
||||
var userId = UserId.Create();
|
||||
var project = Project.Create(TenantId.Create(Guid.NewGuid()), "Test Project", "Description", "TST", userId);
|
||||
var project = Project.Create(TenantId.Create(_tenantId), "Test Project", "Description", "TST", userId);
|
||||
var epic = project.CreateEpic("Test Epic", "Epic Description", userId);
|
||||
var story = epic.CreateStory("Test Story", "Story Description", TaskPriority.Medium, userId);
|
||||
var task = story.CreateTask("Task to Delete", "Description", TaskPriority.Medium, userId);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.Commands.UpdateStory;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
|
||||
@@ -12,13 +13,19 @@ public class UpdateStoryCommandHandlerTests
|
||||
{
|
||||
private readonly Mock<IProjectRepository> _projectRepositoryMock;
|
||||
private readonly Mock<IUnitOfWork> _unitOfWorkMock;
|
||||
private readonly Mock<ITenantContext> _tenantContextMock;
|
||||
private readonly UpdateStoryCommandHandler _handler;
|
||||
private readonly Guid _tenantId;
|
||||
|
||||
public UpdateStoryCommandHandlerTests()
|
||||
{
|
||||
_projectRepositoryMock = new Mock<IProjectRepository>();
|
||||
_unitOfWorkMock = new Mock<IUnitOfWork>();
|
||||
_handler = new UpdateStoryCommandHandler(_projectRepositoryMock.Object, _unitOfWorkMock.Object);
|
||||
_tenantContextMock = new Mock<ITenantContext>();
|
||||
_tenantId = Guid.NewGuid();
|
||||
|
||||
_tenantContextMock.Setup(x => x.GetCurrentTenantId()).Returns(_tenantId);
|
||||
_handler = new UpdateStoryCommandHandler(_projectRepositoryMock.Object, _unitOfWorkMock.Object, _tenantContextMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -26,7 +33,7 @@ public class UpdateStoryCommandHandlerTests
|
||||
{
|
||||
// Arrange
|
||||
var userId = UserId.Create();
|
||||
var project = Project.Create(TenantId.Create(Guid.NewGuid()), "Test Project", "Description", "TST", userId);
|
||||
var project = Project.Create(TenantId.Create(_tenantId), "Test Project", "Description", "TST", userId);
|
||||
var epic = project.CreateEpic("Test Epic", "Epic Description", userId);
|
||||
var story = epic.CreateStory("Original Title", "Original Description", TaskPriority.Low, userId);
|
||||
var storyId = story.Id;
|
||||
@@ -89,7 +96,7 @@ public class UpdateStoryCommandHandlerTests
|
||||
{
|
||||
// Arrange
|
||||
var userId = UserId.Create();
|
||||
var project = Project.Create(TenantId.Create(Guid.NewGuid()), "Test Project", "Description", "TST", userId);
|
||||
var project = Project.Create(TenantId.Create(_tenantId), "Test Project", "Description", "TST", userId);
|
||||
var epic = project.CreateEpic("Test Epic", "Epic Description", userId);
|
||||
var story = epic.CreateStory("Original", "Original", TaskPriority.Low, userId);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.Queries.GetStoryById;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
|
||||
@@ -11,12 +12,18 @@ namespace ColaFlow.Application.Tests.Queries.GetStoryById;
|
||||
public class GetStoryByIdQueryHandlerTests
|
||||
{
|
||||
private readonly Mock<IProjectRepository> _projectRepositoryMock;
|
||||
private readonly Mock<ITenantContext> _tenantContextMock;
|
||||
private readonly GetStoryByIdQueryHandler _handler;
|
||||
private readonly Guid _tenantId;
|
||||
|
||||
public GetStoryByIdQueryHandlerTests()
|
||||
{
|
||||
_projectRepositoryMock = new Mock<IProjectRepository>();
|
||||
_handler = new GetStoryByIdQueryHandler(_projectRepositoryMock.Object);
|
||||
_tenantContextMock = new Mock<ITenantContext>();
|
||||
_tenantId = Guid.NewGuid();
|
||||
|
||||
_tenantContextMock.Setup(x => x.GetCurrentTenantId()).Returns(_tenantId);
|
||||
_handler = new GetStoryByIdQueryHandler(_projectRepositoryMock.Object, _tenantContextMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -24,7 +31,7 @@ public class GetStoryByIdQueryHandlerTests
|
||||
{
|
||||
// Arrange
|
||||
var userId = UserId.Create();
|
||||
var project = Project.Create(TenantId.Create(Guid.NewGuid()), "Test Project", "Description", "TST", userId);
|
||||
var project = Project.Create(TenantId.Create(_tenantId), "Test Project", "Description", "TST", userId);
|
||||
var epic = project.CreateEpic("Test Epic", "Epic Description", userId);
|
||||
var story = epic.CreateStory("Test Story", "Story Description", TaskPriority.High, userId);
|
||||
var task1 = story.CreateTask("Task 1", "Description 1", TaskPriority.Medium, userId);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.Queries.GetTaskById;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
|
||||
@@ -11,12 +12,20 @@ namespace ColaFlow.Application.Tests.Queries.GetTaskById;
|
||||
public class GetTaskByIdQueryHandlerTests
|
||||
{
|
||||
private readonly Mock<IProjectRepository> _projectRepositoryMock;
|
||||
private readonly Mock<ITenantContext> _tenantContextMock;
|
||||
private readonly GetTaskByIdQueryHandler _handler;
|
||||
private readonly Guid _tenantId;
|
||||
|
||||
public GetTaskByIdQueryHandlerTests()
|
||||
{
|
||||
_projectRepositoryMock = new Mock<IProjectRepository>();
|
||||
_handler = new GetTaskByIdQueryHandler(_projectRepositoryMock.Object);
|
||||
_tenantContextMock = new Mock<ITenantContext>();
|
||||
_tenantId = Guid.NewGuid();
|
||||
|
||||
// Setup default tenant context
|
||||
_tenantContextMock.Setup(x => x.GetCurrentTenantId()).Returns(_tenantId);
|
||||
|
||||
_handler = new GetTaskByIdQueryHandler(_projectRepositoryMock.Object, _tenantContextMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -24,7 +33,7 @@ public class GetTaskByIdQueryHandlerTests
|
||||
{
|
||||
// Arrange
|
||||
var userId = UserId.Create();
|
||||
var project = Project.Create(TenantId.Create(Guid.NewGuid()), "Test Project", "Description", "TST", userId);
|
||||
var project = Project.Create(TenantId.Create(_tenantId), "Test Project", "Description", "TST", userId);
|
||||
var epic = project.CreateEpic("Test Epic", "Epic Description", userId);
|
||||
var story = epic.CreateStory("Test Story", "Story Description", TaskPriority.Medium, userId);
|
||||
var task = story.CreateTask("Test Task", "Task Description", TaskPriority.High, userId);
|
||||
|
||||
Reference in New Issue
Block a user