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
42 changes: 42 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 @@ -477,6 +477,48 @@ describe('gitlabService', () => {
)
})

it('should resolve auditor flags from the multi-path auditor defaults', async () => {
const project = makeProjectWithDetails({
members: [
{ user: { id: 'u1', email: 'security@example.com', firstName: 'Secu', lastName: 'User', adminRoleIds: ['security-role-id'] }, roleIds: [] },
{ user: { id: 'u2', email: 'auditor@example.com', firstName: 'Auditor', lastName: 'User', adminRoleIds: ['readonly-role-id'] }, 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.getAdminRolesByOidcGroups.mockResolvedValue([
{ id: 'admin-role-id', oidcGroup: '/console/admin' },
{ id: 'readonly-role-id', oidcGroup: '/console/readonly' },
{ id: 'security-role-id', oidcGroup: '/console/security' },
])

gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group)
gitlab.getGroupMembers.mockResolvedValue([])
gitlab.upsertUser.mockImplementation(async (user) => {
return makeExpandedUserSchema({
id: faker.number.int(),
email: user.email,
username: user.email.split('@')[0],
name: user.name,
})
})
gitlab.getRepos.mockReturnValue((async function* () { })())
gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false }))
gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken())

await service.handleUpsert(project)

expect(datastore.getAdminRolesByOidcGroups).toHaveBeenCalledWith(['/console/admin', '/console/readonly', '/console/security'])
expect(gitlab.upsertUser).toHaveBeenCalledWith(
expect.objectContaining({ email: 'security@example.com', auditor: true }),
expect.objectContaining({ cpnUserId: 'u1' }),
)
expect(gitlab.upsertUser).toHaveBeenCalledWith(
expect.objectContaining({ email: 'auditor@example.com', auditor: true }),
expect.objectContaining({ cpnUserId: 'u2' }),
)
})

