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 remontée 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 @@ -13,10 +14,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 @@ -31,6 +34,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,80 @@
import type { Prisma } from '@prisma/client'

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

export const adminTokenSelect = {
id: true,
name: true,
permissions: true,
lastUse: true,
expirationDate: true,
status: true,
createdAt: true,
userId: true,
owner: {
select: adminTokenOwnerSelect,
},
} satisfies Prisma.AdminTokenSelect

export type AdminTokenRecord = Prisma.AdminTokenGetPayload<{
select: typeof adminTokenSelect
}>

export function listAdminTokens(tx: Prisma.TransactionClient, withRevoked: boolean) {
const where: Prisma.AdminTokenWhereInput = withRevoked
? { status: { in: ['active', 'revoked'] } }
: { status: 'active' }

return tx.adminToken.findMany({
where,
select: adminTokenSelect,
orderBy: [{ status: 'asc' }, { createdAt: 'asc' }],
})
}

export function createBotUser(tx: Prisma.TransactionClient, data: { botUserId: string, name: string }) {
return tx.user.create({
data: {
firstName: 'Bot Admin',
lastName: data.name,
type: 'bot',
id: data.botUserId,
email: `${data.botUserId}@bot.io`,
},
})
}

export function createAdminToken(tx: Prisma.TransactionClient, data: {
name: string
permissions: bigint
expirationDate: Date | null
hash: string
userId: string
}) {
return tx.adminToken.create({
data: {
name: data.name,
permissions: data.permissions,
expirationDate: data.expirationDate,
hash: data.hash,
userId: data.userId,
},
select: adminTokenSelect,
})
}

export function revokeAdminToken(tx: Prisma.TransactionClient, id: string) {
return tx.adminToken.updateMany({
where: { id },
data: {
status: 'revoked',
expirationDate: new Date(),
},
})
}
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, HttpStatus, 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 === 'true')
}

@Post()
@HttpCode(HttpStatus.CREATED)
@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(HttpStatus.NO_CONTENT)
@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,
},
})
})
})
})
92 changes: 92 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,92 @@
import { randomUUID } from 'node:crypto'
import { isAtLeastTomorrow } from '@cpn-console/shared'
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common'
import { trace } from '@opentelemetry/api'
import z from 'zod'
import { generateTokenPair } from '../../utils/token'
import { PrismaService } from '../infrastructure/database/prisma.service'
import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator'
import {
createAdminToken,
createBotUser,
listAdminTokens,
revokeAdminToken,
} from './admin-token-queries.utils'

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

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

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

const tokens = await listAdminTokens(this.prisma, withRevoked)

span?.setAttribute('adminToken.list.count', tokens.length)
this.logger.debug(`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.debug(`adminToken.create started (tokenName=${data.name})`)

const expirationDate = data.expirationDate ? z.coerce.date().parse(data.expirationDate) : null

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

try {
const { password, hash } = await generateTokenPair()
const botUserId = randomUUID()

await createBotUser(this.prisma, { botUserId, name: data.name })

const token = await createAdminToken(this.prisma, {
name: data.name,
permissions: BigInt(data.permissions),
expirationDate,
hash,
userId: botUserId,
})

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.debug(`adminToken.revoke started (adminTokenId=${id})`)

await revokeAdminToken(this.prisma, id)

this.logger.log(`adminToken.revoke completed (adminTokenId=${id})`)
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Inject, Injectable } from '@nestjs/common'
import { HttpStatus, Inject, Injectable } from '@nestjs/common'
import { HealthIndicatorService } from '@nestjs/terminus'
import { ConfigurationService } from '../infrastructure/configuration/configuration.service'

Expand All @@ -16,7 +16,7 @@ export class ArgoCDHealthService {
const url = new URL('/api/version', this.config.argocdUrl).toString()
try {
const response = await fetch(url)
if (response.status < 500) return indicator.up({ httpStatus: response.status })
if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status })
Comment thread
shikanime marked this conversation as resolved.
return indicator.down({ httpStatus: response.status })
} catch (error) {
return indicator.down(error instanceof Error ? error.message : String(error))
Expand Down
Loading