diff --git a/apps/server-nestjs/src/modules/argocd/argocd-plugin.service.ts b/apps/server-nestjs/src/modules/argocd/argocd-plugin.service.ts index 938f277208..f0b6965fc6 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd-plugin.service.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd-plugin.service.ts @@ -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() @@ -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', @@ -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', diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts b/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts index 890d1fdeb7..53841d8d13 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts @@ -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') diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.ts b/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.ts index bb743aaa86..9614fc45c9 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.ts @@ -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 { diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.constants.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.constants.ts index 8afcde1a59..8a6e435a2a 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab.constants.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.constants.ts @@ -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' diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts index 2955461f20..ec0b96efdf 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts @@ -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 }) @@ -251,8 +253,9 @@ describe('gitlabService', () => { const idByEmail: Record = { '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({ @@ -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 () => { diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.utils.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.utils.ts index a1cab2905d..63f574bd3c 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab.utils.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.utils.ts @@ -65,9 +65,9 @@ export function generateAccessLevelMapping( ): Map { 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 } @@ -75,14 +75,15 @@ export function generateAccessLevelMapping( project.roles.map(role => [role.id, getAccessLevelFromOidcGroup(role.oidcGroup)]), ) - return new Map(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()) } export function generateGitlabCIConfigContent() { diff --git a/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.ts b/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.ts index f479241f9a..a1ca54548b 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.ts @@ -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', diff --git a/apps/server-nestjs/src/modules/nexus/nexus.constants.ts b/apps/server-nestjs/src/modules/nexus/nexus.constants.ts index 218d15c79f..ba160a47ec 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus.constants.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus.constants.ts @@ -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' diff --git a/apps/server-nestjs/src/modules/registry/registry.service.spec.ts b/apps/server-nestjs/src/modules/registry/registry.service.spec.ts index 0ea8a6ec9b..66c468e5c0 100644 --- a/apps/server-nestjs/src/modules/registry/registry.service.spec.ts +++ b/apps/server-nestjs/src/modules/registry/registry.service.spec.ts @@ -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) @@ -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) @@ -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 } @@ -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, }, }) diff --git a/apps/server-nestjs/src/modules/registry/registry.service.ts b/apps/server-nestjs/src/modules/registry/registry.service.ts index 42ca8373e3..3d347b1201 100644 --- a/apps/server-nestjs/src/modules/registry/registry.service.ts +++ b/apps/server-nestjs/src/modules/registry/registry.service.ts @@ -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, @@ -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, ]) } } @@ -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() 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 } diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube.constants.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube.constants.ts index e6744737fc..dce1b44ec3 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube.constants.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube.constants.ts @@ -10,14 +10,14 @@ export const DEFAULT_TEMPLATE_PERMISSIONS = ['admin', 'codeviewer', 'issueadmin' // Project-level permission sets per role (SonarQube permission API names) export const PROJECT_ADMIN_PERMISSIONS = ['admin', 'scan', 'user', 'codeviewer', 'issueadmin', 'securityhotspotadmin'] as const export const PROJECT_DEVOPS_PERMISSIONS = ['scan', 'user', 'codeviewer', 'issueadmin', 'securityhotspotadmin'] as const -export const PROJECT_DEVELOPER_PERMISSIONS = ['scan', 'user', 'codeviewer'] as const +export const PROJECT_DEVELOPER_PERMISSIONS = ['scan', 'user', 'codeviewer', 'issueadmin', 'securityhotspotadmin'] as const export const PROJECT_SECURITY_PERMISSIONS = ['scan', 'user', 'codeviewer', 'issueadmin', 'securityhotspotadmin'] as const export const PROJECT_READONLY_PERMISSIONS = ['user', 'codeviewer'] as const // CI robot/service account — needs Execute Analysis + Browse + See Source Code export const ROBOT_PROJECT_PERMISSIONS = ['scan', 'user', 'codeviewer'] as const -// Default platform-wide Keycloak group paths (following gitlab /console/* naming) +// Default platform-wide Keycloak group paths export const DEFAULT_ADMIN_GROUP_PATH = '/console/admin' export const DEFAULT_READONLY_GROUP_PATH = '/console/readonly' export const DEFAULT_SECURITY_GROUP_PATH = '/console/security' diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts index 66733d6056..f5f2e923bb 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts @@ -195,6 +195,8 @@ describe('sonarqubeService', () => { expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: `/${project.slug}/console/developer` })) expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: '/console/readonly' })) expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: '/console/security' })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: `/${project.slug}/console/developer`, permission: 'issueadmin' })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: `/${project.slug}/console/developer`, permission: 'securityhotspotadmin' })) }) it('should not recreate user or write vault when both user and secret exist', async () => { diff --git a/apps/server-nestjs/src/modules/vault/vault.service.spec.ts b/apps/server-nestjs/src/modules/vault/vault.service.spec.ts index 96817c3a07..f5ccad9d0a 100644 --- a/apps/server-nestjs/src/modules/vault/vault.service.spec.ts +++ b/apps/server-nestjs/src/modules/vault/vault.service.spec.ts @@ -144,6 +144,17 @@ describe('vaultService', () => { expect(client.upsertIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-readonly`, expect.any(Object)) expect(client.upsertIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-security`, expect.any(Object)) expect(client.createIdentityGroupAlias).not.toHaveBeenCalled() + + const policyCalls = client.upsertSysPoliciesAcl.mock.calls as Array<[string, { policy: string }]> + expect(policyCalls.find(([name]) => name === `project--${project.slug}--developer`)?.[1].policy) + .toBe(`path "${project.slug}/data/*" { capabilities = ["list"] }`) + expect(policyCalls.find(([name]) => name === `project--${project.slug}--readonly`)?.[1].policy) + .toBe(`path "${project.slug}/data/*" { capabilities = ["list"] }`) + expect(policyCalls.find(([name]) => name === `project--${project.slug}--security`)?.[1].policy) + .toBe([ + `path "${project.slug}/metadata/*" { capabilities = ["list"] }`, + `path "transit/keys/${project.slug}/*" { capabilities = ["list"] }`, + ].join('\n')) }) it('should delete project and destroy secrets on event', async () => { diff --git a/apps/server-nestjs/src/modules/vault/vault.service.ts b/apps/server-nestjs/src/modules/vault/vault.service.ts index 711703a7a3..8ed237e89e 100644 --- a/apps/server-nestjs/src/modules/vault/vault.service.ts +++ b/apps/server-nestjs/src/modules/vault/vault.service.ts @@ -427,25 +427,22 @@ export class VaultService { async createProjectDeveloperPolicy(name: string, projectSlug: string): Promise { await this.client.upsertSysPoliciesAcl(name, { - policy: [ - `path "${projectSlug}/data/*" { capabilities = ["read"] }`, - `path "${projectSlug}/metadata/*" { capabilities = ["read", "list"] }`, - ].join('\n'), + policy: `path "${projectSlug}/data/*" { capabilities = ["list"] }`, }) } async createProjectReadOnlyPolicy(name: string, projectSlug: string): Promise { await this.client.upsertSysPoliciesAcl(name, { - policy: [ - `path "${projectSlug}/data/*" { capabilities = ["read"] }`, - `path "${projectSlug}/metadata/*" { capabilities = ["read", "list"] }`, - ].join('\n'), + policy: `path "${projectSlug}/data/*" { capabilities = ["list"] }`, }) } async createProjectSecurityPolicy(name: string, projectSlug: string): Promise { await this.client.upsertSysPoliciesAcl(name, { - policy: `path "${projectSlug}/metadata/*" { capabilities = ["read", "list"] }`, + policy: [ + `path "${projectSlug}/metadata/*" { capabilities = ["list"] }`, + `path "transit/keys/${projectSlug}/*" { capabilities = ["list"] }`, + ].join('\n'), }) } diff --git a/apps/server/src/resources/project/queries.ts b/apps/server/src/resources/project/queries.ts index a796373414..5957b15478 100644 --- a/apps/server/src/resources/project/queries.ts +++ b/apps/server/src/resources/project/queries.ts @@ -275,15 +275,22 @@ export function initializeProject(params: CreateProjectParams) { }, { name: 'Développeur', - permissions: PROJECT_PERMS.MANAGE_REPOSITORIES | PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.LIST_REPOSITORIES, + permissions: PROJECT_PERMS.MANAGE_REPOSITORIES | PROJECT_PERMS.REPLAY_HOOKS | PROJECT_PERMS.SEE_SECRETS | PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.LIST_REPOSITORIES, position: 2, oidcGroup: `/${params.slug}/console/developer`, type: 'system:managed', }, { - name: 'Lecture seule', + name: 'Sécurité', permissions: PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.LIST_REPOSITORIES, position: 3, + oidcGroup: `/${params.slug}/console/security`, + type: 'system:managed', + }, + { + name: 'Lecture seule', + permissions: PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.LIST_REPOSITORIES, + position: 4, oidcGroup: `/${params.slug}/console/readonly`, type: 'system:managed', },