From 0939c9f4e3638d6575e31f4e3fda4e41cd6d3315 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Mon, 9 Mar 2026 16:48:04 +0100 Subject: [PATCH] feat(gitlab): add builtin roles to GitLab Co-authered-by: William Phetsinorath Signed-off-by: William Phetsinorath Change-Id: Ide088123b0460ae559e3d124f76487dc6a6a6964 --- apps/server/src/utils/hook-wrapper.spec.ts | 6 +- apps/server/src/utils/hook-wrapper.ts | 56 ++++-- .../hooks/src/hooks/hook-project-member.ts | 18 +- packages/hooks/src/hooks/hook-project-role.ts | 13 +- packages/hooks/src/hooks/hook-project.ts | 8 +- plugins/gitlab/src/class.ts | 11 +- plugins/gitlab/src/functions.ts | 160 ++++++++++++++++-- plugins/gitlab/src/index.ts | 24 ++- plugins/gitlab/src/infos.ts | 104 ++++++++++-- plugins/gitlab/src/members.ts | 93 ++++++---- plugins/gitlab/src/user.ts | 5 +- plugins/gitlab/src/utils.ts | 4 + plugins/keycloak/src/functions.ts | 6 +- 13 files changed, 416 insertions(+), 92 deletions(-) diff --git a/apps/server/src/utils/hook-wrapper.spec.ts b/apps/server/src/utils/hook-wrapper.spec.ts index d7d1bdc3ab..5a41bbf39b 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 }) => ({ diff --git a/apps/server/src/utils/hook-wrapper.ts b/apps/server/src/utils/hook-wrapper.ts index 933ac6692f..ee427ee367 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) @@ -331,10 +349,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 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/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/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..ec2b57b653 100644 --- a/plugins/gitlab/src/functions.ts +++ b/plugins/gitlab/src/functions.ts @@ -1,13 +1,17 @@ import { okStatus, parseError, specificallyDisabled } from '@cpn-console/hooks' -import type { ClusterObject, PluginResult, Project, ProjectLite, StepCall, UniqueRepo, ZoneObject } from '@cpn-console/hooks' -import { insert } from '@cpn-console/shared' +import type { AdminRole, ClusterObject, PluginResult, Project, ProjectLite, StepCall, UniqueRepo, ZoneObject, ProjectMember } from '@cpn-console/hooks' import { deleteGroup } from './group.js' -import { createUsername, getUser } from './user.js' -import { ensureMembers } from './members.js' +import { createUsername, getUser, upsertUser } from './user.js' +import { ensureGroup } 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 } from './utils.js' +import { + DEFAULT_ADMIN_GROUP_PATH, + DEFAULT_AUDITOR_GROUP_PATH, +} from './infos.js' // Check export const checkApi: StepCall = async (payload) => { @@ -100,11 +104,9 @@ export const upsertDsoProject: StepCall = async (payload) => { await gitlabApi.getOrCreateProjectGroup() - const { failedInUpsertUsers } = await ensureMembers(gitlabApi, project) - if (failedInUpsertUsers) { - returnResult.status.result = 'WARNING' - returnResult.warnReasons = insert(returnResult.warnReasons, 'Failed to create or upsert users in Gitlab') - } + await Promise.all(project.users.map(user => + ensureGroup(gitlabApi, project, user, payload.config), + )) const projectMirrorCreds = await gitlabApi.getProjectMirrorCreds(vaultApi) await ensureRepositories(gitlabApi, project, vaultApi, { @@ -232,3 +234,141 @@ 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 + + try { + await Promise.all(member.project.users.map(user => + ensureGroup(gitlabApi, member.project, user, payload.config), + )) + + 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..38f5d10eac 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,/console/devops' +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/members.ts b/plugins/gitlab/src/members.ts index ec77ee45dc..2ad8dd5c44 100644 --- a/plugins/gitlab/src/members.ts +++ b/plugins/gitlab/src/members.ts @@ -1,42 +1,67 @@ -import type { Project } from '@cpn-console/hooks' -import type { UserSchema } from '@gitbeaker/core' +import type { UserObject, Config, Project } from '@cpn-console/hooks' +import { AccessLevel } from '@gitbeaker/core' +import { matchRole } from './utils.js' +import { + DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX, +} from './infos.js' import type { GitlabProjectApi } from './class.js' -import { upsertUser } from './user.js' +import { createUsername, upsertUser } from './user.js' -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(), - ]) +export function getGroupAccessLevelFromProjectRole(project: Project, user: UserObject, config: Config) { + const projectReporterGroupPathSuffixes = (config.gitlab?.projectReporterGroupPathSuffix ?? DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX).split(',') + const projectDeveloperGroupPathSuffixes = (config.gitlab?.projectDeveloperGroupPathSuffix ?? DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX).split(',') + const projectMaintainerGroupPathSuffixes = (config.gitlab?.projectMaintainerGroupPathSuffix ?? DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX).split(',') - interface FulfilledResult { status: 'fulfilled', value: UserSchema } - interface RejectedResult { status: 'rejected', reason: any } + const getAccessLevel = (role: any): number | null => { + if (!role.oidcGroup) return null + if (matchRole(project.slug, role.oidcGroup, projectReporterGroupPathSuffixes)) return AccessLevel.REPORTER + if (matchRole(project.slug, role.oidcGroup, projectDeveloperGroupPathSuffixes)) return AccessLevel.DEVELOPER + if (matchRole(project.slug, role.oidcGroup, projectMaintainerGroupPathSuffixes)) return AccessLevel.MAINTAINER + return null + } + + return project.roles.reduce((highestAccessLevel, role) => { + if (role.users.some(userRole => userRole.id === user.id)) { + const level = getAccessLevel(role) + if (level && level > (highestAccessLevel ?? 0)) return level + } + return highestAccessLevel + }, null) +} + +export function getGroupAccessLevel(project: Project, user: UserObject, config: Config): number | null { + if (project.owner.id === user.id) return AccessLevel.OWNER + return getGroupAccessLevelFromProjectRole(project, user, config) +} - const fulfilledGitlabUsers = gitlabUserPromiseResults - .filter((result): result is FulfilledResult => result.status === 'fulfilled') +export async function ensureGroup( + gitlabApi: GitlabProjectApi, + project: Project, + user: UserObject, + config: Config, +) { + const gitlabUser = await upsertUser({ + id: user.id, + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + }) - const rejectedGitlabUsers = gitlabUserPromiseResults - .filter((result): result is RejectedResult => result.status === 'rejected') + const groupMembers = await gitlabApi.getGroupMembers() + const existingMember = groupMembers.find(m => m.username === createUsername(user.email)) + const maxAccessLevel = getGroupAccessLevel(project, user, config) - // 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, - ), - ]) - return { - members: [...members, membersAdded.filter(member => member)], - failedInUpsertUsers: !!rejectedGitlabUsers.length, + if (maxAccessLevel) { + if (existingMember) { + if (existingMember.access_level !== maxAccessLevel) { + await gitlabApi.editGroupMember(gitlabUser.id, maxAccessLevel) + } + } else { + await gitlabApi.addGroupMember(gitlabUser.id, maxAccessLevel) + } + } else { + await gitlabApi.removeGroupMember(gitlabUser.id) } } diff --git a/plugins/gitlab/src/user.ts b/plugins/gitlab/src/user.ts index 48efa5b498..7528591176 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?: boolean, isAuditor?: boolean): 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) { @@ -59,7 +61,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..4c27dff37b 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 configuredRolePath.some(path => roleOidcGroup === `/${projectSlug}${path}`) +} + 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 2aa566c6e0..c07a2f9c50 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 } from '@cpn-console/hooks' import type GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation.js' @@ -376,7 +376,7 @@ export const deleteProjectRole: StepCall = async ({ args: role }) = } } -export const upsertProjectMember: StepCall = async ({ args: member }) => { +export const upsertProjectMember: StepCall = async ({ args: member }) => { const pluginResult = new PluginResultBuilder('Synced') try { const kcClient = await getkcClient() @@ -409,7 +409,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()