Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Cartographier l'ensemble des modules de l'application backend actuelle pour :

| Decision | Choix |
|----------|-------|
| Approche migration | Bottom-up (feuilles d'abord, puis remontee vers les modules couples) |
| Approche migration | Bottom-up (feuilles d'abord, puis remonttee vers les modules couples) |
| Contrats API | Decorateurs NestJS natifs + class-validator (abandon de ts-rest) |
| queries-index.ts | Supprime des le depart : chaque module NestJS possede ses propres queries Prisma |
| Systeme d'evenements | `@nestjs/event-emitter` (remplacement progressif de `@cpn-console/hooks`) |
Expand Down
4 changes: 4 additions & 0 deletions apps/server-nestjs/src/main.module.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common'
import { ScheduleModule } from '@nestjs/schedule'
import { AdminTokenModule } from './modules/admin-token/admin-token.module'
import { DeploymentModule } from './modules/deployment/deployment.module'
import { HealthzModule } from './modules/healthz/healthz.module'
import { KeycloakModule } from './modules/keycloak/keycloak.module'
Expand All @@ -12,10 +13,12 @@ import { ProjectServicesModule } from './modules/project-services/project-servic
import { ProjectModule } from './modules/project/project.module'
import { ServiceChainModule } from './modules/service-chain/service-chain.module'
import { SystemSettingsModule } from './modules/system-settings/system-settings.module'
import { UserTokensModule } from './modules/user-tokens/user-tokens.module'
import { VersionModule } from './modules/version/version.module'

@Module({
imports: [
AdminTokenModule,
HealthzModule,
KeycloakModule,
ScheduleModule.forRoot(),
Expand All @@ -29,6 +32,7 @@ import { VersionModule } from './modules/version/version.module'
ProjectRolesModule,
LogModule,
DeploymentModule,
UserTokensModule,
VersionModule,
],
controllers: [],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { AdminTokenSchema } from '@cpn-console/shared'
import { Body, Controller, Delete, Get, HttpCode, Inject, Param, Post, Query, UseGuards } from '@nestjs/common'
import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator'
import { UserGuard } from '../infrastructure/permission/user/user.guard'
import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe'
import { AdminTokenService } from './admin-token.service'

@Controller('api/v1/admin/tokens')
@UseGuards(UserGuard)
export class AdminTokenController {
constructor(@Inject(AdminTokenService) private readonly service: AdminTokenService) {}

@Get()
@RequireAdminPermission('ListAdminToken')
async list(@Query('withRevoked') withRevoked?: string) {
return this.service.list(withRevoked !== undefined && withRevoked !== 'false')
}

@Post()
@HttpCode(201)
@RequireAdminPermission('ManageAdminToken')
async create(
@Body(new ZodValidationPipe(AdminTokenSchema.pick({ name: true, permissions: true, expirationDate: true }).required())) data: { name: string, permissions: string, expirationDate: string | null },
) {
return this.service.create(data)
}

@Delete(':tokenId')
@HttpCode(204)
@RequireAdminPermission('ManageAdminToken')
async revoke(@Param('tokenId') tokenId: string): Promise<void> {
return this.service.revoke(tokenId)
}
}
12 changes: 12 additions & 0 deletions apps/server-nestjs/src/modules/admin-token/admin-token.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common'
import { InfrastructureModule } from '../infrastructure/infrastructure.module'
import { AdminTokenController } from './admin-token.controller'
import { AdminTokenService } from './admin-token.service'

@Module({
imports: [InfrastructureModule],
controllers: [AdminTokenController],
providers: [AdminTokenService],
exports: [AdminTokenService],
})
export class AdminTokenModule {}
121 changes: 121 additions & 0 deletions apps/server-nestjs/src/modules/admin-token/admin-token.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import type { TestingModule } from '@nestjs/testing'
import type { DeepMockProxy } from 'vitest-mock-extended'
import { faker } from '@faker-js/faker'
import { Test } from '@nestjs/testing'
import { beforeEach, describe, expect, it } from 'vitest'
import { mockDeep } from 'vitest-mock-extended'
import { PrismaService } from '../infrastructure/database/prisma.service'
import { AdminTokenService } from './admin-token.service'

describe('adminTokenService', () => {
let module: TestingModule
let service: AdminTokenService
let prisma: DeepMockProxy<PrismaService>

beforeEach(async () => {
prisma = mockDeep<PrismaService>()

module = await Test.createTestingModule({
providers: [
AdminTokenService,
{ provide: PrismaService, useValue: prisma },
],
}).compile()

service = module.get(AdminTokenService)
})

describe('list', () => {
it('returns active tokens with permissions serialized as string', async () => {
const tokenId = faker.string.uuid()
const userId = faker.string.uuid()
prisma.adminToken.findMany.mockResolvedValue([{
id: tokenId,
name: 'my-token',
permissions: 4n,
lastUse: null,
expirationDate: null,
status: 'active' as const,
createdAt: new Date(),
userId,
hash: 'hash-1',
}])

const result = await service.list()

expect(prisma.adminToken.findMany).toHaveBeenCalled()
expect(result).toHaveLength(1)
expect(result[0].id).toBe(tokenId)
expect(result[0].permissions).toBe('4')
})

it('includes revoked tokens when withRevoked is true', async () => {
prisma.adminToken.findMany.mockResolvedValue([])

await service.list(true)

const callArgs = prisma.adminToken.findMany.mock.calls[0]?.[0]
expect(callArgs?.where).toEqual({ status: { in: ['active', 'revoked'] } })
})

it('filters to active only by default', async () => {
prisma.adminToken.findMany.mockResolvedValue([])

await service.list()

const callArgs = prisma.adminToken.findMany.mock.calls[0]?.[0]
expect(callArgs?.where).toEqual({ status: 'active' })
})
})

describe('create', () => {
it('throws BadRequestException if expirationDate is too soon', async () => {
const today = new Date()
await expect(service.create({ name: 'x', permissions: '4', expirationDate: today.toISOString() }))
.rejects.toThrow('Date d\'expiration trop courte')
expect(prisma.adminToken.create).not.toHaveBeenCalled()
})

it('returns created token with plaintext password and serialized permissions', async () => {
const tokenId = faker.string.uuid()
const botUserId = faker.string.uuid()
prisma.user.create.mockResolvedValue({ id: botUserId, firstName: 'Bot Admin', lastName: 'my-token', type: 'bot', email: 'x@bot.io' } as never)
prisma.adminToken.create.mockResolvedValue({
id: tokenId,
name: 'my-token',
permissions: 2n,
lastUse: null,
expirationDate: null,
status: 'active' as const,
createdAt: new Date(),
userId: botUserId,
hash: 'hash-2',
})

const result = await service.create({ name: 'my-token', permissions: '2', expirationDate: null })

expect(prisma.user.create).toHaveBeenCalled()
expect(prisma.adminToken.create).toHaveBeenCalled()
expect(result.id).toBe(tokenId)
expect(result.password).toBeTruthy()
expect(result.permissions).toBe('2')
})
})

describe('revoke', () => {
it('sets status to revoked and expiration date to now', async () => {
const tokenId = faker.string.uuid()
prisma.adminToken.updateMany.mockResolvedValue({ count: 1 } as never)

await service.revoke(tokenId)

expect(prisma.adminToken.updateMany).toHaveBeenCalledWith({
where: { id: tokenId },
data: {
status: 'revoked',
expirationDate: expect.any(Date) as Date,
},
})
})
})
})
117 changes: 117 additions & 0 deletions apps/server-nestjs/src/modules/admin-token/admin-token.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import type { Prisma } from '@prisma/client'
import { randomBytes, randomUUID, scrypt } from 'node:crypto'
import { promisify } from 'node:util'
import { generateRandomPassword, isAtLeastTomorrow } from '@cpn-console/shared'
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common'
import { trace } from '@opentelemetry/api'
import { PrismaService } from '../infrastructure/database/prisma.service'
import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator'

const scryptAsync = promisify(scrypt)

@Injectable()
export class AdminTokenService {
private readonly logger = new Logger(AdminTokenService.name)

constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}

@StartActiveSpan()
async list(withRevoked?: boolean) {
const span = trace.getActiveSpan()
this.logger.log(`adminToken.list requested (withRevoked=${withRevoked ?? false})`)
span?.setAttribute('adminToken.list.withRevoked', withRevoked ?? false)

const where: Prisma.AdminTokenWhereInput = withRevoked
? { status: { in: ['active', 'revoked'] } }
: { status: 'active' }

const tokens = await this.prisma.adminToken.findMany({
omit: { hash: true },
include: { owner: true },
orderBy: [{ status: 'asc' }, { createdAt: 'asc' }],
where,
})

span?.setAttribute('adminToken.list.count', tokens.length)
this.logger.log(`adminToken.list completed (count=${tokens.length})`)

return tokens.map(({ permissions, ...token }) => ({
...token,
permissions: permissions.toString(),
}))
}

@StartActiveSpan()
async create(data: { name: string, permissions: string, expirationDate?: string | null }) {
const span = trace.getActiveSpan()
span?.setAttribute('adminToken.create.name', data.name)
this.logger.log(`adminToken.create started (tokenName=${data.name})`)

if (data.expirationDate && !isAtLeastTomorrow(new Date(data.expirationDate))) {
this.logger.warn(`adminToken.create rejected (tokenName=${data.name}, reason=expirationTooSoon)`)
throw new BadRequestException('Date d\'expiration trop courte')
}

try {
const password = generateRandomPassword(48, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-')
const salt = randomBytes(16).toString('hex')
const derivedKey = await scryptAsync(password, salt, 64) as Buffer
const hash = `scrypt$${salt}$${derivedKey.toString('hex')}`
const botUserId = randomUUID()

await this.prisma.user.create({
data: {
firstName: 'Bot Admin',
lastName: data.name,
type: 'bot',
id: botUserId,
email: `${botUserId}@bot.io`,
},
})

const token = await this.prisma.adminToken.create({
data: {
...data,
hash,
permissions: BigInt(data.permissions),
expirationDate: data.expirationDate ? new Date(data.expirationDate) : undefined,
userId: botUserId,
},
omit: { hash: true },
include: { owner: true },
})

span?.setAttribute('adminToken.create.tokenId', token.id)
this.logger.log(`adminToken.create completed (adminTokenId=${token.id}, botUserId=${botUserId}, status=${token.status})`)

return {
...token,
password,
permissions: token.permissions.toString(),
}
} catch (error) {
this.logger.error(
`adminToken.create failed (tokenName=${data.name}): ${error instanceof Error ? error.message : String(error)}`,
error instanceof Error ? error.stack : undefined,
)
throw error
}
}

@StartActiveSpan()
async revoke(id: string) {
const span = trace.getActiveSpan()
span?.setAttribute('adminToken.revoke.tokenId', id)
this.logger.log(`adminToken.revoke started (adminTokenId=${id})`)

await this.prisma.adminToken.updateMany({
where: { id },
data: {
status: 'revoked',
expirationDate: new Date(),
},
})

this.logger.log(`adminToken.revoke completed (adminTokenId=${id})`)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type { Prisma } from '@prisma/client'

export const userTokenOwnerSelect = {
id: true,
email: true,
firstName: true,
lastName: true,
type: true,
} satisfies Prisma.UserSelect

export const userTokenSelect = {
id: true,
name: true,
lastUse: true,
expirationDate: true,
status: true,
createdAt: true,
userId: true,
owner: {
select: userTokenOwnerSelect,
},
} satisfies Prisma.PersonalAccessTokenSelect

export type UserTokenRecord = Prisma.PersonalAccessTokenGetPayload<{
select: typeof userTokenSelect
}>

export function listUserTokens(db: Prisma.TransactionClient, userId: string) {
return db.personalAccessToken.findMany({
where: { userId },
orderBy: [{ status: 'asc' }, { createdAt: 'asc' }],
select: userTokenSelect,
})
}

export function createUserToken(db: Prisma.TransactionClient, data: {
name: string
expirationDate: string
hash: string
userId: string
}) {
return db.personalAccessToken.create({
data: {
name: data.name,
hash: data.hash,
expirationDate: new Date(data.expirationDate),
userId: data.userId,
},
select: userTokenSelect,
})
}

export function getOwnedUserToken(db: Prisma.TransactionClient, tokenId: string, userId: string) {
return db.personalAccessToken.findUnique({
where: {
id: tokenId,
userId,
},
})
}
Loading