From 9ac049ba5e014376f163a84be8d657d36e8064ac Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Mon, 9 Feb 2026 12:19:47 +0100 Subject: [PATCH 1/6] 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..0bff05f54 --- /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 054ae1235d43df13c60f5294d09157d0a115137d Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Mon, 9 Feb 2026 12:19:50 +0100 Subject: [PATCH 2/6] 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 ++++---- 3 files changed, 10 insertions(+), 10 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 })), } } From 175aca9da8bad616f1c2bbb4c53b67dcf1dd8470 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Mon, 9 Feb 2026 12:19:50 +0100 Subject: [PATCH 3/6] 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 }, }, From 6d50ed844aa1e8fae2e77fd403fce9aed1f9a953 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Mon, 9 Feb 2026 12:19:50 +0100 Subject: [PATCH 4/6] fix(ci): Helm generate invalid YAML syntax Signed-off-by: William Phetsinorath --- .../migrations/20260206145218_dso/migration.sql | 12 ++++++++++++ ci/kind/env/dso-values-dev.yaml | 6 ++++-- ci/kind/env/dso-values.yaml | 1 - scripts/cancel-ci-shikanime.sh | 17 +++++++++++++++++ 4 files changed, 33 insertions(+), 3 deletions(-) create mode 100644 apps/server/src/prisma/migrations/20260206145218_dso/migration.sql create mode 100755 scripts/cancel-ci-shikanime.sh diff --git a/apps/server/src/prisma/migrations/20260206145218_dso/migration.sql b/apps/server/src/prisma/migrations/20260206145218_dso/migration.sql new file mode 100644 index 000000000..23593ba4f --- /dev/null +++ b/apps/server/src/prisma/migrations/20260206145218_dso/migration.sql @@ -0,0 +1,12 @@ +-- AlterTable +ALTER TABLE "Cluster" ALTER COLUMN "cpu" DROP DEFAULT, +ALTER COLUMN "gpu" DROP DEFAULT, +ALTER COLUMN "memory" DROP DEFAULT; + +-- AlterTable +ALTER TABLE "Project" ALTER COLUMN "hprodCpu" DROP DEFAULT, +ALTER COLUMN "hprodGpu" DROP DEFAULT, +ALTER COLUMN "hprodMemory" DROP DEFAULT, +ALTER COLUMN "prodCpu" DROP DEFAULT, +ALTER COLUMN "prodGpu" DROP DEFAULT, +ALTER COLUMN "prodMemory" DROP DEFAULT; diff --git a/ci/kind/env/dso-values-dev.yaml b/ci/kind/env/dso-values-dev.yaml index 11f8227dc..23efb29ec 100644 --- a/ci/kind/env/dso-values-dev.yaml +++ b/ci/kind/env/dso-values-dev.yaml @@ -1,7 +1,10 @@ global: env: NODE_ENV: development - + keycloak: + domain: + backend: dso-cpn-keycloak + frontend: dso-cpn-keycloak server: image: repository: dso-console/server @@ -15,7 +18,6 @@ server: # enabled: false env: CI: "false" - KEYCLOAK_DOMAIN: dso-cpn-keycloak extraVolumes: - name: dev-workspace path: /app/apps/server/src diff --git a/ci/kind/env/dso-values.yaml b/ci/kind/env/dso-values.yaml index 162ab8361..d0a5f855d 100644 --- a/ci/kind/env/dso-values.yaml +++ b/ci/kind/env/dso-values.yaml @@ -56,7 +56,6 @@ server: env: CI: "true" DEV_SETUP: "true" - KEYCLOAK_DOMAIN: dso-cpn-keycloak client: image: diff --git a/scripts/cancel-ci-shikanime.sh b/scripts/cancel-ci-shikanime.sh new file mode 100755 index 000000000..ce137b3f8 --- /dev/null +++ b/scripts/cancel-ci-shikanime.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +# Cancel all in_progress and queued runs for user 'shikanime' on workflow 'Continuous Integration' + +echo "Cancelling in_progress runs..." +gh run list --workflow "Continuous Integration" --user shikanime --status in_progress --json databaseId --jq '.[].databaseId' | while read -r run_id; do + echo "Cancelling run $run_id" + gh run cancel "$run_id" +done + +echo "Cancelling queued runs..." +gh run list --workflow "Continuous Integration" --user shikanime --status queued --json databaseId --jq '.[].databaseId' | while read -r run_id; do + echo "Cancelling run $run_id" + gh run cancel "$run_id" +done + +echo "Done." From c4c1626aa0038e29ec7b2cdf5db18b43fa7949c5 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Mon, 9 Feb 2026 12:19:50 +0100 Subject: [PATCH 5/6] feat(keycloack): add configurable purge at upsert Signed-off-by: William Phetsinorath --- plugins/keycloak/src/functions.ts | 33 +++++++++++++++++-------------- plugins/keycloak/src/infos.ts | 19 ++++++++++++++++++ 2 files changed, 37 insertions(+), 15 deletions(-) diff --git a/plugins/keycloak/src/functions.ts b/plugins/keycloak/src/functions.ts index 150d73dfd..768cb45e5 100644 --- a/plugins/keycloak/src/functions.ts +++ b/plugins/keycloak/src/functions.ts @@ -1,5 +1,5 @@ import type { AdminRole, Project, StepCall, UserEmail, ZoneObject, ProjectMemberPayload } from '@cpn-console/hooks' -import type { ProjectRole } from '@cpn-console/shared' +import { ENABLED, 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' @@ -60,31 +60,34 @@ export const deleteProject: StepCall = async ({ args: project }) => { } } -export const upsertProject: StepCall = async ({ args: project }) => { +export const upsertProject: StepCall = async (payload) => { const pluginResult = new PluginResultBuilder('Up-to-date') try { const kcClient = await getkcClient() - const projectName = project.slug + const projectName = payload.args.slug + const purgeEnabled = payload.config.argocd?.purge === ENABLED const projectGroup = await getOrCreateProjectGroup(kcClient, projectName) const groupMembers = await kcClient.groups.listMembers({ id: projectGroup.id }) await Promise.all([ ...groupMembers.map((member) => { - if (!project.users.some(({ id }) => id === member.id)) { - return kcClient.users.delFromGroup({ - // @ts-ignore id is present on user, bad typing in lib - id: member.id, - groupId: projectGroup.id, - }) - .catch((err) => { - pluginResult.addKoMessage(`Can't remove ${member.email} from keycloak project group`) - pluginResult.addExtra(`remove-${member.id}`, err) + if (!payload.args.users.some(({ id }) => id === member.id)) { + if (purgeEnabled) { + return kcClient.users.delFromGroup({ + // @ts-ignore id is present on user, bad typing in lib + id: member.id, + groupId: projectGroup.id, }) + .catch((err) => { + pluginResult.addKoMessage(`Can't remove ${member.email} from keycloak project group`) + pluginResult.addExtra(`remove-${member.id}`, err) + }) + } } return undefined }), - ...project.users.map((user) => { + ...payload.args.users.map((user) => { if (!groupMembers.some(({ id }) => id === user.id)) { return kcClient.users.addToGroup({ id: user.id, @@ -108,7 +111,7 @@ export const upsertProject: StepCall = async ({ args: project }) => { const envGroups = await getAllSubgroups(kcClient, consoleGroup.id, 0) as CustomGroup[] const promises: Promise[] = [] - for (const environment of project.environments) { + for (const environment of payload.args.environments) { const envGroup: Required = envGroups.find(group => group.name === environment.name) as Required ?? await getOrCreateChildGroup(kcClient, consoleGroup.id, environment.name) @@ -135,7 +138,7 @@ export const upsertProject: StepCall = async ({ args: project }) => { await Promise.all(promises) await Promise.all(envGroups.map((subGroup) => { - if (!project.environments.some(({ name }) => name === subGroup.name)) { + if (!payload.args.environments.some(({ name }) => name === subGroup.name)) { return kcClient.groups.del({ id: subGroup.id }) } return undefined diff --git a/plugins/keycloak/src/infos.ts b/plugins/keycloak/src/infos.ts index da32d4d24..e4b723879 100644 --- a/plugins/keycloak/src/infos.ts +++ b/plugins/keycloak/src/infos.ts @@ -1,8 +1,27 @@ import type { ServiceInfos } from '@cpn-console/hooks' +import { DISABLED } from '@cpn-console/shared' const infos: ServiceInfos = { name: 'keycloak', title: 'Keycloak', + config: { + global: [ + ], + project: [ + { + kind: 'switch', + key: 'purge', + initialValue: DISABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Purger les utilisateurs non synchronisés', + value: DISABLED, + description: 'Purger les utilisateurs non synchronisés de Keycloak lors de la synchronisation', + }, + ], + }, } export default infos From 24d74f8c847f5994e3fd01004a39503a92c5b5c5 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Mon, 9 Feb 2026 12:19:50 +0100 Subject: [PATCH 6/6] feat(gitlab): add builtin admin roles to GitLab Signed-off-by: William Phetsinorath --- plugins/gitlab/src/functions.ts | 88 ++++++++++++++++++++++++++++++++- plugins/gitlab/src/index.ts | 6 +++ plugins/gitlab/src/infos.ts | 22 +++++++++ plugins/gitlab/src/user.ts | 5 +- 4 files changed, 117 insertions(+), 4 deletions(-) diff --git a/plugins/gitlab/src/functions.ts b/plugins/gitlab/src/functions.ts index 110ff01b8..7b4464a96 100644 --- a/plugins/gitlab/src/functions.ts +++ b/plugins/gitlab/src/functions.ts @@ -1,8 +1,8 @@ import { okStatus, parseError, specificallyDisabled } from '@cpn-console/hooks' -import type { ClusterObject, PluginResult, Project, ProjectLite, StepCall, UniqueRepo, ZoneObject } from '@cpn-console/hooks' +import type { AdminRole, ClusterObject, PluginResult, Project, ProjectLite, StepCall, UniqueRepo, ZoneObject } from '@cpn-console/hooks' import { insert } from '@cpn-console/shared' import { deleteGroup } from './group.js' -import { createUsername, getUser } from './user.js' +import { createUsername, getUser, upsertUser } from './user.js' import { ensureMembers } from './members.js' import { ensureRepositories } from './repositories.js' import type { VaultSecrets } from './utils.js' @@ -232,3 +232,87 @@ export const commitFiles: StepCall = async (payload) => { + try { + const role = payload.args + const adminGroupPath = payload.config.gitlab?.adminGroupPath + if (!adminGroupPath) { + throw new Error('adminGroupPath is required') + } + const auditorGroupPath = payload.config.gitlab?.auditorGroupPath + if (!auditorGroupPath) { + throw new Error('auditorGroupPath is required') + } + + const isAdmin = role.oidcGroup === adminGroupPath ? true : undefined + const isAuditor = role.oidcGroup === auditorGroupPath ? true : undefined + + if (isAdmin === undefined && isAuditor === undefined) { + return { + status: { + result: 'OK', + message: 'Not a managed role for GitLab plugin', + }, + } + } + + for (const member of role.members) { + await upsertUser(member, isAdmin, isAuditor) + } + + return { + status: { + result: 'OK', + message: 'Members synced', + }, + } + } catch (error) { + return { + error: parseError(cleanGitlabError(error)), + status: { + result: 'KO', + message: 'An error occured while syncing admin role', + }, + } + } +} + +export const deleteAdminRole: StepCall = async (payload) => { + try { + const role = payload.args + const adminGroupPath = payload.config.gitlab?.adminGroupPath ?? '/console/admin' + const auditorGroupPath = payload.config.gitlab?.auditorGroupPath ?? '/console/readonly' + + const isAdmin = role.oidcGroup === adminGroupPath ? false : undefined + const isAuditor = role.oidcGroup === auditorGroupPath ? false : undefined + + if (isAdmin === undefined && isAuditor === undefined) { + return { + status: { + result: 'OK', + message: 'Not a managed role for GitLab plugin', + }, + } + } + + for (const member of role.members) { + await upsertUser(member, isAdmin, isAuditor) + } + + return { + status: { + result: 'OK', + message: 'Admin role deleted and members synced', + }, + } + } catch (error) { + return { + error: parseError(cleanGitlabError(error)), + status: { + result: 'KO', + message: 'An error occured while deleting admin role', + }, + } + } +} diff --git a/plugins/gitlab/src/index.ts b/plugins/gitlab/src/index.ts index ef72f7dd5..44f857e18 100644 --- a/plugins/gitlab/src/index.ts +++ b/plugins/gitlab/src/index.ts @@ -6,6 +6,7 @@ import { deleteZone, getDsoProjectSecrets, syncRepository, + upsertAdminRole, upsertDsoProject, upsertZone, } from './functions.js' @@ -74,6 +75,11 @@ export const plugin: Plugin = { main: deleteZone, }, }, + upsertAdminRole: { + steps: { + main: upsertAdminRole, + }, + }, }, monitor, start, diff --git a/plugins/gitlab/src/infos.ts b/plugins/gitlab/src/infos.ts index af3b03869..98cfaa582 100644 --- a/plugins/gitlab/src/infos.ts +++ b/plugins/gitlab/src/infos.ts @@ -20,6 +20,28 @@ const infos = { title: 'Afficher l\'aide de trigger de pipeline', value: ENABLED, description: 'Afficher l\'aide de trigger de pipeline aux utilisateurs lorsqu\'ils souhaitent afficher les secrets du projet', + }, { + kind: 'text', + key: 'adminGroupPath', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Chemin du groupe OIDC Admin', + value: '/console/admin', + description: 'Le chemin du groupe OIDC qui donne les droits d\'administrateur GitLab', + placeholder: '/console/admin', + }, { + kind: 'text', + key: 'auditorGroupPath', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Chemin du groupe OIDC Auditeur', + value: '/console/readonly', + description: 'Le chemin du groupe OIDC qui donne les droits d\'auditeur GitLab', + placeholder: '/console/readonly', }], project: [], }, diff --git a/plugins/gitlab/src/user.ts b/plugins/gitlab/src/user.ts index cb984c036..2eed6599b 100644 --- a/plugins/gitlab/src/user.ts +++ b/plugins/gitlab/src/user.ts @@ -25,7 +25,7 @@ export async function getUser(user: { email: string, username: string, id: strin || allUsers.find(gitlabUser => gitlabUser.username === user.username) } -export async function upsertUser(user: UserObject): Promise { +export async function upsertUser(user: UserObject, isAdmin = false, isAuditor = false): Promise { const api = getApi() const username = createUsername(user.email) const existingUser = await getUser({ ...user, username }) @@ -38,6 +38,8 @@ export async function upsertUser(user: UserObject): Promise { // sso options externUid: user.id, provider: 'openid_connect', + admin: isAdmin, + auditor: isAuditor, } if (existingUser) { @@ -64,7 +66,6 @@ export async function upsertUser(user: UserObject): Promise { return api.Users.create({ ...userDefinitionBase, - admin: false, canCreateGroup: false, forceRandomPassword: true, projectsLimit: 0,