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,8 @@ const role = ref({
...props,
permissions: props.permissions ?? 0n,
allMembers: props.allMembers ?? [],
+ oidcGroup: props.oidcGroup ?? '',
+ type: props.type ?? 'custom',
})
const isUpdated = computed(() => {
@@ -66,7 +70,15 @@ function updateChecked(checked: boolean, value: bigint) {
data-testid="roleNameInput"
label-visible
class="mb-5"
- :disabled="role.isEveryone"
+ :disabled="role.isEveryone || role.type === 'system'"
+ />
+ Groupe OIDC
+
Permissions
updateChecked(checked, PROJECT_PERMS[perm.key])"
/>
@@ -100,7 +112,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/__mocks__/utils/hook-wrapper.ts b/apps/server/src/__mocks__/utils/hook-wrapper.ts
index 34c7bee26..07ef829dc 100644
--- a/apps/server/src/__mocks__/utils/hook-wrapper.ts
+++ b/apps/server/src/__mocks__/utils/hook-wrapper.ts
@@ -17,6 +17,10 @@ export const hook = {
delete: vi.fn(),
getSecrets: vi.fn(),
},
+ projectRole: {
+ upsert: vi.fn(),
+ delete: vi.fn(),
+ },
user: {
retrieveUserByEmail: vi.fn(),
},
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..04c98df03
--- /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 DEFAULT '';
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..40da41a75
--- /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éveloppeur', '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..5a5d465b7 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 dbRole: AdminRole = {
+ id: faker.string.uuid(),
+ name: faker.string.alphanumeric(),
permissions: 4n,
+ position: 0,
+ oidcGroup: '',
+ type: 'custom',
}
- prisma.adminRole.findMany.mockResolvedValueOnce([partialRole])
+ prisma.adminRole.findMany.mockResolvedValueOnce([dbRole])
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,16 +82,40 @@ 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: [] } })
@@ -84,40 +125,88 @@ describe('test admin-role business', () => {
})
describe('countRolesMembers', () => {
it('should return aggregated role member counts', async () => {
- const partialRoles = [{
+ const roles = [{
id: faker.string.uuid(),
+ name: faker.string.alphanumeric(),
+ oidcGroup: '',
+ permissions: faker.number.bigInt({ min: 0n, max: 50000n }),
+ position: 0,
+ type: 'custom',
}, {
id: faker.string.uuid(),
- }] as const satisfies Partial[]
+ name: faker.string.alphanumeric(),
+ 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', () => {
const dbRoles: AdminRole[] = [{
id: faker.string.uuid(),
- name: faker.company.name(),
+ name: faker.string.alphanumeric(),
oidcGroup: '',
permissions: faker.number.bigInt({ min: 0n, max: 50000n }),
position: 0,
+ type: 'custom',
}, {
id: faker.string.uuid(),
- name: faker.company.name(),
+ name: faker.string.alphanumeric(),
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 +214,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 +226,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 +237,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 +248,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 +262,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..17cb7503a 100644
--- a/apps/server/src/resources/admin-role/queries.ts
+++ b/apps/server/src/resources/admin-role/queries.ts
@@ -12,6 +12,7 @@ export function createAdminRole(data: Pick ({
+ hook: {
+ project: {
+ upsert: vi.fn(),
+ delete: vi.fn(),
+ getSecrets: vi.fn(),
+ },
+ projectRole: {
+ upsert: vi.fn(),
+ delete: vi.fn(),
+ },
+ user: {
+ retrieveUserByEmail: vi.fn(),
+ },
+ },
+}))
-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 = {
+ const dbRole: 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([dbRole])
+ prisma.projectRole.findMany.mockResolvedValueOnce([dbRole])
const response = await listRoles(projectId)
- expect(response).toEqual([{ permissions: '4' }])
+ expect(response).toContainEqual(expect.objectContaining({ permissions: '4' }))
+ })
+
+ it('should strip oidcGroup prefix', async () => {
+ const dbRole: ProjectRole = {
+ id: faker.string.uuid(),
+ name: faker.string.alphanumeric(),
+ projectId,
+ permissions: 4n,
+ position: 0,
+ oidcGroup: `/${project.slug}/admin`,
+ type: 'custom',
+ }
+
+ prisma.project.findUnique.mockResolvedValueOnce(project)
+ prisma.projectRole.findMany.mockResolvedValueOnce([dbRole])
+
+ const response = await listRoles(projectId)
+ expect(response[0].oidcGroup).toBe('/admin')
})
})
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: faker.string.alphanumeric(),
projectId,
permissions: 4n,
position: 0,
+ oidcGroup: '',
+ type: 'custom',
}
+ prisma.project.findUnique.mockResolvedValue(project)
prisma.projectRole.findFirst.mockResolvedValueOnce(dbRole)
+ prisma.projectRole.create.mockResolvedValue(dbRole)
prisma.projectRole.findMany.mockResolvedValueOnce([dbRole])
- prisma.projectRole.create.mockResolvedValue(null)
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.project.findUnique.mockResolvedValue(project)
prisma.projectRole.findFirst.mockResolvedValueOnce(dbRole)
+ prisma.projectRole.create.mockResolvedValue(dbRole)
prisma.projectRole.findMany.mockResolvedValueOnce([dbRole])
- prisma.projectRole.create.mockResolvedValue(null)
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.project.findUnique.mockResolvedValue(project)
+ prisma.projectRole.findFirst.mockResolvedValueOnce(null)
+ prisma.projectRole.create.mockResolvedValue(dbRole)
+ prisma.projectRole.findMany.mockResolvedValueOnce([dbRole])
+ prisma.projectRole.findFirst.mockResolvedValueOnce(null)
+ prisma.projectRole.create.mockResolvedValue(dbRole)
prisma.projectRole.findMany.mockResolvedValueOnce([dbRole])
- prisma.projectRole.create.mockResolvedValue(null)
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.slug}/admin`,
+ type: 'custom',
+ }
+
+ prisma.project.findUnique.mockResolvedValueOnce(project)
+ prisma.projectRole.findFirst.mockResolvedValueOnce(dbRole)
+ prisma.projectRole.create.mockResolvedValue(dbRole)
+ 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.slug}/admin`,
+ }),
+ }))
+ })
})
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 +194,7 @@ describe('test project-role business', () => {
roleIds: [roleId, faker.string.uuid()],
}] as const satisfies Partial[]
+ prisma.projectRole.findUnique.mockResolvedValue(dbRole)
prisma.projectMembers.findMany.mockResolvedValueOnce(members)
prisma.projectRole.findMany.mockResolvedValueOnce([])
prisma.projectRole.delete.mockResolvedValue(dbRole)
@@ -91,54 +204,116 @@ 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.mockResolvedValue(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 = [{
+ const roles = [{
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: [roles[0].id, roles[1].id],
}, {
- projectRoleIds: [partialRoles[1].id],
- }] as const satisfies Partial[]
- prisma.projectRole.findMany.mockResolvedValue(partialRoles)
- prisma.user.findMany.mockResolvedValue(users)
+ userId: faker.string.uuid(),
+ projectId,
+ roleIds: [roles[1].id],
+ }] as const satisfies ProjectMembers[]
+
+ prisma.projectRole.findMany.mockResolvedValue(roles)
+ 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 })
+ expect(response).toEqual({ [roles[0].id]: 1, [roles[1].id]: 2 })
})
})
+
describe('patchRoles', () => {
const dbRoles: ProjectRole[] = [{
id: faker.string.uuid(),
- name: faker.company.name(),
+ name: faker.string.alphanumeric(),
permissions: faker.number.bigInt({ min: 0n, max: 50000n }),
position: 0,
projectId,
+ oidcGroup: 'group1',
+ type: 'custom',
}, {
id: faker.string.uuid(),
- name: faker.company.name(),
+ name: faker.string.alphanumeric(),
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.project.findUnique.mockResolvedValue({ name: 'My Project', slug: 'myproject' } as any)
+ 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.project.findUnique.mockResolvedValue(project)
prisma.projectRole.findMany.mockResolvedValue([])
await patchRoles(projectId, [])
expect(prisma.projectRole.update).toHaveBeenCalledTimes(0)
})
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 },
]
+ prisma.project.findUnique.mockResolvedValue(project)
prisma.projectRole.findMany.mockResolvedValue(dbRoles)
const response = await patchRoles(projectId, updateRoles)
@@ -148,9 +323,10 @@ describe('test project-role business', () => {
})
it('should return 400 if incoherent positions (missing)', async () => {
- const updateRoles: Pick = [
+ const updateRoles: Pick[] = [
{ id: dbRoles[1].id, position: 1 },
]
+ prisma.project.findUnique.mockResolvedValue(project)
prisma.projectRole.findMany.mockResolvedValue(dbRoles)
const response = await patchRoles(projectId, updateRoles)
@@ -160,10 +336,11 @@ describe('test project-role business', () => {
})
it('should update positions', async () => {
- const updateRoles: Pick = [
+ const updateRoles: Pick[] = [
{ 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)
@@ -172,24 +349,46 @@ describe('test project-role business', () => {
})
it('should update permissions', async () => {
- const updateRoles: Pick = [
+ const updateRoles: (Pick & { permissions: string })[] = [
{ 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,
position: 1,
+ oidcGroup: dbRoles[1].oidcGroup,
+ type: 'custom',
},
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.slug}/group2` }
+ prisma.projectRole.findMany.mockResolvedValue([dbRoleWithPrefix])
+
+ await patchRoles(projectId, updateRoles)
+
+ expect(prisma.projectRole.update).toHaveBeenCalledWith(expect.objectContaining({
+ data: expect.objectContaining({
+ oidcGroup: `/${project.slug}/admin`,
+ }),
+ }))
})
})
})
diff --git a/apps/server/src/resources/project-role/business.ts b/apps/server/src/resources/project-role/business.ts
index 3d20dc13c..bbf60b815 100644
--- a/apps/server/src/resources/project-role/business.ts
+++ b/apps/server/src/resources/project-role/business.ts
@@ -6,15 +6,22 @@ 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 { hook } from '@/utils/hook-wrapper.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(/^\/[^/]+/, '') : 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[] = []
@@ -25,37 +32,59 @@ 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') {
+ throw new Forbidden403('Ce rôle système ne peut pas être renommé')
+ }
+ 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.slug}${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) {
await updateRole(id, role)
+ await hook.projectRole.upsert(id)
}
return listRoles(projectId)
}
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
- await prisma.projectRole.create({
+ if (role.type === 'system') {
+ throw new Forbidden403('Ce rôle système ne peut pas être renommé')
+ }
+
+ if (role.oidcGroup && !role.oidcGroup.startsWith('/')) {
+ throw new BadRequest400('oidcGroup doit commencer par /')
+ }
+
+ const createdRole = await prisma.projectRole.create({
data: {
...role,
projectId,
position: dbMaxPosRole + 1,
permissions: BigInt(role.permissions),
+ oidcGroup: role.oidcGroup ? `/${project.slug}${role.oidcGroup}` : undefined,
},
})
+ await hook.projectRole.upsert(createdRole.id)
+
return listRoles(projectId)
}
@@ -72,6 +101,11 @@ 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?.type === 'system') {
+ throw new Forbidden403('Ce rôle système ne peut pas être supprimé')
+ }
+ await hook.projectRole.delete(roleId)
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..810d4c83a 100644
--- a/apps/server/src/resources/project-role/queries.ts
+++ b/apps/server/src/resources/project-role/queries.ts
@@ -1,26 +1,29 @@
import type {
Prisma,
Project,
-
ProjectRole,
} from '@prisma/client'
import prisma from '@/prisma.js'
+export const getRole = (id: ProjectRole['id']) => prisma.projectRole.findUnique({ where: { id } })
+
export const listRoles = (projectId: Project['id']) => prisma.projectRole.findMany({ where: { projectId }, orderBy: { position: 'asc' } })
-export function createRole(data: Pick) {
+export function createRole(data: Pick) {
return prisma.projectRole.create({
data: {
name: data.name,
permissions: 0n,
position: data.position,
projectId: data.projectId,
+ oidcGroup: data.oidcGroup,
+ type: 'custom',
},
})
}
-export function updateRole(id: ProjectRole['id'], 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/business.ts b/apps/server/src/resources/project/business.ts
index e81ac8d3e..2109ffe80 100644
--- a/apps/server/src/resources/project/business.ts
+++ b/apps/server/src/resources/project/business.ts
@@ -46,7 +46,7 @@ export async function listProjects({ status, statusIn, statusNotIn, filter = 'me
}).then(projects => projects.map(({ clusters, ...project }) => ({
...project,
clusterIds: clusters.map(({ id }) => id),
- roles: project.roles.map(role => ({ ...role, permissions: role.permissions.toString() })),
+ roles: project.roles.map(role => ({ ...role, permissions: role.permissions.toString(), oidcGroup: project.slug ? role.oidcGroup?.replace(`/${project.slug}/`, '') : role.oidcGroup })),
everyonePerms: project.everyonePerms.toString(),
})))
}
@@ -86,7 +86,7 @@ export async function createProject(dataDto: typeof projectContract.createProjec
...projectInfos,
clusterIds: projectInfos.clusters.map(({ id }) => id),
everyonePerms: projectInfos.everyonePerms.toString(),
- roles: projectInfos.roles.map(role => ({ ...role, permissions: role.permissions.toString() })),
+ roles: projectInfos.roles.map(role => ({ ...role, permissions: role.permissions.toString(), oidcGroup: projectInfos.slug ? role.oidcGroup?.replace(`/${project.slug}/`, '') : role.oidcGroup })),
}
}
@@ -94,7 +94,7 @@ export async function getProject(projectId: Project['id']) {
return getProjectOrThrow(projectId).then(({ clusters, ...project }) => ({
...project,
clusterIds: clusters.map(({ id }) => id),
- roles: project.roles.map(role => ({ ...role, permissions: role.permissions.toString() })),
+ roles: project.roles.map(role => ({ ...role, permissions: role.permissions.toString(), oidcGroup: project.slug ? role.oidcGroup?.replace(`/${project.slug}/`, '') : role.oidcGroup })),
everyonePerms: project.everyonePerms.toString(),
}))
}
@@ -151,7 +151,7 @@ export async function updateProject(
...projectInfos,
clusterIds: projectInfos.clusters.map(({ id }) => id),
everyonePerms: projectInfos.everyonePerms.toString(),
- roles: projectInfos.roles.map(role => ({ ...role, permissions: role.permissions.toString() })),
+ roles: projectInfos.roles.map(role => ({ ...role, permissions: role.permissions.toString(), oidcGroup: projectInfos.slug ? role.oidcGroup?.replace(`/${projectInfos.slug}/`, '') : role.oidcGroup })),
}
}
diff --git a/apps/server/src/resources/project/queries.ts b/apps/server/src/resources/project/queries.ts
index 23544d1d2..311a5c2cd 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: `/${params.slug}/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: `/${params.slug}/devops`,
+ type: 'system',
+ },
+ {
+ name: 'Développeur',
+ permissions: PROJECT_PERMS.MANAGE_REPOSITORIES | PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.LIST_REPOSITORIES,
+ position: 2,
+ oidcGroup: `/${params.slug}/developer`,
+ type: 'system',
+ },
+ {
+ name: 'Lecture seule',
+ permissions: PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.LIST_REPOSITORIES,
+ position: 3,
+ oidcGroup: `/${params.slug}/readonly`,
+ type: 'system',
+ },
+ ],
+ },
},
})
}
diff --git a/apps/server/src/resources/stage/business.spec.ts b/apps/server/src/resources/stage/business.spec.ts
index d608ee0c0..815cba497 100644
--- a/apps/server/src/resources/stage/business.spec.ts
+++ b/apps/server/src/resources/stage/business.spec.ts
@@ -11,7 +11,7 @@ describe('test stage busines logic', () => {
vi.resetAllMocks()
stage = {
id: faker.string.uuid(),
- name: faker.company.name(),
+ name: faker.string.alphanumeric(),
}
})
describe('createStage', () => {
diff --git a/apps/server/src/resources/user/business.spec.ts b/apps/server/src/resources/user/business.spec.ts
index 50e1dd20c..4226d9fdf 100644
--- a/apps/server/src/resources/user/business.spec.ts
+++ b/apps/server/src/resources/user/business.spec.ts
@@ -128,13 +128,13 @@ describe('test users business', () => {
// ça ne teste pas tout mais c'est déjà bien hein
const adminRoles = [{
id: faker.string.uuid(),
- name: faker.company.name(),
+ name: faker.string.alphanumeric(),
oidcGroup: '',
permissions: 0n,
position: 0,
}, {
id: faker.string.uuid(),
- name: faker.company.name(),
+ name: faker.string.alphanumeric(),
oidcGroup: '/admin',
permissions: 0n,
position: 0,
diff --git a/apps/server/src/resources/zone/business.spec.ts b/apps/server/src/resources/zone/business.spec.ts
index b126fce18..18a02b7db 100644
--- a/apps/server/src/resources/zone/business.spec.ts
+++ b/apps/server/src/resources/zone/business.spec.ts
@@ -17,7 +17,7 @@ vi.mock('../../utils/hook-wrapper.ts', async () => ({
describe('test zone business', () => {
const zones: Zone[] = [{
id: faker.string.uuid(),
- label: faker.company.name(),
+ label: faker.string.alphanumeric(),
argocdUrl: faker.internet.url(),
createdAt: new Date(),
updatedAt: new Date(),
@@ -25,7 +25,7 @@ describe('test zone business', () => {
slug: faker.string.alphanumeric(5),
}, {
id: faker.string.uuid(),
- label: faker.company.name(),
+ label: faker.string.alphanumeric(),
argocdUrl: faker.internet.url(),
createdAt: new Date(),
updatedAt: new Date(),
diff --git a/apps/server/src/utils/hook-wrapper.ts b/apps/server/src/utils/hook-wrapper.ts
index 4a248450e..2d2496953 100644
--- a/apps/server/src/utils/hook-wrapper.ts
+++ b/apps/server/src/utils/hook-wrapper.ts
@@ -4,7 +4,7 @@ import { hooks } from '@cpn-console/hooks'
import type { AsyncReturnType } from '@cpn-console/shared'
import { ProjectAuthorized, getPermsByUserRoles, resourceListToDict } from '@cpn-console/shared'
import { genericProxy } from './proxy.js'
-import { archiveProject, getAdminPlugin, getClusterByIdOrThrow, getClusterNamesByZoneId, getClustersAssociatedWithProject, getHookProjectInfos, getHookRepository, getProjectStore, getZoneByIdOrThrow, saveProjectStore, updateProjectClusterHistory, updateProjectCreated, updateProjectFailed, updateProjectWarning } from '@/resources/queries-index.js'
+import { archiveProject, getAdminPlugin, getClusterByIdOrThrow, getClusterNamesByZoneId, getClustersAssociatedWithProject, getHookProjectInfos, getHookRepository, getProjectStore, getRole, getZoneByIdOrThrow, saveProjectStore, updateProjectClusterHistory, updateProjectCreated, updateProjectFailed, updateProjectWarning } from '@/resources/queries-index.js'
import type { ConfigRecords } from '@/resources/project-service/business.js'
import { dbToObj } from '@/resources/project-service/business.js'
@@ -139,6 +139,31 @@ const user = {
},
} as const
+const projectRole = {
+ upsert: async (roleId: ProjectRole['id']) => {
+ const role = await getRole(roleId)
+ if (!role) throw new Error('Role not found')
+
+ const rolePayload = {
+ ...role,
+ permissions: role.permissions.toString(),
+ }
+ const store = dbToObj(await getAdminPlugin())
+ return hooks.upsertProjectRole.execute(rolePayload, store)
+ },
+ delete: async (roleId: ProjectRole['id']) => {
+ const role = await getRole(roleId)
+ if (!role) throw new Error('Role not found')
+
+ const rolePayload = {
+ ...role,
+ permissions: role.permissions.toString(),
+ }
+ const store = dbToObj(await getAdminPlugin())
+ return hooks.deleteProjectRole.execute(rolePayload, store)
+ },
+} as const
+
const zone = {
upsert: async (zoneId: Zone['id']) => {
const zone: ZoneObject = await getZoneByIdOrThrow(zoneId)
@@ -177,6 +202,8 @@ export const hook = {
// @ts-ignore TODO voir comment opti la signature de la fonction
project: genericProxy(project, { upsert: ['delete'], delete: ['upsert', 'delete'], getSecrets: ['delete'] }),
// @ts-ignore TODO voir comment opti la signature de la fonction
+ projectRole: genericProxy(projectRole, { delete: ['upsert', 'delete'], upsert: ['delete'] }),
+ // @ts-ignore TODO voir comment opti la signature de la fonction
cluster: genericProxy(cluster, { delete: ['upsert', 'delete'], upsert: ['delete'] }),
// @ts-ignore TODO voir comment opti la signature de la fonction
zone: genericProxy(zone, { delete: ['upsert'], upsert: ['delete'] }),
diff --git a/packages/hooks/src/hooks/hook-project-role.ts b/packages/hooks/src/hooks/hook-project-role.ts
new file mode 100644
index 000000000..e481144e9
--- /dev/null
+++ b/packages/hooks/src/hooks/hook-project-role.ts
@@ -0,0 +1,6 @@
+import type { ProjectRole } from '@cpn-console/shared'
+import type { Hook } from './hook.js'
+import { createHook } from './hook.js'
+
+export const upsertProjectRole: Hook = createHook()
+export const deleteProjectRole: Hook = createHook()
diff --git a/packages/hooks/src/hooks/index.ts b/packages/hooks/src/hooks/index.ts
index 452ae98cc..2f195a5fc 100644
--- a/packages/hooks/src/hooks/index.ts
+++ b/packages/hooks/src/hooks/index.ts
@@ -1,6 +1,7 @@
export * from './hook-cluster.js'
export * from './hook-misc.js'
export * from './hook-project.js'
+export * from './hook-project-role.js'
export * from './hook-user.js'
export * from './hook-zone.js'
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: [
diff --git a/plugins/keycloak/src/functions.ts b/plugins/keycloak/src/functions.ts
index 6ab77eeae..16c1eecd8 100644
--- a/plugins/keycloak/src/functions.ts
+++ b/plugins/keycloak/src/functions.ts
@@ -1,9 +1,10 @@
import type { Project, StepCall, UserEmail, ZoneObject } from '@cpn-console/hooks'
+import type { ProjectRole } from '@cpn-console/shared'
import { generateRandomPassword, parseError, PluginResultBuilder } from '@cpn-console/hooks'
import type GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation.js'
import type ClientRepresentation from '@keycloak/keycloak-admin-client/lib/defs/clientRepresentation.js'
import type { CustomGroup } from './group.js'
-import { consoleGroupName, getAllSubgroups, getGroupByName, getOrCreateChildGroup, getOrCreateProjectGroup } from './group.js'
+import { consoleGroupName, deleteGroup, getAllSubgroups, getGroupByName, getOrCreateChildGroup, getOrCreateProjectGroup } from './group.js'
import { getkcClient } from './client.js'
export const retrieveKeycloakUserByEmail: StepCall = async ({ args: { email } }) => {
@@ -65,6 +66,7 @@ export const upsertProject: StepCall = async ({ args: project }) => {
const kcClient = await getkcClient()
const projectName = project.slug
const projectGroup = await getOrCreateProjectGroup(kcClient, projectName)
+
const groupMembers = await kcClient.groups.listMembers({ id: projectGroup.id })
await Promise.all([
@@ -221,6 +223,82 @@ export const deleteZone: StepCall = async ({ args: zone }) => {
}
}
+export const upsertProjectRole: StepCall = async ({ args: role }) => {
+ if (!role.oidcGroup) {
+ return {
+ status: {
+ result: 'OK',
+ message: 'No OIDC group defined',
+ },
+ }
+ }
+ try {
+ const kcClient = await getkcClient()
+ const [projectName, roleName] = role.oidcGroup.split('/').filter(Boolean)
+ if (!projectName || !roleName) throw new Error('Invalid OIDC group format')
+ const projectGroup = await getOrCreateProjectGroup(kcClient, projectName)
+ await getOrCreateChildGroup(kcClient, projectGroup.id, roleName)
+ return {
+ status: {
+ result: 'OK',
+ message: 'Synced',
+ },
+ }
+ } catch (error) {
+ return {
+ error: parseError(error),
+ status: {
+ result: 'KO',
+ message: 'Failed to sync role',
+ },
+ }
+ }
+}
+
+export const deleteProjectRole: StepCall = async ({ args: role }) => {
+ if (!role.oidcGroup) {
+ return {
+ status: {
+ result: 'OK',
+ message: 'No OIDC group defined',
+ },
+ }
+ }
+ try {
+ const kcClient = await getkcClient()
+ const [projectName, roleName] = role.oidcGroup.split('/').filter(Boolean)
+ if (!projectName || !roleName) throw new Error('Invalid OIDC group format')
+ const projectGroup = await getGroupByName(kcClient, projectName)
+ if (projectGroup?.id) {
+ const subGroups = await getAllSubgroups(kcClient, projectGroup.id, 0)
+ const roleGroup = subGroups.find(g => g.name === roleName)
+ if (roleGroup?.id) {
+ await deleteGroup(kcClient, roleGroup.id)
+ return {
+ status: {
+ result: 'OK',
+ message: 'Deleted',
+ },
+ }
+ }
+ }
+ return {
+ status: {
+ result: 'OK',
+ message: 'Already Missing',
+ },
+ }
+ } catch (error) {
+ return {
+ error: parseError(error),
+ status: {
+ result: 'KO',
+ message: 'Failed to delete role',
+ },
+ }
+ }
+}
+
function getClientZoneId(zone: ZoneObject): string {
return `argocd-${zone.slug}-zone`
}
diff --git a/plugins/keycloak/src/group.ts b/plugins/keycloak/src/group.ts
index 388b63539..2feccd38a 100644
--- a/plugins/keycloak/src/group.ts
+++ b/plugins/keycloak/src/group.ts
@@ -72,3 +72,7 @@ export async function getOrCreateProjectGroup(kcClient: KeycloakAdminClient, nam
name: existingGroup.name,
}
}
+
+export async function deleteGroup(kcClient: KeycloakAdminClient, groupId: string) {
+ await kcClient.groups.del({ id: groupId })
+}
diff --git a/plugins/keycloak/src/index.ts b/plugins/keycloak/src/index.ts
index 04e623047..ed5be5000 100644
--- a/plugins/keycloak/src/index.ts
+++ b/plugins/keycloak/src/index.ts
@@ -1,9 +1,11 @@
import type { DefaultArgs, Plugin, Project, ProjectLite } from '@cpn-console/hooks'
import {
deleteProject,
+ deleteProjectRole,
deleteZone,
retrieveKeycloakUserByEmail,
upsertProject,
+ upsertProjectRole,
upsertZone,
} from './functions.js'
import infos from './infos.js'
@@ -22,12 +24,18 @@ export const plugin: Plugin = {
api: project => new KeycloakProjectApi(project.slug),
steps: { main: upsertProject },
},
+ upsertProjectRole: {
+ steps: { main: upsertProjectRole },
+ },
upsertZone: {
steps: { main: upsertZone },
},
deleteZone: {
steps: { post: deleteZone },
},
+ deleteProjectRole: {
+ steps: { post: deleteProjectRole },
+ },
retrieveUserByEmail: { steps: { main: retrieveKeycloakUserByEmail } },
},
monitor,