Skip to content
Closed
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
14 changes: 12 additions & 2 deletions apps/client/src/components/ProjectRoleForm.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script lang="ts" setup>
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<{
Expand All @@ -10,19 +10,21 @@ const props = defineProps<{
allMembers: Member[]
projectId: ProjectV2['id']
isEveryone: boolean
oidcGroup?: string
}>()

defineEmits<{
delete: []
updateMemberRoles: [checked: boolean, userId: Member['userId']]
save: [value: Omit<RoleBigint, 'position'>]
save: [value: Omit<ProjectRoleBigint, 'position' | 'projectId'>]
cancel: []
}>()
const router = useRouter()
const role = ref({
...props,
permissions: props.permissions ?? 0n,
allMembers: props.allMembers ?? [],
oidcGroup: props.oidcGroup ?? '',
})

const isUpdated = computed(() => {
Expand Down Expand Up @@ -68,6 +70,14 @@ function updateChecked(checked: boolean, value: bigint) {
class="mb-5"
:disabled="role.isEveryone"
/>
<h6>Groupe OIDC</h6>
<DsfrInput
v-model="role.oidcGroup"
data-testid="roleOidcGroupInput"
label-visible
class="mb-5"
:disabled="role.isEveryone"
/>
<h6>Permissions</h6>
<div
v-for="scope in projectPermsDetails"
Expand Down
8 changes: 5 additions & 3 deletions apps/client/src/components/ProjectRoles.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts" setup>
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'

Expand All @@ -11,7 +11,7 @@ const snackbarStore = useSnackbarStore()

const selectedId = ref<string>()

type RoleItem = Omit<Role, 'permissions'> & { permissions: bigint, memberCounts: number, isEveryone: boolean }
type RoleItem = Omit<ProjectRole, 'permissions'> & { permissions: bigint, memberCounts: number, isEveryone: boolean }

const roleList = ref<RoleItem[]>([])

Expand Down Expand Up @@ -56,7 +56,7 @@ async function saveEveryoneRole(role: { permissions: bigint }) {
snackbarStore.setMessage('Rôle mis à jour', 'success')
}

async function saveRole(role: Omit<RoleBigint, 'position'>) {
async function saveRole(role: Omit<ProjectRoleBigint, 'position' | 'projectId'>) {
if (role.id === 'everyone') {
await saveEveryoneRole(role)
snackbarStore.setMessage('Rôle mis à jour', 'success')
Expand All @@ -67,6 +67,7 @@ async function saveRole(role: Omit<RoleBigint, 'position'>) {
id: selectedRole.value.id,
permissions: role.permissions.toString(),
name: role.name,
oidcGroup: role.oidcGroup,
}])
reload()
snackbarStore.setMessage('Rôle mis à jour', 'success')
Expand All @@ -86,6 +87,7 @@ function reload() {
permissions: BigInt(props.project.everyonePerms),
position: 1000,
isEveryone: true,
projectId: props.project.id,
})
roleList.value = roles
}
Expand Down
2 changes: 1 addition & 1 deletion apps/client/src/utils/project-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export class Project implements ProjectV2 {
locked: boolean
owner: Omit<User, 'adminRoleIds'>
ownerId: string
roles: { id: string, name: string, permissions: string, position: number }[]
roles: { id: string, name: string, permissions: string, position: number, projectId: string, oidcGroup?: 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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "ProjectRole" ADD COLUMN "oidcGroup" TEXT NOT NULL DEFAULT '';
2 changes: 1 addition & 1 deletion apps/server/src/prisma/migrations/migration_lock.toml
Original file line number Diff line number Diff line change
@@ -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"
provider = "postgresql"
1 change: 1 addition & 0 deletions apps/server/src/prisma/schema/project.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ model ProjectRole {
permissions BigInt
projectId String @db.Uuid
position Int @db.SmallInt
oidcGroup String @default("")
project Project @relation(fields: [projectId], references: [id])
}

Expand Down
102 changes: 96 additions & 6 deletions apps/server/src/resources/project-role/business.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,29 @@ import prisma from '../../__mocks__/prisma.js'
import { BadRequest400 } from '../../utils/errors.ts'
import { countRolesMembers, createRole, deleteRole, listRoles, patchRoles } from './business.ts'

const projectId = faker.string.uuid()
describe('test project-role business', () => {
const project: Project = {
id: faker.string.uuid(),
name: faker.lorem.word({ length: { min: 2, max: 10 } }),
slug: faker.lorem.word({ length: { min: 2, max: 10 } }),
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 }),
description: faker.lorem.sentence({ min: 2, max: 10 }),
status: 'created',
locked: false,
createdAt: faker.date.past(),
updatedAt: faker.date.recent(),
everyonePerms: 0n,
ownerId: faker.string.uuid(),
lastSuccessProvisionningVersion: null,
}
const projectId = faker.string.uuid()

describe('listRoles', () => {
it('should stringify bigint', async () => {
const partialRole: Partial<ProjectRole> = {
Expand All @@ -17,6 +38,23 @@ describe('test project-role business', () => {
const response = await listRoles(projectId)
expect(response).toEqual([{ permissions: '4' }])
})

it('should strip oidcGroup prefix', async () => {
const dbRole: any = {
id: faker.string.uuid(),
name: faker.string.alphanumeric(),
projectId,
permissions: 4n,
position: 0,
oidcGroup: `/project-${project.slug}/admin`,
}

prisma.project.findUnique.mockResolvedValueOnce(project)
prisma.projectRole.findMany.mockResolvedValueOnce([dbRole])

const response = await listRoles(projectId)
expect(response[0].oidcGroup).toBe('/admin')
})
})

describe('createRole', () => {
Expand All @@ -27,9 +65,10 @@ describe('test project-role business', () => {
position: 0,
}

prisma.project.findUnique.mockResolvedValue(project)
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 } })
Expand All @@ -41,6 +80,7 @@ describe('test project-role business', () => {
position: 50,
}

prisma.project.findUnique.mockResolvedValue(project)
prisma.projectRole.findFirst.mockResolvedValueOnce(dbRole)
prisma.projectRole.findMany.mockResolvedValueOnce([dbRole])
prisma.projectRole.create.mockResolvedValue(null)
Expand All @@ -55,13 +95,39 @@ describe('test project-role business', () => {
position: 50,
}

prisma.projectRole.findFirst.mockResolvedValueOnce(undefined)
prisma.project.findUnique.mockResolvedValue(project)
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 } })
})

it('should create role with enforced oidcGroup prefix', async () => {
const dbRole: any = {
id: faker.string.uuid(),
name: faker.string.alphanumeric(),
projectId,
permissions: 4n,
position: 0,
oidcGroup: `/project-${project.slug}/admin`,
}

prisma.project.findUnique.mockResolvedValueOnce(project)
prisma.projectRole.findFirst.mockResolvedValueOnce(dbRole)
prisma.projectRole.create.mockResolvedValue(dbRole)
prisma.project.findUnique.mockResolvedValueOnce(project)
prisma.projectRole.findMany.mockResolvedValueOnce([dbRole])

await createRole(projectId, { name: 'test', permissions: '4', oidcGroup: '/admin' })

expect(prisma.projectRole.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
oidcGroup: `/project-${project.slug}/admin`,
}),
}))
})
})

