Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions apps/server-nestjs/src/modules/argocd/argocd-plugin.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import {
DEFAULT_DSO_NS_CHART_VERSION,
PLATFORM_ADMIN_GROUP_PATH,
PLATFORM_READONLY_GROUP_PATH,
PLATFORM_SECURITY_GROUP_PATH,
PROJECT_ADMIN_GROUP_PATH_SUFFIX,
PROJECT_DEVELOPER_GROUP_PATH_SUFFIX,
PROJECT_DEVOPS_GROUP_PATH_SUFFIX,
PROJECT_READONLY_GROUP_PATH_SUFFIX,
PROJECT_SECURITY_GROUP_PATH_SUFFIX,
} from './argocd.constant'

@Injectable()
Expand Down Expand Up @@ -57,6 +59,16 @@ export class ArgoCDPluginService {
title: 'Platform Readonly Group Path',
value: PLATFORM_READONLY_GROUP_PATH,
description: 'Chemin du groupe lecture seule de plateforme',
}, {
key: 'platformSecurityGroupPath',
kind: 'text',
permissions: {
admin: { read: true, write: true },
user: { read: false, write: false },
},
title: 'Platform Security Group Path',
value: PLATFORM_SECURITY_GROUP_PATH,
description: 'Chemin du groupe sécurité de plateforme',
}, {
key: 'projectAdminGroupPathSuffix',
kind: 'text',
Expand Down Expand Up @@ -97,6 +109,16 @@ export class ArgoCDPluginService {
title: 'Project Readonly Group Path Suffix',
value: PROJECT_READONLY_GROUP_PATH_SUFFIX,
description: 'Suffixe du chemin du groupe lecture seule de projet',
}, {
key: 'projectSecurityGroupPathSuffix',
kind: 'text',
permissions: {
admin: { read: true, write: true },
user: { read: false, write: false },
},
title: 'Project Security Group Path Suffix',
value: PROJECT_SECURITY_GROUP_PATH_SUFFIX,
description: 'Suffixe du chemin du groupe sécurité de projet',
}, {
key: 'dsoEnvChartVersion',
kind: 'text',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import {
TOPIC_PLUGIN_MANAGED,
USER_ID_CUSTOM_ATTRIBUTE_KEY,
} from './gitlab.constants'
import { hasFileContentChanged, generateGitlabCIConfigContent, generateMirrorScriptContent } from './gitlab.utils'
import { generateGitlabCIConfigContent, generateMirrorScriptContent, hasFileContentChanged } from './gitlab.utils'

export const GITLAB_REST_CLIENT = Symbol('GITLAB_REST_CLIENT')

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,7 @@ import type { ServiceInfos } from '@cpn-console/hooks'
import { DISABLED, ENABLED } from '@cpn-console/shared'
import { Inject, Injectable } from '@nestjs/common'
import { ConfigurationService } from '../infrastructure/configuration/configuration.service'

const DEFAULT_ADMIN_GROUP_PATH = '/console/admin'
const DEFAULT_AUDITOR_GROUP_PATH = '/console/readonly'
const DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX = '/console/admin'
const DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX = '/console/developer,/console/devops'
const DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX = '/console/readonly'
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 './gitlab.constants'

@Injectable()
export class GitlabPluginService {
Expand Down
8 changes: 4 additions & 4 deletions apps/server-nestjs/src/modules/gitlab/gitlab.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ export const TOKEN_DESCRIPTION = 'mirroring-from-external-repo'

// Default group paths for console roles
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'
export const DEFAULT_AUDITOR_GROUP_PATH = '/console/readonly,/console/security'
export const DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX = '/console/admin,/console/devops'
export const DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX = '/console/developer'
export const DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX = '/console/readonly,/console/security'

// Plugin configuration keys
export const ADMIN_GROUP_PATH_PLUGIN_KEY = 'adminGroupPath'
Expand Down
74 changes: 69 additions & 5 deletions apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,14 +233,16 @@ describe('gitlabService', () => {
roles: [
{ id: 'r-reporter', oidcGroup: '/project-1/console/readonly' },
{ id: 'r-developer', oidcGroup: '/project-1/console/developer' },
{ id: 'r-devops', oidcGroup: '/project-1/console/devops' },
{ id: 'r-maintainer', oidcGroup: '/project-1/console/admin' },
{ id: 'r-unknown', oidcGroup: '/other/group' },
],
members: [
{ user: { id: 'u1', email: 'reporter@example.com', firstName: 'Rep', lastName: 'User', adminRoleIds: [] }, roleIds: ['r-reporter'] },
{ user: { id: 'u2', email: 'developer@example.com', firstName: 'Dev', lastName: 'User', adminRoleIds: [] }, roleIds: ['r-developer'] },
{ user: { id: 'u3', email: 'maintainer@example.com', firstName: 'Main', lastName: 'User', adminRoleIds: [] }, roleIds: ['r-maintainer'] },
{ user: { id: 'u4', email: 'mixed@example.com', firstName: 'Mixed', lastName: 'User', adminRoleIds: [] }, roleIds: ['r-reporter', 'r-developer'] },
{ user: { id: 'u3', email: 'devops@example.com', firstName: 'Ops', lastName: 'User', adminRoleIds: [] }, roleIds: ['r-devops'] },
{ user: { id: 'u4', email: 'maintainer@example.com', firstName: 'Main', lastName: 'User', adminRoleIds: [] }, roleIds: ['r-maintainer'] },
{ user: { id: 'u5', email: 'mixed@example.com', firstName: 'Mixed', lastName: 'User', adminRoleIds: [] }, roleIds: ['r-reporter', 'r-developer'] },
],
})
const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 })
Expand All @@ -251,8 +253,9 @@ describe('gitlabService', () => {
const idByEmail: Record<string, number> = {
'reporter@example.com': 101,
'developer@example.com': 102,
'maintainer@example.com': 103,
'mixed@example.com': 104,
'devops@example.com': 103,
'maintainer@example.com': 104,
'mixed@example.com': 105,
'owner@example.com': 100,
}
return makeExpandedUserSchema({
Expand All @@ -271,7 +274,68 @@ describe('gitlabService', () => {
expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 101, AccessLevel.REPORTER)
expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 102, AccessLevel.DEVELOPER)
expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 103, AccessLevel.MAINTAINER)
expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 104, AccessLevel.DEVELOPER)
expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 104, AccessLevel.MAINTAINER)
expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 105, AccessLevel.DEVELOPER)
})

