From a8aec8d6f0f62e6677ee55df5bd867e3b772fdc0 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 13 Feb 2026 16:59:55 +1100 Subject: [PATCH] Additional access controls to private engagements and assignments --- src/engagements/engagements.controller.ts | 38 ++++- src/engagements/engagements.service.ts | 26 ++- test/auth.e2e-spec.ts | 196 ++++++++++++++++++++++ 3 files changed, 250 insertions(+), 10 deletions(-) diff --git a/src/engagements/engagements.controller.ts b/src/engagements/engagements.controller.ts index cba97ae..d9f5085 100644 --- a/src/engagements/engagements.controller.ts +++ b/src/engagements/engagements.controller.ts @@ -194,11 +194,30 @@ export class EngagementsController { description: "Engagement retrieved.", type: EngagementResponseDto, }) + @ApiUnauthorizedResponse({ + description: + "Private engagements require administrator, talent manager, or M2M authentication.", + }) @ApiNotFoundResponse({ description: "Engagement not found." }) - async findOne(@Param("id") id: string): Promise { - return this.engagementsService.findOne(id, { + async findOne( + @Param("id") id: string, + @Req() req: Request & { authUser?: Record }, + ): Promise { + const canViewPrivateEngagement = this.canViewAssignmentDetails( + req.authUser, + ); + const engagement = await this.engagementsService.findOne(id, { includeCreatorEmail: true, + includeAssignments: canViewPrivateEngagement, }); + + if (engagement.isPrivate && !canViewPrivateEngagement) { + throw new UnauthorizedException( + "You are not authorized to access this private engagement.", + ); + } + + return engagement; } @Put(":id") @@ -458,4 +477,19 @@ export class EngagementsController { ); } } + + private canViewAssignmentDetails(authUser?: Record): boolean { + if (!authUser) { + return false; + } + + if (authUser.isMachine) { + return true; + } + + const roles = getUserRoles(authUser); + return roles.some((role) => + this.includePrivateRoles.has(role?.toLowerCase()), + ); + } } diff --git a/src/engagements/engagements.service.ts b/src/engagements/engagements.service.ts index 91426b5..480c0c4 100644 --- a/src/engagements/engagements.service.ts +++ b/src/engagements/engagements.service.ts @@ -569,7 +569,10 @@ export class EngagementsService { async findOne( id: string, - options: { includeCreatorEmail?: boolean } = {}, + options: { + includeCreatorEmail?: boolean; + includeAssignments?: boolean; + } = {}, ): Promise { const engagement = await this.db.engagement.findUnique({ where: { id }, @@ -580,18 +583,25 @@ export class EngagementsService { } this.logger.debug("Raw engagement", engagement); + const includeAssignments = options.includeAssignments !== false; - const engagementWithAssignments = this.applyAssignmentFields(engagement); + const engagementWithFields = includeAssignments + ? this.applyAssignmentFields(engagement) + : (() => { + const { assignments, ...rest } = engagement; + void assignments; + return rest; + })(); const normalizedEngagement = { - ...engagementWithAssignments, - role: engagementWithAssignments.role - ? (engagementWithAssignments.role.toString() as Role) + ...engagementWithFields, + role: engagementWithFields.role + ? (engagementWithFields.role.toString() as Role) : null, - workload: engagementWithAssignments.workload - ? (engagementWithAssignments.workload.toString() as Workload) + workload: engagementWithFields.workload + ? (engagementWithFields.workload.toString() as Workload) : null, - compensationRange: engagementWithAssignments.compensationRange ?? null, + compensationRange: engagementWithFields.compensationRange ?? null, }; if (!options.includeCreatorEmail) { diff --git a/test/auth.e2e-spec.ts b/test/auth.e2e-spec.ts index d25f8eb..c62ef20 100644 --- a/test/auth.e2e-spec.ts +++ b/test/auth.e2e-spec.ts @@ -267,16 +267,212 @@ describe("Authentication & Authorization (e2e)", () => { }); it("allows anonymous access to engagement by ID", async () => { + engagementsServiceMock.findOne.mockClear(); + await request(app.getHttpServer()) .get("/engagements/eng-1") .expect(200); + + expect(engagementsServiceMock.findOne).toHaveBeenCalledWith( + "eng-1", + expect.objectContaining({ + includeAssignments: false, + includeCreatorEmail: true, + }), + ); }); it("allows authenticated user without scopes to access engagement by ID", async () => { + engagementsServiceMock.findOne.mockClear(); + await request(app.getHttpServer()) .get("/engagements/eng-1") .set("Authorization", "Bearer bare-user") .expect(200); + + expect(engagementsServiceMock.findOne).toHaveBeenCalledWith( + "eng-1", + expect.objectContaining({ + includeAssignments: false, + includeCreatorEmail: true, + }), + ); + }); + + it("includes assignments for admin user when accessing engagement by ID", async () => { + engagementsServiceMock.findOne.mockClear(); + + await request(app.getHttpServer()) + .get("/engagements/eng-1") + .set("Authorization", "Bearer admin-user") + .expect(200); + + expect(engagementsServiceMock.findOne).toHaveBeenCalledWith( + "eng-1", + expect.objectContaining({ + includeAssignments: true, + includeCreatorEmail: true, + }), + ); + }); + + it("includes assignments for talent manager user when accessing engagement by ID", async () => { + engagementsServiceMock.findOne.mockClear(); + + await request(app.getHttpServer()) + .get("/engagements/eng-1") + .set("Authorization", "Bearer talent-manager-user") + .expect(200); + + expect(engagementsServiceMock.findOne).toHaveBeenCalledWith( + "eng-1", + expect.objectContaining({ + includeAssignments: true, + includeCreatorEmail: true, + }), + ); + }); + + it("includes assignments for M2M token when accessing engagement by ID", async () => { + engagementsServiceMock.findOne.mockClear(); + + await request(app.getHttpServer()) + .get("/engagements/eng-1") + .set("Authorization", "Bearer m2m-read") + .expect(200); + + expect(engagementsServiceMock.findOne).toHaveBeenCalledWith( + "eng-1", + expect.objectContaining({ + includeAssignments: true, + includeCreatorEmail: true, + }), + ); + }); + }); + + describe("Private Engagement Access", () => { + it("returns 401 for anonymous access when engagement is private", async () => { + engagementsServiceMock.findOne.mockClear(); + engagementsServiceMock.findOne.mockResolvedValueOnce({ + id: "eng-private", + isPrivate: true, + }); + + await request(app.getHttpServer()).get("/engagements/eng-private").expect(401); + + expect(engagementsServiceMock.findOne).toHaveBeenCalledWith( + "eng-private", + expect.objectContaining({ + includeAssignments: false, + includeCreatorEmail: true, + }), + ); + }); + + it("returns 401 for non-privileged user when engagement is private", async () => { + engagementsServiceMock.findOne.mockClear(); + engagementsServiceMock.findOne.mockResolvedValueOnce({ + id: "eng-private", + isPrivate: true, + }); + + await request(app.getHttpServer()) + .get("/engagements/eng-private") + .set("Authorization", "Bearer member-user") + .expect(401); + + expect(engagementsServiceMock.findOne).toHaveBeenCalledWith( + "eng-private", + expect.objectContaining({ + includeAssignments: false, + includeCreatorEmail: true, + }), + ); + }); + + it("returns 401 for project manager when engagement is private", async () => { + engagementsServiceMock.findOne.mockClear(); + engagementsServiceMock.findOne.mockResolvedValueOnce({ + id: "eng-private", + isPrivate: true, + }); + + await request(app.getHttpServer()) + .get("/engagements/eng-private") + .set("Authorization", "Bearer project-manager-user") + .expect(401); + + expect(engagementsServiceMock.findOne).toHaveBeenCalledWith( + "eng-private", + expect.objectContaining({ + includeAssignments: false, + includeCreatorEmail: true, + }), + ); + }); + + it("allows admin when engagement is private", async () => { + engagementsServiceMock.findOne.mockClear(); + engagementsServiceMock.findOne.mockResolvedValueOnce({ + id: "eng-private", + isPrivate: true, + }); + + await request(app.getHttpServer()) + .get("/engagements/eng-private") + .set("Authorization", "Bearer admin-user") + .expect(200); + + expect(engagementsServiceMock.findOne).toHaveBeenCalledWith( + "eng-private", + expect.objectContaining({ + includeAssignments: true, + includeCreatorEmail: true, + }), + ); + }); + + it("allows talent manager when engagement is private", async () => { + engagementsServiceMock.findOne.mockClear(); + engagementsServiceMock.findOne.mockResolvedValueOnce({ + id: "eng-private", + isPrivate: true, + }); + + await request(app.getHttpServer()) + .get("/engagements/eng-private") + .set("Authorization", "Bearer talent-manager-user") + .expect(200); + + expect(engagementsServiceMock.findOne).toHaveBeenCalledWith( + "eng-private", + expect.objectContaining({ + includeAssignments: true, + includeCreatorEmail: true, + }), + ); + }); + + it("allows M2M when engagement is private", async () => { + engagementsServiceMock.findOne.mockClear(); + engagementsServiceMock.findOne.mockResolvedValueOnce({ + id: "eng-private", + isPrivate: true, + }); + + await request(app.getHttpServer()) + .get("/engagements/eng-private") + .set("Authorization", "Bearer m2m-read") + .expect(200); + + expect(engagementsServiceMock.findOne).toHaveBeenCalledWith( + "eng-private", + expect.objectContaining({ + includeAssignments: true, + includeCreatorEmail: true, + }), + ); }); });