From 3e4c9299a65bbb40a43983b957fe71368b044dcc Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Thu, 8 Oct 2026 11:27:09 +0200 Subject: [PATCH] fix(auth): return oidc-resolved admin role ids in the session payload Co-authored-by: Automata Signed-off-by: William Phetsinorath Change-Id: Ib4c3e29a5b12e9029bd271649c324c996a6a6964 --- .../src/modules/auth/auth.controller.spec.ts | 9 +++++++++ .../src/modules/auth/auth.controller.ts | 5 ++++- .../infrastructure/auth/auth-user.decorator.ts | 2 ++ .../auth/keycloak-jwt/keycloak-jwt.service.spec.ts | 3 +++ .../auth/keycloak-jwt/keycloak-jwt.service.ts | 14 +++++++++++--- .../permission/project/project.guard.ts | 2 ++ .../permission/user/user.guard.spec.ts | 12 ++++++++++++ .../infrastructure/permission/user/user.guard.ts | 2 ++ 8 files changed, 45 insertions(+), 4 deletions(-) diff --git a/apps/server-nestjs/src/modules/auth/auth.controller.spec.ts b/apps/server-nestjs/src/modules/auth/auth.controller.spec.ts index 13ca5bbad2..7cd8bfc224 100644 --- a/apps/server-nestjs/src/modules/auth/auth.controller.spec.ts +++ b/apps/server-nestjs/src/modules/auth/auth.controller.spec.ts @@ -38,4 +38,13 @@ describe('authController', () => { expect(await controller.auth({ userId: user.id })).toEqual(toContractUser(user)) expect(service.getUser).toHaveBeenCalledWith(user.id) }) + + it('merges the oidc-resolved admin role ids into the session payload', async () => { + const user: User = makeUser({ adminRoleIds: ['persisted-role'] }) + service.getUser.mockResolvedValue(user) + + const payload = await controller.auth({ userId: user.id, adminRoleIds: ['persisted-role', 'oidc-role'] }) + + expect(payload).toEqual({ ...toContractUser(user), adminRoleIds: ['persisted-role', 'oidc-role'] }) + }) }) diff --git a/apps/server-nestjs/src/modules/auth/auth.controller.ts b/apps/server-nestjs/src/modules/auth/auth.controller.ts index a6068db8b6..5037ca4504 100644 --- a/apps/server-nestjs/src/modules/auth/auth.controller.ts +++ b/apps/server-nestjs/src/modules/auth/auth.controller.ts @@ -13,6 +13,9 @@ export class AuthController { @Get() @UseGuards(UserGuard) async auth(@AuthUser() user: UserContext): Promise { - return toContractUser(await this.authUserService.getUser(user.userId)) + const contractUser = toContractUser(await this.authUserService.getUser(user.userId)) + return user.adminRoleIds === undefined + ? contractUser + : { ...contractUser, adminRoleIds: user.adminRoleIds } } } diff --git a/apps/server-nestjs/src/modules/infrastructure/auth/auth-user.decorator.ts b/apps/server-nestjs/src/modules/infrastructure/auth/auth-user.decorator.ts index f018ac647e..0755a646c5 100644 --- a/apps/server-nestjs/src/modules/infrastructure/auth/auth-user.decorator.ts +++ b/apps/server-nestjs/src/modules/infrastructure/auth/auth-user.decorator.ts @@ -6,6 +6,7 @@ import { createParamDecorator } from '@nestjs/common' export interface UserContext { userId: string adminPermissions?: bigint + adminRoleIds?: string[] userType?: User['type'] } @@ -15,6 +16,7 @@ export const AuthUser = createParamDecorator( return { userId: request.userId, adminPermissions: request.adminPermissions, + adminRoleIds: request.adminRoleIds, userType: request.userType, } }, diff --git a/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.service.spec.ts b/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.service.spec.ts index a1692faaa3..a99e3ddc14 100644 --- a/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.service.spec.ts +++ b/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.service.spec.ts @@ -90,6 +90,7 @@ describe('keycloakJwtService', () => { expect(result).toEqual({ userId: payload.sub, adminPermissions: 60n, + adminRoleIds: ['stale-oidc-role', 'manual-role', 'current-oidc-role'], userType: 'human', }) }) @@ -131,6 +132,7 @@ describe('keycloakJwtService', () => { expect(result).toEqual({ userId: payload.sub, adminPermissions: 0n, + adminRoleIds: [], userType: 'human', }) }) @@ -165,6 +167,7 @@ describe('keycloakJwtService', () => { expect(result).toEqual({ userId: payload.sub, adminPermissions: undefined, + adminRoleIds: undefined, userType: 'human', }) }) diff --git a/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.service.ts b/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.service.ts index 78a3ddfe99..19fbc1a9eb 100644 --- a/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.service.ts +++ b/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.service.ts @@ -61,21 +61,29 @@ export class KeycloakJwtService implements AuthProvider { ): Promise { return this.prisma.$transaction(async (tx) => { const user = await upsertUser(tx, payload, makeUserSelect(requirements)) + const adminContext = await this.maybeAdminContext(tx, payload, user.adminRoleIds ?? [], requirements) return { userId: payload.sub, - adminPermissions: await this.maybeAdminPermissions(tx, payload, user.adminRoleIds ?? [], requirements), + adminPermissions: adminContext?.adminPermissions, + adminRoleIds: adminContext?.adminRoleIds, userType: this.maybeUserType(user, requirements), } }) } - private async maybeAdminPermissions(tx: Prisma.TransactionClient, payload: KeycloakPayload, adminRoleIds: string[], requirements?: AuthRequirements) { + private async maybeAdminContext(tx: Prisma.TransactionClient, payload: KeycloakPayload, adminRoleIds: string[], requirements?: AuthRequirements) { if (!(requirements?.includeAdminRoleIds ?? true)) { return undefined } const matchingAdminRoles = await listMatchingAdminRoles(tx, payload.groups, adminRoleIds) - return matchingAdminRoles.reduce((acc, curr) => acc | curr.permissions, 0n) + const oidcRoleIds = matchingAdminRoles + .filter(({ oidcGroup }) => oidcGroup && payload.groups.includes(oidcGroup)) + .map(({ id }) => id) + return { + adminPermissions: matchingAdminRoles.reduce((acc, curr) => acc | curr.permissions, 0n), + adminRoleIds: [...new Set([...adminRoleIds, ...oidcRoleIds])], + } } private maybeUserType(user: UserRecord, requirements?: AuthRequirements) { diff --git a/apps/server-nestjs/src/modules/infrastructure/permission/project/project.guard.ts b/apps/server-nestjs/src/modules/infrastructure/permission/project/project.guard.ts index b5ef931868..3b66d10046 100644 --- a/apps/server-nestjs/src/modules/infrastructure/permission/project/project.guard.ts +++ b/apps/server-nestjs/src/modules/infrastructure/permission/project/project.guard.ts @@ -23,6 +23,7 @@ export interface RequestWithProjectContext extends FastifyRequest { type RequestWithUserContext = FastifyRequest & { userId?: string adminPermissions?: bigint + adminRoleIds?: string[] userType?: string } @@ -70,6 +71,7 @@ export class ProjectGuard implements CanActivate { request.userId = user.userId if (user.adminPermissions !== undefined) request.adminPermissions = user.adminPermissions + if (user.adminRoleIds !== undefined) request.adminRoleIds = user.adminRoleIds if (user.userType !== undefined) request.userType = user.userType return user diff --git a/apps/server-nestjs/src/modules/infrastructure/permission/user/user.guard.spec.ts b/apps/server-nestjs/src/modules/infrastructure/permission/user/user.guard.spec.ts index 447b77477e..211f26f6a9 100644 --- a/apps/server-nestjs/src/modules/infrastructure/permission/user/user.guard.spec.ts +++ b/apps/server-nestjs/src/modules/infrastructure/permission/user/user.guard.spec.ts @@ -61,6 +61,18 @@ describe('userGuard', () => { expect(request.userId).toBe('u1') }) + it('propagates the oidc-resolved admin role ids onto the request', async () => { + authService.authenticate.mockResolvedValue({ userId: 'u1', adminRoleIds: ['oidc-role'] }) + userPolicy.build.mockReturnValue({ adminPermissions: [], userTypes: [] }) + const ctx = makeExecutionContext({ 'x-dso-token': 'tok' }) + + const result = await guard.canActivate(ctx) + const request = ctx.switchToHttp().getRequest() + + expect(result).toBe(true) + expect(request.adminRoleIds).toEqual(['oidc-role']) + }) + it('validates required admin permissions', async () => { authService.authenticate.mockResolvedValue({ userId: 'u1', adminPermissions: 32768n }) const policy: UserPolicyConfig = { adminPermissions: ['ListSystem'], userTypes: [] } diff --git a/apps/server-nestjs/src/modules/infrastructure/permission/user/user.guard.ts b/apps/server-nestjs/src/modules/infrastructure/permission/user/user.guard.ts index 28c339accd..9b16edcb04 100644 --- a/apps/server-nestjs/src/modules/infrastructure/permission/user/user.guard.ts +++ b/apps/server-nestjs/src/modules/infrastructure/permission/user/user.guard.ts @@ -8,6 +8,7 @@ import { UserPermissionService } from './user.service' type RequestWithAuthContext = FastifyRequest & { userId?: string adminPermissions?: bigint + adminRoleIds?: string[] userType?: string } @@ -45,6 +46,7 @@ export class UserGuard implements CanActivate { request.userId = user.userId if (user.adminPermissions !== undefined) request.adminPermissions = user.adminPermissions + if (user.adminRoleIds !== undefined) request.adminRoleIds = user.adminRoleIds if (user.userType !== undefined) request.userType = user.userType return user