diff --git a/apps/api/src/federation/federation.controller.ts b/apps/api/src/federation/federation.controller.ts index 172ee6d..1aceb6a 100644 --- a/apps/api/src/federation/federation.controller.ts +++ b/apps/api/src/federation/federation.controller.ts @@ -12,6 +12,7 @@ import { FederationAuditService } from "./audit.service"; import { ConnectionService } from "./connection.service"; import { AuthGuard } from "../auth/guards/auth.guard"; import { AdminGuard } from "../auth/guards/admin.guard"; +import { WorkspaceGuard } from "../common/guards/workspace.guard"; import { CsrfGuard } from "../common/guards/csrf.guard"; import { SkipCsrf } from "../common/decorators/skip-csrf.decorator"; import type { PublicInstanceIdentity } from "./types/instance.types"; @@ -76,11 +77,11 @@ export class FederationController { /** * Initiate a connection to a remote instance - * Requires authentication + * Requires authentication and workspace access * Rate limit: "medium" tier (20 req/min) - authenticated endpoint */ @Post("connections/initiate") - @UseGuards(AuthGuard) + @UseGuards(AuthGuard, WorkspaceGuard) @Throttle({ medium: { limit: 20, ttl: 60000 } }) async initiateConnection( @Req() req: AuthenticatedRequest, @@ -99,11 +100,11 @@ export class FederationController { /** * Accept a pending connection - * Requires authentication + * Requires authentication and workspace access * Rate limit: "medium" tier (20 req/min) - authenticated endpoint */ @Post("connections/:id/accept") - @UseGuards(AuthGuard) + @UseGuards(AuthGuard, WorkspaceGuard) @Throttle({ medium: { limit: 20, ttl: 60000 } }) async acceptConnection( @Req() req: AuthenticatedRequest, @@ -127,11 +128,11 @@ export class FederationController { /** * Reject a pending connection - * Requires authentication + * Requires authentication and workspace access * Rate limit: "medium" tier (20 req/min) - authenticated endpoint */ @Post("connections/:id/reject") - @UseGuards(AuthGuard) + @UseGuards(AuthGuard, WorkspaceGuard) @Throttle({ medium: { limit: 20, ttl: 60000 } }) async rejectConnection( @Req() req: AuthenticatedRequest, @@ -149,11 +150,11 @@ export class FederationController { /** * Disconnect an active connection - * Requires authentication + * Requires authentication and workspace access * Rate limit: "medium" tier (20 req/min) - authenticated endpoint */ @Post("connections/:id/disconnect") - @UseGuards(AuthGuard) + @UseGuards(AuthGuard, WorkspaceGuard) @Throttle({ medium: { limit: 20, ttl: 60000 } }) async disconnectConnection( @Req() req: AuthenticatedRequest, @@ -171,11 +172,11 @@ export class FederationController { /** * Get all connections for the workspace - * Requires authentication + * Requires authentication and workspace access * Rate limit: "long" tier (200 req/hour) - read-only endpoint */ @Get("connections") - @UseGuards(AuthGuard) + @UseGuards(AuthGuard, WorkspaceGuard) @Throttle({ long: { limit: 200, ttl: 3600000 } }) async getConnections( @Req() req: AuthenticatedRequest, @@ -190,11 +191,11 @@ export class FederationController { /** * Get a single connection - * Requires authentication + * Requires authentication and workspace access * Rate limit: "long" tier (200 req/hour) - read-only endpoint */ @Get("connections/:id") - @UseGuards(AuthGuard) + @UseGuards(AuthGuard, WorkspaceGuard) @Throttle({ long: { limit: 200, ttl: 3600000 } }) async getConnection( @Req() req: AuthenticatedRequest, diff --git a/apps/api/src/federation/workspace-access.integration.spec.ts b/apps/api/src/federation/workspace-access.integration.spec.ts new file mode 100644 index 0000000..27b02aa --- /dev/null +++ b/apps/api/src/federation/workspace-access.integration.spec.ts @@ -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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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); + }); + }); +}); diff --git a/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-federation-federation.controller.ts_20260203-2149_1_remediation_needed.md b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-federation-federation.controller.ts_20260203-2149_1_remediation_needed.md new file mode 100644 index 0000000..f6e1800 --- /dev/null +++ b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-federation-federation.controller.ts_20260203-2149_1_remediation_needed.md @@ -0,0 +1,20 @@ +# QA Remediation Report + +**File:** /home/jwoltje/src/mosaic-stack/apps/api/src/federation/federation.controller.ts +**Tool Used:** Edit +**Epic:** general +**Iteration:** 1 +**Generated:** 2026-02-03 21:49:34 + +## Status + +Pending QA validation + +## Next Steps + +This report was created by the QA automation hook. +To process this report, run: + +```bash +claude -p "Use Task tool to launch universal-qa-agent for report: /home/jwoltje/src/mosaic-stack/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-federation-federation.controller.ts_20260203-2149_1_remediation_needed.md" +``` diff --git a/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-federation-federation.controller.ts_20260203-2149_2_remediation_needed.md b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-federation-federation.controller.ts_20260203-2149_2_remediation_needed.md new file mode 100644 index 0000000..c16a923 --- /dev/null +++ b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-federation-federation.controller.ts_20260203-2149_2_remediation_needed.md @@ -0,0 +1,20 @@ +# QA Remediation Report + +**File:** /home/jwoltje/src/mosaic-stack/apps/api/src/federation/federation.controller.ts +**Tool Used:** Edit +**Epic:** general +**Iteration:** 2 +**Generated:** 2026-02-03 21:49:39 + +## Status + +Pending QA validation + +## Next Steps + +This report was created by the QA automation hook. +To process this report, run: + +```bash +claude -p "Use Task tool to launch universal-qa-agent for report: /home/jwoltje/src/mosaic-stack/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-federation-federation.controller.ts_20260203-2149_2_remediation_needed.md" +``` diff --git a/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-federation-federation.controller.ts_20260203-2150_1_remediation_needed.md b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-federation-federation.controller.ts_20260203-2150_1_remediation_needed.md new file mode 100644 index 0000000..bfab02c --- /dev/null +++ b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-federation-federation.controller.ts_20260203-2150_1_remediation_needed.md @@ -0,0 +1,20 @@ +# QA Remediation Report + +**File:** /home/jwoltje/src/mosaic-stack/apps/api/src/federation/federation.controller.ts +**Tool Used:** Edit +**Epic:** general +**Iteration:** 1 +**Generated:** 2026-02-03 21:50:03 + +## Status + +Pending QA validation + +## Next Steps + +This report was created by the QA automation hook. +To process this report, run: + +```bash +claude -p "Use Task tool to launch universal-qa-agent for report: /home/jwoltje/src/mosaic-stack/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-federation-federation.controller.ts_20260203-2150_1_remediation_needed.md" +``` diff --git a/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-federation-workspace-access.integration.spec.ts_20260203-2148_1_remediation_needed.md b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-federation-workspace-access.integration.spec.ts_20260203-2148_1_remediation_needed.md new file mode 100644 index 0000000..03bc112 --- /dev/null +++ b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-federation-workspace-access.integration.spec.ts_20260203-2148_1_remediation_needed.md @@ -0,0 +1,20 @@ +# QA Remediation Report + +**File:** /home/jwoltje/src/mosaic-stack/apps/api/src/federation/workspace-access.integration.spec.ts +**Tool Used:** Write +**Epic:** general +**Iteration:** 1 +**Generated:** 2026-02-03 21:48:25 + +## Status + +Pending QA validation + +## Next Steps + +This report was created by the QA automation hook. +To process this report, run: + +```bash +claude -p "Use Task tool to launch universal-qa-agent for report: /home/jwoltje/src/mosaic-stack/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-federation-workspace-access.integration.spec.ts_20260203-2148_1_remediation_needed.md" +``` diff --git a/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-federation-workspace-access.integration.spec.ts_20260203-2148_2_remediation_needed.md b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-federation-workspace-access.integration.spec.ts_20260203-2148_2_remediation_needed.md new file mode 100644 index 0000000..992677f --- /dev/null +++ b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-federation-workspace-access.integration.spec.ts_20260203-2148_2_remediation_needed.md @@ -0,0 +1,20 @@ +# QA Remediation Report + +**File:** /home/jwoltje/src/mosaic-stack/apps/api/src/federation/workspace-access.integration.spec.ts +**Tool Used:** Edit +**Epic:** general +**Iteration:** 2 +**Generated:** 2026-02-03 21:48:42 + +## Status + +Pending QA validation + +## Next Steps + +This report was created by the QA automation hook. +To process this report, run: + +```bash +claude -p "Use Task tool to launch universal-qa-agent for report: /home/jwoltje/src/mosaic-stack/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-federation-workspace-access.integration.spec.ts_20260203-2148_2_remediation_needed.md" +``` diff --git a/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-federation-workspace-access.integration.spec.ts_20260203-2148_3_remediation_needed.md b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-federation-workspace-access.integration.spec.ts_20260203-2148_3_remediation_needed.md new file mode 100644 index 0000000..f8e6666 --- /dev/null +++ b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-federation-workspace-access.integration.spec.ts_20260203-2148_3_remediation_needed.md @@ -0,0 +1,20 @@ +# QA Remediation Report + +**File:** /home/jwoltje/src/mosaic-stack/apps/api/src/federation/workspace-access.integration.spec.ts +**Tool Used:** Edit +**Epic:** general +**Iteration:** 3 +**Generated:** 2026-02-03 21:48:56 + +## Status + +Pending QA validation + +## Next Steps + +This report was created by the QA automation hook. +To process this report, run: + +```bash +claude -p "Use Task tool to launch universal-qa-agent for report: /home/jwoltje/src/mosaic-stack/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-federation-workspace-access.integration.spec.ts_20260203-2148_3_remediation_needed.md" +``` diff --git a/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-federation-workspace-access.integration.spec.ts_20260203-2149_1_remediation_needed.md b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-federation-workspace-access.integration.spec.ts_20260203-2149_1_remediation_needed.md new file mode 100644 index 0000000..1e46bb5 --- /dev/null +++ b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-federation-workspace-access.integration.spec.ts_20260203-2149_1_remediation_needed.md @@ -0,0 +1,20 @@ +# QA Remediation Report + +**File:** /home/jwoltje/src/mosaic-stack/apps/api/src/federation/workspace-access.integration.spec.ts +**Tool Used:** Edit +**Epic:** general +**Iteration:** 1 +**Generated:** 2026-02-03 21:49:01 + +## Status + +Pending QA validation + +## Next Steps + +This report was created by the QA automation hook. +To process this report, run: + +```bash +claude -p "Use Task tool to launch universal-qa-agent for report: /home/jwoltje/src/mosaic-stack/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-federation-workspace-access.integration.spec.ts_20260203-2149_1_remediation_needed.md" +``` diff --git a/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-federation-workspace-access.integration.spec.ts_20260203-2149_2_remediation_needed.md b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-federation-workspace-access.integration.spec.ts_20260203-2149_2_remediation_needed.md new file mode 100644 index 0000000..0b20644 --- /dev/null +++ b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-federation-workspace-access.integration.spec.ts_20260203-2149_2_remediation_needed.md @@ -0,0 +1,20 @@ +# QA Remediation Report + +**File:** /home/jwoltje/src/mosaic-stack/apps/api/src/federation/workspace-access.integration.spec.ts +**Tool Used:** Edit +**Epic:** general +**Iteration:** 2 +**Generated:** 2026-02-03 21:49:06 + +## Status + +Pending QA validation + +## Next Steps + +This report was created by the QA automation hook. +To process this report, run: + +```bash +claude -p "Use Task tool to launch universal-qa-agent for report: /home/jwoltje/src/mosaic-stack/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-federation-workspace-access.integration.spec.ts_20260203-2149_2_remediation_needed.md" +``` diff --git a/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-federation-workspace-access.integration.spec.ts_20260203-2149_3_remediation_needed.md b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-federation-workspace-access.integration.spec.ts_20260203-2149_3_remediation_needed.md new file mode 100644 index 0000000..f9d3060 --- /dev/null +++ b/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-federation-workspace-access.integration.spec.ts_20260203-2149_3_remediation_needed.md @@ -0,0 +1,20 @@ +# QA Remediation Report + +**File:** /home/jwoltje/src/mosaic-stack/apps/api/src/federation/workspace-access.integration.spec.ts +**Tool Used:** Edit +**Epic:** general +**Iteration:** 3 +**Generated:** 2026-02-03 21:49:21 + +## Status + +Pending QA validation + +## Next Steps + +This report was created by the QA automation hook. +To process this report, run: + +```bash +claude -p "Use Task tool to launch universal-qa-agent for report: /home/jwoltje/src/mosaic-stack/docs/reports/qa-automation/pending/home-jwoltje-src-mosaic-stack-apps-api-src-federation-workspace-access.integration.spec.ts_20260203-2149_3_remediation_needed.md" +```