it('should prioritize higher access level when oidc group appears in multiple paths', async () => {
const project = makeProjectWithDetails({
roles: [
{ id: 'r-devops', oidcGroup: '/project-1/console/devops' },
],
members: [
{ user: { id: 'u1', email: 'devops@example.com', firstName: 'Ops', lastName: 'User', adminRoleIds: [] }, roleIds: ['r-devops'] },
],
})
const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 })

gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group)
gitlab.getGroupMembers.mockResolvedValue([])
gitlab.upsertUser.mockImplementation(async (user) => {
return makeExpandedUserSchema({
id: user.email === 'devops@example.com' ? 101 : 100,
email: user.email,
username: user.email.split('@')[0] ?? user.email,
name: user.name,
})
})
gitlab.getRepos.mockReturnValue((async function* () { })())
gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false }))
gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken())

await service.handleUpsert(project)

expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 101, AccessLevel.MAINTAINER)
})

it('should map security project role to reporter access level', async () => {
const project = makeProjectWithDetails({
roles: [
{ id: 'r-security', oidcGroup: '/project-1/console/security' },
],
members: [
{ user: { id: 'u1', email: 'security@example.com', firstName: 'Sec', lastName: 'User', adminRoleIds: [] }, roleIds: ['r-security'] },
],
})
const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 })

gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group)
gitlab.getGroupMembers.mockResolvedValue([])
gitlab.upsertUser.mockImplementation(async (user) => {
return makeExpandedUserSchema({
id: user.email === 'security@example.com' ? 105 : 100,
email: user.email,
username: user.email.split('@')[0] ?? user.email,
name: user.name,
})
})
gitlab.getRepos.mockReturnValue((async function* () { })())
gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false }))
gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken())

await service.handleUpsert(project)

expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 105, AccessLevel.REPORTER)
})

