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
12 changes: 11 additions & 1 deletion apps/server-nestjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,15 @@
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix"
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "vitest run",
"test:watch": "vitest",
"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 @@ -35,10 +41,12 @@
"@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.6.2",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
"@nestjs/event-emitter": "^3.0.1",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/schedule": "^5.0.1",
"@opentelemetry/auto-instrumentations-node": "^0.70.1",
Expand All @@ -56,7 +64,9 @@
"fastify": "^4.29.1",
"fastify-keycloak-adapter": "2.3.2",
"json-2-csv": "^5.5.7",
"keycloak-connect": "^25.0.0",
"mustache": "^4.2.0",
"nest-keycloak-connect": "^1.10.1",
"nestjs-pino": "^4.5.0",
"pino-http": "^11.0.0",
"prisma": "^6.0.1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ export class ConfigurationService {
keycloakClientId = process.env.KEYCLOAK_CLIENT_ID
keycloakClientSecret = process.env.KEYCLOAK_CLIENT_SECRET
keycloakRedirectUri = process.env.KEYCLOAK_REDIRECT_URI
keycloakControllerPurgeOrphanGroups = Boolean(process.env.KEYCLOAK_CONTROLLER_PURGE_ORPHAN_GROUPS)
keycloakControllerPurgeOrphanMembers = Boolean(process.env.KEYCLOAK_CONTROLLER_PURGE_ORPHAN_MEMBERS)

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 {}
12 changes: 11 additions & 1 deletion apps/server-nestjs/src/main.module.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
import { Module } from '@nestjs/common'
import { EventEmitterModule } from '@nestjs/event-emitter'
import { ScheduleModule } from '@nestjs/schedule'

import { CpinModule } from './cpin-module/cpin.module'
import { IamModule } from './modules/iam/iam.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,
IamModule,
KeycloakModule,
EventEmitterModule.forRoot(),
ScheduleModule.forRoot(),
],
controllers: [],
providers: [],
})
Expand Down
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 || !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()
}
}
36 changes: 36 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,36 @@
import type { CanActivate, ExecutionContext } from '@nestjs/common'
import { Inject, Injectable } from '@nestjs/common'
import { Reflector } from '@nestjs/core'
import { CaslAbilityFactory, 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 reflector: Reflector,
@Inject(CaslAbilityFactory) private 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 {}
30 changes: 30 additions & 0 deletions apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Inject, Injectable, Logger, type OnModuleInit } from '@nestjs/common'
import KcAdminClient from '@keycloak/keycloak-admin-client'
import { ConfigurationService } from '../../cpin-module/infrastructure/configuration/configuration.service'

@Injectable()
export class KeycloakClientService extends KcAdminClient implements OnModuleInit {
private readonly logger = new Logger(KeycloakClientService.name)

constructor(
@Inject(ConfigurationService) private readonly config: ConfigurationService,
) {
super({
baseUrl: `${config.keycloakProtocol}://${config.keycloakDomain}`,
realmName: config.keycloakRealm,
})
}

async onModuleInit() {
try {
await this.auth({
grantType: 'client_credentials',
clientId: this.config.keycloakClientId!,
clientSecret: this.config.keycloakClientSecret!,
})
this.logger.log('Keycloak Admin Client authenticated')
} catch (error) {
this.logger.error('Failed to authenticate with Keycloak', error)
}
}
}
Loading