diff --git a/apps/server-nestjs/package.json b/apps/server-nestjs/package.json index b2950759bb..b0c3d6ac48 100644 --- a/apps/server-nestjs/package.json +++ b/apps/server-nestjs/package.json @@ -1,9 +1,9 @@ { "name": "server-nestjs", "version": "9.13.2", + "private": true, "description": "", "author": "", - "private": true, "license": "UNLICENSED", "scripts": { "build": "nest build", @@ -12,9 +12,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:^", @@ -31,11 +37,14 @@ "@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", "@prisma/client": "^6.0.1", "@ts-rest/core": "^3.52.1", "@ts-rest/fastify": "^3.52.1", @@ -46,7 +55,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", diff --git a/apps/server-nestjs/src/cpin-module/infrastructure/configuration/configuration.service.ts b/apps/server-nestjs/src/cpin-module/infrastructure/configuration/configuration.service.ts index b3af13bcb9..b8ee7b5c85 100644 --- a/apps/server-nestjs/src/cpin-module/infrastructure/configuration/configuration.service.ts +++ b/apps/server-nestjs/src/cpin-module/infrastructure/configuration/configuration.service.ts @@ -25,6 +25,7 @@ export class ConfigurationService { keycloakClientId = process.env.KEYCLOAK_CLIENT_ID keycloakClientSecret = process.env.KEYCLOAK_CLIENT_SECRET keycloakRedirectUri = process.env.KEYCLOAK_REDIRECT_URI + keycloakControllerPurgeOrphans = Boolean(process.env.KEYCLOAK_RECONCILER_PURGE_ORPHANS) adminsUserId = process.env.ADMIN_KC_USER_ID ? process.env.ADMIN_KC_USER_ID.split(',') : [] diff --git a/apps/server-nestjs/src/cpin-module/infrastructure/database/prisma.service.ts b/apps/server-nestjs/src/cpin-module/infrastructure/database/prisma.service.ts new file mode 100644 index 0000000000..410e662ea5 --- /dev/null +++ b/apps/server-nestjs/src/cpin-module/infrastructure/database/prisma.service.ts @@ -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() + } +} diff --git a/apps/server-nestjs/src/cpin-module/infrastructure/infrastructure.module.ts b/apps/server-nestjs/src/cpin-module/infrastructure/infrastructure.module.ts index 8bd7fac8af..35f17db524 100644 --- a/apps/server-nestjs/src/cpin-module/infrastructure/infrastructure.module.ts +++ b/apps/server-nestjs/src/cpin-module/infrastructure/infrastructure.module.ts @@ -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 {} diff --git a/apps/server-nestjs/src/main.module.ts b/apps/server-nestjs/src/main.module.ts index a8c7b3fd0b..29edea89e1 100644 --- a/apps/server-nestjs/src/main.module.ts +++ b/apps/server-nestjs/src/main.module.ts @@ -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: [], }) diff --git a/apps/server-nestjs/src/modules/iam/decorators/check-policies.decorator.ts b/apps/server-nestjs/src/modules/iam/decorators/check-policies.decorator.ts new file mode 100644 index 0000000000..e08973bc88 --- /dev/null +++ b/apps/server-nestjs/src/modules/iam/decorators/check-policies.decorator.ts @@ -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) +} diff --git a/apps/server-nestjs/src/modules/iam/factories/casl-ability.factory.ts b/apps/server-nestjs/src/modules/iam/factories/casl-ability.factory.ts new file mode 100644 index 0000000000..4edff5f583 --- /dev/null +++ b/apps/server-nestjs/src/modules/iam/factories/casl-ability.factory.ts @@ -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( + 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() + } +} diff --git a/apps/server-nestjs/src/modules/iam/guards/policies.guard.ts b/apps/server-nestjs/src/modules/iam/guards/policies.guard.ts new file mode 100644 index 0000000000..7f4117636a --- /dev/null +++ b/apps/server-nestjs/src/modules/iam/guards/policies.guard.ts @@ -0,0 +1,36 @@ +import type { CanActivate, ExecutionContext } from '@nestjs/common' +import { Injectable } from '@nestjs/common' +import type { Reflector } from '@nestjs/core' +import type { CaslAbilityFactory, 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( + private reflector: Reflector, + private caslAbilityFactory: CaslAbilityFactory, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const policyHandlers + = this.reflector.get( + 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) + } +} diff --git a/apps/server-nestjs/src/modules/iam/iam.module.ts b/apps/server-nestjs/src/modules/iam/iam.module.ts new file mode 100644 index 0000000000..f1e5b0899f --- /dev/null +++ b/apps/server-nestjs/src/modules/iam/iam.module.ts @@ -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: (configService: ConfigurationService) => ({ + authServerUrl: `${configService.keycloakProtocol}://${configService.keycloakDomain}`, + realm: configService.keycloakRealm!, + clientId: configService.keycloakClientId!, + secret: configService.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 {} diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts b/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts new file mode 100644 index 0000000000..8a5630dcb0 --- /dev/null +++ b/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts @@ -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 configService: ConfigurationService, + ) { + super({ + baseUrl: `${configService.keycloakProtocol}://${configService.keycloakDomain}`, + realmName: configService.keycloakRealm, + }) + } + + async onModuleInit() { + try { + await this.auth({ + grantType: 'client_credentials', + clientId: this.configService.keycloakClientId!, + clientSecret: this.configService.keycloakClientSecret!, + }) + this.logger.log('Keycloak Admin Client authenticated') + } catch (error) { + this.logger.error('Failed to authenticate with Keycloak', error) + } + } +} diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak-controller.service.spec.ts b/apps/server-nestjs/src/modules/keycloak/keycloak-controller.service.spec.ts new file mode 100644 index 0000000000..a1c92538f1 --- /dev/null +++ b/apps/server-nestjs/src/modules/keycloak/keycloak-controller.service.spec.ts @@ -0,0 +1,304 @@ +import { Test } from '@nestjs/testing' +import { describe, it, expect, beforeEach, vi, type Mocked } from 'vitest' +import { KeycloakControllerService } from './keycloak-controller.service' +import { KeycloakDatastoreService, type ProjectWithDetails } from './keycloak-datastore.service' +import { KeycloakService } from './keycloak.service' +import { ConfigurationService } from '@/cpin-module/infrastructure/configuration/configuration.service' + +function createKeycloakControllerServiceTestingModule() { + return Test.createTestingModule({ + providers: [ + KeycloakControllerService, + { + provide: KeycloakService, + useValue: { + getAllGroups: vi.fn().mockImplementation(async function* () {}), + deleteGroup: vi.fn().mockResolvedValue(undefined), + getOrCreateGroupByPath: vi.fn().mockResolvedValue({}), + getGroupMembers: vi.fn().mockResolvedValue([]), + addUserToGroup: vi.fn().mockResolvedValue(undefined), + removeUserFromGroup: vi.fn().mockResolvedValue(undefined), + getOrCreateSubGroupByName: vi.fn().mockResolvedValue({}), + getSubGroups: vi.fn().mockImplementation(async function* () {}), + getOrCreateConsoleGroup: vi.fn().mockResolvedValue({ id: 'console-group-id', name: 'console' }), + getOrCreateEnvironmentGroups: vi.fn().mockResolvedValue({ + roGroup: { id: 'ro-id', name: 'RO' }, + rwGroup: { id: 'rw-id', name: 'RW' }, + }), + } satisfies Partial, + }, + { + provide: KeycloakDatastoreService, + useValue: { + getAllProjects: vi.fn().mockResolvedValue([]), + } satisfies Partial, + }, + { + provide: ConfigurationService, + useValue: { + keycloakControllerPurgeOrphans: false, + } satisfies Partial, + }, + ], + }) +} + +describe('keycloakControllerService', () => { + let service: KeycloakControllerService + let keycloakService: Mocked + let keycloakDatastore: Mocked + let configService: Mocked + + beforeEach(async () => { + vi.clearAllMocks() + const module = await createKeycloakControllerServiceTestingModule().compile() + service = module.get(KeycloakControllerService) + keycloakService = module.get(KeycloakService) + keycloakDatastore = module.get(KeycloakDatastoreService) + configService = module.get(ConfigurationService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('reconcile', () => { + const mockProject: ProjectWithDetails = { + id: 'project-id', + slug: 'test-project', + ownerId: 'owner-id', + everyonePerms: 0n, + members: [], + roles: [], + environments: [], + } + + it('should purge orphans if enabled', async () => { + // Setup + configService.keycloakControllerPurgeOrphans = true + keycloakDatastore.getAllProjects.mockResolvedValue([mockProject]) + + const projectGroup = { id: 'group-id', name: 'test-project', subGroups: [] } + const orphanGroup = { id: 'orphan-id', name: 'orphan-project', subGroups: [{ name: 'console' }] } + + keycloakService.getAllGroups.mockImplementation(async function* () { + yield projectGroup + yield orphanGroup + }) + keycloakService.getOrCreateGroupByPath.mockResolvedValue(projectGroup) + keycloakService.getGroupMembers.mockResolvedValue([]) + keycloakService.getOrCreateSubGroupByName.mockResolvedValue({ id: 'console-id', name: 'console' }) + await service.handleCron() + + expect(keycloakDatastore.getAllProjects).toHaveBeenCalled() + expect(keycloakService.getAllGroups).toHaveBeenCalled() + expect(keycloakService.getOrCreateGroupByPath).toHaveBeenCalledWith('/test-project') + expect(keycloakService.deleteGroup).toHaveBeenCalledWith('orphan-id') + }) + + it('should not purge orphans if disabled', async () => { + // Setup + configService.keycloakControllerPurgeOrphans = false + keycloakDatastore.getAllProjects.mockResolvedValue([mockProject]) + + const projectGroup = { id: 'group-id', name: 'test-project', subGroups: [] } + const orphanGroup = { id: 'orphan-id', name: 'orphan-project', subGroups: [{ name: 'console' }] } + + keycloakService.getAllGroups.mockImplementation(async function* () { + yield projectGroup + yield orphanGroup + }) + keycloakService.getOrCreateGroupByPath.mockResolvedValue(projectGroup) + keycloakService.getGroupMembers.mockResolvedValue([]) + keycloakService.getOrCreateSubGroupByName.mockResolvedValue({ id: 'console-id', name: 'console' }) + keycloakService.getSubGroups.mockImplementation(async function* () { /* empty */ }) + + await service.handleCron() + + expect(keycloakService.deleteGroup).not.toHaveBeenCalled() + }) + + it('should sync project members', async () => { + // Setup + configService.keycloakControllerPurgeOrphans = true + const projectWithMembers = { + ...mockProject, + members: [{ user: { id: 'user-1', email: 'user1@example.com' }, roleIds: [] }], + } + keycloakDatastore.getAllProjects.mockResolvedValue([projectWithMembers]) + + const projectGroup = { id: 'group-id', name: 'test-project' } + keycloakService.getOrCreateGroupByPath.mockResolvedValue(projectGroup) + + // Current members: user-2 (extra), missing user-1 + keycloakService.getGroupMembers.mockResolvedValue([ + { id: 'user-2', email: 'user2@example.com' }, + ]) + + keycloakService.getOrCreateSubGroupByName.mockResolvedValue({ id: 'console-id', name: 'console' }) + keycloakService.getSubGroups.mockImplementation(async function* () { /* empty */ }) + + await service.handleCron() + + // Should add missing member + expect(keycloakService.addUserToGroup).toHaveBeenCalledWith('user-1', 'group-id') + // Should add owner (missing in group members) + expect(keycloakService.addUserToGroup).toHaveBeenCalledWith('owner-id', 'group-id') + // Should remove extra member (purge enabled) + expect(keycloakService.removeUserFromGroup).toHaveBeenCalledWith('user-2', 'group-id') + }) + + it('should sync OIDC role groups', async () => { + // Setup + configService.keycloakControllerPurgeOrphans = true + const roleWithOidc = { + id: 'role-oidc', + permissions: 0n, + oidcGroup: '/oidc-group', + } + const projectWithRole = { + ...mockProject, + members: [{ user: { id: 'user-1', email: 'user1@example.com' }, roleIds: ['role-oidc'] }], + roles: [roleWithOidc], + } + keycloakDatastore.getAllProjects.mockResolvedValue([projectWithRole]) + + const projectGroup = { id: 'group-id', name: 'test-project' } + const roleGroup = { id: 'role-group-id', name: 'oidc-group', path: '/oidc-group' } + + keycloakService.getOrCreateGroupByPath.mockImplementation((path) => { + if (path === '/test-project') return Promise.resolve(projectGroup) + if (path === '/oidc-group') return Promise.resolve(roleGroup) + return Promise.resolve({}) + }) + + // Project members: owner + keycloakService.getGroupMembers.mockImplementation((groupId) => { + if (groupId === 'group-id') return Promise.resolve([{ id: 'owner-id' }]) + // Role group members: user-2 (extra), missing user-1 + if (groupId === 'role-group-id') return Promise.resolve([{ id: 'user-2', email: 'user2@example.com', groups: ['/oidc-group'] }]) + return Promise.resolve([]) + }) + + keycloakService.getOrCreateSubGroupByName.mockResolvedValue({ id: 'console-id', name: 'console' }) + keycloakService.getSubGroups.mockImplementation(async function* () { /* empty */ }) + + await service.handleCron() + + // Should create/get role group + expect(keycloakService.getOrCreateGroupByPath).toHaveBeenCalledWith('/oidc-group') + // Should add user-1 to role group + expect(keycloakService.addUserToGroup).toHaveBeenCalledWith('user-1', 'role-group-id') + // Should remove user-2 from role group (purge enabled) + expect(keycloakService.removeUserFromGroup).toHaveBeenCalledWith('user-2', 'role-group-id') + }) + + it('should sync environment groups', async () => { + // Setup + configService.keycloakControllerPurgeOrphans = true + const projectWithEnv = { + ...mockProject, + environments: [{ id: 'env-1', name: 'dev' }], + } + keycloakDatastore.getAllProjects.mockResolvedValue([projectWithEnv]) + + const projectGroup = { id: 'group-id', name: 'test-project', subGroups: [{ name: 'console', id: 'console-id' }] } + keycloakService.getOrCreateGroupByPath.mockResolvedValue(projectGroup) + keycloakService.getGroupMembers.mockResolvedValue([]) + + // Mock console group retrieval + keycloakService.getOrCreateConsoleGroup.mockResolvedValue({ id: 'console-id', name: 'console' }) + keycloakService.getOrCreateEnvironmentGroups.mockResolvedValue({ + roGroup: { id: 'dev-ro-id', name: 'RO' }, + rwGroup: { id: 'dev-rw-id', name: 'RW' }, + }) + keycloakService.getOrCreateSubGroupByName.mockImplementation((_parentId, name) => { + if (name === 'console') return Promise.resolve({ id: 'console-id', name: 'console' }) + if (name === 'dev') return Promise.resolve({ id: 'dev-id', name: 'dev' }) + if (name === 'RO') return Promise.resolve({ id: 'dev-ro-id', name: 'RO' }) + if (name === 'RW') return Promise.resolve({ id: 'dev-rw-id', name: 'RW' }) + return Promise.resolve({ id: 'new-id', name }) + }) + + // Mock existing environments: 'staging' (extra) + keycloakService.getSubGroups.mockImplementation(async function* (parentId) { + if (parentId === 'console-id') { + yield { id: 'staging-id', name: 'staging' } + } + }) + + await service.handleCron() + + // Should create dev group + expect(keycloakService.getOrCreateConsoleGroup).toHaveBeenCalledWith(projectGroup) + // Should create RO/RW groups + expect(keycloakService.getOrCreateEnvironmentGroups).toHaveBeenCalledWith({ id: 'console-id', name: 'console' }, projectWithEnv.environments[0]) + // Should delete staging group (purge enabled) + expect(keycloakService.deleteGroup).toHaveBeenCalledWith('staging-id') + }) + + it('should sync environment permissions', async () => { + // Setup + configService.keycloakControllerPurgeOrphans = true + + const userRo = { id: 'user-ro', email: 'ro@example.com' } + const userRw = { id: 'user-rw', email: 'rw@example.com' } + const userNone = { id: 'user-none', email: 'none@example.com' } + + const projectWithEnvAndMembers = { + id: mockProject.id, + slug: mockProject.slug, + ownerId: mockProject.ownerId, + everyonePerms: mockProject.everyonePerms, + members: [ + { userId: userRo.id, user: userRo, roleIds: ['role-ro'] }, + { userId: userRw.id, user: userRw, roleIds: ['role-rw'] }, + { userId: userNone.id, user: userNone, roleIds: [] }, + ], + roles: [ + { id: 'role-ro', permissions: BigInt(256), oidcGroup: '' }, // ListEnvironments (bit 8) + { id: 'role-rw', permissions: BigInt(8), oidcGroup: '' }, // ManageEnvironments (bit 3) + ], + environments: [{ id: 'env-1', name: 'dev' }], + } + keycloakDatastore.getAllProjects.mockResolvedValue([projectWithEnvAndMembers]) + + const projectGroup = { id: 'group-id', name: 'test-project', subGroups: [{ name: 'console', id: 'console-id' }] } + keycloakService.getOrCreateGroupByPath.mockResolvedValue(projectGroup) + keycloakService.getOrCreateConsoleGroup.mockResolvedValue({ id: 'console-id', name: 'console' }) + keycloakService.getOrCreateEnvironmentGroups.mockResolvedValue({ + roGroup: { id: 'dev-ro-id', name: 'RO' }, + rwGroup: { id: 'dev-rw-id', name: 'RW' }, + }) + + // Project group members (assume all are in project group for simplicity) + keycloakService.getGroupMembers.mockImplementation((groupId) => { + if (groupId === 'group-id') return Promise.resolve([userRo, userRw, userNone]) + // RO group has userNone (extra), missing userRo + if (groupId === 'dev-ro-id') return Promise.resolve([userNone]) + // RW group has userNone (extra), missing userRw + if (groupId === 'dev-rw-id') return Promise.resolve([userNone]) + return Promise.resolve([]) + }) + + keycloakService.getOrCreateSubGroupByName.mockImplementation((_parentId, name) => { + if (name === 'console') return Promise.resolve({ id: 'console-id', name: 'console' }) + if (name === 'dev') return Promise.resolve({ id: 'dev-id', name: 'dev' }) + if (name === 'RO') return Promise.resolve({ id: 'dev-ro-id', name: 'RO' }) + if (name === 'RW') return Promise.resolve({ id: 'dev-rw-id', name: 'RW' }) + return Promise.resolve({ id: 'new-id', name }) + }) + + keycloakService.getSubGroups.mockImplementation(async function* () { /* empty */ }) + + await service.handleCron() + + // Sync RO + expect(keycloakService.addUserToGroup).toHaveBeenCalledWith('user-ro', 'dev-ro-id') + expect(keycloakService.removeUserFromGroup).toHaveBeenCalledWith('user-none', 'dev-ro-id') + // Sync RW + expect(keycloakService.addUserToGroup).toHaveBeenCalledWith('user-rw', 'dev-rw-id') + expect(keycloakService.removeUserFromGroup).toHaveBeenCalledWith('user-none', 'dev-rw-id') + }) + }) +}) diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak-controller.service.ts b/apps/server-nestjs/src/modules/keycloak/keycloak-controller.service.ts new file mode 100644 index 0000000000..c5735caedc --- /dev/null +++ b/apps/server-nestjs/src/modules/keycloak/keycloak-controller.service.ts @@ -0,0 +1,438 @@ +import type { OnModuleInit } from '@nestjs/common' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { Cron, CronExpression } from '@nestjs/schedule' +import { ProjectAuthorized, getPermsByUserRoles, resourceListToDict } from '@cpn-console/shared' +import { ConfigurationService } from '@/cpin-module/infrastructure/configuration/configuration.service' +import { KeycloakService } from './keycloak.service' +import type GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation' +import type UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation.js' +import { KeycloakDatastoreService, type ProjectWithDetails } from './keycloak-datastore.service' +import { CONSOLE_GROUP_NAME } from './keycloak.constant' + +@Injectable() +export class KeycloakControllerService implements OnModuleInit { + private readonly logger = new Logger(KeycloakControllerService.name) + + constructor( + @Inject(KeycloakService) private readonly keycloakService: KeycloakService, + @Inject(KeycloakDatastoreService) private readonly keycloakDatastore: KeycloakDatastoreService, + @Inject(ConfigurationService) private readonly configService: ConfigurationService, + ) { + this.logger.log('KeycloakControllerService initialized') + } + + onModuleInit() { + // this.handleCron() + } + + @OnEvent('project.upsert') + async handleUpsert(project: ProjectWithDetails) { + this.logger.log(`Handling project upsert for ${project.slug}`) + return this.reconcile() + } + + @OnEvent('project.delete') + async handleDelete(project: ProjectWithDetails) { + this.logger.log(`Handling project delete for ${project.slug}`) + return this.reconcile() + } + + @Cron(CronExpression.EVERY_HOUR) + async handleCron() { + this.logger.log('Starting periodic Keycloak reconciliation') + await this.reconcile() + this.logger.log('Periodic Keycloak reconciliation completed') + } + + private async reconcile(): Promise[]> { + const results: PromiseSettledResult[] = [] + try { + const projects = await this.keycloakDatastore.getAllProjects() + + const projectGroupResults = await this.ensureProjectGroups(projects) + results.push(...projectGroupResults) + projectGroupResults.forEach((result) => { + if (result.status === 'rejected') { + this.logger.error(`Failed to ensure project group ${result.reason}`) + } + }) + + const orphanResults = await this.purgeOrphanGroups(projects) + results.push(...orphanResults) + orphanResults.forEach((result) => { + if (result.status === 'rejected') { + this.logger.error(`Failed to purge orphan group ${result.reason}`) + } + }) + } catch (error) { + this.logger.error('Failed to reconcile Keycloak state', error) + results.push({ status: 'rejected', reason: error }) + } + + return results + } + + private async ensureProjectGroups(projects: ProjectWithDetails[]) { + const results = await Promise.all(projects.map(async (project) => { + try { + const projectGroup = await this.keycloakService.getOrCreateGroupByPath(`/${project.slug}`) + const memberResults = await this.ensureProjectGroup(project, projectGroup) + const subResults = await Promise.all([ + this.ensureProjectRoleGroups(project, projectGroup), + this.ensureEnvironmentGroups(project, projectGroup), + ]) + return [...memberResults, ...subResults.flat()] + } catch (error) { + return [{ status: 'rejected', reason: error }] as PromiseSettledResult[] + } + })) + return results.flat() + } + + private async purgeOrphanGroups(projects: ProjectWithDetails[]) { + const groups = this.keycloakService.getAllGroups() + const projectSlugs = new Set(projects.map(p => p.slug)) + const promises: Promise[] = [] + + for await (const group of groups) { + if (group.name && !projectSlugs.has(group.name)) { + if (this.isOwnedProjectGroup(group)) { + if (this.configService.keycloakControllerPurgeOrphans) { + if (group.id) { + this.logger.log(`Deleting orphan Keycloak group: ${group.name}`) + promises.push( + this.keycloakService.deleteGroup(group.id) + .catch(error => this.logger.error(`Failed to delete orphan group ${group.name}`, error)), + ) + } else { + this.logger.warn(`Orphan Keycloak group detected but ID is missing: ${group.name}`) + } + } else { + this.logger.warn(`Orphan Keycloak group detected but purge is disabled: ${group.name}`) + } + } + } + } + return Promise.allSettled(promises) + } + + private isOwnedProjectGroup(group: GroupRepresentation) { + // Safety check: Only delete if it looks like a project group (has 'console' subgroup) + // or if we can be sure it's not a system group. + // For now, we rely on the 'console' subgroup heuristic as it's created by us. + return !!group.subGroups?.some(sg => sg.name === CONSOLE_GROUP_NAME) + } + + private async ensureProjectGroup(project: ProjectWithDetails, projectGroup: GroupRepresentation) { + if (!projectGroup.id) { + throw new Error(`Failed to create or retrieve project group for ${project.slug}`) + } + const groupMembers = await this.keycloakService.getGroupMembers(projectGroup.id) + + const results = await Promise.all([ + this.addMissingProjectMembers(project, projectGroup, groupMembers), + this.deleteExtraProjectMembers(project, projectGroup, groupMembers), + ]) + return results.flat() + } + + private async addMissingProjectMembers( + project: ProjectWithDetails, + projectGroup: GroupRepresentation, + members: UserRepresentation[], + ) { + const promises = project.members.map(async (member) => { + if (!members.some(m => m.id === member.user.id)) { + if (member.user.id && projectGroup.id) { + await this.keycloakService.addUserToGroup(member.user.id, projectGroup.id) + } + this.logger.log(`Added ${member.user.email} to keycloak project group ${projectGroup.name}`) + } + }) + return Promise.allSettled([ + ...promises, + this.addMissingOwner(project, projectGroup, members), + ]) + } + + private async addMissingOwner( + project: ProjectWithDetails, + projectGroup: GroupRepresentation, + members: UserRepresentation[], + ) { + if (!projectGroup.id) { + throw new Error(`Failed to create or retrieve project group for ${project.slug}`) + } + if (!members.some(m => m.id === project.ownerId)) { + await this.keycloakService.addUserToGroup(project.ownerId, projectGroup.id) + this.logger.log(`Added owner ${project.ownerId} to keycloak project group ${projectGroup.name}`) + } + } + + private async deleteExtraProjectMembers( + project: ProjectWithDetails, + projectGroup: GroupRepresentation, + members: UserRepresentation[], + ) { + if (!projectGroup.id) { + throw new Error(`Failed to create or retrieve project group for ${project.slug}`) + } + const promises = members.map(async (member) => { + const isMember = project.members.some(m => m.user.id === member.id) || project.ownerId === member.id + if (!isMember) { + if (this.configService.keycloakControllerPurgeOrphans) { + await this.keycloakService.removeUserFromGroup(member.id!, projectGroup.id!) + this.logger.log(`Removed ${member.email} from keycloak project group ${projectGroup.name}`) + } else { + this.logger.warn(`User ${member.email} is in Keycloak group but not in project ${project.slug} (purge disabled)`) + } + } + }) + return Promise.allSettled(promises) + } + + private async ensureProjectRoleGroups(project: ProjectWithDetails, projectGroup: GroupRepresentation): Promise[]> { + if (!projectGroup.id) { + return [{ status: 'rejected', reason: new Error(`Failed to create or retrieve project group for ${project.slug}`) }] + } + const results = await Promise.all(project.roles.map(async (role) => { + if (role.oidcGroup) { + try { + const roleGroup = await this.keycloakService.getOrCreateGroupByPath(role.oidcGroup) + if (!roleGroup.id) { + throw new Error(`Failed to create or retrieve role group for ${role.oidcGroup}`) + } + const groupMembers = await this.keycloakService.getGroupMembers(roleGroup.id) + const results = await Promise.all([ + this.addMissingRoleMembers(roleGroup, project, role, groupMembers), + this.deleteExtraRoleMembers(roleGroup, project, role, groupMembers), + ]) + return results.flat() + } catch (error) { + return [{ status: 'rejected', reason: error }] as PromiseSettledResult[] + } + } + return [] + })) + return results.flat() + } + + private async addMissingRoleMembers( + roleGroup: GroupRepresentation, + project: ProjectWithDetails, + role: ProjectWithDetails['roles'][number], + members: UserRepresentation[], + ) { + if (!roleGroup.id) { + throw new Error(`Failed to create or retrieve role group for ${role.oidcGroup}`) + } + return Promise.allSettled(project.members.map(async (member) => { + if (!members.some(m => m.id === member.user.id) && member.roleIds.includes(role.id)) { + await this.keycloakService.addUserToGroup(member.user.id, roleGroup.id!) + this.logger.log(`Added ${member.user.email} to keycloak role group ${roleGroup.name}`) + } + })) + } + + private async deleteExtraRoleMembers( + roleGroup: GroupRepresentation, + project: ProjectWithDetails, + role: ProjectWithDetails['roles'][number], + members: UserRepresentation[], + ) { + if (!roleGroup.id) { + throw new Error(`Failed to create or retrieve role group for ${role.oidcGroup}`) + } + return Promise.allSettled(members.map(async (member) => { + const isMember = project.members.some(m => m.user.id === member.id) || project.ownerId === member.id + if (!isMember && member.groups?.some(g => g === roleGroup.path)) { + if (this.configService.keycloakControllerPurgeOrphans) { + await this.keycloakService.removeUserFromGroup(member.id!, roleGroup.id!) + this.logger.log(`Removed ${member.email} from keycloak role group ${roleGroup.name}`) + } else { + this.logger.warn(`User ${member.email} is in Keycloak group but not in project ${project.slug} (purge disabled)`) + } + } + })) + } + + private async ensureEnvironmentGroups(project: ProjectWithDetails, projectGroup: GroupRepresentation): Promise[]> { + try { + const consoleGroup = await this.keycloakService.getOrCreateConsoleGroup(projectGroup) + const envResults = await Promise.all(project.environments.map(environment => + this.ensureEnvironmentGroup(consoleGroup, environment, project))) + const orphanResults = await this.purgeOrphanEnvironmentGroups(consoleGroup, project) + return [...envResults.flat(), ...orphanResults] + } catch (error) { + return [{ status: 'rejected', reason: error }] satisfies PromiseSettledResult[] + } + } + + private async purgeOrphanEnvironmentGroups(consoleGroup: GroupRepresentation, project: ProjectWithDetails) { + if (!consoleGroup.id) { + throw new Error(`Failed to create or retrieve console group for ${project.slug}`) + } + const promises: Promise[] = [] + for await (const envGroup of this.keycloakService.getSubGroups(consoleGroup.id)) { + if (!this.isOwnedEnvironmentGroup(envGroup, project) && envGroup.id) { + if (this.configService.keycloakControllerPurgeOrphans) { + promises.push( + this.keycloakService.deleteGroup(envGroup.id) + .catch(e => this.logger.warn(`Failed to delete environment group ${envGroup.name}`, e)), + ) + } else { + this.logger.warn(`Environment group ${envGroup.name} detected but purge is disabled`) + } + } + } + return Promise.allSettled(promises) + } + + private isOwnedEnvironmentGroup( + envGroup: GroupRepresentation, + project: ProjectWithDetails, + ) { + return project.environments.some(e => e.name === envGroup.name) + } + + private async ensureEnvironmentGroup( + consoleGroup: GroupRepresentation, + environment: ProjectWithDetails['environments'][number], + project: ProjectWithDetails, + ) { + const { roGroup, rwGroup } = await this.keycloakService.getOrCreateEnvironmentGroups(consoleGroup, environment) + if (!roGroup.id || !rwGroup.id) { + throw new Error(`Failed to create or retrieve RO and RW groups for ${environment.name}`) + } + + const rolesById = resourceListToDict(project.roles) + + // Get current members of RO and RW groups to ensure we clean up removed users + const [roMembers, rwMembers] = await Promise.all([ + this.keycloakService.getGroupMembers(roGroup.id), + this.keycloakService.getGroupMembers(rwGroup.id), + ]) + + const results = await Promise.all([ + this.ensureEnvironmentMemberPermissions( + environment, + project, + rolesById, + roGroup, + rwGroup, + roMembers, + rwMembers, + ), + this.purgeOrphanMembersFromEnvironment( + environment, + project, + roGroup, + rwGroup, + roMembers, + rwMembers, + ), + ]) + return results.flat() + } + + private async ensureEnvironmentMemberPermissions( + environment: ProjectWithDetails['environments'][number], + project: ProjectWithDetails, + rolesById: Record, + roGroup: GroupRepresentation, + rwGroup: GroupRepresentation, + roMembers: UserRepresentation[], + rwMembers: UserRepresentation[], + ) { + if (!roGroup.id || !rwGroup.id) { + throw new Error(`Failed to create or retrieve RO and RW groups for ${environment.name}`) + } + + const projectUserIds = new Set([project.ownerId, ...project.members.map(m => m.user.id)]) + + return Promise.allSettled(Array.from(projectUserIds).map(async (userId) => { + const perms = this.getUserPermissions(userId, project, rolesById) + + // Sync RO + const isInRo = roMembers.some(m => m.id === userId) + if (perms.ro && !isInRo) { + await this.keycloakService.addUserToGroup(userId, roGroup.id!) + this.logger.log(`User ${userId} added to RO group for ${environment.name}`) + } else if (!perms.ro && isInRo) { + if (this.configService.keycloakControllerPurgeOrphans) { + await this.keycloakService.removeUserFromGroup(userId, roGroup.id!) + this.logger.log(`User ${userId} removed from RO group for ${environment.name}`) + } else { + this.logger.warn(`User ${userId} has no RO permission but is in RO group for ${environment.name} (purge disabled)`) + } + } + + // Sync RW + const isInRw = rwMembers.some(m => m.id === userId) + if (perms.rw && !isInRw) { + await this.keycloakService.addUserToGroup(userId, rwGroup.id!) + this.logger.log(`User ${userId} added to RW group for ${environment.name}`) + } else if (!perms.rw && isInRw) { + if (this.configService.keycloakControllerPurgeOrphans) { + await this.keycloakService.removeUserFromGroup(userId, rwGroup.id!) + this.logger.log(`User ${userId} removed from RW group for ${environment.name}`) + } else { + this.logger.warn(`User ${userId} has no RW permission but is in RW group for ${environment.name} (purge disabled)`) + } + } + })) + } + + private async purgeOrphanMembersFromEnvironment( + environment: ProjectWithDetails['environments'][number], + project: ProjectWithDetails, + roGroup: GroupRepresentation, + rwGroup: GroupRepresentation, + roMembers: UserRepresentation[], + rwMembers: UserRepresentation[], + ) { + if (!roGroup.id || !rwGroup.id) { + throw new Error(`Failed to create or retrieve RO and RW groups for ${environment.name}`) + } + + const projectUserIds = new Set([project.ownerId, ...project.members.map(m => m.user.id)]) + + const roPromises = roMembers.map(async (member) => { + if (!projectUserIds.has(member.id!)) { + if (this.configService.keycloakControllerPurgeOrphans) { + await this.keycloakService.removeUserFromGroup(member.id!, roGroup.id!) + this.logger.log(`User ${member.id} removed from RO group for ${environment.name}`) + } else { + this.logger.warn(`User ${member.id} is in RO group for ${environment.name} but not in project (purge disabled)`) + } + } + }) + + const rwPromises = rwMembers.map(async (member) => { + if (!projectUserIds.has(member.id!)) { + if (this.configService.keycloakControllerPurgeOrphans) { + await this.keycloakService.removeUserFromGroup(member.id!, rwGroup.id!) + this.logger.log(`User ${member.id} removed from RW group for ${environment.name}`) + } else { + this.logger.warn(`User ${member.id} is in RW group for ${environment.name} but not in project (purge disabled)`) + } + } + }) + + return Promise.allSettled([...roPromises, ...rwPromises]) + } + + private getUserPermissions(userId: string, project: ProjectWithDetails, rolesById: Record) { + if (userId === project.ownerId) return { ro: true, rw: true } + const member = project.members.find(m => m.user.id === userId) + if (!member) return { ro: false, rw: false } + + const projectPermissions = getPermsByUserRoles(member.roleIds, rolesById, project.everyonePerms) + + return { + ro: ProjectAuthorized.ListEnvironments({ adminPermissions: 0n, projectPermissions }), + rw: ProjectAuthorized.ManageEnvironments({ adminPermissions: 0n, projectPermissions }), + } + } +} diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak-datastore.service.ts b/apps/server-nestjs/src/modules/keycloak/keycloak-datastore.service.ts new file mode 100644 index 0000000000..46801787df --- /dev/null +++ b/apps/server-nestjs/src/modules/keycloak/keycloak-datastore.service.ts @@ -0,0 +1,51 @@ +import { Injectable, Logger } from '@nestjs/common' +import type { Prisma } from '@prisma/client' +import type { PrismaService } from '@/cpin-module/infrastructure/database/prisma.service' + +export const projectSelect = { + id: true, + slug: true, + ownerId: true, + everyonePerms: true, + members: { + select: { + roleIds: true, + user: { + select: { + id: true, + email: true, + }, + }, + }, + }, + roles: { + select: { + id: true, + permissions: true, + oidcGroup: true, + }, + }, + environments: { + select: { + id: true, + name: true, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +@Injectable() +export class KeycloakDatastoreService { + private readonly logger = new Logger(KeycloakDatastoreService.name) + + constructor(private readonly prisma: PrismaService) {} + + async getAllProjects(): Promise { + return this.prisma.project.findMany({ + select: projectSelect, + }) + } +} diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak.module.ts b/apps/server-nestjs/src/modules/keycloak/keycloak.module.ts new file mode 100644 index 0000000000..4275271c28 --- /dev/null +++ b/apps/server-nestjs/src/modules/keycloak/keycloak.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common' +import { KeycloakService } from './keycloak.service' +import { KeycloakControllerService } from './keycloak-controller.service' +import { KeycloakDatastoreService } from './keycloak-datastore.service' +import { KeycloakClientService } from './keycloak-client.service' +import { ConfigurationModule } from '../../cpin-module/infrastructure/configuration/configuration.module' +import { InfrastructureModule } from '../../cpin-module/infrastructure/infrastructure.module' + +@Module({ + imports: [ConfigurationModule, InfrastructureModule], + providers: [KeycloakService, KeycloakControllerService, KeycloakDatastoreService, KeycloakClientService], + exports: [KeycloakService], +}) +export class KeycloakModule {} diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts b/apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts new file mode 100644 index 0000000000..61baa060d9 --- /dev/null +++ b/apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts @@ -0,0 +1,75 @@ +import { Test, type TestingModule } from '@nestjs/testing' +import { KeycloakService } from './keycloak.service' +import { KeycloakClientService } from './keycloak-client.service' +import { ConfigurationService } from '@/cpin-module/infrastructure/configuration/configuration.service' +import { describe, it, expect, beforeEach } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import type GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation' + +const keycloakMock = mockDeep() + +function createKeycloakServiceTestModule() { + return Test.createTestingModule({ + providers: [ + KeycloakService, + { + provide: KeycloakClientService, + useValue: keycloakMock, + }, + { + provide: ConfigurationService, + useValue: mockDeep(), + }, + ], + }) +} + +describe('keycloakService', () => { + let service: KeycloakService + + beforeEach(async () => { + const module: TestingModule = await createKeycloakServiceTestModule().compile() + service = module.get(KeycloakService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('getOrCreateGroupByPath', () => { + it('should return existing group if found by path', async () => { + const groupA: GroupRepresentation = { id: 'id-a', name: 'a' } + const groupB: GroupRepresentation = { id: 'id-b', name: 'b', path: '/a/b' } + + // First call to getGroupByName('a') + keycloakMock.groups.find.mockResolvedValueOnce([groupA]) + + // Call to getSubGroups('id-a') + keycloakMock.groups.listSubGroups.mockResolvedValueOnce([groupB]) + + // When checking 'b', it matches groupB.name + const result = await service.getOrCreateGroupByPath('a/b') + + expect(result).toEqual(groupB) + }) + + it('should create groups if they do not exist', async () => { + // At the first call to getGroupByName('new'), it returns empty + keycloakMock.groups.find.mockResolvedValueOnce([]) + + // Create 'new' group + keycloakMock.groups.find.mockResolvedValueOnce([]) + keycloakMock.groups.create.mockResolvedValue({ id: 'id-new' }) + + // Create 'group' subgroup + keycloakMock.groups.listSubGroups.mockResolvedValueOnce([]) + keycloakMock.groups.createChildGroup.mockResolvedValue({ id: 'id-group' }) + + const result = await service.getOrCreateGroupByPath('new/group') + + expect(result).toEqual({ id: 'id-group' }) + expect(keycloakMock.groups.create).toHaveBeenCalledWith({ name: 'new' }) + expect(keycloakMock.groups.createChildGroup).toHaveBeenCalledWith({ id: 'id-new' }, { name: 'group' }) + }) + }) +}) diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts b/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts new file mode 100644 index 0000000000..f71e5126b1 --- /dev/null +++ b/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts @@ -0,0 +1,146 @@ +import { Inject, Injectable } from '@nestjs/common' +import type GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation' +import type { ProjectWithDetails } from './keycloak-datastore.service' +import { CONSOLE_GROUP_NAME } from './keycloak.constant' +import { KeycloakClientService } from './keycloak-client.service' + +@Injectable() +export class KeycloakService { + constructor( + @Inject(KeycloakClientService) private readonly client: KeycloakClientService, + ) { + } + + async *getAllGroups() { + let first = 0 + while (true) { + const fetched = await this.client.groups.find({ first, max: 50, briefRepresentation: false }) + if (fetched.length === 0) break + for (const group of fetched) { + yield group + } + if (fetched.length < 50) break + first += 50 + } + } + + // TODO: May return undefined if group not found in the most recent search + async getGroupByName(name: string): Promise { + const groups = await this.client.groups.find({ search: name }) + return groups.find(g => g.name === name) + } + + async getGroupByPath(path: string): Promise { + const parts = path.split('/').filter(Boolean) + let current: GroupRepresentation | undefined + for (const name of parts) { + if (!current) { + current = await this.getGroupByName(name) + } else { + for await (const subgroup of this.getSubGroups(current.id!)) { + if (subgroup.name === name) { + current = subgroup + break + } + } + if (current?.name !== name) return undefined + } + if (!current) return undefined + } + return current + } + + async deleteGroup(id: string): Promise { + await this.client.groups.del({ id }) + } + + async getGroupMembers(groupId: string) { + return this.client.groups.listMembers({ id: groupId }) + } + + async createGroup(name: string) { + return this.client.groups.create({ name }) + } + + async addUserToGroup(userId: string, groupId: string) { + return this.client.users.addToGroup({ id: userId, groupId }) + } + + async removeUserFromGroup(userId: string, groupId: string) { + return this.client.users.delFromGroup({ id: userId, groupId }) + } + + async* getSubGroups(parentId: string) { + let first = 0 + while (true) { + const page = await this.client.groups.listSubGroups({ parentId, briefRepresentation: false, max: 10, first }) + if (page.length === 0) break + for (const subgroup of page) { + yield subgroup + } + if (page.length < 10) break + first += 10 + } + } + + async getOrCreateGroupByPath(path: string) { + const existingGroup = await this.getGroupByPath(path) + if (existingGroup) return existingGroup + + const parts = path.split('/').filter(Boolean) + let parentId: string | undefined + let current: GroupRepresentation | undefined + + for (let i = 0; i < parts.length; i++) { + const name = parts[i] + if (!current) { + current = await this.getGroupByName(name) + if (!current) { + current = await this.createGroup(name) + } + } else { + if (!parentId) parentId = current.id! + current = await this.getOrCreateSubGroupByName(parentId, name) + } + parentId = current.id! + } + + return { id: parentId } satisfies GroupRepresentation + } + + async getOrCreateSubGroupByName(parentId: string, name: string) { + for await (const subgroup of this.getSubGroups(parentId)) { + if (subgroup.name === name) return subgroup + } + const createdGroup = await this.client.groups.createChildGroup({ id: parentId }, { name }) + return { id: createdGroup.id } satisfies GroupRepresentation + } + + async getOrCreateConsoleGroup(projectGroup: GroupRepresentation) { + if (!projectGroup.id) { + throw new Error(`Failed to create or retrieve project group for ${projectGroup.name}`) + } + return this.getOrCreateSubGroupByName(projectGroup.id, CONSOLE_GROUP_NAME) + } + + async getOrCreateEnvironmentGroups(consoleGroup: GroupRepresentation, environment: ProjectWithDetails['environments'][number]) { + if (!consoleGroup.id) { + throw new Error(`Failed to create or retrieve console group for ${consoleGroup.name}`) + } + + const envGroup = await this.getOrCreateSubGroupByName(consoleGroup.id, environment.name) + if (!envGroup.id) { + throw new Error(`Failed to create or retrieve environment group for ${environment.name}`) + } + + const [roGroup, rwGroup] = await Promise.all([ + this.getOrCreateSubGroupByName(envGroup.id, 'RO'), + this.getOrCreateSubGroupByName(envGroup.id, 'RW'), + ]) + if (!roGroup.id || !rwGroup.id) { + throw new Error(`Failed to create or retrieve RO and RW groups for ${environment.name}`) + } + + return { roGroup, rwGroup } + } +} diff --git a/apps/server-nestjs/vitest.config.ts b/apps/server-nestjs/vitest.config.ts new file mode 100644 index 0000000000..edeacaacb4 --- /dev/null +++ b/apps/server-nestjs/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vitest/config' +import path from 'node:path' + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/**/*.spec.ts', 'test/**/*.e2e-spec.ts'], + alias: { + '@': path.resolve(__dirname, './src'), + }, + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + }, + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 947571d75a..6dc9312a28 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -359,6 +359,12 @@ importers: apps/server-nestjs: dependencies: + '@casl/ability': + specifier: ^6.7.1 + version: 6.8.0 + '@casl/prisma': + specifier: ^1.5.0 + version: 1.6.1(@casl/ability@6.8.0)(@prisma/client@6.19.0(prisma@6.19.0(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3)) '@cpn-console/argocd-plugin': specifier: workspace:^ version: file:plugins/argocd(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(terser@5.44.1)) @@ -407,6 +413,9 @@ importers: '@gitbeaker/rest': specifier: ^40.6.0 version: 40.6.0 + '@keycloak/keycloak-admin-client': + specifier: ^24.0.0 + version: 24.0.5 '@kubernetes-models/argo-cd': specifier: ^2.6.2 version: 2.7.2 @@ -419,9 +428,15 @@ importers: '@nestjs/core': specifier: ^11.0.1 version: 11.1.11(@nestjs/common@11.1.11(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.11)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/event-emitter': + specifier: ^3.0.1 + version: 3.0.1(@nestjs/common@11.1.11(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.11) '@nestjs/platform-express': specifier: ^11.0.1 version: 11.1.11(@nestjs/common@11.1.11(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.11) + '@nestjs/schedule': + specifier: ^5.0.1 + version: 5.0.1(@nestjs/common@11.1.11(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.11) '@prisma/client': specifier: ^6.0.1 version: 6.19.0(prisma@6.19.0(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3) @@ -452,9 +467,15 @@ importers: json-2-csv: specifier: ^5.5.7 version: 5.5.10 + keycloak-connect: + specifier: ^25.0.0 + version: 25.0.6 mustache: specifier: ^4.2.0 version: 4.2.0 + nest-keycloak-connect: + specifier: ^1.10.1 + version: 1.10.1(@nestjs/common@11.1.11(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.11)(keycloak-connect@25.0.6) nestjs-pino: specifier: ^4.5.0 version: 4.5.0(@nestjs/common@11.1.11(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.1.0)(rxjs@7.8.2) @@ -1818,6 +1839,15 @@ packages: '@cacheable/utils@2.2.0': resolution: {integrity: sha512-7xaQayO3msdVcxXLYcLU5wDqJBNdQcPPPHr6mdTEIQI7N7TbtSVVTpWOTfjyhg0L6AQwQdq7miKdWtTDBoBldQ==} + '@casl/ability@6.8.0': + resolution: {integrity: sha512-Ipt4mzI4gSgnomFdaPjaLgY2MWuXqAEZLrU6qqWBB7khGiBBuuEp6ytYDnq09bRXqcjaeeHiaCvCGFbBA2SpvA==} + + '@casl/prisma@1.6.1': + resolution: {integrity: sha512-VSAzfTMOZvP3Atj3F0qwJItOm1ixIiumjbBz21PL/gLUIDwoktyAx2dB7dPwjH9AQvzZPE629ee7fVU5K2hpzg==} + peerDependencies: + '@casl/ability': ^5.3.0 || ^6.0.0 + '@prisma/client': ^2.14.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@clack/core@0.4.1': resolution: {integrity: sha512-Pxhij4UXg8KSr7rPek6Zowm+5M22rbd2g1nfojHJkxp5YkFqiZ2+YLEM/XGVIzvGOcM0nqjIFxrpDwWRZYWYjA==} @@ -2751,6 +2781,10 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@keycloak/keycloak-admin-client@24.0.5': + resolution: {integrity: sha512-SXDVtQ3ov7GQbxXq51Uq8lzhwzQwNg6XiY50ZA9whuUe2t/0zPT4Zd/LcULcjweIjSNWWgfbDyN1E3yRSL8Qqw==} + engines: {node: '>=18'} + '@keycloak/keycloak-admin-client@26.4.2': resolution: {integrity: sha512-BDZuV+s9XoYSElHmG/Ul6r/uHzbExRSC3jybBR9CHZ9JDad1PcVwpSVSBTqiJhF7P1OtMm1gnLLS1TMO/QY+8Q==} engines: {node: '>=18'} @@ -2841,12 +2875,24 @@ packages: '@nestjs/websockets': optional: true + '@nestjs/event-emitter@3.0.1': + resolution: {integrity: sha512-0Ln/x+7xkU6AJFOcQI9tIhUMXVF7D5itiaQGOyJbXtlAfAIt8gzDdJm+Im7cFzKoWkiW5nCXCPh6GSvdQd/3Dw==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + '@nestjs/platform-express@11.1.11': resolution: {integrity: sha512-kyABSskdMRIAMWL0SlbwtDy4yn59RL4HDdwHDz/fxWuv7/53YP8Y2DtV3/sHqY5Er0msMVTZrM38MjqXhYL7gw==} peerDependencies: '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 + '@nestjs/schedule@5.0.1': + resolution: {integrity: sha512-kFoel84I4RyS2LNPH6yHYTKxB16tb3auAEciFuc788C3ph6nABkUfzX5IE+unjVaRX+3GuruJwurNepMlHXpQg==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + '@nestjs/schematics@11.0.9': resolution: {integrity: sha512-0NfPbPlEaGwIT8/TCThxLzrlz3yzDNkfRNpbL7FiplKq3w4qXpJg0JYwqgMEJnLQZm3L/L/5XjoyfJHUO3qX9g==} peerDependencies: @@ -3127,6 +3173,9 @@ packages: '@swc/helpers@0.5.17': resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==} + '@testim/chrome-version@1.1.4': + resolution: {integrity: sha512-kIhULpw9TrGYnHp/8VfdcneIcxKnLixmADtukQRtJUmsVlMg0niMkwV0xZmi8hqa57xqilIHjWFA0GKvEjVU5g==} + '@tokenizer/inflate@0.4.1': resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} engines: {node: '>=18'} @@ -3134,6 +3183,9 @@ packages: '@tokenizer/token@0.3.0': resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@tootallnate/quickjs-emscripten@0.23.0': + resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} + '@ts-rest/core@3.52.1': resolution: {integrity: sha512-tAjz7Kxq/grJodcTA1Anop4AVRDlD40fkksEV5Mmal88VoZeRKAG8oMHsDwdwPZz+B/zgnz0q2sF+cm5M7Bc7g==} peerDependencies: @@ -3248,6 +3300,9 @@ packages: '@types/lodash@4.17.20': resolution: {integrity: sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==} + '@types/luxon@3.4.2': + resolution: {integrity: sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -3379,6 +3434,18 @@ packages: resolution: {integrity: sha512-uk574k8IU0rOF/AjniX8qbLSGURJVUCeM5e4MIMKBFFi8weeiLrG1fyQejyLXQpRZbU/1BuQasleV/RfHC3hHg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@ucast/core@1.10.2': + resolution: {integrity: sha512-ons5CwXZ/51wrUPfoduC+cO7AS1/wRb0ybpQJ9RrssossDxVy4t49QxWoWgfBDvVKsz9VXzBk9z0wqTdZ+Cq8g==} + + '@ucast/js@3.1.0': + resolution: {integrity: sha512-eJ7yQeYtMK85UZjxoxBEbTWx6UMxEXKbjVyp+NlzrT5oMKV5Gpo/9bjTl3r7msaXTVC8iD9NJacqJ8yp7joX+Q==} + + '@ucast/mongo2js@1.4.1': + resolution: {integrity: sha512-9aeg5cmqwRQnKCXHN6I17wk83Rcm487bHelaG8T4vfpWneAI469wSI3Srnbu+PuZ5znWRbnwtVq9RgPL+bN6CA==} + + '@ucast/mongo@2.4.3': + resolution: {integrity: sha512-XcI8LclrHWP83H+7H2anGCEeDq0n+12FU2mXCTz6/Tva9/9ddK/iacvvhCyW6cijAAOILmt0tWplRyRhVyZLsA==} + '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} @@ -3941,6 +4008,10 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-types@0.13.4: + resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} + engines: {node: '>=4'} + astral-regex@2.0.0: resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} engines: {node: '>=8'} @@ -3984,6 +4055,9 @@ packages: axios@1.12.2: resolution: {integrity: sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==} + axios@1.13.5: + resolution: {integrity: sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==} + babel-jest@30.2.0: resolution: {integrity: sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -4037,6 +4111,10 @@ packages: resolution: {integrity: sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==} hasBin: true + basic-ftp@5.2.0: + resolution: {integrity: sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==} + engines: {node: '>=10.0.0'} + bcrypt-pbkdf@1.0.2: resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} @@ -4221,6 +4299,11 @@ packages: resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} engines: {node: '>=6.0'} + chromedriver@146.0.0: + resolution: {integrity: sha512-fDAbuEy+Dn9F/h8fphiQIUEyUDOTGlfjZHfI9dJZz75+ui/LIHqWzStQt87vpwA9oV3ut4C2W3flfvbn3KELFQ==} + engines: {node: '>=20'} + hasBin: true + ci-info@4.3.1: resolution: {integrity: sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==} engines: {node: '>=8'} @@ -4350,6 +4433,9 @@ packages: compare-func@2.0.0: resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} + compare-versions@6.1.1: + resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} + component-emitter@1.3.1: resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} @@ -4457,6 +4543,9 @@ packages: cron-validator@1.4.0: resolution: {integrity: sha512-wGcJ9FCy65iaU6egSH8b5dZYJF7GU/3Jh06wzaT9lsa5dbqExjljmu+0cJ8cpKn+vUyZa/EM4WAxeLR6SypJXw==} + cron@3.5.0: + resolution: {integrity: sha512-0eYZqCnapmxYcV06uktql93wNWdlTmmBFP2iYz+JPVcQqlyFYcn1lFuIk4R54pkOmE7mcldTAPZv6X5XA4Q46A==} + cross-spawn@6.0.6: resolution: {integrity: sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==} engines: {node: '>=4.8'} @@ -4507,6 +4596,10 @@ packages: resolution: {integrity: sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==} engines: {node: '>=0.10'} + data-uri-to-buffer@6.0.2: + resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} + engines: {node: '>= 14'} + data-urls@5.0.0: resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} engines: {node: '>=18'} @@ -4543,6 +4636,15 @@ packages: supports-color: optional: true + debug@4.3.1: + resolution: {integrity: sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -4599,6 +4701,10 @@ packages: defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + degenerator@5.0.1: + resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==} + engines: {node: '>= 14'} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -4830,6 +4936,11 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} + escodegen@2.1.0: + resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} + engines: {node: '>=6.0'} + hasBin: true + eslint-compat-utils@0.5.1: resolution: {integrity: sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==} engines: {node: '>=12'} @@ -5068,6 +5179,9 @@ packages: eventemitter2@6.4.7: resolution: {integrity: sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg==} + eventemitter2@6.4.9: + resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==} + eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} @@ -5275,6 +5389,15 @@ packages: focus-trap@7.6.6: resolution: {integrity: sha512-v/Z8bvMCajtx4mEXmOo7QEsIzlIOqRXTIwgUfsFOF9gEsespdbD0AkPIka1bSXZ8Y8oZ+2IVDQZePkTfEHZl7Q==} + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + follow-redirects@1.15.9: resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} engines: {node: '>=4.0'} @@ -5413,6 +5536,10 @@ packages: get-tsconfig@4.13.0: resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + get-uri@6.0.5: + resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==} + engines: {node: '>= 14'} + getpass@0.1.7: resolution: {integrity: sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==} @@ -5441,11 +5568,13 @@ packages: glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@11.0.3: resolution: {integrity: sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==} engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@13.0.0: @@ -5454,7 +5583,7 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me global-directory@4.0.1: resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==} @@ -5727,6 +5856,10 @@ packages: peerDependencies: fp-ts: ^2.5.0 + ip-address@10.1.0: + resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} + engines: {node: '>= 12'} + ip-regex@4.3.0: resolution: {integrity: sha512-B9ZWJxHHOHUhUjCPrMpLD4xEq35bUTClHM1S6CBU5ixQnkZmwipwgc96vAd7AAGM9TGHvJR+Uss+/Ak6UphK+Q==} engines: {node: '>=8'} @@ -5914,6 +6047,9 @@ packages: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} + is-url@1.2.4: + resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==} + is-weakmap@2.0.2: resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} engines: {node: '>= 0.4'} @@ -5926,6 +6062,10 @@ packages: resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} engines: {node: '>= 0.4'} + is2@2.0.9: + resolution: {integrity: sha512-rZkHeBn9Zzq52sd9IUIV3a5mfwBY+o2HePMh0wkGBM4z4qjvy2GwVxQ6nNXSfw6MmVP6gf1QIlWjiOavhM3x5g==} + engines: {node: '>=v0.10.0'} + isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} @@ -6238,6 +6378,10 @@ packages: jws@4.0.0: resolution: {integrity: sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==} + keycloak-connect@25.0.6: + resolution: {integrity: sha512-UbOj4ee2u1LYNff5rkcVuWxc/GTaoga6TKg+/ylJd7djaGh20HVI3qmAVxfGme3BZPIa6/pxEIDpK4KQn+xx1w==} + engines: {node: '>=14'} + keycloak-js@26.2.1: resolution: {integrity: sha512-bZt6fQj/TLBAmivXSxSlqAJxBx/knNZDQGJIW4ensGYGN4N6tUKV8Zj3Y7/LOV8eIpvWsvqV70fbACihK8Ze0Q==} @@ -6401,6 +6545,14 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lru-cache@7.18.3: + resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} + engines: {node: '>=12'} + + luxon@3.5.0: + resolution: {integrity: sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==} + engines: {node: '>=12'} + magic-string@0.25.9: resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==} @@ -6689,6 +6841,9 @@ packages: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} + ms@2.1.2: + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -6745,6 +6900,17 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + nest-keycloak-connect@1.10.1: + resolution: {integrity: sha512-tvAYOTPFnxDnQI06jrtcJa6UhyqVtah6V/XwRrNCCL2mklPYnfllGMgVJX0sc3Mca5yJiTVDZOoWruSxnM5qtg==} + peerDependencies: + '@nestjs/common': '>=6.0.0 <11.0.0' + '@nestjs/core': '>=6.0.0 <11.0.0' + '@nestjs/graphql': '>=6' + keycloak-connect: '>=10.0.0' + peerDependenciesMeta: + '@nestjs/graphql': + optional: true + nestjs-pino@4.5.0: resolution: {integrity: sha512-e54ChJMACSGF8gPYaHsuD07RW7l/OVoV6aI8Hqhpp0ZQ4WA8QY3eewL42JX7Z1U6rV7byNU7bGBV9l6d9V6PDQ==} engines: {node: '>= 14'} @@ -6754,6 +6920,10 @@ packages: pino-http: ^6.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 rxjs: ^7.1.0 + netmask@2.0.2: + resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==} + engines: {node: '>= 0.4.0'} + nice-try@1.0.5: resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} @@ -6948,6 +7118,14 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} + pac-proxy-agent@7.2.0: + resolution: {integrity: sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==} + engines: {node: '>= 14'} + + pac-resolver@7.0.1: + resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==} + engines: {node: '>= 14'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -7233,12 +7411,19 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + proxy-agent@6.5.0: + resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==} + engines: {node: '>= 14'} + proxy-from-env@1.0.0: resolution: {integrity: sha512-F2JHgJQ1iqwnHDcQjVBsq3n/uoaFL+iPW/eAeL7kVxy/2RrWaN4WroKjjvbsoRtv0ftelNyC01bjRhn/bhcf4A==} proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + proxy-from-env@2.0.0: + resolution: {integrity: sha512-h2lD3OfRraP3R51rNFKIE8nX+qoLr1mE74X91YhVxtDbt+OD6ntoNZv56+JgI4RCdtwQ5eexsOk1KdOQDfvPCQ==} + pstree.remy@1.1.8: resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} @@ -7688,6 +7873,10 @@ packages: resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} engines: {node: '>=18'} + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + smob@1.5.0: resolution: {integrity: sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==} @@ -7695,6 +7884,14 @@ packages: resolution: {integrity: sha512-Gz11jbNU0plrReU9Sj7fmshSBxxJ9ShdD2q4ktHIHo/rpTH6lFyQoYHYKINPJtPe8aHFnsbtW46Ls0tCCBsIZg==} engines: {node: '>=0.10'} + socks-proxy-agent@8.0.5: + resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} + engines: {node: '>= 14'} + + socks@2.8.7: + resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + sonic-boom@4.2.0: resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} @@ -7927,6 +8124,7 @@ packages: supertest@7.2.1: resolution: {integrity: sha512-/OfhUL9WRLfoovZuWJ4l+2GVz3Eoo8Eo2TZVs9QxF2kmxdrmK7rCww4iJBstHevUH/M44aJ9TMN7yB+W+oWxlA==} engines: {node: '>=14.18.0'} + deprecated: Please upgrade to v7.2.2+ as we fixed an issue https://github.com/forwardemail/supertest/issues/875 supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} @@ -7991,6 +8189,9 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + tcp-port-used@1.0.2: + resolution: {integrity: sha512-l7ar8lLUD3XS1V2lfoJlCBaeoaWo/2xfYt81hM7VlvR4RrMVFqfmzfhLVk40hAb368uitje5gPtBRL1m/DGvLA==} + temp-dir@2.0.0: resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==} engines: {node: '>=8'} @@ -8716,6 +8917,7 @@ packages: whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} @@ -9854,6 +10056,17 @@ snapshots: dependencies: keyv: 5.5.3 + '@casl/ability@6.8.0': + dependencies: + '@ucast/mongo2js': 1.4.1 + + '@casl/prisma@1.6.1(@casl/ability@6.8.0)(@prisma/client@6.19.0(prisma@6.19.0(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))': + dependencies: + '@casl/ability': 6.8.0 + '@prisma/client': 6.19.0(prisma@6.19.0(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3) + '@ucast/core': 1.10.2 + '@ucast/js': 3.1.0 + '@clack/core@0.4.1': dependencies: picocolors: 1.1.1 @@ -10952,6 +11165,12 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@keycloak/keycloak-admin-client@24.0.5': + dependencies: + camelize-ts: 3.0.0 + url-join: 5.0.0 + url-template: 3.1.1 + '@keycloak/keycloak-admin-client@26.4.2': dependencies: camelize-ts: 3.0.0 @@ -11066,6 +11285,12 @@ snapshots: optionalDependencies: '@nestjs/platform-express': 11.1.11(@nestjs/common@11.1.11(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.11) + '@nestjs/event-emitter@3.0.1(@nestjs/common@11.1.11(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.11)': + dependencies: + '@nestjs/common': 11.1.11(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.11(@nestjs/common@11.1.11(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.11)(reflect-metadata@0.2.2)(rxjs@7.8.2) + eventemitter2: 6.4.9 + '@nestjs/platform-express@11.1.11(@nestjs/common@11.1.11(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.11)': dependencies: '@nestjs/common': 11.1.11(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -11078,6 +11303,12 @@ snapshots: transitivePeerDependencies: - supports-color + '@nestjs/schedule@5.0.1(@nestjs/common@11.1.11(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.11)': + dependencies: + '@nestjs/common': 11.1.11(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.11(@nestjs/common@11.1.11(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.11)(reflect-metadata@0.2.2)(rxjs@7.8.2) + cron: 3.5.0 + '@nestjs/schematics@11.0.9(chokidar@4.0.3)(typescript@5.9.3)': dependencies: '@angular-devkit/core': 19.2.17(chokidar@4.0.3) @@ -11324,6 +11555,9 @@ snapshots: dependencies: tslib: 2.8.1 + '@testim/chrome-version@1.1.4': + optional: true + '@tokenizer/inflate@0.4.1': dependencies: debug: 4.4.3(supports-color@8.1.1) @@ -11333,6 +11567,9 @@ snapshots: '@tokenizer/token@0.3.0': {} + '@tootallnate/quickjs-emscripten@0.23.0': + optional: true + '@ts-rest/core@3.52.1(@types/node@22.19.3)(zod@3.25.76)': optionalDependencies: '@types/node': 22.19.3 @@ -11480,6 +11717,8 @@ snapshots: '@types/lodash@4.17.20': {} + '@types/luxon@3.4.2': {} + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -11648,6 +11887,22 @@ snapshots: '@typescript-eslint/types': 8.46.3 eslint-visitor-keys: 4.2.1 + '@ucast/core@1.10.2': {} + + '@ucast/js@3.1.0': + dependencies: + '@ucast/core': 1.10.2 + + '@ucast/mongo2js@1.4.1': + dependencies: + '@ucast/core': 1.10.2 + '@ucast/js': 3.1.0 + '@ucast/mongo': 2.4.3 + + '@ucast/mongo@2.4.3': + dependencies: + '@ucast/core': 1.10.2 + '@ungap/structured-clone@1.3.0': {} '@unocss/astro@66.5.4(vite@7.2.1(@types/node@24.10.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.1))': @@ -12325,6 +12580,11 @@ snapshots: assertion-error@2.0.1: {} + ast-types@0.13.4: + dependencies: + tslib: 2.8.1 + optional: true + astral-regex@2.0.0: {} async-function@1.0.0: {} @@ -12365,6 +12625,15 @@ snapshots: transitivePeerDependencies: - debug + axios@1.13.5: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.5 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + optional: true + babel-jest@30.2.0(@babel/core@7.28.5): dependencies: '@babel/core': 7.28.5 @@ -12449,6 +12718,9 @@ snapshots: baseline-browser-mapping@2.9.11: {} + basic-ftp@5.2.0: + optional: true + bcrypt-pbkdf@1.0.2: dependencies: tweetnacl: 0.14.5 @@ -12499,8 +12771,7 @@ snapshots: dependencies: fill-range: 7.1.1 - brorand@1.1.0: - optional: true + brorand@1.1.0: {} browserslist@4.28.1: dependencies: @@ -12668,6 +12939,20 @@ snapshots: chrome-trace-event@1.0.4: {} + chromedriver@146.0.0: + dependencies: + '@testim/chrome-version': 1.1.4 + axios: 1.13.5 + compare-versions: 6.1.1 + extract-zip: 2.0.1(supports-color@8.1.1) + proxy-agent: 6.5.0 + proxy-from-env: 2.0.0 + tcp-port-used: 1.0.2 + transitivePeerDependencies: + - debug + - supports-color + optional: true + ci-info@4.3.1: {} cidr-regex@3.1.1: @@ -12788,6 +13073,9 @@ snapshots: array-ify: 1.0.0 dot-prop: 5.3.0 + compare-versions@6.1.1: + optional: true + component-emitter@1.3.1: {} concat-map@0.0.1: {} @@ -12883,6 +13171,11 @@ snapshots: cron-validator@1.4.0: {} + cron@3.5.0: + dependencies: + '@types/luxon': 3.4.2 + luxon: 3.5.0 + cross-spawn@6.0.6: dependencies: nice-try: 1.0.5 @@ -12978,6 +13271,9 @@ snapshots: assert-plus: 1.0.0 optional: true + data-uri-to-buffer@6.0.2: + optional: true + data-urls@5.0.0: dependencies: whatwg-mimetype: 4.0.0 @@ -13017,6 +13313,11 @@ snapshots: supports-color: 8.1.1 optional: true + debug@4.3.1: + dependencies: + ms: 2.1.2 + optional: true + debug@4.4.3(supports-color@5.5.0): dependencies: ms: 2.1.3 @@ -13065,6 +13366,13 @@ snapshots: defu@6.1.4: {} + degenerator@5.0.1: + dependencies: + ast-types: 0.13.4 + escodegen: 2.1.0 + esprima: 4.0.1 + optional: true + delayed-stream@1.0.0: {} delegate@3.2.0: {} @@ -13172,7 +13480,6 @@ snapshots: inherits: 2.0.4 minimalistic-assert: 1.0.1 minimalistic-crypto-utils: 1.0.1 - optional: true emittery@0.13.1: {} @@ -13419,6 +13726,15 @@ snapshots: escape-string-regexp@5.0.0: {} + escodegen@2.1.0: + dependencies: + esprima: 4.0.1 + estraverse: 5.3.0 + esutils: 2.0.3 + optionalDependencies: + source-map: 0.6.1 + optional: true + eslint-compat-utils@0.5.1(eslint@9.39.1(jiti@2.6.1)): dependencies: eslint: 9.39.1(jiti@2.6.1) @@ -13743,6 +14059,8 @@ snapshots: eventemitter2@6.4.7: optional: true + eventemitter2@6.4.9: {} + eventemitter3@5.0.1: {} events@3.3.0: {} @@ -14065,6 +14383,9 @@ snapshots: dependencies: tabbable: 6.3.0 + follow-redirects@1.15.11: + optional: true + follow-redirects@1.15.9: {} for-each@0.3.5: @@ -14211,6 +14532,15 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + get-uri@6.0.5: + dependencies: + basic-ftp: 5.2.0 + data-uri-to-buffer: 6.0.2 + debug: 4.4.3(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + optional: true + getpass@0.1.7: dependencies: assert-plus: 1.0.0 @@ -14389,7 +14719,6 @@ snapshots: dependencies: inherits: 2.0.4 minimalistic-assert: 1.0.1 - optional: true hasha@5.2.2: dependencies: @@ -14412,7 +14741,6 @@ snapshots: hash.js: 1.1.7 minimalistic-assert: 1.0.1 minimalistic-crypto-utils: 1.0.1 - optional: true hookified@1.12.2: {} @@ -14543,6 +14871,9 @@ snapshots: dependencies: fp-ts: 2.16.9 + ip-address@10.1.0: + optional: true + ip-regex@4.3.0: {} ipaddr.js@1.9.1: {} @@ -14708,6 +15039,9 @@ snapshots: is-unicode-supported@0.1.0: {} + is-url@1.2.4: + optional: true + is-weakmap@2.0.2: {} is-weakref@1.1.1: @@ -14719,6 +15053,13 @@ snapshots: call-bound: 1.0.4 get-intrinsic: 1.3.0 + is2@2.0.9: + dependencies: + deep-is: 0.1.4 + ip-regex: 4.3.0 + is-url: 1.2.4 + optional: true + isarray@1.0.0: {} isarray@2.0.5: {} @@ -15239,7 +15580,6 @@ snapshots: asn1.js: 5.4.1 elliptic: 6.6.1 safe-buffer: 5.2.1 - optional: true jws@4.0.0: dependencies: @@ -15247,6 +15587,15 @@ snapshots: safe-buffer: 5.2.1 optional: true + keycloak-connect@25.0.6: + dependencies: + jwk-to-pem: 2.0.7 + optionalDependencies: + chromedriver: 146.0.0 + transitivePeerDependencies: + - debug + - supports-color + keycloak-js@26.2.1: {} keyv@4.5.4: @@ -15420,6 +15769,11 @@ snapshots: dependencies: yallist: 3.1.1 + lru-cache@7.18.3: + optional: true + + luxon@3.5.0: {} + magic-string@0.25.9: dependencies: sourcemap-codec: 1.4.8 @@ -15820,8 +16174,7 @@ snapshots: minimalistic-assert@1.0.1: {} - minimalistic-crypto-utils@1.0.1: - optional: true + minimalistic-crypto-utils@1.0.1: {} minimatch@10.1.1: dependencies: @@ -15862,6 +16215,9 @@ snapshots: mrmime@2.0.1: {} + ms@2.1.2: + optional: true + ms@2.1.3: {} muggle-string@0.4.1: {} @@ -15903,6 +16259,12 @@ snapshots: neo-async@2.6.2: {} + nest-keycloak-connect@1.10.1(@nestjs/common@11.1.11(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.11)(keycloak-connect@25.0.6): + dependencies: + '@nestjs/common': 11.1.11(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.11(@nestjs/common@11.1.11(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.11)(reflect-metadata@0.2.2)(rxjs@7.8.2) + keycloak-connect: 25.0.6 + nestjs-pino@4.5.0(@nestjs/common@11.1.11(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.1.0)(rxjs@7.8.2): dependencies: '@nestjs/common': 11.1.11(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -15910,6 +16272,9 @@ snapshots: pino-http: 11.0.0 rxjs: 7.8.2 + netmask@2.0.2: + optional: true + nice-try@1.0.5: {} node-abort-controller@3.1.1: {} @@ -16141,6 +16506,26 @@ snapshots: p-try@2.2.0: {} + pac-proxy-agent@7.2.0: + dependencies: + '@tootallnate/quickjs-emscripten': 0.23.0 + agent-base: 7.1.4 + debug: 4.4.3(supports-color@8.1.1) + get-uri: 6.0.5 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + pac-resolver: 7.0.1 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + optional: true + + pac-resolver@7.0.1: + dependencies: + degenerator: 5.0.1 + netmask: 2.0.2 + optional: true + package-json-from-dist@1.0.1: {} package-manager-detector@1.5.0: {} @@ -16413,11 +16798,28 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + proxy-agent@6.5.0: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@8.1.1) + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + lru-cache: 7.18.3 + pac-proxy-agent: 7.2.0 + proxy-from-env: 1.1.0 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + optional: true + proxy-from-env@1.0.0: optional: true proxy-from-env@1.1.0: {} + proxy-from-env@2.0.0: + optional: true + pstree.remy@1.1.8: {} pump@3.0.3: @@ -16945,12 +17347,30 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + smart-buffer@4.2.0: + optional: true + smob@1.5.0: {} smtp-address-parser@1.1.0: dependencies: nearley: 2.20.1 + socks-proxy-agent@8.0.5: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@8.1.1) + socks: 2.8.7 + transitivePeerDependencies: + - supports-color + optional: true + + socks@2.8.7: + dependencies: + ip-address: 10.1.0 + smart-buffer: 4.2.0 + optional: true + sonic-boom@4.2.0: dependencies: atomic-sleep: 1.0.0 @@ -17336,6 +17756,14 @@ snapshots: tapable@2.3.0: {} + tcp-port-used@1.0.2: + dependencies: + debug: 4.3.1 + is2: 2.0.9 + transitivePeerDependencies: + - supports-color + optional: true + temp-dir@2.0.0: {} tempy@0.6.0: