From 4b8fabd3d83f36f239c08f2acf465ea5d2bf7efb Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Fri, 17 Apr 2026 16:16:20 +0200 Subject: [PATCH] feat(gitlab): upstream support for role binding on NestJS Signed-off-by: William Phetsinorath --- .../gitlab/gitlab-client.service.spec.ts | 68 ++++++- .../modules/gitlab/gitlab-client.service.ts | 155 ++++++++++----- .../gitlab/gitlab-datastore.service.ts | 29 +++ .../src/modules/gitlab/gitlab.constants.ts | 11 +- .../src/modules/gitlab/gitlab.service.spec.ts | 140 ++++++++++++- .../src/modules/gitlab/gitlab.service.ts | 185 +++++++++++++----- .../src/modules/gitlab/gitlab.utils.ts | 10 + .../keycloak/keycloak-client.service.ts | 3 +- 8 files changed, 495 insertions(+), 106 deletions(-) diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.spec.ts b/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.spec.ts index 3f7564298b..25d7da70c1 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.spec.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.spec.ts @@ -21,6 +21,7 @@ import { makeRepositoryTreeSchema, } from './gitlab-testing.utils' import { + GROUP_ROOT_CUSTOM_ATTRIBUTE_KEY, INFRA_GROUP_CUSTOM_ATTRIBUTE_KEY, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, PROJECT_GROUP_CUSTOM_ATTRIBUTE_KEY, @@ -107,6 +108,7 @@ describe('gitlab-client', () => { path_with_namespace: 'forge/infra/zone-1', }) expect(gitlabMock.Groups.create).toHaveBeenCalledWith('infra', 'infra', expect.any(Object)) + expect(gitlabMock.GroupCustomAttributes.set).toHaveBeenCalledWith(rootId, GROUP_ROOT_CUSTOM_ATTRIBUTE_KEY, 'true') expect(gitlabMock.GroupCustomAttributes.set).toHaveBeenCalledWith(infraGroupId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') expect(gitlabMock.GroupCustomAttributes.set).toHaveBeenCalledWith(infraGroupId, INFRA_GROUP_CUSTOM_ATTRIBUTE_KEY, 'true') expect(gitlabMock.Projects.create).toHaveBeenCalledWith(expect.objectContaining({ @@ -114,6 +116,7 @@ describe('gitlab-client', () => { path: zoneSlug, namespaceId: infraGroupId, })) + expect(gitlabMock.ProjectCustomAttributes.set).toHaveBeenCalledWith(projectId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') }) }) @@ -290,27 +293,78 @@ describe('gitlab-client', () => { describe('upsertUser', () => { it('should create user and set custom attribute if not exists', async () => { const consoleUser = { id: 'u1', email: 'new@example.com', firstName: 'New', lastName: 'User' } + const gitlabUser = { + email: consoleUser.email, + username: 'new', + name: 'New User', + } const gitlabUsersAllMock = gitlabMock.Users.all as MockedFunction gitlabUsersAllMock.mockResolvedValue([]) gitlabMock.Users.create.mockResolvedValue(makeExpandedUserSchema({ id: 999, email: consoleUser.email })) - const result = await service.upsertUser(consoleUser) + const result = await service.upsertUser(gitlabUser, { cpnUserId: consoleUser.id }) expect(result).toEqual(expect.objectContaining({ id: 999, email: consoleUser.email })) + expect(gitlabMock.Users.create).toHaveBeenCalledWith(expect.objectContaining({ + email: 'new@example.com', + username: 'new', + name: 'New User', + externUid: 'new@example.com', + provider: 'openid_connect', + skipConfirmation: true, + })) expect(gitlabMock.UserCustomAttributes.set).toHaveBeenCalledWith(999, USER_ID_CUSTOM_ATTRIBUTE_KEY, consoleUser.id) + expect(gitlabMock.UserCustomAttributes.set).toHaveBeenCalledWith(999, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') }) it('should set custom attribute if user exists', async () => { const consoleUser = { id: 'u1', email: 'existing@example.com', firstName: 'Existing', lastName: 'User' } + const gitlabUser = { + email: consoleUser.email, + username: 'existing', + name: 'Existing User', + } const gitlabUsersAllMock = gitlabMock.Users.all as MockedFunction gitlabUsersAllMock.mockResolvedValue([makeExpandedUserSchema({ id: 1000, email: consoleUser.email })]) - const result = await service.upsertUser(consoleUser) + const result = await service.upsertUser(gitlabUser, { cpnUserId: consoleUser.id }) expect(result).toEqual(expect.objectContaining({ id: 1000, email: consoleUser.email })) + expect(gitlabMock.Users.edit).toHaveBeenCalledWith(1000, expect.objectContaining({ + email: 'existing@example.com', + username: 'existing', + name: 'Existing User', + externUid: 'existing@example.com', + provider: 'openid_connect', + })) expect(gitlabMock.UserCustomAttributes.set).toHaveBeenCalledWith(1000, USER_ID_CUSTOM_ATTRIBUTE_KEY, consoleUser.id) + expect(gitlabMock.UserCustomAttributes.set).toHaveBeenCalledWith(1000, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') expect(gitlabMock.Users.create).not.toHaveBeenCalled() }) + + it('should set admin flag when provided', async () => { + const consoleUser = { id: 'u1', email: 'admin@example.com', firstName: 'Admin', lastName: 'User' } + const gitlabUser = { + email: consoleUser.email, + username: 'admin', + name: 'Admin User', + } + const gitlabUsersAllMock = gitlabMock.Users.all as MockedFunction + gitlabUsersAllMock.mockResolvedValue([]) + gitlabMock.Users.create.mockResolvedValue(makeExpandedUserSchema({ id: 999, email: consoleUser.email })) + + await service.upsertUser({ ...gitlabUser, admin: true }, { cpnUserId: consoleUser.id }) + + expect(gitlabMock.Users.create).toHaveBeenCalledWith(expect.objectContaining({ + email: 'admin@example.com', + username: 'admin', + name: 'Admin User', + externUid: 'admin@example.com', + provider: 'openid_connect', + admin: true, + skipConfirmation: true, + })) + }) }) it('should create pipeline trigger token if not exists', async () => { @@ -422,9 +476,11 @@ describe('gitlab-client', () => { paginationInfo: { next: null }, }) - const result = await service.getOrCreateProjectGroupRepo(fullPath) + const result = await service.getOrCreateProjectGroupRepo(subGroupPath, fullPath) expect(result).toEqual(expect.objectContaining({ id: projectId })) + expect(gitlabMock.ProjectCustomAttributes.set).toHaveBeenCalledWith(projectId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + expect(gitlabMock.ProjectCustomAttributes.set).toHaveBeenCalledWith(projectId, PROJECT_GROUP_CUSTOM_ATTRIBUTE_KEY, 'project-1') }) it('should create repo if not exists', async () => { @@ -454,7 +510,7 @@ describe('gitlab-client', () => { gitlabMock.Projects.create.mockResolvedValue({ id: projectId, name: repoName } as ProjectSchema) - const result = await service.getOrCreateProjectGroupRepo(fullPath) + const result = await service.getOrCreateProjectGroupRepo(subGroupPath, fullPath) expect(result).toEqual(expect.objectContaining({ id: projectId })) expect(gitlabMock.Projects.create).toHaveBeenCalledWith(expect.objectContaining({ @@ -462,6 +518,8 @@ describe('gitlab-client', () => { path: repoName, namespaceId: groupId, })) + expect(gitlabMock.ProjectCustomAttributes.set).toHaveBeenCalledWith(projectId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + expect(gitlabMock.ProjectCustomAttributes.set).toHaveBeenCalledWith(projectId, PROJECT_GROUP_CUSTOM_ATTRIBUTE_KEY, 'project-1') }) }) @@ -568,7 +626,7 @@ describe('gitlab-client', () => { gitlabMock.Users.create.mockResolvedValue(user) - const result = await service.createUser(email, username, name) + const result = await service.createUser({ email, username, name }) expect(result).toEqual(user) expect(gitlabMock.Users.create).toHaveBeenCalledWith(expect.objectContaining({ diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts b/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts index ce5a714037..1c18bb2f2e 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts @@ -1,14 +1,19 @@ import type { + AccessLevel, AccessTokenScopes, BaseRequestOptions, CommitAction, CondensedGroupSchema, CondensedProjectSchema, + CreateUserOptions, + EditUserOptions, + ExpandedUserSchema, Gitlab, GroupSchema, OffsetPagination, PaginationRequestOptions, PipelineTriggerTokenSchema, + SimpleUserSchema, } from '@gitbeaker/core' import { createHash } from 'node:crypto' import { readFile } from 'node:fs/promises' @@ -28,10 +33,15 @@ import { TOPIC_PLUGIN_MANAGED, USER_ID_CUSTOM_ATTRIBUTE_KEY, } from './gitlab.constants' -import { generateUsername } from './gitlab.utils.js' export const GITLAB_REST_CLIENT = Symbol('GITLAB_REST_CLIENT') +type With = T & Required> +export type CondensedGroupSchemaWith = With +export type CondensedProjectSchemaWith = With +export type CreateUserOptionsWith = With +type UserSchema = SimpleUserSchema | ExpandedUserSchema + export interface OffsetPaginateOptions { startPage?: number perPage?: number @@ -75,17 +85,37 @@ export class GitlabClientService { } } - private async setManagedGroupAttributes(group: CondensedGroupSchema, opts: { isRoot?: boolean, isInfra?: boolean, projectSlug?: string } = {}) { - await this.upsertGroupCustomAttribute(group.id, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') - if (opts.isRoot && this.config.projectRootDir) { - await this.upsertGroupCustomAttribute(group.id, GROUP_ROOT_CUSTOM_ATTRIBUTE_KEY, this.config.projectRootDir) - } - if (opts.isInfra) { - await this.upsertGroupCustomAttribute(group.id, INFRA_GROUP_CUSTOM_ATTRIBUTE_KEY, 'true') - } - if (opts.projectSlug) { - await this.upsertGroupCustomAttribute(group.id, PROJECT_GROUP_CUSTOM_ATTRIBUTE_KEY, opts.projectSlug) - } + private async setManagedUserAttributes(userId: number, cpnUserId: string) { + await this.upsertUserCustomAttribute(userId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + await this.upsertUserCustomAttribute(userId, USER_ID_CUSTOM_ATTRIBUTE_KEY, cpnUserId) + } + + private async setManagedInfraProjectAttributes(projectId: number) { + await this.upsertProjectCustomAttribute(projectId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + } + + private async setManagedProjectAttributes(projectId: number, projectSlug: string) { + await this.upsertProjectCustomAttribute(projectId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + await this.upsertProjectCustomAttribute(projectId, PROJECT_GROUP_CUSTOM_ATTRIBUTE_KEY, projectSlug) + } + + private async setManagedGroupAttributes(groupId: number) { + await this.upsertGroupCustomAttribute(groupId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + } + + private async setManagedRootGroupAttributes(groupId: number) { + await this.setManagedGroupAttributes(groupId) + await this.upsertGroupCustomAttribute(groupId, GROUP_ROOT_CUSTOM_ATTRIBUTE_KEY, 'true') + } + + private async setManagedInfraGroupAttributes(groupId: number) { + await this.setManagedGroupAttributes(groupId) + await this.upsertGroupCustomAttribute(groupId, INFRA_GROUP_CUSTOM_ATTRIBUTE_KEY, 'true') + } + + private async setManagedProjectGroupAttributes(groupId: number, projectSlug: string) { + await this.setManagedGroupAttributes(groupId) + await this.upsertGroupCustomAttribute(groupId, PROJECT_GROUP_CUSTOM_ATTRIBUTE_KEY, projectSlug) } async getGroupByPath(path: string) { @@ -100,25 +130,25 @@ export class GitlabClientService { this.logger.log(`Creating a GitLab group at path ${path}`) const created = await this.client.Groups.create(path, path) if (this.config.projectRootDir && created.full_path === this.config.projectRootDir) { - await this.setManagedGroupAttributes(created, { isRoot: true }) + await this.setManagedRootGroupAttributes(created.id) } if (this.config.projectRootDir && created.full_path === `${this.config.projectRootDir}/${INFRA_GROUP_PATH}`) { - await this.setManagedGroupAttributes(created, { isInfra: true }) + await this.setManagedInfraGroupAttributes(created.id) } return created } - async createSubGroup(parentGroup: CondensedGroupSchema, name: string, fullPath: string) { + async createSubGroup(parentGroup: CondensedGroupSchemaWith<'id' | 'full_path'>, name: string, fullPath: string) { this.logger.log(`Creating a GitLab subgroup ${fullPath} (parentId=${parentGroup.id})`) const created = await this.client.Groups.create(name, name, { parentId: parentGroup.id }) if (this.config.projectRootDir && fullPath === this.config.projectRootDir) { - await this.setManagedGroupAttributes(created, { isRoot: true }) + await this.setManagedRootGroupAttributes(created.id) } else if (this.config.projectRootDir && fullPath === `${this.config.projectRootDir}/${INFRA_GROUP_PATH}`) { - await this.setManagedGroupAttributes(created, { isInfra: true }) + await this.setManagedInfraGroupAttributes(created.id) } else if (this.config.projectRootDir && fullPath.startsWith(`${this.config.projectRootDir}/`) && !fullPath.slice(this.config.projectRootDir.length + 1).includes('/')) { const projectSlug = fullPath.slice(this.config.projectRootDir.length + 1) if (projectSlug && projectSlug !== INFRA_GROUP_PATH) { - await this.setManagedGroupAttributes(created, { projectSlug }) + await this.setManagedProjectGroupAttributes(created.id, projectSlug) } } return created @@ -131,6 +161,9 @@ export class GitlabClientService { this.logger.verbose(`Resolving GitLab group path ${path} (depth=${1 + parts.length})`) let parentGroup = await this.getGroupByPath(rootGroupPath) ?? await this.createGroup(rootGroupPath) + if (this.config.projectRootDir && parentGroup.full_path === this.config.projectRootDir) { + await this.setManagedRootGroupAttributes(parentGroup.id) + } let currentFullPath: string for (const part of parts) { @@ -171,7 +204,7 @@ export class GitlabClientService { return `${urlBase}/${projectGroup.full_path}/${repoName}.git` } - async getOrCreateProjectGroupRepo(subGroupPath: string) { + private async getOrCreateRepo(subGroupPath: string) { const fullPath = this.config.projectRootDir ? `${this.config.projectRootDir}/${subGroupPath}` : subGroupPath @@ -215,17 +248,27 @@ export class GitlabClientService { } catch (error) { if (error instanceof GitbeakerRequestError && error.cause?.description?.includes('has already been taken')) { this.logger.warn(`GitLab project repository already exists (race); reloading ${fullPath}`) - return this.client.Projects.show(fullPath) + const reloaded = await this.client.Projects.show(fullPath) + return reloaded } throw error } } + async getOrCreateProjectGroupRepo(projectSlug: string, subGroupPath: string) { + const repo = await this.getOrCreateRepo(subGroupPath) + await this.setManagedProjectAttributes(repo.id, projectSlug) + return repo + } + async getOrCreateInfraGroupRepo(path: string) { - return this.getOrCreateProjectGroupRepo(join(INFRA_GROUP_PATH, path)) + const fullPath = join(INFRA_GROUP_PATH, path) + const repo = await this.getOrCreateRepo(fullPath) + await this.setManagedInfraProjectAttributes(repo.id) + return repo } - async getFile(repo: CondensedProjectSchema, filePath: string, ref: string = 'main') { + async getFile(repo: CondensedProjectSchemaWith<'id'>, filePath: string, ref: string = 'main') { try { return await this.client.RepositoryFiles.show(repo.id, filePath, ref) } catch (error) { @@ -238,7 +281,7 @@ export class GitlabClientService { } async maybeCreateCommit( - repo: CondensedProjectSchema, + repo: CondensedProjectSchemaWith<'id'>, message: string, actions: CommitAction[], ref: string = 'main', @@ -252,7 +295,7 @@ export class GitlabClientService { this.logger.verbose(`GitLab commit created (repoId=${repo.id}, ref=${ref}, actions=${actions.length})`) } - async generateCreateOrUpdateAction(repo: CondensedProjectSchema, ref: string, filePath: string, content: string) { + async generateCreateOrUpdateAction(repo: CondensedProjectSchemaWith<'id'>, ref: string, filePath: string, content: string) { const file = await this.getFile(repo, filePath, ref) if (file && !hasFileContentChanged(file, content)) { this.logger.debug(`GitLab file is up to date; skipping commit action (repoId=${repo.id}, ref=${ref}, filePath=${filePath})`) @@ -266,7 +309,7 @@ export class GitlabClientService { } satisfies CommitAction } - async listFiles(repo: CondensedProjectSchema, options: { path?: string, recursive?: boolean, ref?: string } = {}) { + async listFiles(repo: CondensedProjectSchemaWith<'id'>, options: { path?: string, recursive?: boolean, ref?: string } = {}) { try { const path = options.path ?? '/' const recursive = options.recursive ?? false @@ -298,53 +341,70 @@ export class GitlabClientService { ) } - async deleteGroup(group: CondensedGroupSchema): Promise { + async deleteGroup(group: CondensedGroupSchemaWith<'id' | 'full_path'>): Promise { this.logger.verbose(`Deleting GitLab group ${group.full_path} (groupId=${group.id})`) await this.client.Groups.remove(group.id) } - async getGroupMembers(group: CondensedGroupSchema) { + async getGroupMembers(group: CondensedGroupSchemaWith<'id'>) { this.logger.verbose(`Loading GitLab group members (groupId=${group.id})`) return this.client.GroupMembers.all(group.id) } - async addGroupMember(group: CondensedGroupSchema, userId: number, accessLevel: number) { + async addGroupMember(group: CondensedGroupSchemaWith<'id'>, userId: number, accessLevel: Exclude) { this.logger.verbose(`Adding a GitLab group member (groupId=${group.id}, userId=${userId}, accessLevel=${accessLevel})`) return this.client.GroupMembers.add(group.id, userId, accessLevel) } - async editGroupMember(group: CondensedGroupSchema, userId: number, accessLevel: number) { + async editGroupMember(group: CondensedGroupSchemaWith<'id'>, userId: number, accessLevel: Exclude) { this.logger.verbose(`Editing a GitLab group member (groupId=${group.id}, userId=${userId}, accessLevel=${accessLevel})`) return this.client.GroupMembers.edit(group.id, userId, accessLevel) } - async removeGroupMember(group: CondensedGroupSchema, userId: number) { + async removeGroupMember(group: CondensedGroupSchemaWith<'id'>, userId: number) { this.logger.verbose(`Removing a GitLab group member (groupId=${group.id}, userId=${userId})`) return this.client.GroupMembers.remove(group.id, userId) } - async getUserByEmail(email: string) { + async getUserByEmail(email: string): Promise { const users = await this.client.Users.all({ search: email, orderBy: 'username' }) if (users.length === 0) return null - return users[0] + return users[0] as UserSchema } - async createUser(email: string, username: string, name: string) { - this.logger.log(`Creating a GitLab user (email=${email}, username=${username})`) + async createUser(user: CreateUserOptions): Promise { + this.logger.log(`Creating a GitLab user (email=${user.email}, username=${user.username})`) return await this.client.Users.create({ - email, - username, - name, + ...user, skipConfirmation: true, - }) + }) as UserSchema } - async upsertUser(user: { id: string, email: string, firstName: string, lastName: string }) { - const existing = await this.getUserByEmail(user.email) - const username = generateUsername(user.email) - const name = `${user.firstName} ${user.lastName}`.trim() - const gitlabUser = existing ?? await this.createUser(user.email, username, name) - await this.upsertUserCustomAttribute(gitlabUser.id, USER_ID_CUSTOM_ATTRIBUTE_KEY, user.id) + async upsertUser( + user: Omit, 'externUid' | 'provider'>, + options: { cpnUserId: string }, + ): Promise { + const existing: UserSchema | null = await this.getUserByEmail(user.email) + + const createOptions: CreateUserOptions = { + ...user, + externUid: user.email, + provider: 'openid_connect', + } + const editOptions: EditUserOptions = createOptions as unknown as EditUserOptions + + const gitlabUser: UserSchema = existing ?? await this.createUser(createOptions) + + if (existing) { + const hasDiff = Object.entries(editOptions).some(([key, value]) => { + if (value === undefined) return false + return (existing as Record)[key] !== value + }) + if (hasDiff) { + await this.client.Users.edit(gitlabUser.id, editOptions) + } + } + await this.setManagedUserAttributes(gitlabUser.id, options.cpnUserId) return gitlabUser } @@ -357,19 +417,20 @@ export class GitlabClientService { } async upsertProjectGroupRepo(projectSlug: string, repoName: string, description?: string) { - const repo = await this.getOrCreateProjectGroupRepo(`${projectSlug}/${repoName}`) + const fullPath = `${projectSlug}/${repoName}` + const repo = await this.getOrCreateProjectGroupRepo(projectSlug, fullPath) const updated = await this.client.Projects.edit(repo.id, { name: repoName, path: repoName, topics: [TOPIC_PLUGIN_MANAGED], description, }) - await this.upsertProjectCustomAttribute(repo.id, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') return updated } async deleteProjectGroupRepo(projectSlug: string, repoName: string) { - const repo = await this.getOrCreateProjectGroupRepo(`${projectSlug}/${repoName}`) + const fullPath = `${projectSlug}/${repoName}` + const repo = await this.getOrCreateProjectGroupRepo(projectSlug, fullPath) return this.client.Projects.remove(repo.id) } diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab-datastore.service.ts b/apps/server-nestjs/src/modules/gitlab/gitlab-datastore.service.ts index 893414df6f..3820f98632 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab-datastore.service.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab-datastore.service.ts @@ -93,6 +93,35 @@ export class GitlabDatastoreService { }) } + async getAdminPluginConfig(pluginName: string, key: string): Promise { + const result = await this.prisma.adminPlugin.findUnique({ + where: { + pluginName_key: { + pluginName, + key, + }, + }, + select: { + value: true, + }, + }) + return result?.value ?? null + } + + async getAdminRolesByOidcGroups(oidcGroups: string[]): Promise<{ id: string, oidcGroup: string }[]> { + return this.prisma.adminRole.findMany({ + where: { + oidcGroup: { + in: oidcGroups, + }, + }, + select: { + id: true, + oidcGroup: true, + }, + }) + } + async getUser(id: string) { return this.prisma.user.findUnique({ where: { diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.constants.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.constants.ts index b52f8b95a5..07f440ebbb 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab.constants.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.constants.ts @@ -11,12 +11,15 @@ export const DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX = '/console/admin' export const DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX = '/console/developer,/console/devops' export const DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX = '/console/readonly' +export const ADMIN_GROUP_PATH_PLUGIN_KEY = 'adminGroupPath' +export const AUDITOR_GROUP_PATH_PLUGIN_KEY = 'auditorGroupPath' +export const PROJECT_REPORTER_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectReporterGroupPathSuffix' +export const PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectDeveloperGroupPathSuffix' +export const PROJECT_MAINTAINER_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectMaintainerGroupPathSuffix' +export const PURGE_PLUGIN_KEY = 'purge' + export const GROUP_ROOT_CUSTOM_ATTRIBUTE_KEY = 'cpn_projects_root_dir' export const INFRA_GROUP_CUSTOM_ATTRIBUTE_KEY = 'cpn_infra_group' export const PROJECT_GROUP_CUSTOM_ATTRIBUTE_KEY = 'cpn_project_slug' export const USER_ID_CUSTOM_ATTRIBUTE_KEY = 'cpn_user_id' export const MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY = 'cpn_managed_by_console' - -export function customAttributesFilter(key: string, value: string) { - return { [`custom_attributes[${key}]`]: value } as Record -} 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 e948a7071a..73e20821c7 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts @@ -1,4 +1,3 @@ -import type { AccessTokenExposedSchema } from '@gitbeaker/core' import type { Mocked } from 'vitest' import { ENABLED } from '@cpn-console/shared' import { faker } from '@faker-js/faker' @@ -44,6 +43,8 @@ function createGitlabControllerServiceTestingModule() { provide: GitlabDatastoreService, useValue: { getAllProjects: vi.fn(), + getAdminPluginConfig: vi.fn(), + getAdminRolesByOidcGroups: vi.fn(), } satisfies Partial, }, { @@ -89,6 +90,8 @@ describe('gitlabService', () => { vault.writeMirrorTriggerToken.mockResolvedValue(undefined) vault.readTechnReadOnlyCreds.mockResolvedValue(null) vault.readGitlabMirrorCreds.mockResolvedValue(null) + gitlabDatastore.getAdminPluginConfig.mockResolvedValue(null) + gitlabDatastore.getAdminRolesByOidcGroups.mockResolvedValue([]) }) it('should be defined', () => { @@ -251,7 +254,7 @@ describe('gitlabService', () => { id: user.email === 'new@example.com' ? 999 : 998, email: user.email, username: user.email.split('@')[0] ?? user.email, - name: `${user.firstName} ${user.lastName}`, + name: user.name, }) }) gitlab.getRepos.mockReturnValue((async function* () { })()) @@ -260,12 +263,141 @@ describe('gitlabService', () => { await service.handleUpsert(project) - expect(gitlab.upsertUser).toHaveBeenCalledWith(expect.objectContaining({ email: 'new@example.com' })) - expect(gitlab.upsertUser).toHaveBeenCalledWith(expect.objectContaining({ email: 'owner@example.com' })) + expect(gitlab.upsertUser).toHaveBeenCalledWith( + expect.objectContaining({ email: 'new@example.com' }), + expect.objectContaining({ cpnUserId: 'u1' }), + ) + expect(gitlab.upsertUser).toHaveBeenCalledWith( + expect.objectContaining({ email: 'owner@example.com' }), + expect.objectContaining({ cpnUserId: 'o1' }), + ) expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 999, AccessLevel.GUEST) expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 998, AccessLevel.OWNER) }) + it('should map roles to access levels and apply highest level', async () => { + const project = makeProjectWithDetails({ + roles: [ + { id: 'r-reporter', oidcGroup: '/project-1/console/readonly' }, + { id: 'r-developer', oidcGroup: '/project-1/console/developer' }, + { id: 'r-maintainer', oidcGroup: '/project-1/console/admin' }, + { id: 'r-unknown', oidcGroup: '/other/group' }, + ], + members: [ + { user: { id: 'u1', email: 'reporter@example.com', firstName: 'Rep', lastName: 'User', adminRoleIds: [] }, roleIds: ['r-reporter'] }, + { user: { id: 'u2', email: 'developer@example.com', firstName: 'Dev', lastName: 'User', adminRoleIds: [] }, roleIds: ['r-developer'] }, + { user: { id: 'u3', email: 'maintainer@example.com', firstName: 'Main', lastName: 'User', adminRoleIds: [] }, roleIds: ['r-maintainer'] }, + { user: { id: 'u4', email: 'mixed@example.com', firstName: 'Mixed', lastName: 'User', adminRoleIds: [] }, roleIds: ['r-reporter', 'r-developer'] }, + ], + }) + 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 }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.upsertUser.mockImplementation(async (user) => { + const idByEmail: Record = { + 'reporter@example.com': 101, + 'developer@example.com': 102, + 'maintainer@example.com': 103, + 'mixed@example.com': 104, + 'owner@example.com': 100, + } + return makeExpandedUserSchema({ + id: idByEmail[user.email] ?? 999, + email: user.email, + username: user.email.split('@')[0] ?? user.email, + 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(gitlab.addGroupMember).toHaveBeenCalledWith(group, 101, AccessLevel.REPORTER) + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 102, AccessLevel.DEVELOPER) + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 103, AccessLevel.MAINTAINER) + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 104, AccessLevel.DEVELOPER) + }) + + it('should downgrade existing member to guest when no role maps to an access level', async () => { + const project = makeProjectWithDetails({ + roles: [{ id: 'r-unknown', oidcGroup: '/other/group' }], + members: [{ user: { id: 'u1', email: 'no-access@example.com', firstName: 'No', lastName: 'Access', adminRoleIds: [] }, roleIds: ['r-unknown'] }], + }) + 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 }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([makeMemberSchema({ id: 105, username: 'no-access', access_level: AccessLevel.REPORTER })]) + gitlab.upsertUser.mockImplementation(async (user) => { + return makeExpandedUserSchema({ + id: user.email === 'no-access@example.com' ? 105 : 100, + email: user.email, + username: user.email.split('@')[0] ?? user.email, + 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(gitlab.editGroupMember).toHaveBeenCalledWith(group, 105, AccessLevel.GUEST) + expect(gitlab.removeGroupMember).not.toHaveBeenCalledWith(group, 105) + }) + + it('should bind builtin roles (admin/auditor) when role ids are resolved', async () => { + const project = makeProjectWithDetails({ + owner: { id: 'o1', email: 'owner@example.com', firstName: 'Owner', lastName: 'User', adminRoleIds: ['admin-role-id'] }, + members: [ + { user: { id: 'u1', email: 'admin@example.com', firstName: 'Admin', lastName: 'User', adminRoleIds: ['admin-role-id'] }, roleIds: [] }, + { user: { id: 'u2', email: 'auditor@example.com', firstName: 'Auditor', lastName: 'User', adminRoleIds: ['auditor-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 }) + + gitlabDatastore.getAdminPluginConfig.mockImplementation(async (_pluginName: string, key: string) => { + if (key === 'adminGroupPath') return '/console/admin' + if (key === 'auditorGroupPath') return '/console/readonly' + return null + }) + gitlabDatastore.getAdminRolesByOidcGroups.mockResolvedValue([ + { id: 'admin-role-id', oidcGroup: '/console/admin' }, + { id: 'auditor-role-id', oidcGroup: '/console/readonly' }, + ]) + + 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(gitlab.upsertUser).toHaveBeenCalledWith( + expect.objectContaining({ email: 'admin@example.com', admin: true, auditor: false }), + expect.objectContaining({ cpnUserId: 'u1' }), + ) + expect(gitlab.upsertUser).toHaveBeenCalledWith( + expect.objectContaining({ email: 'auditor@example.com', admin: false, auditor: true }), + expect.objectContaining({ cpnUserId: 'u2' }), + ) + expect(gitlab.upsertUser).toHaveBeenCalledWith( + expect.objectContaining({ email: 'owner@example.com', admin: true, auditor: false }), + expect.objectContaining({ cpnUserId: 'o1' }), + ) + }) + it('should configure repository mirroring if external url is present', async () => { const project = makeProjectWithDetails({ repositories: [{ diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts index 0abc166131..46a5bd1b42 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts @@ -13,11 +13,25 @@ import { getAll } from '../../utils/iterable' import { VaultClientService } from '../vault/vault-client.service' import { GitlabClientService } from './gitlab-client.service' import { GitlabDatastoreService } from './gitlab-datastore.service' -import { INFRA_APPS_REPO_NAME, TOPIC_PLUGIN_MANAGED } from './gitlab.constants' -import { DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX } from './gitlab.constants.js' -import { generateUsernameCandidates } from './gitlab.utils' +import { + ADMIN_GROUP_PATH_PLUGIN_KEY, + AUDITOR_GROUP_PATH_PLUGIN_KEY, + DEFAULT_ADMIN_GROUP_PATH, + DEFAULT_AUDITOR_GROUP_PATH, + DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX, + INFRA_APPS_REPO_NAME, + PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_MAINTAINER_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_REPORTER_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PURGE_PLUGIN_KEY, + TOPIC_PLUGIN_MANAGED, +} from './gitlab.constants' +import { generateName, generateUsername, generateUsernameCandidates } from './gitlab.utils' const ownedUserRegex = /group_\d+_bot/u +type ProjectAccessLevel = Exclude @Injectable() export class GitlabService { @@ -97,8 +111,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})`) - await this.addMissingMembers(project, group, members) - await this.addMissingOwnerMember(project, group, members) + const { adminRoleId, auditorRoleId } = await this.getAdminRoleIds(project) + await this.addMissingMembers(project, group, members, adminRoleId, auditorRoleId) + await this.addMissingOwnerMember(project, group, members, adminRoleId, auditorRoleId) await this.purgeOrphanMembers(project, group, members) } @@ -106,17 +121,28 @@ export class GitlabService { project: ProjectWithDetails, group: CondensedGroupSchema, members: MemberSchema[], + adminRoleId?: string, + auditorRoleId?: string, ) { const membersById = new Map(members.map(m => [m.id, m])) - const accessLevelByUserId = generateAccessLevelMapping(project) + const groupPaths = await this.getProjectRoleGroupPaths(project) + const accessLevelByUserId = generateAccessLevelMapping(project, groupPaths) await Promise.all(project.members.map(async ({ user }) => { - const gitlabUser = await this.gitlab.upsertUser(user) + const gitlabUser = await this.gitlab.upsertUser({ + email: user.email, + username: generateUsername(user.email), + name: generateName(user.firstName, user.lastName), + admin: adminRoleFlag(user, adminRoleId), + auditor: adminRoleFlag(user, auditorRoleId), + }, { + cpnUserId: user.id, + }) if (!gitlabUser) { this.logger.warn(`Unable to resolve a GitLab user for a project member (project=${project.slug}, userId=${user.id}, email=${user.email})`) return } - const accessLevel = accessLevelByUserId.get(user.id) ?? AccessLevel.NO_ACCESS + const accessLevel = accessLevelByUserId.get(user.id) ?? AccessLevel.GUEST await this.ensureGroupMemberAccessLevel(group, gitlabUser.id, accessLevel, membersById) })) } @@ -124,7 +150,7 @@ export class GitlabService { private async ensureGroupMemberAccessLevel( group: CondensedGroupSchema, gitlabUserId: number, - accessLevel: AccessLevel, + accessLevel: ProjectAccessLevel, membersById: Map, ) { const existingMember = membersById.get(gitlabUserId) @@ -150,8 +176,18 @@ export class GitlabService { project: ProjectWithDetails, group: CondensedGroupSchema, members: MemberSchema[], + adminRoleId?: string, + auditorRoleId?: string, ) { - const gitlabUser = await this.gitlab.upsertUser(project.owner) + 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), + }, { + cpnUserId: project.owner.id, + }) if (!gitlabUser) { this.logger.warn(`Unable to resolve the GitLab owner account (project=${project.slug}, ownerId=${project.owner.id}, email=${project.owner.email})`) return @@ -160,6 +196,63 @@ 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.gitlabDatastore.getAdminRolesByOidcGroups([adminGroupPath, auditorGroupPath]) + return generateAdminRoleMapping(roles, adminGroupPath, auditorGroupPath) + } + + 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 { + return await this.getAdminOrProjectPluginConfig(project, AUDITOR_GROUP_PATH_PLUGIN_KEY) ?? DEFAULT_AUDITOR_GROUP_PATH + } + + private async getAdminOrProjectPluginConfig(project: ProjectWithDetails, key: string): Promise { + const adminPluginConfig = await this.gitlabDatastore.getAdminPluginConfig('gitlab', key) + if (adminPluginConfig) return adminPluginConfig + if (!project) return undefined + return getProjectPluginConfig(project, key) ?? undefined + } + + private async getProjectRoleGroupPaths(project: ProjectWithDetails): Promise<{ reporter: string[], developer: string[], maintainer: string[] }> { + const [reporter, developer, maintainer] = await Promise.all([ + this.getProjectReporterGroupPaths(project), + this.getProjectDeveloperGroupPaths(project), + this.getProjectMaintainerGroupPaths(project), + ]) + + return { + reporter, + developer, + maintainer, + } + } + + private async getProjectReporterGroupPaths(project: ProjectWithDetails): Promise { + const projectConfig = getProjectPluginConfig(project, PROJECT_REPORTER_GROUP_PATH_SUFFIX_PLUGIN_KEY) + const globalConfig = await this.getAdminOrProjectPluginConfig(project, PROJECT_REPORTER_GROUP_PATH_SUFFIX_PLUGIN_KEY) + const raw = projectConfig ?? globalConfig ?? DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectDeveloperGroupPaths(project: ProjectWithDetails): Promise { + const projectConfig = getProjectPluginConfig(project, PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY) + const globalConfig = await this.getAdminOrProjectPluginConfig(project, PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY) + const raw = projectConfig ?? globalConfig ?? DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectMaintainerGroupPaths(project: ProjectWithDetails): Promise { + const projectConfig = getProjectPluginConfig(project, PROJECT_MAINTAINER_GROUP_PATH_SUFFIX_PLUGIN_KEY) + const globalConfig = await this.getAdminOrProjectPluginConfig(project, PROJECT_MAINTAINER_GROUP_PATH_SUFFIX_PLUGIN_KEY) + const raw = projectConfig ?? globalConfig ?? DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX + return generateProjectRoleGroupPath(project.slug, raw) + } + @StartActiveSpan() private async purgeOrphanMembers( project: ProjectWithDetails, @@ -172,7 +265,7 @@ export class GitlabService { 'group.id': group.id, 'members.total': members.length, }) - const purgeConfig = getPluginConfig(project, 'purge') + const purgeConfig = getProjectPluginConfig(project, PURGE_PLUGIN_KEY) const usernames = new Set([ ...generateUsernameCandidates(project.owner.email), ...project.members.flatMap(m => generateUsernameCandidates(m.user.email)), @@ -255,7 +348,7 @@ export class GitlabService { const orphanRepos = gitlabRepositories.filter(r => isOwnedRepo(r) && !isSystemRepo(project, r)) span?.setAttribute('orphan.repositories.count', orphanRepos.length) - if (specificallyEnabled(getPluginConfig(project, 'purge'))) { + if (specificallyEnabled(getProjectPluginConfig(project, PURGE_PLUGIN_KEY))) { span?.setAttribute('purge.enabled', true) let removedCount = 0 await Promise.all(orphanRepos.map(async (orphan) => { @@ -424,58 +517,60 @@ function isSystemRepo(project: ProjectWithDetails, repo: ProjectSchema) { return project.repositories.some(r => r.internalRepoName === repo.name) } -function getPluginConfig(project: ProjectWithDetails, key: string) { +function getProjectPluginConfig(project: ProjectWithDetails, key: string) { return project.plugins?.find(p => p.key === key)?.value } -function getGroupPathSuffixes(project: ProjectWithDetails, key: string) { - const value = getPluginConfig(project, key) - if (!value) return null - return value.split(',').map(path => `/${project.slug}${path}`) +function generateProjectRoleGroupPath(projectSlug: string, rawGroupPathSuffixes: string) { + return rawGroupPathSuffixes + .split(',') + .map(path => path.trim()) + .filter(Boolean) + .map(path => `/${projectSlug}${path}`) } -function generateAccessLevelMapping(project: ProjectWithDetails) { - const projectReporterGroupPathSuffixes = getProjectReporterGroupPaths(project) - const projectDeveloperGroupPathSuffixes = getProjectDeveloperGroupPaths(project) - const projectMaintainerGroupPathSuffixes = getProjectMaintainerGroupPaths(project) +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])) + return { + adminRoleId: roleIdByOidcGroup.get(adminGroupPath), + auditorRoleId: roleIdByOidcGroup.get(auditorGroupPath), + } +} - const getAccessLevelFromOidcGroup = (oidcGroup: string | null) => { +function generateAccessLevelMapping( + project: ProjectWithDetails, + groupPaths: { reporter: string[], developer: string[], maintainer: string[] }, +) { + const getAccessLevelFromOidcGroup = (oidcGroup: string | null): ProjectAccessLevel | null => { if (!oidcGroup) return null - if (projectReporterGroupPathSuffixes.includes(oidcGroup)) return AccessLevel.REPORTER - if (projectDeveloperGroupPathSuffixes.includes(oidcGroup)) return AccessLevel.DEVELOPER - if (projectMaintainerGroupPathSuffixes.includes(oidcGroup)) return AccessLevel.MAINTAINER + if (groupPaths.reporter.includes(oidcGroup)) return AccessLevel.REPORTER + if (groupPaths.developer.includes(oidcGroup)) return AccessLevel.DEVELOPER + if (groupPaths.maintainer.includes(oidcGroup)) return AccessLevel.MAINTAINER return null } - const roleAccessLevelById = new Map( + const roleAccessLevelById = new Map( project.roles.map(role => [role.id, getAccessLevelFromOidcGroup(role.oidcGroup)]), ) - return new Map(project.members.map((membership) => { - let highest = AccessLevel.GUEST + return new Map(project.members.map((membership) => { + let highest: ProjectAccessLevel | null = null for (const roleId of membership.roleIds) { const level = roleAccessLevelById.get(roleId) - if (level !== null && level !== undefined && level > highest) highest = level + if (level !== null && level !== undefined && (highest === null || level > highest)) highest = level } - return [membership.user.id, highest] as const + return [membership.user.id, highest ?? AccessLevel.GUEST] as const })) } -function getProjectMaintainerGroupPaths(project: ProjectWithDetails) { - return getGroupPathSuffixes(project, 'projectMaintainerGroupPathSuffix') - ?? DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX.split(',').map(path => `/${project.slug}${path}`) -} - -function getProjectDeveloperGroupPaths(project: ProjectWithDetails) { - return getGroupPathSuffixes(project, 'projectDeveloperGroupPathSuffix') - ?? DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX.split(',').map(path => `/${project.slug}${path}`) -} - -function getProjectReporterGroupPaths(project: ProjectWithDetails) { - return getGroupPathSuffixes(project, 'projectReporterGroupPathSuffix') - ?? DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX.split(',').map(path => `/${project.slug}${path}`) -} - function daysAgoFromNow(date: Date) { return Math.floor((Date.now() - date.getTime()) / (1000 * 60 * 60 * 24)) } + +function adminRoleFlag(user: ProjectWithDetails['user'], adminRoleId?: string) { + return adminRoleId ? user.adminRoleIds?.includes(adminRoleId) : undefined +} diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.utils.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.utils.ts index 3a5eb62b7d..9d2e1c6a3b 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab.utils.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.utils.ts @@ -2,6 +2,16 @@ export function generateUsername(email: string) { return email.split('@')[0] ?? email } +export function generateName(firstName: string | null | undefined, lastName: string | null | undefined) { + const first = firstName?.trim() ?? '' + const last = lastName?.trim() ?? '' + return `${first} ${last}`.trim() +} + +export function customAttributesFilter(key: string, value: string) { + return { [`custom_attributes[${key}]`]: value } as Record +} + export function generateFullyQualifiedUsername(email: string) { return email.replace('@', '.') } diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts b/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts index 7465ba190b..8e916c630f 100644 --- a/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts +++ b/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts @@ -9,7 +9,8 @@ import { ConfigurationService } from '../../cpin-module/infrastructure/configura import { StartActiveSpan } from '../../cpin-module/infrastructure/telemetry/telemetry.decorator' import { CONSOLE_GROUP_NAME, SUBGROUPS_PAGINATE_QUERY_MAX } from './keycloak.constants' -export type GroupRepresentationWith = GroupRepresentation & Required> +type With = T & Required> +export type GroupRepresentationWith = With export const KEYCLOAK_ADMIN_CLIENT = Symbol('KEYCLOAK_ADMIN_CLIENT')