it('should downgrade existing member to guest when no role maps to an access level', async () => {
Expand Down
19 changes: 10 additions & 9 deletions apps/server-nestjs/src/modules/gitlab/gitlab.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,24 +65,25 @@ export function generateAccessLevelMapping(
): Map<string, ProjectAccessLevel> {
const getAccessLevelFromOidcGroup = (oidcGroup: string | null): ProjectAccessLevel | null => {
if (!oidcGroup) return null
if (groupPaths.reporter.includes(oidcGroup)) return AccessLevel.REPORTER
if (groupPaths.developer.includes(oidcGroup)) return AccessLevel.DEVELOPER
if (groupPaths.maintainer.includes(oidcGroup)) return AccessLevel.MAINTAINER
if (groupPaths.developer.includes(oidcGroup)) return AccessLevel.DEVELOPER
if (groupPaths.reporter.includes(oidcGroup)) return AccessLevel.REPORTER
return null
}

const roleAccessLevelById = new Map<string, ProjectAccessLevel | null>(
project.roles.map(role => [role.id, getAccessLevelFromOidcGroup(role.oidcGroup)]),
)

return new Map<string, ProjectAccessLevel>(project.members.map((membership) => {
let highest: ProjectAccessLevel | null = null
for (const roleId of membership.roleIds) {
return project.members.reduce((acc, membership) => {
const highest = membership.roleIds.reduce((highest: ProjectAccessLevel | null, roleId) => {
const level = roleAccessLevelById.get(roleId)
if (level !== null && level !== undefined && (highest === null || level > highest)) highest = level
}
return [membership.user.id, highest ?? AccessLevel.GUEST] as const
}))
if (level !== null && level !== undefined && (highest === null || level > highest)) return level
return highest
}, null)
acc.set(membership.user.id, highest ?? AccessLevel.GUEST)
return acc
}, new Map<string, ProjectAccessLevel>())
}

export function generateGitlabCIConfigContent() {
Expand Down
48 changes: 48 additions & 0 deletions apps/server-nestjs/src/modules/nexus/nexus-plugin.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,54 @@ export class NexusPluginService {
},
],
global: [
{
key: 'platformWriteGroupPaths',
kind: 'text',
permissions: {
admin: { read: true, write: true },
user: { read: false, write: false },
},
title: 'Chemins des groupes OIDC plateforme en écriture',
value: '/console/admin',
description: 'Liste séparée par des virgules des chemins des groupes OIDC ayant accès en écriture aux dépôts Nexus de la plateforme',
placeholder: '/console/admin',
},
{
key: 'platformReadGroupPaths',
kind: 'text',
permissions: {
admin: { read: true, write: true },
user: { read: false, write: false },
},
title: 'Chemins des groupes OIDC plateforme en lecture',
value: '/console/readonly,/console/security',
description: 'Liste séparée par des virgules des chemins des groupes OIDC ayant accès en lecture aux dépôts Nexus de la plateforme',
placeholder: '/console/readonly,/console/security',
},
{
key: 'projectWriteGroupPathSuffixes',
kind: 'text',
permissions: {
admin: { read: true, write: true },
user: { read: false, write: false },
},
title: 'Suffixes des groupes OIDC projet en écriture',
value: '/console/admin,/console/devops',
description: 'Liste séparée par des virgules des suffixes des chemins des groupes OIDC ayant accès en écriture aux dépôts Nexus du projet',
placeholder: '/console/admin,/console/devops',
},
{
key: 'projectReadGroupPathSuffixes',
kind: 'text',
permissions: {
admin: { read: true, write: true },
user: { read: false, write: false },
},
title: 'Suffixes des groupes OIDC projet en lecture',
value: '/console/readonly,/console/security,/console/developer',
description: 'Liste séparée par des virgules des suffixes des chemins des groupes OIDC ayant accès en lecture aux dépôts Nexus du projet',
placeholder: '/console/readonly,/console/security,/console/developer',
},
{
key: 'activateNpmRepoDefaultValue',
section: 'NPM',
Expand Down
4 changes: 2 additions & 2 deletions apps/server-nestjs/src/modules/nexus/nexus.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ export const DEFAULT_PLATFORM_WRITE_GROUP_PATHS = '/console/admin'
export const DEFAULT_PLATFORM_READ_GROUP_PATHS = '/console/readonly,/console/security'

// Default group path suffixes granting write and read access at the project level
export const DEFAULT_PROJECT_WRITE_GROUP_PATH_SUFFIXES = '/console/admin,/console/devops,/console/developer'
export const DEFAULT_PROJECT_READ_GROUP_PATH_SUFFIXES = '/console/readonly,/console/security'
export const DEFAULT_PROJECT_WRITE_GROUP_PATH_SUFFIXES = '/console/admin,/console/devops'
export const DEFAULT_PROJECT_READ_GROUP_PATH_SUFFIXES = '/console/readonly,/console/security,/console/developer'

// Plugin configuration keys for platform-level group paths
export const PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY = 'platformWriteGroupPaths'
Expand Down
18 changes: 9 additions & 9 deletions apps/server-nestjs/src/modules/registry/registry.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,9 @@ describe('registryService', () => {
{ groupName: '/console/security', roleId: 3 },
{ groupName: `/${project.slug}/console/readonly`, roleId: 3 },
{ groupName: `/${project.slug}/console/security`, roleId: 3 },
{ groupName: `/${project.slug}/console/developer`, roleId: 2 },
{ groupName: `/${project.slug}/console/devops`, roleId: 4 },
{ groupName: `/${project.slug}/console/admin`, roleId: 1 },
{ groupName: `/${project.slug}/console/developer`, roleId: 3 },
{ groupName: `/${project.slug}/console/devops`, roleId: 3 },
{ groupName: `/${project.slug}/console/admin`, roleId: 2 },
]

expect(registry.addGroupMember).toHaveBeenCalledTimes(expected.length)
Expand All @@ -145,7 +145,7 @@ describe('registryService', () => {
it('reconciles an existing group membership when role differs', async () => {
const project = makeProjectWithDetails()
registry.getGroupMembers.mockResolvedValueOnce(makeOkResponse([
{ id: 10, entity_name: `/${project.slug}/console/developer`, entity_type: 'g', role_id: 3 },
{ id: 10, entity_name: `/${project.slug}/console/admin`, entity_type: 'g', role_id: 3 },
]))

await service.handleUpsert(project)
Expand All @@ -154,16 +154,16 @@ describe('registryService', () => {
expect(registry.addGroupMember).toHaveBeenCalledWith(project.slug, {
role_id: 2,
member_group: {
group_name: `/${project.slug}/console/developer`,
group_name: `/${project.slug}/console/admin`,
group_type: 3,
},
})
})

it('throws when Maintainer membership creation fails', async () => {
it('throws when project admin membership creation fails', async () => {
const project = makeProjectWithDetails()
registry.addGroupMember.mockImplementation(async (_projectName, body) => {
if (body.member_group.group_name === `/${project.slug}/console/devops` && body.role_id === 4) {
if (body.member_group.group_name === `/${project.slug}/console/admin` && body.role_id === 2) {
return { status: 400, data: null }
}
return { status: 201, data: null }
Expand All @@ -172,9 +172,9 @@ describe('registryService', () => {
await expect(service.handleUpsert(project)).rejects.toThrow('Harbor create member failed')

expect(registry.addGroupMember).toHaveBeenCalledWith(project.slug, {
role_id: 4,
role_id: 2,
member_group: {
group_name: `/${project.slug}/console/devops`,
group_name: `/${project.slug}/console/admin`,
group_type: 3,
},
})
Expand Down
25 changes: 10 additions & 15 deletions apps/server-nestjs/src/modules/registry/registry.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ import {
HARBOR_ROLE_DEVELOPER,
HARBOR_ROLE_GUEST,
HARBOR_ROLE_LIMITED_GUEST,
HARBOR_ROLE_MAINTAINER,
HARBOR_ROLE_PROJECT_ADMIN,
PLATFORM_ADMIN_GROUP_PATH_PLUGIN_KEY,
PLATFORM_GUEST_GROUP_PATHS_PLUGIN_KEY,
Expand Down Expand Up @@ -400,23 +399,17 @@ export class RegistryService {
this.getProjectGuestGroupPaths(project),
])

const platformRoles = generateHarborAccessLevelMapping({
guest: platformGuestGroupPaths,
developer: [],
maintainer: [],
admin: platformAdminGroupPaths,
})

const projectRoles = generateHarborAccessLevelMapping({
const roles = generateHarborAccessLevelMapping({
guest: projectGuestGroupPaths,
developer: projectDeveloperGroupPaths,
maintainer: projectMaintainerGroupPaths,
admin: projectAdminGroupPaths,
platformAdmin: platformAdminGroupPaths,
platformGuest: platformGuestGroupPaths,
})
return new Map([
[`/${project.slug}`, HARBOR_ROLE_LIMITED_GUEST],
...platformRoles,
...projectRoles,
...roles,
])
}
}
Expand All @@ -436,12 +429,14 @@ function generateProjectRoleGroupPath(projectSlug: string, rawGroupPathSuffixes:
return parseGroupPaths(rawGroupPathSuffixes).map(path => `/${projectSlug}${path}`)
}

function generateHarborAccessLevelMapping(args: { guest: string[], developer: string[], maintainer: string[], admin: string[] }) {
function generateHarborAccessLevelMapping(args: { guest: string[], developer: string[], maintainer: string[], admin: string[], platformAdmin: string[], platformGuest: string[] }) {
const byGroupName = new Map<string, number>()
for (const groupName of args.guest) byGroupName.set(groupName, HARBOR_ROLE_GUEST)
for (const groupName of args.developer) byGroupName.set(groupName, HARBOR_ROLE_DEVELOPER)
for (const groupName of args.maintainer) byGroupName.set(groupName, HARBOR_ROLE_MAINTAINER)
for (const groupName of args.admin) byGroupName.set(groupName, HARBOR_ROLE_PROJECT_ADMIN)
for (const groupName of args.developer) byGroupName.set(groupName, HARBOR_ROLE_GUEST)
for (const groupName of args.maintainer) byGroupName.set(groupName, HARBOR_ROLE_GUEST)
for (const groupName of args.admin) byGroupName.set(groupName, HARBOR_ROLE_DEVELOPER)
for (const groupName of args.platformAdmin) byGroupName.set(groupName, HARBOR_ROLE_PROJECT_ADMIN)
for (const groupName of args.platformGuest) byGroupName.set(groupName, HARBOR_ROLE_GUEST)
return byGroupName
}

Expand Down
Loading