describe('deleteRole', () => {
Expand Down Expand Up @@ -129,6 +195,7 @@ describe('test project-role business', () => {
}]

it('should do nothing', async () => {
prisma.project.findUnique.mockResolvedValue(project)
prisma.projectRole.findMany.mockResolvedValue([])
await patchRoles(projectId, [])
expect(prisma.projectRole.update).toHaveBeenCalledTimes(0)
Expand All @@ -139,6 +206,7 @@ describe('test project-role business', () => {
{ id: dbRoles[0].id, position: 1 },
{ id: dbRoles[1].id, position: 1 },
]
prisma.project.findUnique.mockResolvedValue(project)
prisma.projectRole.findMany.mockResolvedValue(dbRoles)

const response = await patchRoles(projectId, updateRoles)
Expand All @@ -151,6 +219,7 @@ describe('test project-role business', () => {
const updateRoles: Pick<ProjectRole, 'id' | 'position'> = [
{ id: dbRoles[1].id, position: 1 },
]
prisma.project.findUnique.mockResolvedValue(project)
prisma.projectRole.findMany.mockResolvedValue(dbRoles)

const response = await patchRoles(projectId, updateRoles)
Expand All @@ -164,6 +233,7 @@ describe('test project-role business', () => {
{ id: dbRoles[0].id, position: 1 },
{ id: dbRoles[1].id, position: 0 },
]
prisma.project.findUnique.mockResolvedValue(project)
prisma.projectRole.findMany.mockResolvedValue(dbRoles)

await patchRoles(projectId, updateRoles)
Expand All @@ -175,12 +245,13 @@ describe('test project-role business', () => {
const updateRoles: Pick<ProjectRole, 'id' | 'position'> = [
{ id: dbRoles[1].id, permissions: '0' },
]
prisma.project.findUnique.mockResolvedValue(project)
prisma.projectRole.findMany.mockResolvedValue(dbRoles)

await patchRoles(projectId, updateRoles)

expect(prisma.projectRole.update).toHaveBeenCalledTimes(1)
expect(prisma.projectRole.update).toHaveBeenCalledWith({
expect(prisma.projectRole.update).toHaveBeenCalledWith(expect.objectContaining({
data: {
name: dbRoles[1].name,
permissions: 0n,
Expand All @@ -189,7 +260,26 @@ describe('test project-role business', () => {
where: {
id: dbRoles[1].id,
},
})
}))
})

it('should update role with enforced oidcGroup prefix', async () => {
const updateRoles: any[] = [
{ id: dbRoles[1].id, oidcGroup: '/admin' },
]

prisma.project.findUnique.mockResolvedValue(project)

const dbRoleWithPrefix = { ...dbRoles[1], oidcGroup: '/project-myproject/group2' }
prisma.projectRole.findMany.mockResolvedValue([dbRoleWithPrefix])

await patchRoles(projectId, updateRoles)

expect(prisma.projectRole.update).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
oidcGroup: `/project-${project.slug}/admin`,
}),
}))
})
})
})
23 changes: 20 additions & 3 deletions apps/server/src/resources/project-role/business.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,21 @@ import {
listRoles as listRolesQuery,
updateRole,
} from '@/resources/queries-index.js'
import { BadRequest400 } from '@/utils/errors.js'
import { BadRequest400, NotFound404 } from '@/utils/errors.js'
import prisma from '@/prisma.js'

export async function listRoles(projectId: Project['id']) {
return listRolesQuery(projectId)
.then(roles => roles.map(role => ({ ...role, permissions: role.permissions.toString() })))
const roles = await listRolesQuery(projectId)
return roles.map(role => ({
...role,
permissions: role.permissions.toString(),
oidcGroup: role.oidcGroup ? role.oidcGroup.replace(/^\/project-[^/]+/, '') : role.oidcGroup,
}))
}

export async function patchRoles(projectId: Project['id'], roles: typeof projectRoleContract.patchProjectRoles.body._type) {
const project = await prisma.project.findUnique({ where: { id: projectId }, select: { slug: true } })
if (!project) throw new NotFound404()
const dbRoles = await listRoles(projectId)
const positionsAvailable: number[] = []

Expand All @@ -25,11 +31,15 @@ export async function patchRoles(projectId: Project['id'], roles: typeof project
if (typeof matchingRole?.position !== 'undefined' && !positionsAvailable.includes(matchingRole.position)) {
positionsAvailable.push(matchingRole.position)
}
if (matchingRole?.oidcGroup && !matchingRole.oidcGroup.startsWith('/')) {
throw new BadRequest400('oidcGroup doit commencer par /')
}
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 ? `/project-${project.slug}${matchingRole.oidcGroup}` : dbRole.oidcGroup,
}
})
if (positionsAvailable.length && positionsAvailable.length !== dbRoles.length) return new BadRequest400('Les numéros de position des rôles sont incohérentes')
Expand All @@ -41,18 +51,25 @@ export async function patchRoles(projectId: Project['id'], roles: typeof project
}

export async function createRole(projectId: Project['id'], role: typeof projectRoleContract.createProjectRole.body._type) {
const project = await prisma.project.findUnique({ where: { id: projectId }, select: { slug: true } })
if (!project) throw new NotFound404()
const dbMaxPosRole = (await prisma.projectRole.findFirst({
where: { projectId },
orderBy: { position: 'desc' },
select: { position: true },
}))?.position ?? -1

if (role.oidcGroup && !role.oidcGroup.startsWith('/')) {
throw new BadRequest400('oidcGroup doit commencer par /')
}

await prisma.projectRole.create({
data: {
...role,
projectId,
position: dbMaxPosRole + 1,
permissions: BigInt(role.permissions),
oidcGroup: role.oidcGroup ? `/project-${project.slug}${role.oidcGroup}` : undefined,
},
})

Expand Down
Loading
Loading