diff --git a/packages/shared/src/utils/const.ts b/packages/shared/src/utils/const.ts index 0f546feed..197d5c1ef 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 9f00644c6..6551aa063 100644 --- a/plugins/sonarqube/src/functions.ts +++ b/plugins/sonarqube/src/functions.ts @@ -1,99 +1,169 @@ -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 { formatGroupName } from './utils.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' +import { DEFAULT_ADMIN_GROUP_PATH, DEFAULT_READONLY_GROUP_PATH } from './infos.js' -const globalPermissions = [ - 'admin', - 'profileadmin', - 'gateadmin', - 'scan', - 'provisioning', -] +const PLATFORM_ADMIN_TEMPLATE_NAME = 'Default platform admin template' +const PLATFORM_READONLY_TEMPLATE_NAME = 'Default platform readonly template' -const projectPermissions = [ +const platformAdminPermissions = [ 'admin', 'codeviewer', 'issueadmin', - 'securityhotspotadmin', 'scan', + 'securityhotspotadmin', 'user', -] +] as const -export async function initSonar() { - await setTemplatePermisions() - await createAdminGroup() - await setAdminPermisions() -} +const platformReadonlyPermissions = [ + 'codeviewer', + 'user', +] 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 + + let managedGroupPath: string | undefined + + if (role.oidcGroup === adminGroupPath) { + managedGroupPath = formatGroupName(adminGroupPath) + await ensureAdminTemplateExists() + await ensureGroupExists(managedGroupPath) + await setTemplateGroupPermissions(managedGroupPath, platformAdminPermissions, PLATFORM_ADMIN_TEMPLATE_NAME) + } else if (role.oidcGroup === readonlyGroupPath) { + managedGroupPath = formatGroupName(readonlyGroupPath) + await ensureReadonlyTemplateExists() + await ensureGroupExists(managedGroupPath) + await setTemplateGroupPermissions(managedGroupPath, platformReadonlyPermissions, PLATFORM_READONLY_TEMPLATE_NAME) + } + + 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(payload.config.sonarqube?.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() { const axiosInstance = getAxiosInstance() + + // Create Admin Template await axiosInstance({ method: 'post', - params: { name: 'Forge Default' }, + params: { name: PLATFORM_ADMIN_TEMPLATE_NAME }, 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(platformAdminPermissions.map(permission => + axiosInstance({ method: 'post', params: { - templateName: 'Forge Default', + templateName: PLATFORM_ADMIN_TEMPLATE_NAME, permission, }, url: 'permissions/add_project_creator_to_template', - }) - await axiosInstance({ + }), + )) +} + +async function ensureReadonlyTemplateExists() { + const axiosInstance = getAxiosInstance() + + // Create Readonly Template + await axiosInstance({ + method: 'post', + params: { name: PLATFORM_READONLY_TEMPLATE_NAME }, + url: 'permissions/create_template', + validateStatus: code => [200, 400].includes(code), + }) + // Add Project Creator and sonar-administrators to Readonly Template + await Promise.all(platformReadonlyPermissions.map(permission => + axiosInstance({ method: 'post', params: { - groupName: 'sonar-administrators', - templateName: 'Forge Default', + templateName: PLATFORM_READONLY_TEMPLATE_NAME, permission, }, - url: 'permissions/add_group_to_template', - }) - } + url: 'permissions/add_project_creator_to_template', + }), + )) + + // Set Readonly Template as Default await axiosInstance({ method: 'post', params: { - templateName: 'Forge Default', + templateName: PLATFORM_READONLY_TEMPLATE_NAME, }, url: 'permissions/set_default_template', }) @@ -111,11 +181,12 @@ export const upsertProject: StepCall = async (payload) => { } = project const username = project.slug const keycloakGroupPath = await keycloakApi.getProjectGroupPath() + const sonarGroupPath = formatGroupName(keycloakGroupPath) const sonarRepositories = await findSonarProjectsForDsoProjects(projectSlug) await Promise.all([ ensureUserAndVault(vaultApi, username, projectSlug), - ensureGroupExists(keycloakGroupPath), + ensureGroupExists(sonarGroupPath), // Remove excess repositories ...sonarRepositories @@ -128,7 +199,7 @@ export const upsertProject: StepCall = async (payload) => { if (!sonarRepositories.some(sonarRepository => sonarRepository.repository === repository.internalRepoName)) { await createDsoRepository(projectSlug, repository.internalRepoName) } - await ensureRepositoryConfiguration(projectKey, username, keycloakGroupPath) + await ensureRepositoryConfiguration(projectKey, username, sonarGroupPath) }), ]) @@ -166,7 +237,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 +304,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 83bde1f19..4e209d90d 100644 --- a/plugins/sonarqube/src/group.ts +++ b/plugins/sonarqube/src/group.ts @@ -1,6 +1,25 @@ import { getAxiosInstance } from './tech.js' import type { SonarPaging } from './project.js' +import { find, getAll, pagePaginate } from './utils.js' +export async function getGroupMembers(groupName: string): Promise { + const axiosInstance = getAxiosInstance() + const users = await getAll<{ login: string }>(pagePaginate(async (params) => { + const response = await axiosInstance({ + url: 'user_groups/users', + params: { + ...params, + name: groupName, + }, + }) + 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 +28,22 @@ 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(pagePaginate(async (params) => { + const response = await axiosInstance({ + url: 'user_groups/search', + params: { + ...params, + q: name, + }, + }) + 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 +59,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 3b149807d..174fe040e 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 94ea6616c..e0fb78e38 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 1c0085c16..9386a04f8 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, pagePaginate } from './utils.js' export interface SonarPaging { pageIndex: number @@ -133,25 +134,20 @@ 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(pagePaginate(async (params) => { + const response = await axiosInstance.get('projects/search', { params: { + ...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 f5d98bbfc..962f23c1b 100644 --- a/plugins/sonarqube/src/user.ts +++ b/plugins/sonarqube/src/user.ts @@ -2,7 +2,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, pagePaginate } from './utils.js' export interface SonarUser { login: string @@ -62,25 +62,19 @@ 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(pagePaginate(async (params) => { const response = await axiosInstance({ url: 'users/search', params: { + ...params, q: username, - ps: pageSize, - p: page, }, }) - 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 + return { + items: response.data.users, + paging: response.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 000000000..bf4c43399 --- /dev/null +++ b/plugins/sonarqube/src/utils.ts @@ -0,0 +1,57 @@ +import type { SonarPaging } from './project.js' + +export interface PaginatedResult { + items: T[] + paging: SonarPaging +} + +export async function* pagePaginate( + request: (params: { p: number }) => Promise>, +): AsyncGenerator { + let page: number | null = 1 + while (page !== null) { + const { items, paging } = await request({ p: page }) + for (const item of items) { + yield item + } + if (!items.length || paging.pageIndex * paging.pageSize >= paging.total) { + page = null + } else { + page = paging.pageIndex + 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 +} + +export function formatGroupName(group: string) { + if (group.startsWith('/console/')) { + return `platform-${group.replace('/console/', '')}` + } + const projectMatch = group.match(/^\/(.+)\/console\/(.+)$/) + if (projectMatch) { + const [, slug, role] = projectMatch + return `project-${slug}-${role}` + } + return group +}