Skip to content

Commit dfe7ea0

Browse files
committed
feat(gitlab): add builtin roles to GitLab
Co-authered-by: William Phetsinorath <william.phetsinorath@shikanime.studio> Signed-off-by: William Phetsinorath <william.phetsinorath-open@interieur.gouv.fr>
1 parent 8bee63d commit dfe7ea0

10 files changed

Lines changed: 379 additions & 45 deletions

File tree

apps/server/src/utils/hook-wrapper.ts

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -142,14 +142,21 @@ const user = {
142142
const projectMember = {
143143
upsert: async (projectId: Project['id'], userId: ProjectMembers['userId']) => {
144144
const project = await getHookProjectInfos(projectId)
145+
const projectStore = dbToObj(await getProjectStore(project.id))
146+
const hookProject = transformToHookProject(project, projectStore)
145147
const store = dbToObj(await getAdminPlugin())
146148

147149
const member = project.members.find(m => m.userId === userId)
148150
if (!member) throw new Error('Member not found')
149151

150152
const memberRoles = project.roles
151153
.filter(role => member.roleIds.includes(role.id))
152-
.map(role => ({ ...role, permissions: role.permissions.toString(), oidcGroup: role.oidcGroup ?? undefined }))
154+
.map(role => ({
155+
...role,
156+
permissions: role.permissions.toString(),
157+
oidcGroup: role.oidcGroup ?? undefined,
158+
project: hookProject,
159+
}))
153160

154161
const payload = {
155162
userId: member.userId,
@@ -163,24 +170,28 @@ const projectMember = {
163170
lastLogin: member.user.lastLogin?.toISOString(),
164171
projectId: project.id,
165172
roles: memberRoles,
166-
project: {
167-
id: project.id,
168-
slug: project.slug,
169-
},
173+
project: hookProject,
170174
}
171175

172176
return hooks.upsertProjectMember.execute(payload, store)
173177
},
174178
delete: async (projectId: Project['id'], userId: ProjectMembers['userId']) => {
175179
const project = await getHookProjectInfos(projectId)
180+
const projectStore = dbToObj(await getProjectStore(project.id))
181+
const hookProject = transformToHookProject(project, projectStore)
176182
const store = dbToObj(await getAdminPlugin())
177183

178184
const member = project.members.find(m => m.userId === userId)
179185
if (!member) throw new Error('Member not found')
180186

181187
const memberRoles = project.roles
182188
.filter(role => member.roleIds.includes(role.id))
183-
.map(role => ({ ...role, permissions: role.permissions.toString(), oidcGroup: role.oidcGroup ?? undefined }))
189+
.map(role => ({
190+
...role,
191+
permissions: role.permissions.toString(),
192+
oidcGroup: role.oidcGroup ?? undefined,
193+
project: hookProject,
194+
}))
184195

185196
const payload = {
186197
userId: member.userId,
@@ -194,10 +205,7 @@ const projectMember = {
194205
lastLogin: member.user.lastLogin?.toISOString(),
195206
projectId: project.id,
196207
roles: memberRoles,
197-
project: {
198-
id: project.id,
199-
slug: project.slug,
200-
},
208+
project: hookProject,
201209
}
202210

203211
return hooks.deleteProjectMember.execute(payload, store)
@@ -209,9 +217,14 @@ const projectRole = {
209217
const role = await getRole(roleId)
210218
if (!role) throw new Error('Role not found')
211219

220+
const project = await getHookProjectInfos(role.projectId)
221+
const projectStore = dbToObj(await getProjectStore(role.projectId))
222+
const hookProject = transformToHookProject(project, projectStore)
223+
212224
const rolePayload = {
213225
...role,
214226
permissions: role.permissions.toString(),
227+
project: hookProject,
215228
}
216229
const store = dbToObj(await getAdminPlugin())
217230
return hooks.upsertProjectRole.execute(rolePayload, store)
@@ -220,9 +233,14 @@ const projectRole = {
220233
const role = await getRole(roleId)
221234
if (!role) throw new Error('Role not found')
222235

236+
const project = await getHookProjectInfos(role.projectId)
237+
const projectStore = dbToObj(await getProjectStore(role.projectId))
238+
const hookProject = transformToHookProject(project, projectStore)
239+
223240
const rolePayload = {
224241
...role,
225242
permissions: role.permissions.toString(),
243+
project: hookProject,
226244
}
227245
const store = dbToObj(await getAdminPlugin())
228246
return hooks.deleteProjectRole.execute(rolePayload, store)
Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
1-
import type { ProjectMember, ProjectRole } from '@cpn-console/shared'
1+
import type { ProjectRole } from './hook-project-role.js'
2+
import type { Project } from './hook-project.js'
23
import type { Hook } from './hook.js'
34
import { createHook } from './hook.js'
45

5-
export type ProjectMemberPayload = ProjectMember & {
6+
export interface ProjectMember {
7+
userId: string
8+
email: string
9+
firstName: string
10+
lastName: string
611
roles: ProjectRole[]
7-
project: {
8-
id: string
9-
slug: string
10-
}
12+
project: Project
1113
}
1214

13-
export const upsertProjectMember: Hook<ProjectMemberPayload> = createHook()
14-
export const deleteProjectMember: Hook<ProjectMemberPayload> = createHook()
15+
export const upsertProjectMember: Hook<ProjectMember> = createHook()
16+
export const deleteProjectMember: Hook<ProjectMember> = createHook()
Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,17 @@
1-
import type { ProjectRole } from '@cpn-console/shared'
1+
import type { Project } from './hook-project.js'
22
import type { Hook } from './hook.js'
33
import { createHook } from './hook.js'
44

5+
export interface ProjectRole {
6+
id: string
7+
name: string
8+
permissions: string
9+
projectId: string
10+
position: number
11+
type?: string
12+
oidcGroup?: string
13+
project: Project
14+
}
15+
516
export const upsertProjectRole: Hook<ProjectRole> = createHook()
617
export const deleteProjectRole: Hook<ProjectRole> = createHook()

plugins/gitlab/src/class.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { createHash } from 'node:crypto'
2-
import { PluginApi, type Project, type UniqueRepo } from '@cpn-console/hooks'
2+
import { PluginApi, type Project, type UniqueRepo, type ProjectMember } from '@cpn-console/hooks'
33
import type { AccessTokenScopes, CommitAction, GroupSchema, MemberSchema, ProjectVariableSchema, VariableSchema, AllRepositoryTreesOptions, CondensedProjectSchema, Gitlab, ProjectSchema, RepositoryFileExpandedSchema } from '@gitbeaker/core'
44
import { AccessLevel } from '@gitbeaker/core'
55
import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/vault-project-api.js'
@@ -234,12 +234,12 @@ export class GitlabZoneApi extends GitlabApi {
234234
}
235235

236236
export class GitlabProjectApi extends GitlabApi {
237-
private project: Project | UniqueRepo
237+
private project: Project | UniqueRepo | ProjectMember['project']
238238
private gitlabGroup: GroupSchema | undefined
239239
private specialRepositories: string[] = [infraAppsRepoName, internalMirrorRepoName]
240240
private zoneApi: GitlabZoneApi
241241

242-
constructor(project: Project | UniqueRepo) {
242+
constructor(project: Project | UniqueRepo | ProjectMember['project']) {
243243
super()
244244
this.project = project
245245
this.api = getApi()
@@ -434,6 +434,11 @@ export class GitlabProjectApi extends GitlabApi {
434434
return this.api.GroupMembers.add(group.id, userId, accessLevel)
435435
}
436436

437+
public async editGroupMember(userId: number, accessLevel: AccessLevelAllowed = AccessLevel.DEVELOPER): Promise<MemberSchema> {
438+
const group = await this.getOrCreateProjectGroup()
439+
return this.api.GroupMembers.edit(group.id, userId, accessLevel)
440+
}
441+
437442
public async removeGroupMember(userId: number) {
438443
const group = await this.getOrCreateProjectGroup()
439444
return this.api.GroupMembers.remove(group.id, userId)

plugins/gitlab/src/functions.ts

Lines changed: 197 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,22 @@
1-
import { okStatus, parseError, specificallyDisabled } from '@cpn-console/hooks'
2-
import type { ClusterObject, PluginResult, Project, ProjectLite, StepCall, UniqueRepo, ZoneObject } from '@cpn-console/hooks'
1+
import { okStatus, parseError, specificallyDisabled, specificallyEnabled } from '@cpn-console/hooks'
2+
import type { AdminRole, ClusterObject, PluginResult, Project, ProjectLite, StepCall, UniqueRepo, ZoneObject, ProjectMember } from '@cpn-console/hooks'
33
import { insert } from '@cpn-console/shared'
4+
import { AccessLevel } from '@gitbeaker/core'
45
import { deleteGroup } from './group.js'
5-
import { createUsername, getUser } from './user.js'
6+
import { createUsername, getUser, upsertUser } from './user.js'
67
import { ensureMembers } from './members.js'
78
import { ensureRepositories } from './repositories.js'
89
import type { VaultSecrets } from './utils.js'
9-
import { cleanGitlabError } from './utils.js'
1010
import config from './config.js'
11+
import type { GitlabProjectApi } from './class.js'
12+
import { cleanGitlabError, matchRole } from './utils.js'
13+
import {
14+
DEFAULT_ADMIN_GROUP_PATH,
15+
DEFAULT_AUDITOR_GROUP_PATH,
16+
DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX,
17+
DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX,
18+
DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX,
19+
} from './infos.js'
1120

1221
// Check
1322
export const checkApi: StepCall<Project> = async (payload) => {
@@ -232,3 +241,187 @@ export const commitFiles: StepCall<UniqueRepo | Project | ClusterObject | ZoneOb
232241
return returnResult
233242
}
234243
}
244+
245+
export const upsertAdminRole: StepCall<AdminRole> = async (payload) => {
246+
try {
247+
const role = payload.args
248+
const adminGroupPath = payload.config.gitlab?.adminGroupPath ?? DEFAULT_ADMIN_GROUP_PATH
249+
const auditorGroupPath = payload.config.gitlab?.auditorGroupPath ?? DEFAULT_AUDITOR_GROUP_PATH
250+
251+
const isAdmin = role.oidcGroup === adminGroupPath ? true : undefined
252+
const isAuditor = role.oidcGroup === auditorGroupPath ? true : undefined
253+
254+
if (isAdmin === undefined && isAuditor === undefined) {
255+
return {
256+
status: {
257+
result: 'OK',
258+
message: 'Not a managed role for GitLab plugin',
259+
},
260+
}
261+
}
262+
263+
for (const member of role.members) {
264+
await upsertUser(member, isAdmin, isAuditor)
265+
}
266+
267+
return {
268+
status: {
269+
result: 'OK',
270+
message: 'Members synced',
271+
},
272+
}
273+
} catch (error) {
274+
return {
275+
error: parseError(cleanGitlabError(error)),
276+
status: {
277+
result: 'KO',
278+
message: 'An error occured while syncing admin role',
279+
},
280+
}
281+
}
282+
}
283+
284+
export const deleteAdminRole: StepCall<AdminRole> = async (payload) => {
285+
try {
286+
const role = payload.args
287+
const adminGroupPath = payload.config.gitlab?.adminGroupPath ?? DEFAULT_ADMIN_GROUP_PATH
288+
const auditorGroupPath = payload.config.gitlab?.auditorGroupPath ?? DEFAULT_AUDITOR_GROUP_PATH
289+
290+
const isAdmin = role.oidcGroup === adminGroupPath ? false : undefined
291+
const isAuditor = role.oidcGroup === auditorGroupPath ? false : undefined
292+
293+
if (isAdmin === undefined && isAuditor === undefined) {
294+
return {
295+
status: {
296+
result: 'OK',
297+
message: 'Not a managed role for GitLab plugin',
298+
},
299+
}
300+
}
301+
302+
for (const member of role.members) {
303+
await upsertUser(member, isAdmin, isAuditor)
304+
}
305+
306+
return {
307+
status: {
308+
result: 'OK',
309+
message: 'Admin role deleted and members synced',
310+
},
311+
}
312+
} catch (error) {
313+
return {
314+
error: parseError(cleanGitlabError(error)),
315+
status: {
316+
result: 'KO',
317+
message: 'An error occured while deleting admin role',
318+
},
319+
}
320+
}
321+
}
322+
323+
export const upsertProjectMember: StepCall<ProjectMember> = async (payload) => {
324+
const member = payload.args
325+
const { gitlab: gitlabApi } = payload.apis as { gitlab: GitlabProjectApi } // TODO: apis is never type for some resaon
326+
const purge = payload.config.gitlab?.purge
327+
const projectReporterGroupPathSuffix = payload.config.gitlab?.projectReporterGroupPathSuffix ?? DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX
328+
const projectDeveloperGroupPathSuffix = payload.config.gitlab?.projectDeveloperGroupPathSuffix ?? DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX
329+
const projectMaintainerGroupPathSuffix = payload.config.gitlab?.projectMaintainerGroupPathSuffix ?? DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX
330+
331+
try {
332+
const gitlabUser = await upsertUser({
333+
id: member.userId,
334+
firstName: member.firstName,
335+
lastName: member.lastName,
336+
email: member.email,
337+
})
338+
339+
let maxAccessLevel: number | undefined
340+
341+
if (member.project.owner.id === member.userId) {
342+
maxAccessLevel = AccessLevel.OWNER
343+
} else if (member.roles.find(role => role.oidcGroup && matchRole(member.project.slug, role.oidcGroup, projectReporterGroupPathSuffix))) {
344+
maxAccessLevel = AccessLevel.GUEST
345+
} else if (member.roles.find(role => role.oidcGroup && matchRole(member.project.slug, role.oidcGroup, projectDeveloperGroupPathSuffix))) {
346+
maxAccessLevel = AccessLevel.DEVELOPER
347+
} else if (member.roles.find(role => role.oidcGroup && matchRole(member.project.slug, role.oidcGroup, projectMaintainerGroupPathSuffix))) {
348+
maxAccessLevel = AccessLevel.MAINTAINER
349+
}
350+
351+
const groupMembers = await gitlabApi.getGroupMembers()
352+
const existingMember = groupMembers.find(m => m.id === gitlabUser.id)
353+
354+
if (maxAccessLevel === undefined) {
355+
if (specificallyEnabled(purge)) {
356+
if (existingMember) {
357+
await gitlabApi.removeGroupMember(gitlabUser.id)
358+
}
359+
return {
360+
status: {
361+
result: 'OK',
362+
message: 'Member has no matching roles, removed from group',
363+
},
364+
}
365+
} else {
366+
console.warn(`Member ${gitlabUser.username} has no matching roles, not synced`)
367+
}
368+
}
369+
370+
if (existingMember) {
371+
if (existingMember.access_level !== maxAccessLevel) {
372+
await gitlabApi.editGroupMember(gitlabUser.id, maxAccessLevel)
373+
}
374+
} else {
375+
await gitlabApi.addGroupMember(gitlabUser.id, maxAccessLevel)
376+
}
377+
378+
return {
379+
status: {
380+
result: 'OK',
381+
message: 'Member synced',
382+
},
383+
}
384+
} catch (error) {
385+
return {
386+
error: parseError(cleanGitlabError(error)),
387+
status: {
388+
result: 'KO',
389+
message: 'An error happened while syncing project member',
390+
},
391+
}
392+
}
393+
}
394+
395+
export const deleteProjectMember: StepCall<ProjectMember> = async (payload) => {
396+
const member = payload.args
397+
const { gitlab: gitlabApi } = payload.apis as { gitlab: GitlabProjectApi } // TODO: apis is never type for some resaon
398+
399+
try {
400+
const userInfos = await getUser({ ...member, id: member.userId, username: createUsername(member.email) })
401+
if (!userInfos) {
402+
return {
403+
status: {
404+
result: 'OK',
405+
message: 'User not found in GitLab',
406+
},
407+
}
408+
}
409+
410+
await gitlabApi.removeGroupMember(userInfos.id)
411+
412+
return {
413+
status: {
414+
result: 'OK',
415+
message: 'Member deleted',
416+
},
417+
}
418+
} catch (error) {
419+
return {
420+
error: parseError(cleanGitlabError(error)),
421+
status: {
422+
result: 'KO',
423+
message: 'An error happened while deleting project member',
424+
},
425+
}
426+
}
427+
}

0 commit comments

Comments
 (0)