Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 36 additions & 2 deletions src/engagements/engagements.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Engagement> {
return this.engagementsService.findOne(id, {
async findOne(
@Param("id") id: string,
@Req() req: Request & { authUser?: Record<string, any> },
): Promise<Engagement> {
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")
Expand Down Expand Up @@ -458,4 +477,19 @@ export class EngagementsController {
);
}
}

private canViewAssignmentDetails(authUser?: Record<string, any>): boolean {
if (!authUser) {
return false;
}

if (authUser.isMachine) {
return true;
}

const roles = getUserRoles(authUser);
return roles.some((role) =>
this.includePrivateRoles.has(role?.toLowerCase()),
);
}
}
26 changes: 18 additions & 8 deletions src/engagements/engagements.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -569,7 +569,10 @@ export class EngagementsService {

async findOne(
id: string,
options: { includeCreatorEmail?: boolean } = {},
options: {
includeCreatorEmail?: boolean;
includeAssignments?: boolean;
} = {},
): Promise<Engagement> {
const engagement = await this.db.engagement.findUnique({
where: { id },
Expand All @@ -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) {
Expand Down
196 changes: 196 additions & 0 deletions test/auth.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
);
});
});

Expand Down