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
Expand Up @@ -432,9 +432,10 @@ export class GitlabClientService {
}

async getUserByEmail(email: string) {
// Fuzzy search matches username and display name too; only an exact
// email hit is safe on destructive paths (member removal).
const users = await this.client.Users.all({ search: email, orderBy: 'username' })
if (users.length === 0) return undefined
return users[0] as UserSchema
return users.find(user => user.email === email) as UserSchema | undefined
}

async createUser(user: EditUserOptions) {
Expand Down
73 changes: 72 additions & 1 deletion apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { OBSERVABILITY_REPOSITORY } from '../observability/observability.constan
import { VaultClientService } from '../vault/vault-client.service'
import { GitlabClientService } from './gitlab-client.service'
import { GitlabDatastoreService } from './gitlab-datastore.service'
import { makeAccessTokenExposedSchema, makeExpandedUserSchema, makeGroupSchema, makeMemberSchema, makePipeline, makePipelineTriggerToken, makeProjectSchema, makeProjectWithDetails, makeVaultSecret } from './gitlab-testing.utils'
import { makeAccessTokenExposedSchema, makeExpandedUserSchema, makeGroupSchema, makeMemberSchema, makePipeline, makePipelineTriggerToken, makeProjectSchema, makeProjectWithDetails, makeUser, makeVaultSecret } from './gitlab-testing.utils'
import { INFRA_APPS_REPO_NAME, MIRROR_REPO_NAME, PLUGIN_NAME, TOPIC_PLUGIN_MANAGED, TOPIC_SYSTEM_MANAGED } from './gitlab.constants'
import { GitlabService } from './gitlab.service'

Expand Down Expand Up @@ -56,6 +56,77 @@ describe('gitlabService', () => {
expect(service).toBeDefined()
})

describe('handleProjectMemberUpsert', () => {
it('should reconcile project members on projectMember.upsert', async () => {
const project = makeProjectWithDetails({
members: [{ user: { id: 'u1', email: 'member@example.com', firstName: 'New', lastName: 'User', adminRoleIds: [] }, roleIds: [] }],
})
const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 })
datastore.getProject.mockResolvedValue(project)
gitlab.ensureProjectSubGroup.mockResolvedValue(group)
gitlab.getGroupMembers.mockResolvedValue([])
gitlab.ensureUser.mockImplementation(async user => makeExpandedUserSchema({
id: user.email === 'member@example.com' ? 999 : 998,
email: user.email,
username: user.email.split('@')[0] ?? user.email,
name: user.name,
}))

const result = await service.handleProjectMemberUpsert({ projectId: project.id, userId: 'u1' })

expect(result.gitlab.status).toBe('OK')
expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 999, AccessLevel.GUEST)
expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 998, AccessLevel.OWNER)
})

it('should fail with KO when the project no longer exists on projectMember.upsert', async () => {
datastore.getProject.mockResolvedValue(null)

const result = await service.handleProjectMemberUpsert({ projectId: 'missing', userId: 'u1' })

expect(result.gitlab.status).toBe('KO')
})
})

describe('handleProjectMemberDelete', () => {
it('should remove the group member on projectMember.delete', async () => {
const project = makeProjectWithDetails()
const group = makeGroupSchema({ id: 321, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 })
datastore.getProject.mockResolvedValue(project)
datastore.getUser.mockResolvedValue(makeUser({ id: 'u1', email: 'leaver@example.com' }))
gitlab.getUserByEmail.mockResolvedValue(makeExpandedUserSchema({ id: 555, email: 'leaver@example.com', username: 'leaver', name: 'Leaver User' }))
gitlab.getProjectGroup.mockResolvedValue(group)

const result = await service.handleProjectMemberDelete({ projectId: project.id, userId: 'u1' })

expect(result.gitlab.status).toBe('OK')
expect(gitlab.removeGroupMember).toHaveBeenCalledWith(group, 555)
})

it('should tolerate a user absent from GitLab on projectMember.delete', async () => {
datastore.getProject.mockResolvedValue(makeProjectWithDetails())
datastore.getUser.mockResolvedValue(makeUser({ id: 'u1', email: 'ghost@example.com' }))
gitlab.getUserByEmail.mockResolvedValue(null)

const result = await service.handleProjectMemberDelete({ projectId: 'p1', userId: 'u1' })

expect(result.gitlab.status).toBe('OK')
expect(gitlab.removeGroupMember).not.toHaveBeenCalled()
})

it('should tolerate a missing GitLab group on projectMember.delete', async () => {
datastore.getProject.mockResolvedValue(makeProjectWithDetails())
datastore.getUser.mockResolvedValue(makeUser({ id: 'u1', email: 'leaver@example.com' }))
gitlab.getUserByEmail.mockResolvedValue(makeExpandedUserSchema({ id: 555, email: 'leaver@example.com', username: 'leaver', name: 'Leaver User' }))
gitlab.getProjectGroup.mockResolvedValue(undefined)

const result = await service.handleProjectMemberDelete({ projectId: 'p1', userId: 'u1' })

expect(result.gitlab.status).toBe('OK')
expect(gitlab.removeGroupMember).not.toHaveBeenCalled()
})
})

