Skip to content

Commit 8e01fa7

Browse files
committed
refactor(keycloak): migrate Keycloak plugin to NestJS
Signed-off-by: William Phetsinorath <william.phetsinorath-open@interieur.gouv.fr>
1 parent 23aa5ef commit 8e01fa7

24 files changed

+2226
-13
lines changed

apps/server-nestjs/.env-example

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@ KEYCLOAK_PROTOCOL=http
1515
KEYCLOAK_CLIENT_ID=dso-console-backend
1616
# Secret du client Keycloak backend (confidentiel)
1717
KEYCLOAK_CLIENT_SECRET=client-secret-backend
18+
# Identifiant de l'administrateur Keycloak
19+
KEYCLOAK_ADMIN=admin
20+
# Mot de passe de l'administrateur Keycloak
21+
KEYCLOAK_ADMIN_PASSWORD=admin
22+
# Identifiant administrateur Keycloak (utilisé pour l'API admin)
23+
KEYCLOAK_ADMIN=admin
24+
# Mot de passe administrateur Keycloak (confidentiel)
25+
KEYCLOAK_ADMIN_PASSWORD=admin
1826
# URL de redirection après authentification Keycloak
1927
KEYCLOAK_REDIRECT_URI=http://localhost:8080
2028
# Port d'écoute du serveur backend

apps/server-nestjs/.env.docker-example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ KEYCLOAK_PROTOCOL=http
1616
KEYCLOAK_CLIENT_ID=dso-console-backend
1717
# Secret du client Keycloak backend (confidentiel)
1818
KEYCLOAK_CLIENT_SECRET=client-secret-backend
19+
# Identifiant de l'administrateur Keycloak
20+
KEYCLOAK_ADMIN=admin
21+
# Mot de passe de l'administrateur Keycloak
22+
KEYCLOAK_ADMIN_PASSWORD=admin
1923
# URL de redirection après authentification Keycloak
2024
KEYCLOAK_REDIRECT_URI=http://localhost:8080
2125
# Port d'écoute du serveur dans le réseau Docker

apps/server-nestjs/package.json

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,15 @@
1616
"start:dev": "nest start --watch",
1717
"start:debug": "nest start --debug --watch",
1818
"start:prod": "node dist/main",
19-
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix"
19+
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
20+
"test": "vitest run",
21+
"test:watch": "vitest",
22+
"test:cov": "vitest run --coverage",
23+
"test:debug": "vitest --inspect"
2024
},
2125
"dependencies": {
26+
"@casl/ability": "^6.7.1",
27+
"@casl/prisma": "^1.5.0",
2228
"@cpn-console/argocd-plugin": "workspace:^",
2329
"@cpn-console/gitlab-plugin": "workspace:^",
2430
"@cpn-console/harbor-plugin": "workspace:^",
@@ -35,10 +41,12 @@
3541
"@fastify/swagger-ui": "^4.2.0",
3642
"@gitbeaker/core": "^40.6.0",
3743
"@gitbeaker/rest": "^40.6.0",
44+
"@keycloak/keycloak-admin-client": "^24.0.0",
3845
"@kubernetes-models/argo-cd": "^2.6.2",
3946
"@nestjs/common": "^11.0.1",
4047
"@nestjs/config": "^4.0.2",
4148
"@nestjs/core": "^11.0.1",
49+
"@nestjs/event-emitter": "^3.0.1",
4250
"@nestjs/platform-express": "^11.0.1",
4351
"@nestjs/schedule": "^5.0.1",
4452
"@opentelemetry/api": "^1.9.0",
@@ -59,7 +67,9 @@
5967
"fastify": "^4.29.1",
6068
"fastify-keycloak-adapter": "2.3.2",
6169
"json-2-csv": "^5.5.7",
70+
"keycloak-connect": "^25.0.0",
6271
"mustache": "^4.2.0",
72+
"nest-keycloak-connect": "^1.10.1",
6373
"nestjs-pino": "^4.5.0",
6474
"pino-http": "^11.0.0",
6575
"prisma": "^6.0.1",

apps/server-nestjs/src/cpin-module/infrastructure/configuration/configuration.service.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ export class ConfigurationService {
2424
keycloakRealm = process.env.KEYCLOAK_REALM
2525
keycloakClientId = process.env.KEYCLOAK_CLIENT_ID
2626
keycloakClientSecret = process.env.KEYCLOAK_CLIENT_SECRET
27+
keycloakAdmin = process.env.KEYCLOAK_ADMIN
28+
keycloakAdminPassword = process.env.KEYCLOAK_ADMIN_PASSWORD
2729
keycloakRedirectUri = process.env.KEYCLOAK_REDIRECT_URI
30+
2831
adminsUserId = process.env.ADMIN_KC_USER_ID
2932
? process.env.ADMIN_KC_USER_ID.split(',')
3033
: []
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import type { OnModuleInit, OnModuleDestroy } from '@nestjs/common'
2+
import { Injectable } from '@nestjs/common'
3+
import { PrismaClient } from '@prisma/client'
4+
5+
@Injectable()
6+
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
7+
async onModuleInit() {
8+
await this.$connect()
9+
}
10+
11+
async onModuleDestroy() {
12+
await this.$disconnect()
13+
}
14+
}

apps/server-nestjs/src/cpin-module/infrastructure/infrastructure.module.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@ import { Module } from '@nestjs/common'
22

33
import { ConfigurationModule } from './configuration/configuration.module'
44
import { DatabaseService } from './database/database.service'
5+
import { PrismaService } from './database/prisma.service'
56
import { HttpClientService } from './http-client/http-client.service'
67
import { LoggerModule } from './logger/logger.module'
78
import { ServerService } from './server/server.service'
89

910
@Module({
10-
providers: [DatabaseService, HttpClientService, ServerService],
11+
providers: [DatabaseService, PrismaService, HttpClientService, ServerService],
1112
imports: [LoggerModule, ConfigurationModule],
12-
exports: [DatabaseService, HttpClientService, ServerService],
13+
exports: [DatabaseService, PrismaService, HttpClientService, ServerService],
1314
})
1415
export class InfrastructureModule {}

apps/server-nestjs/src/main.module.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
11
import { Module } from '@nestjs/common'
2+
import { EventEmitterModule } from '@nestjs/event-emitter'
3+
import { ScheduleModule } from '@nestjs/schedule'
24

35
import { CpinModule } from './cpin-module/cpin.module'
6+
import { KeycloakModule } from './modules/keycloak/keycloak.module'
47

58
// This module only exists to import other module.
69
// « One module to rule them all, and in NestJs bind them »
710
@Module({
8-
imports: [CpinModule],
11+
imports: [
12+
CpinModule,
13+
KeycloakModule,
14+
EventEmitterModule.forRoot(),
15+
ScheduleModule.forRoot(),
16+
],
917
controllers: [],
1018
providers: [],
1119
})
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { SetMetadata } from '@nestjs/common'
2+
import type { AppAbility } from '../factories/casl-ability.factory'
3+
4+
export interface IPolicyHandler {
5+
handle: (ability: AppAbility) => boolean
6+
}
7+
8+
type PolicyHandlerCallback = (ability: AppAbility) => boolean
9+
10+
export type PolicyHandler = IPolicyHandler | PolicyHandlerCallback
11+
12+
export const CHECK_POLICIES_KEY = 'check_policy'
13+
export function CheckPolicies(...handlers: PolicyHandler[]) {
14+
return SetMetadata(CHECK_POLICIES_KEY, handlers)
15+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import type { PureAbility } from '@casl/ability'
2+
import { AbilityBuilder } from '@casl/ability'
3+
import type { PrismaQuery, Subjects } from '@casl/prisma'
4+
import { createPrismaAbility } from '@casl/prisma'
5+
import { Injectable } from '@nestjs/common'
6+
import type { Project, Environment, User, ProjectMembers } from '@prisma/client'
7+
8+
export type AppAbility = PureAbility<
9+
[string, Subjects<{ Project: Project, Environment: Environment, User: User, ProjectMembers: ProjectMembers }>],
10+
PrismaQuery
11+
>
12+
13+
@Injectable()
14+
export class CaslAbilityFactory {
15+
createForUser(user: any) {
16+
const { can, build } = new AbilityBuilder<AppAbility>(
17+
createPrismaAbility,
18+
)
19+
20+
// If user is not authenticated or doesn't have an ID
21+
if (!user || !user.sub) {
22+
return build()
23+
}
24+
25+
const userId = user.sub
26+
27+
// A user can read projects they are a member of (via ProjectMembers)
28+
can('read', 'Project', {
29+
members: {
30+
some: {
31+
userId,
32+
},
33+
},
34+
})
35+
36+
// A project owner can manage everything
37+
can('manage', 'Project', {
38+
ownerId: userId,
39+
})
40+
41+
// A user can update an environment if the project is not locked
42+
// and they are a member of the project
43+
can('update', 'Environment', {
44+
project: {
45+
is: {
46+
locked: false,
47+
members: {
48+
some: {
49+
userId,
50+
},
51+
},
52+
},
53+
},
54+
})
55+
56+
return build()
57+
}
58+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { CanActivate, ExecutionContext } from '@nestjs/common'
2+
import { Inject, Injectable } from '@nestjs/common'
3+
import { Reflector } from '@nestjs/core'
4+
import { CaslAbilityFactory, type AppAbility } from '../factories/casl-ability.factory'
5+
import type { PolicyHandler } from '../decorators/check-policies.decorator'
6+
import { CHECK_POLICIES_KEY } from '../decorators/check-policies.decorator'
7+
8+
@Injectable()
9+
export class PoliciesGuard implements CanActivate {
10+
constructor(
11+
@Inject(Reflector) private reflector: Reflector,
12+
@Inject(CaslAbilityFactory) private caslAbilityFactory: CaslAbilityFactory,
13+
) {}
14+
15+
async canActivate(context: ExecutionContext): Promise<boolean> {
16+
const policyHandlers
17+
= this.reflector.get<PolicyHandler[]>(
18+
CHECK_POLICIES_KEY,
19+
context.getHandler(),
20+
) || []
21+
22+
const { user } = context.switchToHttp().getRequest()
23+
const ability = this.caslAbilityFactory.createForUser(user)
24+
25+
return policyHandlers.every(handler =>
26+
this.execPolicyHandler(handler, ability),
27+
)
28+
}
29+
30+
private execPolicyHandler(handler: PolicyHandler, ability: AppAbility) {
31+
if (typeof handler === 'function') {
32+
return handler(ability)
33+
}
34+
return handler.handle(ability)
35+
}
36+
}

0 commit comments

Comments
 (0)