Skip to content

Commit 4ed4ddf

Browse files
committed
chore(keycloak): migrate Keycloak plugin to NestJS
Signed-off-by: William Phetsinorath <william.phetsinorath-open@interieur.gouv.fr>
1 parent 708870b commit 4ed4ddf

16 files changed

Lines changed: 1600 additions & 14 deletions

apps/server-nestjs/package.json

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
{
22
"name": "server-nestjs",
33
"version": "9.13.2",
4+
"private": true,
45
"description": "",
56
"author": "",
6-
"private": true,
77
"license": "UNLICENSED",
88
"scripts": {
99
"build": "nest build",
@@ -12,9 +12,15 @@
1212
"start:dev": "nest start --watch",
1313
"start:debug": "nest start --debug --watch",
1414
"start:prod": "node dist/main",
15-
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix"
15+
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
16+
"test": "vitest run",
17+
"test:watch": "vitest",
18+
"test:cov": "vitest run --coverage",
19+
"test:debug": "vitest --inspect"
1620
},
1721
"dependencies": {
22+
"@casl/ability": "^6.7.1",
23+
"@casl/prisma": "^1.5.0",
1824
"@cpn-console/argocd-plugin": "workspace:^",
1925
"@cpn-console/gitlab-plugin": "workspace:^",
2026
"@cpn-console/harbor-plugin": "workspace:^",
@@ -31,11 +37,14 @@
3137
"@fastify/swagger-ui": "^4.2.0",
3238
"@gitbeaker/core": "^40.6.0",
3339
"@gitbeaker/rest": "^40.6.0",
40+
"@keycloak/keycloak-admin-client": "^24.0.0",
3441
"@kubernetes-models/argo-cd": "^2.6.2",
3542
"@nestjs/common": "^11.0.1",
3643
"@nestjs/config": "^4.0.2",
3744
"@nestjs/core": "^11.0.1",
45+
"@nestjs/event-emitter": "^3.0.1",
3846
"@nestjs/platform-express": "^11.0.1",
47+
"@nestjs/schedule": "^5.0.1",
3948
"@prisma/client": "^6.0.1",
4049
"@ts-rest/core": "^3.52.1",
4150
"@ts-rest/fastify": "^3.52.1",
@@ -46,7 +55,9 @@
4655
"fastify": "^4.29.1",
4756
"fastify-keycloak-adapter": "2.3.2",
4857
"json-2-csv": "^5.5.7",
58+
"keycloak-connect": "^25.0.0",
4959
"mustache": "^4.2.0",
60+
"nest-keycloak-connect": "^1.10.1",
5061
"nestjs-pino": "^4.5.0",
5162
"pino-http": "^11.0.0",
5263
"prisma": "^6.0.1",

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export class ConfigurationService {
2525
keycloakClientId = process.env.KEYCLOAK_CLIENT_ID
2626
keycloakClientSecret = process.env.KEYCLOAK_CLIENT_SECRET
2727
keycloakRedirectUri = process.env.KEYCLOAK_REDIRECT_URI
28+
keycloakControllerPurgeOrphans = process.env.KEYCLOAK_RECONCILER_PURGE_ORPHANS === 'true'
2829
adminsUserId = process.env.ADMIN_KC_USER_ID
2930
? process.env.ADMIN_KC_USER_ID.split(',')
3031
: []
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: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
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 { IamModule } from './modules/iam/iam.module'
7+
import { KeycloakModule } from './modules/keycloak/keycloak.module'
48

59
// This module only exists to import other module.
610
// « One module to rule them all, and in NestJs bind them »
711
@Module({
8-
imports: [CpinModule],
12+
imports: [
13+
CpinModule,
14+
IamModule,
15+
KeycloakModule,
16+
EventEmitterModule.forRoot(),
17+
ScheduleModule.forRoot(),
18+
],
919
controllers: [],
1020
providers: [],
1121
})
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 { Injectable } from '@nestjs/common'
3+
import type { Reflector } from '@nestjs/core'
4+
import type { CaslAbilityFactory, 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+
private reflector: Reflector,
12+
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+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { Module } from '@nestjs/common'
2+
import { APP_GUARD } from '@nestjs/core'
3+
import {
4+
AuthGuard,
5+
ResourceGuard,
6+
KeycloakConnectModule,
7+
PolicyEnforcementMode,
8+
TokenValidation,
9+
} from 'nest-keycloak-connect'
10+
import { ConfigurationModule } from '../../cpin-module/infrastructure/configuration/configuration.module'
11+
import { ConfigurationService } from '../../cpin-module/infrastructure/configuration/configuration.service'
12+
import { PoliciesGuard } from './guards/policies.guard'
13+
import { CaslAbilityFactory } from './factories/casl-ability.factory'
14+
15+
@Module({
16+
imports: [
17+
ConfigurationModule,
18+
KeycloakConnectModule.registerAsync({
19+
imports: [ConfigurationModule],
20+
useFactory: (configService: ConfigurationService) => ({
21+
authServerUrl: `${configService.keycloakProtocol}://${configService.keycloakDomain}`,
22+
realm: configService.keycloakRealm!,
23+
clientId: configService.keycloakClientId!,
24+
secret: configService.keycloakClientSecret!,
25+
policyEnforcement: PolicyEnforcementMode.PERMISSIVE,
26+
tokenValidation: TokenValidation.ONLINE,
27+
}),
28+
inject: [ConfigurationService],
29+
}),
30+
],
31+
providers: [
32+
CaslAbilityFactory,
33+
{
34+
provide: APP_GUARD,
35+
useClass: AuthGuard,
36+
},
37+
{
38+
provide: APP_GUARD,
39+
useClass: ResourceGuard,
40+
},
41+
{
42+
provide: APP_GUARD,
43+
useClass: PoliciesGuard,
44+
},
45+
],
46+
exports: [CaslAbilityFactory],
47+
})
48+
export class IamModule {}

0 commit comments

Comments
 (0)