From a486d482c30e28efd121c787d456d1cc487a8f32 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Mon, 9 Feb 2026 14:56:46 +0100 Subject: [PATCH 1/3] fix(admin-roles): admin conflicting with existing admin group in Keycloack The new AdminRole implementation introduced by https://github.com/cloud-pi-native/console/pull/1893 changed the source of truth from Keycloak to Console, which overrides all existing data based on the state of Console. Signed-off-by: William Phetsinorath --- .../migration.sql | 2 +- .../20260206105522_dso/migration.sql | 28 +++++++++++++++++++ packages/test-utils/src/imports/data.ts | 10 ++++++- 3 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 apps/server/src/prisma/migrations/20260206105522_dso/migration.sql 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 index adb8b6f25..92c200d26 100644 --- a/apps/server/src/prisma/migrations/20260204150335_add_system_roles/migration.sql +++ b/apps/server/src/prisma/migrations/20260204150335_add_system_roles/migration.sql @@ -1,6 +1,6 @@ -- Update existing Admin role to be system role 'Administrateur Plateforme' UPDATE "AdminRole" -SET +SET "name" = 'Administrateur Plateforme', "type" = 'system', "permissions" = 3, -- Assuming 3n means bit 0 and 1 (1 | 2 = 3) diff --git a/apps/server/src/prisma/migrations/20260206105522_dso/migration.sql b/apps/server/src/prisma/migrations/20260206105522_dso/migration.sql new file mode 100644 index 000000000..2a4afa897 --- /dev/null +++ b/apps/server/src/prisma/migrations/20260206105522_dso/migration.sql @@ -0,0 +1,28 @@ +-- Update existing Admin role to be system role 'Root Administrateur Plateforme' +UPDATE "AdminRole" +SET + "name" = 'Root Administrateur Plateforme' +WHERE "id" = '76229c96-4716-45bc-99da-00498ec9018c'::uuid; + +-- Insert 'Administrateur Plateforme' system role if it doesn't exist +INSERT INTO "AdminRole" ("id", "name", "permissions", "position", "oidcGroup", "type") +VALUES ( + '6bebe7b2-0f0a-456e-ab7f-b3d7640a7cbf'::uuid, + 'Administrateur Plateforme', + 3, -- Assuming 3n means bit 0 and 1 (1 | 2 = 3) + 0, + '/console/admin', + 'system' +) +ON CONFLICT ("id") DO UPDATE +SET + "name" = 'Administrateur Plateforme', + "type" = 'system', + "permissions" = 3, + "oidcGroup" = '/console/admin'; + +-- Update 'Lecture Seule Plateforme' system role +UPDATE "AdminRole" +SET + "oidcGroup" = '/console/readonly' +WHERE "id" = '35848aa2-e881-4770-9844-0c5c3693e506'::uuid; diff --git a/packages/test-utils/src/imports/data.ts b/packages/test-utils/src/imports/data.ts index 394b07959..b4d38d6b3 100644 --- a/packages/test-utils/src/imports/data.ts +++ b/packages/test-utils/src/imports/data.ts @@ -24,6 +24,14 @@ export const data = { permissions: '3n', position: 0, oidcGroup: '/admin', + name: 'Root Administrateur Plateforme', + type: 'system', + }, + { + id: '6bebe7b2-0f0a-456e-ab7f-b3d7640a7cbf', + permissions: '3n', + position: 0, + oidcGroup: '/console/admin', name: 'Administrateur Plateforme', type: 'system', }, @@ -39,7 +47,7 @@ export const data = { id: '35848aa2-e881-4770-9844-0c5c3693e506', permissions: '1n', position: 2, - oidcGroup: '/readonly', + oidcGroup: '/console/readonly', name: 'Lecture Seule Plateforme', type: 'system', }, From 96073a28ca0f5b979a888ff28164277de6500e10 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Mon, 9 Feb 2026 14:56:54 +0100 Subject: [PATCH 2/3] fix(project-roles): remove /console prefix strip Signed-off-by: William Phetsinorath --- apps/server/src/resources/project-role/business.spec.ts | 6 +++--- apps/server/src/resources/project-role/business.ts | 6 +++--- apps/server/src/resources/project/business.ts | 8 ++++---- playwright/e2e-tests/system-roles.spec.ts | 8 ++++---- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/server/src/resources/project-role/business.spec.ts b/apps/server/src/resources/project-role/business.spec.ts index f1719ef06..da877b925 100644 --- a/apps/server/src/resources/project-role/business.spec.ts +++ b/apps/server/src/resources/project-role/business.spec.ts @@ -78,7 +78,7 @@ describe('test project-role business', () => { prisma.projectRole.findMany.mockResolvedValueOnce([dbRole]) const response = await listRoles(projectId) - expect(response[0].oidcGroup).toBe('/admin') + expect(response[0].oidcGroup).toBe('/console/admin') }) }) @@ -162,7 +162,7 @@ describe('test project-role business', () => { prisma.projectRole.create.mockResolvedValue(dbRole) prisma.projectRole.findMany.mockResolvedValueOnce([dbRole]) - await createRole(projectId, { name: 'test', permissions: '4', oidcGroup: '/admin' }) + await createRole(projectId, { name: 'test', permissions: '4', oidcGroup: '/console/admin' }) expect(prisma.projectRole.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ @@ -378,7 +378,7 @@ describe('test project-role business', () => { it('should update role with enforced oidcGroup prefix', async () => { const updateRoles: any[] = [ - { id: dbRoles[1].id, oidcGroup: '/admin' }, + { id: dbRoles[1].id, oidcGroup: '/console/admin' }, ] prisma.project.findUnique.mockResolvedValue(project) diff --git a/apps/server/src/resources/project-role/business.ts b/apps/server/src/resources/project-role/business.ts index 13e413bd4..fb4021194 100644 --- a/apps/server/src/resources/project-role/business.ts +++ b/apps/server/src/resources/project-role/business.ts @@ -15,7 +15,7 @@ export async function listRoles(projectId: Project['id']) { return roles.map(role => ({ ...role, permissions: role.permissions.toString(), - oidcGroup: role.oidcGroup ? role.oidcGroup.replace(/^\/[^/]+\/console/, '') : role.oidcGroup, + oidcGroup: role.oidcGroup ? role.oidcGroup.replace(/^\/[^/]+/, '') : role.oidcGroup, })) } @@ -45,7 +45,7 @@ export async function patchRoles(projectId: Project['id'], roles: typeof project 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, + oidcGroup: matchingRole.oidcGroup ? `/${project.slug}${matchingRole.oidcGroup}` : dbRole.oidcGroup, type: matchingRole.type ?? dbRole.type, projectId: dbRole.projectId, }) @@ -84,7 +84,7 @@ export async function createRole(projectId: Project['id'], role: typeof projectR projectId, position: dbMaxPosRole + 1, permissions: BigInt(role.permissions), - oidcGroup: role.oidcGroup ? `/${project.slug}/console${role.oidcGroup}` : undefined, + oidcGroup: role.oidcGroup ? `/${project.slug}${role.oidcGroup}` : undefined, }, }) diff --git a/apps/server/src/resources/project/business.ts b/apps/server/src/resources/project/business.ts index 9f76e983f..58cee33f1 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(), oidcGroup: project.slug ? role.oidcGroup?.replace(`/${project.slug}/console`, '') : role.oidcGroup })), + roles: project.roles.map(role => ({ ...role, permissions: role.permissions.toString(), oidcGroup: project.slug ? role.oidcGroup?.replace(`/${project.slug}`, '') : role.oidcGroup })), everyonePerms: project.everyonePerms.toString(), }))) } @@ -91,7 +91,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(), oidcGroup: projectInfos.slug ? role.oidcGroup?.replace(`/${project.slug}/console`, '') : role.oidcGroup })), + roles: projectInfos.roles.map(role => ({ ...role, permissions: role.permissions.toString(), oidcGroup: projectInfos.slug ? role.oidcGroup?.replace(`/${project.slug}`, '') : role.oidcGroup })), } } @@ -99,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(), oidcGroup: project.slug ? role.oidcGroup?.replace(`/${project.slug}/console`, '') : role.oidcGroup })), + roles: project.roles.map(role => ({ ...role, permissions: role.permissions.toString(), oidcGroup: project.slug ? role.oidcGroup?.replace(`/${project.slug}`, '') : role.oidcGroup })), everyonePerms: project.everyonePerms.toString(), })) } @@ -156,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(), oidcGroup: projectInfos.slug ? role.oidcGroup?.replace(`/${projectInfos.slug}/console`, '') : role.oidcGroup })), + roles: projectInfos.roles.map(role => ({ ...role, permissions: role.permissions.toString(), oidcGroup: projectInfos.slug ? role.oidcGroup?.replace(`/${projectInfos.slug}`, '') : role.oidcGroup })), } } diff --git a/playwright/e2e-tests/system-roles.spec.ts b/playwright/e2e-tests/system-roles.spec.ts index 2c5947f22..f8d1647c4 100644 --- a/playwright/e2e-tests/system-roles.spec.ts +++ b/playwright/e2e-tests/system-roles.spec.ts @@ -20,10 +20,10 @@ test.describe('System Roles at Project Creation', () => { // Assert const systemRoles = [ - { name: 'Administrateur', oidcGroup: '/admin' }, - { name: 'DevOps', oidcGroup: '/devops' }, - { name: 'Développeur', oidcGroup: '/developer' }, - { name: 'Lecture seule', oidcGroup: '/readonly' }, + { name: 'Administrateur', oidcGroup: '/console/admin' }, + { name: 'DevOps', oidcGroup: '/console/devops' }, + { name: 'Développeur', oidcGroup: '/console/developer' }, + { name: 'Lecture seule', oidcGroup: '/console/readonly' }, ] for (const role of systemRoles) { From 554c122e5e6075fdd091cdd54e37fc2a798a1ec5 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Mon, 9 Feb 2026 14:57:54 +0100 Subject: [PATCH 3/3] fix(keycloak): project members sync to Keycloak The implementation of ProjectRole is slightly different from AdminRole in which the members are stored around Project struture. I didn't took account of this difference in https://github.com/cloud-pi-native/console/pull/1880 and https://github.com/cloud-pi-native/console/pull/1879 leading to member addition to never materialize. Signed-off-by: William Phetsinorath --- .../src/resources/project-member/business.ts | 3 + apps/server/src/utils/hook-wrapper.ts | 65 ++++++++++++++++++- .../hooks/src/hooks/hook-project-member.ts | 19 ++++++ packages/hooks/src/hooks/index.ts | 1 + packages/shared/src/schemas/user.ts | 5 ++ plugins/keycloak/src/functions.ts | 65 +++++++++++++++++-- plugins/keycloak/src/index.ts | 8 +++ 7 files changed, 158 insertions(+), 8 deletions(-) create mode 100644 packages/hooks/src/hooks/hook-project-member.ts diff --git a/apps/server/src/resources/project-member/business.ts b/apps/server/src/resources/project-member/business.ts index 392b7dd15..b0f1c127b 100644 --- a/apps/server/src/resources/project-member/business.ts +++ b/apps/server/src/resources/project-member/business.ts @@ -44,17 +44,20 @@ export async function addMember(projectId: Project['id'], user: XOR<{ userId: st } await upsertMember({ projectId, userId: userInDb.id, roleIds: [] }) + await hook.projectMember.upsert(projectId, userInDb.id) return listMembers(projectId) } export async function patchMembers(projectId: Project['id'], members: typeof projectMemberContract.patchMembers.body._type) { for (const member of members) { await upsertMember({ projectId, userId: member.userId, roleIds: member.roles }) + await hook.projectMember.upsert(projectId, member.userId) } return listMembers(projectId) } export async function removeMember(projectId: Project['id'], userId: User['id']) { + await hook.projectMember.delete(projectId, userId) await deleteMember({ projectId, userId }) return listMembers(projectId) } diff --git a/apps/server/src/utils/hook-wrapper.ts b/apps/server/src/utils/hook-wrapper.ts index e7f335633..ec7c69974 100644 --- a/apps/server/src/utils/hook-wrapper.ts +++ b/apps/server/src/utils/hook-wrapper.ts @@ -1,5 +1,5 @@ -import type { Cluster, Kubeconfig, Project, ProjectRole, Zone } from '@prisma/client' -import type { ClusterObject, HookResult, KubeCluster, KubeUser, Project as ProjectPayload, RepoCreds, Repository, Store, ZoneObject } from '@cpn-console/hooks' +import type { Cluster, Kubeconfig, Project, ProjectRole, Zone, ProjectMembers } from '@prisma/client' +import type { ClusterObject, HookResult, KubeCluster, KubeUser, ProjectMemberPayload, Project as ProjectPayload, RepoCreds, Repository, Store, ZoneObject } from '@cpn-console/hooks' import { hooks } from '@cpn-console/hooks' import type { AsyncReturnType } from '@cpn-console/shared' import { ProjectAuthorized, getPermsByUserRoles, resourceListToDict } from '@cpn-console/shared' @@ -139,6 +139,65 @@ const user = { }, } as const +const projectMember = { + upsert: async (projectId: Project['id'], userId: ProjectMembers['userId']) => { + const project = await getHookProjectInfos(projectId) + const store = dbToObj(await getAdminPlugin()) + + const member = project.members.find(m => m.userId === userId) + if (!member) throw new Error('Member not found') + + const memberRoles = project.roles + .filter(role => member.roleIds.includes(role.id)) + .map(role => ({ ...role, oidcGroup: role.oidcGroup ?? undefined })) + + const payload = { + userId: member.userId, + roleIds: member.roleIds, + firstName: member.user.firstName, + lastName: member.user.lastName, + email: member.user.email, + type: member.user.type as 'human' | 'bot' | 'ghost', + createdAt: member.user.createdAt.toISOString(), + updatedAt: member.user.updatedAt.toISOString(), + lastLogin: member.user.lastLogin?.toISOString(), + projectId: project.id, + roles: memberRoles, + project: { id: project.id, slug: project.slug }, + } as unknown as ProjectMemberPayload + + return hooks.upsertProjectMember.execute(payload, store) + }, + delete: async (projectId: Project['id'], userId: ProjectMembers['userId']) => { + const project = await getHookProjectInfos(projectId) + const store = dbToObj(await getAdminPlugin()) + + const member = project.members.find(m => m.userId === userId) + if (!member) throw new Error('Member not found') + + const memberRoles = project.roles + .filter(role => member.roleIds.includes(role.id)) + .map(role => ({ ...role, oidcGroup: role.oidcGroup ?? undefined })) + + const payload = { + userId: member.userId, + roleIds: member.roleIds, + firstName: member.user.firstName, + lastName: member.user.lastName, + email: member.user.email, + type: member.user.type as 'human' | 'bot' | 'ghost', + createdAt: member.user.createdAt.toISOString(), + updatedAt: member.user.updatedAt.toISOString(), + lastLogin: member.user.lastLogin?.toISOString(), + projectId: project.id, + roles: memberRoles, + project: { id: project.id, slug: project.slug }, + } as unknown as ProjectMemberPayload + + return hooks.deleteProjectMember.execute(payload, store) + }, +} as const + const projectRole = { upsert: async (roleId: ProjectRole['id']) => { const role = await getRole(roleId) @@ -218,6 +277,8 @@ export const hook = { // @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 + projectMember: genericProxy(projectMember, { delete: ['upsert'], 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-member.ts b/packages/hooks/src/hooks/hook-project-member.ts new file mode 100644 index 000000000..190f0411d --- /dev/null +++ b/packages/hooks/src/hooks/hook-project-member.ts @@ -0,0 +1,19 @@ +import type { ProjectMember, ProjectRole } from '@cpn-console/shared' +import type { Hook } from './hook.js' +import { createHook } from './hook.js' + +export type ProjectMemberPayload = ProjectMember & { + roles: ProjectRole[] + project: { id: string, slug: string } + environments: { + id: string + name: string + permissions: { + ro: boolean + rw: boolean + } + }[] +} + +export const upsertProjectMember: Hook = createHook() +export const deleteProjectMember: Hook = createHook() diff --git a/packages/hooks/src/hooks/index.ts b/packages/hooks/src/hooks/index.ts index e31ae74d1..81fc6f879 100644 --- a/packages/hooks/src/hooks/index.ts +++ b/packages/hooks/src/hooks/index.ts @@ -2,6 +2,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-project-member.js' export * from './hook-user.js' export * from './hook-zone.js' export * from './hook-admin-role.js' diff --git a/packages/shared/src/schemas/user.ts b/packages/shared/src/schemas/user.ts index 130cfd57f..fecd0721c 100644 --- a/packages/shared/src/schemas/user.ts +++ b/packages/shared/src/schemas/user.ts @@ -31,5 +31,10 @@ export const MemberSchema = z.object({ , ) +export const ProjectMemberSchema = MemberSchema.and(z.object({ + projectId: z.string().uuid(), +})) + export type User = Zod.infer export type Member = Zod.infer +export type ProjectMember = Zod.infer diff --git a/plugins/keycloak/src/functions.ts b/plugins/keycloak/src/functions.ts index efdfdf48c..150d73dfd 100644 --- a/plugins/keycloak/src/functions.ts +++ b/plugins/keycloak/src/functions.ts @@ -1,4 +1,4 @@ -import type { AdminRole, Project, StepCall, UserEmail, ZoneObject } from '@cpn-console/hooks' +import type { AdminRole, Project, StepCall, UserEmail, ZoneObject, ProjectMemberPayload } 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' @@ -310,11 +310,7 @@ export const upsertProjectRole: StepCall = async ({ args: role }) = } 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) + await getOrCreateGroupByPath(kcClient, role.oidcGroup) return { status: { result: 'OK', @@ -380,6 +376,63 @@ export const deleteProjectRole: StepCall = async ({ args: role }) = } } +export const upsertProjectMember: StepCall = async ({ args: member }) => { + const pluginResult = new PluginResultBuilder('Synced') + try { + const kcClient = await getkcClient() + + const projectGroup = await getOrCreateProjectGroup(kcClient, member.project.slug) + const consoleGroup = await getOrCreateChildGroup(kcClient, projectGroup.id, consoleGroupName) + const allRoleGroups = await getAllSubgroups(kcClient, consoleGroup.id, 0) + const userGroups = await kcClient.users.listGroups({ id: member.userId }) + + const userRolesOidcGroups = member.roles + .map(r => r.oidcGroup) + .filter((g): g is string => !!g) + + // Sync Roles + for (const roleGroup of allRoleGroups) { + if (!roleGroup.id || !roleGroup.path) continue + const isMember = userGroups.some(ug => ug.id === roleGroup.id) + const shouldBeMember = userRolesOidcGroups.includes(roleGroup.path) + + if (shouldBeMember && !isMember) { + await kcClient.users.addToGroup({ id: member.userId, groupId: roleGroup.id }) + } else if (!shouldBeMember && isMember) { + await kcClient.users.delFromGroup({ id: member.userId, groupId: roleGroup.id }) + } + } + + return pluginResult.getResultObject() + } catch (error) { + return pluginResult.returnUnexpectedError(error) + } +} + +export const deleteProjectMember: StepCall = async ({ args: member }) => { + const pluginResult = new PluginResultBuilder('Deleted') + try { + const kcClient = await getkcClient() + if (!member.userId) return pluginResult.getResultObject() + + const projectGroup = await getGroupByName(kcClient, member.project.slug) + if (!projectGroup?.id) return pluginResult.getResultObject() + + const userGroups = await kcClient.users.listGroups({ id: member.userId }) + const projectGroups = userGroups.filter(g => g.path?.startsWith(projectGroup.path!)) + + for (const group of projectGroups) { + if (group.id) { + await kcClient.users.delFromGroup({ id: member.userId, groupId: group.id }) + } + } + + return pluginResult.getResultObject() + } catch (error) { + return pluginResult.returnUnexpectedError(error) + } +} + function getClientZoneId(zone: ZoneObject): string { return `argocd-${zone.slug}-zone` } diff --git a/plugins/keycloak/src/index.ts b/plugins/keycloak/src/index.ts index 9d1b75568..063d67155 100644 --- a/plugins/keycloak/src/index.ts +++ b/plugins/keycloak/src/index.ts @@ -9,6 +9,8 @@ import { upsertZone, upsertAdminRole, deleteAdminRole, + upsertProjectMember, + deleteProjectMember, } from './functions.js' import infos from './infos.js' import monitor from './monitor.js' @@ -29,6 +31,12 @@ export const plugin: Plugin = { upsertProjectRole: { steps: { main: upsertProjectRole }, }, + upsertProjectMember: { + steps: { main: upsertProjectMember }, + }, + deleteProjectMember: { + steps: { post: deleteProjectMember }, + }, upsertZone: { steps: { main: upsertZone }, },