diff --git a/apps/server-nestjs/package.json b/apps/server-nestjs/package.json index 5cf37b2fa4..7708562573 100644 --- a/apps/server-nestjs/package.json +++ b/apps/server-nestjs/package.json @@ -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:^", @@ -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", @@ -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", 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..bd1b931f85 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,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(',') : [] 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..8295459ed5 --- /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 { 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 { + 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..a71ebd2b68 --- /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: (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 {} 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..ef451605ca --- /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 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) + } + } +} 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..f758e52153 --- /dev/null +++ b/apps/server-nestjs/src/modules/keycloak/keycloak-controller.service.spec.ts @@ -0,0 +1,305 @@ +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: { + keycloakControllerPurgeOrphanGroups: false, + keycloakControllerPurgeOrphanMembers: false, + } satisfies Partial, + }, + ], + }) +} + +describe('keycloakControllerService', () => { + let service: KeycloakControllerService + let keycloak: Mocked + let keycloakDatastore: Mocked + let config: Mocked + + beforeEach(async () => { + vi.clearAllMocks() + const module = await createKeycloakControllerServiceTestingModule().compile() + service = module.get(KeycloakControllerService) + keycloak = module.get(KeycloakService) + keycloakDatastore = module.get(KeycloakDatastoreService) + config = 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 + config.keycloakControllerPurgeOrphanGroups = 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' }] } + + keycloak.getAllGroups.mockImplementation(async function* () { + yield projectGroup + yield orphanGroup + }) + keycloak.getOrCreateGroupByPath.mockResolvedValue(projectGroup) + keycloak.getGroupMembers.mockResolvedValue([]) + keycloak.getOrCreateSubGroupByName.mockResolvedValue({ id: 'console-id', name: 'console' }) + await service.handleCron() + + expect(keycloakDatastore.getAllProjects).toHaveBeenCalled() + expect(keycloak.getAllGroups).toHaveBeenCalled() + expect(keycloak.getOrCreateGroupByPath).toHaveBeenCalledWith('/test-project') + expect(keycloak.deleteGroup).toHaveBeenCalledWith('orphan-id') + }) + + it('should not purge orphans if disabled', async () => { + // Setup + config.keycloakControllerPurgeOrphanGroups = 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' }] } + + keycloak.getAllGroups.mockImplementation(async function* () { + yield projectGroup + yield orphanGroup + }) + keycloak.getOrCreateGroupByPath.mockResolvedValue(projectGroup) + keycloak.getGroupMembers.mockResolvedValue([]) + keycloak.getOrCreateSubGroupByName.mockResolvedValue({ id: 'console-id', name: 'console' }) + keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) + + await service.handleCron() + + expect(keycloak.deleteGroup).not.toHaveBeenCalled() + }) + + it('should sync project members', async () => { + // Setup + config.keycloakControllerPurgeOrphanMembers = 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' } + keycloak.getOrCreateGroupByPath.mockResolvedValue(projectGroup) + + // Current members: user-2 (extra), missing user-1 + keycloak.getGroupMembers.mockResolvedValue([ + { id: 'user-2', email: 'user2@example.com' }, + ]) + + keycloak.getOrCreateSubGroupByName.mockResolvedValue({ id: 'console-id', name: 'console' }) + keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) + + await service.handleCron() + + // Should add missing member + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-1', 'group-id') + // Should add owner (missing in group members) + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('owner-id', 'group-id') + // Should remove extra member (purge enabled) + expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-2', 'group-id') + }) + + it('should sync OIDC role groups', async () => { + // Setup + config.keycloakControllerPurgeOrphanMembers = 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' } + + keycloak.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 + keycloak.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([]) + }) + + keycloak.getOrCreateSubGroupByName.mockResolvedValue({ id: 'console-id', name: 'console' }) + keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) + + await service.handleCron() + + // Should create/get role group + expect(keycloak.getOrCreateGroupByPath).toHaveBeenCalledWith('/oidc-group') + // Should add user-1 to role group + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-1', 'role-group-id') + // Should remove user-2 from role group (purge enabled) + expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-2', 'role-group-id') + }) + + it('should sync environment groups', async () => { + // Setup + config.keycloakControllerPurgeOrphanGroups = 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' }] } + keycloak.getOrCreateGroupByPath.mockResolvedValue(projectGroup) + keycloak.getGroupMembers.mockResolvedValue([]) + + // Mock console group retrieval + keycloak.getOrCreateConsoleGroup.mockResolvedValue({ id: 'console-id', name: 'console' }) + keycloak.getOrCreateEnvironmentGroups.mockResolvedValue({ + roGroup: { id: 'dev-ro-id', name: 'RO' }, + rwGroup: { id: 'dev-rw-id', name: 'RW' }, + }) + keycloak.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) + keycloak.getSubGroups.mockImplementation(async function* (parentId) { + if (parentId === 'console-id') { + yield { id: 'staging-id', name: 'staging' } + } + }) + + await service.handleCron() + + // Should create dev group + expect(keycloak.getOrCreateConsoleGroup).toHaveBeenCalledWith(projectGroup) + // Should create RO/RW groups + expect(keycloak.getOrCreateEnvironmentGroups).toHaveBeenCalledWith({ id: 'console-id', name: 'console' }, projectWithEnv.environments[0]) + // Should delete staging group (purge enabled) + expect(keycloak.deleteGroup).toHaveBeenCalledWith('staging-id') + }) + + it('should sync environment permissions', async () => { + // Setup + config.keycloakControllerPurgeOrphanMembers = 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' }] } + keycloak.getOrCreateGroupByPath.mockResolvedValue(projectGroup) + keycloak.getOrCreateConsoleGroup.mockResolvedValue({ id: 'console-id', name: 'console' }) + keycloak.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) + keycloak.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([]) + }) + + keycloak.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 }) + }) + + keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) + + await service.handleCron() + + // Sync RO + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-ro', 'dev-ro-id') + expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-none', 'dev-ro-id') + // Sync RW + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-rw', 'dev-rw-id') + expect(keycloak.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..e35b18e900 --- /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 keycloak: KeycloakService, + @Inject(KeycloakDatastoreService) private readonly keycloakDatastore: KeycloakDatastoreService, + @Inject(ConfigurationService) private readonly config: 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.keycloak.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.keycloak.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.config.keycloakControllerPurgeOrphanGroups) { + if (group.id) { + this.logger.log(`Deleting orphan Keycloak group: ${group.name}`) + promises.push( + this.keycloak.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.keycloak.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.keycloak.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.keycloak.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.config.keycloakControllerPurgeOrphanMembers) { + await this.keycloak.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.keycloak.getOrCreateGroupByPath(role.oidcGroup) + if (!roleGroup.id) { + throw new Error(`Failed to create or retrieve role group for ${role.oidcGroup}`) + } + const groupMembers = await this.keycloak.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.keycloak.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.config.keycloakControllerPurgeOrphanMembers) { + await this.keycloak.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.keycloak.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.keycloak.getSubGroups(consoleGroup.id)) { + if (!this.isOwnedEnvironmentGroup(envGroup, project) && envGroup.id) { + if (this.config.keycloakControllerPurgeOrphanGroups) { + promises.push( + this.keycloak.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.keycloak.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.keycloak.getGroupMembers(roGroup.id), + this.keycloak.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.keycloak.addUserToGroup(userId, roGroup.id!) + this.logger.log(`User ${userId} added to RO group for ${environment.name}`) + } else if (!perms.ro && isInRo) { + if (this.config.keycloakControllerPurgeOrphanMembers) { + await this.keycloak.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.keycloak.addUserToGroup(userId, rwGroup.id!) + this.logger.log(`User ${userId} added to RW group for ${environment.name}`) + } else if (!perms.rw && isInRw) { + if (this.config.keycloakControllerPurgeOrphanMembers) { + await this.keycloak.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.config.keycloakControllerPurgeOrphanMembers) { + await this.keycloak.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.config.keycloakControllerPurgeOrphanMembers) { + await this.keycloak.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..1ae38ac665 --- /dev/null +++ b/apps/server-nestjs/src/modules/keycloak/keycloak-datastore.service.ts @@ -0,0 +1,51 @@ +import { Inject, Injectable, Logger } from '@nestjs/common' +import type { Prisma } from '@prisma/client' +import { 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(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async getAllProjects(): Promise { + return this.prisma.project.findMany({ + select: projectSelect, + }) + } +} diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak.constant.ts b/apps/server-nestjs/src/modules/keycloak/keycloak.constant.ts new file mode 100644 index 0000000000..93d81fcf3c --- /dev/null +++ b/apps/server-nestjs/src/modules/keycloak/keycloak.constant.ts @@ -0,0 +1 @@ +export const CONSOLE_GROUP_NAME = 'console' 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..9e03110cdb --- /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('keycloak', () => { + 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 a74836ee06..aa4f6f437b 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,6 +428,9 @@ 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) @@ -470,9 +482,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) @@ -1836,6 +1854,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==} @@ -2781,6 +2808,10 @@ packages: '@js-sdsl/ordered-map@4.4.2': resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + '@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'} @@ -2871,6 +2902,12 @@ 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: @@ -3690,6 +3727,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'} @@ -3697,6 +3737,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: @@ -3969,6 +4012,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==} @@ -4536,6 +4591,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'} @@ -4579,6 +4638,9 @@ packages: axios@1.12.2: resolution: {integrity: sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==} + axios@1.13.6: + resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==} + 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} @@ -4632,6 +4694,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==} @@ -4819,6 +4885,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'} @@ -4948,6 +5019,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==} @@ -5112,6 +5186,10 @@ packages: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} + 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'} @@ -5148,6 +5226,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'} @@ -5204,6 +5291,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'} @@ -5435,6 +5526,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'} @@ -5673,6 +5769,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==} @@ -5884,6 +5983,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'} @@ -6037,6 +6145,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==} @@ -6358,6 +6470,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'} @@ -6545,6 +6661,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'} @@ -6557,6 +6676,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==} @@ -6872,6 +6995,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==} @@ -7038,6 +7165,10 @@ 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'} @@ -7333,6 +7464,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==} @@ -7389,6 +7523,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'} @@ -7398,6 +7543,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==} @@ -7601,6 +7750,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==} @@ -7921,12 +8078,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==} @@ -8380,6 +8544,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==} @@ -8387,6 +8555,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==} @@ -8683,6 +8859,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'} @@ -10550,6 +10729,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 @@ -11662,6 +11852,12 @@ snapshots: '@js-sdsl/ordered-map@4.4.2': {} + '@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 @@ -11776,6 +11972,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) @@ -12781,6 +12983,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@5.5.0) @@ -12790,6 +12995,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 @@ -13139,6 +13347,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))': @@ -13820,6 +14044,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: {} @@ -13860,6 +14089,15 @@ snapshots: transitivePeerDependencies: - debug + axios@1.13.6: + 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 @@ -13944,6 +14182,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 @@ -13996,8 +14237,7 @@ snapshots: dependencies: fill-range: 7.1.1 - brorand@1.1.0: - optional: true + brorand@1.1.0: {} browserslist@4.28.1: dependencies: @@ -14165,6 +14405,20 @@ snapshots: chrome-trace-event@1.0.4: {} + chromedriver@146.0.0: + dependencies: + '@testim/chrome-version': 1.1.4 + axios: 1.13.6 + compare-versions: 6.1.1 + extract-zip: 2.0.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: @@ -14285,6 +14539,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: {} @@ -14482,6 +14739,9 @@ snapshots: data-uri-to-buffer@4.0.1: {} + data-uri-to-buffer@6.0.2: + optional: true + data-urls@5.0.0: dependencies: whatwg-mimetype: 4.0.0 @@ -14521,6 +14781,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 @@ -14570,6 +14835,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: {} @@ -14677,7 +14949,6 @@ snapshots: inherits: 2.0.4 minimalistic-assert: 1.0.1 minimalistic-crypto-utils: 1.0.1 - optional: true emittery@0.13.1: {} @@ -14924,6 +15195,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) @@ -15248,6 +15528,8 @@ snapshots: eventemitter2@6.4.7: optional: true + eventemitter2@6.4.9: {} + eventemitter3@5.0.1: {} events@3.3.0: {} @@ -15344,6 +15626,17 @@ snapshots: extend@3.0.2: {} + extract-zip@2.0.1: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + get-stream: 5.2.0 + yauzl: 2.10.0 + optionalDependencies: + '@types/yauzl': 2.10.3 + transitivePeerDependencies: + - supports-color + optional: true + extract-zip@2.0.1(supports-color@8.1.1): dependencies: debug: 4.4.3(supports-color@8.1.1) @@ -15575,6 +15868,9 @@ snapshots: dependencies: tabbable: 6.3.0 + follow-redirects@1.15.11: + optional: true + follow-redirects@1.15.9: {} for-each@0.3.5: @@ -15743,6 +16039,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@5.5.0) + transitivePeerDependencies: + - supports-color + optional: true + getpass@0.1.7: dependencies: assert-plus: 1.0.0 @@ -15923,7 +16228,6 @@ snapshots: dependencies: inherits: 2.0.4 minimalistic-assert: 1.0.1 - optional: true hasha@5.2.2: dependencies: @@ -15946,7 +16250,6 @@ snapshots: hash.js: 1.1.7 minimalistic-assert: 1.0.1 minimalistic-crypto-utils: 1.0.1 - optional: true hookified@1.12.2: {} @@ -16084,6 +16387,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: {} @@ -16249,6 +16555,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: @@ -16260,6 +16569,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: {} @@ -16784,7 +17100,6 @@ snapshots: asn1.js: 5.4.1 elliptic: 6.6.1 safe-buffer: 5.2.1 - optional: true jws@4.0.0: dependencies: @@ -16792,6 +17107,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: @@ -16967,6 +17291,9 @@ snapshots: dependencies: yallist: 3.1.1 + lru-cache@7.18.3: + optional: true + luxon@3.5.0: {} magic-string@0.25.9: @@ -17369,8 +17696,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: @@ -17413,6 +17739,9 @@ snapshots: mrmime@2.0.1: {} + ms@2.1.2: + optional: true + ms@2.1.3: {} muggle-string@0.4.1: {} @@ -17454,6 +17783,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) @@ -17461,6 +17796,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: {} @@ -17700,6 +18038,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@5.5.0) + 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: {} @@ -18024,11 +18382,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@5.5.0) + 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: @@ -18563,12 +18938,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@5.5.0) + 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 @@ -18954,6 +19347,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: