diff --git a/apps/client/src/components/AdminRoleForm.vue b/apps/client/src/components/AdminRoleForm.vue index e576aed06..a12b4ce45 100644 --- a/apps/client/src/components/AdminRoleForm.vue +++ b/apps/client/src/components/AdminRoleForm.vue @@ -12,13 +12,15 @@ const props = withDefaults(defineProps<{ permissions: bigint name: string oidcGroup: string + type: string }>(), { name: 'Nouveau rôle', oidcGroup: '', + type: 'custom', }) const emits = defineEmits<{ delete: [] - save: [{ name: string, permissions: string, oidcGroup: string }] + save: [{ name: string, permissions: string, oidcGroup: string, type: string }] cancel: [] }>() const usersStore = useUsersStore() @@ -33,6 +35,8 @@ const isUpdated = computed(() => { return !shallowEqual(props, role.value) }) +const isSystem = computed(() => props.type === 'system') + const errorSchema = computed(() => { const schemaValidation = RoleSchema.partial().safeParse(role.value) return schemaValidation.success ? undefined : schemaValidation.error @@ -139,6 +143,7 @@ function closeModal() { label-visible hint="Ne doit pas dépasser 30 caractères." class="mb-5" + :disabled="isSystem" />

@@ -174,8 +179,10 @@ function closeModal() { label-visible placeholder="/admin" class="mb-5" + :disabled="isSystem" /> 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,12 +25,17 @@ const role = ref({ ...props, permissions: props.permissions ?? 0n, allMembers: props.allMembers ?? [], + oidcGroup: props.oidcGroup ?? '', + type: props.type ?? 'custom', }) const isUpdated = computed(() => { if (role.value.isEveryone) return props.permissions !== role.value.permissions return !shallowEqual(props, role.value) }) + +const isSystem = computed(() => props.type === 'system') + const tabListName = 'Liste d’onglet' const tabTitles = [ { title: 'Général', icon: 'ri:checkbox-circle-line', tabId: 'general' }, @@ -66,7 +73,15 @@ function updateChecked(checked: boolean, value: bigint) { data-testid="roleNameInput" label-visible class="mb-5" - :disabled="role.isEveryone" + :disabled="role.isEveryone || isSystem" + /> +

Groupe OIDC
+
Permissions
@@ -100,7 +115,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..30081e3d5 100644 --- a/apps/client/src/views/admin/AdminRoles.vue +++ b/apps/client/src/views/admin/AdminRoles.vue @@ -32,7 +32,7 @@ async function deleteRole(roleId: Role['id']) { selectedId.value = undefined } -async function saveRole(role: Pick) { +async function saveRole(role: Pick) { if (!selectedRole.value) return await adminRoleStore.patchRoles( [{ @@ -40,6 +40,7 @@ async function saveRole(role: Pick { :name="selectedRole.name" :permissions="BigInt(selectedRole.permissions)" :oidc-group="selectedRole.oidcGroup" + :type="selectedRole.type" @delete="deleteRole(selectedRole.id)" - @save="(role: Pick) => saveRole(role)" + @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/20260204150335_add_system_roles/migration.sql b/apps/server/src/prisma/migrations/20260204150335_add_system_roles/migration.sql new file mode 100644 index 000000000..adb8b6f25 --- /dev/null +++ b/apps/server/src/prisma/migrations/20260204150335_add_system_roles/migration.sql @@ -0,0 +1,26 @@ +-- Update existing Admin role to be system role 'Administrateur Plateforme' +UPDATE "AdminRole" +SET + "name" = 'Administrateur Plateforme', + "type" = 'system', + "permissions" = 3, -- Assuming 3n means bit 0 and 1 (1 | 2 = 3) + "oidcGroup" = '/admin', + "position" = 0 +WHERE "id" = '76229c96-4716-45bc-99da-00498ec9018c'::uuid; + +-- Insert 'Lecture Seule Plateforme' system role if it doesn't exist +INSERT INTO "AdminRole" ("id", "name", "permissions", "position", "oidcGroup", "type") +VALUES ( + '35848aa2-e881-4770-9844-0c5c3693e506'::uuid, + 'Lecture Seule Plateforme', + 1, -- Assuming 1n means bit 0 + 2, + '/readonly', + 'system' +) +ON CONFLICT ("id") DO UPDATE +SET + "name" = 'Lecture Seule Plateforme', + "type" = 'system', + "permissions" = 1, + "oidcGroup" = '/readonly'; 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..d182ce2a5 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,59 +82,147 @@ 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.user.findMany.mockResolvedValue(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 return 403 if trying to delete system role', async () => { + const systemRole = { + id: roleId, + type: 'system', + } + prisma.adminRole.findUnique.mockResolvedValue(systemRole as any) + prisma.user.findMany.mockResolvedValue([]) + + const response = await deleteRole(roleId) + expect(response).toBeInstanceOf(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.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', + }] + + const result = await patchRoles(updateRoles) + + await expect(result).toBeInstanceOf(Forbidden403) + expect(prisma.adminRole.update).toHaveBeenCalledTimes(0) + }) + it('should do nothing', async () => { prisma.adminRole.findMany.mockResolvedValue([]) await patchRoles([]) @@ -125,7 +230,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 +242,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 +253,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 +264,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,11 +278,27 @@ describe('test admin-role business', () => { oidcGroup: dbRoles[1].oidcGroup, permissions: 0n, position: 1, + type: 'custom', }, where: { id: dbRoles[1].id, }, }) }) + + it('should return 403 if trying to update system role', async () => { + const systemRole = { + id: faker.string.uuid(), + type: 'system', + name: 'sys', + permissions: 0n, + position: 0, + } + prisma.adminRole.findMany.mockResolvedValue([systemRole as any]) + + const response = await patchRoles([{ id: systemRole.id, name: 'new name' }]) + expect(response).toBeInstanceOf(Forbidden403) + expect(prisma.adminRole.update).not.toHaveBeenCalled() + }) }) }) diff --git a/apps/server/src/resources/admin-role/business.ts b/apps/server/src/resources/admin-role/business.ts index a43cebf22..0610cf310 100644 --- a/apps/server/src/resources/admin-role/business.ts +++ b/apps/server/src/resources/admin-role/business.ts @@ -1,39 +1,49 @@ import type { Project, ProjectRole } from '@prisma/client' import type { AdminRole, adminRoleContract } from '@cpn-console/shared' import { + getAdminRoleById, 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() { return listAdminRoles() - .then(roles => roles.map(role => ({ ...role, permissions: role.permissions.toString() }))) + .then(roles => roles.map(role => ({ ...role, permissions: role.permissions.toString(), type: role.type ?? 'custom' }))) } export async function patchRoles(roles: typeof adminRoleContract.patchAdminRoles.body._type): Promise { const dbRoles = await prisma.adminRole.findMany() const positionsAvailable: number[] = [] + const updatedRoles: (Omit & { permissions: bigint })[] = [] - const updatedRoles: (Omit & { permissions: bigint })[] = dbRoles - .filter(dbRole => roles.find(role => role.id === dbRole.id)) // filter non concerned dbRoles - .map((dbRole) => { - const matchingRole = roles.find(role => role.id === dbRole.id) - if (typeof matchingRole?.position !== 'undefined' && !positionsAvailable.includes(matchingRole.position)) { + for (const dbRole of dbRoles) { + const matchingRole = roles.find(role => role.id === dbRole.id) + if (matchingRole) { + if (dbRole.type === 'system') { + return new Forbidden403('Impossible de modifier un rôle système') + } + + if (typeof matchingRole.position !== 'undefined' && !positionsAvailable.includes(matchingRole.position)) { positionsAvailable.push(matchingRole.position) } - return { + updatedRoles.push({ id: dbRole.id, - name: matchingRole?.name ?? dbRole.name, - permissions: matchingRole?.permissions ? BigInt(matchingRole?.permissions) : dbRole.permissions, - position: matchingRole?.position ?? dbRole.position, - oidcGroup: matchingRole?.oidcGroup ?? dbRole.oidcGroup, - } - }) + name: matchingRole.name ?? dbRole.name, + 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') { + return new Forbidden403('Ce rôle système ne peut pas être renommé') + } await prisma.adminRole.update({ where: { id }, data: role }) } @@ -74,6 +84,11 @@ export async function countRolesMembers() { } export async function deleteRole(roleId: Project['id']) { + const role = await getAdminRoleById(roleId) + if (role) { + if (role.type === 'system') return new Forbidden403('Impossible de supprimer un rôle système') + } + 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..bb3c4e4d5 100644 --- a/apps/server/src/resources/admin-role/queries.ts +++ b/apps/server/src/resources/admin-role/queries.ts @@ -12,21 +12,29 @@ 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, }, }) } + +export async function getAdminRoleById(id: string) { + const role = await prisma.adminRole.findUnique({ where: { id } }) + if (!role) return null + const members = await prisma.user.findMany({ where: { adminRoleIds: { has: id } } }) + return { ...role, members } +} diff --git a/apps/server/src/resources/admin-role/router.spec.ts b/apps/server/src/resources/admin-role/router.spec.ts index 5fc0bc66c..f23880cdf 100644 --- a/apps/server/src/resources/admin-role/router.spec.ts +++ b/apps/server/src/resources/admin-role/router.spec.ts @@ -22,7 +22,7 @@ describe('test adminRoleContract', () => { describe('listAdminRoles', () => { it('should return list of admin roles', async () => { - const roles = [{ id: faker.string.uuid(), name: 'Role 1', oidcGroup: '', position: 0, permissions: '1' }] + const roles = [{ id: faker.string.uuid(), name: 'Role 1', oidcGroup: '', position: 0, permissions: '1', type: 'custom' }] businessListRolesMock.mockResolvedValueOnce(roles) const response = await app.inject() @@ -70,8 +70,8 @@ describe('test adminRoleContract', () => { }) describe('patchAdminRoles', () => { - const updatedRoles = [{ id: faker.string.uuid(), name: 'Role 1', oidcGroup: '', position: 0, permissions: '1' }] - const rolesData = [{ id: updatedRoles[0].id, name: 'Updated Role' }] + const updatedRoles = [{ id: faker.string.uuid(), name: 'Role 1', oidcGroup: '', position: 0, permissions: '1', type: 'custom' }] + const rolesData = [{ id: updatedRoles[0].id, name: 'Updated Role', type: 'custom' }] it('should update roles for authorized users', async () => { const user = getUserMockInfos(true) diff --git a/apps/server/src/resources/admin-role/router.ts b/apps/server/src/resources/admin-role/router.ts index 18b1fc226..da758c0e9 100644 --- a/apps/server/src/resources/admin-role/router.ts +++ b/apps/server/src/resources/admin-role/router.ts @@ -64,6 +64,7 @@ export function adminRoleRouter() { if (!AdminAuthorized.isAdmin(perms.adminPermissions)) return new Forbidden403() const resBody = await deleteRole(params.roleId) + if (resBody instanceof ErrorResType) return resBody return { status: 204, diff --git a/apps/server/src/resources/project-role/business.spec.ts b/apps/server/src/resources/project-role/business.spec.ts index cdbaa3fd1..f1719ef06 100644 --- a/apps/server/src/resources/project-role/business.spec.ts +++ b/apps/server/src/resources/project-role/business.spec.ts @@ -1,76 +1,188 @@ import { faker } from '@faker-js/faker' -import { describe, expect, it } from 'vitest' -import type { ProjectMembers, ProjectRole, User } from '@prisma/client' +import { describe, expect, it, vi } from 'vitest' +import type { Project, ProjectMembers, ProjectRole } from '@prisma/client' import prisma from '../../__mocks__/prisma.js' -import { BadRequest400 } from '../../utils/errors.ts' import { countRolesMembers, createRole, deleteRole, listRoles, patchRoles } from './business.ts' +import { Forbidden403, BadRequest400 } from '@/utils/errors.js' + +vi.mock('../../utils/hook-wrapper.ts', () => ({ + 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([dbRole]) + prisma.projectRole.findMany.mockResolvedValueOnce([dbRole]) + const response = await listRoles(projectId) + 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}/console/admin`, + type: 'custom', } - prisma.projectRole.findMany.mockResolvedValueOnce([partialRole]) + prisma.project.findUnique.mockResolvedValueOnce(project) + prisma.projectRole.findMany.mockResolvedValueOnce([dbRole]) + const response = await listRoles(projectId) - expect(response).toEqual([{ permissions: '4' }]) + 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}/console/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}/console/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,120 @@ 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) + + const result = await deleteRole(roleId) + + await expect(result).toBeInstanceOf(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', + }] + + const result = await patchRoles(projectId, updateRoles) + + await expect(result).toBeInstanceOf(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 +327,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 +340,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 +353,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, + projectId, + 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}/console/group2` } + prisma.projectRole.findMany.mockResolvedValue([dbRoleWithPrefix]) + + await patchRoles(projectId, updateRoles) + + expect(prisma.projectRole.update).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + oidcGroup: `/${project.slug}/console/admin`, + }), + })) }) }) }) diff --git a/apps/server/src/resources/project-role/business.ts b/apps/server/src/resources/project-role/business.ts index 3d20dc13c..13e413bd4 100644 --- a/apps/server/src/resources/project-role/business.ts +++ b/apps/server/src/resources/project-role/business.ts @@ -6,56 +6,90 @@ 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(/^\/[^/]+\/console/, '') : role.oidcGroup, + })) } export async function patchRoles(projectId: Project['id'], roles: typeof projectRoleContract.patchProjectRoles.body._type) { - const dbRoles = await listRoles(projectId) + const project = await prisma.project.findUnique({ where: { id: projectId }, select: { slug: true } }) + if (!project) throw new NotFound404() + const dbRoles = await listRolesQuery(projectId) const positionsAvailable: number[] = [] - const updatedRoles = dbRoles - .filter(dbRole => roles.find(role => role.id === dbRole.id)) // filter non concerned dbRoles - .map((dbRole) => { - const matchingRole = roles.find(role => role.id === dbRole.id) - if (typeof matchingRole?.position !== 'undefined' && !positionsAvailable.includes(matchingRole.position)) { + const updatedRoles: (Omit & { permissions: bigint })[] = [] + + for (const dbRole of dbRoles) { + const matchingRole = roles.find(role => role.id === dbRole.id) + if (matchingRole) { + if (dbRole.type === 'system') { + return new Forbidden403('Ce rôle système ne peut pas être renommé') + } + + if (typeof matchingRole?.position !== 'undefined' && !positionsAvailable.includes(matchingRole?.position)) { positionsAvailable.push(matchingRole.position) } - 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, + if (matchingRole.oidcGroup && !matchingRole.oidcGroup.startsWith('/')) { + return new BadRequest400('oidcGroup doit commencer par /') } - }) + updatedRoles.push({ + id: dbRole.id, + name: matchingRole.name ?? dbRole.name, + permissions: matchingRole.permissions ? BigInt(matchingRole.permissions) : dbRole.permissions, + position: matchingRole.position ?? dbRole.position, + oidcGroup: matchingRole.oidcGroup ? `/${project.slug}/console${matchingRole.oidcGroup}` : dbRole.oidcGroup, + type: matchingRole.type ?? dbRole.type, + projectId: dbRole.projectId, + }) + } + } + 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') { + return 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}/console${role.oidcGroup}` : undefined, }, }) + await hook.projectRole.upsert(createdRole.id) + return listRoles(projectId) } @@ -72,6 +106,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') { + return 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-role/router.ts b/apps/server/src/resources/project-role/router.ts index 2d55a2b84..7c6e1c71e 100644 --- a/apps/server/src/resources/project-role/router.ts +++ b/apps/server/src/resources/project-role/router.ts @@ -35,6 +35,7 @@ export function projectRoleRouter() { if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') const resBody = await createRole(projectId, body) + if (resBody instanceof ErrorResType) return resBody return { status: 201, @@ -80,6 +81,7 @@ export function projectRoleRouter() { if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') const resBody = await deleteRole(roleId) + if (resBody instanceof ErrorResType) return resBody return { status: 204, diff --git a/apps/server/src/resources/project/business.ts b/apps/server/src/resources/project/business.ts index e81ac8d3e..9f76e983f 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}/console`, '') : role.oidcGroup })), everyonePerms: project.everyonePerms.toString(), }))) } @@ -82,11 +82,16 @@ export async function createProject(dataDto: typeof projectContract.createProjec return new Unprocessable422('Echec des services à la création du projet') } + for (const role of projectInfos.roles) { + const roleResult = await hook.projectRole.upsert(role.id) + await addLogs({ action: 'Upsert Project Role', data: roleResult.results, userId: requestor.id, requestId, projectId: project.id }) + } + return { ...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}/console`, '') : role.oidcGroup })), } } @@ -94,7 +99,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}/console`, '') : role.oidcGroup })), everyonePerms: project.everyonePerms.toString(), })) } @@ -151,7 +156,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}/console`, '') : role.oidcGroup })), } } diff --git a/apps/server/src/resources/project/queries.ts b/apps/server/src/resources/project/queries.ts index 23544d1d2..4bf5d61aa 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' @@ -257,6 +258,38 @@ export function initializeProject(params: CreateProjectParams) { description: params.description ?? '', status: ProjectStatus.created, locked: false, + roles: { + create: [ + { + name: 'Administrateur', + permissions: PROJECT_PERMS.MANAGE, + position: 0, + oidcGroup: `/${params.slug}/console/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}/console/devops`, + type: 'system', + }, + { + name: 'Développeur', + permissions: PROJECT_PERMS.MANAGE_REPOSITORIES | PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.LIST_REPOSITORIES, + position: 2, + oidcGroup: `/${params.slug}/console/developer`, + type: 'system', + }, + { + name: 'Lecture seule', + permissions: PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.LIST_REPOSITORIES, + position: 3, + oidcGroup: `/${params.slug}/console/readonly`, + type: 'system', + }, + ], + }, ...params, }, }) 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/admin-role.ts b/packages/shared/src/contracts/admin-role.ts index b2c41f0d4..fbc83446b 100644 --- a/packages/shared/src/contracts/admin-role.ts +++ b/packages/shared/src/contracts/admin-role.ts @@ -31,7 +31,7 @@ export const adminRoleContract = contractInstance.router({ patchAdminRoles: { method: 'PATCH', path: '', - body: AdminRoleSchema.partial({ name: true, permissions: true, position: true, oidcGroup: true }).array(), + body: AdminRoleSchema.partial({ name: true, permissions: true, position: true, oidcGroup: true, type: true }).array(), responses: { 200: AdminRoleSchema.array(), 400: ErrorSchema, 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..24224f5fc 100644 --- a/packages/shared/src/schemas/role.ts +++ b/packages/shared/src/schemas/role.ts @@ -9,14 +9,17 @@ 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({ oidcGroup: z.string(), + type: z.string().optional().default('custom'), }) export const RoleNameCsvSchema = z.string() @@ -29,3 +32,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/shared/src/utils/permissions.ts b/packages/shared/src/utils/permissions.ts index 5aed0bd0f..a042e8d14 100644 --- a/packages/shared/src/utils/permissions.ts +++ b/packages/shared/src/utils/permissions.ts @@ -58,6 +58,7 @@ export const PROJECT_PERMS = { // project permissions // Be very careful and think to apply corresponding updates in database if you modify these values, You'll have to do binary updates in SQL, good luck ! export const ADMIN_PERMS = { // admin permissions + LIST: bit(0n), MANAGE: bit(1n), } diff --git a/packages/test-utils/src/imports/data.ts b/packages/test-utils/src/imports/data.ts index 7ee16e5e2..394b07959 100644 --- a/packages/test-utils/src/imports/data.ts +++ b/packages/test-utils/src/imports/data.ts @@ -21,10 +21,11 @@ export const data = { adminRole: [ { id: '76229c96-4716-45bc-99da-00498ec9018c', - permissions: '2n', + permissions: '3n', position: 0, oidcGroup: '/admin', - name: 'Admin', + name: 'Administrateur Plateforme', + type: 'system', }, { id: 'eadf604f-5f54-4744-bdfb-4793d2271e9b', @@ -32,6 +33,15 @@ export const data = { position: 1, oidcGroup: '', name: 'Admin Locaux', + type: 'custom', + }, + { + id: '35848aa2-e881-4770-9844-0c5c3693e506', + permissions: '1n', + position: 2, + oidcGroup: '/readonly', + name: 'Lecture Seule Plateforme', + type: 'system', }, ], kubeconfig: [ diff --git a/playwright/e2e-tests/project-logs.spec.ts b/playwright/e2e-tests/project-logs.spec.ts index 2634ea3e5..e4878622f 100644 --- a/playwright/e2e-tests/project-logs.spec.ts +++ b/playwright/e2e-tests/project-logs.spec.ts @@ -23,7 +23,7 @@ test.describe('Project logs page', () => { await page.getByTestId('test-tab-logs').click() await expect(page.locator('#panel-logs')).toBeVisible() await expect(page.getByTestId('positionInfo')).toContainText( - '1 - 1 sur 1', + '1 - 5 sur 5', ) }, ) @@ -40,7 +40,7 @@ test.describe('Project logs page', () => { await page.getByTestId('test-tab-logs').click() await expect(page.locator('#panel-logs')).toBeVisible() await expect(page.getByTestId('positionInfo')).toContainText( - '1 - 1 sur 1', + '1 - 5 sur 5', ) // Act @@ -49,7 +49,7 @@ test.describe('Project logs page', () => { // Assert await expect(page.locator('#panel-logs')).toBeVisible() await expect(page.getByTestId('positionInfo')).toContainText( - '1 - 2 sur 2', + '1 - 5 sur 6', ) }, ) @@ -73,7 +73,7 @@ test.describe('Project logs page', () => { await expect(page.locator('#panel-logs')).toBeVisible() await page.getByTestId('replayHooksBtn').click() await expect(page.getByTestId('positionInfo')).toContainText( - '1 - 2 sur 2', + '1 - 5 sur 6', ) // Assert - as Project Member @@ -84,7 +84,7 @@ test.describe('Project logs page', () => { await page.getByTestId('test-tab-logs').click() await expect(page.locator('#panel-logs')).toBeVisible() await expect(page.getByTestId('positionInfo')).toContainText( - '1 - 2 sur 2', + '1 - 5 sur 6', ) }, ) diff --git a/playwright/e2e-tests/roles.spec.ts b/playwright/e2e-tests/roles.spec.ts index 1b64cc4dc..7028e4ad5 100644 --- a/playwright/e2e-tests/roles.spec.ts +++ b/playwright/e2e-tests/roles.spec.ts @@ -194,4 +194,23 @@ test.describe('Administration Roles', () => { // Assert await expect(page.getByTestId('role-list')).not.toContainText(newRole.name) }) + + test('Should not be able to edit system roles', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationRoles').click() + + // Select system role + await page.getByTestId('76229c96-4716-45bc-99da-00498ec9018c-tab').click() + + // Assert + await expect(page.getByTestId('roleNameInput')).toBeDisabled() + await expect(page.getByTestId('oidcGroupInput')).toBeDisabled() + await expect(page.getByTestId('saveBtn')).toBeHidden() + await expect(page.getByTestId('deleteBtn')).toBeHidden() + }) }) diff --git a/playwright/e2e-tests/service-chains.spec.ts b/playwright/e2e-tests/service-chains.spec.ts index 961932da5..45feb09b0 100644 --- a/playwright/e2e-tests/service-chains.spec.ts +++ b/playwright/e2e-tests/service-chains.spec.ts @@ -4,16 +4,11 @@ import { adminUser, clientURL, signInCloudPiNative } from '../config/console' test.describe('Service Chains page', () => { test.describe('Given an Admin-level user', () => { - let user: Credentials - test.beforeEach(() => { - user = adminUser - }) - // @TODO These tests assume that there is at least one Service Chain present // in the mocked up data. Ensure that this is true at all times ! test('should list service chains', { tag: '@e2e' }, async ({ page }) => { await page.goto(clientURL) - await signInCloudPiNative({ page, credentials: user }) + await signInCloudPiNative({ page, credentials: adminUser }) await page.getByTestId('menuAdministrationBtn').click() await page.getByTestId('menuAdministrationServiceChains').click() // We take the first service chain available @@ -27,7 +22,7 @@ test.describe('Service Chains page', () => { test('should show a service chain details', { tag: '@e2e' }, async ({ page }) => { await page.goto(clientURL) - await signInCloudPiNative({ page, credentials: user }) + await signInCloudPiNative({ page, credentials: adminUser }) await page.getByTestId('menuAdministrationBtn').click() await page.getByTestId('menuAdministrationServiceChains').click() // We take the first service chain available @@ -43,7 +38,7 @@ test.describe('Service Chains page', () => { test('should show a service chain flows', { tag: '@e2e' }, async ({ page }) => { await page.goto(clientURL) - await signInCloudPiNative({ page, credentials: user }) + await signInCloudPiNative({ page, credentials: adminUser }) await page.getByTestId('menuAdministrationBtn').click() await page.getByTestId('menuAdministrationServiceChains').click() await expect(page.getByTestId('cpin-loader')).toHaveCount(0) diff --git a/playwright/e2e-tests/system-roles.spec.ts b/playwright/e2e-tests/system-roles.spec.ts new file mode 100644 index 000000000..2c5947f22 --- /dev/null +++ b/playwright/e2e-tests/system-roles.spec.ts @@ -0,0 +1,47 @@ +import { expect, test } from '@playwright/test' +import { + clientURL, + signInCloudPiNative, + testUser, +} from '../config/console' +import { + addProject, +} from './utils' + +test.describe('System Roles at Project Creation', () => { + test('Should have built-in system roles after project creation', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await addProject({ page }) + + // Act + await page.getByTestId('test-tab-roles').click() + + // Assert + const systemRoles = [ + { name: 'Administrateur', oidcGroup: '/admin' }, + { name: 'DevOps', oidcGroup: '/devops' }, + { name: 'Développeur', oidcGroup: '/developer' }, + { name: 'Lecture seule', oidcGroup: '/readonly' }, + ] + + for (const role of systemRoles) { + // Click on the role to select it + const roleElement = await page.getByText(role.name) + await expect(roleElement).toBeVisible() + await roleElement.click() + + // Check name input is disabled + await expect(page.getByTestId('roleNameInput')).toBeDisabled() + await expect(page.getByTestId('roleNameInput')).toHaveValue(role.name) + + // Check OIDC group input is disabled and has correct value + await expect(page.getByTestId('roleOidcGroupInput')).toBeDisabled() + await expect(page.getByTestId('roleOidcGroupInput')).toHaveValue(role.oidcGroup) + + // Check delete button is not visible + await expect(page.getByTestId('deleteBtn')).toBeHidden() + } + }) +}) diff --git a/plugins/keycloak/src/functions.ts b/plugins/keycloak/src/functions.ts index 6ab77eeae..b772b0a2b 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,87 @@ 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, pluginName, roleName] = role.oidcGroup.split('/').slice(1) + if (!projectName || !pluginName || !roleName) throw new Error('Invalid OIDC group format') + const projectGroup = await getOrCreateProjectGroup(kcClient, projectName) + const pluginGroup = await getOrCreateChildGroup(kcClient, projectGroup.id, pluginName) + await getOrCreateChildGroup(kcClient, pluginGroup.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, pluginName, roleName] = role.oidcGroup.split('/').slice(1) + if (!projectName || !pluginName || !roleName) throw new Error('Invalid OIDC group format') + const projectGroup = await getGroupByName(kcClient, projectName) + if (projectGroup?.id) { + const pluginGroups = await getAllSubgroups(kcClient, projectGroup.id, 0) + const pluginGroup = pluginGroups.find(({ name }) => name === pluginName) as Required | undefined + if (pluginGroup?.id) { + const roleGroups = await getAllSubgroups(kcClient, pluginGroup.id, 0) + const roleGroup = roleGroups.find(({ name }) => name === roleName) as Required | undefined + if (roleGroup?.id) { + await deleteGroup(kcClient, roleGroup.id) + return { + status: { + result: 'OK', + message: 'Deleted', + }, + } + } + } + } + return { + status: { + result: 'OK', + message: 'Already deleted', + }, + } + } 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,