diff --git a/AGENTS.md b/AGENTS.md index 0f0d98791..44b0893f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,6 @@ Plugins use TS module augmentation to extend `ProjectStore` and `Config` interfa ## Database (Prisma) Multi-file schema in `apps/server/src/prisma/schema/*.prisma` (project, user, token, admin, topography). -Singleton PrismaClient in `apps/server/src/prisma.ts`. Queries centralized per resource, re-exported via `queries-index.ts`. Migrations: standard Prisma Migrate. Major version data migrations in `migrations/v9/`. ## Environment config diff --git a/apps/server-nestjs/.env-example b/apps/server-nestjs/.env-example index 0525122fc..f4f7960cc 100644 --- a/apps/server-nestjs/.env-example +++ b/apps/server-nestjs/.env-example @@ -15,6 +15,14 @@ KEYCLOAK_PROTOCOL=http KEYCLOAK_CLIENT_ID=dso-console-backend # Secret du client Keycloak backend (confidentiel) KEYCLOAK_CLIENT_SECRET=client-secret-backend +# Identifiant de l'administrateur Keycloak +KEYCLOAK_ADMIN=admin +# Mot de passe de l'administrateur Keycloak +KEYCLOAK_ADMIN_PASSWORD=admin +# Identifiant administrateur Keycloak (utilisé pour l'API admin) +KEYCLOAK_ADMIN=admin +# Mot de passe administrateur Keycloak (confidentiel) +KEYCLOAK_ADMIN_PASSWORD=admin # URL de redirection après authentification Keycloak KEYCLOAK_REDIRECT_URI=http://localhost:8080 # Port d'écoute du serveur backend diff --git a/apps/server-nestjs/.env.docker-example b/apps/server-nestjs/.env.docker-example index 0dc790476..b2034118c 100644 --- a/apps/server-nestjs/.env.docker-example +++ b/apps/server-nestjs/.env.docker-example @@ -16,6 +16,10 @@ KEYCLOAK_PROTOCOL=http KEYCLOAK_CLIENT_ID=dso-console-backend # Secret du client Keycloak backend (confidentiel) KEYCLOAK_CLIENT_SECRET=client-secret-backend +# Identifiant de l'administrateur Keycloak +KEYCLOAK_ADMIN=admin +# Mot de passe de l'administrateur Keycloak +KEYCLOAK_ADMIN_PASSWORD=admin # URL de redirection après authentification Keycloak KEYCLOAK_REDIRECT_URI=http://localhost:8080 # Port d'écoute du serveur dans le réseau Docker diff --git a/apps/server-nestjs/.env.integ-example b/apps/server-nestjs/.env.integ-example index d5850a09c..8c515966b 100644 --- a/apps/server-nestjs/.env.integ-example +++ b/apps/server-nestjs/.env.integ-example @@ -17,6 +17,10 @@ KEYCLOAK_CLIENT_SECRET= KEYCLOAK_DOMAIN= # Royaume Keycloak d'intégration KEYCLOAK_REALM= +# Identifiant de l'administrateur Keycloak +KEYCLOAK_ADMIN= +# Mot de passe de l'administrateur Keycloak +KEYCLOAK_ADMIN_PASSWORD= # --- ArgoCD --- # Namespace Kubernetes dans lequel ArgoCD est déployé diff --git a/apps/server-nestjs/documentation/Modularisation-de-console-server/02-ARCHITECTURE-MODULES.md b/apps/server-nestjs/documentation/Modularisation-de-console-server/02-ARCHITECTURE-MODULES.md new file mode 100644 index 000000000..1156a2c57 --- /dev/null +++ b/apps/server-nestjs/documentation/Modularisation-de-console-server/02-ARCHITECTURE-MODULES.md @@ -0,0 +1,127 @@ +# Architecture d’un module (pattern `apps/server-nestjs/src/modules/*`) + +Les modules NestJS métier vivent dans `src/modules//` et suivent un découpage “vertical slice” avec des responsabilités explicites : **client**, **service**, **controller service (orchestration)**, **datastore**, **utils** et **tests**. + +Exemples concrets : +- Module GitLab : `src/modules/gitlab/` +- Module Keycloak : `src/modules/keycloak/` + +## Structure type + +```txt +src/modules// +├── .module.ts +├── .constants.ts +├── -client.service.ts +├── .service.ts +├── -controller.service.ts +├── -datastore.service.ts +├── .utils.ts +├── -testing.utils.ts +└── *.spec.ts +``` + +## Sens des dépendances (flow recommandé) + +Objectif : un flux de dépendances lisible et sans cycles. + +```txt +-controller.service.ts + ↓ + .service.ts + ↙ ↘ +-client -datastore +.service.ts .service.ts +``` + +Règles pratiques : +- Le `controller service` orchestre des workflows (cron, events, reconcile) et appelle le `service` et/ou le `datastore`. +- Le `service` contient les règles métier (décisions, transformations, validations) et s’appuie sur le `client` et le `datastore`. +- Le `client` encapsule l’accès à une API externe (initialisation + appels + erreurs bas niveau). +- Le `datastore` encapsule l’accès DB (Prisma) et expose des méthodes de lecture/écriture typées. +- Les `utils` restent “purs” (pas d’IO, pas d’injection Nest). +- Les `testing utils` centralisent les factories/fixtures pour réduire la duplication dans les tests. + +## Composants + +### `.module.ts` + +Rôle : +- Déclare les providers, imports, exports du module. +- Exporte généralement le service principal du module. + +### `-client.service.ts` + +Rôle : +- Adapter vers le système externe (SDK HTTP, client Keycloak, client GitLab, etc.). +- Conserver un contrat stable pour le reste du module. +- Mapper/normaliser les erreurs externes si nécessaire. + +À éviter : +- Décisions métier (permissions, synchronisation, règles de purge) : elles vont dans `.service.ts` ou le controller service. + +### `.service.ts` + +Rôle : +- Coeur métier du module (logique, mapping, validations, règles). +- Appels aux dépendances (client/datastore) via des méthodes orientées domaine. + +À éviter : +- Cron/events : c’est le rôle du controller service. + +### `-controller.service.ts` + +Rôle : +- Orchestrateur de workflows : `@Cron`, `@OnEvent`, reconcile périodique, tâches “batch”. +- Coordination entre datastore et service (et parfois appels directs au client pour des opérations transverses). +- Garde-fous “safety” avant opérations destructrices (ex: suppression de groupes orphelins). + +### `-datastore.service.ts` + +Rôle : +- Accès DB via Prisma (select/include, transactions, pagination). +- Exposition de types agrégés utiles au domaine (ex: `ProjectWithDetails`). + +À éviter : +- Appliquer des règles métier (ex: calcul de permissions) : on garde le datastore centré persistence. + +### `.utils.ts` + +Rôle : +- Fonctions utilitaires pures : mapping, helpers de collections, types partagés. +- Aucune dépendance Nest, aucune lecture/écriture DB, aucun appel réseau. + +### `-testing.utils.ts` + +Rôle : +- Factories typées pour les structures fréquemment utilisées en tests. +- Support d’`overrides` pour construire rapidement des variantes. +- Centralisation des erreurs/fake responses spécifiques au module (quand utile). + +## Tests (Vitest) + +### `.service.spec.ts` + +Cible : +- Logique métier : transformations, décisions, mapping d’erreurs. + +Approche : +- Mock du `client` et du `datastore`. +- Pas d’IO réel. + +### `-controller.service.spec.ts` + +Cible : +- Orchestration : séquences d’appels, side-effects attendus, reconcile. + +Approche : +- Mock du `service`, du `datastore`, et des appels externes. +- Vérification des appels effectués et des paramètres attendus. + +### `-datastore.service.spec.ts` (si présent) + +Cible : +- Forme des requêtes Prisma, mapping de résultat, typage de l’agrégat renvoyé. + +Approche : +- Mock de Prisma/DatabaseService, pas de logique métier. diff --git a/apps/server-nestjs/package.json b/apps/server-nestjs/package.json index 04dd3e850..1f84babde 100644 --- a/apps/server-nestjs/package.json +++ b/apps/server-nestjs/package.json @@ -15,11 +15,18 @@ "db:migrate": "prisma migrate dev --name dso", "db:reset": "prisma migrate reset", "format": "eslint ./ --fix", - "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "prestart": "prisma generate", "start": "nest start", "start:debug": "nest start --debug --watch", "start:dev": "nest start --watch", - "start:prod": "node dist/main" + "start:prod": "node dist/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "pretest": "prisma generate", + "test": "vitest run", + "test:watch": "vitest", + "pretest:cov": "prisma generate", + "test:cov": "vitest run --coverage", + "test:debug": "vitest --inspect" }, "dependencies": { "@cpn-console/argocd-plugin": "workspace:^", @@ -38,11 +45,15 @@ "@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.7.2", "@nestjs/common": "^11.1.16", "@nestjs/config": "^4.0.3", "@nestjs/core": "^11.1.16", + "@nestjs/event-emitter": "^3.0.1", "@nestjs/platform-express": "^11.1.16", + "@nestjs/schedule": "^6.1.1", + "@opentelemetry/api": "^1.9.0", "@prisma/client": "^6.19.2", "@ts-rest/core": "^3.52.1", "@ts-rest/fastify": "^3.52.1", @@ -53,14 +64,18 @@ "fastify": "^4.29.1", "fastify-keycloak-adapter": "2.3.2", "json-2-csv": "^5.5.10", + "keycloak-connect": "^25.0.0", "mustache": "^4.2.0", + "nest-keycloak-connect": "^1.10.1", "nestjs-pino": "^4.6.0", "pino-http": "^11.0.0", "prisma": "^6.19.2", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", "undici": "^7.22.0", - "vitest-mock-extended": "^2.0.2" + "vitest-mock-extended": "^2.0.2", + "yaml": "^2.8.2", + "zod": "^3.25.76" }, "devDependencies": { "@cpn-console/eslint-config": "workspace:^", @@ -79,6 +94,7 @@ "eslint": "^9.39.4", "fastify-plugin": "^5.1.0", "globals": "^16.5.0", + "msw": "^2.12.10", "nodemon": "^3.1.14", "pino-pretty": "^13.1.3", "rimraf": "^6.1.3", diff --git a/apps/server-nestjs/src/cpin-module/application-initialization/application-initialization-service/application-initialization.service.spec.ts b/apps/server-nestjs/src/cpin-module/application-initialization/application-initialization-service/application-initialization.service.spec.ts index f247c9d6f..46845248c 100644 --- a/apps/server-nestjs/src/cpin-module/application-initialization/application-initialization-service/application-initialization.service.spec.ts +++ b/apps/server-nestjs/src/cpin-module/application-initialization/application-initialization-service/application-initialization.service.spec.ts @@ -7,6 +7,7 @@ import { ConfigurationModule } from '@/cpin-module/infrastructure/configuration/ import { PluginManagementService } from '../plugin-management/plugin-management.service' import { DatabaseInitializationService } from '../database-initialization/database-initialization.service' import { DatabaseService } from '@/cpin-module/infrastructure/database/database.service' +import { PrismaService } from '@/cpin-module/infrastructure/database/prisma.service' describe('applicationInitializationServiceService', () => { let service: ApplicationInitializationService @@ -19,6 +20,10 @@ describe('applicationInitializationServiceService', () => { PluginManagementService, DatabaseInitializationService, DatabaseService, + { + provide: PrismaService, + useValue: {}, + }, ], }).compile() diff --git a/apps/server-nestjs/src/cpin-module/application-initialization/database-initialization/database-initialization.service.spec.ts b/apps/server-nestjs/src/cpin-module/application-initialization/database-initialization/database-initialization.service.spec.ts index bd1cb5671..bb00cdc75 100644 --- a/apps/server-nestjs/src/cpin-module/application-initialization/database-initialization/database-initialization.service.spec.ts +++ b/apps/server-nestjs/src/cpin-module/application-initialization/database-initialization/database-initialization.service.spec.ts @@ -3,13 +3,20 @@ import { Test } from '@nestjs/testing' import { describe, beforeEach, it, expect } from 'vitest' import { DatabaseInitializationService } from './database-initialization.service' +import { PrismaService } from '@/cpin-module/infrastructure/database/prisma.service' describe('databaseInitializationService', () => { let service: DatabaseInitializationService beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [DatabaseInitializationService], + providers: [ + DatabaseInitializationService, + { + provide: PrismaService, + useValue: {}, + }, + ], }).compile() service = module.get( diff --git a/apps/server-nestjs/src/cpin-module/application-initialization/database-initialization/database-initialization.service.ts b/apps/server-nestjs/src/cpin-module/application-initialization/database-initialization/database-initialization.service.ts index 45b4e4678..5133460c9 100644 --- a/apps/server-nestjs/src/cpin-module/application-initialization/database-initialization/database-initialization.service.ts +++ b/apps/server-nestjs/src/cpin-module/application-initialization/database-initialization/database-initialization.service.ts @@ -1,16 +1,11 @@ -import prisma from '@/prisma' -import { Injectable, Logger } from '@nestjs/common' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { PrismaService } from '@/cpin-module/infrastructure/database/prisma.service' import { modelKeys } from './utils' -type ExtractKeysWithFields = { - [K in keyof T]: T[K] extends { fields: any } ? K : never; -}[keyof T] - -type Models = ExtractKeysWithFields - -type Imports = Partial> & { - associations: [Models, any[]] +type ModelKey = (typeof modelKeys)[number] +type Imports = Partial> & { + associations: [ModelKey, any[]][] } @Injectable() @@ -19,6 +14,8 @@ export class DatabaseInitializationService { DatabaseInitializationService.name, ) + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + async initDb(data: Imports) { const dataStringified = JSON.stringify(data) const dataParsed = JSON.parse(dataStringified, (key, value) => { 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 b3af13bcb..f049b3764 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 @@ -24,7 +24,10 @@ export class ConfigurationService { keycloakRealm = process.env.KEYCLOAK_REALM keycloakClientId = process.env.KEYCLOAK_CLIENT_ID keycloakClientSecret = process.env.KEYCLOAK_CLIENT_SECRET + keycloakAdmin = process.env.KEYCLOAK_ADMIN + keycloakAdminPassword = process.env.KEYCLOAK_ADMIN_PASSWORD keycloakRedirectUri = process.env.KEYCLOAK_REDIRECT_URI + 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/database.service.spec.ts b/apps/server-nestjs/src/cpin-module/infrastructure/database/database.service.spec.ts index 2324b038f..094f4f1ce 100644 --- a/apps/server-nestjs/src/cpin-module/infrastructure/database/database.service.spec.ts +++ b/apps/server-nestjs/src/cpin-module/infrastructure/database/database.service.spec.ts @@ -1,9 +1,10 @@ import type { TestingModule } from '@nestjs/testing' import { Test } from '@nestjs/testing' -import { describe, beforeEach, it, expect } from 'vitest' +import { describe, beforeEach, it, expect, vi } from 'vitest' import { DatabaseService } from './database.service' import { ConfigurationModule } from '../configuration/configuration.module' +import { PrismaService } from './prisma.service' describe('databaseService', () => { let service: DatabaseService @@ -11,7 +12,16 @@ describe('databaseService', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ imports: [ConfigurationModule], - providers: [DatabaseService], + providers: [ + DatabaseService, + { + provide: PrismaService, + useValue: { + $connect: vi.fn().mockResolvedValue(undefined), + $disconnect: vi.fn().mockResolvedValue(undefined), + }, + }, + ], }).compile() service = module.get(DatabaseService) diff --git a/apps/server-nestjs/src/cpin-module/infrastructure/database/database.service.ts b/apps/server-nestjs/src/cpin-module/infrastructure/database/database.service.ts index 5f09ccf55..078487d0b 100644 --- a/apps/server-nestjs/src/cpin-module/infrastructure/database/database.service.ts +++ b/apps/server-nestjs/src/cpin-module/infrastructure/database/database.service.ts @@ -1,13 +1,16 @@ -import prisma from '@/prisma' import { Inject, Injectable, Logger } from '@nestjs/common' import { setTimeout } from 'node:timers/promises' import { ConfigurationService } from '../configuration/configuration.service' +import { PrismaService } from './prisma.service' @Injectable() export class DatabaseService { private readonly loggerService = new Logger(DatabaseService.name) - constructor(@Inject(ConfigurationService) private readonly configurationService: ConfigurationService) { + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(ConfigurationService) private readonly configurationService: ConfigurationService, + ) { this.DELAY_BEFORE_RETRY = this.configurationService.isTest || this.configurationService.isCI ? 1000 @@ -33,7 +36,7 @@ export class DatabaseService { `Trying to connect to Postgres with: ${this.configurationService.dbUrl}`, ) } - await prisma.$connect() + await this.prisma.$connect() this.loggerService.log('Connected to Postgres!') } catch (error) { @@ -59,7 +62,7 @@ export class DatabaseService { async closeConnections() { this.closingConnections = true try { - await prisma.$disconnect() + await this.prisma.$disconnect() } catch (error) { this.loggerService.error(error) } finally { 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 000000000..410e662ea --- /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 8bd7fac8a..35f17db52 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 a8c7b3fd0..545a42b81 100644 --- a/apps/server-nestjs/src/main.module.ts +++ b/apps/server-nestjs/src/main.module.ts @@ -1,11 +1,19 @@ import { Module } from '@nestjs/common' +import { EventEmitterModule } from '@nestjs/event-emitter' +import { ScheduleModule } from '@nestjs/schedule' import { CpinModule } from './cpin-module/cpin.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, + KeycloakModule, + EventEmitterModule.forRoot(), + ScheduleModule.forRoot(), + ], controllers: [], providers: [], }) diff --git a/apps/server-nestjs/src/modules/keycloak/keycloack-testing.utils.ts b/apps/server-nestjs/src/modules/keycloak/keycloack-testing.utils.ts new file mode 100644 index 000000000..612f3fa31 --- /dev/null +++ b/apps/server-nestjs/src/modules/keycloak/keycloack-testing.utils.ts @@ -0,0 +1,86 @@ +import { faker } from '@faker-js/faker' +import type GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation' +import type UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation' + +import type { ProjectWithDetails } from './keycloak-datastore.service' + +export function makeUserRepresentation( + overrides: Partial = {}, +) { + return { + id: faker.string.uuid(), + email: faker.internet.email().toLowerCase(), + username: faker.internet.username(), + enabled: true, + ...overrides, + } satisfies UserRepresentation +} + +export function makeGroupRepresentation( + overrides: Partial = {}, +) { + return { + id: faker.string.uuid(), + name: faker.word.noun(), + path: `/${faker.word.noun()}`, + subGroups: [], + ...overrides, + } satisfies GroupRepresentation +} + +export function makeProjectUser( + overrides: Partial = {}, +) { + return { + id: faker.string.uuid(), + email: faker.internet.email().toLowerCase(), + ...overrides, + } satisfies ProjectWithDetails['members'][number]['user'] +} + +export function makeProjectMember( + overrides: Partial = {}, +) { + return { + roleIds: [], + user: makeProjectUser(), + ...overrides, + } satisfies ProjectWithDetails['members'][number] +} + +export function makeProjectRole( + overrides: Partial = {}, +) { + return { + id: faker.string.uuid(), + permissions: 0n, + oidcGroup: '', + type: 'managed', + ...overrides, + } satisfies ProjectWithDetails['roles'][number] +} + +export function makeProjectEnvironment( + overrides: Partial = {}, +) { + return { + id: faker.string.uuid(), + name: faker.word.noun(), + ...overrides, + } satisfies ProjectWithDetails['environments'][number] +} + +export function makeProjectWithDetails( + overrides: Partial = {}, +) { + return { + id: faker.string.uuid(), + slug: faker.helpers.slugify(faker.word.words({ count: 2 })).toLowerCase(), + ownerId: faker.string.uuid(), + everyonePerms: 0n, + members: [], + roles: [], + environments: [], + ...overrides, + } satisfies ProjectWithDetails +} diff --git a/apps/server-nestjs/src/modules/keycloak/keycloack.utils.ts b/apps/server-nestjs/src/modules/keycloak/keycloack.utils.ts new file mode 100644 index 000000000..d126a92dc --- /dev/null +++ b/apps/server-nestjs/src/modules/keycloak/keycloack.utils.ts @@ -0,0 +1,29 @@ +import type UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation' +import type { ProjectWithDetails } from './keycloak-datastore.service' +import type GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation' + +export type GroupRepresentationWith = GroupRepresentation & Required> + +export function isMember(project: ProjectWithDetails, member: UserRepresentation) { + return project.members.some(m => m.user.id === member.id) || project.ownerId === member.id +} + +export async function* map( + iterable: AsyncIterable, + mapper: (value: T, index: number) => U | Promise, +): AsyncIterable { + let index = 0 + for await (const value of iterable) { + yield await mapper(value, index++) + } +} + +export async function getAll( + iterable: AsyncIterable, +): Promise { + const items: T[] = [] + for await (const item of iterable) { + items.push(item) + } + return items +} 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 000000000..a7a0bf01d --- /dev/null +++ b/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts @@ -0,0 +1,32 @@ +import { Inject, Injectable, Logger } from '@nestjs/common' +import 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() { + if (!this.config.keycloakAdmin || !this.config.keycloakAdminPassword) { + this.logger.fatal('Keycloak admin or admin password not configured') + return + } + await this.auth({ + clientId: 'admin-cli', + grantType: 'password', + username: this.config.keycloakAdmin, + password: this.config.keycloakAdminPassword, + }) + this.logger.log('Keycloak Admin Client authenticated') + } +} 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 000000000..a804661f3 --- /dev/null +++ b/apps/server-nestjs/src/modules/keycloak/keycloak-controller.service.spec.ts @@ -0,0 +1,381 @@ +import { Test } from '@nestjs/testing' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import type { Mocked } from 'vitest' +import { KeycloakControllerService } from './keycloak-controller.service' +import { KeycloakDatastoreService } from './keycloak-datastore.service' +import type { ProjectWithDetails } from './keycloak-datastore.service' +import { KeycloakService } from './keycloak.service' +import { ConfigurationService } from '@/cpin-module/infrastructure/configuration/configuration.service' +import { + makeGroupRepresentation, + makeProjectEnvironment, + makeProjectMember, + makeProjectRole, + makeProjectUser, + makeProjectWithDetails, + makeUserRepresentation, +} from './keycloack-testing.utils' + +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({}), + getOrCreateRoleGroup: vi.fn().mockResolvedValue({}), + getSubGroups: vi.fn().mockImplementation(async function* () {}), + getOrCreateConsoleGroup: vi.fn().mockResolvedValue(makeGroupRepresentation({ id: 'console-group-id', name: 'console' })), + getOrCreateEnvironmentGroups: vi.fn().mockResolvedValue({ + roGroup: makeGroupRepresentation({ id: 'ro-id', name: 'RO' }), + rwGroup: makeGroupRepresentation({ id: 'rw-id', name: 'RW' }), + }), + } satisfies Partial, + }, + { + provide: KeycloakDatastoreService, + useValue: { + getAllProjects: vi.fn().mockResolvedValue([]), + } satisfies Partial, + }, + { + provide: ConfigurationService, + useValue: { + } satisfies Partial, + }, + ], + }) +} + +describe('keycloakControllerService', () => { + let service: KeycloakControllerService + let keycloak: Mocked + let keycloakDatastore: Mocked + + beforeEach(async () => { + vi.clearAllMocks() + const module = await createKeycloakControllerServiceTestingModule().compile() + service = module.get(KeycloakControllerService) + keycloak = module.get(KeycloakService) + keycloakDatastore = module.get(KeycloakDatastoreService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('reconcile', () => { + const mockProject: ProjectWithDetails = makeProjectWithDetails({ + id: 'project-id', + slug: 'test-project', + ownerId: 'owner-id', + everyonePerms: 0n, + }) + + it('should purge orphans', async () => { + keycloakDatastore.getAllProjects.mockResolvedValue([mockProject]) + + const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project', subGroups: [] }) + const orphanGroup = makeGroupRepresentation({ + id: 'orphan-id', + name: 'orphan-project', + subGroups: [makeGroupRepresentation({ name: 'console' })], + }) + + keycloak.getAllGroups.mockImplementation(async function* () { + yield projectGroup + yield orphanGroup + }) + keycloak.getOrCreateGroupByPath.mockResolvedValue(projectGroup) + keycloak.getGroupMembers.mockResolvedValue([]) + keycloak.getOrCreateSubGroupByName.mockResolvedValue(makeGroupRepresentation({ id: 'console-id', name: 'console' })) + keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) + 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 sync project members', async () => { + const projectWithMembers = makeProjectWithDetails({ + ...mockProject, + members: [ + makeProjectMember({ + user: makeProjectUser({ id: 'user-1', email: 'user1@example.com' }), + roleIds: [], + }), + ], + }) + keycloakDatastore.getAllProjects.mockResolvedValue([projectWithMembers]) + + const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project' }) + keycloak.getOrCreateGroupByPath.mockResolvedValue(projectGroup) + + // Current members: user-2 (extra), missing user-1 + keycloak.getGroupMembers.mockResolvedValue([ + makeUserRepresentation({ id: 'user-2', email: 'user2@example.com' }), + ]) + + keycloak.getOrCreateSubGroupByName.mockResolvedValue(makeGroupRepresentation({ 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 + expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-2', 'group-id') + }) + + it('should sync OIDC role groups', async () => { + const roleWithOidc = makeProjectRole({ + id: 'role-oidc', + permissions: 0n, + oidcGroup: '/oidc-group', + type: 'managed', + }) + const projectWithRole = makeProjectWithDetails({ + ...mockProject, + members: [ + makeProjectMember({ + user: makeProjectUser({ id: 'user-1', email: 'user1@example.com' }), + roleIds: ['role-oidc'], + }), + ], + roles: [roleWithOidc], + }) + keycloakDatastore.getAllProjects.mockResolvedValue([projectWithRole]) + + const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project' }) + const consoleGroup = { id: 'console-id', name: 'console' } + const roleGroup = makeGroupRepresentation({ id: 'role-group-id', name: 'oidc-group', path: '/console/oidc-group' }) + + keycloak.getOrCreateGroupByPath.mockImplementation((path) => { + if (path === '/test-project') return Promise.resolve(projectGroup) + return Promise.resolve({}) + }) + keycloak.getOrCreateConsoleGroup.mockResolvedValue(consoleGroup) + keycloak.getOrCreateRoleGroup.mockResolvedValue(roleGroup) + + // Project members: owner + keycloak.getGroupMembers.mockImplementation((groupId) => { + if (groupId === 'group-id') return Promise.resolve([makeUserRepresentation({ id: 'owner-id' })]) + // Role group members: user-2 (extra), missing user-1 + if (groupId === 'role-group-id') return Promise.resolve([makeUserRepresentation({ id: 'user-2', email: 'user2@example.com' })]) + return Promise.resolve([]) + }) + + keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) + + await service.handleCron() + + // Should create/get role group + expect(keycloak.getOrCreateRoleGroup).toHaveBeenCalledWith(consoleGroup, '/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 + expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-2', 'role-group-id') + }) + + it('should sync environment groups', async () => { + const projectWithEnv = makeProjectWithDetails({ + ...mockProject, + environments: [makeProjectEnvironment({ id: 'env-1', name: 'dev' })], + }) + keycloakDatastore.getAllProjects.mockResolvedValue([projectWithEnv]) + + const projectGroup = makeGroupRepresentation({ + id: 'group-id', + name: 'test-project', + subGroups: [makeGroupRepresentation({ name: 'console', id: 'console-id' })], + }) + keycloak.getOrCreateGroupByPath.mockResolvedValue(projectGroup) + keycloak.getGroupMembers.mockResolvedValue([]) + + // Mock console group retrieval + keycloak.getOrCreateConsoleGroup.mockResolvedValue(makeGroupRepresentation({ id: 'console-id', name: 'console' })) + keycloak.getOrCreateEnvironmentGroups.mockResolvedValue({ + roGroup: makeGroupRepresentation({ id: 'dev-ro-id', name: 'RO' }), + rwGroup: makeGroupRepresentation({ id: 'dev-rw-id', name: 'RW' }), + }) + keycloak.getOrCreateSubGroupByName.mockImplementation((_parentId, name) => { + if (name === 'console') return Promise.resolve(makeGroupRepresentation({ id: 'console-id', name: 'console' })) + if (name === 'dev') return Promise.resolve(makeGroupRepresentation({ id: 'dev-id', name: 'dev' })) + if (name === 'RO') return Promise.resolve(makeGroupRepresentation({ id: 'dev-ro-id', name: 'RO' })) + if (name === 'RW') return Promise.resolve(makeGroupRepresentation({ id: 'dev-rw-id', name: 'RW' })) + return Promise.resolve(makeGroupRepresentation({ id: 'new-id', name })) + }) + + // Mock existing environments: 'staging' (extra) + keycloak.getSubGroups.mockImplementation(async function* (parentId) { + if (parentId === 'console-id') { + yield makeGroupRepresentation({ id: 'staging-id', name: 'staging' }) + } + if (parentId === 'staging-id') { + yield makeGroupRepresentation({ name: 'RO' }) + yield makeGroupRepresentation({ name: 'RW' }) + } + }) + + await service.handleCron() + + // Should create dev group + expect(keycloak.getOrCreateConsoleGroup).toHaveBeenCalledWith({ id: 'group-id', name: 'test-project' }) + // Should create RO/RW groups + expect(keycloak.getOrCreateEnvironmentGroups).toHaveBeenCalledWith({ id: 'console-id', name: 'console' }, projectWithEnv.environments[0]) + // Should delete staging group + expect(keycloak.deleteGroup).toHaveBeenCalledWith('staging-id') + }) + + it('should sync environment permissions', async () => { + const userRo = makeUserRepresentation({ id: 'user-ro', email: 'ro@example.com' }) + const userRw = makeUserRepresentation({ id: 'user-rw', email: 'rw@example.com' }) + const userNone = makeUserRepresentation({ id: 'user-none', email: 'none@example.com' }) + + const projectWithEnvAndMembers = makeProjectWithDetails({ + ...mockProject, + members: [ + makeProjectMember({ + user: makeProjectUser({ id: userRo.id, email: userRo.email }), + roleIds: ['role-ro'], + }), + makeProjectMember({ + user: makeProjectUser({ id: userRw.id, email: userRw.email }), + roleIds: ['role-rw'], + }), + makeProjectMember({ + user: makeProjectUser({ id: userNone.id, email: userNone.email }), + roleIds: [], + }), + ], + roles: [ + makeProjectRole({ id: 'role-ro', permissions: 256n, oidcGroup: '', type: 'managed' }), + makeProjectRole({ id: 'role-rw', permissions: 8n, oidcGroup: '', type: 'managed' }), + ], + environments: [makeProjectEnvironment({ id: 'env-1', name: 'dev' })], + }) + keycloakDatastore.getAllProjects.mockResolvedValue([projectWithEnvAndMembers]) + + const projectGroup = makeGroupRepresentation({ + id: 'group-id', + name: 'test-project', + subGroups: [makeGroupRepresentation({ name: 'console', id: 'console-id' })], + }) + keycloak.getOrCreateGroupByPath.mockResolvedValue(projectGroup) + keycloak.getOrCreateConsoleGroup.mockResolvedValue(makeGroupRepresentation({ id: 'console-id', name: 'console' })) + keycloak.getOrCreateEnvironmentGroups.mockResolvedValue({ + roGroup: makeGroupRepresentation({ id: 'dev-ro-id', name: 'RO' }), + rwGroup: makeGroupRepresentation({ 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(makeGroupRepresentation({ id: 'console-id', name: 'console' })) + if (name === 'dev') return Promise.resolve(makeGroupRepresentation({ id: 'dev-id', name: 'dev' })) + if (name === 'RO') return Promise.resolve(makeGroupRepresentation({ id: 'dev-ro-id', name: 'RO' })) + if (name === 'RW') return Promise.resolve(makeGroupRepresentation({ id: 'dev-rw-id', name: 'RW' })) + return Promise.resolve(makeGroupRepresentation({ 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') + }) + + it('should handle different role types (managed, external, global)', async () => { + const roleManaged = makeProjectRole({ id: 'role-managed', permissions: 0n, oidcGroup: '/managed-group', type: 'managed' }) + const roleExternal = makeProjectRole({ id: 'role-external', permissions: 0n, oidcGroup: '/external-group', type: 'external' }) + const roleGlobal = makeProjectRole({ id: 'role-global', permissions: 0n, oidcGroup: '/global-group', type: 'global' }) + + const projectWithRoles = makeProjectWithDetails({ + ...mockProject, + members: [ + makeProjectMember({ + user: makeProjectUser({ id: 'user-1', email: 'user1@example.com' }), + roleIds: ['role-managed', 'role-external', 'role-global'], + }), + ], + roles: [roleManaged, roleExternal, roleGlobal], + }) + keycloakDatastore.getAllProjects.mockResolvedValue([projectWithRoles]) + + const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project' }) + const consoleGroup = { id: 'console-id', name: 'console' } + const managedGroup = makeGroupRepresentation({ id: 'managed-id', name: 'managed-group' }) + const externalGroup = makeGroupRepresentation({ id: 'external-id', name: 'external-group' }) + const globalGroup = makeGroupRepresentation({ id: 'global-id', name: 'global-group' }) + + keycloak.getOrCreateGroupByPath.mockImplementation((path) => { + if (path === '/test-project') return Promise.resolve(projectGroup) + return Promise.resolve({}) + }) + keycloak.getOrCreateConsoleGroup.mockResolvedValue(consoleGroup) + keycloak.getOrCreateRoleGroup.mockImplementation((_consoleGroup, oidcGroup) => { + if (oidcGroup === '/managed-group') return Promise.resolve({ ...managedGroup, path: '/console/managed-group' }) + if (oidcGroup === '/external-group') return Promise.resolve({ ...externalGroup, path: '/console/external-group' }) + if (oidcGroup === '/global-group') return Promise.resolve({ ...globalGroup, path: '/console/global-group' }) + return Promise.resolve(makeGroupRepresentation({ id: 'new-id', name: oidcGroup, path: `/console/${oidcGroup}` })) + }) + + // Group members + keycloak.getGroupMembers.mockImplementation((groupId) => { + if (groupId === 'group-id') return Promise.resolve([makeUserRepresentation({ id: 'owner-id' })]) + + // Managed: has extra user-2, missing user-1 + if (groupId === 'managed-id') return Promise.resolve([makeUserRepresentation({ id: 'user-2' })]) + + // External: has extra user-2, missing user-1 + if (groupId === 'external-id') return Promise.resolve([makeUserRepresentation({ id: 'user-2' })]) + + // Global: create group if it doesn't exist but no members + if (groupId === 'global-id') return Promise.resolve([makeUserRepresentation({ id: 'user-2' })]) + + return Promise.resolve([]) + }) + + keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) + + await service.handleCron() + + // Managed: should add user-1, remove user-2 + expect(keycloak.getOrCreateRoleGroup).toHaveBeenCalledWith(consoleGroup, '/managed-group') + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-1', 'managed-id') + expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-2', 'managed-id') + + // External: should add user-1, NOT remove user-2 + expect(keycloak.getOrCreateRoleGroup).toHaveBeenCalledWith(consoleGroup, '/external-group') + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-1', 'external-id') + expect(keycloak.removeUserFromGroup).not.toHaveBeenCalledWith('user-2', 'external-id') + + // Global: should sync group but no members + expect(keycloak.getOrCreateRoleGroup).toHaveBeenCalledWith(consoleGroup, '/global-group') + }) + }) +}) 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 000000000..898ff7f2f --- /dev/null +++ b/apps/server-nestjs/src/modules/keycloak/keycloak-controller.service.ts @@ -0,0 +1,654 @@ +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 { KeycloakService } from './keycloak.service' +import type UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation.js' +import { KeycloakDatastoreService } from './keycloak-datastore.service' +import type { ProjectWithDetails } from './keycloak-datastore.service' +import { CONSOLE_GROUP_NAME } from './keycloak.constants' +import type { GroupRepresentationWith } from './keycloack.utils' +import { getAll, isMember, map } from './keycloack.utils' +import { trace } from '@opentelemetry/api' +import z from 'zod' + +const tracer = trace.getTracer('keycloak-controller') + +@Injectable() +export class KeycloakControllerService { + private readonly logger = new Logger(KeycloakControllerService.name) + + constructor( + @Inject(KeycloakService) private readonly keycloak: KeycloakService, + @Inject(KeycloakDatastoreService) private readonly keycloakDatastore: KeycloakDatastoreService, + ) { + this.logger.log('KeycloakControllerService initialized') + } + + @OnEvent('project.upsert') + async handleUpsert(project: ProjectWithDetails) { + return tracer.startActiveSpan('handleUpsert', async (span) => { + try { + span.setAttribute('project.slug', project.slug) + this.logger.log(`Handling project upsert for ${project.slug}`) + await this.ensureProjectGroups([project]) + } catch (error) { + if (error instanceof Error) { + span.recordException(error) + } + throw error + } finally { + span.end() + } + }) + } + + @OnEvent('project.delete') + async handleDelete(project: ProjectWithDetails) { + return tracer.startActiveSpan('handleDelete', async (span) => { + try { + span.setAttribute('project.slug', project.slug) + this.logger.log(`Handling project delete for ${project.slug}`) + await this.purgeOrphanGroups([project]) + } catch (error) { + if (error instanceof Error) { + span.recordException(error) + } + throw error + } finally { + span.end() + } + }) + } + + @Cron(CronExpression.EVERY_HOUR) + async handleCron() { + return tracer.startActiveSpan('handleCron', async (span) => { + try { + this.logger.log('Starting periodic Keycloak reconciliation') + const projects = await this.keycloakDatastore.getAllProjects() + span.setAttribute('projects.count', projects.length) + this.logger.debug(`Reconciling ${projects.length} projects`) + await this.ensureProjectGroups(projects) + await this.purgeOrphanGroups(projects) + } catch (error) { + if (error instanceof Error) { + span.recordException(error) + } + throw error + } finally { + span.end() + } + }) + } + + private async ensureProjectGroups(projects: ProjectWithDetails[]) { + return tracer.startActiveSpan('ensureProjectGroups', async (span) => { + try { + span.setAttribute('projects.count', projects.length) + await Promise.all(projects.map(project => this.ensureProjectGroup(project))) + } catch (error) { + if (error instanceof Error) { + span.recordException(error) + } + throw error + } finally { + span.end() + } + }) + } + + private async ensureProjectGroup(project: ProjectWithDetails) { + return tracer.startActiveSpan('ensureProjectGroup', async (span) => { + try { + span.setAttribute('project.slug', project.slug) + span.setAttribute('project.members.count', project.members.length) + span.setAttribute('project.roles.count', project.roles.length) + span.setAttribute('project.environments.count', project.environments.length) + + const projectGroup = z.object({ + id: z.string(), + name: z.string(), + }).parse(await this.keycloak.getOrCreateGroupByPath(`/${project.slug}`)) + + span.setAttribute('keycloak.project_group.id', projectGroup.id) + + await Promise.all([ + this.ensureProjectGroupMembers(project, projectGroup), + this.ensureConsoleGroup(project, projectGroup), + ]) + } catch (error) { + if (error instanceof Error) { + span.recordException(error) + } + throw error + } finally { + span.end() + } + }) + } + + private async ensureConsoleGroup(project: ProjectWithDetails, group: GroupRepresentationWith<'id'>) { + const consoleGroup = z.object({ + id: z.string(), + name: z.string(), + }).parse(await this.keycloak.getOrCreateConsoleGroup(group)) + await Promise.all([ + this.ensureRoleGroups(project, consoleGroup), + this.ensureEnvironmentGroups(project, consoleGroup), + this.purgeOrphanEnvironmentGroups(project, consoleGroup), + ]) + } + + private async purgeOrphanGroups(projects: ProjectWithDetails[]) { + return tracer.startActiveSpan('purgeOrphanGroups', async (span) => { + try { + const groups = map(this.keycloak.getAllGroups(), async (group) => { + return z.object({ + id: z.string(), + name: z.string(), + subGroups: z.array(z.object({ name: z.string() })), + }).parse(group) + }) + const projectSlugs = new Set(projects.map(p => p.slug)) + const promises: Promise[] = [] + let purgedCount = 0 + + for await (const group of groups) { + if (!projectSlugs.has(group.name)) { + if (this.isOwnedProjectGroup(group)) { + this.logger.log(`Deleting orphan Keycloak group: ${group.name}`) + purgedCount++ + promises.push(this.keycloak.deleteGroup(group.id)) + } + } + } + span.setAttribute('purged.count', purgedCount) + await Promise.all(promises) + } catch (error) { + if (error instanceof Error) { + span.recordException(error) + } + throw error + } finally { + span.end() + } + }) + } + + private isOwnedProjectGroup(group: GroupRepresentationWith<'subGroups'>) { + // 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 maybeAddUserToGroup(userId: string, groupId: string, groupName: string) { + try { + await this.keycloak.addUserToGroup(userId, groupId) + this.logger.log(`Added ${userId} to keycloak group ${groupName}`) + } catch (e) { + if (e.response?.status === 404) { + this.logger.warn(`User ${userId} not found in Keycloak, skipping addition to group ${groupName}`) + } else if (e.response?.status === 409) { + this.logger.debug(`User ${userId} is already a member of keycloak group ${groupName}`) + } else { + throw e + } + } + } + + private async maybeRemoveUserFromGroup(userId: string, groupId: string, groupName: string) { + try { + await this.keycloak.removeUserFromGroup(userId, groupId) + this.logger.log(`Removed ${userId} from keycloak group ${groupName}`) + } catch (e) { + if (e.response?.status === 404) { + this.logger.warn(`User ${userId} not found in Keycloak, skipping removal from group ${groupName}`) + } else { + throw e + } + } + } + + private async ensureProjectGroupMembers( + project: ProjectWithDetails, + group: GroupRepresentationWith<'id' | 'name'>, + ) { + return tracer.startActiveSpan('ensureProjectGroupMembers', async (span) => { + try { + span.setAttribute('project.slug', project.slug) + span.setAttribute('keycloak.group.id', group.id) + + const groupMembers = await this.keycloak.getGroupMembers(group.id) + const desiredUserIds = new Set([project.ownerId, ...project.members.map(m => m.user.id)]) + + span.setAttribute('keycloak.group.members.current', groupMembers.length) + span.setAttribute('keycloak.group.members.desired', desiredUserIds.size) + + let addedCount = 0 + let removedCount = 0 + + await Promise.all([ + ...Array.from(desiredUserIds, async (userId) => { + if (!groupMembers.some(m => m.id === userId)) { + addedCount++ + await this.maybeAddUserToGroup(userId, group.id, group.name) + } + }), + ...groupMembers.map(async (member) => { + if (member.id && !desiredUserIds.has(member.id)) { + removedCount++ + await this.maybeRemoveUserFromGroup(member.id, group.id, group.name) + } + }), + ]) + + span.setAttribute('keycloak.group.members.added', addedCount) + span.setAttribute('keycloak.group.members.removed', removedCount) + } catch (error) { + if (error instanceof Error) { + span.recordException(error) + } + throw error + } finally { + span.end() + } + }) + } + + private async ensureRoleGroups( + project: ProjectWithDetails, + group: GroupRepresentationWith<'id' | 'name'>, + ) { + return tracer.startActiveSpan('ensureRoleGroups', async (span) => { + try { + span.setAttribute('project.slug', project.slug) + span.setAttribute('keycloak.group.id', group.id) + span.setAttribute('project.roles.count', project.roles.length) + + const rolesWithOidcGroup = project.roles.filter(r => !!r.oidcGroup).length + span.setAttribute('project.roles.oidc_group.count', rolesWithOidcGroup) + + await Promise.all(project.roles.map(async (role) => { + if (!role.oidcGroup) return + + return tracer.startActiveSpan('ensureRoleGroup', async (roleSpan) => { + try { + roleSpan.setAttribute('project.slug', project.slug) + roleSpan.setAttribute('role.id', role.id) + roleSpan.setAttribute('role.type', role.type) + roleSpan.setAttribute('role.oidc_group', role.oidcGroup) + + const roleGroup = await this.keycloak.getOrCreateRoleGroup(group, role.oidcGroup) + roleSpan.setAttribute('keycloak.group.id', roleGroup.id) + roleSpan.setAttribute('keycloak.group.path', roleGroup.path) + + const groupMembers = await this.keycloak.getGroupMembers(roleGroup.id) + roleSpan.setAttribute('keycloak.group.members.current', groupMembers.length) + + switch (role.type) { + case 'managed': + await Promise.all([ + this.ensureRoleGroupMembers(project, role, roleGroup, groupMembers), + this.purgeOrphanRoleGroupMembers(project, role, roleGroup, groupMembers), + ]) + break + case 'external': + await this.ensureRoleGroupMembers(project, role, roleGroup, groupMembers) + break + case 'global': + await this.ensureRoleGroupMembers(project, role, roleGroup, groupMembers) + break + default: + throw new Error(`Unknown role type ${role.type}`) + } + } catch (error) { + if (error instanceof Error) { + roleSpan.recordException(error) + } + throw error + } finally { + roleSpan.end() + } + }) + })) + } catch (error) { + if (error instanceof Error) { + span.recordException(error) + } + throw error + } finally { + span.end() + } + }) + } + + private async ensureRoleGroupMembers( + project: ProjectWithDetails, + role: ProjectWithDetails['roles'][number], + group: GroupRepresentationWith<'id' | 'name' | 'path'>, + members: UserRepresentation[], + ) { + return tracer.startActiveSpan('ensureRoleGroupMembers', async (span) => { + try { + span.setAttribute('project.slug', project.slug) + span.setAttribute('role.id', role.id) + span.setAttribute('role.type', role.type) + span.setAttribute('keycloak.group.id', group.id) + span.setAttribute('keycloak.group.members.current', members.length) + + const desiredMemberIds = project.members + .filter(m => m.roleIds.includes(role.id)) + .map(m => m.user.id) + + span.setAttribute('keycloak.group.members.desired', desiredMemberIds.length) + + let addedCount = 0 + await Promise.all(project.members.map(async (member) => { + if (!members.some(m => m.id === member.user.id) && member.roleIds.includes(role.id)) { + addedCount++ + await this.maybeAddUserToGroup(member.user.id, group.id, group.name) + } + })) + + span.setAttribute('keycloak.group.members.added', addedCount) + } catch (error) { + if (error instanceof Error) { + span.recordException(error) + } + throw error + } finally { + span.end() + } + }) + } + + private async purgeOrphanRoleGroupMembers( + project: ProjectWithDetails, + role: ProjectWithDetails['roles'][number], + group: GroupRepresentationWith<'id' | 'name' | 'path'>, + members: UserRepresentation[], + ) { + return tracer.startActiveSpan('purgeOrphanRoleGroupMembers', async (span) => { + try { + span.setAttribute('project.slug', project.slug) + span.setAttribute('role.id', role.id) + span.setAttribute('role.type', role.type) + span.setAttribute('keycloak.group.id', group.id) + span.setAttribute('keycloak.group.members.current', members.length) + + let removedCount = 0 + await Promise.all(members.map(async (member) => { + if (!isMember(project, member)) { + if (!member.id) { + throw new Error(`Failed to create or retrieve role group for ${role.oidcGroup}`) + } + removedCount++ + await this.maybeRemoveUserFromGroup(member.id, group.id, group.name) + } + })) + span.setAttribute('keycloak.group.members.removed', removedCount) + } catch (error) { + if (error instanceof Error) { + span.recordException(error) + } + throw error + } finally { + span.end() + } + }) + } + + private async ensureEnvironmentGroups( + project: ProjectWithDetails, + group: GroupRepresentationWith<'id'>, + ) { + await Promise.all(project.environments.map(environment => + this.ensureEnvironmentGroup(project, environment, group))) + } + + private async ensureEnvironmentGroup( + project: ProjectWithDetails, + environment: ProjectWithDetails['environments'][number], + group: GroupRepresentationWith<'id'>, + ) { + return tracer.startActiveSpan('ensureEnvironmentGroup', async (span) => { + try { + span.setAttribute('project.slug', project.slug) + span.setAttribute('environment.id', environment.id) + span.setAttribute('environment.name', environment.name) + span.setAttribute('project.roles.count', project.roles.length) + + const { roGroup, rwGroup } = z.object({ + roGroup: z.object({ + id: z.string(), + name: z.string(), + }), + rwGroup: z.object({ + id: z.string(), + name: z.string(), + }), + }).parse(await this.keycloak.getOrCreateEnvironmentGroups(group, environment)) + + span.setAttribute('keycloak.env_group.ro.id', roGroup.id) + span.setAttribute('keycloak.env_group.rw.id', rwGroup.id) + + const rolesById = resourceListToDict(project.roles) + + const [roMembers, rwMembers] = await Promise.all([ + this.keycloak.getGroupMembers(roGroup.id), + this.keycloak.getGroupMembers(rwGroup.id), + ]) + + span.setAttribute('keycloak.env_group.ro.members.current', roMembers.length) + span.setAttribute('keycloak.env_group.rw.members.current', rwMembers.length) + + await Promise.all([ + this.ensureEnvironmentGroupMembers( + project, + environment, + rolesById, + roGroup, + rwGroup, + roMembers, + rwMembers, + ), + this.purgeOrphanEnvironmentGroupMembers( + project, + environment, + roGroup, + rwGroup, + roMembers, + rwMembers, + ), + ]) + } catch (error) { + if (error instanceof Error) { + span.recordException(error) + } + throw error + } finally { + span.end() + } + }) + } + + private async ensureEnvironmentGroupMembers( + project: ProjectWithDetails, + environment: ProjectWithDetails['environments'][number], + rolesById: Record, + roGroup: GroupRepresentationWith<'id'>, + rwGroup: GroupRepresentationWith<'id'>, + roMembers: UserRepresentation[], + rwMembers: UserRepresentation[], + ) { + return tracer.startActiveSpan('ensureEnvironmentGroupMembers', async (span) => { + try { + span.setAttribute('project.slug', project.slug) + span.setAttribute('environment.id', environment.id) + span.setAttribute('environment.name', environment.name) + span.setAttribute('keycloak.env_group.ro.id', roGroup.id) + span.setAttribute('keycloak.env_group.rw.id', rwGroup.id) + span.setAttribute('keycloak.env_group.ro.members.current', roMembers.length) + span.setAttribute('keycloak.env_group.rw.members.current', rwMembers.length) + + const projectUserIds = new Set([project.ownerId, ...project.members.map(m => m.user.id)]) + span.setAttribute('project.users.count', projectUserIds.size) + + let roAdded = 0 + let roRemoved = 0 + let rwAdded = 0 + let rwRemoved = 0 + + await Promise.all(Array.from(projectUserIds, async (userId) => { + const perms = this.getUserPermissions(project, rolesById, userId) + + const isInRo = roMembers.some(m => m.id === userId) + if (perms.ro && !isInRo) { + roAdded++ + await this.maybeAddUserToGroup(userId, roGroup.id, `RO group for ${environment.name}`) + } else if (!perms.ro && isInRo) { + roRemoved++ + await this.maybeRemoveUserFromGroup(userId, roGroup.id, `RO group for ${environment.name}`) + } + + const isInRw = rwMembers.some(m => m.id === userId) + if (perms.rw && !isInRw) { + rwAdded++ + await this.maybeAddUserToGroup(userId, rwGroup.id, `RW group for ${environment.name}`) + } else if (!perms.rw && isInRw) { + rwRemoved++ + await this.maybeRemoveUserFromGroup(userId, rwGroup.id, `RW group for ${environment.name}`) + } + })) + + span.setAttribute('keycloak.env_group.ro.members.added', roAdded) + span.setAttribute('keycloak.env_group.ro.members.removed', roRemoved) + span.setAttribute('keycloak.env_group.rw.members.added', rwAdded) + span.setAttribute('keycloak.env_group.rw.members.removed', rwRemoved) + } catch (error) { + if (error instanceof Error) { + span.recordException(error) + } + throw error + } finally { + span.end() + } + }) + } + + private async purgeOrphanEnvironmentGroupMembers( + project: ProjectWithDetails, + environment: ProjectWithDetails['environments'][number], + roGroup: GroupRepresentationWith<'id'>, + rwGroup: GroupRepresentationWith<'id'>, + roMembers: UserRepresentation[], + rwMembers: UserRepresentation[], + ) { + return tracer.startActiveSpan('purgeOrphanEnvironmentGroupMembers', async (span) => { + try { + span.setAttribute('project.slug', project.slug) + span.setAttribute('environment.id', environment.id) + span.setAttribute('environment.name', environment.name) + span.setAttribute('keycloak.env_group.ro.id', roGroup.id) + span.setAttribute('keycloak.env_group.rw.id', rwGroup.id) + span.setAttribute('keycloak.env_group.ro.members.current', roMembers.length) + span.setAttribute('keycloak.env_group.rw.members.current', rwMembers.length) + + const projectUserIds = new Set([project.ownerId, ...project.members.map(m => m.user.id)]) + + let roRemoved = 0 + let rwRemoved = 0 + + await Promise.all([ + ...roMembers.map(async (member) => { + if (!member.id) { + throw new Error(`Failed to create or retrieve RO and RW groups for ${environment.name}`) + } + if (!projectUserIds.has(member.id)) { + roRemoved++ + await this.maybeRemoveUserFromGroup(member.id, roGroup.id, `RO group for ${environment.name}`) + } + }), + ...rwMembers.map(async (member) => { + if (!member.id) { + throw new Error(`Failed to create or retrieve RO and RW groups for ${environment.name}`) + } + if (!projectUserIds.has(member.id)) { + rwRemoved++ + await this.maybeRemoveUserFromGroup(member.id, rwGroup.id, `RW group for ${environment.name}`) + } + }), + ]) + + span.setAttribute('keycloak.env_group.ro.members.removed', roRemoved) + span.setAttribute('keycloak.env_group.rw.members.removed', rwRemoved) + } catch (error) { + if (error instanceof Error) { + span.recordException(error) + } + throw error + } finally { + span.end() + } + }) + } + + private async purgeOrphanEnvironmentGroups( + project: ProjectWithDetails, + group: GroupRepresentationWith<'id' | 'name'>, + ) { + const envGroups = map(this.keycloak.getSubGroups(group.id), envGroup => z.object({ + id: z.string(), + name: z.string(), + }).parse(envGroup)) + + const promises: Promise[] = [] + + for await (const envGroup of envGroups) { + const subGroups = await getAll(map(this.keycloak.getSubGroups(envGroup.id), subgroup => z.object({ + name: z.string(), + }).parse(subgroup))) + + if (this.isEnvironmentGroup(subGroups) && !this.isOwnedEnvironmentGroup(project, envGroup)) { + this.logger.log(`Deleting orphan environment group ${envGroup.name} for project ${project.slug}`) + promises.push( + this.keycloak.deleteGroup(envGroup.id) + .catch(e => this.logger.warn(`Failed to delete environment group ${envGroup.name} for project ${project.slug}`, e)), + ) + } + } + await Promise.all(promises) + } + + private isEnvironmentGroup( + subGroups: GroupRepresentationWith<'name'>[], + ) { + return subGroups.some(subgroup => subgroup.name === 'RO' || subgroup.name === 'RW') + } + + private isOwnedEnvironmentGroup( + project: ProjectWithDetails, + group: GroupRepresentationWith<'name'>, + ) { + return project.environments.some(e => e.name === group.name) + } + + private getUserPermissions( + project: ProjectWithDetails, + rolesById: Record, + userId: string, + ) { + 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 000000000..58405b9c0 --- /dev/null +++ b/apps/server-nestjs/src/modules/keycloak/keycloak-datastore.service.ts @@ -0,0 +1,50 @@ +import { Inject, Injectable } 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, + type: true, + }, + }, + environments: { + select: { + id: true, + name: true, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +@Injectable() +export class KeycloakDatastoreService { + 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.constants.ts b/apps/server-nestjs/src/modules/keycloak/keycloak.constants.ts new file mode 100644 index 000000000..253fc146e --- /dev/null +++ b/apps/server-nestjs/src/modules/keycloak/keycloak.constants.ts @@ -0,0 +1,3 @@ +export const CONSOLE_GROUP_NAME = 'console' +export const GROUPS_PAGINATE_QUERY_MAX = 20 +export const SUBGROUPS_PAGINATE_QUERY_MAX = 20 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 000000000..4275271c2 --- /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 000000000..e5bbcc49f --- /dev/null +++ b/apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts @@ -0,0 +1,77 @@ +import { Test } from '@nestjs/testing' +import 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' +import { makeGroupRepresentation } from './keycloack-testing.utils' + +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 = makeGroupRepresentation({ id: 'id-a', name: 'a', path: '/a' }) + const groupB: GroupRepresentation = makeGroupRepresentation({ 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', name: 'group', path: 'new/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 000000000..a7bb6a55c --- /dev/null +++ b/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts @@ -0,0 +1,223 @@ +import { Inject, Injectable, Logger } 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, SUBGROUPS_PAGINATE_QUERY_MAX } from './keycloak.constants' +import { KeycloakClientService } from './keycloak-client.service' +import { trace } from '@opentelemetry/api' +import type { GroupRepresentationWith } from './keycloack.utils' +import z from 'zod' + +const tracer = trace.getTracer('keycloak-service') + +@Injectable() +export class KeycloakService { + private readonly logger = new Logger(KeycloakService.name) + + constructor( + @Inject(KeycloakClientService) private readonly client: KeycloakClientService, + ) { + } + + async* getAllGroups() { + let first = 0 + while (true) { + const fetched = await this.client.groups.find({ first, max: SUBGROUPS_PAGINATE_QUERY_MAX, briefRepresentation: false }) + if (fetched.length === 0) break + for (const group of fetched) { + yield group + } + if (fetched.length < SUBGROUPS_PAGINATE_QUERY_MAX) break + first += SUBGROUPS_PAGINATE_QUERY_MAX + } + } + + async getGroupByName(name: string): Promise { + return tracer.startActiveSpan('getGroupByName', async (span) => { + try { + span.setAttribute('group.name', name) + const groups = await this.client.groups.find({ search: name, briefRepresentation: false }) ?? [] + const result = groups.find(g => g.name === name) + return result + } catch (error) { + if (error instanceof Error) { + span.recordException(error) + } + throw error + } finally { + span.end() + } + }) + } + + async getGroupByPath(path: string): Promise { + const parts = path.split('/').filter(Boolean) + let current: GroupRepresentationWith<'id'> | undefined + + for (const name of parts) { + current = current + ? await this.getSubGroupByName(current.id, name) + : await this.getRootGroupByName(name) + + if (!current) return undefined + } + return current + } + + private async getSubGroupByName(parentId: string, name: string): Promise | undefined> { + for await (const subgroup of this.getSubGroups(parentId)) { + if (subgroup.name === name) { + const parsed = z.object({ id: z.string() }).and(z.record(z.string(), z.unknown())).safeParse(subgroup) + return parsed.success ? parsed.data : undefined + } + } + return undefined + } + + private async getRootGroupByName(name: string): Promise | undefined> { + const candidates = await this.client.groups.find({ search: name, briefRepresentation: false }) ?? [] + const match = candidates.find(g => g.path === `/${name}`) ?? candidates.find(g => g.name === name) + const parsed = z.object({ id: z.string() }).and(z.record(z.string(), z.unknown())).safeParse(match) + return parsed.success ? parsed.data : undefined + } + + async deleteGroup(id: string): Promise { + await this.client.groups.del({ id }) + } + + async getGroupMembers(groupId: string) { + // The type is lying, it can be undefined + const members = await this.client.groups.listMembers({ id: groupId }) + return members || [] + } + + async createGroup(name: string) { + return tracer.startActiveSpan('createGroup', async (span) => { + try { + span.setAttribute('group.name', name) + this.logger.debug(`Creating Keycloak group: ${name}`) + const result = await this.client.groups.create({ name }) + return { ...result, name } as GroupRepresentation + } catch (error) { + if (error instanceof Error) { + span.recordException(error) + } + throw error + } finally { + span.end() + } + }) + } + + 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: SUBGROUPS_PAGINATE_QUERY_MAX, + first, + }) + if (page.length === 0) break + for (const subgroup of page) { + yield subgroup + } + if (page.length < SUBGROUPS_PAGINATE_QUERY_MAX) break + first += SUBGROUPS_PAGINATE_QUERY_MAX + } + } + + 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: GroupRepresentationWith<'id' | 'name'> | undefined + + for (const name of parts.values()) { + if (current) { + if (!parentId) parentId = current.id + const next = z.object({ id: z.string(), name: z.string() }).safeParse(await this.getOrCreateSubGroupByName(parentId, name)) + if (next.success) current = next.data + } else { + const next = z.object({ id: z.string(), name: z.string() }).safeParse(await this.getGroupByName(name) ?? await this.createGroup(name)) + if (next.success) current = next.data + } + parentId = current?.id + } + + return { ...current, path } as GroupRepresentation + } + + async getOrCreateSubGroupByName(parentId: string, name: string) { + return tracer.startActiveSpan('getOrCreateSubGroupByName', async (span) => { + try { + span.setAttribute('group.name', name) + span.setAttribute('parent.id', parentId) + for await (const subgroup of this.getSubGroups(parentId)) { + if (subgroup.name === name) { + return subgroup + } + } + this.logger.debug(`Creating SubGroup ${name} under parent ${parentId}`) + const createdGroup = await this.client.groups.createChildGroup({ id: parentId }, { name }) + return { id: createdGroup.id, name } satisfies GroupRepresentation + } catch (error) { + if (error instanceof Error) { + span.recordException(error) + } + throw error + } finally { + span.end() + } + }) + } + + async getOrCreateConsoleGroup(projectGroup: GroupRepresentationWith<'id'>) { + return this.getOrCreateSubGroupByName(projectGroup.id, CONSOLE_GROUP_NAME) + } + + async getOrCreateRoleGroup( + consoleGroup: GroupRepresentationWith<'id' | 'name'>, + oidcGroup: string, + ) { + const parts = oidcGroup.split('/').filter(Boolean) + if (parts.length === 0) { + throw new Error(`Invalid oidcGroup for project role: "${oidcGroup}"`) + } + + let current = z.object({ + id: z.string(), + name: z.string(), + }).parse(await this.getOrCreateSubGroupByName(consoleGroup.id, parts[0])) + + for (const name of parts.slice(1)) { + current = z.object({ + id: z.string(), + name: z.string(), + }).parse(await this.getOrCreateSubGroupByName(current.id, name)) + } + + return { ...current, path: `/${consoleGroup.name}/${parts.join('/')}` } satisfies GroupRepresentation + } + + async getOrCreateEnvironmentGroups(consoleGroup: GroupRepresentationWith<'id'>, environment: ProjectWithDetails['environments'][number]) { + const envGroup = z.object({ + id: z.string(), + name: z.string(), + }).parse(await this.getOrCreateSubGroupByName(consoleGroup.id, environment.name)) + const [roGroup, rwGroup] = await Promise.all([ + this.getOrCreateSubGroupByName(envGroup.id, 'RO'), + this.getOrCreateSubGroupByName(envGroup.id, 'RW'), + ]) + return { roGroup, rwGroup } + } +} diff --git a/apps/server-nestjs/src/prisma.ts b/apps/server-nestjs/src/prisma.ts deleted file mode 100644 index 4590932b6..000000000 --- a/apps/server-nestjs/src/prisma.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { PrismaClient } from '@prisma/client' - -const prisma = new PrismaClient() - -export default prisma diff --git a/apps/server-nestjs/src/prisma/schema/project.prisma b/apps/server-nestjs/src/prisma/schema/project.prisma index e76048675..833845eee 100644 --- a/apps/server-nestjs/src/prisma/schema/project.prisma +++ b/apps/server-nestjs/src/prisma/schema/project.prisma @@ -65,6 +65,8 @@ model ProjectRole { permissions BigInt projectId String @db.Uuid position Int @db.SmallInt + oidcGroup String @default("") + type String @default("managed") project Project @relation(fields: [projectId], references: [id]) } diff --git a/apps/server-nestjs/test/app.e2e-spec.ts b/apps/server-nestjs/test/app.e2e-spec.ts deleted file mode 100644 index c6472b81c..000000000 --- a/apps/server-nestjs/test/app.e2e-spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { INestApplication } from '@nestjs/common' -import type { TestingModule } from '@nestjs/testing' -import { Test } from '@nestjs/testing' -import type { App } from 'supertest/types' - -import { MainModule } from './../src/main.module' - -describe('AppController (e2e)', () => { - let app: INestApplication - - beforeEach(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [MainModule], - }).compile() - - app = moduleFixture.createNestApplication() - await app.init() - }) - - it('should be defined', () => { - expect(app).toBeDefined() - }) -}) diff --git a/apps/server-nestjs/test/keycloak.e2e-spec.ts b/apps/server-nestjs/test/keycloak.e2e-spec.ts new file mode 100644 index 000000000..33208cd10 --- /dev/null +++ b/apps/server-nestjs/test/keycloak.e2e-spec.ts @@ -0,0 +1,531 @@ +import type { TestingModule } from '@nestjs/testing' +import { Test } from '@nestjs/testing' +import { KeycloakModule } from '@/modules/keycloak/keycloak.module' +import { KeycloakControllerService } from '@/modules/keycloak/keycloak-controller.service' +import { projectSelect } from '@/modules/keycloak/keycloak-datastore.service' +import { PrismaService } from '@/cpin-module/infrastructure/database/prisma.service' +import { KeycloakService } from '@/modules/keycloak/keycloak.service' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { ConfigurationModule } from '../src/cpin-module/infrastructure/configuration/configuration.module' +import { Logger } from '@nestjs/common' +import { KeycloakClientService } from '@/modules/keycloak/keycloak-client.service' +import { InfrastructureModule } from '@/cpin-module/infrastructure/infrastructure.module' +import z from 'zod' +import { faker } from '@faker-js/faker' + +const canRunKeycloakE2E + = Boolean(process.env.E2E) + && Boolean(process.env.KEYCLOAK_DOMAIN) + && Boolean(process.env.KEYCLOAK_REALM) + && Boolean(process.env.KEYCLOAK_PROTOCOL) + && Boolean(process.env.KEYCLOAK_ADMIN) + && Boolean(process.env.KEYCLOAK_ADMIN_PASSWORD) + +const describeWithKeycloak = describe.runIf(canRunKeycloakE2E) + +describeWithKeycloak('KeycloakController (e2e)', () => { + let moduleRef: TestingModule + let keycloakController: KeycloakControllerService + let keycloakService: KeycloakService + let keycloakClient: KeycloakClientService + let prisma: PrismaService + + let ownerId: string + let testProjectId: string + let testProjectSlug: string + let testRoleName: string + let testRoleId: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [KeycloakModule, ConfigurationModule, InfrastructureModule], + }).compile() + + await moduleRef.init() + + keycloakController = moduleRef.get(KeycloakControllerService) + keycloakService = moduleRef.get(KeycloakService) + keycloakClient = moduleRef.get(KeycloakClientService) + prisma = moduleRef.get(PrismaService) + + ownerId = faker.string.uuid() + testProjectId = faker.string.uuid() + testProjectSlug = faker.helpers.slugify(`test-project-${faker.string.uuid()}`) + testRoleName = faker.helpers.slugify(`test-role-${faker.string.uuid()}`) + testRoleId = faker.string.uuid() + + const ownerEmail = faker.internet.email({ firstName: 'test-owner', provider: 'example.com' }) + + // Create owner in Keycloak + const createdUser = await keycloakClient.users.create({ + id: ownerId, + username: `test-owner-${ownerId}`, + email: ownerEmail, + enabled: true, + firstName: 'Test', + lastName: 'Owner', + }) + if (createdUser.id) { + ownerId = createdUser.id + } + + // Create owner in DB + await prisma.user.create({ + data: { + id: ownerId, + email: ownerEmail, + firstName: 'Test', + lastName: 'Owner', + type: 'human', + }, + }) + }) + + afterAll(async () => { + try { + // Clean Keycloak + const group = await keycloakService.getGroupByPath(`/${testProjectSlug}`) + if (group) { + await keycloakService.deleteGroup(group.id!) + } + + // Clean owner user + if (ownerId) { + await keycloakClient.users.del({ id: ownerId }).catch(() => {}) + if (prisma) { + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + } + + // Clean DB + if (prisma) { + await prisma.projectMembers.deleteMany({ where: { projectId: testProjectId } }) + // Prisma cascade delete should handle roles/envs if configured correctly, but explicit delete is safer + // We catch errors to avoid failing cleanup if tables/relations are different + await prisma.project.deleteMany({ where: { id: testProjectId } }).catch(() => {}) + } + } catch (e: any) { + Logger.warn(`Cleanup failed: ${e.message}`) + } + + await moduleRef.close() + + vi.unstubAllEnvs() + }) + + it('should reconcile and create groups in Keycloak', async () => { + // Create Project in DB + await prisma.project.create({ + data: { + id: testProjectId, + slug: testProjectSlug, + name: testProjectSlug, + ownerId, + description: 'E2E Test Project', + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + roles: { + create: { + id: testRoleId, + name: testRoleName, + oidcGroup: `/${testRoleName}`, + permissions: BigInt(0), + position: 0, + }, + }, + }, + }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Act + await keycloakController.handleUpsert(project) + + // Assert + // Check main project group + const projectGroup = z.object({ + id: z.string(), + name: z.string(), + }).parse(await keycloakService.getGroupByPath(`/${testProjectSlug}`)) + expect(projectGroup.name).toBe(testProjectSlug) + + const consoleGroup = z.object({ + id: z.string(), + name: z.string(), + }).parse(await keycloakService.getGroupByPath(`/${testProjectSlug}/console`)) + expect(consoleGroup.name).toBe('console') + + // Check role group + const roleGroup = z.object({ + name: z.string(), + }).parse(await keycloakService.getGroupByPath(`/${testProjectSlug}/console/${testRoleName}`)) + expect(roleGroup.name).toBe(testRoleName) + + // Check membership (owner should be added) + const members = await keycloakService.getGroupMembers(projectGroup.id) + const isMember = members.some(m => m.id === ownerId) + expect(isMember).toBe(true) + }, 60000) + + it('should add member to project group when added in DB', async () => { + // Create another user in Keycloak and DB + const newUserId = faker.string.uuid() + const newUserEmail = `test-user-${newUserId}@example.com` + + // Create in Keycloak + const kcUser = await keycloakClient.users.create({ + username: `test-user-${newUserId}`, + email: newUserEmail, + enabled: true, + firstName: 'Test', + lastName: 'User', + }) + + // Create in DB + await prisma.user.create({ + data: { + id: kcUser.id, + email: newUserEmail, + firstName: 'Test', + lastName: 'User', + type: 'human', + }, + }) + + // Add member to project in DB + await prisma.projectMembers.create({ + data: { + projectId: testProjectId, + userId: kcUser.id, + roleIds: [testRoleId], + }, + }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Act + await keycloakController.handleUpsert(project) + + // Assert + const projectGroup = z.object({ + id: z.string(), + }).parse(await keycloakService.getGroupByPath(`/${testProjectSlug}`)) + const members = await keycloakService.getGroupMembers(projectGroup.id) + const isMember = members.some(m => m.id === kcUser.id) + expect(isMember).toBe(true) + + // Check role group membership + const roleGroup = z.object({ + id: z.string(), + }).parse(await keycloakService.getGroupByPath(`/${testProjectSlug}/console/${testRoleName}`)) + const roleMembers = await keycloakService.getGroupMembers(roleGroup.id) + const isRoleMember = roleMembers.some(m => m.id === kcUser.id) + expect(isRoleMember).toBe(true) + + // Cleanup user + await keycloakClient.users.del({ id: kcUser.id }) + await prisma.projectMembers.deleteMany({ where: { userId: kcUser.id } }) + await prisma.user.delete({ where: { id: kcUser.id } }) + }, 60000) + + it('should remove member from project group when removed in DB', async () => { + const newUserId = faker.string.uuid() + const newUserEmail = `test-user-remove-${newUserId}@example.com` + + // Create in Keycloak + const kcUser = await keycloakClient.users.create({ + username: `test-user-remove-${newUserId}`, + email: newUserEmail, + enabled: true, + firstName: 'Test', + lastName: 'UserRemove', + }) + + // Create in DB + await prisma.user.create({ + data: { + id: kcUser.id, + email: newUserEmail, + firstName: 'Test', + lastName: 'UserRemove', + type: 'human', + }, + }) + + // Add member to project in DB + await prisma.projectMembers.create({ + data: { + projectId: testProjectId, + userId: kcUser.id, + roleIds: [], // No roles + }, + }) + + let project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Sync add + await keycloakController.handleUpsert(project) + + // Verify added + const projectGroup = z.object({ + id: z.string(), + }).parse(await keycloakService.getGroupByPath(`/${testProjectSlug}`)) + let members = await keycloakService.getGroupMembers(projectGroup.id) + expect(members.some(m => m.id === kcUser.id)).toBe(true) + + // Remove from DB + await prisma.projectMembers.delete({ + where: { + projectId_userId: { + projectId: testProjectId, + userId: kcUser.id, + }, + }, + }) + + project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Sync remove + await keycloakController.handleUpsert(project) + + // Verify removed + members = await keycloakService.getGroupMembers(projectGroup.id) + expect(members.some(m => m.id === kcUser.id)).toBe(false) + + // Cleanup + await keycloakClient.users.del({ id: kcUser.id }) + await prisma.projectMembers.deleteMany({ where: { userId: kcUser.id } }) + await prisma.user.delete({ where: { id: kcUser.id } }) + }, 60000) + + it('should handle non-existent users gracefully', async () => { + // Add a member in DB that does not exist in Keycloak + const fakeUserId = faker.string.uuid() + + await prisma.user.create({ + data: { + id: fakeUserId, + email: `fake-${fakeUserId}@example.com`, + firstName: 'Fake', + lastName: 'User', + type: 'human', + }, + }) + + await prisma.projectMembers.create({ + data: { + projectId: testProjectId, + userId: fakeUserId, + roleIds: [], + }, + }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Act - should not throw + await expect(keycloakController.handleUpsert(project)).resolves.not.toThrow() + + // Cleanup + await prisma.projectMembers.deleteMany({ where: { userId: fakeUserId } }) + await prisma.user.delete({ where: { id: fakeUserId } }) + }, 60000) + + it('should add user back to Keycloak group if missing but present in DB', async () => { + // Create user and add to project in DB + const newUserId = faker.string.uuid() + const newUserEmail = `test-user-sync-${newUserId}@example.com` + + const kcUser = await keycloakClient.users.create({ + username: `test-user-sync-${newUserId}`, + email: newUserEmail, + enabled: true, + firstName: 'Test', + lastName: 'UserSync', + }) + + await prisma.user.create({ + data: { + id: kcUser.id, + email: newUserEmail, + firstName: 'Test', + lastName: 'UserSync', + type: 'human', + }, + }) + + await prisma.projectMembers.create({ + data: { + projectId: testProjectId, + userId: kcUser.id, + roleIds: [], + }, + }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Sync to ensure they are added initially + await keycloakController.handleUpsert(project) + + const projectGroup = z.object({ + id: z.string(), + }).parse(await keycloakService.getGroupByPath(`/${testProjectSlug}`)) + + // Manually remove user from Keycloak group + await keycloakService.removeUserFromGroup(kcUser.id, projectGroup.id) + + // Verify removal + let members = await keycloakService.getGroupMembers(projectGroup.id) + expect(members.some(m => m.id === kcUser.id)).toBe(false) + + // Sync again + await keycloakController.handleUpsert(project) + + // Verify added back + members = await keycloakService.getGroupMembers(projectGroup.id) + expect(members.some(m => m.id === kcUser.id)).toBe(true) + + // Cleanup + await keycloakClient.users.del({ id: kcUser.id }) + await prisma.projectMembers.deleteMany({ where: { userId: kcUser.id } }) + await prisma.user.delete({ where: { id: kcUser.id } }) + }, 60000) + + it('should remove user from Keycloak group if present but missing in DB', async () => { + // Create user + const newUserId = faker.string.uuid() + const newUserEmail = `test-user-orphan-${newUserId}@example.com` + + const kcUser = await keycloakClient.users.create({ + username: `test-user-orphan-${newUserId}`, + email: newUserEmail, + enabled: true, + firstName: 'Test', + lastName: 'UserOrphan', + }) + + // We only need them in Keycloak for this test, but the controller checks if user is in DB to define "missing". + // Actually, `deleteExtraProjectMembers` iterates over Keycloak group members. + // So we don't strictly need the user in DB, but to be "clean" we should probably have them in DB but NOT in the project. + + await prisma.user.create({ + data: { + id: kcUser.id, + email: newUserEmail, + firstName: 'Test', + lastName: 'UserOrphan', + type: 'human', + }, + }) + + // Get project from DB (user is NOT a member) + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Sync to create group + await keycloakController.handleUpsert(project) + + // Manually add user to Keycloak group + const projectGroup = z.object({ + id: z.string(), + }).parse(await keycloakService.getGroupByPath(`/${testProjectSlug}`)) + await keycloakService.addUserToGroup(kcUser.id, projectGroup.id) + + // Verify added + let members = await keycloakService.getGroupMembers(projectGroup.id) + expect(members.some(m => m.id === kcUser.id)).toBe(true) + + // Sync again to remove user + await keycloakController.handleUpsert(project) + + // Verify removed + members = await keycloakService.getGroupMembers(projectGroup.id) + expect(members.some(m => m.id === kcUser.id)).toBe(false) + + // Cleanup + await keycloakClient.users.del({ id: kcUser.id }) + await prisma.projectMembers.deleteMany({ where: { userId: kcUser.id } }) + await prisma.user.delete({ where: { id: kcUser.id } }) + }, 60000) + + it('should recreate project group if deleted in Keycloak', async () => { + // Ensure project exists and is synced + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + await keycloakController.handleUpsert(project) + + const projectGroup = z.object({ + id: z.string(), + }).parse(await keycloakService.getGroupByPath(`/${testProjectSlug}`)) + + // Delete group in Keycloak + await keycloakService.deleteGroup(projectGroup.id) + + // Verify deleted + const deletedProjectGroup = await keycloakService.getGroupByPath(`/${testProjectSlug}`) + expect(deletedProjectGroup).toBeUndefined() + + // Sync + await keycloakController.handleUpsert(project) + + // Verify recreated + const recreatedProjectGroup = z.object({ + name: z.string(), + }).parse(await keycloakService.getGroupByPath(`/${testProjectSlug}`)) + expect(recreatedProjectGroup?.name).toBe(testProjectSlug) + }, 60000) + + it('should recreate role group if deleted in Keycloak', async () => { + // Ensure project exists and is synced + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + await keycloakController.handleUpsert(project) + + const roleGroup = z.object({ + id: z.string(), + }).parse(await keycloakService.getGroupByPath(`/${testProjectSlug}/console/${testRoleName}`)) + + // Delete role group in Keycloak + await keycloakService.deleteGroup(roleGroup.id) + + // Verify deleted + const deletedRoleGroup = await keycloakService.getGroupByPath(`/${testProjectSlug}/console/${testRoleName}`) + expect(deletedRoleGroup).toBeUndefined() + + // Sync + await keycloakController.handleUpsert(project) + + // Verify recreated + const recreatedRoleGroup = z.object({ + name: z.string(), + }).parse(await keycloakService.getGroupByPath(`/${testProjectSlug}/console/${testRoleName}`)) + expect(recreatedRoleGroup?.name).toBe(testRoleName) + }, 60000) +}) diff --git a/apps/server-nestjs/tsconfig.json b/apps/server-nestjs/tsconfig.json index ed7656af9..3dd209ffe 100644 --- a/apps/server-nestjs/tsconfig.json +++ b/apps/server-nestjs/tsconfig.json @@ -8,7 +8,9 @@ "module": "nodenext", "moduleResolution": "nodenext", "paths": { - "@/*": ["src/*"] + "@/*": ["src/*", "../../packages/hooks/src/*"], + "@cpn-console/shared": ["../../packages/shared/src/index.ts"], + "@cpn-console/hooks": ["../../packages/hooks/src/index.ts"] }, "resolvePackageJsonExports": true, "strictBindCallApply": false, diff --git a/apps/server-nestjs/vitest.config.ts b/apps/server-nestjs/vitest.config.ts new file mode 100644 index 000000000..e43e55cda --- /dev/null +++ b/apps/server-nestjs/vitest.config.ts @@ -0,0 +1,19 @@ +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'), + '@cpn-console/shared': path.resolve(__dirname, '../../packages/shared/src/index.ts'), + '@cpn-console/hooks': path.resolve(__dirname, '../../packages/hooks/src/index.ts'), + }, + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + }, + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 00578e9e7..1999f405a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -401,6 +401,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.7.2 version: 2.7.2 @@ -413,9 +416,18 @@ importers: '@nestjs/core': specifier: ^11.1.16 version: 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/event-emitter': + specifier: ^3.0.1 + version: 3.0.1(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) '@nestjs/platform-express': specifier: ^11.1.16 version: 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) + '@nestjs/schedule': + specifier: ^6.1.1 + version: 6.1.1(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) + '@opentelemetry/api': + specifier: ^1.9.0 + version: 1.9.0 '@prisma/client': specifier: ^6.19.2 version: 6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3) @@ -446,9 +458,15 @@ importers: json-2-csv: specifier: ^5.5.10 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.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(keycloak-connect@25.0.6) nestjs-pino: specifier: ^4.6.0 version: 4.6.0(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.3.1)(rxjs@7.8.2) @@ -470,6 +488,12 @@ importers: vitest-mock-extended: specifier: ^2.0.2 version: 2.0.2(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.15)(jsdom@25.0.1)(msw@2.12.10(@types/node@22.19.15)(typescript@5.9.3))(terser@5.46.0)) + yaml: + specifier: ^2.8.2 + version: 2.8.2 + zod: + specifier: ^3.25.76 + version: 3.25.76 devDependencies: '@cpn-console/eslint-config': specifier: workspace:^ @@ -519,6 +543,9 @@ importers: globals: specifier: ^16.5.0 version: 16.5.0 + msw: + specifier: ^2.12.10 + version: 2.12.10(@types/node@22.19.15)(typescript@5.9.3) nodemon: specifier: ^3.1.14 version: 3.1.14 @@ -2617,6 +2644,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.5.5': resolution: {integrity: sha512-ZYP1Z+4qZ+vChNKWI+g1X08F2gCpZEWRlEMjwF03xet7bB5j5898nSJNFT1g6XIqVslHq15R8pt55fcULpRuvw==} engines: {node: '>=18'} @@ -2711,12 +2742,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.16': resolution: {integrity: sha512-IOegr5+ZfUiMKgk+garsSU4MOkPRhm46e6w8Bp1GcO4vCdl9Piz6FlWAzKVfa/U3Hn/DdzSVJOW3TWcQQFdBDw==} peerDependencies: '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 + '@nestjs/schedule@6.1.1': + resolution: {integrity: sha512-kQl1RRgi02GJ0uaUGCrXHCcwISsCsJDciCKe38ykJZgnAeeoeVWs8luWtBo4AqAAXm4nS5K8RlV0smHUJ4+2FA==} + 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: @@ -2765,6 +2808,10 @@ packages: '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + '@opentelemetry/api@1.9.0': + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + engines: {node: '>=8.0.0'} + '@ota-meshi/ast-token-store@0.3.0': resolution: {integrity: sha512-XRO0zi2NIUKq2lUk3T1ecFSld1fMWRKE6naRFGkgkdeosx7IslyUKNv5Dcb5PJTja9tHJoFu0v/7yEpAkrkrTg==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} @@ -3150,6 +3197,9 @@ packages: '@swc/helpers@0.5.19': resolution: {integrity: sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==} + '@testim/chrome-version@1.1.4': + resolution: {integrity: sha512-kIhULpw9TrGYnHp/8VfdcneIcxKnLixmADtukQRtJUmsVlMg0niMkwV0xZmi8hqa57xqilIHjWFA0GKvEjVU5g==} + '@tokenizer/inflate@0.4.1': resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} engines: {node: '>=18'} @@ -3157,6 +3207,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: @@ -3256,6 +3309,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/luxon@3.7.1': + resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -3827,6 +3883,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'} @@ -3900,6 +3960,10 @@ packages: engines: {node: '>=6.0.0'} 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==} @@ -4076,6 +4140,11 @@ packages: resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} engines: {node: '>=6.0'} + chromedriver@146.0.2: + resolution: {integrity: sha512-/A6ht59pGGrV3bU6eC//yH6W+NRexVGXy/KEe+pNn1MP5Xb34krSA02bGlYuA5XCrfdXPsFI//slvsOBwH//4Q==} + engines: {node: '>=20'} + hasBin: true + ci-info@4.4.0: resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} engines: {node: '>=8'} @@ -4205,6 +4274,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==} @@ -4313,6 +4385,10 @@ packages: cron-validator@1.4.0: resolution: {integrity: sha512-wGcJ9FCy65iaU6egSH8b5dZYJF7GU/3Jh06wzaT9lsa5dbqExjljmu+0cJ8cpKn+vUyZa/EM4WAxeLR6SypJXw==} + cron@4.4.0: + resolution: {integrity: sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==} + engines: {node: '>=18.x'} + cross-spawn@6.0.6: resolution: {integrity: sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==} engines: {node: '>=4.8'} @@ -4363,6 +4439,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'} @@ -4399,6 +4479,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'} @@ -4447,6 +4536,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'} @@ -4672,6 +4765,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'} @@ -4920,6 +5018,9 @@ packages: eventemitter2@6.4.7: resolution: {integrity: sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg==} + eventemitter2@6.4.9: + resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==} + eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} @@ -5223,6 +5324,10 @@ packages: get-tsconfig@4.13.6: resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} + get-uri@6.0.5: + resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==} + engines: {node: '>= 14'} + getpass@0.1.7: resolution: {integrity: sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==} @@ -5533,6 +5638,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'} @@ -5715,6 +5824,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'} @@ -5727,6 +5839,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==} @@ -5895,6 +6011,10 @@ packages: jws@4.0.1: resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + keycloak-connect@25.0.6: + resolution: {integrity: sha512-UbOj4ee2u1LYNff5rkcVuWxc/GTaoga6TKg+/ylJd7djaGh20HVI3qmAVxfGme3BZPIa6/pxEIDpK4KQn+xx1w==} + engines: {node: '>=14'} + keycloak-js@26.2.3: resolution: {integrity: sha512-widjzw/9T6bHRgEp6H/Se3NCCarU7u5CwFKBcwtu7xfA1IfdZb+7Q7/KGusAnBo34Vtls8Oz9vzSqkQvQ7+b4Q==} @@ -6031,6 +6151,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.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + magic-regexp@0.10.0: resolution: {integrity: sha512-Uly1Bu4lO1hwHUW0CQeSWuRtzCMNO00CmXtS8N6fyvB3B979GOEEeAkiTUDsmbYLAbvpUS/Kt5c4ibosAzVyVg==} @@ -6314,6 +6442,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==} @@ -6375,6 +6506,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.6.0: resolution: {integrity: sha512-MzSgnOu9MhRT/f7MsvoDnxat11D9JRJYwL1t+tI6J44UrNz9rUVDpceEh9VFsyfiiIJKUri5S+/snMOoaWh7YA==} engines: {node: '>= 14'} @@ -6384,6 +6526,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==} @@ -6562,6 +6708,14 @@ packages: resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} engines: {node: '>=10'} + 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==} @@ -6820,12 +6974,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==} @@ -7267,6 +7428,10 @@ packages: resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} engines: {node: '>=20'} + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + smob@1.6.1: resolution: {integrity: sha512-KAkBqZl3c2GvNgNhcoyJae1aKldDW0LO279wF9bk1PnluRTETKBq0WyzRXxEhoQLk56yHaOY4JCBEKDuJIET5g==} engines: {node: '>=20.0.0'} @@ -7275,6 +7440,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.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} @@ -7560,6 +7733,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'} @@ -8003,6 +8179,10 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + url-join@5.0.0: + resolution: {integrity: sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + url-template@3.1.1: resolution: {integrity: sha512-4oszoaEKE/mQOtAmdMWqIRHmkxWkUZMnXFnjQ5i01CuRSK3uluxcH1MRVVVWmhlnzT1SCDfKxxficm2G37qzCA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -10263,6 +10443,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.5.5': dependencies: camelize-ts: 3.0.0 @@ -10318,7 +10504,6 @@ snapshots: is-node-process: 1.2.0 outvariant: 1.4.3 strict-event-emitter: 0.5.1 - optional: true '@napi-rs/wasm-runtime@1.1.1': dependencies: @@ -10387,6 +10572,12 @@ snapshots: optionalDependencies: '@nestjs/platform-express': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) + '@nestjs/event-emitter@3.0.1(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + eventemitter2: 6.4.9 + '@nestjs/platform-express@11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)': dependencies: '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -10399,6 +10590,12 @@ snapshots: transitivePeerDependencies: - supports-color + '@nestjs/schedule@6.1.1(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + cron: 4.4.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) @@ -10436,17 +10633,16 @@ snapshots: dependencies: consola: 3.4.2 - '@open-draft/deferred-promise@2.2.0': - optional: true + '@open-draft/deferred-promise@2.2.0': {} '@open-draft/logger@0.3.0': dependencies: is-node-process: 1.2.0 outvariant: 1.4.3 - optional: true - '@open-draft/until@2.1.0': - optional: true + '@open-draft/until@2.1.0': {} + + '@opentelemetry/api@1.9.0': {} '@ota-meshi/ast-token-store@0.3.0': {} @@ -10726,6 +10922,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 @@ -10735,6 +10934,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.15)(zod@3.25.76)': optionalDependencies: '@types/node': 22.19.15 @@ -10867,6 +11069,8 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/luxon@3.7.1': {} + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -10904,8 +11108,7 @@ snapshots: '@types/sizzle@2.3.10': optional: true - '@types/statuses@2.0.6': - optional: true + '@types/statuses@2.0.6': {} '@types/superagent@8.1.9': dependencies: @@ -11719,6 +11922,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: {} @@ -11791,6 +11999,9 @@ snapshots: baseline-browser-mapping@2.10.0: {} + basic-ftp@5.2.0: + optional: true + bcrypt-pbkdf@1.0.2: dependencies: tweetnacl: 0.14.5 @@ -11845,8 +12056,7 @@ snapshots: dependencies: fill-range: 7.1.1 - brorand@1.1.0: - optional: true + brorand@1.1.0: {} browserslist@4.28.1: dependencies: @@ -12005,6 +12215,20 @@ snapshots: chrome-trace-event@1.0.4: {} + chromedriver@146.0.2: + 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.4.0: {} cidr-regex@3.1.1: @@ -12127,6 +12351,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: {} @@ -12171,8 +12398,7 @@ snapshots: cookie@0.7.2: {} - cookie@1.1.1: - optional: true + cookie@1.1.1: {} cookiejar@2.1.4: {} @@ -12219,6 +12445,11 @@ snapshots: cron-validator@1.4.0: {} + cron@4.4.0: + dependencies: + '@types/luxon': 3.7.1 + luxon: 3.7.2 + cross-spawn@6.0.6: dependencies: nice-try: 1.0.5 @@ -12314,6 +12545,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 @@ -12352,6 +12586,11 @@ snapshots: supports-color: 8.1.1 optional: true + debug@4.3.1: + dependencies: + ms: 2.1.2 + optional: true + debug@4.4.3: dependencies: ms: 2.1.3 @@ -12403,6 +12642,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: {} @@ -12508,7 +12754,6 @@ snapshots: inherits: 2.0.4 minimalistic-assert: 1.0.1 minimalistic-crypto-utils: 1.0.1 - optional: true emoji-regex@10.6.0: {} @@ -12700,6 +12945,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@10.0.3(jiti@2.6.1)): dependencies: eslint: 10.0.3(jiti@2.6.1) @@ -13057,6 +13311,8 @@ snapshots: eventemitter2@6.4.7: optional: true + eventemitter2@6.4.9: {} + eventemitter3@5.0.4: {} events@3.3.0: {} @@ -13118,6 +13374,17 @@ snapshots: extend@3.0.2: {} + extract-zip@2.0.1: + dependencies: + debug: 4.4.3 + 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) @@ -13461,6 +13728,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 + transitivePeerDependencies: + - supports-color + optional: true + getpass@0.1.7: dependencies: assert-plus: 1.0.0 @@ -13593,8 +13869,7 @@ snapshots: jwk-to-pem: 2.0.7 jws: 4.0.1 - graphql@16.13.1: - optional: true + graphql@16.13.1: {} gzip-size@6.0.0: dependencies: @@ -13626,7 +13901,6 @@ snapshots: dependencies: inherits: 2.0.4 minimalistic-assert: 1.0.1 - optional: true hasha@5.2.2: dependencies: @@ -13644,8 +13918,7 @@ snapshots: he@1.2.0: {} - headers-polyfill@4.0.3: - optional: true + headers-polyfill@4.0.3: {} helmet@7.2.0: {} @@ -13656,7 +13929,6 @@ snapshots: hash.js: 1.1.7 minimalistic-assert: 1.0.1 minimalistic-crypto-utils: 1.0.1 - optional: true hookified@1.15.1: {} @@ -13778,6 +14050,9 @@ snapshots: dependencies: fp-ts: 2.16.11 + ip-address@10.1.0: + optional: true + ip-regex@4.3.0: {} ipaddr.js@1.9.1: {} @@ -13874,8 +14149,7 @@ snapshots: is-negative-zero@2.0.3: {} - is-node-process@1.2.0: - optional: true + is-node-process@1.2.0: {} is-number-object@1.1.1: dependencies: @@ -13940,6 +14214,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: @@ -13951,6 +14228,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: {} @@ -14141,7 +14425,6 @@ snapshots: asn1.js: 5.4.1 elliptic: 6.6.1 safe-buffer: 5.2.1 - optional: true jws@4.0.1: dependencies: @@ -14149,6 +14432,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.2 + transitivePeerDependencies: + - debug + - supports-color + keycloak-js@26.2.3: {} keyv@4.5.4: @@ -14296,6 +14588,11 @@ snapshots: dependencies: yallist: 3.1.1 + lru-cache@7.18.3: + optional: true + + luxon@3.7.2: {} + magic-regexp@0.10.0: dependencies: estree-walker: 3.0.3 @@ -14700,8 +14997,7 @@ snapshots: minimalistic-assert@1.0.1: {} - minimalistic-crypto-utils@1.0.1: - optional: true + minimalistic-crypto-utils@1.0.1: {} minimatch@10.2.4: dependencies: @@ -14740,6 +15036,9 @@ snapshots: mrmime@2.0.1: {} + ms@2.1.2: + optional: true + ms@2.1.3: {} msw@2.12.10(@types/node@22.19.15)(typescript@5.9.3): @@ -14766,7 +15065,6 @@ snapshots: typescript: 5.9.3 transitivePeerDependencies: - '@types/node' - optional: true msw@2.12.10(@types/node@24.12.0)(typescript@5.7.2): dependencies: @@ -14854,6 +15152,12 @@ snapshots: neo-async@2.6.2: {} + nest-keycloak-connect@1.10.1(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(keycloak-connect@25.0.6): + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + keycloak-connect: 25.0.6 + nestjs-pino@4.6.0(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.3.1)(rxjs@7.8.2): dependencies: '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -14861,6 +15165,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: {} @@ -15044,8 +15351,7 @@ snapshots: ospath@1.2.2: optional: true - outvariant@1.4.3: - optional: true + outvariant@1.4.3: {} own-keys@1.0.1: dependencies: @@ -15098,6 +15404,26 @@ snapshots: aggregate-error: 3.1.0 optional: true + pac-proxy-agent@7.2.0: + dependencies: + '@tootallnate/quickjs-emscripten': 0.23.0 + agent-base: 7.1.4 + debug: 4.4.3 + 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.6.0: {} @@ -15154,8 +15480,7 @@ snapshots: lru-cache: 11.2.6 minipass: 7.1.3 - path-to-regexp@6.3.0: - optional: true + path-to-regexp@6.3.0: {} path-to-regexp@8.3.0: {} @@ -15354,11 +15679,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 + 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.4: @@ -15556,8 +15898,7 @@ snapshots: ret@0.4.3: {} - rettime@0.10.1: - optional: true + rettime@0.10.1: {} reusify@1.1.0: {} @@ -15874,12 +16215,30 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + smart-buffer@4.2.0: + optional: true + smob@1.6.1: {} 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 + 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.1: dependencies: atomic-sleep: 1.0.0 @@ -15960,8 +16319,7 @@ snapshots: streamsearch@1.1.0: {} - strict-event-emitter@0.5.1: - optional: true + strict-event-emitter@0.5.1: {} string-argv@0.3.2: {} @@ -16259,6 +16617,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: @@ -16324,8 +16690,7 @@ snapshots: tldts-core@6.1.86: {} - tldts-core@7.0.25: - optional: true + tldts-core@7.0.25: {} tldts@6.1.86: dependencies: @@ -16334,7 +16699,6 @@ snapshots: tldts@7.0.25: dependencies: tldts-core: 7.0.25 - optional: true tmp@0.2.5: optional: true @@ -16373,7 +16737,6 @@ snapshots: tough-cookie@6.0.0: dependencies: tldts: 7.0.25 - optional: true tr46@0.0.3: {} @@ -16728,8 +17091,7 @@ snapshots: picomatch: 4.0.3 webpack-virtual-modules: 0.6.2 - until-async@3.0.2: - optional: true + until-async@3.0.2: {} untildify@4.0.0: optional: true @@ -16746,6 +17108,8 @@ snapshots: dependencies: punycode: 2.3.1 + url-join@5.0.0: {} + url-template@3.1.1: {} util-deprecate@1.0.2: {}