Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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({})
}),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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[]
Expand Down Expand Up @@ -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<SearchPermissionTemplatesResponse>('permissions/search_templates', { params }).then(res => res.data!)
}

@StartActiveSpan()
async setPermissionDefaultTemplate(params: SetPermissionDefaultTemplateParams) {
await this.http.fetch('permissions/set_default_template', { method: 'POST', params })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export interface SonarqubeResponse<T = unknown> {
data: T | null
}

export type SonarqubeErrorKind = 'NotConfigured' | 'Unexpected'
export type SonarqubeErrorKind = 'NotConfigured' | 'ClientError' | 'ServerError' | 'Unexpected'

export class SonarqubeError extends Error {
readonly kind: SonarqubeErrorKind
Expand Down Expand Up @@ -52,9 +52,8 @@ export class SonarqubeHttpClientService {

private get defaultHeaders(): Record<string, string> {
if (!this.config.sonarApiToken) throw new SonarqubeError('NotConfigured', 'SONAR_API_TOKEN is required')
const bearerToken = Buffer.from(`${this.config.sonarApiToken}:`, 'utf8').toString('base64')
Comment thread
KepoParis marked this conversation as resolved.
return {
Authorization: `Bearer ${bearerToken}`,
Authorization: `Bearer ${this.config.sonarApiToken}`,
}
}

Expand All @@ -70,7 +69,12 @@ export class SonarqubeHttpClientService {
})

span?.setAttribute('sonarqube.http.status', response.status)
return handleResponse<T>(response)
const result = await handleResponse<T>(response)
if (response.status >= 400) {
Comment thread
KepoParis marked this conversation as resolved.
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<string, string | number | boolean | undefined | null>): Request {
Expand All @@ -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<T>(response: Response): Promise<SonarqubeResponse<T>> {
if (response.status === 204) return { status: response.status, data: null }
const contentType = response.headers.get('content-type') ?? ''
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 41 additions & 6 deletions apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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(),
Expand Down Expand Up @@ -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)
Expand All @@ -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' }))
Expand Down Expand Up @@ -226,21 +238,43 @@ 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 })] })
vault.readSonarqubeUser.mockResolvedValue(makeVaultSecret({ data: { SONAR_USERNAME: project.slug, SONAR_PASSWORD: 'pw', SONAR_TOKEN: 'tok' } }))

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 () => {
Expand All @@ -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')
})
Expand Down
17 changes: 13 additions & 4 deletions apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -181,7 +181,14 @@ export class SonarqubeService implements OnModuleInit {
@StartActiveSpan()
private async ensureDefaultPermissionTemplate(): Promise<void> {
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})`)
Comment thread
KepoParis marked this conversation as resolved.
} 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 }),
))
Expand Down Expand Up @@ -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) {
Comment thread
shikanime marked this conversation as resolved.
acc.push({ projectSlug, repository, key: sonarKey })
break
}
Expand Down
33 changes: 33 additions & 0 deletions apps/server-nestjs/src/utils/crypto.spec.ts
Comment thread
StephaneTrebel marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -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'))
})
})
18 changes: 18 additions & 0 deletions apps/server-nestjs/src/utils/crypto.ts
Original file line number Diff line number Diff line change
@@ -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) {
Comment thread
KepoParis marked this conversation as resolved.
const repoHash = createHmac('sha256', '')

Check failure on line 13 in apps/server-nestjs/src/utils/crypto.ts

View check run for this annotation

cloud-pi-native-sonarqube / SonarQube Code Analysis

apps/server-nestjs/src/utils/crypto.ts#L13

Revoke and change this password, as it is compromised.
.update(internalRepoName)
.digest('hex')
.slice(0, 4)
return `${projectSlug}-${internalRepoName}-${repoHash}`
}