Skip to content

Commit 5d97d67

Browse files
committed
feat(sonarqube): make admin group path configurable though console
Co-authered-by: William Phetsinorath <william.phetsinorath@shikanime.studio> Signed-off-by: William Phetsinorath <william.phetsinorath-open@interieur.gouv.fr>
1 parent dfe7ea0 commit 5d97d67

File tree

8 files changed

+313
-98
lines changed

8 files changed

+313
-98
lines changed

packages/shared/src/utils/const.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
export const adminGroupPath = '/admin'
21
export const deleteValidationInput = 'DELETE'
32
export const forbiddenRepoNames = ['mirror', 'infra-apps', 'infra-observability']
43

plugins/sonarqube/src/functions.ts

Lines changed: 134 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,12 @@
1-
import { adminGroupPath } from '@cpn-console/shared'
2-
import type { Project, StepCall } from '@cpn-console/hooks'
3-
import { generateProjectKey, parseError } from '@cpn-console/hooks'
1+
import type { AdminRole, Project, StepCall } from '@cpn-console/hooks'
2+
import { generateProjectKey, parseError, specificallyEnabled } from '@cpn-console/hooks'
43
import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/vault-project-api.js'
5-
import { ensureGroupExists, findGroupByName } from './group.js'
4+
import { addUserToGroup, ensureGroupExists, getGroupMembers, removeUserFromGroup } from './group.js'
65
import type { VaultSonarSecret } from './tech.js'
76
import { getAxiosInstance } from './tech.js'
8-
import type { SonarUser } from './user.js'
9-
import { ensureUserExists } from './user.js'
10-
import type { SonarPaging } from './project.js'
7+
import { ensureUserExists, getUser } from './user.js'
118
import { createDsoRepository, deleteDsoRepository, ensureRepositoryConfiguration, files, findSonarProjectsForDsoProjects } from './project.js'
12-
13-
const globalPermissions = [
14-
'admin',
15-
'profileadmin',
16-
'gateadmin',
17-
'scan',
18-
'provisioning',
19-
]
9+
import { DEFAULT_ADMIN_GROUP_PATH, DEFAULT_READONLY_GROUP_PATH } from './infos.js'
2010

2111
const projectPermissions = [
2212
'admin',
@@ -25,75 +15,163 @@ const projectPermissions = [
2515
'securityhotspotadmin',
2616
'scan',
2717
'user',
28-
]
18+
] as const
2919

30-
export async function initSonar() {
31-
await setTemplatePermisions()
32-
await createAdminGroup()
33-
await setAdminPermisions()
34-
}
20+
const readonlyProjectPermissions = [
21+
'codeviewer',
22+
'user',
23+
'scan',
24+
'issueadmin',
25+
'securityhotspotadmin',
26+
] as const
3527

