Skip to content

Commit cbb90b8

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

24 files changed

Lines changed: 2673 additions & 115 deletions

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/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: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { CanActivate, ExecutionContext } from '@nestjs/common'
2+
import { Inject, Injectable } from '@nestjs/common'
3+
import { Reflector } from '@nestjs/core'
4+
import { CaslAbilityFactory } from '../factories/casl-ability.factory'
5+
import type { AppAbility } from '../factories/casl-ability.factory'
6+
import type { PolicyHandler } from '../decorators/check-policies.decorator'
7+
import { CHECK_POLICIES_KEY } from '../decorators/check-policies.decorator'
8+
9+
@Injectable()
10+
export class PoliciesGuard implements CanActivate {
11+
constructor(
12+
@Inject(Reflector) private reflector: Reflector,
13+
@Inject(CaslAbilityFactory) private caslAbilityFactory: CaslAbilityFactory,
14+
) {}
15+
16+
async canActivate(context: ExecutionContext): Promise<boolean> {
17+
const policyHandlers
18+
= this.reflector.get<PolicyHandler[]>(
19+
CHECK_POLICIES_KEY,
20+
context.getHandler(),
21+
) || []
22+
23+
const { user } = context.switchToHttp().getRequest()
24+
const ability = this.caslAbilityFactory.createForUser(user)
25+
26+
return policyHandlers.every(handler =>
27+
this.execPolicyHandler(handler, ability),
28+
)
29+
}
30+
31+
private execPolicyHandler(handler: PolicyHandler, ability: AppAbility) {
32+
if (typeof handler === 'function') {
33+
return handler(ability)
34+
}
35+
return handler.handle(ability)
36+
}
37+
}
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: (config: ConfigurationService) => ({
21+
authServerUrl: `${config.keycloakProtocol}://${config.keycloakDomain}`,
22+
realm: config.keycloakRealm!,
23+
clientId: config.keycloakClientId!,
24+
secret: config.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)