diff --git a/apps/server-nestjs/src/modules/events/app-events-testing.utils.ts b/apps/server-nestjs/src/modules/events/app-events-testing.utils.ts new file mode 100644 index 0000000000..c4764dc7b3 --- /dev/null +++ b/apps/server-nestjs/src/modules/events/app-events-testing.utils.ts @@ -0,0 +1,19 @@ +import type { AdminRoleEventMember, AdminRoleEventPayload } from './app-events.service' +import { faker } from '@faker-js/faker' + +export function makeAdminRoleEventMember(overrides: { id?: string, email?: string, firstName?: string, lastName?: string } = {}): AdminRoleEventMember { + return { + id: overrides.id ?? faker.string.uuid(), + email: overrides.email ?? faker.internet.email(), + firstName: overrides.firstName ?? faker.person.firstName(), + lastName: overrides.lastName ?? faker.person.lastName(), + } +} + +export function makeAdminRoleEventPayload(overrides: { id?: string, oidcGroup?: string | null, members?: AdminRoleEventMember[] } = {}): AdminRoleEventPayload { + return { + id: overrides.id ?? faker.string.uuid(), + oidcGroup: overrides.oidcGroup ?? null, + members: overrides.members ?? [makeAdminRoleEventMember()], + } +} diff --git a/apps/server-nestjs/src/modules/events/app-events.module.spec.ts b/apps/server-nestjs/src/modules/events/app-events.module.spec.ts new file mode 100644 index 0000000000..440697115c --- /dev/null +++ b/apps/server-nestjs/src/modules/events/app-events.module.spec.ts @@ -0,0 +1,86 @@ +import type { ConfigType } from '@nestjs/config' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { EventEmitter2, EventEmitterModule } from '@nestjs/event-emitter' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { gitlabConfigFactory } from '../../config/gitlab.config' +import { GitlabClientService } from '../gitlab/gitlab-client.service' +import { GitlabDatastoreService } from '../gitlab/gitlab-datastore.service' +import { GitlabService } from '../gitlab/gitlab.service' +import { KeycloakClientService } from '../keycloak/keycloak-client.service' +import { KeycloakDatastoreService } from '../keycloak/keycloak-datastore.service' +import { makeGroupRepresentation } from '../keycloak/keycloak-testing.utils' +import { KeycloakService } from '../keycloak/keycloak.service' +import { VaultClientService } from '../vault/vault-client.service' +import { makeAdminRoleEventMember, makeAdminRoleEventPayload } from './app-events-testing.utils' + +describe('appEventsModule', () => { + let eventEmitter: EventEmitter2 + let keycloak: DeepMockProxy + let gitlab: DeepMockProxy + + beforeEach(async () => { + keycloak = mockDeep({ + getOrCreateGroupByPath: vi.fn().mockResolvedValue(makeGroupRepresentation({ id: 'kc-group-id', name: 'admin' })), + getGroupMembers: vi.fn().mockResolvedValue([]), + }) + gitlab = mockDeep({ + upsertUser: vi.fn().mockResolvedValue({ id: 1 }), + }) + + const moduleRef = await Test.createTestingModule({ + imports: [EventEmitterModule.forRoot()], + providers: [ + KeycloakService, + GitlabService, + { provide: KeycloakClientService, useValue: keycloak }, + { provide: KeycloakDatastoreService, useValue: mockDeep({ + getAllAdminRoles: vi.fn().mockResolvedValue([{ id: 'role-1', oidcGroup: '/console/admin', type: 'global' }]), + getAllUsersWithAdminRoleIds: vi.fn().mockResolvedValue([{ id: 'user-1', adminRoleIds: ['role-1'] }]), + }) }, + { provide: GitlabClientService, useValue: gitlab }, + { provide: GitlabDatastoreService, useValue: mockDeep({ + getAdminPluginConfig: vi.fn().mockResolvedValue(null), + }) }, + { provide: VaultClientService, useValue: mockDeep() }, + { provide: gitlabConfigFactory.KEY, useValue: mockDeep>({}) }, + ], + }).compile() + + const app = moduleRef.createNestApplication() + await app.init() + + eventEmitter = moduleRef.get(EventEmitter2) + }) + + it('delivers the canonical AdminRoleEventPayload to both consumers on adminRole.upsert', async () => { + const payload = makeAdminRoleEventPayload({ + id: 'role-1', + oidcGroup: '/console/admin', + members: [ + makeAdminRoleEventMember({ + id: 'u1', + email: 'a@b.c', + firstName: 'A', + lastName: 'B', + }), + ], + }) + + const results = await eventEmitter.emitAsync('adminRole.upsert', payload) + + expect(keycloak.getOrCreateGroupByPath).toHaveBeenCalledWith('/console/admin') + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-1', 'kc-group-id') + + expect(gitlab.upsertUser).toHaveBeenCalledWith( + expect.objectContaining({ email: 'a@b.c', admin: true }), + { cpnUserId: 'u1' }, + ) + + expect(results).toEqual(expect.arrayContaining([ + { keycloak: expect.objectContaining({ status: 'OK' }) }, + { gitlab: expect.objectContaining({ status: 'OK' }) }, + ])) + }) +}) diff --git a/apps/server-nestjs/src/modules/events/app-events.service.ts b/apps/server-nestjs/src/modules/events/app-events.service.ts index ad3861f697..32e1e6d612 100644 --- a/apps/server-nestjs/src/modules/events/app-events.service.ts +++ b/apps/server-nestjs/src/modules/events/app-events.service.ts @@ -34,6 +34,19 @@ export type RepositorySyncEventPayload = { | { syncAllBranches: false, branchName: string } ) +export interface AdminRoleEventMember { + id: string + email: string + firstName: string + lastName: string +} + +export interface AdminRoleEventPayload { + id: string + oidcGroup: string | null + members: AdminRoleEventMember[] +} + /** Admin-log action labels (legacy hooks wording). */ export type EventLogAction = | 'Create Project' | 'Update Project' | 'Delete all project resources' diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts index 299b431fdc..6b40077400 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts @@ -7,6 +7,7 @@ import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { mockDeep } from 'vitest-mock-extended' import { gitlabConfigFactory } from '../../config/gitlab.config' +import { makeAdminRoleEventMember, makeAdminRoleEventPayload } from '../events/app-events-testing.utils' import { OBSERVABILITY_REPOSITORY } from '../observability/observability.constants' import { VaultClientService } from '../vault/vault-client.service' import { GitlabClientService } from './gitlab-client.service' @@ -734,4 +735,60 @@ describe('gitlabService', () => { expect(gitlab.deleteGroup).not.toHaveBeenCalled() }) }) + + describe('handleAdminRoleUpsert', () => { + it('should skip roles outside managed group paths', async () => { + await service.handleAdminRoleUpsert(makeAdminRoleEventPayload({ oidcGroup: '/other' })) + + expect(gitlab.upsertUser).not.toHaveBeenCalled() + }) + + it('should flag admin for the admin group and auditor otherwise', async () => { + await service.handleAdminRoleUpsert(makeAdminRoleEventPayload({ + oidcGroup: '/console/admin', + members: [makeAdminRoleEventMember({ id: 'u1', email: 'a@b.c' })], + })) + expect(gitlab.upsertUser).toHaveBeenCalledWith(expect.objectContaining({ admin: true }), expect.anything()) + + await service.handleAdminRoleUpsert(makeAdminRoleEventPayload({ + oidcGroup: '/console/readonly', + members: [makeAdminRoleEventMember({ id: 'u1', email: 'a@b.c' })], + })) + expect(gitlab.upsertUser).toHaveBeenLastCalledWith(expect.objectContaining({ auditor: true, admin: undefined }), expect.anything()) + }) + + it('should recognize every configured admin group path', async () => { + vi.mocked(datastore.getAdminPluginConfig).mockResolvedValue('/console/admin,/console/ops') + try { + await service.handleAdminRoleUpsert(makeAdminRoleEventPayload({ + oidcGroup: '/console/ops', + members: [makeAdminRoleEventMember({ id: 'u1', email: 'a@b.c' })], + })) + } finally { + vi.mocked(datastore.getAdminPluginConfig).mockResolvedValue(undefined) + } + + expect(gitlab.upsertUser).toHaveBeenCalledWith(expect.objectContaining({ admin: true }), expect.anything()) + }) + }) + + describe('handleAdminRoleDelete', () => { + it('should clear the admin flag on revoke', async () => { + await service.handleAdminRoleDelete(makeAdminRoleEventPayload({ + oidcGroup: '/console/admin', + members: [makeAdminRoleEventMember({ id: 'u1', email: 'a@b.c' })], + })) + + expect(gitlab.upsertUser).toHaveBeenCalledWith(expect.objectContaining({ admin: false }), expect.anything()) + }) + + it('should provision absent GitLab members on revoke', async () => { + await service.handleAdminRoleDelete(makeAdminRoleEventPayload({ + oidcGroup: '/console/admin', + members: [makeAdminRoleEventMember({ id: 'u1', email: 'ghost@b.c' })], + })) + + expect(gitlab.upsertUser).toHaveBeenCalledWith(expect.objectContaining({ admin: false }), expect.anything()) + }) + }) }) diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts index 2b54ae6062..905efa4d6c 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts @@ -1,6 +1,6 @@ import type { MemberSchema } from '@gitbeaker/core' import type { ConfigType } from '@nestjs/config' -import type { RepositorySyncEventPayload } from '../events/app-events.service' +import type { AdminRoleEventPayload, RepositorySyncEventPayload } from '../events/app-events.service' import type { RequiredPluginResult } from '../plugin/plugin.utils' import type { MirrorUserSecret, VaultSecret } from '../vault/vault-client.service' import type { GroupSchemaWith } from './gitlab-client.service' @@ -85,6 +85,49 @@ export class GitlabService { return capturePluginResult('gitlab', () => this.cleanupProject(project)) } + @OnEvent('adminRole.upsert') + async handleAdminRoleUpsert(role: AdminRoleEventPayload): Promise> { + return capturePluginResult('gitlab', () => this.syncAdminRole(role)) + } + + @OnEvent('adminRole.delete') + async handleAdminRoleDelete(role: AdminRoleEventPayload): Promise> { + return capturePluginResult('gitlab', () => this.syncAdminRole(role, false)) + } + + @StartActiveSpan() + private async syncAdminRole(role: AdminRoleEventPayload, enabled = true) { + const span = trace.getActiveSpan() + span?.setAttribute('admin_role.id', role.id) + this.logger.log(`Handling an admin role ${enabled ? 'upsert' : 'delete'} event for ${role.id}`) + + const { isAdminRole, isAuditorRole } = await this.getAdminRoleFlags(role) + if (!isAdminRole && !isAuditorRole) { + this.logger.verbose(`Not a managed role for GitLab plugin (roleId=${role.id})`) + return + } + + for (const member of role.members) { + await this.gitlab.upsertUser({ + email: member.email, + username: generateUsername(member.email), + name: generateName(member.firstName, member.lastName), + admin: isAdminRole ? enabled : undefined, + auditor: isAuditorRole ? enabled : undefined, + }, { + cpnUserId: member.id, + }) + } + } + + private async getAdminRoleFlags(role: AdminRoleEventPayload) { + const oidcGroup = role.oidcGroup ?? '' + return { + isAdminRole: parseGroupPaths(await this.getAdminGroupPath()).includes(oidcGroup), + isAuditorRole: parseGroupPaths(await this.getAuditorGroupPath()).includes(oidcGroup), + } + } + @OnEvent('repository.sync') async handleRepositorySync(payload: RepositorySyncEventPayload): Promise> { return capturePluginResult('gitlab', () => this.syncRepositoryMirror(payload)) @@ -258,15 +301,15 @@ export class GitlabService { return generateAdminRoleMapping(roles, adminGroupPaths, auditorGroupPaths) } - private async getAdminGroupPath(project: ProjectWithDetails): Promise { + private async getAdminGroupPath(project?: ProjectWithDetails): Promise { return await this.getAdminOrProjectPluginConfig(project, ADMIN_GROUP_PATH_PLUGIN_KEY) ?? DEFAULT_ADMIN_GROUP_PATH } - private async getAuditorGroupPath(project: ProjectWithDetails): Promise { + private async getAuditorGroupPath(project?: ProjectWithDetails): Promise { return await this.getAdminOrProjectPluginConfig(project, AUDITOR_GROUP_PATH_PLUGIN_KEY) ?? DEFAULT_AUDITOR_GROUP_PATH } - private async getAdminOrProjectPluginConfig(project: ProjectWithDetails, key: string): Promise { + private async getAdminOrProjectPluginConfig(project: ProjectWithDetails | undefined, key: string): Promise { const adminPluginConfig = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, key) if (adminPluginConfig) return adminPluginConfig if (!project) return undefined diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts b/apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts index b557a04ccd..ba147431c6 100644 --- a/apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts +++ b/apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts @@ -3,6 +3,7 @@ import type { AdminRoleWithDetails, ProjectWithDetails, UserWithAdminRoles } fro import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { mockDeep } from 'vitest-mock-extended' +import { makeAdminRoleEventPayload } from '../events/app-events-testing.utils' import { KeycloakClientService } from './keycloak-client.service' import { KeycloakDatastoreService } from './keycloak-datastore.service' import { @@ -526,4 +527,27 @@ describe('keycloakService', () => { expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-2', 'system-managed-id') }) }) + + describe('handleAdminRoleUpsert', () => { + it('should sync the impacted role group on adminRole.upsert', async () => { + datastore.getAllAdminRoles.mockResolvedValue([{ id: 'role-1', oidcGroup: '/console/admin', type: 'global' }]) + datastore.getAllUsersWithAdminRoleIds.mockResolvedValue([{ id: 'user-1', adminRoleIds: ['role-1'] }]) + keycloak.getOrCreateGroupByPath.mockResolvedValue(makeGroupRepresentation({ id: 'kc-group-id', name: 'admin' })) + keycloak.getGroupMembers.mockResolvedValue([makeUserRepresentation({ id: 'user-2' })]) + + await service.handleAdminRoleUpsert(makeAdminRoleEventPayload({ id: 'role-1', oidcGroup: '/console/admin', members: [] })) + + expect(keycloak.getOrCreateGroupByPath).toHaveBeenCalledWith('/console/admin') + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-1', 'kc-group-id') + expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-2', 'kc-group-id') + }) + }) + + describe('handleAdminRoleDelete', () => { + it('should warn and no-op when the role no longer exists', async () => { + await service.handleAdminRoleDelete(makeAdminRoleEventPayload({ id: 'gone', oidcGroup: '/console/admin', members: [] })) + + expect(keycloak.getOrCreateGroupByPath).not.toHaveBeenCalled() + }) + }) }) diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts b/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts index 5947fbde1e..796c08e508 100644 --- a/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts +++ b/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts @@ -1,4 +1,5 @@ import type UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation' +import type { AdminRoleEventPayload } from '../events/app-events.service' import type { RequiredPluginResult } from '../plugin/plugin.utils' import type { AdminRoleWithDetails, ProjectWithDetails, UserWithAdminRoles } from './keycloak-datastore.service' import type { GroupRepresentationWith, GroupRepresentationWithIdNamePath } from './keycloak.utils' @@ -56,6 +57,49 @@ export class KeycloakService { this.logger.log(`Keycloak cleanup completed for project ${project.slug}`) } + @OnEvent('adminRole.upsert') + async handleAdminRoleUpsert(role: AdminRoleEventPayload): Promise> { + return capturePluginResult('keycloak', () => this.syncAdminRole(role.id)) + } + + @OnEvent('adminRole.delete') + async handleAdminRoleDelete(role: AdminRoleEventPayload): Promise> { + return capturePluginResult('keycloak', () => this.revokeAdminRoleGroup(role)) + } + + @StartActiveSpan() + private async revokeAdminRoleGroup(role: AdminRoleEventPayload) { + const span = trace.getActiveSpan() + span?.setAttribute('admin_role.id', role.id) + const roleGroupPath = toGroupPath(role.oidcGroup) + if (!roleGroupPath) return + const roleGroup = await this.keycloak.getGroupByPath(roleGroupPath) + if (!roleGroup?.id) { + this.logger.warn(`Keycloak group not found for deleted admin role (roleId=${role.id}, path=${roleGroupPath})`) + return + } + for (const member of role.members) { + await this.maybeRemoveUserFromGroup(member.id, roleGroup.id, roleGroup.name) + } + } + + @StartActiveSpan() + private async syncAdminRole(roleId: string) { + const span = trace.getActiveSpan() + span?.setAttribute('admin_role.id', roleId) + this.logger.log(`Handling an admin role event for ${roleId}`) + const [roles, users] = await Promise.all([ + this.datastore.getAllAdminRoles(), + this.datastore.getAllUsersWithAdminRoleIds(), + ]) + const role = roles.find(({ id }) => id === roleId) + if (!role) { + this.logger.warn(`Admin role not found for event (roleId=${roleId})`) + return + } + await this.ensureAdminRoleGroup(role, users) + } + // @Cron(CronExpression.EVERY_HOUR) @StartActiveSpan() async handleCron() { @@ -147,7 +191,7 @@ export class KeycloakService { span?.setAttribute('admin_role.id', role.id) span?.setAttribute('admin_role.oidc_group.present', isNonEmptyGroupPath(role.oidcGroup)) const roleGroupPath = toGroupPath(role.oidcGroup) - if (!roleGroupPath) return + if (!roleGroupPath || isExternalRoleType(role.type)) return span?.setAttribute('keycloak.group.path', roleGroupPath) const roleGroup = await this.keycloak.getOrCreateGroupByPath(roleGroupPath)