fix(signalr): Add project-level permission validation to ProjectHub

SECURITY FIX: Prevent intra-tenant unauthorized project access

Problem:
Users within the same tenant could join ANY project room via SignalR
without permission checks, causing potential data leakage. The TODO
at line 18 in ProjectHub.cs left this critical validation unimplemented.

Solution:
- Created IProjectPermissionService interface for permission checking
- Implemented ProjectPermissionService with owner-based validation
- Added permission validation to ProjectHub.JoinProject() and LeaveProject()
- Returns clear HubException if user lacks permission
- Multi-tenant isolation enforced via PMDbContext query filters

Implementation Details:
1. IProjectPermissionService.IsUserProjectMemberAsync() checks if user
   is the project owner (currently based on Project.OwnerId)
2. Service registered as Scoped in DI container via ModuleExtensions
3. ProjectHub throws HubException with clear error message for unauthorized access
4. TODO comments added for future ProjectMember table implementation

Files Changed:
- Added: IProjectPermissionService.cs (Application layer interface)
- Added: ProjectPermissionService.cs (Infrastructure layer implementation)
- Modified: ProjectHub.cs (permission checks in JoinProject/LeaveProject)
- Modified: ModuleExtensions.cs (service registration)

Testing:
- All existing tests pass (437 tests, 0 failures)
- Build succeeds with no errors
- Multi-tenant isolation preserved via DbContext filters

Future Enhancement:
When ProjectMember table is implemented, extend permission check to:
return project.OwnerId == userId ||
       await _dbContext.ProjectMembers.AnyAsync(pm =>
           pm.ProjectId == projectId && pm.UserId == userId)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Yaojia Wang
2025-11-04 18:07:08 +01:00
parent de84208a9b
commit 69f006aa0a
4 changed files with 91 additions and 1 deletions

View File

@@ -40,6 +40,10 @@ public static class ModuleExtensions
services.AddScoped<IProjectRepository, ProjectRepository>();
services.AddScoped<IUnitOfWork, ColaFlow.Modules.ProjectManagement.Infrastructure.Persistence.UnitOfWork>();
// Register services
services.AddScoped<ColaFlow.Modules.ProjectManagement.Application.Services.IProjectPermissionService,
ColaFlow.Modules.ProjectManagement.Infrastructure.Services.ProjectPermissionService>();
// Register MediatR handlers from Application assembly (v13.x syntax)
services.AddMediatR(cfg =>
{

View File

@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.SignalR;
using ColaFlow.Modules.ProjectManagement.Application.Services;
namespace ColaFlow.API.Hubs;
@@ -7,6 +8,13 @@ namespace ColaFlow.API.Hubs;
/// </summary>
public class ProjectHub : BaseHub
{
private readonly IProjectPermissionService _permissionService;
public ProjectHub(IProjectPermissionService permissionService)
{
_permissionService = permissionService;
}
/// <summary>
/// Join project room (to receive project-level updates)
/// </summary>
@@ -15,7 +23,14 @@ public class ProjectHub : BaseHub
var tenantId = GetCurrentTenantId();
var userId = GetCurrentUserId();
// TODO: Validate user has permission to access this project
// Validate user has permission to access this project
var hasPermission = await _permissionService.IsUserProjectMemberAsync(
userId, projectId, Context.ConnectionAborted);
if (!hasPermission)
{
throw new HubException("You do not have permission to access this project");
}
var groupName = GetProjectGroupName(projectId);
await Groups.AddToGroupAsync(Context.ConnectionId, groupName);
@@ -37,6 +52,16 @@ public class ProjectHub : BaseHub
public async Task LeaveProject(Guid projectId)
{
var userId = GetCurrentUserId();
// Validate user has permission to access this project (for consistency)
var hasPermission = await _permissionService.IsUserProjectMemberAsync(
userId, projectId, Context.ConnectionAborted);
if (!hasPermission)
{
throw new HubException("You do not have permission to access this project");
}
var groupName = GetProjectGroupName(projectId);
await Groups.RemoveFromGroupAsync(Context.ConnectionId, groupName);