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
4 changes: 3 additions & 1 deletion apps/server-nestjs/src/main.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand All @@ -38,6 +39,7 @@ import { getDotenvPaths } from './utils/dotenv.utils'
TerminusModule.forRoot(),
AdminRoleModule,
AdminTokenModule,
AuthModule,
Comment thread
shikanime marked this conversation as resolved.
DeploymentModule,
EnvironmentModule,
HealthzModule,
Expand Down
41 changes: 41 additions & 0 deletions apps/server-nestjs/src/modules/auth/auth.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -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<AuthUserService>

beforeEach(async () => {
service = mock<AuthUserService>()

module = await Test.createTestingModule({
controllers: [AuthController],
providers: [
{ provide: AuthUserService, useValue: service },
],
})
.overrideGuard(UserGuard)
.useValue({ canActivate: () => true })
.compile()

controller = module.get<AuthController>(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)
})
})
18 changes: 18 additions & 0 deletions apps/server-nestjs/src/modules/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -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<User> {
return toContractUser(await this.authUserService.getUser(user.userId))
Comment thread
StephaneTrebel marked this conversation as resolved.
}
}
13 changes: 13 additions & 0 deletions apps/server-nestjs/src/modules/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
16 changes: 16 additions & 0 deletions apps/server-nestjs/src/modules/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -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<User> {
const user = await this.prisma.user.findUnique({ where: { id } })
if (!user) {
throw new NotFoundException(`Unable to find user ${id}`)
}
return user
}
}
Original file line number Diff line number Diff line change
@@ -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<typeof makeUserSelect> }>

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' },
})
}
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -19,6 +18,7 @@ describe('keycloakJwtService', () => {

beforeEach(async () => {
prisma = mockDeep<PrismaService>()
prisma.$transaction.mockImplementation(async fn => fn(prisma))
jwtService = mockDeep<JwtService>()

module = await Test.createTestingModule({
Expand Down Expand Up @@ -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'],
Expand Down Expand Up @@ -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(),
Expand All @@ -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 () => {
Expand All @@ -120,7 +144,7 @@ describe('keycloakJwtService', () => {
groups: ['/current-group'],
}

prisma.user.findUnique.mockResolvedValue(
prisma.user.upsert.mockResolvedValue(
makeMockUser({
id: payload.sub,
type: 'human',
Expand All @@ -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,
Expand All @@ -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({
Expand All @@ -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(
Expand Down
Loading
Loading