diff --git a/apps/client/src/components/AdminRoleForm.vue b/apps/client/src/components/AdminRoleForm.vue
index e576aed06..b4457cb91 100644
--- a/apps/client/src/components/AdminRoleForm.vue
+++ b/apps/client/src/components/AdminRoleForm.vue
@@ -12,6 +12,7 @@ const props = withDefaults(defineProps<{
permissions: bigint
name: string
oidcGroup: string
+ type?: string
}>(), {
name: 'Nouveau rôle',
oidcGroup: '',
@@ -139,6 +140,7 @@ function closeModal() {
label-visible
hint="Ne doit pas dépasser 30 caractères."
class="mb-5"
+ :disabled="role.type === 'system'"
/>
import { computed, ref } from 'vue'
-import type { Member, ProjectV2, RoleBigint } from '@cpn-console/shared'
+import type { Member, ProjectRoleBigint, ProjectV2 } from '@cpn-console/shared'
import { PROJECT_PERMS, projectPermsDetails, shallowEqual } from '@cpn-console/shared'
const props = defineProps<{
@@ -10,12 +10,14 @@ const props = defineProps<{
allMembers: Member[]
projectId: ProjectV2['id']
isEveryone: boolean
+ oidcGroup?: string
+ type?: string
}>()
defineEmits<{
delete: []
updateMemberRoles: [checked: boolean, userId: Member['userId']]
- save: [value: Omit]
+ save: [value: Omit]
cancel: []
}>()
const router = useRouter()
@@ -23,6 +25,7 @@ const role = ref({
...props,
permissions: props.permissions ?? 0n,
allMembers: props.allMembers ?? [],
+ oidcGroup: props.oidcGroup ?? '',
})
const isUpdated = computed(() => {
@@ -68,6 +71,14 @@ function updateChecked(checked: boolean, value: bigint) {
class="mb-5"
:disabled="role.isEveryone"
/>
+ Groupe OIDC
+
Permissions
updateChecked(checked, PROJECT_PERMS[perm.key])"
/>
@@ -100,7 +111,7 @@ function updateChecked(checked: boolean, value: bigint) {
@click="$emit('save', role)"
/>
-import type { Member, Role, RoleBigint } from '@cpn-console/shared'
+import type { Member, ProjectRole, ProjectRoleBigint, Role, RoleBigint } from '@cpn-console/shared'
import { useSnackbarStore } from '@/stores/snackbar.js'
import type { Project } from '@/utils/project-utils.js'
@@ -11,7 +11,7 @@ const snackbarStore = useSnackbarStore()
const selectedId = ref()
-type RoleItem = Omit & { permissions: bigint, memberCounts: number, isEveryone: boolean }
+type RoleItem = Omit & { permissions: bigint, memberCounts: number, isEveryone: boolean }
const roleList = ref([])
@@ -56,7 +56,7 @@ async function saveEveryoneRole(role: { permissions: bigint }) {
snackbarStore.setMessage('Rôle mis à jour', 'success')
}
-async function saveRole(role: Omit) {
+async function saveRole(role: Omit) {
if (role.id === 'everyone') {
await saveEveryoneRole(role)
snackbarStore.setMessage('Rôle mis à jour', 'success')
@@ -67,6 +67,7 @@ async function saveRole(role: Omit) {
id: selectedRole.value.id,
permissions: role.permissions.toString(),
name: role.name,
+ oidcGroup: role.oidcGroup,
}])
reload()
snackbarStore.setMessage('Rôle mis à jour', 'success')
@@ -86,6 +87,7 @@ function reload() {
permissions: BigInt(props.project.everyonePerms),
position: 1000,
isEveryone: true,
+ projectId: props.project.id,
})
roleList.value = roles
}
@@ -142,6 +144,8 @@ watch(props.project, reload, { immediate: true })
:permissions="BigInt(selectedRole.permissions)"
:project-id="project.id"
:is-everyone="selectedRole.isEveryone"
+ :oidc-group="selectedRole.oidcGroup"
+ :type="selectedRole.type"
:all-members="project.members"
@delete="deleteRole(selectedRole.id)"
@update-member-roles="(checked: boolean, userId: Member['userId']) => updateMember(checked, userId)"
diff --git a/apps/client/src/utils/project-utils.ts b/apps/client/src/utils/project-utils.ts
index 0b31bdda9..58b03e265 100644
--- a/apps/client/src/utils/project-utils.ts
+++ b/apps/client/src/utils/project-utils.ts
@@ -64,7 +64,7 @@ export class Project implements ProjectV2 {
locked: boolean
owner: Omit
ownerId: string
- roles: { id: string, name: string, permissions: string, position: number }[]
+ roles: { id: string, name: string, permissions: string, position: number, projectId: string, oidcGroup?: string, type?: string }[]
members: ({ userId: string, firstName: string, lastName: string, email: string, roleIds: string[] } | { updatedAt: string, createdAt: string, firstName: string, lastName: string, email: string, userId: string, roleIds: string[] })[]
createdAt: string
updatedAt: string
diff --git a/apps/client/src/views/admin/AdminRoles.vue b/apps/client/src/views/admin/AdminRoles.vue
index 155222222..e784168cc 100644
--- a/apps/client/src/views/admin/AdminRoles.vue
+++ b/apps/client/src/views/admin/AdminRoles.vue
@@ -117,6 +117,7 @@ onBeforeMount(async () => {
:name="selectedRole.name"
:permissions="BigInt(selectedRole.permissions)"
:oidc-group="selectedRole.oidcGroup"
+ :type="selectedRole.type"
@delete="deleteRole(selectedRole.id)"
@save="(role: Pick) => saveRole(role)"
@cancel="() => cancel()"
diff --git a/apps/server/src/prisma/migrations/20260127154602_add_oidc_group_to_project_role/migration.sql b/apps/server/src/prisma/migrations/20260127154602_add_oidc_group_to_project_role/migration.sql
new file mode 100644
index 000000000..08c9944cb
--- /dev/null
+++ b/apps/server/src/prisma/migrations/20260127154602_add_oidc_group_to_project_role/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "ProjectRole" ADD COLUMN "oidcGroup" TEXT NOT NULL;
diff --git a/apps/server/src/prisma/migrations/20260127164002_add_type_to_roles/migration.sql b/apps/server/src/prisma/migrations/20260127164002_add_type_to_roles/migration.sql
new file mode 100644
index 000000000..25ca43ca7
--- /dev/null
+++ b/apps/server/src/prisma/migrations/20260127164002_add_type_to_roles/migration.sql
@@ -0,0 +1,11 @@
+-- AlterTable
+ALTER TABLE "AdminRole" ADD COLUMN "type" TEXT NOT NULL DEFAULT 'custom';
+
+-- AlterTable
+ALTER TABLE "ProjectRole" ADD COLUMN "type" TEXT NOT NULL DEFAULT 'custom';
+
+-- Update AdminRole system roles
+UPDATE "AdminRole" SET "type" = 'system' WHERE "name" IN ('Admin', 'Admin Locaux');
+
+-- Update ProjectRole system roles
+UPDATE "ProjectRole" SET "type" = 'system' WHERE "name" IN ('Administrateur', 'DevOps', 'Développer', 'Lecture seule');
diff --git a/apps/server/src/prisma/migrations/migration_lock.toml b/apps/server/src/prisma/migrations/migration_lock.toml
index 648c57fd5..044d57cdb 100644
--- a/apps/server/src/prisma/migrations/migration_lock.toml
+++ b/apps/server/src/prisma/migrations/migration_lock.toml
@@ -1,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
-provider = "postgresql"
\ No newline at end of file
+provider = "postgresql"
diff --git a/apps/server/src/prisma/schema/admin.prisma b/apps/server/src/prisma/schema/admin.prisma
index 71cfb1754..e4a193197 100644
--- a/apps/server/src/prisma/schema/admin.prisma
+++ b/apps/server/src/prisma/schema/admin.prisma
@@ -12,6 +12,7 @@ model AdminRole {
permissions BigInt
position Int @db.SmallInt
oidcGroup String @default("")
+ type String @default("custom")
}
model SystemSetting {
diff --git a/apps/server/src/prisma/schema/project.prisma b/apps/server/src/prisma/schema/project.prisma
index 9bff043eb..dfb84f521 100644
--- a/apps/server/src/prisma/schema/project.prisma
+++ b/apps/server/src/prisma/schema/project.prisma
@@ -66,6 +66,8 @@ model ProjectRole {
permissions BigInt
projectId String @db.Uuid
position Int @db.SmallInt
+ oidcGroup String @default("")
+ type String @default("custom")
project Project @relation(fields: [projectId], references: [id])
}
diff --git a/apps/server/src/resources/admin-role/business.spec.ts b/apps/server/src/resources/admin-role/business.spec.ts
index 9174f544e..c7136332a 100644
--- a/apps/server/src/resources/admin-role/business.spec.ts
+++ b/apps/server/src/resources/admin-role/business.spec.ts
@@ -1,61 +1,78 @@
+import { faker } from '@faker-js/faker'
import { describe, expect, it } from 'vitest'
import type { AdminRole, User } from '@prisma/client'
-import { faker } from '@faker-js/faker'
import prisma from '../../__mocks__/prisma.js'
-import { BadRequest400 } from '../../utils/errors.ts'
+import { BadRequest400, Forbidden403 } from '../../utils/errors.ts'
import { countRolesMembers, createRole, deleteRole, listRoles, patchRoles } from './business.ts'
describe('test admin-role business', () => {
describe('listRoles', () => {
it('should stringify bigint', async () => {
- const partialRole: Partial = {
+ const partialRole: AdminRole = {
+ id: faker.string.uuid(),
+ name: faker.string.alphanumeric(),
permissions: 4n,
+ position: 0,
+ oidcGroup: '',
+ type: 'custom',
}
prisma.adminRole.findMany.mockResolvedValueOnce([partialRole])
const response = await listRoles()
- expect(response).toEqual([{ permissions: '4' }])
+ expect(response).toContainEqual(expect.objectContaining({ permissions: '4', type: 'custom' }))
})
})
describe('createRole', () => {
it('should create role with incremented position when position 0 is the highest', async () => {
- const dbRole: Partial = {
+ const dbRole: AdminRole = {
+ id: faker.string.uuid(),
+ name: faker.string.alphanumeric(),
permissions: 4n,
position: 0,
+ oidcGroup: '',
+ type: 'custom',
}
prisma.adminRole.findFirst.mockResolvedValueOnce(dbRole)
prisma.adminRole.findMany.mockResolvedValueOnce([dbRole])
- prisma.adminRole.create.mockResolvedValue(null)
+ prisma.adminRole.create.mockResolvedValue(dbRole)
await createRole({ name: 'test' })
expect(prisma.adminRole.create).toHaveBeenCalledWith({ data: { name: 'test', permissions: 0n, position: 1 } })
})
it('should create role with incremented position with bigger position', async () => {
- const dbRole: Partial = {
+ const dbRole: AdminRole = {
+ id: faker.string.uuid(),
+ name: faker.string.alphanumeric(),
permissions: 4n,
position: 50,
+ oidcGroup: '',
+ type: 'custom',
}
prisma.adminRole.findFirst.mockResolvedValueOnce(dbRole)
prisma.adminRole.findMany.mockResolvedValueOnce([dbRole])
- prisma.adminRole.create.mockResolvedValue(null)
+ prisma.adminRole.create.mockResolvedValue(dbRole)
await createRole({ name: 'test' })
expect(prisma.adminRole.create).toHaveBeenCalledWith({ data: { name: 'test', permissions: 0n, position: 51 } })
})
it('should create role with incremented position with no role in db', async () => {
- const dbRole: Partial = {
+ const dbRole: AdminRole = {
+ id: faker.string.uuid(),
+ name: faker.string.alphanumeric(),
permissions: 4n,
position: 50,
+ oidcGroup: '',
+ type: 'custom',
}
- prisma.adminRole.findFirst.mockResolvedValueOnce(undefined)
+ prisma.adminRole.findFirst.mockResolvedValueOnce(null)
prisma.adminRole.findMany.mockResolvedValueOnce([dbRole])
- prisma.adminRole.create.mockResolvedValue(null)
+ prisma.adminRole.create.mockResolvedValue(dbRole)
await createRole({ name: 'test' })
expect(prisma.adminRole.create).toHaveBeenCalledWith({ data: { name: 'test', permissions: 0n, position: 0 } })
@@ -65,42 +82,119 @@ describe('test admin-role business', () => {
const roleId = faker.string.uuid()
it('should delete role and remove id from concerned users', async () => {
const users = [{
- adminRoleIds: [roleId],
id: faker.string.uuid(),
+ type: 'human',
+ firstName: faker.person.firstName(),
+ lastName: faker.person.lastName(),
+ email: faker.internet.email(),
+ adminRoleIds: [roleId],
+ createdAt: faker.date.past(),
+ updatedAt: faker.date.recent(),
+ lastLogin: faker.date.past(),
}, {
- adminRoleIds: [roleId, faker.string.uuid()],
id: faker.string.uuid(),
- }] as const satisfies Partial[]
+ type: 'human',
+ firstName: faker.person.firstName(),
+ lastName: faker.person.lastName(),
+ email: faker.internet.email(),
+ adminRoleIds: [roleId, faker.string.uuid()],
+ createdAt: faker.date.past(),
+ updatedAt: faker.date.recent(),
+ lastLogin: faker.date.past(),
+ }] as const satisfies User[]
+
+ const dbRole: AdminRole = {
+ name: 'Admin',
+ id: roleId,
+ permissions: 4n,
+ position: 50,
+ oidcGroup: '',
+ type: 'custom',
+ }
prisma.user.findMany.mockResolvedValueOnce(users)
prisma.adminRole.findMany.mockResolvedValueOnce([])
- prisma.adminRole.create.mockResolvedValue(null)
+ prisma.adminRole.findUnique.mockResolvedValueOnce(dbRole)
+ prisma.adminRole.create.mockResolvedValue(dbRole)
await deleteRole(roleId)
expect(prisma.user.update).toHaveBeenNthCalledWith(1, { where: { id: users[0].id }, data: { adminRoleIds: [] } })
expect(prisma.user.update).toHaveBeenNthCalledWith(2, { where: { id: users[1].id }, data: { adminRoleIds: [users[1].adminRoleIds[1]] } })
expect(prisma.adminRole.delete).toHaveBeenCalledWith({ where: { id: roleId } })
})
+
+ it('should throw Forbidden403 when deleting a system role', async () => {
+ const dbRole: AdminRole = {
+ name: 'Admin',
+ id: roleId,
+ permissions: 4n,
+ position: 50,
+ oidcGroup: '',
+ type: 'system',
+ }
+ const users = [{
+ id: faker.string.uuid(),
+ type: 'human',
+ firstName: faker.person.firstName(),
+ lastName: faker.person.lastName(),
+ email: faker.internet.email(),
+ adminRoleIds: [roleId],
+ createdAt: faker.date.past(),
+ updatedAt: faker.date.recent(),
+ lastLogin: faker.date.past(),
+ }] as const satisfies User[]
+ prisma.adminRole.findFirst.mockResolvedValueOnce(dbRole)
+ prisma.user.findMany.mockResolvedValue(users)
+
+ await expect(deleteRole(roleId)).rejects.toThrow(Forbidden403)
+ expect(prisma.adminRole.delete).not.toHaveBeenCalled()
+ })
})
describe('countRolesMembers', () => {
it('should return aggregated role member counts', async () => {
- const partialRoles = [{
+ const roles = [{
id: faker.string.uuid(),
+ name: faker.company.name(),
+ oidcGroup: '',
+ permissions: faker.number.bigInt({ min: 0n, max: 50000n }),
+ position: 0,
+ type: 'custom',
}, {
id: faker.string.uuid(),
- }] as const satisfies Partial[]
+ name: faker.company.name(),
+ oidcGroup: '',
+ permissions: faker.number.bigInt({ min: 0n, max: 50000n }),
+ position: 1,
+ type: 'custom',
+ }] as const satisfies AdminRole[]
const users = [{
- adminRoleIds: [partialRoles[0].id, partialRoles[1].id],
+ id: faker.string.uuid(),
+ type: 'human',
+ firstName: faker.person.firstName(),
+ lastName: faker.person.lastName(),
+ email: faker.internet.email(),
+ adminRoleIds: [roles[0].id, roles[1].id],
+ createdAt: faker.date.past(),
+ updatedAt: faker.date.recent(),
+ lastLogin: faker.date.past(),
}, {
- adminRoleIds: [partialRoles[1].id],
- }] as const satisfies Partial[]
- prisma.adminRole.findMany.mockResolvedValue(partialRoles)
+ id: faker.string.uuid(),
+ type: 'human',
+ firstName: faker.person.firstName(),
+ lastName: faker.person.lastName(),
+ email: faker.internet.email(),
+ adminRoleIds: [roles[1].id],
+ createdAt: faker.date.past(),
+ updatedAt: faker.date.recent(),
+ lastLogin: faker.date.past(),
+ }] as const satisfies User[]
+ prisma.adminRole.findMany.mockResolvedValue(roles)
prisma.user.findMany.mockResolvedValue(users)
const response = await countRolesMembers()
- expect(response).toEqual({ [partialRoles[0].id]: 1, [partialRoles[1].id]: 2 })
+ expect(response).toEqual({ [roles[0].id]: 1, [roles[1].id]: 2 })
})
})
describe('patchRoles', () => {
@@ -110,14 +204,36 @@ describe('test admin-role business', () => {
oidcGroup: '',
permissions: faker.number.bigInt({ min: 0n, max: 50000n }),
position: 0,
+ type: 'custom',
}, {
id: faker.string.uuid(),
name: faker.company.name(),
oidcGroup: '',
permissions: faker.number.bigInt({ min: 0n, max: 50000n }),
position: 1,
+ type: 'custom',
}]
+ it('should throw Forbidden403 when renaming a system role', async () => {
+ const systemRole: AdminRole = {
+ id: faker.string.uuid(),
+ name: 'Admin',
+ permissions: 10n,
+ position: 0,
+ oidcGroup: 'admin-group',
+ type: 'system',
+ }
+ prisma.adminRole.findMany.mockResolvedValue([systemRole])
+
+ const updateRoles = [{
+ id: systemRole.id,
+ name: 'New Admin Name',
+ }]
+
+ await expect(patchRoles(updateRoles)).rejects.toThrow(Forbidden403)
+ expect(prisma.adminRole.update).toHaveBeenCalledTimes(0)
+ })
+
it('should do nothing', async () => {
prisma.adminRole.findMany.mockResolvedValue([])
await patchRoles([])
@@ -125,7 +241,7 @@ describe('test admin-role business', () => {
})
it('should return 400 if incoherent positions', async () => {
- const updateRoles: Pick = [
+ const updateRoles: Pick[] = [
{ id: dbRoles[0].id, position: 1 },
{ id: dbRoles[1].id, position: 1 },
]
@@ -137,7 +253,7 @@ describe('test admin-role business', () => {
expect(prisma.adminRole.update).toHaveBeenCalledTimes(0)
})
it('should return 400 if incoherent positions (missing roles)', async () => {
- const updateRoles: Pick = [
+ const updateRoles: Pick[] = [
{ id: dbRoles[1].id, position: 1 },
]
prisma.adminRole.findMany.mockResolvedValue(dbRoles)
@@ -148,7 +264,7 @@ describe('test admin-role business', () => {
expect(prisma.adminRole.update).toHaveBeenCalledTimes(0)
})
it('should update positions', async () => {
- const updateRoles: Pick = [
+ const updateRoles: Pick[] = [
{ id: dbRoles[0].id, position: 1 },
{ id: dbRoles[1].id, position: 0 },
]
@@ -159,7 +275,7 @@ describe('test admin-role business', () => {
expect(prisma.adminRole.update).toHaveBeenCalledTimes(2)
})
it('should update permissions', async () => {
- const updateRoles: Pick = [
+ const updateRoles: (Pick & { permissions?: string })[] = [
{ id: dbRoles[1].id, permissions: '0' },
]
prisma.adminRole.findMany.mockResolvedValue(dbRoles)
@@ -173,6 +289,7 @@ describe('test admin-role business', () => {
oidcGroup: dbRoles[1].oidcGroup,
permissions: 0n,
position: 1,
+ type: 'custom',
},
where: {
id: dbRoles[1].id,
diff --git a/apps/server/src/resources/admin-role/business.ts b/apps/server/src/resources/admin-role/business.ts
index a43cebf22..15c42df2b 100644
--- a/apps/server/src/resources/admin-role/business.ts
+++ b/apps/server/src/resources/admin-role/business.ts
@@ -4,7 +4,7 @@ import {
listAdminRoles,
} from '@/resources/queries-index.js'
import type { ErrorResType } from '@/utils/errors.js'
-import { BadRequest400 } from '@/utils/errors.js'
+import { BadRequest400, Forbidden403 } from '@/utils/errors.js'
import prisma from '@/prisma.js'
export async function listRoles() {
@@ -29,11 +29,15 @@ export async function patchRoles(roles: typeof adminRoleContract.patchAdminRoles
permissions: matchingRole?.permissions ? BigInt(matchingRole?.permissions) : dbRole.permissions,
position: matchingRole?.position ?? dbRole.position,
oidcGroup: matchingRole?.oidcGroup ?? dbRole.oidcGroup,
+ type: matchingRole?.type ?? dbRole.type,
}
})
if (positionsAvailable.length && positionsAvailable.length !== dbRoles.length) return new BadRequest400('Les numéros de position des rôles sont incohérentes')
for (const { id, ...role } of updatedRoles) {
+ if (role.type === 'system') {
+ throw new Forbidden403('Ce rôle système ne peut pas être renommé')
+ }
await prisma.adminRole.update({ where: { id }, data: role })
}
@@ -74,6 +78,10 @@ export async function countRolesMembers() {
}
export async function deleteRole(roleId: Project['id']) {
+ const role = await prisma.adminRole.findFirst({ where: { id: roleId } })
+ if (role?.type === 'system') {
+ throw new Forbidden403('Ce rôle système ne peut pas être supprimé')
+ }
const allUsers = await prisma.user.findMany({
where: {
adminRoleIds: { has: roleId },
diff --git a/apps/server/src/resources/admin-role/queries.ts b/apps/server/src/resources/admin-role/queries.ts
index f4893b317..3a4b545a3 100644
--- a/apps/server/src/resources/admin-role/queries.ts
+++ b/apps/server/src/resources/admin-role/queries.ts
@@ -12,19 +12,20 @@ export function createAdminRole(data: Pick) {
- return prisma.projectRole.updateMany({
+ return prisma.adminRole.updateMany({
where: { id },
data,
})
}
export function deleteAdminRole(id: AdminRole['id']) {
- return prisma.projectRole.delete({
+ return prisma.adminRole.delete({
where: {
id,
},
diff --git a/apps/server/src/resources/project-role/business.spec.ts b/apps/server/src/resources/project-role/business.spec.ts
index cdbaa3fd1..de40a6406 100644
--- a/apps/server/src/resources/project-role/business.spec.ts
+++ b/apps/server/src/resources/project-role/business.spec.ts
@@ -1,63 +1,83 @@
import { faker } from '@faker-js/faker'
import { describe, expect, it } from 'vitest'
-import type { ProjectMembers, ProjectRole, User } from '@prisma/client'
+import type { ProjectMembers, ProjectRole } from '@prisma/client'
import prisma from '../../__mocks__/prisma.js'
-import { BadRequest400 } from '../../utils/errors.ts'
+import { BadRequest400, Forbidden403 } from '../../utils/errors.ts'
import { countRolesMembers, createRole, deleteRole, listRoles, patchRoles } from './business.ts'
const projectId = faker.string.uuid()
describe('test project-role business', () => {
describe('listRoles', () => {
it('should stringify bigint', async () => {
- const partialRole: Partial = {
+ const partialRole: ProjectRole = {
+ id: faker.string.uuid(),
+ name: faker.string.alphanumeric(),
+ projectId,
permissions: 4n,
+ position: 0,
+ oidcGroup: '',
+ type: 'custom',
}
- prisma.projectRole.findMany.mockResolvedValueOnce([partialRole])
+ prisma.projectRole.findMany.mockResolvedValueOnce([partialRole as any])
const response = await listRoles(projectId)
- expect(response).toEqual([{ permissions: '4' }])
+ expect(response).toContainEqual(expect.objectContaining({ permissions: '4', type: 'custom' }))
})
})
describe('createRole', () => {
it('should create role with incremented position when position 0 is the highest', async () => {
- const dbRole: Partial = {
+ const dbRole: ProjectRole = {
+ id: faker.string.uuid(),
+ name: 'custom-role',
projectId,
permissions: 4n,
position: 0,
+ oidcGroup: '',
+ type: 'custom',
}
prisma.projectRole.findFirst.mockResolvedValueOnce(dbRole)
prisma.projectRole.findMany.mockResolvedValueOnce([dbRole])
- prisma.projectRole.create.mockResolvedValue(null)
+ prisma.projectRole.create.mockResolvedValue(dbRole)
await createRole(projectId, { name: 'test', permissions: '4' })
expect(prisma.projectRole.create).toHaveBeenCalledWith({ data: { name: 'test', permissions: 4n, position: 1, projectId } })
})
it('should create role with incremented position with bigger position', async () => {
- const dbRole: Partial = {
+ const dbRole: ProjectRole = {
+ id: faker.string.uuid(),
+ name: faker.string.alphanumeric(),
+ projectId,
permissions: 4n,
position: 50,
+ oidcGroup: '',
+ type: 'custom',
}
prisma.projectRole.findFirst.mockResolvedValueOnce(dbRole)
prisma.projectRole.findMany.mockResolvedValueOnce([dbRole])
- prisma.projectRole.create.mockResolvedValue(null)
+ prisma.projectRole.create.mockResolvedValue(dbRole)
await createRole(projectId, { name: 'test', permissions: '4' })
expect(prisma.projectRole.create).toHaveBeenCalledWith({ data: { name: 'test', permissions: 4n, position: 51, projectId } })
})
it('should create role with incremented position with no role in db', async () => {
- const dbRole: Partial = {
+ const dbRole: ProjectRole = {
+ id: faker.string.uuid(),
+ name: faker.string.alphanumeric(),
+ projectId,
permissions: 4n,
position: 50,
+ oidcGroup: '',
+ type: 'custom',
}
- prisma.projectRole.findFirst.mockResolvedValueOnce(undefined)
+ prisma.projectRole.findFirst.mockResolvedValueOnce(null)
prisma.projectRole.findMany.mockResolvedValueOnce([dbRole])
- prisma.projectRole.create.mockResolvedValue(null)
+ prisma.projectRole.create.mockResolvedValue(dbRole)
await createRole(projectId, { name: 'test', permissions: '4' })
expect(prisma.projectRole.create).toHaveBeenCalledWith({ data: { name: 'test', permissions: 4n, position: 0, projectId } })
@@ -67,10 +87,14 @@ describe('test project-role business', () => {
describe('deleteRole', () => {
const roleId = faker.string.uuid()
it('should delete role and remove id from concerned users', async () => {
- const dbRole: Partial = {
+ const dbRole: ProjectRole = {
+ id: roleId,
+ name: faker.string.alphanumeric(),
+ projectId,
permissions: 4n,
position: 50,
- id: faker.string.uuid(),
+ oidcGroup: '',
+ type: 'custom',
}
const members = [{
userId: faker.string.uuid(),
@@ -82,6 +106,7 @@ describe('test project-role business', () => {
roleIds: [roleId, faker.string.uuid()],
}] as const satisfies Partial[]
+ prisma.projectRole.findUnique.mockResolvedValueOnce(dbRole)
prisma.projectMembers.findMany.mockResolvedValueOnce(members)
prisma.projectRole.findMany.mockResolvedValueOnce([])
prisma.projectRole.delete.mockResolvedValue(dbRole)
@@ -91,43 +116,101 @@ describe('test project-role business', () => {
expect(prisma.projectMembers.update).toHaveBeenNthCalledWith(2, { where: expect.any(Object), data: { roleIds: { set: [members[1].roleIds[1]] } } })
expect(prisma.projectRole.delete).toHaveBeenCalledWith({ where: { id: roleId } })
})
+
+ it('should throw Forbidden403 when deleting a system role', async () => {
+ const dbRole: ProjectRole = {
+ id: roleId,
+ name: 'Administrateur',
+ projectId,
+ permissions: 4n,
+ position: 50,
+ oidcGroup: '',
+ type: 'system',
+ }
+ prisma.projectRole.findUnique.mockResolvedValueOnce(dbRole)
+
+ await expect(deleteRole(roleId)).rejects.toThrow(Forbidden403)
+ expect(prisma.projectRole.delete).not.toHaveBeenCalled()
+ })
})
describe.skip('countRolesMembers', () => {
it('should return aggregated role member counts', async () => {
const partialRoles = [{
id: faker.string.uuid(),
+ name: faker.string.alphanumeric(),
+ projectId,
+ permissions: 4n,
+ position: 50,
+ oidcGroup: '',
+ type: 'custom',
}, {
id: faker.string.uuid(),
- }] as const satisfies Partial[]
+ name: faker.string.alphanumeric(),
+ projectId,
+ permissions: 4n,
+ position: 50,
+ oidcGroup: '',
+ type: 'custom',
+ }] as const satisfies ProjectRole[]
- const users = [{
- projectRoleIds: [partialRoles[0].id, partialRoles[1].id],
+ const members = [{
+ userId: faker.string.uuid(),
+ projectId,
+ roleIds: [partialRoles[0].id, partialRoles[1].id],
}, {
- projectRoleIds: [partialRoles[1].id],
- }] as const satisfies Partial[]
+ userId: faker.string.uuid(),
+ projectId,
+ roleIds: [partialRoles[1].id],
+ }] as const satisfies ProjectMembers[]
+
prisma.projectRole.findMany.mockResolvedValue(partialRoles)
- prisma.user.findMany.mockResolvedValue(users)
+ prisma.projectMembers.findMany.mockResolvedValue(members)
- const response = await countRolesMembers()
+ const response = await countRolesMembers(projectId)
expect(response).toEqual({ [partialRoles[0].id]: 1, [partialRoles[1].id]: 2 })
})
})
describe('patchRoles', () => {
- const dbRoles: ProjectRole[] = [{
+ const dbRoles: any[] = [{
id: faker.string.uuid(),
name: faker.company.name(),
permissions: faker.number.bigInt({ min: 0n, max: 50000n }),
position: 0,
projectId,
+ oidcGroup: 'group1',
+ type: 'custom',
}, {
id: faker.string.uuid(),
name: faker.company.name(),
permissions: faker.number.bigInt({ min: 0n, max: 50000n }),
position: 1,
projectId,
+ oidcGroup: 'group2',
+ type: 'custom',
}]
+ it('should throw Forbidden403 when renaming a system role', async () => {
+ const systemRole: ProjectRole = {
+ id: faker.string.uuid(),
+ name: 'Administrateur',
+ permissions: 10n,
+ position: 0,
+ projectId,
+ oidcGroup: 'admin-group',
+ type: 'system',
+ }
+ prisma.projectRole.findMany.mockResolvedValue([systemRole])
+
+ const updateRoles = [{
+ id: systemRole.id,
+ name: 'New Admin Name',
+ }]
+
+ await expect(patchRoles(projectId, updateRoles)).rejects.toThrow(Forbidden403)
+ expect(prisma.projectRole.update).toHaveBeenCalledTimes(0)
+ })
+
it('should do nothing', async () => {
prisma.projectRole.findMany.mockResolvedValue([])
await patchRoles(projectId, [])
@@ -135,7 +218,7 @@ describe('test project-role business', () => {
})
it('should return 400 if incoherent positions', async () => {
- const updateRoles: Pick = [
+ const updateRoles: any = [
{ id: dbRoles[0].id, position: 1 },
{ id: dbRoles[1].id, position: 1 },
]
@@ -148,7 +231,7 @@ describe('test project-role business', () => {
})
it('should return 400 if incoherent positions (missing)', async () => {
- const updateRoles: Pick = [
+ const updateRoles: any = [
{ id: dbRoles[1].id, position: 1 },
]
prisma.projectRole.findMany.mockResolvedValue(dbRoles)
@@ -160,7 +243,7 @@ describe('test project-role business', () => {
})
it('should update positions', async () => {
- const updateRoles: Pick = [
+ const updateRoles: any = [
{ id: dbRoles[0].id, position: 1 },
{ id: dbRoles[1].id, position: 0 },
]
@@ -172,7 +255,7 @@ describe('test project-role business', () => {
})
it('should update permissions', async () => {
- const updateRoles: Pick = [
+ const updateRoles: any = [
{ id: dbRoles[1].id, permissions: '0' },
]
prisma.projectRole.findMany.mockResolvedValue(dbRoles)
@@ -185,6 +268,8 @@ describe('test project-role business', () => {
name: dbRoles[1].name,
permissions: 0n,
position: 1,
+ oidcGroup: dbRoles[1].oidcGroup,
+ type: 'custom',
},
where: {
id: dbRoles[1].id,
diff --git a/apps/server/src/resources/project-role/business.ts b/apps/server/src/resources/project-role/business.ts
index 3d20dc13c..a91b9ca11 100644
--- a/apps/server/src/resources/project-role/business.ts
+++ b/apps/server/src/resources/project-role/business.ts
@@ -6,7 +6,7 @@ import {
listRoles as listRolesQuery,
updateRole,
} from '@/resources/queries-index.js'
-import { BadRequest400 } from '@/utils/errors.js'
+import { BadRequest400, Forbidden403, NotFound404 } from '@/utils/errors.js'
import prisma from '@/prisma.js'
export async function listRoles(projectId: Project['id']) {
@@ -25,11 +25,16 @@ export async function patchRoles(projectId: Project['id'], roles: typeof project
if (typeof matchingRole?.position !== 'undefined' && !positionsAvailable.includes(matchingRole.position)) {
positionsAvailable.push(matchingRole.position)
}
+ if (dbRole.type === 'system' && matchingRole?.name !== dbRole.name) {
+ throw new Forbidden403('Ce rôle système ne peut pas être renommé')
+ }
return {
id: matchingRole?.id ?? dbRole.id,
name: matchingRole?.name ?? dbRole.name,
permissions: matchingRole?.permissions ? BigInt(matchingRole?.permissions) : BigInt(dbRole.permissions),
position: matchingRole?.position ?? dbRole.position,
+ oidcGroup: matchingRole?.oidcGroup ?? dbRole.oidcGroup,
+ type: dbRole.type,
}
})
if (positionsAvailable.length && positionsAvailable.length !== dbRoles.length) return new BadRequest400('Les numéros de position des rôles sont incohérentes')
@@ -72,6 +77,13 @@ export async function countRolesMembers(projectId: Project['id']) {
}
export async function deleteRole(roleId: Project['id']) {
+ const role = await prisma.projectRole.findUnique({
+ where: { id: roleId },
+ })
+ if (!role) throw new NotFound404()
+ if (role.type === 'system') {
+ throw new Forbidden403('Ce rôle système ne peut pas être supprimé')
+ }
await deleteRoleQuery(roleId)
return null
}
diff --git a/apps/server/src/resources/project-role/queries.ts b/apps/server/src/resources/project-role/queries.ts
index d915849eb..5466a900b 100644
--- a/apps/server/src/resources/project-role/queries.ts
+++ b/apps/server/src/resources/project-role/queries.ts
@@ -16,11 +16,12 @@ export function createRole(data: Pick) {
+export function updateRole(id: ProjectRole['id'], data: Pick) {
return prisma.projectRole.update({
where: { id },
data,
diff --git a/apps/server/src/resources/project/queries.spec.ts b/apps/server/src/resources/project/queries.spec.ts
new file mode 100644
index 000000000..e971a1ed2
--- /dev/null
+++ b/apps/server/src/resources/project/queries.spec.ts
@@ -0,0 +1,65 @@
+import { describe, expect, it } from 'vitest'
+import prisma from '../../__mocks__/prisma.js'
+import { initializeProject } from './queries.js'
+import { PROJECT_PERMS } from '@cpn-console/shared'
+import { faker } from '@faker-js/faker'
+
+describe('initializeProject', () => {
+ it('should create project with default system roles', async () => {
+ const params = {
+ name: 'test-project',
+ description: 'test',
+ ownerId: faker.string.uuid(),
+ slug: 'test-project',
+ limitless: false,
+ hprodCpu: faker.number.int({ min: 0, max: 1000 }),
+ hprodGpu: faker.number.int({ min: 0, max: 1000 }),
+ hprodMemory: faker.number.int({ min: 0, max: 1000 }),
+ prodCpu: faker.number.int({ min: 0, max: 1000 }),
+ prodGpu: faker.number.int({ min: 0, max: 1000 }),
+ prodMemory: faker.number.int({ min: 0, max: 1000 }),
+ }
+
+ prisma.project.create.mockResolvedValue({ id: 'project-id', ...params } as any)
+
+ await initializeProject(params)
+
+ expect(prisma.project.create).toHaveBeenCalledWith(expect.objectContaining({
+ data: expect.objectContaining({
+ ...params,
+ roles: {
+ create: expect.arrayContaining([
+ {
+ name: 'Administrateur',
+ permissions: PROJECT_PERMS.MANAGE,
+ position: 0,
+ oidcGroup: 'project-test-project-admin',
+ type: 'system',
+ },
+ {
+ name: 'DevOps',
+ 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,
+ position: 1,
+ oidcGroup: 'project-test-project-devops',
+ type: 'system',
+ },
+ {
+ name: 'Développer',
+ permissions: PROJECT_PERMS.MANAGE_REPOSITORIES | PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.LIST_REPOSITORIES,
+ position: 2,
+ oidcGroup: 'project-test-project-developer',
+ type: 'system',
+ },
+ {
+ name: 'Lecture seule',
+ permissions: PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.LIST_REPOSITORIES,
+ position: 3,
+ oidcGroup: 'project-test-project-readonly',
+ type: 'system',
+ },
+ ]),
+ },
+ }),
+ }))
+ })
+})
diff --git a/apps/server/src/resources/project/queries.ts b/apps/server/src/resources/project/queries.ts
index 23544d1d2..14bebc1bd 100644
--- a/apps/server/src/resources/project/queries.ts
+++ b/apps/server/src/resources/project/queries.ts
@@ -7,6 +7,7 @@ import {
ProjectStatus,
} from '@prisma/client'
import type { XOR, projectContract } from '@cpn-console/shared'
+import { PROJECT_PERMS } from '@cpn-console/shared'
import prisma from '@/prisma.js'
import { appVersion } from '@/utils/env.js'
import { uuid } from '@/utils/queries-tools.js'
@@ -258,6 +259,38 @@ export function initializeProject(params: CreateProjectParams) {
status: ProjectStatus.created,
locked: false,
...params,
+ roles: {
+ create: [
+ {
+ name: 'Administrateur',
+ permissions: PROJECT_PERMS.MANAGE,
+ position: 0,
+ oidcGroup: `project-${params.name}-admin`,
+ type: 'system',
+ },
+ {
+ name: 'DevOps',
+ 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,
+ position: 1,
+ oidcGroup: `project-${params.name}-devops`,
+ type: 'system',
+ },
+ {
+ name: 'Développer',
+ permissions: PROJECT_PERMS.MANAGE_REPOSITORIES | PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.LIST_REPOSITORIES,
+ position: 2,
+ oidcGroup: `project-${params.name}-developer`,
+ type: 'system',
+ },
+ {
+ name: 'Lecture seule',
+ permissions: PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.LIST_REPOSITORIES,
+ position: 3,
+ oidcGroup: `project-${params.name}-readonly`,
+ type: 'system',
+ },
+ ],
+ },
},
})
}
diff --git a/packages/shared/src/contracts/project-role.ts b/packages/shared/src/contracts/project-role.ts
index 361388956..5c1176f07 100644
--- a/packages/shared/src/contracts/project-role.ts
+++ b/packages/shared/src/contracts/project-role.ts
@@ -1,5 +1,5 @@
import { z } from 'zod'
-import { RoleSchema, apiPrefix, contractInstance } from '../index.js'
+import { ProjectRoleSchema, apiPrefix, contractInstance } from '../index.js'
import { ErrorSchema, baseHeaders } from './_utils.js'
export const projectRoleContract = contractInstance.router({
@@ -8,7 +8,7 @@ export const projectRoleContract = contractInstance.router({
path: '',
pathParams: z.object({ projectId: z.string().uuid() }),
responses: {
- 200: RoleSchema.array(),
+ 200: ProjectRoleSchema.array(),
400: ErrorSchema,
401: ErrorSchema,
403: ErrorSchema,
@@ -18,11 +18,11 @@ export const projectRoleContract = contractInstance.router({
createProjectRole: {
method: 'POST',
path: '',
- body: RoleSchema.omit({ position: true, id: true }),
+ body: ProjectRoleSchema.omit({ position: true, id: true, projectId: true }),
pathParams: z.object({ projectId: z.string().uuid() }),
responses: {
// 200: z.any(),
- 201: RoleSchema.array(),
+ 201: ProjectRoleSchema.array(),
400: ErrorSchema,
401: ErrorSchema,
403: ErrorSchema,
@@ -34,10 +34,10 @@ export const projectRoleContract = contractInstance.router({
path: '',
pathParams: z.object({ projectId: z.string().uuid() }),
// body: z.any(),
- body: RoleSchema.partial({ name: true, permissions: true, position: true }).array(),
+ body: ProjectRoleSchema.pick({ id: true }).merge(ProjectRoleSchema.omit({ id: true, projectId: true }).partial()).array(),
responses: {
// 200: z.any(),
- 200: RoleSchema.array(),
+ 200: ProjectRoleSchema.array(),
400: ErrorSchema,
401: ErrorSchema,
403: ErrorSchema,
diff --git a/packages/shared/src/schemas/project.ts b/packages/shared/src/schemas/project.ts
index 313db5966..70c9238fb 100644
--- a/packages/shared/src/schemas/project.ts
+++ b/packages/shared/src/schemas/project.ts
@@ -4,7 +4,7 @@ import { longestEnvironmentName, projectStatus } from '../utils/const.js'
import { AtDatesToStringExtend, CoerceBooleanSchema, permissionLevelSchema } from './_utils.js'
import { RepoSchema } from './repository.js'
import { MemberSchema, UserSchema } from './user.js'
-import { RoleSchema } from './role.js'
+import { ProjectRoleSchema } from './role.js'
export const descriptionMaxLength = 280
export const projectNameMaxLength = 20
@@ -76,7 +76,7 @@ export const ProjectSchemaV2 = z.object({
.uuid(),
owner: UserSchema
.omit({ adminRoleIds: true }),
- roles: RoleSchema
+ roles: ProjectRoleSchema
.array(),
everyonePerms: permissionLevelSchema,
lastSuccessProvisionningVersion: z.string()
diff --git a/packages/shared/src/schemas/role.ts b/packages/shared/src/schemas/role.ts
index 0556dca52..95a6989a9 100644
--- a/packages/shared/src/schemas/role.ts
+++ b/packages/shared/src/schemas/role.ts
@@ -9,10 +9,12 @@ export const RoleSchema = z.object({
name: RoleNameSchema,
permissions: permissionLevelSchema,
position: z.number().min(0),
+ type: z.string().optional(),
})
export const ProjectRoleSchema = RoleSchema.extend({
projectId: z.string().uuid(),
+ oidcGroup: z.string().optional(),
})
export const AdminRoleSchema = RoleSchema.extend({
@@ -29,3 +31,4 @@ export type Role = Zod.infer
export type RoleBigint = Omit, 'permissions'> & { permissions: bigint }
export type AdminRole = Zod.infer
export type ProjectRole = Zod.infer
+export type ProjectRoleBigint = Omit, 'permissions'> & { permissions: bigint }
diff --git a/packages/test-utils/src/imports/data.ts b/packages/test-utils/src/imports/data.ts
index 7ee16e5e2..ec6839eba 100644
--- a/packages/test-utils/src/imports/data.ts
+++ b/packages/test-utils/src/imports/data.ts
@@ -25,6 +25,7 @@ export const data = {
position: 0,
oidcGroup: '/admin',
name: 'Admin',
+ type: 'system',
},
{
id: 'eadf604f-5f54-4744-bdfb-4793d2271e9b',
@@ -32,6 +33,7 @@ export const data = {
position: 1,
oidcGroup: '',
name: 'Admin Locaux',
+ type: 'system',
},
],
kubeconfig: [