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 860c72e50e..299b431fdc 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts @@ -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', diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts index 4af900a9d4..2b54ae6062 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts @@ -48,6 +48,7 @@ import { isOwnedRepo, isOwnedUser, isSystemRepo, + parseGroupPaths, } from './gitlab.utils' type ProjectAccessLevel = Exclude @@ -165,9 +166,9 @@ 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) } @@ -175,8 +176,8 @@ export class GitlabService { 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) @@ -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, }) @@ -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, }) @@ -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 { diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.utils.spec.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.utils.spec.ts index 9aad038f23..58055b670d 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab.utils.spec.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.utils.spec.ts @@ -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', () => { @@ -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) + }) +}) diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.utils.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.utils.ts index 1893e26875..63853a9123 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab.utils.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.utils.ts @@ -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(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), } } @@ -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, 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 {