it('should configure repository mirroring if external url is present', async () => {
const project = makeProjectWithDetails({
slug: 'project-1',
Expand Down
33 changes: 17 additions & 16 deletions apps/server-nestjs/src/modules/gitlab/gitlab.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
isOwnedRepo,
isOwnedUser,
isSystemRepo,
parseGroupPaths,
} from './gitlab.utils'

type ProjectAccessLevel = Exclude<AccessLevel, (typeof AccessLevel)['ADMIN']>
Expand Down Expand Up @@ -165,18 +166,18 @@ export class GitlabService {
const span = trace.getActiveSpan()
span?.setAttribute('project.slug', project.slug)
this.logger.verbose(`Reconciling GitLab group members for project ${project.slug} (groupId=${group.id}, members=${members.length})`)
const { adminRoleId, auditorRoleId } = await this.getAdminRoleIds(project)
await this.addMissingMembers(project, group, members, adminRoleId, auditorRoleId)
await this.addMissingOwnerMember(project, group, members, adminRoleId, auditorRoleId)
const { adminRoleIds, auditorRoleIds } = await this.getAdminRoleIds(project)
await this.addMissingMembers(project, group, members, adminRoleIds, auditorRoleIds)
await this.addMissingOwnerMember(project, group, members, adminRoleIds, auditorRoleIds)
await this.purgeOrphanMembers(project, group, members)
}

private async addMissingMembers(
project: ProjectWithDetails,
group: GroupSchemaWith<'id'>,
members: MemberSchema[],
adminRoleId?: string,
auditorRoleId?: string,
adminRoleIds: string[],
auditorRoleIds: string[],
) {
const membersById = new Map(members.map(m => [m.id, m]))
const groupPaths = await this.getProjectRoleGroupPaths(project)
Expand All @@ -187,8 +188,8 @@ export class GitlabService {
email: user.email,
username: generateUsername(user.email),
name: generateName(user.firstName, user.lastName),
admin: adminRoleFlag(user, adminRoleId),
auditor: adminRoleFlag(user, auditorRoleId),
admin: adminRoleFlag(user, adminRoleIds),
auditor: adminRoleFlag(user, auditorRoleIds),
}, {
cpnUserId: user.id,
})
Expand Down Expand Up @@ -230,15 +231,15 @@ export class GitlabService {
project: ProjectWithDetails,
group: GroupSchemaWith<'id'>,
members: MemberSchema[],
adminRoleId?: string,
auditorRoleId?: string,
adminRoleIds: string[],
auditorRoleIds: string[],
) {
const gitlabUser = await this.gitlab.upsertUser({
email: project.owner.email,
username: generateUsername(project.owner.email),
name: generateName(project.owner.firstName, project.owner.lastName),
admin: adminRoleFlag(project.owner, adminRoleId),
auditor: adminRoleFlag(project.owner, auditorRoleId),
admin: adminRoleFlag(project.owner, adminRoleIds),
auditor: adminRoleFlag(project.owner, auditorRoleIds),
}, {
cpnUserId: project.owner.id,
})
Expand All @@ -250,11 +251,11 @@ export class GitlabService {
await this.ensureGroupMemberAccessLevel(group, gitlabUser.id, AccessLevel.OWNER, membersById)
}

private async getAdminRoleIds(project: ProjectWithDetails): Promise<{ adminRoleId?: string, auditorRoleId?: string }> {
const adminGroupPath = await this.getAdminGroupPath(project)
const auditorGroupPath = await this.getAuditorGroupPath(project)
const roles = await this.datastore.getAdminRolesByOidcGroups([adminGroupPath, auditorGroupPath])
return generateAdminRoleMapping(roles, adminGroupPath, auditorGroupPath)
private async getAdminRoleIds(project: ProjectWithDetails): Promise<{ adminRoleIds: string[], auditorRoleIds: string[] }> {
const adminGroupPaths = parseGroupPaths(await this.getAdminGroupPath(project))
const auditorGroupPaths = parseGroupPaths(await this.getAuditorGroupPath(project))
const roles = await this.datastore.getAdminRolesByOidcGroups([...adminGroupPaths, ...auditorGroupPaths])
return generateAdminRoleMapping(roles, adminGroupPaths, auditorGroupPaths)
}

private async getAdminGroupPath(project: ProjectWithDetails): Promise<string> {
Expand Down
33 changes: 32 additions & 1 deletion apps/server-nestjs/src/modules/gitlab/gitlab.utils.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { makeGitbeakerRequestError } from './gitlab-testing.utils'
import { ensure, isCommitAlreadyApplied, isGitbeakerRace } from './gitlab.utils'
import { adminRoleFlag, ensure, generateAdminRoleMapping, isCommitAlreadyApplied, isGitbeakerRace } from './gitlab.utils'

describe('isGitbeakerRace', () => {
it('should match collision errors only', () => {
Expand Down Expand Up @@ -56,3 +56,34 @@ describe('ensure', () => {
expect(reload).not.toHaveBeenCalled()
})
})

describe('generateAdminRoleMapping', () => {
it('should resolve role ids from every configured group path', () => {
const roles = [
{ id: 'admin-id', oidcGroup: '/console/admin' },
{ id: 'readonly-id', oidcGroup: '/console/readonly' },
{ id: 'security-id', oidcGroup: '/console/security' },
]

expect(generateAdminRoleMapping(roles, ['/console/admin'], ['/console/readonly', '/console/security']))
.toEqual({ adminRoleIds: ['admin-id'], auditorRoleIds: ['readonly-id', 'security-id'] })
})

it('should ignore unmatched paths and dedupe resolved ids', () => {
const roles = [{ id: 'readonly-id', oidcGroup: '/console/readonly' }]

expect(generateAdminRoleMapping(roles, ['/console/none'], ['/console/readonly', '/console/readonly']))
.toEqual({ adminRoleIds: [], auditorRoleIds: ['readonly-id'] })
})
})

describe('adminRoleFlag', () => {
it('should stay undefined when no role id is resolved', () => {
expect(adminRoleFlag({ adminRoleIds: ['x'] }, [])).toBeUndefined()
})

it('should match membership against any resolved role id', () => {
expect(adminRoleFlag({ adminRoleIds: ['x'] }, ['y', 'x'])).toBe(true)
expect(adminRoleFlag({ adminRoleIds: ['z'] }, ['y', 'x'])).toBe(false)
})
})
29 changes: 17 additions & 12 deletions apps/server-nestjs/src/modules/gitlab/gitlab.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,23 +40,27 @@ export function generateName(firstName: string | null, lastName: string | null):
return parts.length > 0 ? parts.join(' ') : 'User'
}

export function generateProjectRoleGroupPath(projectSlug: string, rawGroupPathSuffixes: string): string[] {
return rawGroupPathSuffixes
export function parseGroupPaths(rawGroupPaths: string): string[] {
return rawGroupPaths
.split(',')
.map(path => path.trim())
.filter(Boolean)
.map(path => `/${projectSlug}${path}`)
}

export function generateProjectRoleGroupPath(projectSlug: string, rawGroupPathSuffixes: string): string[] {
return parseGroupPaths(rawGroupPathSuffixes).map(path => `/${projectSlug}${path}`)
}

export function generateAdminRoleMapping(
roles: ProjectWithDetails['roles'],
adminGroupPath: string,
auditorGroupPath: string,
): { adminRoleId?: string, auditorRoleId?: string } {
const roleIdByOidcGroup = new Map<string | null, string>(roles.map(r => [r.oidcGroup, r.id] as [string | null, string]))
roles: { id: string, oidcGroup: string }[],
adminGroupPaths: string[],
auditorGroupPaths: string[],
): { adminRoleIds: string[], auditorRoleIds: string[] } {
const roleIdByOidcGroup = new Map(roles.map(r => [r.oidcGroup, r.id]))
const resolveRoleIds = (paths: string[]) => [...new Set(paths.map(path => roleIdByOidcGroup.get(path)).filter((id): id is string => id !== undefined))]
return {
adminRoleId: roleIdByOidcGroup.get(adminGroupPath),
auditorRoleId: roleIdByOidcGroup.get(auditorGroupPath),
adminRoleIds: resolveRoleIds(adminGroupPaths),
auditorRoleIds: resolveRoleIds(auditorGroupPaths),
}
}

Expand Down Expand Up @@ -241,8 +245,9 @@ export function daysAgoFromNow(date: Date) {
return Math.floor((Date.now() - date.getTime()) / (1000 * 60 * 60 * 24))
}

export function adminRoleFlag(user: ProjectWithDetails['members'][0]['user'], adminRoleId?: string) {
return adminRoleId ? user.adminRoleIds?.includes(adminRoleId) : undefined
export function adminRoleFlag(user: Pick<ProjectWithDetails['members'][0]['user'], 'adminRoleIds'>, adminRoleIds: string[]): boolean | undefined {
if (adminRoleIds.length === 0) return undefined
return user.adminRoleIds?.some(id => adminRoleIds.includes(id))
}

export function isGitbeakerNotFound(error: unknown): error is GitbeakerRequestError {
Expand Down
Loading