Skip to content

Commit 94215d7

Browse files
committed
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 #1880 and #1879 leading to member addition to never materialize. Signed-off-by: William Phetsinorath <william.phetsinorath-open@interieur.gouv.fr>
1 parent 2a50710 commit 94215d7

File tree

7 files changed

+158
-8
lines changed

7 files changed

+158
-8
lines changed

apps/server/src/resources/project-member/business.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,17 +44,20 @@ export async function addMember(projectId: Project['id'], user: XOR<{ userId: st
4444
}
4545

4646
await upsertMember({ projectId, userId: userInDb.id, roleIds: [] })
47+
await hook.projectMember.upsert(projectId, userInDb.id)
4748
return listMembers(projectId)
4849
}
4950

5051
export async function patchMembers(projectId: Project['id'], members: typeof projectMemberContract.patchMembers.body._type) {
5152
for (const member of members) {
5253
await upsertMember({ projectId, userId: member.userId, roleIds: member.roles })
54+
await hook.projectMember.upsert(projectId, member.userId)
5355
}
5456
return listMembers(projectId)
5557
}
5658

5759
export async function removeMember(projectId: Project['id'], userId: User['id']) {
60+
await hook.projectMember.delete(projectId, userId)
5861
await deleteMember({ projectId, userId })
5962
return listMembers(projectId)
6063
}

apps/server/src/utils/hook-wrapper.ts

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import type { Cluster, Kubeconfig, Project, ProjectRole, Zone } from '@prisma/client'
2-
import type { ClusterObject, HookResult, KubeCluster, KubeUser, Project as ProjectPayload, RepoCreds, Repository, Store, ZoneObject } from '@cpn-console/hooks'
1+
import type { Cluster, Kubeconfig, Project, ProjectRole, Zone, ProjectMembers } from '@prisma/client'
2+
import type { ClusterObject, HookResult, KubeCluster, KubeUser, ProjectMemberPayload, Project as ProjectPayload, RepoCreds, Repository, Store, ZoneObject } from '@cpn-console/hooks'
33
import { hooks } from '@cpn-console/hooks'
44
import type { AsyncReturnType } from '@cpn-console/shared'
55
import { ProjectAuthorized, getPermsByUserRoles, resourceListToDict } from '@cpn-console/shared'
@@ -139,6 +139,65 @@ const user = {
139139
},
140140
} as const
141141