36-
async function createAdminGroup() {
37-
const axiosInstance = getAxiosInstance()
38-
const adminGroup = await findGroupByName(adminGroupPath)
39-
if (!adminGroup) {
40-
await axiosInstance({
41-
method: 'post',
42-
params: {
43-
name: adminGroupPath,
44-
description: 'DSO platform admins',
28+
export const upsertAdminRole: StepCall<AdminRole> = async (payload) => {
29+
try {
30+
const role = payload.args
31+
const adminGroupPath = payload.config.sonarqube?.adminGroupPath ?? DEFAULT_ADMIN_GROUP_PATH
32+
const readonlyGroupPath = payload.config.sonarqube?.readonlyGroupPath ?? DEFAULT_READONLY_GROUP_PATH
33+
if (!readonlyGroupPath) {
34+
throw new Error('readonlyGroupPath is required')
35+
}
36+
const purge = payload.config.sonarqube?.purge
37+
if (!adminGroupPath) {
38+
throw new Error('adminGroupPath is required')
39+
}
40+
41+
let managedGroupPath: string | undefined
42+
43+
if (role.oidcGroup === adminGroupPath) {
44+
managedGroupPath = adminGroupPath
45+
await ensureAdminTemplateExists(adminGroupPath)
46+
await ensureGroupExists(adminGroupPath)
47+
await setTemplateGroupPermissions(adminGroupPath, projectPermissions, adminGroupPath)
48+
} else if (role.oidcGroup === readonlyGroupPath) {
49+
managedGroupPath = readonlyGroupPath
50+
await ensureReadonlyTemplateExists(readonlyGroupPath)
51+
await ensureGroupExists(readonlyGroupPath)
52+
await setTemplateGroupPermissions(readonlyGroupPath, readonlyProjectPermissions, readonlyGroupPath)
53+
}
54+
55+
if (!managedGroupPath) {
56+
return {
57+
status: {
58+
result: 'OK',
59+
message: 'Not a managed role for SonarQube plugin',
60+
},
61+
}
62+
}
63+
64+
const groupMembers = await getGroupMembers(managedGroupPath)
65+
66+
await Promise.all([
67+
...role.members.map((member) => {
68+
if (!groupMembers.includes(member.email)) {
69+
return addUserToGroup(managedGroupPath, member.email)
70+
.catch((error) => {
71+
console.warn(`Failed to add user ${member.email} to group ${managedGroupPath}`, error)
72+
})
73+
}
74+
return undefined
75+
}),
76+
...groupMembers.map((memberEmail) => {
77+
if (!role.members.some(m => m.email === memberEmail)) {
78+
if (specificallyEnabled(purge)) {
79+
return removeUserFromGroup(managedGroupPath, memberEmail)
80+
.catch((error) => {
81+
console.warn(`Failed to remove user ${memberEmail} from group ${managedGroupPath}`, error)
82+
})
83+
}
84+
}
85+
return undefined
86+
}),
87+
])
88+
89+
return {
90+
status: {
91+
result: 'OK',
92+
message: 'Admin role synced',
4593
},
46-
url: 'user_groups/create',
47-
})
94+
}
95+
} catch (error) {
96+
return {
97+
error: parseError(error),
98+
status: {
99+
result: 'KO',
100+
message: 'An error occured while syncing admin role',
101+
},
102+
}
48103
}
49104
}
50105

51-
async function setAdminPermisions() {
106+
async function setTemplateGroupPermissions(groupName: string, permissions: readonly string[], templateName: string) {
52107
const axiosInstance = getAxiosInstance()
53-
for (const permission of globalPermissions) {
54-
await axiosInstance({
108+
await Promise.all(permissions.map(permission =>
109+
axiosInstance({
55110
method: 'post',
56111
params: {
57-
groupName: adminGroupPath,
112+
groupName,
113+
templateName,
58114
permission,
59115
},
60-
url: 'permissions/add_group',
61-
})
62-
}
116+
url: 'permissions/add_group_to_template',
117+
}),
118+
))
63119
}
64120

65-
async function setTemplatePermisions() {
121+
async function ensureAdminTemplateExists(adminTemplateName: string) {
66122
const axiosInstance = getAxiosInstance()
123+
124+
// Create Admin Template
67125
await axiosInstance({
68126
method: 'post',
69-
params: { name: 'Forge Default' },
127+
params: { name: adminTemplateName },
70128
url: 'permissions/create_template',
71129
validateStatus: code => [200, 400].includes(code),
72130
})
73-
for (const permission of projectPermissions) {
74-
await axiosInstance({
131+
132+
// Add Project Creator and sonar-administrators to Admin Template
133+
await Promise.all(projectPermissions.map(permission =>
134+
axiosInstance({
75135
method: 'post',
76136
params: {
77-
templateName: 'Forge Default',
137+
templateName: adminTemplateName,
78138
permission,
79139
},
80140
url: 'permissions/add_project_creator_to_template',
81-
})
82-
await axiosInstance({
141+
}),
142+
))
143+
await setTemplateGroupPermissions('sonar-administrators', projectPermissions, adminTemplateName)
144+
}
145+
146+
async function ensureReadonlyTemplateExists(readonlyTemplateName: string) {
147+
const axiosInstance = getAxiosInstance()
148+
149+
// Create Readonly Template
150+
await axiosInstance({
151+
method: 'post',
152+
params: { name: readonlyTemplateName },
153+
url: 'permissions/create_template',
154+
validateStatus: code => [200, 400].includes(code),
155+
})
83156

157+
// Add Project Creator and sonar-administrators to Readonly Template
158+
await Promise.all(projectPermissions.map(permission =>
159+
axiosInstance({
84160
method: 'post',
85161
params: {
86-
groupName: 'sonar-administrators',
87-
templateName: 'Forge Default',
162+
templateName: readonlyTemplateName,
88163
permission,
89164
},
90-
url: 'permissions/add_group_to_template',
91-
})
92-
}
165+
url: 'permissions/add_project_creator_to_template',
166+
}),
167+
))
168+
await setTemplateGroupPermissions('sonar-administrators', projectPermissions, readonlyTemplateName)
169+
170+
// Set Readonly Template as Default
93171
await axiosInstance({
94172
method: 'post',
95173
params: {
96-
templateName: 'Forge Default',
174+
templateName: readonlyTemplateName,
97175
},
98176
url: 'permissions/set_default_template',
99177
})
@@ -166,7 +244,9 @@ export const setVariables: StepCall<Project> = async (payload) => {
166244
...project.repositories.map(async (repo) => {
167245
const projectKey = generateProjectKey(projectSlug, repo.internalRepoName)
168246
const repoId = await payload.apis.gitlab.getProjectId(repo.internalRepoName)
169-
if (!repoId) return
247+
if (!repoId) {
248+
throw new Error(`Unable to find GitLab project for repository ${repo.internalRepoName}`)
249+
}
170250
const listVars = await gitlabApi.getGitlabRepoVariables(repoId)
171251
return [
172252
await gitlabApi.setGitlabRepoVariable(repoId, listVars, {
@@ -231,13 +311,7 @@ export const deleteProject: StepCall<Project> = async (payload) => {
231311
try {
232312
const sonarRepositories = await findSonarProjectsForDsoProjects(projectSlug)
233313
await Promise.all(sonarRepositories.map(repo => deleteRepo(repo.key)))
234-
const users: { paging: SonarPaging, users: SonarUser[] } = (await axiosInstance({
235-
url: 'users/search',
236-
params: {
237-
q: username,
238-
},
239-
}))?.data
240-
const user = users.users.find(u => u.login === username)
314+
const user = await getUser(username)
241315
if (!user) {
242316
return {
243317
status: {

plugins/sonarqube/src/group.ts

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,26 @@
11
import { getAxiosInstance } from './tech.js'
22
import type { SonarPaging } from './project.js'
3+
import { find, getAll, iter } from './utils.js'
34

5+
export async function getGroupMembers(groupName: string): Promise<string[]> {
6+
const axiosInstance = getAxiosInstance()
7+
const users = await getAll<{ login: string }>(iter(async (page, pageSize) => {
8+
const response = await axiosInstance({
9+
url: 'user_groups/users',
10+
params: {
11+
name: groupName,
12+
p: page,
13+
ps: pageSize,
14+
},
15+
})
16+
const data: { paging: SonarPaging, users: { login: string }[] } = response.data
17+
return {
18+
items: data.users,
19+
paging: data.paging,
20+
}
21+
}))
22+
return users.map(u => u.login)
23+
}
424
export interface SonarGroup {
525
id: string
626
name: string
@@ -9,15 +29,23 @@ export interface SonarGroup {
929
default: boolean
1030
}
1131

12-
export async function findGroupByName(name: string): Promise<void | SonarGroup> {
32+
export async function findGroupByName(name: string): Promise<SonarGroup | undefined> {
1333
const axiosInstance = getAxiosInstance()
14-
const groupsSearch: { paging: SonarPaging, groups: SonarGroup[] } = (await axiosInstance({
15-
url: 'user_groups/search',
16-
params: {
17-
q: name,
18-
},
19-
}))?.data
20-
return groupsSearch.groups.find(g => g.name === name)
34+
return find<SonarGroup>(iter(async (page, pageSize) => {
35+
const response = await axiosInstance({
36+
url: 'user_groups/search',
37+
params: {
38+
q: name,
39+
p: page,
40+
ps: pageSize,
41+
},
42+
})
43+
const data: { paging: SonarPaging, groups: SonarGroup[] } = response.data
44+
return {
45+
items: data.groups,
46+
paging: data.paging,
47+
}
48+
}), group => group.name === name)
2149
}
2250

2351
export async function ensureGroupExists(groupName: string) {
@@ -33,3 +61,27 @@ export async function ensureGroupExists(groupName: string) {
3361
})
3462
}
3563
}
64+
65+
export async function addUserToGroup(groupName: string, login: string) {
66+
const axiosInstance = getAxiosInstance()
67+
await axiosInstance({
68+
url: 'user_groups/add_user',
69+
method: 'post',
70+
params: {
71+
name: groupName,
72+
login,
73+
},
74+
})
75+
}
76+
77+
export async function removeUserFromGroup(groupName: string, login: string) {
78+
const axiosInstance = getAxiosInstance()
79+
await axiosInstance({
80+
url: 'user_groups/remove_user',
81+
method: 'post',
82+
params: {
83+
name: groupName,
84+
login,
85+
},
86+
})
87+
}

plugins/sonarqube/src/index.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,23 @@
1-
import type { HookStepsNames, Plugin } from '@cpn-console/hooks'
1+
import type { DeclareModuleGenerator, HookStepsNames, Plugin } from '@cpn-console/hooks'
22
import { getStatus } from './check.js'
3-
import { deleteProject, initSonar, setVariables, upsertProject } from './functions.js'
3+
import { deleteProject, setVariables, upsertAdminRole, upsertProject } from './functions.js'
44
import infos from './infos.js'
55
import monitor from './monitor.js'
66

7-
function start(_options: unknown) {
7+
function start() {
88
try {
9-
initSonar()
109
getStatus()
1110
} catch (_error) {}
1211
}
1312

1413
export const plugin: Plugin = {
1514
infos,
1615
subscribedHooks: {
16+
upsertAdminRole: {
17+
steps: {
18+
main: upsertAdminRole,
19+
},
20+
},
1721
upsertProject: {
1822
steps: {
1923
main: upsertProject,
@@ -34,4 +38,5 @@ declare module '@cpn-console/hooks' {
3438
interface PluginResult {
3539
errors?: Partial<Record<HookStepsNames, unknown>>
3640
}
41+
interface Config extends DeclareModuleGenerator<typeof infos, 'global'> {}
3742
}

0 commit comments

Comments
 (0)