Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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()],
}
}
86 changes: 86 additions & 0 deletions apps/server-nestjs/src/modules/events/app-events.module.spec.ts
Original file line number Diff line number Diff line change
@@ -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<KeycloakClientService>
let gitlab: DeepMockProxy<GitlabClientService>

beforeEach(async () => {
keycloak = mockDeep<KeycloakClientService>({
getOrCreateGroupByPath: vi.fn().mockResolvedValue(makeGroupRepresentation({ id: 'kc-group-id', name: 'admin' })),
getGroupMembers: vi.fn().mockResolvedValue([]),
})
gitlab = mockDeep<GitlabClientService>({
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<KeycloakDatastoreService>({
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<GitlabDatastoreService>({
getAdminPluginConfig: vi.fn().mockResolvedValue(null),
}) },
{ provide: VaultClientService, useValue: mockDeep<VaultClientService>() },
{ provide: gitlabConfigFactory.KEY, useValue: mockDeep<ConfigType<typeof gitlabConfigFactory>>({}) },
],
}).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)
Comment thread
shikanime marked this conversation as resolved.

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' }) },
]))
})
})
13 changes: 13 additions & 0 deletions apps/server-nestjs/src/modules/events/app-events.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
57 changes: 57 additions & 0 deletions apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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())
})
})
})
51 changes: 47 additions & 4 deletions apps/server-nestjs/src/modules/gitlab/gitlab.service.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -85,6 +85,49 @@ export class GitlabService {
return capturePluginResult('gitlab', () => this.cleanupProject(project))
}

@OnEvent('adminRole.upsert')
async handleAdminRoleUpsert(role: AdminRoleEventPayload): Promise<RequiredPluginResult<'gitlab'>> {
return capturePluginResult('gitlab', () => this.syncAdminRole(role))
}

@OnEvent('adminRole.delete')
async handleAdminRoleDelete(role: AdminRoleEventPayload): Promise<RequiredPluginResult<'gitlab'>> {
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({
Comment thread
shikanime marked this conversation as resolved.
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<RequiredPluginResult<'gitlab'>> {
return capturePluginResult('gitlab', () => this.syncRepositoryMirror(payload))
Expand Down Expand Up @@ -258,15 +301,15 @@ export class GitlabService {
return generateAdminRoleMapping(roles, adminGroupPaths, auditorGroupPaths)
}

private async getAdminGroupPath(project: ProjectWithDetails): Promise<string> {
private async getAdminGroupPath(project?: ProjectWithDetails): Promise<string> {
return await this.getAdminOrProjectPluginConfig(project, ADMIN_GROUP_PATH_PLUGIN_KEY) ?? DEFAULT_ADMIN_GROUP_PATH
}

private async getAuditorGroupPath(project: ProjectWithDetails): Promise<string> {
private async getAuditorGroupPath(project?: ProjectWithDetails): Promise<string> {
return await this.getAdminOrProjectPluginConfig(project, AUDITOR_GROUP_PATH_PLUGIN_KEY) ?? DEFAULT_AUDITOR_GROUP_PATH
}

private async getAdminOrProjectPluginConfig(project: ProjectWithDetails, key: string): Promise<string | undefined> {
private async getAdminOrProjectPluginConfig(project: ProjectWithDetails | undefined, key: string): Promise<string | undefined> {
const adminPluginConfig = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, key)
if (adminPluginConfig) return adminPluginConfig
if (!project) return undefined
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
})
})
})
46 changes: 45 additions & 1 deletion apps/server-nestjs/src/modules/keycloak/keycloak.service.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<RequiredPluginResult<'keycloak'>> {
return capturePluginResult('keycloak', () => this.syncAdminRole(role.id))
}

@OnEvent('adminRole.delete')
async handleAdminRoleDelete(role: AdminRoleEventPayload): Promise<RequiredPluginResult<'keycloak'>> {
Comment thread
shikanime marked this conversation as resolved.
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)
Comment thread
shikanime marked this conversation as resolved.
}

// @Cron(CronExpression.EVERY_HOUR)
@StartActiveSpan()
async handleCron() {
Expand Down Expand Up @@ -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)
Expand Down
Loading