diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.spec.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.spec.ts index 66689ec44..e5773eac6 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.spec.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.spec.ts @@ -11,8 +11,7 @@ import { makeSonarqubeGeneratedToken, makeSonarqubeGroup, makeSonarqubePaging, m const sonarUrl = 'https://sonarqube.internal' const sonarToken = 'my-token' -const sonarBearerToken = Buffer.from(`${sonarToken}:`, 'utf8').toString('base64') -const sonarAuthHeader = `Bearer ${sonarBearerToken}` +const sonarAuthHeader = `Bearer ${sonarToken}` const server = setupServer() @@ -118,7 +117,7 @@ describe('sonarqubeClientService', () => { http.post(`${sonarUrl}/api/users/deactivate`, ({ request }) => { const params = new URL(request.url).searchParams expect(params.get('login')).toBe(user.login) - expect(params.get('anonymize')).toBe(user.anonymize) + expect(params.get('anonymize')).toBe(String(user.anonymize)) return HttpResponse.json({}) }), ) diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.ts index 07f444593..f33ed2ce1 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.ts @@ -90,6 +90,10 @@ export interface CreatePermissionTemplateParams extends BaseParams { projectKeyPattern?: string } +export interface SearchPermissionTemplatesParams extends BaseParams { + q?: string +} + export interface SetPermissionDefaultTemplateParams extends BaseParams { templateName: string projectKeyPattern?: string @@ -169,6 +173,16 @@ export interface SearchUserGroupResponse { groups: SonarqubeGroup[] } +export interface SonarqubePermissionTemplate { + id: string + name: string + description?: string +} + +export interface SearchPermissionTemplatesResponse { + permissionTemplates: SonarqubePermissionTemplate[] +} + export interface SearchUsersResponse { paging: SonarqubePaging users: SonarqubeUser[] @@ -200,6 +214,11 @@ export class SonarqubeClientService { await this.http.fetch('permissions/create_template', { method: 'POST', params }) } + @StartActiveSpan() + searchPermissionTemplates(params: SearchPermissionTemplatesParams) { + return this.http.fetch('permissions/search_templates', { params }).then(res => res.data!) + } + @StartActiveSpan() async setPermissionDefaultTemplate(params: SetPermissionDefaultTemplateParams) { await this.http.fetch('permissions/set_default_template', { method: 'POST', params }) diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube-http-client.service.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube-http-client.service.ts index 678b05999..f1d508723 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube-http-client.service.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube-http-client.service.ts @@ -12,7 +12,7 @@ export interface SonarqubeResponse { data: T | null } -export type SonarqubeErrorKind = 'NotConfigured' | 'Unexpected' +export type SonarqubeErrorKind = 'NotConfigured' | 'ClientError' | 'ServerError' | 'Unexpected' export class SonarqubeError extends Error { readonly kind: SonarqubeErrorKind @@ -52,9 +52,8 @@ export class SonarqubeHttpClientService { private get defaultHeaders(): Record { if (!this.config.sonarApiToken) throw new SonarqubeError('NotConfigured', 'SONAR_API_TOKEN is required') - const bearerToken = Buffer.from(`${this.config.sonarApiToken}:`, 'utf8').toString('base64') return { - Authorization: `Bearer ${bearerToken}`, + Authorization: `Bearer ${this.config.sonarApiToken}`, } } @@ -70,7 +69,12 @@ export class SonarqubeHttpClientService { }) span?.setAttribute('sonarqube.http.status', response.status) - return handleResponse(response) + const result = await handleResponse(response) + if (response.status >= 400) { + const kind = response.status >= 500 ? 'ServerError' : 'ClientError' + throw new SonarqubeError(kind, formatErrorMessage(response.status, result.data), { status: response.status, method, path }) + } + return result } private createRequest(path: string, method: string, params?: Record): Request { @@ -84,6 +88,16 @@ export class SonarqubeHttpClientService { } } +function formatErrorMessage(status: number, data: unknown): string { + const errors = (data as { errors?: { msg?: unknown }[] } | null)?.errors + const details = Array.isArray(errors) + ? errors.map(e => e?.msg).filter((msg): msg is string => typeof msg === 'string').join('; ') + : '' + return details + ? `SonarQube API responded with status ${status}: ${details}` + : `SonarQube API responded with status ${status}` +} + async function handleResponse(response: Response): Promise> { if (response.status === 204) return { status: response.status, data: null } const contentType = response.headers.get('content-type') ?? '' diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube.constants.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube.constants.ts index dce1b44ec..b8689c911 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube.constants.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube.constants.ts @@ -2,7 +2,7 @@ export const SONARQUBE_PLUGIN_NAME = 'sonarqube' export const DEFAULT_PERMISSION_TEMPLATE_NAME = 'Forge Default' // SonarQube global permission names -export const GLOBAL_ADMIN_PERMISSIONS = ['admin', 'profileadmin', 'gateadmin', 'provisioning'] as const +export const GLOBAL_ADMIN_PERMISSIONS = ['admin', 'profileadmin', 'gateadmin', 'scan', 'provisioning'] as const // Permission template — grants to project creator and sonar-administrators on new projects export const DEFAULT_TEMPLATE_PERMISSIONS = ['admin', 'codeviewer', 'issueadmin', 'securityhotspotadmin', 'scan', 'user'] as const 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 f5f2e923b..f6b663798 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts @@ -1,6 +1,7 @@ import type { Mocked } from 'vitest' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { generateProjectKey } from '../../utils/crypto' import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { VaultClientService } from '../vault/vault-client.service' import { makeVaultSecret } from '../vault/vault-testing.utils' @@ -27,6 +28,7 @@ function createTestingModule() { searchUserGroup: vi.fn(), createUserGroup: vi.fn(), createPermissionTemplate: vi.fn(), + searchPermissionTemplates: vi.fn(), setPermissionDefaultTemplate: vi.fn(), addPermissionProjectCreatorToTemplate: vi.fn(), addPermissionGroupToTemplate: vi.fn(), @@ -86,6 +88,7 @@ describe('sonarqubeService', () => { client.searchUserGroup.mockResolvedValue(makeEmptyGroupsResponse()) client.createUserGroup.mockResolvedValue(undefined) client.createPermissionTemplate.mockResolvedValue(undefined) + client.searchPermissionTemplates.mockResolvedValue({ permissionTemplates: [] }) client.setPermissionDefaultTemplate.mockResolvedValue(undefined) client.addPermissionProjectCreatorToTemplate.mockResolvedValue(undefined) client.addPermissionGroupToTemplate.mockResolvedValue(undefined) @@ -110,6 +113,15 @@ describe('sonarqubeService', () => { expect(client.setPermissionDefaultTemplate).toHaveBeenCalledWith({ templateName: 'Forge Default' }) }) + it('should not recreate the permission template when it already exists', async () => { + client.searchPermissionTemplates.mockResolvedValue({ + permissionTemplates: [{ id: '1', name: 'Forge Default' }], + }) + await service.init() + expect(client.createPermissionTemplate).not.toHaveBeenCalled() + expect(client.setPermissionDefaultTemplate).toHaveBeenCalledWith({ templateName: 'Forge Default' }) + }) + it('should create /console/admin group with global permissions when it does not exist', async () => { await service.init() expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: '/console/admin' })) @@ -226,12 +238,35 @@ describe('sonarqubeService', () => { it('should delete sonarqube projects for removed repositories', async () => { const project = makeProjectWithDetails({ repositories: [{ internalRepoName: 'kept' }] }) + const keptKey = generateProjectKey(project.slug, 'kept') + const removedKey = generateProjectKey(project.slug, 'removed') + client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug })) + client.searchProject.mockResolvedValue({ + paging: makeSonarqubePaging({ total: 2 }), + components: [ + { key: keptKey, name: '', qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, visibility: 'private' }, + { key: removedKey, name: '', qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, visibility: 'private' }, + ], + }) + client.searchUsers.mockResolvedValue({ paging: makeSonarqubePaging({ total: 1 }), users: [makeSonarqubeUser({ login: project.slug })] }) + vault.readSonarqubeUser.mockResolvedValue(makeVaultSecret({ data: { SONAR_USERNAME: project.slug, SONAR_PASSWORD: 'pw', SONAR_TOKEN: 'tok' } })) + + await service.handleUpsert(project) + + expect(client.deleteProject).toHaveBeenCalledWith({ project: removedKey }) + expect(client.deleteProject).not.toHaveBeenCalledWith({ project: keptKey }) + }) + + it('should not delete sonarqube projects whose key was not generated by the console', async () => { + const project = makeProjectWithDetails({ slug: 'my', repositories: [] }) client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug })) client.searchProject.mockResolvedValue({ paging: makeSonarqubePaging({ total: 2 }), components: [ - { key: `${project.slug}-kept-aabb`, name: '', qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, visibility: 'private' }, - { key: `${project.slug}-removed-ccdd`, name: '', qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, visibility: 'private' }, + // manually created project, hash suffix does not match generateProjectKey + { key: 'my-manual-project', name: '', qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, visibility: 'private' }, + // belongs to project "my-app" (repo "x"), not to project "my" + { key: generateProjectKey('my-app', 'x'), name: '', qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, visibility: 'private' }, ], }) client.searchUsers.mockResolvedValue({ paging: makeSonarqubePaging({ total: 1 }), users: [makeSonarqubeUser({ login: project.slug })] }) @@ -239,8 +274,7 @@ describe('sonarqubeService', () => { await service.handleUpsert(project) - expect(client.deleteProject).toHaveBeenCalledWith({ project: `${project.slug}-removed-ccdd` }) - expect(client.deleteProject).not.toHaveBeenCalledWith({ project: `${project.slug}-kept-aabb` }) + expect(client.deleteProject).not.toHaveBeenCalled() }) it('should use comma-separated group path suffixes from project plugin config', async () => { @@ -260,15 +294,16 @@ describe('sonarqubeService', () => { describe('handleDelete', () => { it('should delete sonarqube projects, anonymize user and remove vault entry', async () => { const project = makeProjectWithDetails({ slug: 'doomed' }) + const doomedKey = generateProjectKey('doomed', 'repo') client.searchProject.mockResolvedValue({ paging: makeSonarqubePaging({ total: 1 }), - components: [{ key: 'doomed-repo-aabb', name: '', qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, visibility: 'private' }], + components: [{ key: doomedKey, name: '', qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, visibility: 'private' }], }) client.searchUsers.mockResolvedValue({ paging: makeSonarqubePaging({ total: 1 }), users: [makeSonarqubeUser({ login: 'doomed' })] }) await service.handleDelete(project) - expect(client.deleteProject).toHaveBeenCalledWith({ project: 'doomed-repo-aabb' }) + expect(client.deleteProject).toHaveBeenCalledWith({ project: doomedKey }) expect(client.deactivateUser).toHaveBeenCalledWith({ login: 'doomed', anonymize: true }) expect(vault.deleteSonarqubeUser).toHaveBeenCalledWith('doomed') }) diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts index 3c78c7c83..aadc0e441 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts @@ -3,10 +3,10 @@ import type { RequiredPluginResult } from '../plugin/plugin.utils' import type { SonarqubeUserSecret } from '../vault/vault-client.service' import type { SonarqubeProjectResult, SonarqubeUser } from './sonarqube-client.service' import type { ProjectWithDetails } from './sonarqube-datastore.service' -import { generateProjectKey, generateRandomPassword } from '@cpn-console/hooks' import { Inject, Injectable, Logger } from '@nestjs/common' import { OnEvent } from '@nestjs/event-emitter' import { trace } from '@opentelemetry/api' +import { generateProjectKey, generateRandomPassword } from '../../utils/crypto' import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' import { capturePluginResult } from '../plugin/plugin.utils' @@ -64,7 +64,7 @@ export class SonarqubeService implements OnModuleInit { } async onModuleInit() { - await this.init().catch(error => this.logger.error('SonarQube initialization failed', error)) + await this.init().catch(error => this.logger.error({ error }, 'SonarQube initialization failed')) } @StartActiveSpan() @@ -181,7 +181,14 @@ export class SonarqubeService implements OnModuleInit { @StartActiveSpan() private async ensureDefaultPermissionTemplate(): Promise { this.logger.verbose(`Ensuring SonarQube permission template (name=${DEFAULT_PERMISSION_TEMPLATE_NAME})`) - await this.client.createPermissionTemplate({ name: DEFAULT_PERMISSION_TEMPLATE_NAME }) + const { permissionTemplates } = await this.client.searchPermissionTemplates({ q: DEFAULT_PERMISSION_TEMPLATE_NAME }) + + if (permissionTemplates.some(t => t.name.toLowerCase() === DEFAULT_PERMISSION_TEMPLATE_NAME.toLowerCase())) { + this.logger.verbose(`SonarQube permission template already exists (name=${DEFAULT_PERMISSION_TEMPLATE_NAME})`) + } else { + await this.client.createPermissionTemplate({ name: DEFAULT_PERMISSION_TEMPLATE_NAME }) + this.logger.log(`Created SonarQube permission template (name=${DEFAULT_PERMISSION_TEMPLATE_NAME})`) + } await Promise.all(DEFAULT_TEMPLATE_PERMISSIONS.map(permission => this.client.addPermissionProjectCreatorToTemplate({ templateName: DEFAULT_PERMISSION_TEMPLATE_NAME, permission }), )) @@ -440,7 +447,9 @@ function filterProjectsOwningSlug( for (let i = parts.length - 1; i > 0; i--) { const project = parts.slice(0, i).join('-') const repository = parts.slice(i).join('-') - if (sonarKey.startsWith(`${project}-${repository}-`) && project === projectSlug) { + // recompute the key (with its HMAC suffix) so keys not managed by the + // console, or owned by a project whose slug is a prefix, are never claimed + if (generateProjectKey(project, repository) === sonarKey && project === projectSlug) { acc.push({ projectSlug, repository, key: sonarKey }) break } diff --git a/apps/server-nestjs/src/utils/crypto.spec.ts b/apps/server-nestjs/src/utils/crypto.spec.ts new file mode 100644 index 000000000..19bad4e44 --- /dev/null +++ b/apps/server-nestjs/src/utils/crypto.spec.ts @@ -0,0 +1,33 @@ +import { generateProjectKey as legacyGenerateProjectKey } from '@cpn-console/hooks' +import { describe, expect, it } from 'vitest' +import { generateProjectKey } from './crypto' + +describe('generateProjectKey', () => { + it('matches the legacy @cpn-console/hooks implementation byte-for-byte', () => { + const cases: [string, string][] = [ + ['my-app', 'my-repo'], + ['my-app', 'front'], + ['my-app-2', 'front'], + ['slug-with-many-dashes', 'repo-with-dashes'], + ['a', 'b'], + ] + for (const [slug, repo] of cases) { + expect(generateProjectKey(slug, repo)).toBe(legacyGenerateProjectKey(slug, repo)) + } + }) + + it('produces stable known keys', () => { + // Golden values: existing SonarQube projects were created with these exact keys, + // and reconciliation recomputes them to decide ownership. Any change here means + // the console would stop recognizing (and could delete) existing projects. + expect(generateProjectKey('my-app', 'my-repo')).toBe('my-app-my-repo-923f') + expect(generateProjectKey('my-app', 'front')).toBe('my-app-front-1013') + expect(generateProjectKey('my-app', 'api')).toBe('my-app-api-a814') + }) + + it('disambiguates identical slug-repo concatenations via the hash suffix', () => { + // "my-app" + "front-api" and "my-app-front" + "api" share the prefix + // "my-app-front-api-"; only the repo hash tells them apart. + expect(generateProjectKey('my-app', 'front-api')).not.toBe(generateProjectKey('my-app-front', 'api')) + }) +}) diff --git a/apps/server-nestjs/src/utils/crypto.ts b/apps/server-nestjs/src/utils/crypto.ts new file mode 100644 index 000000000..f12bebcd1 --- /dev/null +++ b/apps/server-nestjs/src/utils/crypto.ts @@ -0,0 +1,18 @@ +import crypto, { createHmac } from 'node:crypto' + +export function generateRandomPassword(length = 24) { + const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@-_#*' + return Array.from(crypto.getRandomValues(new Uint32Array(length)), x => chars[x % chars.length]) + .join('') +} + +// Must stay byte-for-byte identical to the legacy @cpn-console/hooks implementation: +// existing SonarQube project keys were generated with it, and ownership matching +// recomputes keys to decide which projects to reconcile or delete. +export function generateProjectKey(projectSlug: string, internalRepoName: string) { + const repoHash = createHmac('sha256', '') + .update(internalRepoName) + .digest('hex') + .slice(0, 4) + return `${projectSlug}-${internalRepoName}-${repoHash}` +}