diff --git a/apps/server-nestjs/src/main.module.ts b/apps/server-nestjs/src/main.module.ts index 362ea2d114..0fdc0dce02 100644 --- a/apps/server-nestjs/src/main.module.ts +++ b/apps/server-nestjs/src/main.module.ts @@ -5,6 +5,7 @@ import { TerminusModule } from '@nestjs/terminus' import { baseConfigFactory } from './config/base.config' import { AdminRoleModule } from './modules/admin-role/admin-role.module' import { AdminTokenModule } from './modules/admin-token/admin-token.module' +import { AuthModule } from './modules/auth/auth.module' import { DeploymentModule } from './modules/deployment/deployment.module' import { EnvironmentModule } from './modules/environment/environment.module' import { HealthzModule } from './modules/healthz/healthz.module' @@ -20,8 +21,8 @@ import { ProjectSecretsModule } from './modules/project-secrets/project-secrets. import { ProjectServicesModule } from './modules/project-services/project-services.module' import { ProjectModule } from './modules/project/project.module' import { RepositoryModule } from './modules/repository/repository.module' -import { SystemConfigModule } from './modules/system-config/system-config.module' import { StageModule } from './modules/stage/stage.module' +import { SystemConfigModule } from './modules/system-config/system-config.module' import { SystemSettingsModule } from './modules/system-settings/system-settings.module' import { UserModule } from './modules/user/user.module' import { VersionModule } from './modules/version/version.module' @@ -38,6 +39,7 @@ import { getDotenvPaths } from './utils/dotenv.utils' TerminusModule.forRoot(), AdminRoleModule, AdminTokenModule, + AuthModule, DeploymentModule, EnvironmentModule, HealthzModule, diff --git a/apps/server-nestjs/src/modules/auth/auth.controller.spec.ts b/apps/server-nestjs/src/modules/auth/auth.controller.spec.ts new file mode 100644 index 0000000000..13ca5bbad2 --- /dev/null +++ b/apps/server-nestjs/src/modules/auth/auth.controller.spec.ts @@ -0,0 +1,41 @@ +import type { TestingModule } from '@nestjs/testing' +import type { User } from '@prisma/client' +import type { MockProxy } from 'vitest-mock-extended' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mock } from 'vitest-mock-extended' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { makeUser } from '../user/user-testing.utils' +import { toContractUser } from '../user/user.utils' +import { AuthController } from './auth.controller' +import { AuthUserService } from './auth.service' + +describe('authController', () => { + let module: TestingModule + let controller: AuthController + let service: MockProxy + + beforeEach(async () => { + service = mock() + + module = await Test.createTestingModule({ + controllers: [AuthController], + providers: [ + { provide: AuthUserService, useValue: service }, + ], + }) + .overrideGuard(UserGuard) + .useValue({ canActivate: () => true }) + .compile() + + controller = module.get(AuthController) + }) + + it('routes an authenticated session to the service', async () => { + const user: User = makeUser() + service.getUser.mockResolvedValue(user) + + expect(await controller.auth({ userId: user.id })).toEqual(toContractUser(user)) + expect(service.getUser).toHaveBeenCalledWith(user.id) + }) +}) diff --git a/apps/server-nestjs/src/modules/auth/auth.controller.ts b/apps/server-nestjs/src/modules/auth/auth.controller.ts new file mode 100644 index 0000000000..a6068db8b6 --- /dev/null +++ b/apps/server-nestjs/src/modules/auth/auth.controller.ts @@ -0,0 +1,18 @@ +import type { User } from '@cpn-console/shared' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import { Controller, Get, Inject, UseGuards } from '@nestjs/common' +import { AuthUser } from '../infrastructure/auth/auth-user.decorator' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { toContractUser } from '../user/user.utils' +import { AuthUserService } from './auth.service' + +@Controller('api/v1/auth') +export class AuthController { + constructor(@Inject(AuthUserService) private readonly authUserService: AuthUserService) {} + + @Get() + @UseGuards(UserGuard) + async auth(@AuthUser() user: UserContext): Promise { + return toContractUser(await this.authUserService.getUser(user.userId)) + } +} diff --git a/apps/server-nestjs/src/modules/auth/auth.module.ts b/apps/server-nestjs/src/modules/auth/auth.module.ts new file mode 100644 index 0000000000..4bc2a17e90 --- /dev/null +++ b/apps/server-nestjs/src/modules/auth/auth.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common' +import { AuthModule as InfrastructureAuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { UserPermissionModule } from '../infrastructure/permission/user/user.module' +import { AuthController } from './auth.controller' +import { AuthUserService } from './auth.service' + +@Module({ + imports: [InfrastructureAuthModule, DatabaseModule, UserPermissionModule], + controllers: [AuthController], + providers: [AuthUserService], +}) +export class AuthModule {} diff --git a/apps/server-nestjs/src/modules/auth/auth.service.ts b/apps/server-nestjs/src/modules/auth/auth.service.ts new file mode 100644 index 0000000000..50d40eecad --- /dev/null +++ b/apps/server-nestjs/src/modules/auth/auth.service.ts @@ -0,0 +1,16 @@ +import type { User } from '@prisma/client' +import { Inject, Injectable, NotFoundException } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' + +@Injectable() +export class AuthUserService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async getUser(id: string): Promise { + const user = await this.prisma.user.findUnique({ where: { id } }) + if (!user) { + throw new NotFoundException(`Unable to find user ${id}`) + } + return user + } +} diff --git a/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt-queries.utils.ts b/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt-queries.utils.ts new file mode 100644 index 0000000000..961c8196cf --- /dev/null +++ b/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt-queries.utils.ts @@ -0,0 +1,68 @@ +import type { Prisma } from '@prisma/client' +import type { AuthRequirements } from '../auth.utils' + +export const userSelect = { + id: true, + adminRoleIds: true, + type: true, +} satisfies Prisma.UserSelect + +export const userIdSelect = { + id: true, +} satisfies Prisma.UserSelect + +export type UserRecord = Prisma.UserGetPayload<{ select: ReturnType }> + +export function makeUserSelect(requirements?: AuthRequirements): Prisma.UserSelect { + const includeAdminRoleIds = requirements?.includeAdminRoleIds ?? true + const includeUserType = requirements?.includeUserType ?? true + return { + id: true, + ...(includeAdminRoleIds ? { adminRoleIds: true } : {}), + ...(includeUserType ? { type: true } : {}), + } +} + +export function upsertUser( + tx: Prisma.TransactionClient, + payload: { sub: string, email: string, given_name: string, family_name: string }, + select: Prisma.UserSelect, +) { + return tx.user.upsert({ + where: { id: payload.sub }, + create: { + id: payload.sub, + email: payload.email, + firstName: payload.given_name, + lastName: payload.family_name, + adminRoleIds: [], + type: 'human', + lastLogin: new Date().toISOString(), + }, + update: { + email: payload.email, + firstName: payload.given_name, + lastName: payload.family_name, + lastLogin: new Date().toISOString(), + }, + select, + }) +} + +// Roles match the token groups (legacy parity), the persisted ids, or are global. +function adminRoleWhere(groups: string[], adminRoleIds: string[]): Prisma.AdminRoleWhereInput { + return { + OR: [ + { oidcGroup: { in: groups } }, + { id: { in: adminRoleIds } }, + { type: 'global' }, + ], + } +} + +export function listMatchingAdminRoles(tx: Prisma.TransactionClient, groups: string[], adminRoleIds: string[]) { + return tx.adminRole.findMany({ + where: adminRoleWhere(groups, adminRoleIds), + orderBy: { position: 'asc' }, + }) +} 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 ed2c588caf..a1692faaa3 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 @@ -1,7 +1,6 @@ import type { TestingModule } from '@nestjs/testing' import type { DeepMockProxy } from 'vitest-mock-extended' import { faker } from '@faker-js/faker' -import { UnauthorizedException } from '@nestjs/common' import { JwtService } from '@nestjs/jwt' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it } from 'vitest' @@ -19,6 +18,7 @@ describe('keycloakJwtService', () => { beforeEach(async () => { prisma = mockDeep() + prisma.$transaction.mockImplementation(async fn => fn(prisma)) jwtService = mockDeep() module = await Test.createTestingModule({ @@ -46,7 +46,7 @@ describe('keycloakJwtService', () => { groups: ['/current-group'], } - prisma.user.findUnique.mockResolvedValue( + prisma.user.upsert.mockResolvedValue( makeMockUser({ id: payload.sub, adminRoleIds: ['stale-oidc-role', 'manual-role'], @@ -81,21 +81,20 @@ describe('keycloakJwtService', () => { const result = await service.validatePayload(payload) - expect(prisma.user.update).toHaveBeenCalledWith({ - where: { id: payload.sub }, - data: { - adminRoleIds: ['manual-role', 'current-oidc-role', 'global-role'], - lastLogin: expect.any(String), - }, - }) + expect(prisma.user.update).not.toHaveBeenCalled() + expect(prisma.user.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ lastLogin: expect.any(String) }), + }), + ) expect(result).toEqual({ userId: payload.sub, - adminPermissions: 52n, + adminPermissions: 60n, userType: 'human', }) }) - it('should reject when the local user is missing', async () => { + it('should create the local user when missing (first login)', async () => { const payload = { sub: faker.string.uuid(), email: faker.internet.email().toLowerCase(), @@ -104,11 +103,36 @@ describe('keycloakJwtService', () => { groups: ['/current-group'], } - prisma.user.findUnique.mockResolvedValue(null) + prisma.user.upsert.mockResolvedValue(makeMockUser({ id: payload.sub, email: payload.email })) + prisma.adminRole.findMany.mockResolvedValue([]) - await expect(service.validatePayload(payload)).rejects.toBeInstanceOf(UnauthorizedException) - expect(prisma.adminRole.findMany).not.toHaveBeenCalled() + const result = await service.validatePayload(payload) + + expect(prisma.user.upsert).toHaveBeenCalledWith({ + where: { id: payload.sub }, + create: { + id: payload.sub, + email: payload.email, + firstName: payload.given_name, + lastName: payload.family_name, + adminRoleIds: [], + type: 'human', + lastLogin: expect.any(String), + }, + update: { + email: payload.email, + firstName: payload.given_name, + lastName: payload.family_name, + lastLogin: expect.any(String), + }, + select: expect.objectContaining({ id: true, type: true, adminRoleIds: true }), + }) expect(prisma.user.update).not.toHaveBeenCalled() + expect(result).toEqual({ + userId: payload.sub, + adminPermissions: 0n, + userType: 'human', + }) }) it('should skip admin role resolution when permissions are not required', async () => { @@ -120,7 +144,7 @@ describe('keycloakJwtService', () => { groups: ['/current-group'], } - prisma.user.findUnique.mockResolvedValue( + prisma.user.upsert.mockResolvedValue( makeMockUser({ id: payload.sub, type: 'human', @@ -133,12 +157,11 @@ describe('keycloakJwtService', () => { ) expect(prisma.adminRole.findMany).not.toHaveBeenCalled() - expect(prisma.user.update).toHaveBeenCalledWith({ - where: { id: payload.sub }, - data: { - lastLogin: expect.any(String), - }, - }) + expect(prisma.user.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ lastLogin: expect.any(String) }), + }), + ) expect(result).toEqual({ userId: payload.sub, adminPermissions: undefined, @@ -147,6 +170,32 @@ describe('keycloakJwtService', () => { }) }) + describe('resolveAdminPermissions', () => { + it('should match external roles by group, managed roles by persisted ids and globals always (parity with logViaSession)', async () => { + prisma.user.upsert.mockResolvedValue(makeMockUser({ adminRoleIds: ['managed-role'] })) + prisma.adminRole.findMany.mockResolvedValue([]) + + await service.validatePayload({ + sub: faker.string.uuid(), + email: '', + given_name: '', + family_name: '', + groups: ['/admin'], + }) + + expect(prisma.adminRole.findMany).toHaveBeenCalledWith({ + where: { + OR: [ + { oidcGroup: { in: ['/admin'] } }, + { id: { in: ['managed-role'] } }, + { type: 'global' }, + ], + }, + orderBy: { position: 'asc' }, + }) + }) + }) + describe('authenticate', () => { it('should authenticate a bearer token from the request', async () => { jwtService.verifyAsync.mockResolvedValue({ @@ -156,7 +205,7 @@ describe('keycloakJwtService', () => { family_name: faker.person.lastName(), groups: [], }) - prisma.user.findUnique.mockResolvedValue(makeMockUser({})) + prisma.user.upsert.mockResolvedValue(makeMockUser({})) prisma.adminRole.findMany.mockResolvedValue([]) const result = await service.authenticate( 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 9e1eb00db7..78a3ddfe99 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 @@ -3,10 +3,12 @@ import type { FastifyRequest } from 'fastify' import type { IncomingHttpHeaders } from 'node:http' import type { UserContext } from '../auth-user.decorator' import type { AuthProvider, AuthRequirements } from '../auth.utils' +import type { UserRecord } from './keycloak-jwt-queries.utils' import { Inject, Injectable, UnauthorizedException } from '@nestjs/common' import { JwtService } from '@nestjs/jwt' import { z } from 'zod' import { PrismaService } from '../../database/prisma.service' +import { listMatchingAdminRoles, makeUserSelect, upsertUser } from './keycloak-jwt-queries.utils' const KeycloakPayloadSchema = z.object({ sub: z.string(), @@ -57,67 +59,26 @@ export class KeycloakJwtService implements AuthProvider { payload: KeycloakPayload, requirements?: AuthRequirements, ): Promise { - const authRequirements = normalizeRequirements(requirements) - const user = await this.prisma.user.findUnique({ - where: { id: payload.sub }, - select: makeUserSelect(authRequirements), - }) - if (!user) { - throw new UnauthorizedException('Not authenticated') - } - - let adminPermissions: bigint | undefined - let mergedRoleIds: string[] | undefined - - if (authRequirements.includeAdminRoleIds) { - const adminRoleIds = 'adminRoleIds' in user ? user.adminRoleIds : [] - const groups = payload.groups - - const matchingAdminRoles = await this.prisma.adminRole.findMany({ - where: { - OR: [ - { oidcGroup: { in: groups } }, - { id: { in: adminRoleIds } }, - { type: 'global' }, - ], - }, - }) - - const activeAdminRoles = matchingAdminRoles.filter(({ oidcGroup, type }) => - type === 'global' || !oidcGroup || groups.includes(oidcGroup), - ) - - mergedRoleIds = [...new Set(activeAdminRoles.map(({ id }) => id))] - adminPermissions = activeAdminRoles.reduce((acc, curr) => acc | curr.permissions, 0n) - } - - await this.prisma.user.update({ - where: { id: payload.sub }, - data: { - ...(mergedRoleIds ? { adminRoleIds: mergedRoleIds } : {}), - lastLogin: new Date().toISOString(), - }, + return this.prisma.$transaction(async (tx) => { + const user = await upsertUser(tx, payload, makeUserSelect(requirements)) + + return { + userId: payload.sub, + adminPermissions: await this.maybeAdminPermissions(tx, payload, user.adminRoleIds ?? [], requirements), + userType: this.maybeUserType(user, requirements), + } }) + } - return { - userId: payload.sub, - adminPermissions, - userType: 'type' in user ? user.type : undefined, + private async maybeAdminPermissions(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) } -} - -function makeUserSelect(requirements: Required) { - return { - id: true, - ...(requirements.includeAdminRoleIds ? { adminRoleIds: true } : {}), - ...(requirements.includeUserType ? { type: true } : {}), - } satisfies Prisma.UserSelect -} -function normalizeRequirements(requirements: AuthRequirements = {}): Required { - return { - includeAdminRoleIds: requirements.includeAdminRoleIds ?? true, - includeUserType: requirements.includeUserType ?? true, + private maybeUserType(user: UserRecord, requirements?: AuthRequirements) { + return requirements?.includeUserType ?? true ? user.type : undefined } }