describe('handleUpsert', () => {
it('should reconcile project members and repositories', async () => {
const project = makeProjectWithDetails()
Expand Down
58 changes: 56 additions & 2 deletions apps/server-nestjs/src/modules/gitlab/gitlab.service.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
import type { MemberSchema } from '@gitbeaker/core'
import type { ConfigType } from '@nestjs/config'
import type { AdminRoleEventPayload, RepositorySyncEventPayload, ZoneEventPayload } from '../events/app-events.service'
import type { AdminRoleEventPayload, ProjectMemberEventPayload, RepositorySyncEventPayload, ZoneEventPayload } 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'
import type { ProjectWithDetails } from './gitlab-datastore.service'
import { specificallyEnabled } from '@cpn-console/hooks'
import { daysAgo } from '@cpn-console/shared'
import { AccessLevel } from '@gitbeaker/core'
import { Inject, Injectable, Logger } from '@nestjs/common'
import { OnEvent } from '@nestjs/event-emitter'
import { trace } from '@opentelemetry/api'
import { gitlabConfigFactory } from '../../config/gitlab.config'
import { getAll } from '../../utils/iterable.utils'
import { daysAgo } from '@cpn-console/shared'
import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator'
import { capturePluginResult } from '../plugin/plugin.utils'
import { VaultClientService } from '../vault/vault-client.service'
Expand Down Expand Up @@ -161,6 +161,16 @@ export class GitlabService {
this.logger.log(`GitLab zone cleanup completed for ${payload.slug}`)
}

@OnEvent('projectMember.upsert')
async handleProjectMemberUpsert(payload: ProjectMemberEventPayload): Promise<RequiredPluginResult<'gitlab'>> {
return capturePluginResult('gitlab', () => this.syncProjectMembers(payload))
}

@OnEvent('projectMember.delete')
async handleProjectMemberDelete(payload: ProjectMemberEventPayload): Promise<RequiredPluginResult<'gitlab'>> {
return capturePluginResult('gitlab', () => this.removeProjectMember(payload))
}

@StartActiveSpan()
private async syncRepositoryMirror(payload: RepositorySyncEventPayload) {
const { projectSlug, internalRepoName, syncAllBranches } = payload
Expand Down Expand Up @@ -192,6 +202,50 @@ export class GitlabService {
this.logger.log(`GitLab cleanup completed for project ${project.slug}`)
}

@StartActiveSpan()
private async syncProjectMembers(payload: ProjectMemberEventPayload) {
const span = trace.getActiveSpan()
span?.setAttribute('project.id', payload.projectId)
span?.setAttribute('project.member.userId', payload.userId)
this.logger.log(`Handling a project member upsert event (projectId=${payload.projectId}, userId=${payload.userId})`)
const project = await this.datastore.getProject(payload.projectId)
if (!project) {
throw new Error(`Project not found for member sync (projectId=${payload.projectId})`)
}
Comment thread
shikanime marked this conversation as resolved.
const group = await this.gitlab.ensureProjectSubGroup(project.slug)
const members = await this.gitlab.getGroupMembers(group)
await this.ensureProjectGroupMembers(project, group, members)
this.logger.log(`GitLab member sync completed (projectId=${payload.projectId}, slug=${project.slug})`)
}

@StartActiveSpan()
private async removeProjectMember(payload: ProjectMemberEventPayload) {
const span = trace.getActiveSpan()
span?.setAttribute('project.id', payload.projectId)
span?.setAttribute('project.member.userId', payload.userId)
this.logger.log(`Handling a project member delete event (projectId=${payload.projectId}, userId=${payload.userId})`)
const user = await this.datastore.getUser(payload.userId)
if (!user) {
throw new Error(`User not found for member removal (userId=${payload.userId})`)
}
const project = await this.datastore.getProject(payload.projectId)
if (!project) {
throw new Error(`Project not found for member removal (projectId=${payload.projectId})`)
}
const gitlabUser = await this.gitlab.getUserByEmail(user.email)
Comment thread
shikanime marked this conversation as resolved.
if (!gitlabUser) {
this.logger.log(`User absent from GitLab, nothing to remove (projectId=${payload.projectId}, userId=${payload.userId})`)
return
}
const group = await this.gitlab.getProjectGroup(project.slug)
if (!group) {
Comment thread
shikanime marked this conversation as resolved.
this.logger.log(`No GitLab group for project, nothing to remove (projectId=${payload.projectId}, slug=${project.slug})`)
return
}
await this.gitlab.removeGroupMember(group, gitlabUser.id)
this.logger.log(`GitLab member removed (projectId=${payload.projectId}, gitlabUserId=${gitlabUser.id})`)
}

// @Cron(CronExpression.EVERY_HOUR)
@StartActiveSpan()
async handleCron() {
Expand Down
Loading