Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { keycloakConfigFactory } from '../../config/keycloak.config'
import { getErrorResponseStatus } from '../../utils/http.utils'
import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator'
import { ADMIN_AUTH_REALM, ADMIN_TOKEN_REFRESH_INTERVAL_MS, CONSOLE_GROUP_NAME, PASSWORD_GRANT_TYPE, REFRESH_TOKEN_GRANT_TYPE, SUBGROUPS_PAGINATE_QUERY_MAX } from './keycloak.constants'
import { groupSchema, splitGroupPath } from './keycloak.utils'
import { ensure, groupSchema, splitGroupPath } from './keycloak.utils'

export const KEYCLOAK_ADMIN_CLIENT = Symbol('KEYCLOAK_ADMIN_CLIENT')

Expand Down Expand Up @@ -129,21 +129,16 @@ export class KeycloakClientService implements OnModuleInit {
const span = trace.getActiveSpan()
span?.setAttribute('group.name', name)
this.logger.debug(`Creating Keycloak group ${name}`)
try {
await this.client.groups.create({ name })
} catch (err) {
// A concurrent reconciliation (e.g. the cron sync and a project upsert)
// may have created the root group between the read and the create; treat
// the 409 as "already exists" and re-fetch it.
if (getErrorResponseStatus(err) !== 409) throw err
this.logger.verbose(`Keycloak group ${name} was created concurrently, fetching it`)
const existing = await this.getRootGroupByName(name)
if (!existing) throw err
return existing
}
const created = await this.getRootGroupByName(name)
if (!created) throw new Error(`Created Keycloak group ${name} but could not fetch it back`)
return created
return ensure({
create: async () => {
await this.client.groups.create({ name })
const created = await this.getRootGroupByName(name)
if (!created) throw new Error(`Created Keycloak group ${name} but could not fetch it back`)
return created
},
reload: () => this.getRootGroupByName(name),
onCollision: () => this.logger.verbose(`Keycloak group ${name} was created concurrently, fetching it`),
})
}

async addUserToGroup(userId: string, groupId: string) {
Expand Down Expand Up @@ -213,22 +208,18 @@ export class KeycloakClientService implements OnModuleInit {
if (existing) return existing

this.logger.debug(`Creating Keycloak subgroup ${name} under parentId=${parentId}`)
try {
// createChildGroup only returns the new id; re-list the parent to hand
// back the representation Keycloak computed (name, path, subGroups...)
await this.client.groups.createChildGroup({ id: parentId }, { name })
const created = await this.getSubGroupByName(parentId, name)
if (!created) throw new Error(`Created Keycloak subgroup ${name} under parentId=${parentId} but could not fetch it back`)
return created
} catch (err) {
// A concurrent reconciliation may have created the subgroup between the
// scan and the create; treat the 409 as "already exists" and re-fetch it
if (getErrorResponseStatus(err) !== 409) throw err
this.logger.verbose(`Keycloak subgroup ${name} was created concurrently under parentId=${parentId}, fetching it`)
const subgroup = await this.getSubGroupByName(parentId, name)
if (!subgroup) throw err
return subgroup
}
return ensure({
create: async () => {
// createChildGroup only returns the new id; re-list the parent to hand
// back the representation Keycloak computed (name, path, subGroups...)
await this.client.groups.createChildGroup({ id: parentId }, { name })
const created = await this.getSubGroupByName(parentId, name)
if (!created) throw new Error(`Created Keycloak subgroup ${name} under parentId=${parentId} but could not fetch it back`)
return created
},
reload: () => this.getSubGroupByName(parentId, name),
onCollision: () => this.logger.verbose(`Keycloak subgroup ${name} was created concurrently under parentId=${parentId}, fetching it`),
})
}

async getOrCreateConsoleGroup(projectGroup: GroupRepresentationWith<'id'>) {
Expand Down
34 changes: 34 additions & 0 deletions apps/server-nestjs/src/modules/keycloak/keycloak.utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation'
import type UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation'
import type { ProjectWithDetails } from './keycloak-datastore.service'
import { HttpStatus } from '@nestjs/common'
import z from 'zod'
import { getErrorResponseStatus } from '../../utils/http.utils'
import { CONSOLE_GROUP_NAME } from './keycloak.constants'

type With<T, K extends keyof T> = T & Required<Pick<T, K>>
Expand Down Expand Up @@ -47,3 +49,35 @@ export function toRoleRelativeGroupPath(
): string {
return role.oidcGroup.replace(consoleGroup.path, '')
}

// Whether a Keycloak admin-client error signals an entity already existing
// (race collision): a 409 conflict on the create call.
export function isKeycloakConflict(error: unknown): boolean {
return getErrorResponseStatus(error) === HttpStatus.CONFLICT
}

// Runs an idempotent write: tries `create`, and on a Keycloak race collision
// reloads via `reload` and returns the existing entity instead of failing.
// `onCollision` is invoked once when a collision is detected. If the reload
// finds nothing, the original error is rethrown so genuine failures are not
// swallowed.
export async function ensure<T>({
create,
reload,
onCollision,
}: {
create: () => Promise<T>
reload: () => Promise<T | undefined>
onCollision?: (error: unknown) => void
}): Promise<T> {
try {
return await create()
} catch (error) {
if (isKeycloakConflict(error)) {
onCollision?.(error)
const existing = await reload()
if (existing) return existing
}
throw error
}
}
Loading