From bde52fc416b52de70a37f77c9cc8ee5daa20a06f Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Mon, 23 Feb 2026 11:37:48 +0100 Subject: [PATCH 1/5] fix(gitlab): iterate over pagination Current implementation assume that the API return the whole list, which is not truth. Related: https://github.com/cloud-pi-native/console/issues/1916 Co-authored-by: William Phetsinorath Signed-off-by: William Phetsinorath --- plugins/gitlab/src/class.ts | 74 ++++++++++++++---------------- plugins/gitlab/src/user.ts | 25 ++++------ plugins/gitlab/src/utils.ts | 71 +++++++++++++++++++++------- plugins/sonarqube/src/functions.ts | 9 ++-- plugins/sonarqube/src/user.ts | 26 ++++++++--- 5 files changed, 120 insertions(+), 85 deletions(-) diff --git a/plugins/gitlab/src/class.ts b/plugins/gitlab/src/class.ts index ce8d9e8082..54a882aef7 100644 --- a/plugins/gitlab/src/class.ts +++ b/plugins/gitlab/src/class.ts @@ -1,12 +1,11 @@ import { createHash } from 'node:crypto' import { PluginApi, type Project, type UniqueRepo } from '@cpn-console/hooks' -import type { AccessTokenScopes, CommitAction, GroupSchema, GroupStatisticsSchema, MemberSchema, ProjectVariableSchema, VariableSchema } from '@gitbeaker/rest' -import type { AllRepositoryTreesOptions, CondensedProjectSchema, Gitlab, PaginationRequestOptions, ProjectSchema, RepositoryFileExpandedSchema, RepositoryTreeSchema } from '@gitbeaker/core' +import type { AccessTokenScopes, CommitAction, GroupSchema, MemberSchema, ProjectVariableSchema, VariableSchema, AllRepositoryTreesOptions, CondensedProjectSchema, Gitlab, ProjectSchema, RepositoryFileExpandedSchema } from '@gitbeaker/core' import { AccessLevel } from '@gitbeaker/core' import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/vault-project-api.js' import { objectEntries } from '@cpn-console/shared' import type { GitbeakerRequestError } from '@gitbeaker/requester-utils' -import { getApi, getGroupRootId, infraAppsRepoName, internalMirrorRepoName } from './utils.js' +import { find, getApi, getAll, getGroupRootId, infraAppsRepoName, internalMirrorRepoName, offsetPaginate } from './utils.js' import config from './config.js' type setVariableResult = 'created' | 'updated' | 'already up-to-date' @@ -69,8 +68,8 @@ export class GitlabApi extends PluginApi { ): Promise { let action: CommitAction['action'] = 'create' - const branches = await this.api.Branches.all(repoId) - if (branches.some(b => b.name === branch)) { + const existingBranch = await find(offsetPaginate(opts => this.api.Branches.all(repoId, opts)), b => b.name === branch) + if (existingBranch) { let actualFile: RepositoryFileExpandedSchema | undefined try { actualFile = await this.api.RepositoryFiles.show(repoId, filePath, branch) @@ -152,12 +151,12 @@ export class GitlabApi extends PluginApi { return filesUpdated } - public async listFiles(repoId: number, options: AllRepositoryTreesOptions & PaginationRequestOptions<'keyset'> = {}) { + public async listFiles(repoId: number, options: AllRepositoryTreesOptions = {}) { options.path = options?.path ?? '/' options.ref = options?.ref ?? 'main' options.recursive = options?.recursive ?? false try { - const files: RepositoryTreeSchema[] = await this.api.Repositories.allRepositoryTrees(repoId, options) + const files = await this.api.Repositories.allRepositoryTrees(repoId, options) // if (depth >= 0) { // for (const file of files) { // if (file.type !== 'tree') { @@ -199,8 +198,11 @@ export class GitlabZoneApi extends GitlabApi { public async getOrCreateInfraGroup(): Promise { const rootId = await getGroupRootId() // Get or create projects_root_dir/infra group - const searchResult = await this.api.Groups.search(infraGroupName) - const existingParentGroup = searchResult.find(group => group.parent_id === rootId && group.name === infraGroupName) + const existingParentGroup = await find(offsetPaginate(opts => this.api.Groups.all({ + search: infraGroupName, + orderBy: 'id', + ...opts, + })), group => group.parent_id === rootId && group.name === infraGroupName) return existingParentGroup || await this.api.Groups.create(infraGroupName, infraGroupPath, { parentId: rootId, projectCreationLevel: 'maintainer', @@ -216,18 +218,16 @@ export class GitlabZoneApi extends GitlabApi { } const infraGroup = await this.getOrCreateInfraGroup() // Get or create projects_root_dir/infra/zone - const infraProjects = await this.api.Groups.allProjects(infraGroup.id, { + const project = await find(offsetPaginate(opts => this.api.Groups.allProjects(infraGroup.id, { search: zone, simple: true, - perPage: 100, - }) - const project: ProjectSchema = infraProjects.find(repo => repo.name === zone) ?? await this.createEmptyRepository({ + ...opts, + })), repo => repo.name === zone) ?? await this.createEmptyRepository({ repoName: zone, groupId: infraGroup.id, description: 'Repository hosting deployment files for this zone.', createFirstCommit: true, - }, - ) + }) this.infraProjectsByZoneSlug.set(zone, project) return project } @@ -235,7 +235,7 @@ export class GitlabZoneApi extends GitlabApi { export class GitlabProjectApi extends GitlabApi { private project: Project | UniqueRepo - private gitlabGroup: GroupSchema & { statistics: GroupStatisticsSchema } | undefined + private gitlabGroup: GroupSchema | undefined private specialRepositories: string[] = [infraAppsRepoName, internalMirrorRepoName] private zoneApi: GitlabZoneApi @@ -248,9 +248,12 @@ export class GitlabProjectApi extends GitlabApi { // Group Project private async createProjectGroup(): Promise { - const searchResult = await this.api.Groups.search(this.project.slug) const parentId = await getGroupRootId() - const existingGroup = searchResult.find(group => group.parent_id === parentId && group.name === this.project.slug) + const existingGroup = await find(offsetPaginate(opts => this.api.Groups.all({ + search: this.project.slug, + orderBy: 'id', + ...opts, + })), group => group.parent_id === parentId && group.name === this.project.slug) if (existingGroup) return existingGroup @@ -265,8 +268,7 @@ export class GitlabProjectApi extends GitlabApi { public async getProjectGroup(): Promise { if (this.gitlabGroup) return this.gitlabGroup const parentId = await getGroupRootId() - const searchResult = await this.api.Groups.allSubgroups(parentId) - this.gitlabGroup = searchResult.find(group => group.name === this.project.slug) + this.gitlabGroup = await find(offsetPaginate(opts => this.api.Groups.allSubgroups(parentId, opts)), group => group.name === this.project.slug) return this.gitlabGroup } @@ -323,21 +325,15 @@ export class GitlabProjectApi extends GitlabApi { public async getProjectId(projectName: string) { const projectGroup = await this.getProjectGroup() - if (!projectGroup) { - throw new Error('Parent DSO Project group has not been created yet') - } - const projectsInGroup = await this.api.Groups.allProjects(projectGroup.id, { + if (!projectGroup) throw new Error(`Gitlab inaccessible, impossible de trouver le groupe ${this.project.slug}`) + + const project = await find(offsetPaginate(opts => this.api.Groups.allProjects(projectGroup.id, { search: projectName, simple: true, - perPage: 100, - }) - const project = projectsInGroup.find(p => p.path === projectName) + ...opts, + })), repo => repo.name === projectName) - if (!project) { - const pathProjectName = `${config().projectsRootDir}/${this.project.slug}/${projectName}` - throw new Error(`Gitlab project "${pathProjectName}" not found`) - } - return project.id + return project?.id } public async getProjectById(projectId: number) { @@ -351,8 +347,7 @@ export class GitlabProjectApi extends GitlabApi { public async getProjectToken(tokenName: string) { const group = await this.getProjectGroup() if (!group) throw new Error('Unable to retrieve gitlab project group') - const groupTokens = await this.api.GroupAccessTokens.all(group.id) - return groupTokens.find(token => token.name === tokenName) + return find(offsetPaginate(opts => this.api.GroupAccessTokens.all(group.id, opts)), token => token.name === tokenName) } public async createProjectToken(tokenName: string, scopes: AccessTokenScopes[]) { @@ -375,8 +370,7 @@ export class GitlabProjectApi extends GitlabApi { const gitlabRepositories = await this.listRepositories() const mirrorRepo = gitlabRepositories.find(repo => repo.name === internalMirrorRepoName) if (!mirrorRepo) throw new Error('Don\'t know how mirror repo could not exist') - const allTriggerTokens = await this.api.PipelineTriggerTokens.all(mirrorRepo.id) - const currentTriggerToken = allTriggerTokens.find(token => token.description === tokenDescription) + const currentTriggerToken = await find(offsetPaginate(opts => this.api.PipelineTriggerTokens.all(mirrorRepo.id, opts)), token => token.description === tokenDescription) const tokenVaultSecret = await vaultApi.read('GITLAB', { throwIfNoEntry: false }) @@ -398,7 +392,7 @@ export class GitlabProjectApi extends GitlabApi { public async listRepositories() { const group = await this.getOrCreateProjectGroup() - const projects = await this.api.Groups.allProjects(group.id, { simple: false }) // to refactor with https://github.com/jdalrymple/gitbeaker/pull/3624 + const projects = await getAll(offsetPaginate(opts => this.api.Groups.allProjects(group.id, { simple: false, ...opts }))) // to refactor with https://github.com/jdalrymple/gitbeaker/pull/3624 return Promise.all(projects.map(async (project) => { if (this.specialRepositories.includes(project.name) && (!project.topics || !project.topics.includes(pluginManagedTopic))) { return this.api.Projects.edit(project.id, { topics: project.topics ? [...project.topics, pluginManagedTopic] : [pluginManagedTopic] }) @@ -432,7 +426,7 @@ export class GitlabProjectApi extends GitlabApi { // Group members public async getGroupMembers() { const group = await this.getOrCreateProjectGroup() - return this.api.GroupMembers.all(group.id) + return getAll(offsetPaginate(opts => this.api.GroupMembers.all(group.id, opts))) } public async addGroupMember(userId: number, accessLevel: AccessLevelAllowed = AccessLevel.DEVELOPER): Promise { @@ -448,7 +442,7 @@ export class GitlabProjectApi extends GitlabApi { // CI Variables public async getGitlabGroupVariables(): Promise { const group = await this.getOrCreateProjectGroup() - return await this.api.GroupVariables.all(group.id) + return await getAll(offsetPaginate(opts => this.api.GroupVariables.all(group.id, opts))) } public async setGitlabGroupVariable(listVars: VariableSchema[], toSetVariable: VariableSchema): Promise { @@ -491,7 +485,7 @@ export class GitlabProjectApi extends GitlabApi { } public async getGitlabRepoVariables(repoId: number): Promise { - return await this.api.ProjectVariables.all(repoId) + return await getAll(offsetPaginate(opts => this.api.ProjectVariables.all(repoId, opts))) } public async setGitlabRepoVariable(repoId: number, listVars: VariableSchema[], toSetVariable: ProjectVariableSchema): Promise { diff --git a/plugins/gitlab/src/user.ts b/plugins/gitlab/src/user.ts index cb984c036f..1fdfe49ebb 100644 --- a/plugins/gitlab/src/user.ts +++ b/plugins/gitlab/src/user.ts @@ -1,28 +1,19 @@ import type { UserObject } from '@cpn-console/hooks' import type { CreateUserOptions, SimpleUserSchema } from '@gitbeaker/rest' -import { getApi } from './utils.js' +import { getApi, find, offsetPaginate } from './utils.js' export const createUsername = (email: string) => email.replace('@', '.') export async function getUser(user: { email: string, username: string, id: string }): Promise { const api = getApi() - let gitlabUser: SimpleUserSchema | undefined - - // test finding by extern_uid by searching with email - const usersByEmail = await api.Users.all({ search: user.email }) - gitlabUser = usersByEmail.find(gitlabUser => gitlabUser?.externUid === user.id) - if (gitlabUser) return gitlabUser - - // if not found, test finding by extern_uid by searching with username - const usersByUsername = await api.Users.all({ username: user.username }) - gitlabUser = usersByUsername.find(gitlabUser => gitlabUser?.externUid === user.id) - if (gitlabUser) return gitlabUser - - // if not found, test finding by email or username - const allUsers = [...usersByEmail, ...usersByUsername] - return allUsers.find(gitlabUser => gitlabUser.email === user.email) - || allUsers.find(gitlabUser => gitlabUser.username === user.username) + return find( + offsetPaginate(opts => api.Users.all(opts)), + gitlabUser => + gitlabUser?.externUid === user.id + || gitlabUser.email === user.email + || gitlabUser.username === user.username, + ) } export async function upsertUser(user: UserObject): Promise { diff --git a/plugins/gitlab/src/utils.ts b/plugins/gitlab/src/utils.ts index 8a94638140..1a085cc101 100644 --- a/plugins/gitlab/src/utils.ts +++ b/plugins/gitlab/src/utils.ts @@ -1,5 +1,5 @@ import { Gitlab } from '@gitbeaker/rest' -import type { Gitlab as IGitlab } from '@gitbeaker/core' +import type { Gitlab as IGitlab, BaseRequestOptions, PaginationRequestOptions, OffsetPagination } from '@gitbeaker/core' import { GitbeakerRequestError } from '@gitbeaker/requester-utils' import config from './config.js' @@ -13,8 +13,12 @@ export async function getGroupRootId(throwIfNotFound?: boolean): Promise grp.full_path === projectRootDir))?.id + const groupRoot = await find(offsetPaginate(opts => gitlabApi.Groups.all({ + search: projectRootDir, + orderBy: 'id', + ...opts, + })), grp => grp.full_path === projectRootDir) + const searchId = groupRoot?.id if (typeof searchId === 'undefined') { if (throwIfNotFound) { throw new Error(`Gitlab inaccessible, impossible de trouver le groupe ${projectRootDir}`) @@ -35,9 +39,11 @@ async function createGroupRoot(): Promise { throw new Error('No projectRootDir available') } - let parentGroup = (await gitlabApi.Groups.search(rootGroupPath)) - .find(grp => grp.full_path === rootGroupPath) - ?? await gitlabApi.Groups.create(rootGroupPath, rootGroupPath) + let parentGroup = await find(offsetPaginate(opts => gitlabApi.Groups.all({ + search: rootGroupPath, + orderBy: 'id', + ...opts, + })), grp => grp.full_path === rootGroupPath) ?? await gitlabApi.Groups.create(rootGroupPath, rootGroupPath) if (parentGroup.full_path === projectRootDir) { return parentGroup.id @@ -45,9 +51,11 @@ async function createGroupRoot(): Promise { for (const path of projectRootDirArray) { const futureFullPath = `${parentGroup.full_path}/${path}` - parentGroup = (await gitlabApi.Groups.search(futureFullPath)) - .find(grp => grp.full_path === futureFullPath) - ?? await gitlabApi.Groups.create(path, path, { parentId: parentGroup.id, visibility: 'internal' }) + parentGroup = await find(offsetPaginate(opts => gitlabApi.Groups.all({ + search: futureFullPath, + orderBy: 'id', + ...opts, + })), grp => grp.full_path === futureFullPath) ?? await gitlabApi.Groups.create(path, path, { parentId: parentGroup.id, visibility: 'internal' }) if (parentGroup.full_path === projectRootDir) { return parentGroup.id @@ -57,17 +65,11 @@ async function createGroupRoot(): Promise { } export async function getOrCreateGroupRoot(): Promise { - let rootId = await getGroupRootId(false) - if (typeof rootId === 'undefined') { - rootId = await createGroupRoot() - } - return rootId + return await getGroupRootId(false) ?? createGroupRoot() } export function getApi(): IGitlab { - if (!api) { - api = new Gitlab({ token: config().token, host: config().internalUrl }) - } + api ??= new Gitlab({ token: config().token, host: config().internalUrl }) return api } @@ -89,3 +91,38 @@ export function cleanGitlabError(error: T): T { } return error } + +export async function* offsetPaginate( + request: (options: PaginationRequestOptions<'offset'> & BaseRequestOptions) => Promise<{ data: T[], paginationInfo: OffsetPagination }>, +): AsyncGenerator { + let page: number | null = 1 + while (page !== null) { + const { data, paginationInfo } = await request({ page, showExpanded: true, pagination: 'offset' }) + for (const item of data) { + yield item + } + page = paginationInfo.next + } +} + +export async function getAll( + iterable: AsyncIterable, +): Promise { + const items: T[] = [] + for await (const item of iterable) { + items.push(item) + } + return items +} + +export async function find( + iterable: AsyncIterable, + predicate: (item: T) => boolean, +): Promise { + for await (const item of iterable) { + if (predicate(item)) { + return item + } + } + return undefined +} diff --git a/plugins/sonarqube/src/functions.ts b/plugins/sonarqube/src/functions.ts index b31747c7be..9f00644c62 100644 --- a/plugins/sonarqube/src/functions.ts +++ b/plugins/sonarqube/src/functions.ts @@ -119,13 +119,13 @@ export const upsertProject: StepCall = async (payload) => { // Remove excess repositories ...sonarRepositories - .filter(sonarRepository => !project.repositories.find(repo => repo.internalRepoName === sonarRepository.repository)) + .filter(sonarRepository => !project.repositories.some(repo => repo.internalRepoName === sonarRepository.repository)) .map(sonarRepository => deleteDsoRepository(sonarRepository.key)), // Create or configure needed repos ...project.repositories.map(async (repository) => { const projectKey = generateProjectKey(projectSlug, repository.internalRepoName) - if (!sonarRepositories.find(sonarRepository => sonarRepository.repository === repository.internalRepoName)) { + if (!sonarRepositories.some(sonarRepository => sonarRepository.repository === repository.internalRepoName)) { await createDsoRepository(projectSlug, repository.internalRepoName) } await ensureRepositoryConfiguration(projectKey, username, keycloakGroupPath) @@ -166,6 +166,7 @@ export const setVariables: StepCall = async (payload) => { ...project.repositories.map(async (repo) => { const projectKey = generateProjectKey(projectSlug, repo.internalRepoName) const repoId = await payload.apis.gitlab.getProjectId(repo.internalRepoName) + if (!repoId) return const listVars = await gitlabApi.getGitlabRepoVariables(repoId) return [ await gitlabApi.setGitlabRepoVariable(repoId, listVars, { @@ -193,9 +194,9 @@ export const setVariables: StepCall = async (payload) => { environment_scope: '*', }), ] - }).flat(), + }), // Sonar vars saving in CI (group) - await gitlabApi.setGitlabGroupVariable(listGroupVars, { + gitlabApi.setGitlabGroupVariable(listGroupVars, { key: 'SONAR_TOKEN', masked: true, protected: false, diff --git a/plugins/sonarqube/src/user.ts b/plugins/sonarqube/src/user.ts index 5402ccc84e..ec5d46e276 100644 --- a/plugins/sonarqube/src/user.ts +++ b/plugins/sonarqube/src/user.ts @@ -62,13 +62,25 @@ export async function changeToken(username: string) { export async function getUser(username: string): Promise { const axiosInstance = getAxiosInstance() - const users: { paging: SonarPaging, users: SonarUser[] } = (await axiosInstance({ - url: 'users/search', - params: { - q: username, - }, - }))?.data - return users.users.find(u => u.login === username) + let page = 1 + const pageSize = 100 + while (true) { + const response = await axiosInstance({ + url: 'users/search', + params: { + q: username, + p: page, + ps: pageSize, + }, + }) + const users: { paging: SonarPaging, users: SonarUser[] } = response.data + const found = users.users.find(user => user.login === username) + if (found) return found + if (!users.users.length || users.paging.pageIndex * users.paging.pageSize >= users.paging.total) { + break + } + page += 1 + } } export async function ensureUserExists(username: string, projectSlug: string, vaultUserSecret: VaultSonarSecret | undefined): Promise { From f0e824a82fed3997501661d1a682c8785931d72e Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Mon, 23 Feb 2026 11:38:43 +0100 Subject: [PATCH 2/5] chore(admin-role): add logs Signed-off-by: William Phetsinorath --- .../src/resources/admin-role/business.spec.ts | 34 ++++++++++--------- .../src/resources/admin-role/business.ts | 29 ++++++++++------ .../src/resources/admin-role/router.spec.ts | 8 ++--- .../server/src/resources/admin-role/router.ts | 6 ++-- 4 files changed, 44 insertions(+), 33 deletions(-) diff --git a/apps/server/src/resources/admin-role/business.spec.ts b/apps/server/src/resources/admin-role/business.spec.ts index edb3bad17e..11ab14b35e 100644 --- a/apps/server/src/resources/admin-role/business.spec.ts +++ b/apps/server/src/resources/admin-role/business.spec.ts @@ -42,7 +42,8 @@ describe('test admin-role business', () => { prisma.adminRole.findFirst.mockResolvedValueOnce(dbRole) prisma.adminRole.findMany.mockResolvedValueOnce([dbRole]) prisma.adminRole.create.mockResolvedValue(dbRole) - await createRole({ name: 'test' }) + hook.adminRole.upsert.mockResolvedValue({ args: {}, failed: false, results: {} }) + await createRole({ name: 'test' }, faker.string.uuid()) expect(prisma.adminRole.create).toHaveBeenCalledWith({ data: { name: 'test', permissions: 0n, position: 1 } }) }) @@ -60,7 +61,8 @@ describe('test admin-role business', () => { prisma.adminRole.findFirst.mockResolvedValueOnce(dbRole) prisma.adminRole.findMany.mockResolvedValueOnce([dbRole]) prisma.adminRole.create.mockResolvedValue(dbRole) - await createRole({ name: 'test' }) + hook.adminRole.upsert.mockResolvedValue({ args: {}, failed: false, results: {} }) + await createRole({ name: 'test' }, faker.string.uuid()) expect(prisma.adminRole.create).toHaveBeenCalledWith({ data: { name: 'test', permissions: 0n, position: 51 } }) }) @@ -78,7 +80,8 @@ describe('test admin-role business', () => { prisma.adminRole.findFirst.mockResolvedValueOnce(null) prisma.adminRole.findMany.mockResolvedValueOnce([dbRole]) prisma.adminRole.create.mockResolvedValue(dbRole) - await createRole({ name: 'test' }) + hook.adminRole.upsert.mockResolvedValue({ args: {}, failed: false, results: {} }) + await createRole({ name: 'test' }, faker.string.uuid()) expect(prisma.adminRole.create).toHaveBeenCalledWith({ data: { name: 'test', permissions: 0n, position: 0 } }) }) @@ -121,7 +124,8 @@ describe('test admin-role business', () => { prisma.adminRole.findMany.mockResolvedValueOnce([]) prisma.adminRole.findUnique.mockResolvedValueOnce(dbRole) prisma.adminRole.create.mockResolvedValue(dbRole) - await deleteRole(roleId) + hook.adminRole.delete.mockResolvedValue({ args: {}, failed: false, results: {} }) + await deleteRole(roleId, faker.string.uuid()) expect(prisma.user.findMany).toHaveBeenCalledTimes(2) expect(prisma.user.update).toHaveBeenNthCalledWith(1, { where: { id: users[0].id }, data: { adminRoleIds: [] } }) @@ -137,7 +141,7 @@ describe('test admin-role business', () => { prisma.adminRole.findUnique.mockResolvedValue(systemRole as any) prisma.user.findMany.mockResolvedValue([]) - const response = await deleteRole(roleId) + const response = await deleteRole(roleId, faker.string.uuid()) expect(response).toBeInstanceOf(Forbidden403) expect(prisma.adminRole.delete).not.toHaveBeenCalled() }) @@ -223,7 +227,7 @@ describe('test admin-role business', () => { name: 'New Admin Name', }] - const result = await patchRoles(updateRoles) + const result = await patchRoles(updateRoles, faker.string.uuid()) await expect(result).toBeInstanceOf(Forbidden403) expect(prisma.adminRole.update).toHaveBeenCalledTimes(0) @@ -231,7 +235,7 @@ describe('test admin-role business', () => { it('should do nothing', async () => { prisma.adminRole.findMany.mockResolvedValue([]) - await patchRoles([]) + await patchRoles([], faker.string.uuid()) expect(prisma.adminRole.update).toHaveBeenCalledTimes(0) }) @@ -242,7 +246,7 @@ describe('test admin-role business', () => { ] prisma.adminRole.findMany.mockResolvedValue(dbRoles) - const response = await patchRoles(updateRoles) + const response = await patchRoles(updateRoles, faker.string.uuid()) expect(response).instanceOf(BadRequest400) expect(prisma.adminRole.update).toHaveBeenCalledTimes(0) @@ -253,7 +257,7 @@ describe('test admin-role business', () => { ] prisma.adminRole.findMany.mockResolvedValue(dbRoles) - const response = await patchRoles(updateRoles) + const response = await patchRoles(updateRoles, faker.string.uuid()) expect(response).instanceOf(BadRequest400) expect(prisma.adminRole.update).toHaveBeenCalledTimes(0) @@ -264,9 +268,8 @@ describe('test admin-role business', () => { { id: dbRoles[1].id, position: 0 }, ] prisma.adminRole.findMany.mockResolvedValue(dbRoles) - - await patchRoles(updateRoles) - + hook.adminRole.upsert.mockResolvedValue({ args: {}, failed: false, results: {} }) + await patchRoles(updateRoles, faker.string.uuid()) expect(prisma.adminRole.update).toHaveBeenCalledTimes(2) }) it('should update permissions', async () => { @@ -274,9 +277,8 @@ describe('test admin-role business', () => { { id: dbRoles[1].id, permissions: '0' }, ] prisma.adminRole.findMany.mockResolvedValue(dbRoles) - - await patchRoles(updateRoles) - + hook.adminRole.upsert.mockResolvedValue({ args: {}, failed: false, results: {} }) + await patchRoles(updateRoles, faker.string.uuid()) expect(prisma.adminRole.update).toHaveBeenCalledTimes(1) expect(prisma.adminRole.update).toHaveBeenCalledWith({ data: { @@ -302,7 +304,7 @@ describe('test admin-role business', () => { } prisma.adminRole.findMany.mockResolvedValue([systemRole as any]) - const response = await patchRoles([{ id: systemRole.id, name: 'new name' }]) + const response = await patchRoles([{ id: systemRole.id, name: 'new name' }], faker.string.uuid()) expect(response).toBeInstanceOf(Forbidden403) expect(prisma.adminRole.update).not.toHaveBeenCalled() }) diff --git a/apps/server/src/resources/admin-role/business.ts b/apps/server/src/resources/admin-role/business.ts index 360743a46c..21a9c21167 100644 --- a/apps/server/src/resources/admin-role/business.ts +++ b/apps/server/src/resources/admin-role/business.ts @@ -1,9 +1,6 @@ import type { Project, ProjectRole } from '@prisma/client' import type { AdminRole, adminRoleContract } from '@cpn-console/shared' -import { - getAdminRoleById, - listAdminRoles, -} from '@/resources/queries-index.js' +import { addLogs, getAdminRoleById, listAdminRoles } from '@/resources/queries-index.js' import type { ErrorResType } from '@/utils/errors.js' import { BadRequest400, Forbidden403 } from '@/utils/errors.js' import prisma from '@/prisma.js' @@ -14,7 +11,10 @@ export async function listRoles() { .then(roles => roles.map(role => ({ ...role, permissions: role.permissions.toString(), type: role.type ?? 'custom' }))) } -export async function patchRoles(roles: typeof adminRoleContract.patchAdminRoles.body._type): Promise { +export async function patchRoles( + roles: typeof adminRoleContract.patchAdminRoles.body._type, + requestId: string, +): Promise { const dbRoles = await prisma.adminRole.findMany() const positionsAvailable: number[] = [] const updatedRoles: (Omit & { permissions: bigint })[] = [] @@ -46,13 +46,17 @@ export async function patchRoles(roles: typeof adminRoleContract.patchAdminRoles return new Forbidden403('Ce rôle système ne peut pas être renommé') } await prisma.adminRole.update({ where: { id }, data: role }) - await hook.adminRole.upsert(id) + const hookReply = await hook.adminRole.upsert(id) + await addLogs({ action: 'Update Admin Role', data: hookReply, requestId }) } return listRoles() } -export async function createRole(role: typeof adminRoleContract.createAdminRole.body._type) { +export async function createRole( + role: typeof adminRoleContract.createAdminRole.body._type, + requestId: string, +) { const dbMaxPosRole = (await prisma.adminRole.findFirst({ orderBy: { position: 'desc' }, select: { position: true }, @@ -66,7 +70,8 @@ export async function createRole(role: typeof adminRoleContract.createAdminRole. }, }) - await hook.adminRole.upsert(createdRole.id) + const hookReply = await hook.adminRole.upsert(createdRole.id) + await addLogs({ action: 'Create Admin Role', data: hookReply, requestId }) return listRoles() } @@ -87,11 +92,15 @@ export async function countRolesMembers() { return rolesCounts } -export async function deleteRole(roleId: Project['id']) { +export async function deleteRole( + roleId: Project['id'], + requestId: string, +) { const role = await getAdminRoleById(roleId) if (role) { if (role.type === 'system') return new Forbidden403('Impossible de supprimer un rôle système') - await hook.adminRole.delete(role) + const hookReply = await hook.adminRole.delete(role) + await addLogs({ action: 'Delete Admin Role', data: hookReply, requestId }) } const allUsers = await prisma.user.findMany({ diff --git a/apps/server/src/resources/admin-role/router.spec.ts b/apps/server/src/resources/admin-role/router.spec.ts index f23880cdf5..065b0d9e12 100644 --- a/apps/server/src/resources/admin-role/router.spec.ts +++ b/apps/server/src/resources/admin-role/router.spec.ts @@ -49,7 +49,7 @@ describe('test adminRoleContract', () => { .body(roleData) .end() - expect(businessCreateRoleMock).toHaveBeenCalledWith(roleData) + expect(businessCreateRoleMock).toHaveBeenCalledWith(roleData, expect.any(String)) expect(response.json()).toEqual(newRole) expect(response.statusCode).toEqual(201) }) @@ -83,7 +83,7 @@ describe('test adminRoleContract', () => { .body(rolesData) .end() - expect(businessPatchRolesMock).toHaveBeenCalledWith(rolesData) + expect(businessPatchRolesMock).toHaveBeenCalledWith(rolesData, expect.any(String)) expect(response.json()).toEqual(updatedRoles) expect(response.statusCode).toEqual(200) }) @@ -99,7 +99,7 @@ describe('test adminRoleContract', () => { .body(rolesData) .end() - expect(businessPatchRolesMock).toHaveBeenCalledWith(rolesData) + expect(businessPatchRolesMock).toHaveBeenCalledWith(rolesData, expect.any(String)) expect(response.statusCode).toEqual(400) }) @@ -161,7 +161,7 @@ describe('test adminRoleContract', () => { .delete(adminRoleContract.deleteAdminRole.path.replace(':roleId', roleId)) .end() - expect(businessDeleteRoleMock).toHaveBeenCalledWith(roleId) + expect(businessDeleteRoleMock).toHaveBeenCalledWith(roleId, expect.any(String)) expect(response.statusCode).toEqual(204) }) diff --git a/apps/server/src/resources/admin-role/router.ts b/apps/server/src/resources/admin-role/router.ts index da758c0e92..f7405c7d9f 100644 --- a/apps/server/src/resources/admin-role/router.ts +++ b/apps/server/src/resources/admin-role/router.ts @@ -26,7 +26,7 @@ export function adminRoleRouter() { const perms = await authUser(req) if (!AdminAuthorized.isAdmin(perms.adminPermissions)) return new Forbidden403() - const resBody = await createRole(body) + const resBody = await createRole(body, req.id) return { status: 201, @@ -38,7 +38,7 @@ export function adminRoleRouter() { const perms = await authUser(req) if (!AdminAuthorized.isAdmin(perms.adminPermissions)) return new Forbidden403() - const resBody = await patchRoles(body) + const resBody = await patchRoles(body, req.id) if (resBody instanceof ErrorResType) return resBody return { @@ -63,7 +63,7 @@ export function adminRoleRouter() { const perms = await authUser(req) if (!AdminAuthorized.isAdmin(perms.adminPermissions)) return new Forbidden403() - const resBody = await deleteRole(params.roleId) + const resBody = await deleteRole(params.roleId, req.id) if (resBody instanceof ErrorResType) return resBody return { From 489afbcc9a989d870202b6d2c766289c20433e44 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Mon, 23 Feb 2026 11:38:43 +0100 Subject: [PATCH 3/5] feat(gitlab): add builtin roles to GitLab Co-authered-by: William Phetsinorath Signed-off-by: William Phetsinorath --- apps/server/src/utils/hook-wrapper.ts | 38 +++- .../hooks/src/hooks/hook-project-member.ts | 18 +- packages/hooks/src/hooks/hook-project-role.ts | 13 +- plugins/gitlab/src/class.ts | 11 +- plugins/gitlab/src/functions.ts | 201 +++++++++++++++++- plugins/gitlab/src/index.ts | 24 ++- plugins/gitlab/src/infos.ts | 104 +++++++-- plugins/gitlab/src/user.ts | 5 +- plugins/gitlab/src/utils.ts | 4 + plugins/keycloak/src/functions.ts | 6 +- 10 files changed, 379 insertions(+), 45 deletions(-) diff --git a/apps/server/src/utils/hook-wrapper.ts b/apps/server/src/utils/hook-wrapper.ts index 933ac6692f..33540a3024 100644 --- a/apps/server/src/utils/hook-wrapper.ts +++ b/apps/server/src/utils/hook-wrapper.ts @@ -142,6 +142,8 @@ const user = { const projectMember = { upsert: async (projectId: Project['id'], userId: ProjectMembers['userId']) => { const project = await getHookProjectInfos(projectId) + const projectStore = dbToObj(await getProjectStore(project.id)) + const hookProject = transformToHookProject(project, projectStore) const store = dbToObj(await getAdminPlugin()) const member = project.members.find(m => m.userId === userId) @@ -149,7 +151,12 @@ const projectMember = { const memberRoles = project.roles .filter(role => member.roleIds.includes(role.id)) - .map(role => ({ ...role, permissions: role.permissions.toString(), oidcGroup: role.oidcGroup ?? undefined })) + .map(role => ({ + ...role, + permissions: role.permissions.toString(), + oidcGroup: role.oidcGroup ?? undefined, + project: hookProject, + })) const payload = { userId: member.userId, @@ -163,16 +170,15 @@ const projectMember = { lastLogin: member.user.lastLogin?.toISOString(), projectId: project.id, roles: memberRoles, - project: { - id: project.id, - slug: project.slug, - }, + project: hookProject, } return hooks.upsertProjectMember.execute(payload, store) }, delete: async (projectId: Project['id'], userId: ProjectMembers['userId']) => { const project = await getHookProjectInfos(projectId) + const projectStore = dbToObj(await getProjectStore(project.id)) + const hookProject = transformToHookProject(project, projectStore) const store = dbToObj(await getAdminPlugin()) const member = project.members.find(m => m.userId === userId) @@ -180,7 +186,12 @@ const projectMember = { const memberRoles = project.roles .filter(role => member.roleIds.includes(role.id)) - .map(role => ({ ...role, permissions: role.permissions.toString(), oidcGroup: role.oidcGroup ?? undefined })) + .map(role => ({ + ...role, + permissions: role.permissions.toString(), + oidcGroup: role.oidcGroup ?? undefined, + project: hookProject, + })) const payload = { userId: member.userId, @@ -194,10 +205,7 @@ const projectMember = { lastLogin: member.user.lastLogin?.toISOString(), projectId: project.id, roles: memberRoles, - project: { - id: project.id, - slug: project.slug, - }, + project: hookProject, } return hooks.deleteProjectMember.execute(payload, store) @@ -209,9 +217,14 @@ const projectRole = { const role = await getRole(roleId) if (!role) throw new Error('Role not found') + const project = await getHookProjectInfos(role.projectId) + const projectStore = dbToObj(await getProjectStore(role.projectId)) + const hookProject = transformToHookProject(project, projectStore) + const rolePayload = { ...role, permissions: role.permissions.toString(), + project: hookProject, } const store = dbToObj(await getAdminPlugin()) return hooks.upsertProjectRole.execute(rolePayload, store) @@ -220,9 +233,14 @@ const projectRole = { const role = await getRole(roleId) if (!role) throw new Error('Role not found') + const project = await getHookProjectInfos(role.projectId) + const projectStore = dbToObj(await getProjectStore(role.projectId)) + const hookProject = transformToHookProject(project, projectStore) + const rolePayload = { ...role, permissions: role.permissions.toString(), + project: hookProject, } const store = dbToObj(await getAdminPlugin()) return hooks.deleteProjectRole.execute(rolePayload, store) diff --git a/packages/hooks/src/hooks/hook-project-member.ts b/packages/hooks/src/hooks/hook-project-member.ts index 75c56b6ca2..c1d726f599 100644 --- a/packages/hooks/src/hooks/hook-project-member.ts +++ b/packages/hooks/src/hooks/hook-project-member.ts @@ -1,14 +1,16 @@ -import type { ProjectMember, ProjectRole } from '@cpn-console/shared' +import type { ProjectRole } from './hook-project-role.js' +import type { Project } from './hook-project.js' import type { Hook } from './hook.js' import { createHook } from './hook.js' -export type ProjectMemberPayload = ProjectMember & { +export interface ProjectMember { + userId: string + email: string + firstName: string + lastName: string roles: ProjectRole[] - project: { - id: string - slug: string - } + project: Project } -export const upsertProjectMember: Hook = createHook() -export const deleteProjectMember: Hook = createHook() +export const upsertProjectMember: Hook = createHook() +export const deleteProjectMember: Hook = createHook() diff --git a/packages/hooks/src/hooks/hook-project-role.ts b/packages/hooks/src/hooks/hook-project-role.ts index e481144e97..8d047f4d79 100644 --- a/packages/hooks/src/hooks/hook-project-role.ts +++ b/packages/hooks/src/hooks/hook-project-role.ts @@ -1,6 +1,17 @@ -import type { ProjectRole } from '@cpn-console/shared' +import type { Project } from './hook-project.js' import type { Hook } from './hook.js' import { createHook } from './hook.js' +export interface ProjectRole { + id: string + name: string + permissions: string + projectId: string + position: number + type?: string + oidcGroup?: string + project: Project +} + export const upsertProjectRole: Hook = createHook() export const deleteProjectRole: Hook = createHook() diff --git a/plugins/gitlab/src/class.ts b/plugins/gitlab/src/class.ts index 54a882aef7..7b47731c10 100644 --- a/plugins/gitlab/src/class.ts +++ b/plugins/gitlab/src/class.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto' -import { PluginApi, type Project, type UniqueRepo } from '@cpn-console/hooks' +import { PluginApi, type Project, type UniqueRepo, type ProjectMember } from '@cpn-console/hooks' import type { AccessTokenScopes, CommitAction, GroupSchema, MemberSchema, ProjectVariableSchema, VariableSchema, AllRepositoryTreesOptions, CondensedProjectSchema, Gitlab, ProjectSchema, RepositoryFileExpandedSchema } from '@gitbeaker/core' import { AccessLevel } from '@gitbeaker/core' import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/vault-project-api.js' @@ -234,12 +234,12 @@ export class GitlabZoneApi extends GitlabApi { } export class GitlabProjectApi extends GitlabApi { - private project: Project | UniqueRepo + private project: Project | UniqueRepo | ProjectMember['project'] private gitlabGroup: GroupSchema | undefined private specialRepositories: string[] = [infraAppsRepoName, internalMirrorRepoName] private zoneApi: GitlabZoneApi - constructor(project: Project | UniqueRepo) { + constructor(project: Project | UniqueRepo | ProjectMember['project']) { super() this.project = project this.api = getApi() @@ -434,6 +434,11 @@ export class GitlabProjectApi extends GitlabApi { return this.api.GroupMembers.add(group.id, userId, accessLevel) } + public async editGroupMember(userId: number, accessLevel: AccessLevelAllowed = AccessLevel.DEVELOPER): Promise { + const group = await this.getOrCreateProjectGroup() + return this.api.GroupMembers.edit(group.id, userId, accessLevel) + } + public async removeGroupMember(userId: number) { const group = await this.getOrCreateProjectGroup() return this.api.GroupMembers.remove(group.id, userId) diff --git a/plugins/gitlab/src/functions.ts b/plugins/gitlab/src/functions.ts index 110ff01b88..456dbfc956 100644 --- a/plugins/gitlab/src/functions.ts +++ b/plugins/gitlab/src/functions.ts @@ -1,13 +1,22 @@ -import { okStatus, parseError, specificallyDisabled } from '@cpn-console/hooks' -import type { ClusterObject, PluginResult, Project, ProjectLite, StepCall, UniqueRepo, ZoneObject } from '@cpn-console/hooks' +import { okStatus, parseError, specificallyDisabled, specificallyEnabled } from '@cpn-console/hooks' +import type { AdminRole, ClusterObject, PluginResult, Project, ProjectLite, StepCall, UniqueRepo, ZoneObject, ProjectMember } from '@cpn-console/hooks' import { insert } from '@cpn-console/shared' +import { AccessLevel } from '@gitbeaker/core' import { deleteGroup } from './group.js' -import { createUsername, getUser } from './user.js' +import { createUsername, getUser, upsertUser } from './user.js' import { ensureMembers } from './members.js' import { ensureRepositories } from './repositories.js' import type { VaultSecrets } from './utils.js' -import { cleanGitlabError } from './utils.js' import config from './config.js' +import type { GitlabProjectApi } from './class.js' +import { cleanGitlabError, matchRole } from './utils.js' +import { + 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, +} from './infos.js' // Check export const checkApi: StepCall = async (payload) => { @@ -232,3 +241,187 @@ export const commitFiles: StepCall = async (payload) => { + try { + const role = payload.args + const adminGroupPath = payload.config.gitlab?.adminGroupPath ?? DEFAULT_ADMIN_GROUP_PATH + const auditorGroupPath = payload.config.gitlab?.auditorGroupPath ?? DEFAULT_AUDITOR_GROUP_PATH + + const isAdmin = role.oidcGroup === adminGroupPath ? true : undefined + const isAuditor = role.oidcGroup === auditorGroupPath ? true : undefined + + if (isAdmin === undefined && isAuditor === undefined) { + return { + status: { + result: 'OK', + message: 'Not a managed role for GitLab plugin', + }, + } + } + + for (const member of role.members) { + await upsertUser(member, isAdmin, isAuditor) + } + + return { + status: { + result: 'OK', + message: 'Members synced', + }, + } + } catch (error) { + return { + error: parseError(cleanGitlabError(error)), + status: { + result: 'KO', + message: 'An error occured while syncing admin role', + }, + } + } +} + +export const deleteAdminRole: StepCall = async (payload) => { + try { + const role = payload.args + const adminGroupPath = payload.config.gitlab?.adminGroupPath ?? DEFAULT_ADMIN_GROUP_PATH + const auditorGroupPath = payload.config.gitlab?.auditorGroupPath ?? DEFAULT_AUDITOR_GROUP_PATH + + const isAdmin = role.oidcGroup === adminGroupPath ? false : undefined + const isAuditor = role.oidcGroup === auditorGroupPath ? false : undefined + + if (isAdmin === undefined && isAuditor === undefined) { + return { + status: { + result: 'OK', + message: 'Not a managed role for GitLab plugin', + }, + } + } + + for (const member of role.members) { + await upsertUser(member, isAdmin, isAuditor) + } + + return { + status: { + result: 'OK', + message: 'Admin role deleted and members synced', + }, + } + } catch (error) { + return { + error: parseError(cleanGitlabError(error)), + status: { + result: 'KO', + message: 'An error occured while deleting admin role', + }, + } + } +} + +export const upsertProjectMember: StepCall = async (payload) => { + const member = payload.args + const { gitlab: gitlabApi } = payload.apis as { gitlab: GitlabProjectApi } // TODO: apis is never type for some resaon + const purge = payload.config.gitlab?.purge + const projectReporterGroupPathSuffix = payload.config.gitlab?.projectReporterGroupPathSuffix ?? DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX + const projectDeveloperGroupPathSuffix = payload.config.gitlab?.projectDeveloperGroupPathSuffix ?? DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX + const projectMaintainerGroupPathSuffix = payload.config.gitlab?.projectMaintainerGroupPathSuffix ?? DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX + + try { + const gitlabUser = await upsertUser({ + id: member.userId, + firstName: member.firstName, + lastName: member.lastName, + email: member.email, + }) + + let maxAccessLevel: number | undefined + + if (member.project.owner.id === member.userId) { + maxAccessLevel = AccessLevel.OWNER + } else if (member.roles.find(role => role.oidcGroup && matchRole(member.project.slug, role.oidcGroup, projectReporterGroupPathSuffix))) { + maxAccessLevel = AccessLevel.GUEST + } else if (member.roles.find(role => role.oidcGroup && matchRole(member.project.slug, role.oidcGroup, projectDeveloperGroupPathSuffix))) { + maxAccessLevel = AccessLevel.DEVELOPER + } else if (member.roles.find(role => role.oidcGroup && matchRole(member.project.slug, role.oidcGroup, projectMaintainerGroupPathSuffix))) { + maxAccessLevel = AccessLevel.MAINTAINER + } + + const groupMembers = await gitlabApi.getGroupMembers() + const existingMember = groupMembers.find(m => m.id === gitlabUser.id) + + if (maxAccessLevel === undefined) { + if (specificallyEnabled(purge)) { + if (existingMember) { + await gitlabApi.removeGroupMember(gitlabUser.id) + } + return { + status: { + result: 'OK', + message: 'Member has no matching roles, removed from group', + }, + } + } else { + console.warn(`Member ${gitlabUser.username} has no matching roles, not synced`) + } + } + + if (existingMember) { + if (existingMember.access_level !== maxAccessLevel) { + await gitlabApi.editGroupMember(gitlabUser.id, maxAccessLevel) + } + } else { + await gitlabApi.addGroupMember(gitlabUser.id, maxAccessLevel) + } + + return { + status: { + result: 'OK', + message: 'Member synced', + }, + } + } catch (error) { + return { + error: parseError(cleanGitlabError(error)), + status: { + result: 'KO', + message: 'An error happened while syncing project member', + }, + } + } +} + +export const deleteProjectMember: StepCall = async (payload) => { + const member = payload.args + const { gitlab: gitlabApi } = payload.apis as { gitlab: GitlabProjectApi } // TODO: apis is never type for some resaon + + try { + const userInfos = await getUser({ ...member, id: member.userId, username: createUsername(member.email) }) + if (!userInfos) { + return { + status: { + result: 'OK', + message: 'User not found in GitLab', + }, + } + } + + await gitlabApi.removeGroupMember(userInfos.id) + + return { + status: { + result: 'OK', + message: 'Member deleted', + }, + } + } catch (error) { + return { + error: parseError(cleanGitlabError(error)), + status: { + result: 'KO', + message: 'An error happened while deleting project member', + }, + } + } +} diff --git a/plugins/gitlab/src/index.ts b/plugins/gitlab/src/index.ts index ef72f7dd54..9fa707cb68 100644 --- a/plugins/gitlab/src/index.ts +++ b/plugins/gitlab/src/index.ts @@ -1,12 +1,15 @@ -import type { DeclareModuleGenerator, DefaultArgs, Plugin, Project, UniqueRepo, ZoneObject } from '@cpn-console/hooks' +import type { DeclareModuleGenerator, DefaultArgs, Plugin, Project, ProjectMember, UniqueRepo, ZoneObject } from '@cpn-console/hooks' import { checkApi, commitFiles, deleteDsoProject, + deleteProjectMember, deleteZone, getDsoProjectSecrets, syncRepository, + upsertAdminRole, upsertDsoProject, + upsertProjectMember, upsertZone, } from './functions.js' import { getOrCreateGroupRoot } from './utils.js' @@ -74,6 +77,23 @@ export const plugin: Plugin = { main: deleteZone, }, }, + upsertAdminRole: { + steps: { + main: upsertAdminRole, + }, + }, + upsertProjectMember: { + api: member => new GitlabProjectApi(member.project), + steps: { + main: upsertProjectMember, + }, + }, + deleteProjectMember: { + api: member => new GitlabProjectApi(member.project), + steps: { + post: deleteProjectMember, + }, + }, }, monitor, start, @@ -81,7 +101,7 @@ export const plugin: Plugin = { declare module '@cpn-console/hooks' { interface HookPayloadApis { - gitlab: Args extends Project | UniqueRepo + gitlab: Args extends Project | UniqueRepo | ProjectMember['project'] ? GitlabProjectApi : Args extends ZoneObject ? GitlabZoneApi diff --git a/plugins/gitlab/src/infos.ts b/plugins/gitlab/src/infos.ts index af3b038698..0cf8345032 100644 --- a/plugins/gitlab/src/infos.ts +++ b/plugins/gitlab/src/infos.ts @@ -1,7 +1,13 @@ import type { ServiceInfos } from '@cpn-console/hooks' -import { ENABLED } from '@cpn-console/shared' +import { DISABLED, ENABLED } from '@cpn-console/shared' import config from './config.js' +export const DEFAULT_ADMIN_GROUP_PATH = '/console/admin' +export const DEFAULT_AUDITOR_GROUP_PATH = '/console/readonly' +export const DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX = '/console/admin' +export const DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX = '/console/developer' +export const DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX = '/console/readonly' + const infos = { name: 'gitlab', to: ({ project }) => `${config().publicUrl}/${config().projectsRootDir}/${project.slug}`, @@ -9,18 +15,92 @@ const infos = { imgSrc: '/img/gitlab.svg', description: 'GitLab est un service d\'hébergement de code source et de pipeline CI/CD', config: { - global: [{ - kind: 'switch', - key: 'displayTriggerHint', - initialValue: ENABLED, - permissions: { - admin: { read: true, write: true }, - user: { read: false, write: false }, + global: [ + { + kind: 'switch', + key: 'purge', + initialValue: DISABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Purger les utilisateurs non synchronisés', + value: DISABLED, + description: 'Purger les utilisateurs non synchronisés de GitLab lors de la synchronisation', + }, + { + kind: 'switch', + key: 'displayTriggerHint', + initialValue: ENABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Afficher l\'aide de déclenchement de pipeline', + value: ENABLED, + description: 'Afficher l\'aide de déclenchement de pipeline aux utilisateurs lorsqu\'ils souhaitent afficher les secrets du projet', + }, + { + kind: 'text', + key: 'adminGroupPath', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Chemin du groupe OIDC Admin', + value: DEFAULT_ADMIN_GROUP_PATH, + description: 'Le chemin du groupe OIDC qui donne les droits d\'administrateur GitLab', + placeholder: DEFAULT_ADMIN_GROUP_PATH, + }, + { + kind: 'text', + key: 'auditorGroupPath', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Chemin du groupe OIDC Auditeur', + value: DEFAULT_AUDITOR_GROUP_PATH, + description: 'Le chemin du groupe OIDC qui donne les droits d\'auditeur GitLab', + placeholder: DEFAULT_AUDITOR_GROUP_PATH, + }, + { + kind: 'text', + key: 'projectMaintainerGroupPathSuffix', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Suffixe du chemin du groupe OIDC Maintainer', + value: DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, + description: 'Suffixe du groupe OIDC donnant accès Maintainer', + placeholder: DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, + }, + { + kind: 'text', + key: 'projectDeveloperGroupPathSuffix', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Suffixe du chemin du groupe OIDC Developer', + value: DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + description: 'Suffixe du groupe OIDC donnant accès Developer', + placeholder: DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + }, + { + kind: 'text', + key: 'projectReporterGroupPathSuffix', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Suffixe du chemin du groupe OIDC Reporter', + value: DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX, + description: 'Suffixe du groupe OIDC donnant accès Reporter', + placeholder: DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX, }, - title: 'Afficher l\'aide de trigger de pipeline', - value: ENABLED, - description: 'Afficher l\'aide de trigger de pipeline aux utilisateurs lorsqu\'ils souhaitent afficher les secrets du projet', - }], + ], project: [], }, } as const satisfies ServiceInfos diff --git a/plugins/gitlab/src/user.ts b/plugins/gitlab/src/user.ts index 1fdfe49ebb..d370f7cb35 100644 --- a/plugins/gitlab/src/user.ts +++ b/plugins/gitlab/src/user.ts @@ -16,7 +16,7 @@ export async function getUser(user: { email: string, username: string, id: strin ) } -export async function upsertUser(user: UserObject): Promise { +export async function upsertUser(user: UserObject, isAdmin = false, isAuditor = false): Promise { const api = getApi() const username = createUsername(user.email) const existingUser = await getUser({ ...user, username }) @@ -29,6 +29,8 @@ export async function upsertUser(user: UserObject): Promise { // sso options externUid: user.id, provider: 'openid_connect', + admin: isAdmin, + auditor: isAuditor, } if (existingUser) { @@ -55,7 +57,6 @@ export async function upsertUser(user: UserObject): Promise { return api.Users.create({ ...userDefinitionBase, - admin: false, canCreateGroup: false, forceRandomPassword: true, projectsLimit: 0, diff --git a/plugins/gitlab/src/utils.ts b/plugins/gitlab/src/utils.ts index 1a085cc101..c01bac58be 100644 --- a/plugins/gitlab/src/utils.ts +++ b/plugins/gitlab/src/utils.ts @@ -92,6 +92,10 @@ export function cleanGitlabError(error: T): T { return error } +export function matchRole(projectSlug: string, roleOidcGroup: string, configuredRolePath: string) { + return roleOidcGroup === `/${projectSlug}${configuredRolePath}` +} + export async function* offsetPaginate( request: (options: PaginationRequestOptions<'offset'> & BaseRequestOptions) => Promise<{ data: T[], paginationInfo: OffsetPagination }>, ): AsyncGenerator { diff --git a/plugins/keycloak/src/functions.ts b/plugins/keycloak/src/functions.ts index f5940d00b2..793750f8d3 100644 --- a/plugins/keycloak/src/functions.ts +++ b/plugins/keycloak/src/functions.ts @@ -1,4 +1,4 @@ -import type { AdminRole, Project, StepCall, UserEmail, ZoneObject, ProjectMemberPayload } from '@cpn-console/hooks' +import type { AdminRole, Project, StepCall, UserEmail, ZoneObject, ProjectMember } from '@cpn-console/hooks' import type { ProjectRole } from '@cpn-console/shared' import { generateRandomPassword, parseError, PluginResultBuilder, specificallyEnabled } from '@cpn-console/hooks' import type GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation.js' @@ -386,7 +386,7 @@ export const deleteProjectRole: StepCall = async ({ args: role }) = } } -export const upsertProjectMember: StepCall = async ({ args: member, config }) => { +export const upsertProjectMember: StepCall = async ({ args: member, config }) => { const pluginResult = new PluginResultBuilder('Synced') const purge = config.keycloak?.purge try { @@ -424,7 +424,7 @@ export const upsertProjectMember: StepCall = async ({ args } } -export const deleteProjectMember: StepCall = async ({ args: member }) => { +export const deleteProjectMember: StepCall = async ({ args: member }) => { const pluginResult = new PluginResultBuilder('Deleted') try { const kcClient = await getkcClient() From be2ae313218145bb30aaac633eec5cdadde349ee Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Mon, 23 Feb 2026 11:38:43 +0100 Subject: [PATCH 4/5] feat(sonarqube): make admin group path configurable though console Co-authered-by: William Phetsinorath Signed-off-by: William Phetsinorath --- packages/shared/src/utils/const.ts | 1 - plugins/sonarqube/src/functions.ts | 194 ++++++++++++++++++++--------- plugins/sonarqube/src/group.ts | 68 ++++++++-- plugins/sonarqube/src/index.ts | 13 +- plugins/sonarqube/src/infos.ts | 45 ++++++- plugins/sonarqube/src/project.ts | 23 ++-- plugins/sonarqube/src/user.ts | 17 ++- plugins/sonarqube/src/utils.ts | 50 ++++++++ 8 files changed, 313 insertions(+), 98 deletions(-) create mode 100644 plugins/sonarqube/src/utils.ts diff --git a/packages/shared/src/utils/const.ts b/packages/shared/src/utils/const.ts index 0f546feed4..197d5c1ef4 100644 --- a/packages/shared/src/utils/const.ts +++ b/packages/shared/src/utils/const.ts @@ -1,4 +1,3 @@ -export const adminGroupPath = '/admin' export const deleteValidationInput = 'DELETE' export const forbiddenRepoNames = ['mirror', 'infra-apps', 'infra-observability'] diff --git a/plugins/sonarqube/src/functions.ts b/plugins/sonarqube/src/functions.ts index 9f00644c62..83d1266a68 100644 --- a/plugins/sonarqube/src/functions.ts +++ b/plugins/sonarqube/src/functions.ts @@ -1,22 +1,12 @@ -import { adminGroupPath } from '@cpn-console/shared' -import type { Project, StepCall } from '@cpn-console/hooks' -import { generateProjectKey, parseError } from '@cpn-console/hooks' +import type { AdminRole, Project, StepCall } from '@cpn-console/hooks' +import { generateProjectKey, parseError, specificallyEnabled } from '@cpn-console/hooks' import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/vault-project-api.js' -import { ensureGroupExists, findGroupByName } from './group.js' +import { addUserToGroup, ensureGroupExists, getGroupMembers, removeUserFromGroup } from './group.js' import type { VaultSonarSecret } from './tech.js' import { getAxiosInstance } from './tech.js' -import type { SonarUser } from './user.js' -import { ensureUserExists } from './user.js' -import type { SonarPaging } from './project.js' +import { ensureUserExists, getUser } from './user.js' import { createDsoRepository, deleteDsoRepository, ensureRepositoryConfiguration, files, findSonarProjectsForDsoProjects } from './project.js' - -const globalPermissions = [ - 'admin', - 'profileadmin', - 'gateadmin', - 'scan', - 'provisioning', -] +import { DEFAULT_ADMIN_GROUP_PATH, DEFAULT_READONLY_GROUP_PATH } from './infos.js' const projectPermissions = [ 'admin', @@ -25,75 +15,163 @@ const projectPermissions = [ 'securityhotspotadmin', 'scan', 'user', -] +] as const -export async function initSonar() { - await setTemplatePermisions() - await createAdminGroup() - await setAdminPermisions() -} +const readonlyProjectPermissions = [ + 'codeviewer', + 'user', + 'scan', + 'issueadmin', + 'securityhotspotadmin', +] as const -async function createAdminGroup() { - const axiosInstance = getAxiosInstance() - const adminGroup = await findGroupByName(adminGroupPath) - if (!adminGroup) { - await axiosInstance({ - method: 'post', - params: { - name: adminGroupPath, - description: 'DSO platform admins', +export const upsertAdminRole: StepCall = async (payload) => { + try { + const role = payload.args + const adminGroupPath = payload.config.sonarqube?.adminGroupPath ?? DEFAULT_ADMIN_GROUP_PATH + const readonlyGroupPath = payload.config.sonarqube?.readonlyGroupPath ?? DEFAULT_READONLY_GROUP_PATH + if (!readonlyGroupPath) { + throw new Error('readonlyGroupPath is required') + } + const purge = payload.config.sonarqube?.purge + if (!adminGroupPath) { + throw new Error('adminGroupPath is required') + } + + let managedGroupPath: string | undefined + + if (role.oidcGroup === adminGroupPath) { + managedGroupPath = adminGroupPath + await ensureAdminTemplateExists(adminGroupPath) + await ensureGroupExists(adminGroupPath) + await setTemplateGroupPermissions(adminGroupPath, projectPermissions, adminGroupPath) + } else if (role.oidcGroup === readonlyGroupPath) { + managedGroupPath = readonlyGroupPath + await ensureReadonlyTemplateExists(readonlyGroupPath) + await ensureGroupExists(readonlyGroupPath) + await setTemplateGroupPermissions(readonlyGroupPath, readonlyProjectPermissions, readonlyGroupPath) + } + + if (!managedGroupPath) { + return { + status: { + result: 'OK', + message: 'Not a managed role for SonarQube plugin', + }, + } + } + + const groupMembers = await getGroupMembers(managedGroupPath) + + await Promise.all([ + ...role.members.map((member) => { + if (!groupMembers.includes(member.email)) { + return addUserToGroup(managedGroupPath, member.email) + .catch((error) => { + console.warn(`Failed to add user ${member.email} to group ${managedGroupPath}`, error) + }) + } + return undefined + }), + ...groupMembers.map((memberEmail) => { + if (!role.members.some(m => m.email === memberEmail)) { + if (specificallyEnabled(purge)) { + return removeUserFromGroup(managedGroupPath, memberEmail) + .catch((error) => { + console.warn(`Failed to remove user ${memberEmail} from group ${managedGroupPath}`, error) + }) + } + } + return undefined + }), + ]) + + return { + status: { + result: 'OK', + message: 'Admin role synced', }, - url: 'user_groups/create', - }) + } + } catch (error) { + return { + error: parseError(error), + status: { + result: 'KO', + message: 'An error occured while syncing admin role', + }, + } } } -async function setAdminPermisions() { +async function setTemplateGroupPermissions(groupName: string, permissions: readonly string[], templateName: string) { const axiosInstance = getAxiosInstance() - for (const permission of globalPermissions) { - await axiosInstance({ + await Promise.all(permissions.map(permission => + axiosInstance({ method: 'post', params: { - groupName: adminGroupPath, + groupName, + templateName, permission, }, - url: 'permissions/add_group', - }) - } + url: 'permissions/add_group_to_template', + }), + )) } -async function setTemplatePermisions() { +async function ensureAdminTemplateExists(adminTemplateName: string) { const axiosInstance = getAxiosInstance() + + // Create Admin Template await axiosInstance({ method: 'post', - params: { name: 'Forge Default' }, + params: { name: adminTemplateName }, url: 'permissions/create_template', validateStatus: code => [200, 400].includes(code), }) - for (const permission of projectPermissions) { - await axiosInstance({ + + // Add Project Creator and sonar-administrators to Admin Template + await Promise.all(projectPermissions.map(permission => + axiosInstance({ method: 'post', params: { - templateName: 'Forge Default', + templateName: adminTemplateName, permission, }, url: 'permissions/add_project_creator_to_template', - }) - await axiosInstance({ + }), + )) + await setTemplateGroupPermissions('sonar-administrators', projectPermissions, adminTemplateName) +} + +async function ensureReadonlyTemplateExists(readonlyTemplateName: string) { + const axiosInstance = getAxiosInstance() + + // Create Readonly Template + await axiosInstance({ + method: 'post', + params: { name: readonlyTemplateName }, + url: 'permissions/create_template', + validateStatus: code => [200, 400].includes(code), + }) + // Add Project Creator and sonar-administrators to Readonly Template + await Promise.all(projectPermissions.map(permission => + axiosInstance({ method: 'post', params: { - groupName: 'sonar-administrators', - templateName: 'Forge Default', + templateName: readonlyTemplateName, permission, }, - url: 'permissions/add_group_to_template', - }) - } + url: 'permissions/add_project_creator_to_template', + }), + )) + await setTemplateGroupPermissions('sonar-administrators', projectPermissions, readonlyTemplateName) + + // Set Readonly Template as Default await axiosInstance({ method: 'post', params: { - templateName: 'Forge Default', + templateName: readonlyTemplateName, }, url: 'permissions/set_default_template', }) @@ -166,7 +244,9 @@ export const setVariables: StepCall = async (payload) => { ...project.repositories.map(async (repo) => { const projectKey = generateProjectKey(projectSlug, repo.internalRepoName) const repoId = await payload.apis.gitlab.getProjectId(repo.internalRepoName) - if (!repoId) return + if (!repoId) { + throw new Error(`Unable to find GitLab project for repository ${repo.internalRepoName}`) + } const listVars = await gitlabApi.getGitlabRepoVariables(repoId) return [ await gitlabApi.setGitlabRepoVariable(repoId, listVars, { @@ -231,13 +311,7 @@ export const deleteProject: StepCall = async (payload) => { try { const sonarRepositories = await findSonarProjectsForDsoProjects(projectSlug) await Promise.all(sonarRepositories.map(repo => deleteRepo(repo.key))) - const users: { paging: SonarPaging, users: SonarUser[] } = (await axiosInstance({ - url: 'users/search', - params: { - q: username, - }, - }))?.data - const user = users.users.find(u => u.login === username) + const user = await getUser(username) if (!user) { return { status: { diff --git a/plugins/sonarqube/src/group.ts b/plugins/sonarqube/src/group.ts index 83bde1f199..2b7f720614 100644 --- a/plugins/sonarqube/src/group.ts +++ b/plugins/sonarqube/src/group.ts @@ -1,6 +1,26 @@ import { getAxiosInstance } from './tech.js' import type { SonarPaging } from './project.js' +import { find, getAll, iter } from './utils.js' +export async function getGroupMembers(groupName: string): Promise { + const axiosInstance = getAxiosInstance() + const users = await getAll<{ login: string }>(iter(async (page, pageSize) => { + const response = await axiosInstance({ + url: 'user_groups/users', + params: { + name: groupName, + p: page, + ps: pageSize, + }, + }) + const data: { paging: SonarPaging, users: { login: string }[] } = response.data + return { + items: data.users, + paging: data.paging, + } + })) + return users.map(u => u.login) +} export interface SonarGroup { id: string name: string @@ -9,15 +29,23 @@ export interface SonarGroup { default: boolean } -export async function findGroupByName(name: string): Promise { +export async function findGroupByName(name: string): Promise { const axiosInstance = getAxiosInstance() - const groupsSearch: { paging: SonarPaging, groups: SonarGroup[] } = (await axiosInstance({ - url: 'user_groups/search', - params: { - q: name, - }, - }))?.data - return groupsSearch.groups.find(g => g.name === name) + return find(iter(async (page, pageSize) => { + const response = await axiosInstance({ + url: 'user_groups/search', + params: { + q: name, + p: page, + ps: pageSize, + }, + }) + const data: { paging: SonarPaging, groups: SonarGroup[] } = response.data + return { + items: data.groups, + paging: data.paging, + } + }), group => group.name === name) } export async function ensureGroupExists(groupName: string) { @@ -33,3 +61,27 @@ export async function ensureGroupExists(groupName: string) { }) } } + +export async function addUserToGroup(groupName: string, login: string) { + const axiosInstance = getAxiosInstance() + await axiosInstance({ + url: 'user_groups/add_user', + method: 'post', + params: { + name: groupName, + login, + }, + }) +} + +export async function removeUserFromGroup(groupName: string, login: string) { + const axiosInstance = getAxiosInstance() + await axiosInstance({ + url: 'user_groups/remove_user', + method: 'post', + params: { + name: groupName, + login, + }, + }) +} diff --git a/plugins/sonarqube/src/index.ts b/plugins/sonarqube/src/index.ts index 3b149807dd..174fe040e8 100644 --- a/plugins/sonarqube/src/index.ts +++ b/plugins/sonarqube/src/index.ts @@ -1,12 +1,11 @@ -import type { HookStepsNames, Plugin } from '@cpn-console/hooks' +import type { DeclareModuleGenerator, HookStepsNames, Plugin } from '@cpn-console/hooks' import { getStatus } from './check.js' -import { deleteProject, initSonar, setVariables, upsertProject } from './functions.js' +import { deleteProject, setVariables, upsertAdminRole, upsertProject } from './functions.js' import infos from './infos.js' import monitor from './monitor.js' -function start(_options: unknown) { +function start() { try { - initSonar() getStatus() } catch (_error) {} } @@ -14,6 +13,11 @@ function start(_options: unknown) { export const plugin: Plugin = { infos, subscribedHooks: { + upsertAdminRole: { + steps: { + main: upsertAdminRole, + }, + }, upsertProject: { steps: { main: upsertProject, @@ -34,4 +38,5 @@ declare module '@cpn-console/hooks' { interface PluginResult { errors?: Partial> } + interface Config extends DeclareModuleGenerator {} } diff --git a/plugins/sonarqube/src/infos.ts b/plugins/sonarqube/src/infos.ts index 94ea6616cc..e0fb78e38e 100644 --- a/plugins/sonarqube/src/infos.ts +++ b/plugins/sonarqube/src/infos.ts @@ -1,12 +1,53 @@ import type { ServiceInfos } from '@cpn-console/hooks' +import { DISABLED } from '@cpn-console/shared' import { getConfig } from './tech.js' -const infos: ServiceInfos = { +export const DEFAULT_ADMIN_GROUP_PATH = '/console/admin' +export const DEFAULT_READONLY_GROUP_PATH = '/console/readonly' + +const infos = { name: 'sonarqube', to: () => `${getConfig().url}/projects`, title: 'SonarQube', imgSrc: '/img/sonarqube.svg', description: 'SonarQube permet à tous les développeurs d\'écrire un code plus propre et plus sûr', -} + config: { + global: [{ + kind: 'text', + key: 'adminGroupPath', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Chemin du groupe OIDC Admin', + value: DEFAULT_ADMIN_GROUP_PATH, + description: 'Le chemin du groupe OIDC qui donne les droits d\'administrateur SonarQube', + placeholder: DEFAULT_ADMIN_GROUP_PATH, + }, { + kind: 'text', + key: 'readonlyGroupPath', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Chemin du groupe OIDC ReadOnly', + value: DEFAULT_READONLY_GROUP_PATH, + description: 'Le chemin du groupe OIDC qui donne les droits de lecture seule SonarQube', + placeholder: DEFAULT_READONLY_GROUP_PATH, + }, { + kind: 'switch', + key: 'purge', + initialValue: DISABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Purger les utilisateurs non synchronisés', + value: DISABLED, + description: 'Purger les utilisateurs non synchronisés de SonarQube lors de la synchronisation', + }], + project: [], + }, +} as const satisfies ServiceInfos export default infos diff --git a/plugins/sonarqube/src/project.ts b/plugins/sonarqube/src/project.ts index 1c0085c161..9d981194d2 100644 --- a/plugins/sonarqube/src/project.ts +++ b/plugins/sonarqube/src/project.ts @@ -1,5 +1,6 @@ import { generateProjectKey } from '@cpn-console/hooks' import { getAxiosInstance } from './tech.js' +import { getAll, iter } from './utils.js' export interface SonarPaging { pageIndex: number @@ -133,25 +134,21 @@ function filterProjectsOwning(repos: { key: string }[], projectSlug: string): So export async function findSonarProjectsForDsoProjects(projectSlug: string) { const axiosInstance = getAxiosInstance() - let foundProjectKeys: SonarProjectResult[] = [] - - let page = 0 - const pageSize = 100 - let total = 0 - do { - page++ - const similarProjects = await axiosInstance.get('projects/search', { + const components = await getAll(iter(async (page, pageSize) => { + const response = await axiosInstance.get('projects/search', { params: { q: projectSlug, p: page, ps: pageSize, }, }) - total = similarProjects.data.paging.total - foundProjectKeys = [...foundProjectKeys, ...filterProjectsOwning(similarProjects.data.components, projectSlug)] - } while (page * pageSize < total) - - return foundProjectKeys + const data: { paging: SonarPaging, components: { key: string }[] } = response.data + return { + items: data.components, + paging: data.paging, + } + })) + return filterProjectsOwning(components, projectSlug) } export const files = { diff --git a/plugins/sonarqube/src/user.ts b/plugins/sonarqube/src/user.ts index ec5d46e276..4466d4dc5e 100644 --- a/plugins/sonarqube/src/user.ts +++ b/plugins/sonarqube/src/user.ts @@ -3,6 +3,7 @@ import { generateRandomPassword } from '@cpn-console/hooks' import type { VaultSonarSecret } from './tech.js' import { getAxiosInstance } from './tech.js' import type { SonarPaging } from './project.js' +import { find, iter } from './utils.js' export interface SonarUser { login: string @@ -62,9 +63,7 @@ export async function changeToken(username: string) { export async function getUser(username: string): Promise { const axiosInstance = getAxiosInstance() - let page = 1 - const pageSize = 100 - while (true) { + return find(iter(async (page, pageSize) => { const response = await axiosInstance({ url: 'users/search', params: { @@ -73,14 +72,12 @@ export async function getUser(username: string): Promise ps: pageSize, }, }) - const users: { paging: SonarPaging, users: SonarUser[] } = response.data - const found = users.users.find(user => user.login === username) - if (found) return found - if (!users.users.length || users.paging.pageIndex * users.paging.pageSize >= users.paging.total) { - break + const data: { paging: SonarPaging, users: SonarUser[] } = response.data + return { + items: data.users, + paging: data.paging, } - page += 1 - } + }), user => user.login === username) } export async function ensureUserExists(username: string, projectSlug: string, vaultUserSecret: VaultSonarSecret | undefined): Promise { diff --git a/plugins/sonarqube/src/utils.ts b/plugins/sonarqube/src/utils.ts new file mode 100644 index 0000000000..2f599450a4 --- /dev/null +++ b/plugins/sonarqube/src/utils.ts @@ -0,0 +1,50 @@ +import type { SonarPaging } from './project.js' + +export interface PaginatedResult { + items: T[] + paging: SonarPaging +} + +export interface IteratorOptions { + pageSize?: number +} + +export async function* iter( + request: (page: number, pageSize: number) => Promise>, + options: IteratorOptions = {}, +): AsyncGenerator { + const pageSize = options.pageSize ?? 100 + let page = 1 + for (;;) { + const { items, paging } = await request(page, pageSize) + for (const item of items) { + yield item + } + if (!items.length || paging.pageIndex * paging.pageSize >= paging.total) { + break + } + page += 1 + } +} + +export async function getAll( + iterable: AsyncIterable, +): Promise { + const result: T[] = [] + for await (const item of iterable) { + result.push(item) + } + return result +} + +export async function find( + iterable: AsyncIterable, + predicate: (item: T) => boolean, +): Promise { + for await (const item of iterable) { + if (predicate(item)) { + return item + } + } + return undefined +} From 0155268989bab8d4ef1a98eb14a873ec8bda7397 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Mon, 23 Feb 2026 12:01:42 +0100 Subject: [PATCH 5/5] refactor(hooks): merge projectmember and projectrole to project hook Co-authored-by: William Phetsinorath Signed-off-by: William Phetsinorath --- .../src/resources/project-member/business.ts | 3 - .../src/resources/project-role/business.ts | 10 +- apps/server/src/utils/hook-wrapper.spec.ts | 45 ++++- apps/server/src/utils/hook-wrapper.ts | 134 ++---------- .../hooks/src/hooks/hook-project-member.ts | 16 -- packages/hooks/src/hooks/hook-project-role.ts | 17 -- packages/hooks/src/hooks/hook-project.ts | 8 +- packages/hooks/src/hooks/index.ts | 2 - plugins/argocd/src/functions.ts | 7 +- plugins/gitlab/src/class.ts | 6 +- plugins/gitlab/src/functions.ts | 119 +---------- plugins/gitlab/src/index.ts | 18 +- plugins/gitlab/src/members.ts | 61 ++++-- plugins/gitlab/src/utils.ts | 25 ++- plugins/keycloak/src/functions.ts | 190 +++++------------- plugins/keycloak/src/index.ts | 16 -- 16 files changed, 193 insertions(+), 484 deletions(-) delete mode 100644 packages/hooks/src/hooks/hook-project-member.ts delete mode 100644 packages/hooks/src/hooks/hook-project-role.ts diff --git a/apps/server/src/resources/project-member/business.ts b/apps/server/src/resources/project-member/business.ts index b0f1c127b9..392b7dd150 100644 --- a/apps/server/src/resources/project-member/business.ts +++ b/apps/server/src/resources/project-member/business.ts @@ -44,20 +44,17 @@ export async function addMember(projectId: Project['id'], user: XOR<{ userId: st } await upsertMember({ projectId, userId: userInDb.id, roleIds: [] }) - await hook.projectMember.upsert(projectId, userInDb.id) return listMembers(projectId) } export async function patchMembers(projectId: Project['id'], members: typeof projectMemberContract.patchMembers.body._type) { for (const member of members) { await upsertMember({ projectId, userId: member.userId, roleIds: member.roles }) - await hook.projectMember.upsert(projectId, member.userId) } return listMembers(projectId) } export async function removeMember(projectId: Project['id'], userId: User['id']) { - await hook.projectMember.delete(projectId, userId) await deleteMember({ projectId, userId }) return listMembers(projectId) } diff --git a/apps/server/src/resources/project-role/business.ts b/apps/server/src/resources/project-role/business.ts index fb40211944..5e23d6e680 100644 --- a/apps/server/src/resources/project-role/business.ts +++ b/apps/server/src/resources/project-role/business.ts @@ -7,7 +7,6 @@ import { updateRole, } from '@/resources/queries-index.js' import { BadRequest400, Forbidden403, NotFound404 } from '@/utils/errors.js' -import { hook } from '@/utils/hook-wrapper.js' import prisma from '@/prisma.js' export async function listRoles(projectId: Project['id']) { @@ -55,7 +54,6 @@ export async function patchRoles(projectId: Project['id'], roles: typeof project if (positionsAvailable.length && positionsAvailable.length !== dbRoles.length) return new BadRequest400('Les numéros de position des rôles sont incohérentes') for (const { id, ...role } of updatedRoles) { await updateRole(id, role) - await hook.projectRole.upsert(id) } return listRoles(projectId) @@ -78,7 +76,7 @@ export async function createRole(projectId: Project['id'], role: typeof projectR throw new BadRequest400('oidcGroup doit commencer par /') } - const createdRole = await prisma.projectRole.create({ + await prisma.projectRole.create({ data: { ...role, projectId, @@ -88,8 +86,6 @@ export async function createRole(projectId: Project['id'], role: typeof projectR }, }) - await hook.projectRole.upsert(createdRole.id) - return listRoles(projectId) } @@ -107,10 +103,10 @@ export async function countRolesMembers(projectId: Project['id']) { export async function deleteRole(roleId: Project['id']) { const role = await prisma.projectRole.findUnique({ where: { id: roleId } }) - if (role?.type === 'system') { + if (!role) throw new NotFound404() + if (role.type === 'system') { return new Forbidden403('Ce rôle système ne peut pas être supprimé') } - await hook.projectRole.delete(roleId) await deleteRoleQuery(roleId) return null } diff --git a/apps/server/src/utils/hook-wrapper.spec.ts b/apps/server/src/utils/hook-wrapper.spec.ts index d7d1bdc3ab..3fc7aa563e 100644 --- a/apps/server/src/utils/hook-wrapper.spec.ts +++ b/apps/server/src/utils/hook-wrapper.spec.ts @@ -207,7 +207,11 @@ describe('transformToHookProject', () => { expect(result.users).toEqual([project.owner]) // Assert sur la transformation des rôles - expect(result.roles).toEqual([{ userId: project.owner.id, role: 'owner' }]) + expect(result.roles).toEqual([{ + name: 'owner', + position: 0, + users: [project.owner], + }]) // Assert sur la transformation des clusters expect(result.clusters).toEqual([associatedCluster, nonAssociatedCluster].map(({ kubeconfig, ...cluster }) => ({ @@ -218,7 +222,7 @@ describe('transformToHookProject', () => { }))) // Assert sur la transformation des environnements - expect(result.environments).toEqual(project.environments.map(({ permissions: _, stage, quota, ...environment }) => ({ + expect(result.environments).toEqual(project.environments.map(({ permissions: _, stage, quota, ...environment }: any) => ({ quota, stage: stage.name, permissions: [{ permissions: { rw: true, ro: true }, userId: project.ownerId }], @@ -227,9 +231,44 @@ describe('transformToHookProject', () => { }))) // Assert sur la transformation des repositories - expect(result.repositories).toEqual(project.repositories.map(repo => ({ ...repo, newCreds: mockReposCreds[repo.internalRepoName] }))) + expect(result.repositories).toEqual(project.repositories.map((repo: any) => ({ ...repo, newCreds: mockReposCreds[repo.internalRepoName] }))) // Assert sur le store expect(result.store).toEqual(mockStore) }) + + it('handles members with roles correctly', () => { + const roleDev = { id: 'role-dev', name: 'developer', permissions: 0n, position: 0 } + const memberDev = { + userId: 'user-dev', + roleIds: ['role-dev'], + user: { id: 'user-dev', firstName: 'Dev', lastName: 'User', email: 'dev@test.com', createdAt: new Date(), updatedAt: new Date(), adminRoleIds: [] }, + id: 'member-dev', + projectId: project.id, + createdAt: new Date(), + updatedAt: new Date(), + } + const projectWithRoles = { + ...project, + roles: [roleDev], + members: [memberDev], + } + + // @ts-ignore - limited mock + const result = transformToHookProject(projectWithRoles, mockStore, mockReposCreds) + + expect(result.roles).toContainEqual({ + name: 'owner', + position: 0, + users: [project.owner], + }) + expect(result.roles).toContainEqual({ + name: 'developer', + permissions: '0', + position: 0, + type: undefined, + oidcGroup: undefined, + users: [memberDev.user], + }) + }) }) diff --git a/apps/server/src/utils/hook-wrapper.ts b/apps/server/src/utils/hook-wrapper.ts index 33540a3024..f32f7ed311 100644 --- a/apps/server/src/utils/hook-wrapper.ts +++ b/apps/server/src/utils/hook-wrapper.ts @@ -1,10 +1,10 @@ -import type { Cluster, Kubeconfig, Project, ProjectRole, Zone, ProjectMembers } from '@prisma/client' +import type { Cluster, Kubeconfig, Project, ProjectRole, Zone } from '@prisma/client' import type { ClusterObject, HookResult, KubeCluster, KubeUser, Project as ProjectPayload, RepoCreds, Repository, Store, ZoneObject } from '@cpn-console/hooks' import { hooks } from '@cpn-console/hooks' import type { AsyncReturnType } from '@cpn-console/shared' import { ProjectAuthorized, getPermsByUserRoles, resourceListToDict } from '@cpn-console/shared' import { genericProxy } from './proxy.js' -import { archiveProject, getAdminPlugin, getAdminRoleById, getClusterByIdOrThrow, getClusterNamesByZoneId, getClustersAssociatedWithProject, getHookProjectInfos, getHookRepository, getProjectStore, getRole, getZoneByIdOrThrow, saveProjectStore, updateProjectClusterHistory, updateProjectCreated, updateProjectFailed, updateProjectWarning } from '@/resources/queries-index.js' +import { archiveProject, getAdminPlugin, getAdminRoleById, getClusterByIdOrThrow, getClusterNamesByZoneId, getClustersAssociatedWithProject, getHookProjectInfos, getHookRepository, getProjectStore, getZoneByIdOrThrow, saveProjectStore, updateProjectClusterHistory, updateProjectCreated, updateProjectFailed, updateProjectWarning } from '@/resources/queries-index.js' import type { ConfigRecords } from '@/resources/project-service/business.js' import { dbToObj } from '@/resources/project-service/business.js' @@ -139,114 +139,6 @@ const user = { }, } as const -const projectMember = { - upsert: async (projectId: Project['id'], userId: ProjectMembers['userId']) => { - const project = await getHookProjectInfos(projectId) - const projectStore = dbToObj(await getProjectStore(project.id)) - const hookProject = transformToHookProject(project, projectStore) - const store = dbToObj(await getAdminPlugin()) - - const member = project.members.find(m => m.userId === userId) - if (!member) throw new Error('Member not found') - - const memberRoles = project.roles - .filter(role => member.roleIds.includes(role.id)) - .map(role => ({ - ...role, - permissions: role.permissions.toString(), - oidcGroup: role.oidcGroup ?? undefined, - project: hookProject, - })) - - const payload = { - userId: member.userId, - roleIds: member.roleIds, - firstName: member.user.firstName, - lastName: member.user.lastName, - email: member.user.email, - type: member.user.type as 'human' | 'bot' | 'ghost', - createdAt: member.user.createdAt.toISOString(), - updatedAt: member.user.updatedAt.toISOString(), - lastLogin: member.user.lastLogin?.toISOString(), - projectId: project.id, - roles: memberRoles, - project: hookProject, - } - - return hooks.upsertProjectMember.execute(payload, store) - }, - delete: async (projectId: Project['id'], userId: ProjectMembers['userId']) => { - const project = await getHookProjectInfos(projectId) - const projectStore = dbToObj(await getProjectStore(project.id)) - const hookProject = transformToHookProject(project, projectStore) - const store = dbToObj(await getAdminPlugin()) - - const member = project.members.find(m => m.userId === userId) - if (!member) throw new Error('Member not found') - - const memberRoles = project.roles - .filter(role => member.roleIds.includes(role.id)) - .map(role => ({ - ...role, - permissions: role.permissions.toString(), - oidcGroup: role.oidcGroup ?? undefined, - project: hookProject, - })) - - const payload = { - userId: member.userId, - roleIds: member.roleIds, - firstName: member.user.firstName, - lastName: member.user.lastName, - email: member.user.email, - type: member.user.type as 'human' | 'bot' | 'ghost', - createdAt: member.user.createdAt.toISOString(), - updatedAt: member.user.updatedAt.toISOString(), - lastLogin: member.user.lastLogin?.toISOString(), - projectId: project.id, - roles: memberRoles, - project: hookProject, - } - - return hooks.deleteProjectMember.execute(payload, store) - }, -} as const - -const projectRole = { - upsert: async (roleId: ProjectRole['id']) => { - const role = await getRole(roleId) - if (!role) throw new Error('Role not found') - - const project = await getHookProjectInfos(role.projectId) - const projectStore = dbToObj(await getProjectStore(role.projectId)) - const hookProject = transformToHookProject(project, projectStore) - - const rolePayload = { - ...role, - permissions: role.permissions.toString(), - project: hookProject, - } - const store = dbToObj(await getAdminPlugin()) - return hooks.upsertProjectRole.execute(rolePayload, store) - }, - delete: async (roleId: ProjectRole['id']) => { - const role = await getRole(roleId) - if (!role) throw new Error('Role not found') - - const project = await getHookProjectInfos(role.projectId) - const projectStore = dbToObj(await getProjectStore(role.projectId)) - const hookProject = transformToHookProject(project, projectStore) - - const rolePayload = { - ...role, - permissions: role.permissions.toString(), - project: hookProject, - } - const store = dbToObj(await getAdminPlugin()) - return hooks.deleteProjectRole.execute(rolePayload, store) - }, -} as const - const zone = { upsert: async (zoneId: Zone['id']) => { const zone: ZoneObject = await getZoneByIdOrThrow(zoneId) @@ -299,10 +191,6 @@ export const hook = { // @ts-ignore TODO voir comment opti la signature de la fonction project: genericProxy(project, { upsert: ['delete'], delete: ['upsert', 'delete'], getSecrets: ['delete'] }), // @ts-ignore TODO voir comment opti la signature de la fonction - projectRole: genericProxy(projectRole, { delete: ['upsert', 'delete'], upsert: ['delete'] }), - // @ts-ignore TODO voir comment opti la signature de la fonction - projectMember: genericProxy(projectMember, { delete: ['upsert'], upsert: ['delete'] }), - // @ts-ignore TODO voir comment opti la signature de la fonction cluster: genericProxy(cluster, { delete: ['upsert', 'delete'], upsert: ['delete'] }), // @ts-ignore TODO voir comment opti la signature de la fonction zone: genericProxy(zone, { delete: ['upsert'], upsert: ['delete'] }), @@ -349,10 +237,20 @@ export function transformToHookProject(project: ProjectInfos, store: Store, repo store, users: [project.owner, ...project.members.map(({ user }) => user)], roles: [ - { userId: project.ownerId, role: 'owner' }, - ...project.members.map(member => ({ - userId: member.userId, - role: 'user' as const, + { + name: 'owner', + position: 0, + users: [project.owner], + }, + ...project.roles.map(role => ({ + name: role.name, + permissions: role.permissions.toString(), + position: role.position, + type: role.type, + oidcGroup: role.oidcGroup, + users: project.members + .filter(member => member.roleIds.includes(role.id)) + .map(member => member.user), })), ], }) diff --git a/packages/hooks/src/hooks/hook-project-member.ts b/packages/hooks/src/hooks/hook-project-member.ts deleted file mode 100644 index c1d726f599..0000000000 --- a/packages/hooks/src/hooks/hook-project-member.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { ProjectRole } from './hook-project-role.js' -import type { Project } from './hook-project.js' -import type { Hook } from './hook.js' -import { createHook } from './hook.js' - -export interface ProjectMember { - userId: string - email: string - firstName: string - lastName: string - roles: ProjectRole[] - project: Project -} - -export const upsertProjectMember: Hook = createHook() -export const deleteProjectMember: Hook = createHook() diff --git a/packages/hooks/src/hooks/hook-project-role.ts b/packages/hooks/src/hooks/hook-project-role.ts deleted file mode 100644 index 8d047f4d79..0000000000 --- a/packages/hooks/src/hooks/hook-project-role.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { Project } from './hook-project.js' -import type { Hook } from './hook.js' -import { createHook } from './hook.js' - -export interface ProjectRole { - id: string - name: string - permissions: string - projectId: string - position: number - type?: string - oidcGroup?: string - project: Project -} - -export const upsertProjectRole: Hook = createHook() -export const deleteProjectRole: Hook = createHook() diff --git a/packages/hooks/src/hooks/hook-project.ts b/packages/hooks/src/hooks/hook-project.ts index 7d531d1420..9f92098dc7 100644 --- a/packages/hooks/src/hooks/hook-project.ts +++ b/packages/hooks/src/hooks/hook-project.ts @@ -9,8 +9,12 @@ export interface RepoCreds { } export interface Role { - userId: string - role: 'owner' | 'user' + name: string + permissions?: string + position: number + type?: string + oidcGroup?: string + users: UserObject[] } export interface EnvironmentApis { diff --git a/packages/hooks/src/hooks/index.ts b/packages/hooks/src/hooks/index.ts index 76a11373fa..83e6dbb3e2 100644 --- a/packages/hooks/src/hooks/index.ts +++ b/packages/hooks/src/hooks/index.ts @@ -1,8 +1,6 @@ export * from './hook-cluster.js' export * from './hook-misc.js' export * from './hook-project.js' -export * from './hook-project-role.js' -export * from './hook-project-member.js' export * from './hook-user.js' export * from './hook-zone.js' export * from './hook-admin-role.js' diff --git a/plugins/argocd/src/functions.ts b/plugins/argocd/src/functions.ts index e31796130a..dc2393cffc 100644 --- a/plugins/argocd/src/functions.ts +++ b/plugins/argocd/src/functions.ts @@ -42,8 +42,8 @@ export const upsertProject: StepCall = async (payload) => { ...splitExtraRepositories(project.store.argocd?.extraRepositories), ] - await Promise.all([ - ...project.environments.map(async (environment) => { + await Promise.all( + project.environments.map(async (environment) => { const nsName = generateNamespaceName(project.id, environment.id) const cluster = getCluster(project, environment) const infraProject = await gitlabApi.getOrCreateInfraProject( @@ -72,7 +72,7 @@ export const upsertProject: StepCall = async (payload) => { vaultApi, ) }), - ]) + ) await removeInfraEnvValues(project, gitlabApi) @@ -99,6 +99,7 @@ interface ArgoRepoSource { path: string valueFiles: string[] } + async function ensureInfraEnvValues( project: Project, environment: Environment, diff --git a/plugins/gitlab/src/class.ts b/plugins/gitlab/src/class.ts index 7b47731c10..785259d160 100644 --- a/plugins/gitlab/src/class.ts +++ b/plugins/gitlab/src/class.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto' -import { PluginApi, type Project, type UniqueRepo, type ProjectMember } from '@cpn-console/hooks' +import { PluginApi, type Project, type UniqueRepo } from '@cpn-console/hooks' import type { AccessTokenScopes, CommitAction, GroupSchema, MemberSchema, ProjectVariableSchema, VariableSchema, AllRepositoryTreesOptions, CondensedProjectSchema, Gitlab, ProjectSchema, RepositoryFileExpandedSchema } from '@gitbeaker/core' import { AccessLevel } from '@gitbeaker/core' import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/vault-project-api.js' @@ -234,12 +234,12 @@ export class GitlabZoneApi extends GitlabApi { } export class GitlabProjectApi extends GitlabApi { - private project: Project | UniqueRepo | ProjectMember['project'] + private project: Project | UniqueRepo private gitlabGroup: GroupSchema | undefined private specialRepositories: string[] = [infraAppsRepoName, internalMirrorRepoName] private zoneApi: GitlabZoneApi - constructor(project: Project | UniqueRepo | ProjectMember['project']) { + constructor(project: Project | UniqueRepo) { super() this.project = project this.api = getApi() diff --git a/plugins/gitlab/src/functions.ts b/plugins/gitlab/src/functions.ts index 456dbfc956..e9c5a0a6f6 100644 --- a/plugins/gitlab/src/functions.ts +++ b/plugins/gitlab/src/functions.ts @@ -1,21 +1,16 @@ -import { okStatus, parseError, specificallyDisabled, specificallyEnabled } from '@cpn-console/hooks' -import type { AdminRole, ClusterObject, PluginResult, Project, ProjectLite, StepCall, UniqueRepo, ZoneObject, ProjectMember } from '@cpn-console/hooks' +import { okStatus, parseError, specificallyDisabled } from '@cpn-console/hooks' +import type { AdminRole, ClusterObject, PluginResult, Project, ProjectLite, StepCall, UniqueRepo, ZoneObject } from '@cpn-console/hooks' import { insert } from '@cpn-console/shared' -import { AccessLevel } from '@gitbeaker/core' import { deleteGroup } from './group.js' import { createUsername, getUser, upsertUser } from './user.js' import { ensureMembers } from './members.js' import { ensureRepositories } from './repositories.js' import type { VaultSecrets } from './utils.js' import config from './config.js' -import type { GitlabProjectApi } from './class.js' -import { cleanGitlabError, matchRole } from './utils.js' +import { cleanGitlabError } from './utils.js' import { 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, } from './infos.js' // Check @@ -109,7 +104,7 @@ export const upsertDsoProject: StepCall = async (payload) => { await gitlabApi.getOrCreateProjectGroup() - const { failedInUpsertUsers } = await ensureMembers(gitlabApi, project) + const { failedInUpsertUsers } = await ensureMembers(gitlabApi, project, payload.config) if (failedInUpsertUsers) { returnResult.status.result = 'WARNING' returnResult.warnReasons = insert(returnResult.warnReasons, 'Failed to create or upsert users in Gitlab') @@ -319,109 +314,3 @@ export const deleteAdminRole: StepCall = async (payload) => { } } } - -export const upsertProjectMember: StepCall = async (payload) => { - const member = payload.args - const { gitlab: gitlabApi } = payload.apis as { gitlab: GitlabProjectApi } // TODO: apis is never type for some resaon - const purge = payload.config.gitlab?.purge - const projectReporterGroupPathSuffix = payload.config.gitlab?.projectReporterGroupPathSuffix ?? DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX - const projectDeveloperGroupPathSuffix = payload.config.gitlab?.projectDeveloperGroupPathSuffix ?? DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX - const projectMaintainerGroupPathSuffix = payload.config.gitlab?.projectMaintainerGroupPathSuffix ?? DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX - - try { - const gitlabUser = await upsertUser({ - id: member.userId, - firstName: member.firstName, - lastName: member.lastName, - email: member.email, - }) - - let maxAccessLevel: number | undefined - - if (member.project.owner.id === member.userId) { - maxAccessLevel = AccessLevel.OWNER - } else if (member.roles.find(role => role.oidcGroup && matchRole(member.project.slug, role.oidcGroup, projectReporterGroupPathSuffix))) { - maxAccessLevel = AccessLevel.GUEST - } else if (member.roles.find(role => role.oidcGroup && matchRole(member.project.slug, role.oidcGroup, projectDeveloperGroupPathSuffix))) { - maxAccessLevel = AccessLevel.DEVELOPER - } else if (member.roles.find(role => role.oidcGroup && matchRole(member.project.slug, role.oidcGroup, projectMaintainerGroupPathSuffix))) { - maxAccessLevel = AccessLevel.MAINTAINER - } - - const groupMembers = await gitlabApi.getGroupMembers() - const existingMember = groupMembers.find(m => m.id === gitlabUser.id) - - if (maxAccessLevel === undefined) { - if (specificallyEnabled(purge)) { - if (existingMember) { - await gitlabApi.removeGroupMember(gitlabUser.id) - } - return { - status: { - result: 'OK', - message: 'Member has no matching roles, removed from group', - }, - } - } else { - console.warn(`Member ${gitlabUser.username} has no matching roles, not synced`) - } - } - - if (existingMember) { - if (existingMember.access_level !== maxAccessLevel) { - await gitlabApi.editGroupMember(gitlabUser.id, maxAccessLevel) - } - } else { - await gitlabApi.addGroupMember(gitlabUser.id, maxAccessLevel) - } - - return { - status: { - result: 'OK', - message: 'Member synced', - }, - } - } catch (error) { - return { - error: parseError(cleanGitlabError(error)), - status: { - result: 'KO', - message: 'An error happened while syncing project member', - }, - } - } -} - -export const deleteProjectMember: StepCall = async (payload) => { - const member = payload.args - const { gitlab: gitlabApi } = payload.apis as { gitlab: GitlabProjectApi } // TODO: apis is never type for some resaon - - try { - const userInfos = await getUser({ ...member, id: member.userId, username: createUsername(member.email) }) - if (!userInfos) { - return { - status: { - result: 'OK', - message: 'User not found in GitLab', - }, - } - } - - await gitlabApi.removeGroupMember(userInfos.id) - - return { - status: { - result: 'OK', - message: 'Member deleted', - }, - } - } catch (error) { - return { - error: parseError(cleanGitlabError(error)), - status: { - result: 'KO', - message: 'An error happened while deleting project member', - }, - } - } -} diff --git a/plugins/gitlab/src/index.ts b/plugins/gitlab/src/index.ts index 9fa707cb68..44f857e186 100644 --- a/plugins/gitlab/src/index.ts +++ b/plugins/gitlab/src/index.ts @@ -1,15 +1,13 @@ -import type { DeclareModuleGenerator, DefaultArgs, Plugin, Project, ProjectMember, UniqueRepo, ZoneObject } from '@cpn-console/hooks' +import type { DeclareModuleGenerator, DefaultArgs, Plugin, Project, UniqueRepo, ZoneObject } from '@cpn-console/hooks' import { checkApi, commitFiles, deleteDsoProject, - deleteProjectMember, deleteZone, getDsoProjectSecrets, syncRepository, upsertAdminRole, upsertDsoProject, - upsertProjectMember, upsertZone, } from './functions.js' import { getOrCreateGroupRoot } from './utils.js' @@ -82,18 +80,6 @@ export const plugin: Plugin = { main: upsertAdminRole, }, }, - upsertProjectMember: { - api: member => new GitlabProjectApi(member.project), - steps: { - main: upsertProjectMember, - }, - }, - deleteProjectMember: { - api: member => new GitlabProjectApi(member.project), - steps: { - post: deleteProjectMember, - }, - }, }, monitor, start, @@ -101,7 +87,7 @@ export const plugin: Plugin = { declare module '@cpn-console/hooks' { interface HookPayloadApis { - gitlab: Args extends Project | UniqueRepo | ProjectMember['project'] + gitlab: Args extends Project | UniqueRepo ? GitlabProjectApi : Args extends ZoneObject ? GitlabZoneApi diff --git a/plugins/gitlab/src/members.ts b/plugins/gitlab/src/members.ts index ec77ee45dc..24a7af0611 100644 --- a/plugins/gitlab/src/members.ts +++ b/plugins/gitlab/src/members.ts @@ -1,10 +1,13 @@ -import type { Project } from '@cpn-console/hooks' +import type { Config, Project } from '@cpn-console/hooks' +import { specificallyEnabled } from '@cpn-console/hooks' import type { UserSchema } from '@gitbeaker/core' import type { GitlabProjectApi } from './class.js' import { upsertUser } from './user.js' +import { resolveAccessLevel } from './utils.js' + +export async function ensureMembers(gitlabApi: GitlabProjectApi, project: Project, config: Config) { + const purge = config.gitlab?.purge -export async function ensureMembers(gitlabApi: GitlabProjectApi, project: Project) { - // Ensure all users exists in gitlab const [gitlabUserPromiseResults, members] = await Promise.all([ Promise.allSettled(project.users.map(user => upsertUser(user))), gitlabApi.getGroupMembers(), @@ -19,24 +22,42 @@ export async function ensureMembers(gitlabApi: GitlabProjectApi, project: Projec const rejectedGitlabUsers = gitlabUserPromiseResults .filter((result): result is RejectedResult => result.status === 'rejected') - // Ensure members are set - const membersAdded = await Promise.all([ - ...fulfilledGitlabUsers.map(gitlabUser => - members.find(member => member.id === gitlabUser.value.id) - ? undefined - : gitlabApi.addGroupMember(gitlabUser.value.id), - ), - ...members.map(member => - ( - !member.username.match(/group_\d+_bot/) - && !fulfilledGitlabUsers.find(gitlabUser => member.id === gitlabUser.value.id) - ) - ? gitlabApi.removeGroupMember(member.id) - : undefined, - ), - ]) + const userAccessLevels = new Map() + for (const role of project.roles) { + const accessLevel = resolveAccessLevel(project, role, config) + + if (accessLevel !== undefined) { + for (const user of role.users) { + const currentLevel = userAccessLevels.get(user.id) + if (!currentLevel || accessLevel > currentLevel) { + userAccessLevels.set(user.id, accessLevel) + } + } + } + } + + await Promise.all(fulfilledGitlabUsers.map(async (gitlabUser, index) => { + const projectUser = project.users[index] + if (!projectUser) return + + const accessLevel = userAccessLevels.get(projectUser.id) + const existingMember = members.find(member => member.id === gitlabUser.value.id) + + if (accessLevel !== undefined) { + if (existingMember) { + if (existingMember.access_level !== accessLevel) { + await gitlabApi.editGroupMember(gitlabUser.value.id, accessLevel) + } + } else { + await gitlabApi.addGroupMember(gitlabUser.value.id, accessLevel) + } + } else if (specificallyEnabled(purge) && existingMember) { + await gitlabApi.removeGroupMember(gitlabUser.value.id) + } + })) + return { - members: [...members, membersAdded.filter(member => member)], + members, failedInUpsertUsers: !!rejectedGitlabUsers.length, } } diff --git a/plugins/gitlab/src/utils.ts b/plugins/gitlab/src/utils.ts index c01bac58be..33c0bb282c 100644 --- a/plugins/gitlab/src/utils.ts +++ b/plugins/gitlab/src/utils.ts @@ -1,7 +1,9 @@ import { Gitlab } from '@gitbeaker/rest' -import type { Gitlab as IGitlab, BaseRequestOptions, PaginationRequestOptions, OffsetPagination } from '@gitbeaker/core' +import { type Gitlab as IGitlab, type BaseRequestOptions, type PaginationRequestOptions, type OffsetPagination, AccessLevel } from '@gitbeaker/core' import { GitbeakerRequestError } from '@gitbeaker/requester-utils' import config from './config.js' +import type { Config, Project, Role } from '@cpn-console/hooks' +import { DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX } from './infos.js' let api: IGitlab | undefined @@ -96,6 +98,27 @@ export function matchRole(projectSlug: string, roleOidcGroup: string, configured return roleOidcGroup === `/${projectSlug}${configuredRolePath}` } +export function resolveAccessLevel(project: Project, role: Role, config: Config) { + const projectReporterGroupPathSuffix = config.gitlab?.projectReporterGroupPathSuffix ?? DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX + const projectDeveloperGroupPathSuffix = config.gitlab?.projectDeveloperGroupPathSuffix ?? DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX + const projectMaintainerGroupPathSuffix = config.gitlab?.projectMaintainerGroupPathSuffix ?? DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX + + let accessLevel: number | undefined + if (role.name === 'owner') { + accessLevel = AccessLevel.OWNER + } else if (role.oidcGroup) { + if (matchRole(project.slug, role.oidcGroup, projectReporterGroupPathSuffix)) { + accessLevel = AccessLevel.GUEST + } else if (matchRole(project.slug, role.oidcGroup, projectDeveloperGroupPathSuffix)) { + accessLevel = AccessLevel.DEVELOPER + } else if (matchRole(project.slug, role.oidcGroup, projectMaintainerGroupPathSuffix)) { + accessLevel = AccessLevel.MAINTAINER + } + } + + return accessLevel +} + export async function* offsetPaginate( request: (options: PaginationRequestOptions<'offset'> & BaseRequestOptions) => Promise<{ data: T[], paginationInfo: OffsetPagination }>, ): AsyncGenerator { diff --git a/plugins/keycloak/src/functions.ts b/plugins/keycloak/src/functions.ts index 793750f8d3..125e2b0864 100644 --- a/plugins/keycloak/src/functions.ts +++ b/plugins/keycloak/src/functions.ts @@ -1,10 +1,9 @@ -import type { AdminRole, Project, StepCall, UserEmail, ZoneObject, ProjectMember } from '@cpn-console/hooks' -import type { ProjectRole } from '@cpn-console/shared' +import type { AdminRole, Project, StepCall, UserEmail, ZoneObject } from '@cpn-console/hooks' import { generateRandomPassword, parseError, PluginResultBuilder, specificallyEnabled } from '@cpn-console/hooks' import type GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation.js' import type ClientRepresentation from '@keycloak/keycloak-admin-client/lib/defs/clientRepresentation.js' import type { CustomGroup } from './group.js' -import { consoleGroupName, deleteGroup, getAllSubgroups, getGroupByName, getOrCreateChildGroup, getOrCreateGroupByPath, getOrCreateProjectGroup } from './group.js' +import { consoleGroupName, getAllSubgroups, getGroupByName, getOrCreateChildGroup, getOrCreateGroupByPath, getOrCreateProjectGroup } from './group.js' import { getkcClient } from './client.js' export const retrieveKeycloakUserByEmail: StepCall = async ({ args: { email } }) => { @@ -146,6 +145,52 @@ export const upsertProject: StepCall = async ({ args: project, config } return undefined })) + // Sync Roles + for (const role of project.roles) { + if (!role.oidcGroup) continue + try { + const group = await getOrCreateGroupByPath(kcClient, role.oidcGroup) + if (!group.id) continue + const groupMembers = await kcClient.groups.listMembers({ id: group.id }) + + await Promise.all([ + ...groupMembers.map((member) => { + if (!role.users.some(({ id }) => id === member.id)) { + if (specificallyEnabled(purge)) { + return kcClient.users.delFromGroup({ + id: member.id!, + groupId: group.id!, + }) + .catch((err) => { + pluginResult.addKoMessage(`Can't remove ${member.email} from keycloak role group ${role.name}`) + pluginResult.addExtra(`remove-role-${role.name}-${member.id}`, err) + }) + } else { + console.warn(`User ${member.email} is not in role ${role.name} anymore, but purge is disabled`) + } + } + return undefined + }), + ...role.users.map((user) => { + if (!groupMembers.some(({ id }) => id === user.id)) { + return kcClient.users.addToGroup({ + id: user.id, + groupId: group.id!, + }) + .catch((err) => { + pluginResult.addKoMessage(`Can't add ${user.email} to keycloak role group ${role.name}`) + pluginResult.addExtra(`add-role-${role.name}-${user.id}`, err) + }) + } + return undefined + }), + ]) + } catch (error) { + pluginResult.addKoMessage(`Failed to sync role ${role.name}`) + pluginResult.addExtra(`role-${role.name}`, error) + } + } + return pluginResult.getResultObject() } catch (error) { return pluginResult.returnUnexpectedError(error) @@ -309,145 +354,6 @@ export const deleteAdminRole: StepCall = async ({ args: role }) => { } } -export const upsertProjectRole: StepCall = async ({ args: role }) => { - if (!role.oidcGroup) { - return { - status: { - result: 'OK', - message: 'No OIDC group defined', - }, - } - } - try { - const kcClient = await getkcClient() - await getOrCreateGroupByPath(kcClient, role.oidcGroup) - return { - status: { - result: 'OK', - message: 'Synced', - }, - } - } catch (error) { - return { - error: parseError(error), - status: { - result: 'KO', - message: 'Failed to sync role', - }, - } - } -} - -export const deleteProjectRole: StepCall = async ({ args: role }) => { - if (!role.oidcGroup) { - return { - status: { - result: 'OK', - message: 'No OIDC group defined', - }, - } - } - try { - const kcClient = await getkcClient() - const [projectName, pluginName, roleName] = role.oidcGroup.split('/').slice(1) - if (!projectName || !pluginName || !roleName) throw new Error('Invalid OIDC group format') - const projectGroup = await getGroupByName(kcClient, projectName) - if (projectGroup?.id) { - const pluginGroups = await getAllSubgroups(kcClient, projectGroup.id, 0) - const pluginGroup = pluginGroups.find(({ name }) => name === pluginName) as Required | undefined - if (pluginGroup?.id) { - const roleGroups = await getAllSubgroups(kcClient, pluginGroup.id, 0) - const roleGroup = roleGroups.find(({ name }) => name === roleName) as Required | undefined - if (roleGroup?.id) { - await deleteGroup(kcClient, roleGroup.id) - return { - status: { - result: 'OK', - message: 'Deleted', - }, - } - } - } - } - return { - status: { - result: 'OK', - message: 'Already deleted', - }, - } - } catch (error) { - return { - error: parseError(error), - status: { - result: 'KO', - message: 'Failed to delete role', - }, - } - } -} - -export const upsertProjectMember: StepCall = async ({ args: member, config }) => { - const pluginResult = new PluginResultBuilder('Synced') - const purge = config.keycloak?.purge - try { - const kcClient = await getkcClient() - - const projectGroup = await getOrCreateProjectGroup(kcClient, member.project.slug) - const consoleGroup = await getOrCreateChildGroup(kcClient, projectGroup.id, consoleGroupName) - const allRoleGroups = await getAllSubgroups(kcClient, consoleGroup.id, 0) - const userGroups = await kcClient.users.listGroups({ id: member.userId }) - - const userRolesOidcGroups = member.roles - .map(r => r.oidcGroup) - .filter((g): g is string => !!g) - - // Sync Roles - for (const roleGroup of allRoleGroups) { - if (!roleGroup.id || !roleGroup.path) continue - const isMember = userGroups.some(ug => ug.id === roleGroup.id) - const shouldBeMember = userRolesOidcGroups.includes(roleGroup.path) - - if (shouldBeMember && !isMember) { - await kcClient.users.addToGroup({ id: member.userId, groupId: roleGroup.id }) - } else if (!shouldBeMember && isMember) { - if (specificallyEnabled(purge)) { - await kcClient.users.delFromGroup({ id: member.userId, groupId: roleGroup.id }) - } else { - console.warn(`User ${member.email} is not in project ${member.project.slug} anymore, but purge is disabled`) - } - } - } - - return pluginResult.getResultObject() - } catch (error) { - return pluginResult.returnUnexpectedError(error) - } -} - -export const deleteProjectMember: StepCall = async ({ args: member }) => { - const pluginResult = new PluginResultBuilder('Deleted') - try { - const kcClient = await getkcClient() - if (!member.userId) return pluginResult.getResultObject() - - const projectGroup = await getGroupByName(kcClient, member.project.slug) - if (!projectGroup?.id) return pluginResult.getResultObject() - - const userGroups = await kcClient.users.listGroups({ id: member.userId }) - const projectGroups = userGroups.filter(g => g.path?.startsWith(projectGroup.path!)) - - for (const group of projectGroups) { - if (group.id) { - await kcClient.users.delFromGroup({ id: member.userId, groupId: group.id }) - } - } - - return pluginResult.getResultObject() - } catch (error) { - return pluginResult.returnUnexpectedError(error) - } -} - function getClientZoneId(zone: ZoneObject): string { return `argocd-${zone.slug}-zone` } diff --git a/plugins/keycloak/src/index.ts b/plugins/keycloak/src/index.ts index 063d67155c..424081318c 100644 --- a/plugins/keycloak/src/index.ts +++ b/plugins/keycloak/src/index.ts @@ -1,16 +1,12 @@ import type { DefaultArgs, Plugin, Project, ProjectLite } from '@cpn-console/hooks' import { deleteProject, - deleteProjectRole, deleteZone, retrieveKeycloakUserByEmail, upsertProject, - upsertProjectRole, upsertZone, upsertAdminRole, deleteAdminRole, - upsertProjectMember, - deleteProjectMember, } from './functions.js' import infos from './infos.js' import monitor from './monitor.js' @@ -28,24 +24,12 @@ export const plugin: Plugin = { api: project => new KeycloakProjectApi(project.slug), steps: { main: upsertProject }, }, - upsertProjectRole: { - steps: { main: upsertProjectRole }, - }, - upsertProjectMember: { - steps: { main: upsertProjectMember }, - }, - deleteProjectMember: { - steps: { post: deleteProjectMember }, - }, upsertZone: { steps: { main: upsertZone }, }, deleteZone: { steps: { post: deleteZone }, }, - deleteProjectRole: { - steps: { post: deleteProjectRole }, - }, retrieveUserByEmail: { steps: { main: retrieveKeycloakUserByEmail } }, upsertAdminRole: { steps: { main: upsertAdminRole },