142+
const projectMember = {
143+
upsert: async (projectId: Project['id'], userId: ProjectMembers['userId']) => {
144+
const project = await getHookProjectInfos(projectId)
145+
const store = dbToObj(await getAdminPlugin())
146+
147+
const member = project.members.find(m => m.userId === userId)
148+
if (!member) throw new Error('Member not found')
149+
150+
const memberRoles = project.roles
151+
.filter(role => member.roleIds.includes(role.id))
152+
.map(role => ({ ...role, oidcGroup: role.oidcGroup ?? undefined }))
153+
154+
const payload = {
155+
userId: member.userId,
156+
roleIds: member.roleIds,
157+
firstName: member.user.firstName,
158+
lastName: member.user.lastName,
159+
email: member.user.email,
160+
type: member.user.type as 'human' | 'bot' | 'ghost',
161+
createdAt: member.user.createdAt.toISOString(),
162+
updatedAt: member.user.updatedAt.toISOString(),
163+
lastLogin: member.user.lastLogin?.toISOString(),
164+
projectId: project.id,
165+
roles: memberRoles,
166+
project: { id: project.id, slug: project.slug },
167+
} as unknown as ProjectMemberPayload
168+
169+
return hooks.upsertProjectMember.execute(payload, store)
170+
},
171+
delete: async (projectId: Project['id'], userId: ProjectMembers['userId']) => {
172+
const project = await getHookProjectInfos(projectId)
173+
const store = dbToObj(await getAdminPlugin())
174+
175+
const member = project.members.find(m => m.userId === userId)
176+
if (!member) throw new Error('Member not found')
177+
178+
const memberRoles = project.roles
179+
.filter(role => member.roleIds.includes(role.id))
180+
.map(role => ({ ...role, oidcGroup: role.oidcGroup ?? undefined }))
181+
182+
const payload = {
183+
userId: member.userId,
184+
roleIds: member.roleIds,
185+
firstName: member.user.firstName,
186+
lastName: member.user.lastName,
187+
email: member.user.email,
188+
type: member.user.type as 'human' | 'bot' | 'ghost',
189+
createdAt: member.user.createdAt.toISOString(),
190+
updatedAt: member.user.updatedAt.toISOString(),
191+
lastLogin: member.user.lastLogin?.toISOString(),
192+
projectId: project.id,
193+
roles: memberRoles,
194+
project: { id: project.id, slug: project.slug },
195+
} as unknown as ProjectMemberPayload
196+
197+
return hooks.deleteProjectMember.execute(payload, store)
198+
},
199+
} as const
200+
142201
const projectRole = {
143202
upsert: async (roleId: ProjectRole['id']) => {
144203
const role = await getRole(roleId)
@@ -218,6 +277,8 @@ export const hook = {
218277
// @ts-ignore TODO voir comment opti la signature de la fonction
219278
projectRole: genericProxy(projectRole, { delete: ['upsert', 'delete'], upsert: ['delete'] }),
220279
// @ts-ignore TODO voir comment opti la signature de la fonction
280+
projectMember: genericProxy(projectMember, { delete: ['upsert'], upsert: ['delete'] }),
281+
// @ts-ignore TODO voir comment opti la signature de la fonction
221282
cluster: genericProxy(cluster, { delete: ['upsert', 'delete'], upsert: ['delete'] }),
222283
// @ts-ignore TODO voir comment opti la signature de la fonction
223284
zone: genericProxy(zone, { delete: ['upsert'], upsert: ['delete'] }),
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type { ProjectMember, ProjectRole } from '@cpn-console/shared'
2+
import type { Hook } from './hook.js'
3+
import { createHook } from './hook.js'
4+
5+
export type ProjectMemberPayload = ProjectMember & {
6+
roles: ProjectRole[]
7+
project: { id: string, slug: string }
8+
environments: {
9+
id: string
10+
name: string
11+
permissions: {
12+
ro: boolean
13+
rw: boolean
14+
}
15+
}[]
16+
}
17+
18+
export const upsertProjectMember: Hook<ProjectMemberPayload> = createHook()
19+
export const deleteProjectMember: Hook<ProjectMemberPayload> = createHook()

packages/hooks/src/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export * from './hook-cluster.js'
22
export * from './hook-misc.js'
33
export * from './hook-project.js'
44
export * from './hook-project-role.js'
5+
export * from './hook-project-member.js'
56
export * from './hook-user.js'
67
export * from './hook-zone.js'
78
export * from './hook-admin-role.js'

packages/shared/src/schemas/user.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,5 +31,10 @@ export const MemberSchema = z.object({
3131
,
3232
)
3333

34+
export const ProjectMemberSchema = MemberSchema.and(z.object({
35+
projectId: z.string().uuid(),
36+
}))
37+
3438
export type User = Zod.infer<typeof UserSchema>
3539
export type Member = Zod.infer<typeof MemberSchema>
40+
export type ProjectMember = Zod.infer<typeof ProjectMemberSchema>

plugins/keycloak/src/functions.ts

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { AdminRole, Project, StepCall, UserEmail, ZoneObject } from '@cpn-console/hooks'
1+
import type { AdminRole, Project, StepCall, UserEmail, ZoneObject, ProjectMemberPayload } from '@cpn-console/hooks'
22
import type { ProjectRole } from '@cpn-console/shared'
33
import { generateRandomPassword, parseError, PluginResultBuilder } from '@cpn-console/hooks'
44
import type GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation.js'
@@ -310,11 +310,7 @@ export const upsertProjectRole: StepCall<ProjectRole> = async ({ args: role }) =
310310
}
311311
try {
312312
const kcClient = await getkcClient()
313-
const [projectName, pluginName, roleName] = role.oidcGroup.split('/').slice(1)
314-
if (!projectName || !pluginName || !roleName) throw new Error('Invalid OIDC group format')
315-
const projectGroup = await getOrCreateProjectGroup(kcClient, projectName)
316-
const pluginGroup = await getOrCreateChildGroup(kcClient, projectGroup.id, pluginName)
317-
await getOrCreateChildGroup(kcClient, pluginGroup.id, roleName)
313+
await getOrCreateGroupByPath(kcClient, role.oidcGroup)
318314
return {
319315
status: {
320316
result: 'OK',
@@ -380,6 +376,63 @@ export const deleteProjectRole: StepCall<ProjectRole> = async ({ args: role }) =
380376
}
381377
}
382378

379+
export const upsertProjectMember: StepCall<ProjectMemberPayload> = async ({ args: member }) => {
380+
const pluginResult = new PluginResultBuilder('Synced')
381+
try {
382+
const kcClient = await getkcClient()
383+
384+
const projectGroup = await getOrCreateProjectGroup(kcClient, member.project.slug)
385+
const consoleGroup = await getOrCreateChildGroup(kcClient, projectGroup.id, consoleGroupName)
386+
const allRoleGroups = await getAllSubgroups(kcClient, consoleGroup.id, 0)
387+
const userGroups = await kcClient.users.listGroups({ id: member.userId })
388+
389+
const userRolesOidcGroups = member.roles
390+
.map(r => r.oidcGroup)
391+
.filter((g): g is string => !!g)
392+
393+
// Sync Roles
394+
for (const roleGroup of allRoleGroups) {
395+
if (!roleGroup.id || !roleGroup.path) continue
396+
const isMember = userGroups.some(ug => ug.id === roleGroup.id)
397+
const shouldBeMember = userRolesOidcGroups.includes(roleGroup.path)
398+
399+
if (shouldBeMember && !isMember) {
400+
await kcClient.users.addToGroup({ id: member.userId, groupId: roleGroup.id })
401+
} else if (!shouldBeMember && isMember) {
402+
await kcClient.users.delFromGroup({ id: member.userId, groupId: roleGroup.id })
403+
}
404+
}
405+
406+
return pluginResult.getResultObject()
407+
} catch (error) {
408+
return pluginResult.returnUnexpectedError(error)
409+
}
410+
}
411+
412+
export const deleteProjectMember: StepCall<ProjectMemberPayload> = async ({ args: member }) => {
413+
const pluginResult = new PluginResultBuilder('Deleted')
414+
try {
415+
const kcClient = await getkcClient()
416+
if (!member.userId) return pluginResult.getResultObject()
417+
418+
const projectGroup = await getGroupByName(kcClient, member.project.slug)
419+
if (!projectGroup?.id) return pluginResult.getResultObject()
420+
421+
const userGroups = await kcClient.users.listGroups({ id: member.userId })
422+
const projectGroups = userGroups.filter(g => g.path?.startsWith(projectGroup.path!))
423+
424+
for (const group of projectGroups) {
425+
if (group.id) {
426+
await kcClient.users.delFromGroup({ id: member.userId, groupId: group.id })
427+
}
428+
}
429+
430+
return pluginResult.getResultObject()
431+
} catch (error) {
432+
return pluginResult.returnUnexpectedError(error)
433+
}
434+
}
435+
383436
function getClientZoneId(zone: ZoneObject): string {
384437
return `argocd-${zone.slug}-zone`
385438
}

plugins/keycloak/src/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import {
99
upsertZone,
1010
upsertAdminRole,
1111
deleteAdminRole,
12+
upsertProjectMember,
13+
deleteProjectMember,
1214
} from './functions.js'
1315
import infos from './infos.js'
1416
import monitor from './monitor.js'
@@ -29,6 +31,12 @@ export const plugin: Plugin = {
2931
upsertProjectRole: {
3032
steps: { main: upsertProjectRole },
3133
},
34+
upsertProjectMember: {
35+
steps: { main: upsertProjectMember },
36+
},
37+
deleteProjectMember: {
38+
steps: { post: deleteProjectMember },
39+
},
3240
upsertZone: {
3341
steps: { main: upsertZone },
3442
},

0 commit comments

Comments
 (0)