Skip to content

Commit b225304

Browse files
committed
feat: segregate system role with prefix type as protected resources
Signed-off-by: William Phetsinorath <william.phetsinorath-open@interieur.gouv.fr>
1 parent ab056c1 commit b225304

File tree

11 files changed

+158
-37
lines changed

11 files changed

+158
-37
lines changed

apps/client/src/components/AdminRoleForm.vue

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<script lang="ts" setup>
22
import type { AdminPermsKeys, LettersQuery, SharedZodError, User } from '@cpn-console/shared'
3-
import { ADMIN_PERMS, adminPermsDetails, RoleSchema, shallowEqual } from '@cpn-console/shared'
3+
import { ADMIN_PERMS, adminPermsDetails, isManagedRole, isSystemRole, RoleSchema, shallowEqual } from '@cpn-console/shared'
44
import pDebounce from 'p-debounce'
55
import { computed, onBeforeMount, ref } from 'vue'
66
import { useUsersStore } from '@/stores/users.js'
@@ -31,6 +31,21 @@ const role = ref({
3131
permissions: props.permissions ?? 0n,
3232
})
3333
34+
const systemTypePrefix = 'system:'
35+
const isSystem = computed(() => isSystemRole(role.value.type))
36+
const isManaged = computed(() => isManagedRole(role.value.type))
37+
const roleTypeForSelect = computed({
38+
get() {
39+
const type = role.value.type
40+
if (type?.startsWith(systemTypePrefix)) return type.slice(systemTypePrefix.length)
41+
return type ?? 'managed'
42+
},
43+
set(value) {
44+
if (role.value.type?.startsWith(systemTypePrefix)) role.value.type = `${systemTypePrefix}${value}`
45+
else role.value.type = value
46+
},
47+
})
48+
3449
const isUpdated = computed(() => {
3550
return !shallowEqual(props, role.value)
3651
})
@@ -44,7 +59,7 @@ const tabListName = 'Liste d’onglet'
4459
const tabTitles = computed(() => [
4560
{ title: 'Général', icon: 'ri:checkbox-circle-line', tabId: 'general' },
4661
...(
47-
role.value.type === 'managed'
62+
isManaged.value
4863
? [{ title: 'Membres', icon: 'ri:checkbox-circle-line', tabId: 'members' }]
4964
: []),
5065
{ title: 'Fermer', icon: 'ri:close-line', tabId: 'close' },
@@ -150,6 +165,7 @@ const typeOptions = [
150165
label-visible
151166
hint="Ne doit pas dépasser 30 caractères."
152167
class="mb-5"
168+
:disabled="isSystem"
153169
/>
154170
<p
155171
class="fr-h6"
@@ -174,18 +190,19 @@ const typeOptions = [
174190
:label="perm.label"
175191
:hint="perm?.hint"
176192
:name="perm.key"
177-
:disabled="role.permissions & ADMIN_PERMS.MANAGE && perm.key !== 'MANAGE'"
193+
:disabled="isSystem || (role.permissions & ADMIN_PERMS.MANAGE && perm.key !== 'MANAGE')"
178194
@update:model-value="(checked: boolean) => updateChecked(checked, perm.key)"
179195
/>
180196
</div>
181197
<DsfrSelect
182-
v-model="role.type"
198+
v-model="roleTypeForSelect"
183199
data-testid="roleTypeSelect"
184200
select-id="roleTypeSelect"
185201
label="Type"
186202
label-visible
187203
:options="typeOptions"
188204
class="mb-5"
205+
:disabled="isSystem"
189206
/>
190207
<DsfrInput
191208
v-model="role.oidcGroup"
@@ -194,16 +211,18 @@ const typeOptions = [
194211
label-visible
195212
placeholder="/admin"
196213
class="mb-5"
214+
:disabled="isSystem"
197215
/>
198216
<DsfrButton
199217
data-testid="saveBtn"
200218
label="Enregistrer"
201219
secondary
202-
:disabled="!isUpdated || !!errorSchema"
220+
:disabled="!isUpdated || !!errorSchema || isSystem"
203221
class="mr-5"
204222
@click="$emit('save', { ...role, permissions: role.permissions.toString() })"
205223
/>
206224
<DsfrButton
225+
v-if="!isSystem"
207226
data-testid="deleteBtn"
208227
label="Supprimer"
209228
secondary

apps/client/src/components/ProjectRoleForm.vue

Lines changed: 42 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<script lang="ts" setup>
22
import type { Member, ProjectRoleBigint, ProjectV2 } from '@cpn-console/shared'
3-
import { PROJECT_PERMS, projectPermsDetails, shallowEqual } from '@cpn-console/shared'
3+
import { isManagedRole, isSystemRole, PROJECT_PERMS, projectPermsDetails, shallowEqual } from '@cpn-console/shared'
44
import { computed, ref } from 'vue'
55
66
const props = defineProps<{
@@ -29,6 +29,21 @@ const role = ref({
2929
type: props.type ?? 'managed',
3030
})
3131
32+
const isSystem = computed(() => isSystemRole(role.value.type))
33+
const isManaged = computed(() => isManagedRole(role.value.type))
34+
const systemTypePrefix = 'system:'
35+
const roleTypeForSelect = computed({
36+
get() {
37+
const type = role.value.type
38+
if (type?.startsWith(systemTypePrefix)) return type.slice(systemTypePrefix.length)
39+
return type ?? 'managed'
40+
},
41+
set(value) {
42+
if (role.value.type?.startsWith(systemTypePrefix)) role.value.type = `${systemTypePrefix}${value}`
43+
else role.value.type = value
44+
},
45+
})
46+
3247
const isUpdated = computed(() => {
3348
if (role.value.isEveryone) return props.permissions !== role.value.permissions
3449
return !shallowEqual(props, role.value)
@@ -38,7 +53,7 @@ const tabListName = 'Liste d’onglet'
3853
const tabTitles = computed(() => [
3954
{ title: 'Général', icon: 'ri:checkbox-circle-line', tabId: 'general' },
4055
...(
41-
role.value.type === 'managed'
56+
isManaged.value
4257
? [{ title: 'Membres', icon: 'ri:checkbox-circle-line', tabId: 'members' }]
4358
: []),
4459
{ title: 'Fermer', icon: 'ri:close-line', tabId: 'close' },
@@ -74,30 +89,34 @@ const typeOptions = [
7489
panel-id="general"
7590
tab-id="general"
7691
>
77-
<h6>Nom du rôle</h6>
7892
<DsfrInput
7993
v-model="role.name"
8094
data-testid="roleNameInput"
95+
label="Nom du rôle"
8196
label-visible
8297
class="mb-5"
83-
:disabled="role.isEveryone"
84-
/>
85-
<h6>Type</h6>
86-
<DsfrSelect
87-
v-model="role.type"
88-
select-id="roleTypeSelect"
89-
:options="typeOptions"
90-
class="mb-5"
91-
:disabled="role.isEveryone"
92-
/>
93-
<h6>Groupe OIDC</h6>
94-
<DsfrInput
95-
v-model="role.oidcGroup"
96-
data-testid="roleOidcGroupInput"
97-
label-visible
98-
class="mb-5"
99-
:disabled="role.isEveryone"
98+
:disabled="role.isEveryone || isSystem"
10099
/>
100+
<template v-if="!role.isEveryone">
101+
<DsfrSelect
102+
v-model="roleTypeForSelect"
103+
data-testid="roleTypeSelect"
104+
select-id="roleTypeSelect"
105+
label="Type"
106+
label-visible
107+
:options="typeOptions"
108+
class="mb-5"
109+
:disabled="isSystem"
110+
/>
111+
<DsfrInput
112+
v-model="role.oidcGroup"
113+
data-testid="roleOidcGroupInput"
114+
label="Groupe OIDC"
115+
label-visible
116+
class="mb-5"
117+
:disabled="isSystem"
118+
/>
119+
</template>
101120
<h6>Permissions</h6>
102121
<div
103122
v-for="scope in projectPermsDetails"
@@ -117,20 +136,20 @@ const typeOptions = [
117136
:label="perm?.label"
118137
:hint="perm?.hint"
119138
:name="perm.key"
120-
:disabled="(role.permissions & PROJECT_PERMS.MANAGE && perm.key !== 'MANAGE')"
139+
:disabled="isSystem || (role.permissions & PROJECT_PERMS.MANAGE && perm.key !== 'MANAGE')"
121140
@update:model-value="(checked: boolean) => updateChecked(checked, PROJECT_PERMS[perm.key])"
122141
/>
123142
</div>
124143
<DsfrButton
125144
label="Enregistrer"
126145
data-testid="saveBtn"
127146
secondary
128-
:disabled="!isUpdated"
147+
:disabled="!isUpdated || isSystem"
129148
class="mr-5"
130149
@click="$emit('save', role)"
131150
/>
132151
<DsfrButton
133-
v-if="!role.isEveryone"
152+
v-if="!role.isEveryone && !isSystem"
134153
data-testid="deleteBtn"
135154
label="Supprimer"
136155
secondary

apps/client/src/components/ProjectRoles.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ async function updateMember(checked: boolean, userId: Member['userId']) {
4141
if (!matchingMember) return
4242
4343
const newRoleList = checked
44-
? [...matchingMember.roleIds, ...selectedRole.value.id]
44+
? [...matchingMember.roleIds, selectedRole.value.id]
4545
: matchingMember.roleIds.filter(id => id !== selectedRole.value?.id)
4646
4747
await props.project.Members.patch([{ userId, roles: newRoleList }])

apps/server-nestjs/src/modules/healthz/healthz.module.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Module } from '@nestjs/common'
22
import { TerminusModule } from '@nestjs/terminus'
3-
import { KeycloakModule } from '../keycloak/keycloak.module'
43
import { DatabaseModule } from '../../cpin-module/infrastructure/database/database.module'
4+
import { KeycloakModule } from '../keycloak/keycloak.module'
55
import { HealthzController } from './healthz.controller'
66

77
@Module({
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
-- Migrate well-known project roles to the new "system:"-prefixed type
2+
UPDATE "ProjectRole"
3+
SET "type" = 'system:managed'
4+
WHERE
5+
"type" = 'managed'
6+
AND "oidcGroup" ~ '^/[^/]+/console/(admin|devops|developer|readonly)$';
7+
8+
-- Migrate well-known admin roles to the new "system:"-prefixed type
9+
UPDATE "AdminRole"
10+
SET "type" = 'system:managed'
11+
WHERE id IN (
12+
'76229c96-4716-45bc-99da-00498ec9018c',
13+
'6bebe7b2-0f0a-456e-ab7f-b3d7640a7cbf',
14+
'35848aa2-e881-4770-9844-0c5c3693e506'
15+
);

apps/server/src/resources/project-role/business.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { projectRoleContract } from '@cpn-console/shared'
22
import type { Project, ProjectRole } from '@prisma/client'
3+
import { isSystemRole } from '@cpn-console/shared'
34
import prisma from '@/prisma.js'
45
import {
56
deleteRole as deleteRoleQuery,
@@ -32,6 +33,12 @@ export async function patchRoles(projectId: Project['id'], roles: typeof project
3233
for (const dbRole of dbRoles) {
3334
const matchingRole = roles.find(role => role.id === dbRole.id)
3435
if (matchingRole) {
36+
if (isSystemRole(dbRole.type)) {
37+
return new BadRequest400('Ce rôle système ne peut pas être modifié')
38+
}
39+
if (isSystemRole(matchingRole.type)) {
40+
return new BadRequest400('Impossible de modifier un rôle en rôle système')
41+
}
3542
if (typeof matchingRole?.position !== 'undefined' && !positionsAvailable.includes(matchingRole?.position)) {
3643
positionsAvailable.push(matchingRole.position)
3744
}
@@ -62,6 +69,9 @@ export async function patchRoles(projectId: Project['id'], roles: typeof project
6269
export async function createRole(projectId: Project['id'], role: typeof projectRoleContract.createProjectRole.body._type) {
6370
const project = await prisma.project.findUnique({ where: { id: projectId }, select: { slug: true } })
6471
if (!project) throw new NotFound404()
72+
if (isSystemRole(role.type)) {
73+
throw new BadRequest400('Impossible de créer un rôle système')
74+
}
6575
const dbMaxPosRole = (await prisma.projectRole.findFirst({
6676
where: { projectId },
6777
orderBy: { position: 'desc' },
@@ -100,6 +110,11 @@ export async function countRolesMembers(projectId: Project['id']) {
100110
}
101111

102112
export async function deleteRole(roleId: Project['id']) {
113+
const role = await prisma.projectRole.findUnique({ where: { id: roleId }, select: { type: true } })
114+
if (!role) throw new NotFound404()
115+
if (isSystemRole(role.type)) {
116+
throw new BadRequest400('Ce rôle système ne peut pas être supprimé')
117+
}
103118
await hook.projectRole.delete(roleId)
104119
await deleteRoleQuery(roleId)
105120
return null

apps/server/src/resources/project/queries.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -264,28 +264,28 @@ export function initializeProject(params: CreateProjectParams) {
264264
permissions: PROJECT_PERMS.MANAGE,
265265
position: 0,
266266
oidcGroup: `/${params.slug}/console/admin`,
267-
type: 'managed',
267+
type: 'system:managed',
268268
},
269269
{
270270
name: 'DevOps',
271271
permissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS | PROJECT_PERMS.MANAGE_REPOSITORIES | PROJECT_PERMS.REPLAY_HOOKS | PROJECT_PERMS.SEE_SECRETS | PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.LIST_REPOSITORIES,
272272
position: 1,
273273
oidcGroup: `/${params.slug}/console/devops`,
274-
type: 'managed',
274+
type: 'system:managed',
275275
},
276276
{
277277
name: 'Développeur',
278278
permissions: PROJECT_PERMS.MANAGE_REPOSITORIES | PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.LIST_REPOSITORIES,
279279
position: 2,
280280
oidcGroup: `/${params.slug}/console/developer`,
281-
type: 'managed',
281+
type: 'system:managed',
282282
},
283283
{
284284
name: 'Lecture seule',
285285
permissions: PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.LIST_REPOSITORIES,
286286
position: 3,
287287
oidcGroup: `/${params.slug}/console/readonly`,
288-
type: 'managed',
288+
type: 'system:managed',
289289
},
290290
],
291291
},

packages/shared/src/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@ export * from './date.js'
33
export * from './functions.js'
44
export * from './permissions.js'
55
export * from './plugins.js'
6+
export * from './roles.js'
67
export * from './schemas.js'
78
export * from './types.js'

packages/shared/src/utils/roles.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
export const systemRoleTypePrefix = 'system:' as const
2+
3+
export function isSystemRole(type: string | null | undefined) {
4+
return !!type?.startsWith(systemRoleTypePrefix)
5+
}
6+
7+
function getBaseRole(type: string | null | undefined) {
8+
if (!type) return undefined
9+
return isSystemRole(type) ? type.slice(systemRoleTypePrefix.length) : type
10+
}
11+
12+
export function isManaged(type: string | null | undefined) {
13+
return getBaseRole(type) === 'managed'
14+
}
15+
16+
export function isGlobal(type: string | null | undefined) {
17+
return getBaseRole(type) === 'global'
18+
}
19+
20+
export function isExternal(type: string | null | undefined) {
21+
return getBaseRole(type) === 'external'
22+
}

packages/test-utils/src/imports/data.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,15 @@ export const data = {
2525
position: 0,
2626
oidcGroup: '/admin',
2727
name: 'Root Administrateur Plateforme',
28-
type: 'managed',
28+
type: 'system:managed',
2929
},
3030
{
3131
id: '6bebe7b2-0f0a-456e-ab7f-b3d7640a7cbf',
3232
permissions: '3n',
3333
position: 0,
3434
oidcGroup: '/console/admin',
3535
name: 'Administrateur Plateforme',
36-
type: 'managed',
36+
type: 'system:managed',
3737
},
3838
{
3939
id: 'eadf604f-5f54-4744-bdfb-4793d2271e9b',
@@ -49,7 +49,7 @@ export const data = {
4949
position: 2,
5050
oidcGroup: '/console/readonly',
5151
name: 'Lecture Seule Plateforme',
52-
type: 'managed',
52+
type: 'system:managed',
5353
},
5454
{
5555
id: '034f589f-1750-4b15-bb34-4cd995e7fcaa',

0 commit comments

Comments
 (0)