Skip to content
Closed
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
8 changes: 8 additions & 0 deletions apps/server-nestjs/.env-example
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ KEYCLOAK_PROTOCOL=http
KEYCLOAK_CLIENT_ID=dso-console-backend
# Secret du client Keycloak backend (confidentiel)
KEYCLOAK_CLIENT_SECRET=client-secret-backend
# Identifiant de l'administrateur Keycloak
KEYCLOAK_ADMIN=admin
# Mot de passe de l'administrateur Keycloak
KEYCLOAK_ADMIN_PASSWORD=admin
# Identifiant administrateur Keycloak (utilisé pour l'API admin)
KEYCLOAK_ADMIN=admin
# Mot de passe administrateur Keycloak (confidentiel)
KEYCLOAK_ADMIN_PASSWORD=admin
# URL de redirection après authentification Keycloak
KEYCLOAK_REDIRECT_URI=http://localhost:8080
# Port d'écoute du serveur backend
Expand Down
4 changes: 4 additions & 0 deletions apps/server-nestjs/.env.docker-example
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ KEYCLOAK_PROTOCOL=http
KEYCLOAK_CLIENT_ID=dso-console-backend
# Secret du client Keycloak backend (confidentiel)
KEYCLOAK_CLIENT_SECRET=client-secret-backend
# Identifiant de l'administrateur Keycloak
KEYCLOAK_ADMIN=admin
# Mot de passe de l'administrateur Keycloak
KEYCLOAK_ADMIN_PASSWORD=admin
# URL de redirection après authentification Keycloak
KEYCLOAK_REDIRECT_URI=http://localhost:8080
# Port d'écoute du serveur dans le réseau Docker
Expand Down
49 changes: 29 additions & 20 deletions apps/server-nestjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,22 @@
"db:migrate": "prisma migrate dev --name dso",
"db:reset": "prisma migrate reset",
"format": "eslint ./ --fix",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"prestart": "prisma generate",
"start": "nest start",
"start:debug": "nest start --debug --watch",
"start:dev": "nest start --watch",
"start:prod": "node dist/main"
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"pretest": "prisma generate",
"test": "vitest run",
"test:watch": "vitest",
"pretest:cov": "prisma generate",
"test:cov": "vitest run --coverage",
"test:debug": "vitest --inspect"
},
"dependencies": {
"@casl/ability": "^6.7.1",
"@casl/prisma": "^1.5.0",
"@cpn-console/argocd-plugin": "workspace:^",
"@cpn-console/gitlab-plugin": "workspace:^",
"@cpn-console/harbor-plugin": "workspace:^",
Expand All @@ -38,11 +47,23 @@
"@fastify/swagger-ui": "^4.2.0",
"@gitbeaker/core": "^40.6.0",
"@gitbeaker/rest": "^40.6.0",
"@keycloak/keycloak-admin-client": "^24.0.0",
"@kubernetes-models/argo-cd": "^2.7.2",
"@nestjs/common": "^11.1.16",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.1.16",
"@nestjs/event-emitter": "^3.0.1",
"@nestjs/platform-express": "^11.1.16",
"@nestjs/schedule": "^5.0.1",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/auto-instrumentations-node": "^0.70.1",
"@opentelemetry/exporter-metrics-otlp-proto": "^0.213.0",
"@opentelemetry/exporter-trace-otlp-proto": "^0.213.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.58.0",
"@opentelemetry/instrumentation-pino": "^0.59.0",
"@opentelemetry/sdk-metrics": "^2.5.1",
"@opentelemetry/sdk-node": "^0.212.0",
"@opentelemetry/sdk-trace-node": "^2.5.1",
"@prisma/client": "^6.19.2",
"@ts-rest/core": "^3.52.1",
"@ts-rest/fastify": "^3.52.1",
Expand All @@ -53,14 +74,17 @@
"fastify": "^4.29.1",
"fastify-keycloak-adapter": "2.3.2",
"json-2-csv": "^5.5.10",
"keycloak-connect": "^25.0.0",
"mustache": "^4.2.0",
"nest-keycloak-connect": "^1.10.1",
"nestjs-pino": "^4.6.0",
"pino-http": "^11.0.0",
"prisma": "^6.19.2",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"undici": "^7.22.0",
"vitest-mock-extended": "^2.0.2"
"vitest-mock-extended": "^2.0.2",
"zod": "^3.25.76"
},
"devDependencies": {
"@cpn-console/eslint-config": "workspace:^",
Expand All @@ -79,6 +103,8 @@
"eslint": "^9.39.4",
"fastify-plugin": "^5.1.0",
"globals": "^16.5.0",
"jest": "^30.3.0",
"msw": "^2.12.10",
"nodemon": "^3.1.14",
"pino-pretty": "^13.1.3",
"rimraf": "^6.1.3",
Expand All @@ -95,23 +121,6 @@
"vite-node": "^2.1.9",
"vitest": "^2.1.9"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
},
"publishConfig": {
"tag": "latest"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ export class ConfigurationService {
keycloakRealm = process.env.KEYCLOAK_REALM
keycloakClientId = process.env.KEYCLOAK_CLIENT_ID
keycloakClientSecret = process.env.KEYCLOAK_CLIENT_SECRET
keycloakAdmin = process.env.KEYCLOAK_ADMIN
keycloakAdminPassword = process.env.KEYCLOAK_ADMIN_PASSWORD
keycloakRedirectUri = process.env.KEYCLOAK_REDIRECT_URI

adminsUserId = process.env.ADMIN_KC_USER_ID
? process.env.ADMIN_KC_USER_ID.split(',')
: []
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { OnModuleInit, OnModuleDestroy } from '@nestjs/common'
import { Injectable } from '@nestjs/common'
import { PrismaClient } from '@prisma/client'

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit() {
await this.$connect()
}

async onModuleDestroy() {
await this.$disconnect()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@ import { Module } from '@nestjs/common'

import { ConfigurationModule } from './configuration/configuration.module'
import { DatabaseService } from './database/database.service'
import { PrismaService } from './database/prisma.service'
import { HttpClientService } from './http-client/http-client.service'
import { LoggerModule } from './logger/logger.module'
import { ServerService } from './server/server.service'

@Module({
providers: [DatabaseService, HttpClientService, ServerService],
providers: [DatabaseService, PrismaService, HttpClientService, ServerService],
imports: [LoggerModule, ConfigurationModule],
exports: [DatabaseService, HttpClientService, ServerService],
exports: [DatabaseService, PrismaService, HttpClientService, ServerService],
})
export class InfrastructureModule {}
30 changes: 30 additions & 0 deletions apps/server-nestjs/src/instrumentation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { NodeSDK } from '@opentelemetry/sdk-node'
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'
import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics'
import { NestInstrumentation } from '@opentelemetry/instrumentation-nestjs-core'
import { PinoInstrumentation } from '@opentelemetry/instrumentation-pino'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto'
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-proto'

function createSdk() {
return new NodeSDK({
traceExporter: new OTLPTraceExporter({}),
metricReader: new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter(),
}),
instrumentations: [
getNodeAutoInstrumentations(),
new NestInstrumentation(),
new PinoInstrumentation(),
],
serviceName: 'cloud-pi-native-console',
})
}

export function instrument() {
const sdk = createSdk()
sdk.start()
process.on('SIGTERM', () => {
sdk.shutdown()
})
}
10 changes: 9 additions & 1 deletion apps/server-nestjs/src/main.module.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import { Module } from '@nestjs/common'
import { EventEmitterModule } from '@nestjs/event-emitter'
import { ScheduleModule } from '@nestjs/schedule'

import { CpinModule } from './cpin-module/cpin.module'
import { KeycloakModule } from './modules/keycloak/keycloak.module'

// This module only exists to import other module.
// « One module to rule them all, and in NestJs bind them »
@Module({
imports: [CpinModule],
imports: [
CpinModule,
KeycloakModule,
EventEmitterModule.forRoot(),
ScheduleModule.forRoot(),
],
controllers: [],
providers: [],
})
Expand Down
4 changes: 3 additions & 1 deletion apps/server-nestjs/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { NestFactory } from '@nestjs/core'
import { Logger } from 'nestjs-pino'

import { instrument } from './instrumentation'
import { ConfigurationService } from './cpin-module/infrastructure/configuration/configuration.service'
import { MainModule } from './main.module'

Expand All @@ -11,4 +11,6 @@ async function bootstrap() {
const config = app.get(ConfigurationService)
await app.listen(config.port ?? 0)
}

instrument()
bootstrap()
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { SetMetadata } from '@nestjs/common'
import type { AppAbility } from '../factories/casl-ability.factory'

export interface IPolicyHandler {
handle: (ability: AppAbility) => boolean
}

type PolicyHandlerCallback = (ability: AppAbility) => boolean

export type PolicyHandler = IPolicyHandler | PolicyHandlerCallback

export const CHECK_POLICIES_KEY = 'check_policy'
export function CheckPolicies(...handlers: PolicyHandler[]) {
return SetMetadata(CHECK_POLICIES_KEY, handlers)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { PureAbility } from '@casl/ability'
import { AbilityBuilder } from '@casl/ability'
import type { PrismaQuery, Subjects } from '@casl/prisma'
import { createPrismaAbility } from '@casl/prisma'
import { Injectable } from '@nestjs/common'
import type { Project, Environment, User, ProjectMembers } from '@prisma/client'

export type AppAbility = PureAbility<
[string, Subjects<{ Project: Project, Environment: Environment, User: User, ProjectMembers: ProjectMembers }>],
PrismaQuery
>

@Injectable()
export class CaslAbilityFactory {
createForUser(user: any) {
const { can, build } = new AbilityBuilder<AppAbility>(
createPrismaAbility,
)

// If user is not authenticated or doesn't have an ID
if (!user?.sub) {
return build()
}

const userId = user.sub

// A user can read projects they are a member of (via ProjectMembers)
can('read', 'Project', {
members: {
some: {
userId,
},
},
})

// A project owner can manage everything
can('manage', 'Project', {
ownerId: userId,
})

// A user can update an environment if the project is not locked
// and they are a member of the project
can('update', 'Environment', {
project: {
is: {
locked: false,
members: {
some: {
userId,
},
},
},
},
})

return build()
}
}
37 changes: 37 additions & 0 deletions apps/server-nestjs/src/modules/iam/guards/policies.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { CanActivate, ExecutionContext } from '@nestjs/common'
import { Inject, Injectable } from '@nestjs/common'
import { Reflector } from '@nestjs/core'
import { CaslAbilityFactory } from '../factories/casl-ability.factory'
import type { AppAbility } from '../factories/casl-ability.factory'
import type { PolicyHandler } from '../decorators/check-policies.decorator'
import { CHECK_POLICIES_KEY } from '../decorators/check-policies.decorator'

@Injectable()
export class PoliciesGuard implements CanActivate {
constructor(
@Inject(Reflector) private readonly reflector: Reflector,
@Inject(CaslAbilityFactory) private readonly caslAbilityFactory: CaslAbilityFactory,
) {}

async canActivate(context: ExecutionContext): Promise<boolean> {
const policyHandlers
= this.reflector.get<PolicyHandler[]>(
CHECK_POLICIES_KEY,
context.getHandler(),
) || []

const { user } = context.switchToHttp().getRequest()
const ability = this.caslAbilityFactory.createForUser(user)

return policyHandlers.every(handler =>
this.execPolicyHandler(handler, ability),
)
}

private execPolicyHandler(handler: PolicyHandler, ability: AppAbility) {
if (typeof handler === 'function') {
return handler(ability)
}
return handler.handle(ability)
}
}
48 changes: 48 additions & 0 deletions apps/server-nestjs/src/modules/iam/iam.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Module } from '@nestjs/common'
import { APP_GUARD } from '@nestjs/core'
import {
AuthGuard,
ResourceGuard,
KeycloakConnectModule,
PolicyEnforcementMode,
TokenValidation,
} from 'nest-keycloak-connect'
import { ConfigurationModule } from '../../cpin-module/infrastructure/configuration/configuration.module'
import { ConfigurationService } from '../../cpin-module/infrastructure/configuration/configuration.service'
import { PoliciesGuard } from './guards/policies.guard'
import { CaslAbilityFactory } from './factories/casl-ability.factory'

@Module({
imports: [
ConfigurationModule,
KeycloakConnectModule.registerAsync({
imports: [ConfigurationModule],
useFactory: (config: ConfigurationService) => ({
authServerUrl: `${config.keycloakProtocol}://${config.keycloakDomain}`,
realm: config.keycloakRealm!,
clientId: config.keycloakClientId!,
secret: config.keycloakClientSecret!,
policyEnforcement: PolicyEnforcementMode.PERMISSIVE,
tokenValidation: TokenValidation.ONLINE,
}),
inject: [ConfigurationService],
}),
],
providers: [
CaslAbilityFactory,
{
provide: APP_GUARD,
useClass: AuthGuard,
},
{
provide: APP_GUARD,
useClass: ResourceGuard,
},
{
provide: APP_GUARD,
useClass: PoliciesGuard,
},
],
exports: [CaslAbilityFactory],
})
export class IamModule {}
Loading
Loading