feat(#286): Add workspace access validation to federation endpoints
Security improvements: - Apply WorkspaceGuard to all workspace-scoped federation endpoints - Enforce workspace membership verification via Prisma - Prevent cross-workspace access attacks - Add comprehensive test coverage for workspace isolation Changes: - Add WorkspaceGuard to federation connection endpoints: - POST /connections/initiate - POST /connections/:id/accept - POST /connections/:id/reject - POST /connections/:id/disconnect - GET /connections - GET /connections/:id - Add workspace-access.integration.spec.ts with tests for: - Workspace membership verification - Cross-workspace access prevention - Multiple workspace ID sources (header, param, body) Part of M7.1 Remediation Sprint P1 security fixes. Fixes #286 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
200
apps/api/src/federation/workspace-access.integration.spec.ts
Normal file
200
apps/api/src/federation/workspace-access.integration.spec.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Workspace Access Integration Tests
|
||||
*
|
||||
* Tests that workspace-scoped federation endpoints enforce workspace access.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { WorkspaceGuard } from "../common/guards/workspace.guard";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { ForbiddenException } from "@nestjs/common";
|
||||
import type { AuthenticatedRequest } from "../common/types/user.types";
|
||||
|
||||
describe("Workspace Access Control - Federation", () => {
|
||||
let prismaService: PrismaService;
|
||||
let workspaceGuard: WorkspaceGuard;
|
||||
|
||||
beforeEach(() => {
|
||||
const mockPrismaService = {
|
||||
workspaceMember: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
prismaService = mockPrismaService as unknown as PrismaService;
|
||||
workspaceGuard = new WorkspaceGuard(prismaService);
|
||||
});
|
||||
|
||||
describe("Workspace membership verification", () => {
|
||||
it("should allow access when user is workspace member", async () => {
|
||||
const mockRequest: Partial<AuthenticatedRequest> = {
|
||||
user: {
|
||||
id: "user-123",
|
||||
email: "test@example.com",
|
||||
workspaceId: "workspace-456",
|
||||
},
|
||||
headers: {
|
||||
"x-workspace-id": "workspace-456",
|
||||
},
|
||||
params: {},
|
||||
body: {},
|
||||
} as AuthenticatedRequest;
|
||||
|
||||
// Mock workspace membership exists
|
||||
vi.spyOn(prismaService.workspaceMember, "findUnique").mockResolvedValue({
|
||||
workspaceId: "workspace-456",
|
||||
userId: "user-123",
|
||||
role: "ADMIN",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as any);
|
||||
|
||||
const canActivate = await workspaceGuard.canActivate({
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => mockRequest,
|
||||
}),
|
||||
} as any);
|
||||
|
||||
expect(canActivate).toBe(true);
|
||||
});
|
||||
|
||||
it("should deny access when user is not workspace member", async () => {
|
||||
const mockRequest: Partial<AuthenticatedRequest> = {
|
||||
user: {
|
||||
id: "user-123",
|
||||
email: "test@example.com",
|
||||
},
|
||||
headers: {
|
||||
"x-workspace-id": "workspace-999",
|
||||
},
|
||||
params: {},
|
||||
body: {},
|
||||
} as AuthenticatedRequest;
|
||||
|
||||
// Mock workspace membership does not exist
|
||||
vi.spyOn(prismaService.workspaceMember, "findUnique").mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
workspaceGuard.canActivate({
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => mockRequest,
|
||||
}),
|
||||
} as any)
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it("should deny access when workspace ID is missing", async () => {
|
||||
const mockRequest: Partial<AuthenticatedRequest> = {
|
||||
user: {
|
||||
id: "user-123",
|
||||
email: "test@example.com",
|
||||
},
|
||||
headers: {},
|
||||
params: {},
|
||||
body: {},
|
||||
} as AuthenticatedRequest;
|
||||
|
||||
await expect(
|
||||
workspaceGuard.canActivate({
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => mockRequest,
|
||||
}),
|
||||
} as any)
|
||||
).rejects.toThrow("Workspace ID is required");
|
||||
});
|
||||
|
||||
it("should check workspace ID from URL parameter", async () => {
|
||||
const mockRequest: Partial<AuthenticatedRequest> = {
|
||||
user: {
|
||||
id: "user-123",
|
||||
email: "test@example.com",
|
||||
},
|
||||
headers: {},
|
||||
params: {
|
||||
workspaceId: "workspace-789",
|
||||
},
|
||||
} as any;
|
||||
|
||||
vi.spyOn(prismaService.workspaceMember, "findUnique").mockResolvedValue({
|
||||
workspaceId: "workspace-789",
|
||||
userId: "user-123",
|
||||
role: "MEMBER",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as any);
|
||||
|
||||
const canActivate = await workspaceGuard.canActivate({
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => mockRequest,
|
||||
}),
|
||||
} as any);
|
||||
|
||||
expect(canActivate).toBe(true);
|
||||
expect(prismaService.workspaceMember.findUnique).toHaveBeenCalledWith({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId: "workspace-789",
|
||||
userId: "user-123",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should check workspace ID from request body", async () => {
|
||||
const mockRequest: Partial<AuthenticatedRequest> = {
|
||||
user: {
|
||||
id: "user-123",
|
||||
email: "test@example.com",
|
||||
},
|
||||
headers: {},
|
||||
params: {},
|
||||
body: {
|
||||
workspaceId: "workspace-111",
|
||||
},
|
||||
} as any;
|
||||
|
||||
vi.spyOn(prismaService.workspaceMember, "findUnique").mockResolvedValue({
|
||||
workspaceId: "workspace-111",
|
||||
userId: "user-123",
|
||||
role: "ADMIN",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as any);
|
||||
|
||||
const canActivate = await workspaceGuard.canActivate({
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => mockRequest,
|
||||
}),
|
||||
} as any);
|
||||
|
||||
expect(canActivate).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Workspace isolation", () => {
|
||||
it("should prevent cross-workspace access", async () => {
|
||||
const mockRequest: Partial<AuthenticatedRequest> = {
|
||||
user: {
|
||||
id: "user-123",
|
||||
email: "test@example.com",
|
||||
},
|
||||
headers: {
|
||||
"x-workspace-id": "workspace-attacker",
|
||||
},
|
||||
params: {},
|
||||
body: {},
|
||||
} as AuthenticatedRequest;
|
||||
|
||||
// User is NOT a member of the requested workspace
|
||||
vi.spyOn(prismaService.workspaceMember, "findUnique").mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
workspaceGuard.canActivate({
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => mockRequest,
|
||||
}),
|
||||
} as any)
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user