Skip to content

Commit 420830e

Browse files
authored
Merge pull request #73 from bcgov/AI-1153
AI-1153 Keycloak & Identity Check Cleanup
2 parents 104471b + e9e4b53 commit 420830e

19 files changed

Lines changed: 71 additions & 282 deletions

apps/backend-services/src/api-key/api-key.controller.spec.ts

Lines changed: 0 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -70,23 +70,6 @@ describe("ApiKeyController", () => {
7070
expect(apiKeyService.getApiKey).toHaveBeenCalledWith("group123");
7171
});
7272

73-
it("should throw ForbiddenException when user is not a group member", async () => {
74-
await expect(
75-
controller.getApiKey(
76-
{
77-
...mockRequest,
78-
resolvedIdentity: {
79-
userId: "testuser",
80-
isSystemAdmin: false,
81-
groupRoles: {},
82-
},
83-
} as any,
84-
"group123",
85-
),
86-
).rejects.toThrow(ForbiddenException);
87-
expect(apiKeyService.getApiKey).not.toHaveBeenCalled();
88-
});
89-
9073
it("should throw BadRequestException when groupId is missing", async () => {
9174
await expect(
9275
controller.getApiKey(mockRequest as any, ""),
@@ -136,23 +119,6 @@ describe("ApiKeyController", () => {
136119
);
137120
});
138121

139-
it("should throw ForbiddenException when user is not a group member", async () => {
140-
await expect(
141-
controller.generateApiKey(
142-
{
143-
...mockRequest,
144-
resolvedIdentity: {
145-
userId: "testuser",
146-
isSystemAdmin: false,
147-
groupRoles: {},
148-
},
149-
} as any,
150-
{ groupId: "group123" },
151-
),
152-
).rejects.toThrow(ForbiddenException);
153-
expect(apiKeyService.generateApiKey).not.toHaveBeenCalled();
154-
});
155-
156122
it("should not throw when user has no email for regenerate", async () => {
157123
mockApiKeyService.regenerateApiKey.mockResolvedValue({});
158124
mockApiKeyService.getApiKeyGroupId.mockResolvedValue("group123");

apps/backend-services/src/api-key/api-key.controller.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,6 @@ export class ApiKeyController {
6060
if (!groupId) {
6161
throw new BadRequestException("groupId query parameter is required");
6262
}
63-
await identityCanAccessGroup(
64-
req.resolvedIdentity,
65-
groupId,
66-
GroupRole.ADMIN,
67-
);
6863
const apiKey = await this.apiKeyService.getApiKey(groupId);
6964
return { apiKey };
7065
}
@@ -90,7 +85,6 @@ export class ApiKeyController {
9085
"User ID is required to generate an API key",
9186
);
9287
}
93-
identityCanAccessGroup(req.resolvedIdentity, body.groupId, GroupRole.ADMIN);
9488
const apiKey = await this.apiKeyService.generateApiKey(
9589
userId,
9690
body.groupId,

apps/backend-services/src/auth/keycloak-jwt.strategy.ts

Lines changed: 0 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,6 @@ interface KeycloakJwtPayload {
3333
*/
3434
@Injectable()
3535
export class KeycloakJwtStrategy extends PassportStrategy(Strategy, "jwt") {
36-
private readonly clientId: string;
37-
3836
constructor(configService: ConfigService) {
3937
const ssoAuthServerUrl = configService.get<string>("SSO_AUTH_SERVER_URL");
4038
const realm = configService.get<string>("SSO_REALM");
@@ -96,64 +94,19 @@ export class KeycloakJwtStrategy extends PassportStrategy(Strategy, "jwt") {
9694
// token signed with the public key as a symmetric secret.
9795
algorithms: ["RS256"],
9896
});
99-
100-
this.clientId = clientId;
10197
}
10298

10399
/**
104100
* Called by Passport AFTER the JWT signature, issuer, audience, and expiry
105101
* have all been verified. The returned object is attached to `req.user`.
106-
*
107-
* We normalize roles here (via extractRoles) so that downstream handlers
108-
* can check `req.user.roles` without caring about Keycloak's nested claim
109-
* structure.
110102
*/
111103
validate(payload: KeycloakJwtPayload): User {
112-
const normalizedRoles = this.extractRoles(payload);
113-
114104
return {
115105
sub: payload.sub,
116106
idir_username: payload.idir_username,
117107
display_name: payload.display_name,
118108
email: payload.email,
119-
roles: normalizedRoles,
120109
...payload,
121110
};
122111
}
123-
124-
// TODO: review. bcgov may store roles in a different structure,
125-
// also determine if we need other roles from other sources.
126-
/**
127-
* Keycloak doesn't put roles in a single place — depending on how the realm
128-
* and client are configured, roles can appear in up to three locations:
129-
*
130-
* - `roles[]` — top-level claim (if client mappers add it)
131-
* - `realm_access.roles[]` — realm-wide roles (e.g. "admin", "user")
132-
* - `resource_access.<clientId>.roles[]` — roles scoped to a specific client
133-
*
134-
* extractRoles merges all three into a flat, deduplicated array so the rest
135-
* of the app (controllers) can just check `user.roles.includes("admin")`
136-
* without knowing which Keycloak claim it came from.
137-
*/
138-
private extractRoles(payload: KeycloakJwtPayload): string[] {
139-
const roleSet = new Set<string>();
140-
141-
const pushRoles = (roles?: string[]) => {
142-
roles?.forEach((role) => {
143-
if (role) {
144-
roleSet.add(role);
145-
}
146-
});
147-
};
148-
149-
// Collect from all potential sources
150-
pushRoles(payload.roles);
151-
pushRoles(payload.realm_access?.roles);
152-
153-
const resourceRoles = payload.resource_access ?? {};
154-
Object.values(resourceRoles).forEach((access) => pushRoles(access.roles));
155-
pushRoles(resourceRoles[this.clientId]?.roles);
156-
157-
return Array.from(roleSet);
158-
}
159112
}

apps/backend-services/src/azure/azure.controller.spec.ts

Lines changed: 0 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -109,19 +109,6 @@ describe("AzureController", () => {
109109
expect(result).toEqual({ id: "1" });
110110
expect(classifierService.createClassifierModel).toHaveBeenCalled();
111111
});
112-
it("should throw ForbiddenException if user not in group", async () => {
113-
const req = createMockReq();
114-
const body = {
115-
name: "c1",
116-
description: "desc",
117-
source: ClassifierSource.AZURE,
118-
status: ClassifierStatus.READY,
119-
group_id: "g1",
120-
};
121-
await expect(controller.createClassifier(req, body)).rejects.toThrow(
122-
ForbiddenException,
123-
);
124-
});
125112
it("should throw ForbiddenException if classifier exists", async () => {
126113
classifierService.findClassifierModel.mockResolvedValue({ id: "1" });
127114
const req = createMockReq("user1", ["g1"]);
@@ -173,17 +160,6 @@ describe("AzureController", () => {
173160
expect.any(Buffer),
174161
);
175162
});
176-
it("should throw ForbiddenException if user not in group", async () => {
177-
const req = createMockReq();
178-
await expect(
179-
controller.uploadClassifierDocuments(
180-
req,
181-
[],
182-
{ name: "c1", label: "l1" },
183-
"g1",
184-
),
185-
).rejects.toThrow(ForbiddenException);
186-
});
187163
it("should throw NotFoundException if classifier does not exist", async () => {
188164
classifierService.findClassifierModel.mockResolvedValue(null);
189165
const req = createMockReq("user1", ["g1"]);
@@ -210,15 +186,6 @@ describe("AzureController", () => {
210186
"classifier/g1/c1/",
211187
);
212188
});
213-
it("should throw ForbiddenException if user not in group", async () => {
214-
const req = createMockReq();
215-
await expect(
216-
controller.deleteClassifierDocuments(req, {
217-
name: "c1",
218-
group_id: "g1",
219-
}),
220-
).rejects.toThrow(ForbiddenException);
221-
});
222189
it("should throw NotFoundException if classifier does not exist", async () => {
223190
classifierService.findClassifierModel.mockResolvedValue(null);
224191
const req = createMockReq("user1", ["g1"]);
@@ -306,12 +273,6 @@ describe("AzureController", () => {
306273
);
307274
expect(result).toEqual({ result: "ok" });
308275
});
309-
it("should throw ForbiddenException if user not in group", async () => {
310-
const req = createMockReq();
311-
await expect(
312-
controller.requestClassification(req, { name: "c1" }, mockFile, "g1"),
313-
).rejects.toThrow(ForbiddenException);
314-
});
315276
it("should throw NotFoundException if classifier does not exist", async () => {
316277
classifierService.findClassifierModel.mockResolvedValue(null);
317278
const req = createMockReq("user1", ["g1"]);
@@ -373,15 +334,6 @@ describe("AzureController", () => {
373334
}),
374335
).rejects.toThrow();
375336
});
376-
it("should throw ForbiddenException if user not in group", async () => {
377-
const req = createMockReq();
378-
await expect(
379-
controller.getTrainingResult(req, {
380-
name: "c1",
381-
group_id: "g1",
382-
}),
383-
).rejects.toThrow(ForbiddenException);
384-
});
385337
it("should throw NotFoundException if classifier not found", async () => {
386338
classifierService.findClassifierModel.mockResolvedValue(null);
387339
const req = createMockReq("user1", ["g1"]);
@@ -436,12 +388,6 @@ describe("AzureController", () => {
436388
controller.getTrainingResult(req, { name: null, group_id: null }),
437389
).rejects.toThrow();
438390
});
439-
it("should throw ForbiddenException if user not in group", async () => {
440-
const req = createMockReq();
441-
await expect(
442-
controller.getTrainingResult(req, { name: "c1", group_id: "g1" }),
443-
).rejects.toThrow(ForbiddenException);
444-
});
445391
it("should throw NotFoundException if classifier not found", async () => {
446392
classifierService.findClassifierModel.mockResolvedValue(null);
447393
const req = createMockReq("user1", ["g1"]);
@@ -514,13 +460,6 @@ describe("AzureController", () => {
514460
expect(result).toEqual(["labelA/doc1", "labelB/doc2"]);
515461
expect(storageService.list).toHaveBeenCalledWith("classifier/g1/c1/");
516462
});
517-
it("should throw ForbiddenException if user not in group", async () => {
518-
const req = createMockReq();
519-
const query = { name: "c1", group_id: "g1" };
520-
await expect(
521-
controller.getClassifierDocuments(req, query),
522-
).rejects.toThrow(ForbiddenException);
523-
});
524463
it("should throw NotFoundException if classifier does not exist", async () => {
525464
classifierService.findClassifierModel.mockResolvedValue(null);
526465
const req = createMockReq("user1", ["g1"]);
@@ -548,13 +487,6 @@ describe("AzureController", () => {
548487
"user1",
549488
);
550489
});
551-
it("should throw ForbiddenException if user not in group", async () => {
552-
const req = createMockReq();
553-
const body = { name: "c1", group_id: "g1", description: "desc" };
554-
await expect(controller.updateClassifier(req, body)).rejects.toThrow(
555-
ForbiddenException,
556-
);
557-
});
558490
it("should throw NotFoundException if classifier does not exist", async () => {
559491
classifierService.findClassifierModel.mockResolvedValue(null);
560492
const req = createMockReq("user1", ["g1"]);

apps/backend-services/src/azure/azure.controller.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ export class AzureController {
7777
) {}
7878

7979
@Get("classifier")
80-
@Identity({ minimumRole: GroupRole.MEMBER })
80+
@Identity()
8181
@ApiOperation({
8282
summary: "Get classifiers for user groups",
8383
description:
@@ -123,7 +123,6 @@ export class AzureController {
123123
})
124124
async createClassifier(@Request() req, @Body() body: ClassifierCreationDto) {
125125
const { name, description, source, group_id } = body;
126-
identityCanAccessGroup(req.resolvedIdentity, group_id);
127126

128127
// Does this classifier already exist?
129128
const classifier = await this.classifierService.findClassifierModel(
@@ -167,8 +166,6 @@ export class AzureController {
167166
async updateClassifier(@Request() req, @Body() body: UpdateClassifierDto) {
168167
const { name, group_id, description, source } = body;
169168

170-
identityCanAccessGroup(req.resolvedIdentity, group_id);
171-
172169
// Check if classifier exists
173170
const classifier = await this.classifierService.findClassifierModel(
174171
name,
@@ -238,7 +235,6 @@ export class AzureController {
238235
@Query("group_id") group_id: string,
239236
): Promise<UploadClassifierDocumentsResponseDto> {
240237
const { name, label } = body;
241-
identityCanAccessGroup(req.resolvedIdentity, group_id);
242238

243239
const existingModelData = await this.classifierService.findClassifierModel(
244240
name,
@@ -281,7 +277,6 @@ export class AzureController {
281277
@Query() query: GetClassifierDocumentsQueryDto,
282278
): Promise<string[]> {
283279
const { name, group_id } = query;
284-
identityCanAccessGroup(req.resolvedIdentity, group_id);
285280

286281
const existingModelData = await this.classifierService.findClassifierModel(
287282
name,
@@ -315,7 +310,6 @@ export class AzureController {
315310
@Query() query: DeleteClassifierDocumentsDto,
316311
): Promise<void> {
317312
const { name, group_id, folder } = query;
318-
identityCanAccessGroup(req.resolvedIdentity, group_id);
319313

320314
const existingModelData = await this.classifierService.findClassifierModel(
321315
name,
@@ -366,7 +360,6 @@ export class AzureController {
366360
): Promise<ClassifierModelResponseDto> {
367361
const { name, group_id } = body;
368362
const userId = req.user.sub;
369-
identityCanAccessGroup(req.resolvedIdentity, group_id);
370363

371364
// Respond immediately and run the heavy work in the background
372365
const model = await this.classifierService.updateClassifierModel(
@@ -454,7 +447,6 @@ export class AzureController {
454447
): Promise<ClassifierResponseDto> {
455448
const { name } = body;
456449
const userId = req.user.sub;
457-
identityCanAccessGroup(req.resolvedIdentity, group_id);
458450
// Is there a classifier trained for this group?
459451
const classifier = await this.classifierService.findClassifierModel(
460452
name,

apps/backend-services/src/benchmark/benchmark-project.controller.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
getIdentityGroupIds,
4040
identityCanAccessGroup,
4141
} from "@/auth/identity.helpers";
42+
import { GroupRole } from "@/generated";
4243
import { BenchmarkProjectService } from "./benchmark-project.service";
4344
import { CreateProjectDto, ProjectDetailsDto, ProjectSummaryDto } from "./dto";
4445

@@ -53,7 +54,11 @@ export class BenchmarkProjectController {
5354

5455
@Post()
5556
@HttpCode(HttpStatus.CREATED)
56-
@Identity({ allowApiKey: true })
57+
@Identity({
58+
allowApiKey: true,
59+
groupIdFrom: { body: "groupId" },
60+
minimumRole: GroupRole.MEMBER,
61+
})
5762
@ApiOperation({ summary: "Create a benchmark project" })
5863
@ApiBody({ type: CreateProjectDto })
5964
@ApiCreatedResponse({
@@ -74,8 +79,6 @@ export class BenchmarkProjectController {
7479

7580
const userId = req.user?.sub || req.resolvedIdentity?.userId || "anonymous";
7681

77-
identityCanAccessGroup(req.resolvedIdentity, createProjectDto.groupId);
78-
7982
return this.benchmarkProjectService.createProject(createProjectDto, userId);
8083
}
8184

apps/backend-services/src/benchmark/benchmark-run.controller.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ export class BenchmarkRunController {
6464
): Promise<void> {
6565
const project =
6666
await this.benchmarkProjectService.getProjectById(projectId);
67-
await identityCanAccessGroup(req.resolvedIdentity, project.groupId);
67+
identityCanAccessGroup(req.resolvedIdentity, project.groupId);
6868
}
6969

7070
@Post("definitions/:definitionId/runs")

apps/backend-services/src/benchmark/dataset.controller.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
jest.mock("@/auth/identity.helpers", () => ({
2-
identityCanAccessGroup: jest.fn().mockResolvedValue(undefined),
3-
getIdentityGroupIds: jest.fn().mockResolvedValue(["test-group"]),
2+
identityCanAccessGroup: jest.fn().mockReturnValue(undefined),
3+
getIdentityGroupIds: jest.fn().mockReturnValue(["test-group"]),
44
}));
55

66
import { BadRequestException, NotFoundException } from "@nestjs/common";

0 commit comments

Comments
 (0)