diff --git a/AGENTS.md b/AGENTS.md index 5f52cd4dd..1602cf9d0 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..257b26040 --- /dev/null +++ b/apps/server-nestjs/documentation/Modularisation-de-console-server/02-ARCHITECTURE-MODULES.md @@ -0,0 +1,120 @@ +# 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 (API publique)**, **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 +├── -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 +.service.ts + ↙ ↘ +-client.service.ts -datastore.service.ts +``` + +Règles pratiques : + +- Le `.service.ts` est un entrypoint interne (cron, events, reconcile) et orchestre en appelant directement le `client` (et le `datastore` si nécessaire), sans dépendre du `service`. +- Le `service` contient les règles métier (décisions, transformations, validations) et s’appuie sur le `client` (et le `datastore` quand la lecture/écriture DB fait partie du cas d’usage). +- 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 le `service` du module (`.service.ts`) qui constitue l’API publique. + +### `-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 : + +- Orchestrateur de workflows : `@Cron`, `@OnEvent`, reconcile périodique, tâches “batch”. +- Coordination entre `client` et `datastore` (sans dépendre du `service`). +- 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 : + +- 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 be3a2ddbb..78a1af305 100644 --- a/apps/server-nestjs/package.json +++ b/apps/server-nestjs/package.json @@ -44,11 +44,14 @@ "@fastify/swagger-ui": "^4.2.0", "@gitbeaker/core": "^40.6.0", "@gitbeaker/rest": "^40.6.0", + "@keycloak/keycloak-admin-client": "^24.0.0", "@kubernetes-models/argo-cd": "^2.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", "@nestjs/terminus": "^11.1.1", "@opentelemetry/api": "^1.9.0", "@opentelemetry/auto-instrumentations-node": "^0.70.1", @@ -76,7 +79,9 @@ "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", "undici": "^7.24.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:^", @@ -95,6 +100,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/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/main.module.ts b/apps/server-nestjs/src/main.module.ts index c92e41621..6fbba7bb4 100644 --- a/apps/server-nestjs/src/main.module.ts +++ b/apps/server-nestjs/src/main.module.ts @@ -1,10 +1,20 @@ import { Module } from '@nestjs/common' +import { EventEmitterModule } from '@nestjs/event-emitter' +import { ScheduleModule } from '@nestjs/schedule' import { CpinModule } from './cpin-module/cpin.module' import { HealthzModule } from './modules/healthz/healthz.module' +import { KeycloakModule } from './modules/keycloak/keycloak.module' import { VersionModule } from './modules/version/version.module' @Module({ - imports: [CpinModule, HealthzModule, VersionModule], + imports: [ + CpinModule, + KeycloakModule, + HealthzModule, + VersionModule, + EventEmitterModule.forRoot(), + ScheduleModule.forRoot(), + ], controllers: [], providers: [], }) diff --git a/apps/server-nestjs/src/modules/healthz/healthz.controller.ts b/apps/server-nestjs/src/modules/healthz/healthz.controller.ts index 25472da06..68724cd78 100644 --- a/apps/server-nestjs/src/modules/healthz/healthz.controller.ts +++ b/apps/server-nestjs/src/modules/healthz/healthz.controller.ts @@ -1,12 +1,14 @@ import { Controller, Get, Inject } from '@nestjs/common' import { HealthCheck, HealthCheckService } from '@nestjs/terminus' import { DatabaseHealthService } from '../../cpin-module/infrastructure/database/database-health.service' +import { KeycloakHealthService } from '../keycloak/keycloak-health.service' @Controller('api/v1/healthz') export class HealthzController { constructor( @Inject(HealthCheckService) private readonly health: HealthCheckService, @Inject(DatabaseHealthService) private readonly database: DatabaseHealthService, + @Inject(KeycloakHealthService) private readonly keycloak: KeycloakHealthService, ) {} @Get() @@ -14,6 +16,7 @@ export class HealthzController { check() { return this.health.check([ () => this.database.check('database'), + () => this.keycloak.check('keycloak'), ]) } } diff --git a/apps/server-nestjs/src/modules/healthz/healthz.module.ts b/apps/server-nestjs/src/modules/healthz/healthz.module.ts index 58b31f46b..7e7307126 100644 --- a/apps/server-nestjs/src/modules/healthz/healthz.module.ts +++ b/apps/server-nestjs/src/modules/healthz/healthz.module.ts @@ -1,10 +1,15 @@ import { Module } from '@nestjs/common' import { TerminusModule } from '@nestjs/terminus' +import { KeycloakModule } from '../keycloak/keycloak.module' import { DatabaseModule } from '../../cpin-module/infrastructure/database/database.module' import { HealthzController } from './healthz.controller' @Module({ - imports: [TerminusModule, DatabaseModule], + imports: [ + TerminusModule, + DatabaseModule, + KeycloakModule, + ], controllers: [HealthzController], }) export class HealthzModule {} 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..9f8062bf2 --- /dev/null +++ b/apps/server-nestjs/src/modules/keycloak/keycloack-testing.utils.ts @@ -0,0 +1,86 @@ +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' + +import { faker } from '@faker-js/faker' + +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/keycloak-client.service.ts b/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts new file mode 100644 index 000000000..204c331e8 --- /dev/null +++ b/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts @@ -0,0 +1,242 @@ +import type KcAdminClient from '@keycloak/keycloak-admin-client' +import type GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation' +import type { OnModuleInit } from '@nestjs/common' +import type { ProjectWithDetails } from './keycloak-datastore.service' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import z from 'zod' +import { ConfigurationService } from '../../cpin-module/infrastructure/configuration/configuration.service' +import { StartActiveSpan } from '../../cpin-module/infrastructure/telemetry/telemetry.decorator' +import { CONSOLE_GROUP_NAME, SUBGROUPS_PAGINATE_QUERY_MAX } from './keycloak.constants' + +export type GroupRepresentationWith = GroupRepresentation & Required> + +export const KEYCLOAK_ADMIN_CLIENT = Symbol('KEYCLOAK_ADMIN_CLIENT') + +@Injectable() +export class KeycloakClientService implements OnModuleInit { + private readonly logger = new Logger(KeycloakClientService.name) + + constructor( + @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(KEYCLOAK_ADMIN_CLIENT) private readonly client: KcAdminClient, + ) { + } + + 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 + } + } + + @StartActiveSpan() + async getGroupByName(name: string): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('group.name', name) + const groups = await this.client.groups.find({ search: name, briefRepresentation: false }) ?? [] + return groups.find(g => g.name === name) + } + + 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 { + const span = trace.getActiveSpan() + span?.setAttribute('keycloak.group.id', id) + await this.client.groups.del({ id }) + } + + async getGroupMembers(groupId: string) { + const span = trace.getActiveSpan() + span?.setAttribute('keycloak.group.id', groupId) + const members = await this.client.groups.listMembers({ id: groupId }) + return members || [] + } + + @StartActiveSpan() + async createGroup(name: string) { + const span = trace.getActiveSpan() + 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 + } + + async addUserToGroup(userId: string, groupId: string) { + const span = trace.getActiveSpan() + span?.setAttribute('keycloak.group.id', groupId) + return this.client.users.addToGroup({ id: userId, groupId }) + } + + async removeUserFromGroup(userId: string, groupId: string) { + const span = trace.getActiveSpan() + span?.setAttribute('keycloak.group.id', groupId) + 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 span = trace.getActiveSpan() + span?.setAttribute('group.path.depth', path.split('/').filter(Boolean).length) + 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 + } + + @StartActiveSpan() + async getOrCreateSubGroupByName(parentId: string, name: string) { + const span = trace.getActiveSpan() + 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 + } + + async getOrCreateConsoleGroup(projectGroup: GroupRepresentationWith<'id'>) { + const span = trace.getActiveSpan() + span?.setAttribute('keycloak.group.id', projectGroup.id) + return this.getOrCreateSubGroupByName(projectGroup.id, CONSOLE_GROUP_NAME) + } + + async getOrCreateRoleGroup( + consoleGroup: GroupRepresentationWith<'id' | 'name'>, + oidcGroup: string, + ) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'keycloak.group.id': consoleGroup.id, + 'role.oidc_group.present': !!oidcGroup, + 'role.oidc_group.depth': oidcGroup.split('/').filter(Boolean).length, + }) + 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 span = trace.getActiveSpan() + span?.setAttributes({ + 'keycloak.group.id': consoleGroup.id, + 'environment.id': environment.id, + 'environment.name': environment.name, + }) + 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 } + } + + async onModuleInit() { + if (!this.config.keycloakRealm) { + this.logger.fatal('Keycloak realm not configured') + return + } + if (!this.config.keycloakAdmin || !this.config.keycloakAdminPassword) { + this.logger.fatal('Keycloak admin or admin password not configured') + return + } + this.client.setConfig({ realmName: this.config.keycloakRealm }) + await this.client.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-datastore.service.ts b/apps/server-nestjs/src/modules/keycloak/keycloak-datastore.service.ts new file mode 100644 index 000000000..81673c526 --- /dev/null +++ b/apps/server-nestjs/src/modules/keycloak/keycloak-datastore.service.ts @@ -0,0 +1,50 @@ +import type { Prisma } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +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-health.service.ts b/apps/server-nestjs/src/modules/keycloak/keycloak-health.service.ts new file mode 100644 index 000000000..ff84eeb07 --- /dev/null +++ b/apps/server-nestjs/src/modules/keycloak/keycloak-health.service.ts @@ -0,0 +1,29 @@ +import { Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { ConfigurationService } from '../../cpin-module/infrastructure/configuration/configuration.service' + +@Injectable() +export class KeycloakHealthService { + constructor( + @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + const protocol = this.config.keycloakProtocol + const domain = this.config.keycloakDomain + const realm = this.config.keycloakRealm + if (!protocol || !domain || !realm) return indicator.down('Not configured') + + const baseUrl = `${protocol}://${domain}` + const url = new URL(`/realms/${encodeURIComponent(realm)}/.well-known/openid-configuration`, baseUrl).toString() + try { + const response = await fetch(url, { method: 'GET' }) + if (response.status < 500) return indicator.up({ httpStatus: response.status }) + return indicator.down({ httpStatus: response.status }) + } catch (error) { + return indicator.down(error instanceof Error ? error.message : String(error)) + } + } +} 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..d9d805809 --- /dev/null +++ b/apps/server-nestjs/src/modules/keycloak/keycloak.module.ts @@ -0,0 +1,30 @@ +import KcAdminClient from '@keycloak/keycloak-admin-client' +import { Module } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { ConfigurationService } from 'src/cpin-module/infrastructure/configuration/configuration.service' +import { ConfigurationModule } from '../../cpin-module/infrastructure/configuration/configuration.module' +import { InfrastructureModule } from '../../cpin-module/infrastructure/infrastructure.module' +import { KEYCLOAK_ADMIN_CLIENT, KeycloakClientService } from './keycloak-client.service' +import { KeycloakDatastoreService } from './keycloak-datastore.service' +import { KeycloakHealthService } from './keycloak-health.service' +import { KeycloakService } from './keycloak.service' + +@Module({ + imports: [ConfigurationModule, InfrastructureModule], + providers: [ + { + provide: KEYCLOAK_ADMIN_CLIENT, + inject: [ConfigurationService], + useFactory: (config: ConfigurationService) => new KcAdminClient({ + baseUrl: `${config.keycloakProtocol}://${config.keycloakDomain}`, + }), + }, + HealthIndicatorService, + KeycloakClientService, + KeycloakDatastoreService, + KeycloakHealthService, + KeycloakService, + ], + exports: [KeycloakClientService, KeycloakHealthService], +}) +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..0f8e446c1 --- /dev/null +++ b/apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts @@ -0,0 +1,381 @@ +import type { Mocked } from 'vitest' +import type { ProjectWithDetails } from './keycloak-datastore.service' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ConfigurationService } from '../../cpin-module/infrastructure/configuration/configuration.service' +import { + makeGroupRepresentation, + makeProjectEnvironment, + makeProjectMember, + makeProjectRole, + makeProjectUser, + makeProjectWithDetails, + makeUserRepresentation, +} from './keycloack-testing.utils' +import { KeycloakClientService } from './keycloak-client.service' +import { KeycloakDatastoreService } from './keycloak-datastore.service' +import { KeycloakService } from './keycloak.service' + +function createKeycloakControllerServiceTestingModule() { + return Test.createTestingModule({ + providers: [ + KeycloakService, + { + provide: KeycloakClientService, + 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('keycloakService', () => { + let service: KeycloakService + let keycloak: Mocked + let keycloakDatastore: Mocked + + beforeEach(async () => { + vi.clearAllMocks() + const module = await createKeycloakControllerServiceTestingModule().compile() + service = module.get(KeycloakService) + keycloak = module.get(KeycloakClientService) + 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.service.ts b/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts new file mode 100644 index 000000000..7973a42e6 --- /dev/null +++ b/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts @@ -0,0 +1,580 @@ +import type UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation' +import type { GroupRepresentationWith } from './keycloak-client.service' +import type { ProjectWithDetails } from './keycloak-datastore.service' +import { getPermsByUserRoles, ProjectAuthorized, resourceListToDict } from '@cpn-console/shared' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { Cron, CronExpression } from '@nestjs/schedule' +import { trace } from '@opentelemetry/api' +import z from 'zod' +import { StartActiveSpan } from '../../cpin-module/infrastructure/telemetry/telemetry.decorator' +import { KeycloakClientService } from './keycloak-client.service' +import { KeycloakDatastoreService } from './keycloak-datastore.service' +import { CONSOLE_GROUP_NAME } from './keycloak.constants' + +@Injectable() +export class KeycloakService { + private readonly logger = new Logger(KeycloakService.name) + + constructor( + @Inject(KeycloakClientService) private readonly keycloak: KeycloakClientService, + @Inject(KeycloakDatastoreService) private readonly keycloakDatastore: KeycloakDatastoreService, + ) { + this.logger.log('KeycloakService initialized') + } + + @OnEvent('project.upsert') + @StartActiveSpan() + async handleUpsert(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling project upsert for ${project.slug}`) + await this.ensureProjectGroups([project]) + } + + @OnEvent('project.delete') + @StartActiveSpan() + async handleDelete(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling project delete for ${project.slug}`) + await this.purgeOrphanGroups([project]) + } + + @Cron(CronExpression.EVERY_HOUR) + @StartActiveSpan() + async handleCron() { + const span = trace.getActiveSpan() + this.logger.log('Starting periodic Keycloak reconciliation') + const projects = await this.keycloakDatastore.getAllProjects() + span?.setAttribute('keycloak.projects.count', projects.length) + this.logger.debug(`Reconciling ${projects.length} projects`) + await this.ensureProjectGroups(projects) + await this.purgeOrphanGroups(projects) + } + + @StartActiveSpan() + private async ensureProjectGroups(projects: ProjectWithDetails[]) { + const span = trace.getActiveSpan() + span?.setAttribute('keycloak.projects.count', projects.length) + await Promise.all(projects.map(project => this.ensureProjectGroup(project))) + } + + @StartActiveSpan() + private async ensureProjectGroup(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'project.members.count': project.members.length, + 'project.roles.count': project.roles.length, + '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), + ]) + } + + @StartActiveSpan() + private async ensureConsoleGroup(project: ProjectWithDetails, group: GroupRepresentationWith<'id'>) { + const span = trace.getActiveSpan() + span?.setAttribute('keycloak.console_group.id', group.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), + ]) + } + + @StartActiveSpan() + private async purgeOrphanGroups(projects: ProjectWithDetails[]) { + const span = trace.getActiveSpan() + 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) + } + + 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 + } + } + } + + @StartActiveSpan() + private async ensureProjectGroupMembers( + project: ProjectWithDetails, + group: GroupRepresentationWith<'id' | 'name'>, + ) { + const span = trace.getActiveSpan() + 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) + } + + @StartActiveSpan() + private async ensureRoleGroups( + project: ProjectWithDetails, + group: GroupRepresentationWith<'id' | 'name'>, + ) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'keycloak.group.id': group.id, + '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(role => this.ensureRoleGroup(project, role, group))) + } + + @StartActiveSpan() + private async ensureRoleGroup( + project: ProjectWithDetails, + role: ProjectWithDetails['roles'][number], + group: GroupRepresentationWith<'id' | 'name'>, + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('role.id', role.id) + span?.setAttribute('role.type', role.type) + span?.setAttribute('role.oidc_group.present', !!role.oidcGroup) + if (role.oidcGroup) { + span?.setAttribute('role.oidc_group.depth', role.oidcGroup.split('/').filter(Boolean).length) + } + + const roleGroup = await this.keycloak.getOrCreateRoleGroup(group, role.oidcGroup) + span?.setAttribute('keycloak.group.id', roleGroup.id) + span?.setAttribute('keycloak.group.path', roleGroup.path) + + const groupMembers = await this.keycloak.getGroupMembers(roleGroup.id) + span?.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}`) + } + } + + @StartActiveSpan() + private async ensureRoleGroupMembers( + project: ProjectWithDetails, + role: ProjectWithDetails['roles'][number], + group: GroupRepresentationWith<'id' | 'name' | 'path'>, + members: UserRepresentation[], + ) { + const span = trace.getActiveSpan() + 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) + } + + @StartActiveSpan() + private async purgeOrphanRoleGroupMembers( + project: ProjectWithDetails, + role: ProjectWithDetails['roles'][number], + group: GroupRepresentationWith<'id' | 'name' | 'path'>, + members: UserRepresentation[], + ) { + const span = trace.getActiveSpan() + 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) + } + + private async ensureEnvironmentGroups( + project: ProjectWithDetails, + group: GroupRepresentationWith<'id'>, + ) { + await Promise.all(project.environments.map(environment => + this.ensureEnvironmentGroup(project, environment, group))) + } + + @StartActiveSpan() + private async ensureEnvironmentGroup( + project: ProjectWithDetails, + environment: ProjectWithDetails['environments'][number], + group: GroupRepresentationWith<'id'>, + ) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'environment.id': environment.id, + 'environment.name': environment.name, + '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, + ), + ]) + } + + @StartActiveSpan() + private async ensureEnvironmentGroupMembers( + project: ProjectWithDetails, + environment: ProjectWithDetails['environments'][number], + rolesById: Record, + roGroup: GroupRepresentationWith<'id'>, + rwGroup: GroupRepresentationWith<'id'>, + roMembers: UserRepresentation[], + rwMembers: UserRepresentation[], + ) { + const span = trace.getActiveSpan() + 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) + } + + @StartActiveSpan() + private async purgeOrphanEnvironmentGroupMembers( + project: ProjectWithDetails, + environment: ProjectWithDetails['environments'][number], + roGroup: GroupRepresentationWith<'id'>, + rwGroup: GroupRepresentationWith<'id'>, + roMembers: UserRepresentation[], + rwMembers: UserRepresentation[], + ) { + const span = trace.getActiveSpan() + 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 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) + } + + @StartActiveSpan() + private async purgeOrphanEnvironmentGroups( + project: ProjectWithDetails, + group: GroupRepresentationWith<'id' | 'name'>, + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('keycloak.group.id', group.id) + span?.setAttribute('keycloak.group.name', group.name) + + const envGroups = map(this.keycloak.getSubGroups(group.id), envGroup => z.object({ + id: z.string(), + name: z.string(), + }).parse(envGroup)) + + const promises: Promise[] = [] + let orphanCount = 0 + + for await (const envGroup of envGroups) { + span?.setAttribute('keycloak.env_group.id', envGroup.id) + span?.setAttribute('keycloak.env_group.name', envGroup.name) + + 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)) { + orphanCount++ + 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)), + ) + } + } + + span?.setAttribute('keycloak.env_groups.orphan.count', orphanCount) + 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 }), + } + } +} + +export function isMember(project: ProjectWithDetails, member: UserRepresentation) { + return project.members.some(m => m.user.id === member.id) || project.ownerId === member.id +} + +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++) + } +} + +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/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/src/utils/iterable.ts b/apps/server-nestjs/src/utils/iterable.ts new file mode 100644 index 000000000..04b0a70cc --- /dev/null +++ b/apps/server-nestjs/src/utils/iterable.ts @@ -0,0 +1,19 @@ +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/test/keycloak.e2e-spec.ts b/apps/server-nestjs/test/keycloak.e2e-spec.ts new file mode 100644 index 000000000..82af95f74 --- /dev/null +++ b/apps/server-nestjs/test/keycloak.e2e-spec.ts @@ -0,0 +1,531 @@ +import type KcAdminClient from '@keycloak/keycloak-admin-client' +import type { TestingModule } from '@nestjs/testing' +import { faker } from '@faker-js/faker' +import { Logger } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import z from 'zod' +import { ConfigurationModule } from '../src/cpin-module/infrastructure/configuration/configuration.module' +import { PrismaService } from '../src/cpin-module/infrastructure/database/prisma.service' +import { InfrastructureModule } from '../src/cpin-module/infrastructure/infrastructure.module' +import { KEYCLOAK_ADMIN_CLIENT, KeycloakClientService } from '../src/modules/keycloak/keycloak-client.service' +import { projectSelect } from '../src/modules/keycloak/keycloak-datastore.service' +import { KeycloakModule } from '../src/modules/keycloak/keycloak.module' +import { KeycloakService } from '../src/modules/keycloak/keycloak.service' + +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: KeycloakService + let keycloak: KeycloakClientService + let keycloakAdminClient: KcAdminClient + 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(KeycloakService) + keycloak = moduleRef.get(KeycloakClientService) + keycloakAdminClient = moduleRef.get(KEYCLOAK_ADMIN_CLIENT) + 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 keycloakAdminClient.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 keycloak.getGroupByPath(`/${testProjectSlug}`) + if (group) { + await keycloak.deleteGroup(group.id!) + } + + // Clean owner user + if (ownerId) { + await keycloakAdminClient.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 keycloak.getGroupByPath(`/${testProjectSlug}`)) + expect(projectGroup.name).toBe(testProjectSlug) + + const consoleGroup = z.object({ + id: z.string(), + name: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}/console`)) + expect(consoleGroup.name).toBe('console') + + // Check role group + const roleGroup = z.object({ + name: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}/console/${testRoleName}`)) + expect(roleGroup.name).toBe(testRoleName) + + // Check membership (owner should be added) + const members = await keycloak.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 keycloakAdminClient.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 keycloak.getGroupByPath(`/${testProjectSlug}`)) + const members = await keycloak.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 keycloak.getGroupByPath(`/${testProjectSlug}/console/${testRoleName}`)) + const roleMembers = await keycloak.getGroupMembers(roleGroup.id) + const isRoleMember = roleMembers.some(m => m.id === kcUser.id) + expect(isRoleMember).toBe(true) + + // Cleanup user + await keycloakAdminClient.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 keycloakAdminClient.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 keycloak.getGroupByPath(`/${testProjectSlug}`)) + let members = await keycloak.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 keycloak.getGroupMembers(projectGroup.id) + expect(members.some(m => m.id === kcUser.id)).toBe(false) + + // Cleanup + await keycloakAdminClient.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 keycloakAdminClient.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 keycloak.getGroupByPath(`/${testProjectSlug}`)) + + // Manually remove user from Keycloak group + await keycloak.removeUserFromGroup(kcUser.id, projectGroup.id) + + // Verify removal + let members = await keycloak.getGroupMembers(projectGroup.id) + expect(members.some(m => m.id === kcUser.id)).toBe(false) + + // Sync again + await keycloakController.handleUpsert(project) + + // Verify added back + members = await keycloak.getGroupMembers(projectGroup.id) + expect(members.some(m => m.id === kcUser.id)).toBe(true) + + // Cleanup + await keycloakAdminClient.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 keycloakAdminClient.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 keycloak.getGroupByPath(`/${testProjectSlug}`)) + await keycloak.addUserToGroup(kcUser.id, projectGroup.id) + + // Verify added + let members = await keycloak.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 keycloak.getGroupMembers(projectGroup.id) + expect(members.some(m => m.id === kcUser.id)).toBe(false) + + // Cleanup + await keycloakAdminClient.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 keycloak.getGroupByPath(`/${testProjectSlug}`)) + + // Delete group in Keycloak + await keycloak.deleteGroup(projectGroup.id) + + // Verify deleted + const deletedProjectGroup = await keycloak.getGroupByPath(`/${testProjectSlug}`) + expect(deletedProjectGroup).toBeUndefined() + + // Sync + await keycloakController.handleUpsert(project) + + // Verify recreated + const recreatedProjectGroup = z.object({ + name: z.string(), + }).parse(await keycloak.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 keycloak.getGroupByPath(`/${testProjectSlug}/console/${testRoleName}`)) + + // Delete role group in Keycloak + await keycloak.deleteGroup(roleGroup.id) + + // Verify deleted + const deletedRoleGroup = await keycloak.getGroupByPath(`/${testProjectSlug}/console/${testRoleName}`) + expect(deletedRoleGroup).toBeUndefined() + + // Sync + await keycloakController.handleUpsert(project) + + // Verify recreated + const recreatedRoleGroup = z.object({ + name: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}/console/${testRoleName}`)) + expect(recreatedRoleGroup?.name).toBe(testRoleName) + }, 60000) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 59bad2c55..0af4519cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -388,6 +388,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 @@ -400,9 +403,15 @@ 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) '@nestjs/terminus': specifier: ^11.1.1 version: 11.1.1(@grpc/grpc-js@1.14.3)(@grpc/proto-loader@0.8.0)(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -487,6 +496,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:^ @@ -536,6 +551,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 @@ -2640,6 +2658,10 @@ packages: '@js-sdsl/ordered-map@4.4.2': resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + '@keycloak/keycloak-admin-client@24.0.5': + resolution: {integrity: sha512-SXDVtQ3ov7GQbxXq51Uq8lzhwzQwNg6XiY50ZA9whuUe2t/0zPT4Zd/LcULcjweIjSNWWgfbDyN1E3yRSL8Qqw==} + engines: {node: '>=18'} + '@keycloak/keycloak-admin-client@26.5.5': resolution: {integrity: sha512-ZYP1Z+4qZ+vChNKWI+g1X08F2gCpZEWRlEMjwF03xet7bB5j5898nSJNFT1g6XIqVslHq15R8pt55fcULpRuvw==} engines: {node: '>=18'} @@ -2734,12 +2756,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: @@ -3937,6 +3971,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==} @@ -4954,6 +4991,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@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -5530,6 +5571,9 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + eventemitter2@6.4.9: + resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==} + eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} @@ -6568,6 +6612,10 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + magic-regexp@0.10.0: resolution: {integrity: sha512-Uly1Bu4lO1hwHUW0CQeSWuRtzCMNO00CmXtS8N6fyvB3B979GOEEeAkiTUDsmbYLAbvpUS/Kt5c4ibosAzVyVg==} @@ -8443,6 +8491,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} @@ -10697,6 +10749,12 @@ snapshots: '@js-sdsl/ordered-map@4.4.2': {} + '@keycloak/keycloak-admin-client@24.0.5': + dependencies: + camelize-ts: 3.0.0 + url-join: 5.0.0 + url-template: 3.1.1 + '@keycloak/keycloak-admin-client@26.5.5': dependencies: camelize-ts: 3.0.0 @@ -10752,7 +10810,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: @@ -10821,6 +10878,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) @@ -10833,6 +10896,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) @@ -10883,17 +10952,14 @@ 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-logs@0.212.0': dependencies: @@ -12151,6 +12217,8 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/luxon@3.7.1': {} + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -12204,8 +12272,7 @@ snapshots: '@types/http-errors': 2.0.5 '@types/node': 22.19.15 - '@types/statuses@2.0.6': - optional: true + '@types/statuses@2.0.6': {} '@types/superagent@8.1.9': dependencies: @@ -13463,8 +13530,7 @@ snapshots: cookie@0.7.2: {} - cookie@1.1.1: - optional: true + cookie@1.1.1: {} cookiejar@2.1.4: {} @@ -13508,6 +13574,11 @@ snapshots: cron-validator@1.4.0: {} + cron@4.4.0: + dependencies: + '@types/luxon': 3.7.1 + luxon: 3.7.2 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -14290,6 +14361,8 @@ snapshots: etag@1.8.1: {} + eventemitter2@6.4.9: {} + eventemitter3@5.0.4: {} events@3.3.0: {} @@ -14797,8 +14870,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: @@ -14842,8 +14914,7 @@ snapshots: he@1.2.0: {} - headers-polyfill@4.0.3: - optional: true + headers-polyfill@4.0.3: {} helmet@7.2.0: {} @@ -15062,8 +15133,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: @@ -15433,6 +15503,8 @@ snapshots: dependencies: yallist: 3.1.1 + luxon@3.7.2: {} + magic-regexp@0.10.0: dependencies: estree-walker: 3.0.3 @@ -15901,7 +15973,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: @@ -16158,8 +16229,7 @@ snapshots: strip-ansi: 6.0.1 wcwidth: 1.0.1 - outvariant@1.4.3: - optional: true + outvariant@1.4.3: {} own-keys@1.0.1: dependencies: @@ -16256,8 +16326,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: {} @@ -16678,8 +16747,7 @@ snapshots: ret@0.4.3: {} - rettime@0.10.1: - optional: true + rettime@0.10.1: {} reusify@1.1.0: {} @@ -17042,8 +17110,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: {} @@ -17389,8 +17456,7 @@ snapshots: tldts-core@6.1.86: {} - tldts-core@7.0.25: - optional: true + tldts-core@7.0.25: {} tldts@6.1.86: dependencies: @@ -17399,7 +17465,6 @@ snapshots: tldts@7.0.25: dependencies: tldts-core: 7.0.25 - optional: true to-regex-range@5.0.1: dependencies: @@ -17435,7 +17500,6 @@ snapshots: tough-cookie@6.0.0: dependencies: tldts: 7.0.25 - optional: true tr46@0.0.3: {} @@ -17775,8 +17839,7 @@ snapshots: picomatch: 4.0.3 webpack-virtual-modules: 0.6.2 - until-async@3.0.2: - optional: true + until-async@3.0.2: {} upath@1.2.0: {} @@ -17790,6 +17853,8 @@ snapshots: dependencies: punycode: 2.3.1 + url-join@5.0.0: {} + url-template@3.1.1: {} util-deprecate@1.0.2: {}