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
@@ -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/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 { + 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.findMany.mockResolvedValueOnce([dbRole]) - prisma.projectRole.create.mockResolvedValue(null) + prisma.projectRole.create.mockResolvedValue(dbRole) await createRole(projectId, { name: 'test', permissions: '4' }) expect(prisma.projectRole.create).toHaveBeenCalledWith({ data: { name: 'test', permissions: 4n, position: 1, projectId } }) }) it('should create role with incremented position with bigger position', async () => { - const dbRole: Partial = { + const dbRole: ProjectRole = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + projectId, permissions: 4n, position: 50, + oidcGroup: '', + type: 'custom', } + prisma.project.findUnique.mockResolvedValue(project) prisma.projectRole.findFirst.mockResolvedValueOnce(dbRole) prisma.projectRole.findMany.mockResolvedValueOnce([dbRole]) - prisma.projectRole.create.mockResolvedValue(null) + prisma.projectRole.create.mockResolvedValue(dbRole) await createRole(projectId, { name: 'test', permissions: '4' }) expect(prisma.projectRole.create).toHaveBeenCalledWith({ data: { name: 'test', permissions: 4n, position: 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.findMany.mockResolvedValueOnce([dbRole]) - prisma.projectRole.create.mockResolvedValue(null) + prisma.projectRole.create.mockResolvedValue(dbRole) + prisma.projectRole.findFirst.mockResolvedValueOnce(null) + prisma.projectRole.findMany.mockResolvedValueOnce([dbRole]) + prisma.projectRole.create.mockResolvedValue(dbRole) await createRole(projectId, { name: 'test', permissions: '4' }) expect(prisma.projectRole.create).toHaveBeenCalledWith({ data: { name: 'test', permissions: 4n, position: 0, projectId } }) }) + + it('should create role with enforced oidcGroup prefix', async () => { + const dbRole: any = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + projectId, + permissions: 4n, + position: 0, + oidcGroup: `/${project.slug}/admin`, + type: 'custom', + } + + prisma.project.findUnique.mockResolvedValueOnce(project) + prisma.projectRole.findFirst.mockResolvedValueOnce(dbRole) + prisma.projectRole.create.mockResolvedValue(dbRole) + prisma.project.findUnique.mockResolvedValueOnce(project) + prisma.projectRole.findMany.mockResolvedValueOnce([dbRole]) + + await createRole(projectId, { name: 'test', permissions: '4', oidcGroup: '/admin' }) + + expect(prisma.projectRole.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + oidcGroup: `/${project.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 +178,7 @@ describe('test project-role business', () => { roleIds: [roleId, faker.string.uuid()], }] as const satisfies Partial[] + prisma.projectRole.findUnique.mockResolvedValueOnce(dbRole) prisma.projectMembers.findMany.mockResolvedValueOnce(members) prisma.projectRole.findMany.mockResolvedValueOnce([]) prisma.projectRole.delete.mockResolvedValue(dbRole) @@ -91,54 +188,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.mockResolvedValueOnce(dbRole) + + await expect(deleteRole(roleId)).rejects.toThrow(Forbidden403) + expect(prisma.projectRole.delete).not.toHaveBeenCalled() + }) }) describe.skip('countRolesMembers', () => { it('should return aggregated role member counts', async () => { - const partialRoles = [{ + 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 +307,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 +320,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 +333,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..1a388ab95 100644 --- a/apps/server/src/resources/project-role/business.ts +++ b/apps/server/src/resources/project-role/business.ts @@ -6,15 +6,21 @@ import { listRoles as listRolesQuery, updateRole, } from '@/resources/queries-index.js' -import { BadRequest400 } from '@/utils/errors.js' +import { BadRequest400, Forbidden403, NotFound404 } from '@/utils/errors.js' import prisma from '@/prisma.js' export async function listRoles(projectId: Project['id']) { - 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,11 +31,19 @@ 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') @@ -41,18 +55,29 @@ export async function patchRoles(projectId: Project['id'], roles: typeof project } export async function createRole(projectId: Project['id'], role: typeof projectRoleContract.createProjectRole.body._type) { + const project = await prisma.project.findUnique({ where: { id: projectId }, select: { slug: true } }) + if (!project) throw new NotFound404() const dbMaxPosRole = (await prisma.projectRole.findFirst({ where: { projectId }, orderBy: { position: 'desc' }, select: { position: true }, }))?.position ?? -1 + if (role.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 /') + } + await prisma.projectRole.create({ data: { ...role, projectId, position: dbMaxPosRole + 1, permissions: BigInt(role.permissions), + oidcGroup: role.oidcGroup ? `/${project.slug}${role.oidcGroup}` : undefined, }, }) @@ -72,6 +97,10 @@ 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 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..7fb791878 100644 --- a/apps/server/src/resources/project-role/queries.ts +++ b/apps/server/src/resources/project-role/queries.ts @@ -1,7 +1,6 @@ import type { Prisma, Project, - ProjectRole, } from '@prisma/client' @@ -9,18 +8,20 @@ import prisma from '@/prisma.js' 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/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: [