diff --git a/src/operators/keycloak/keycloak.test.ts b/src/operators/keycloak/keycloak.test.ts index 450f2195..1b0248d6 100644 --- a/src/operators/keycloak/keycloak.test.ts +++ b/src/operators/keycloak/keycloak.test.ts @@ -13,7 +13,6 @@ jest.mock('@kubernetes/client-node', () => ({ }, })) -import { UnmanagedAttributePolicy } from '@linode/keycloak-client-node' import * as keycloak from './keycloak' import { manageUserProfile, updateUserGroups } from './keycloak' @@ -85,14 +84,6 @@ describe('Keycloak User Group Management', () => { // The realm user profile should be updated expect(api.users.adminRealmsRealmUsersProfilePut).toHaveBeenCalled() }) - - it('should not update realm user profile', async () => { - api.users.adminRealmsRealmUsersProfileGet.mockResolvedValue({ body: { unmanagedAttributePolicy: UnmanagedAttributePolicy.AdminEdit } }) - await manageUserProfile(api) - - // The realm user profile should not be updated - expect(api.users.adminRealmsRealmUsersProfilePut).not.toHaveBeenCalled() - }) }) describe('addUserGroups', () => { diff --git a/src/operators/keycloak/keycloak.ts b/src/operators/keycloak/keycloak.ts index 829256e4..0f312742 100644 --- a/src/operators/keycloak/keycloak.ts +++ b/src/operators/keycloak/keycloak.ts @@ -26,6 +26,7 @@ import { createClient, createClientAudClaimMapper, createClientEmailClaimMapper, + createClientNameClaimMapper, createClientNicknameClaimMapper, createClientScopes, createClientSubClaimMapper, @@ -111,7 +112,7 @@ const env = { REDIRECT_URIS: [] as string[], TEAM_IDS: [] as string[], WAIT_OPTIONS: {}, - USERS: [], + USERS: [] as Record[], } const kc = new k8s.KubeConfig() @@ -185,6 +186,21 @@ async function runKeycloakUpdater() { } export default class MyOperator extends Operator { + private userUpdateTimer: ReturnType | null = null + private readonly USER_UPDATE_DEBOUNCE_MS = 5000 + + private debouncedUserUpdate(secretInitialized: boolean, configMapInitialized: boolean) { + if (this.userUpdateTimer) clearTimeout(this.userUpdateTimer) + this.userUpdateTimer = setTimeout(() => { + this.userUpdateTimer = null + if (secretInitialized && configMapInitialized) { + runKeycloakUpdater().catch((error) => { + console.error('Failed to run keycloak updater after user secret change:', error) + }) + } + }, this.USER_UPDATE_DEBOUNCE_MS) + } + protected async init() { let secretInitialized = false let configMapInitialized = false @@ -210,7 +226,6 @@ export default class MyOperator extends Operator { if (data!.IDP_CLIENT_ID) env.IDP_CLIENT_ID = Buffer.from(data!.IDP_CLIENT_ID, 'base64').toString() if (data!.IDP_CLIENT_SECRET) env.IDP_CLIENT_SECRET = Buffer.from(data!.IDP_CLIENT_SECRET, 'base64').toString() - env.USERS = JSON.parse(Buffer.from(data!.USERS, 'base64').toString()) configMapInitialized = true if (secretInitialized) await runKeycloakUpdater() break @@ -280,6 +295,67 @@ export default class MyOperator extends Operator { } catch (error) { throw extractError('setting up configmap watcher', error) } + + // Watch user secrets in apl-users namespace + try { + console.info('Setting up apl-users secrets watcher') + const k8sCoreApi = kc.makeApiClient(k8s.CoreV1Api) + await this.watchResource( + '', + 'v1', + 'secrets', + async (e) => { + switch (e.type) { + case ResourceEventType.Added: + case ResourceEventType.Modified: + case ResourceEventType.Deleted: { + try { + // List all secrets in apl-users namespace and rebuild users array + const res: any = await k8sCoreApi.listNamespacedSecret({ namespace: 'apl-users' }) + const users: any[] = [] + for (const item of res.items || []) { + if (item.type !== 'Opaque' && item.type !== 'kubernetes.io/opaque') continue + if (!item.data?.email) continue + + const decoded: Record = {} + for (const [key, value] of Object.entries(item.data as Record)) { + decoded[key] = Buffer.from(value, 'base64').toString('utf-8') + } + + const groups: string[] = [] + if (decoded.isPlatformAdmin === 'true') groups.push('platform-admin') + if (decoded.isTeamAdmin === 'true') groups.push('team-admin') + const teams = decoded.teams ? JSON.parse(decoded.teams) : [] + for (const team of teams) groups.push(`team-${team}`) + + users.push({ + email: decoded.email, + firstName: decoded.firstName || '', + lastName: decoded.lastName || '', + initialPassword: decoded.initialPassword || '', + groups, + }) + } + + env.USERS = users + console.info(`Updated USERS from apl-users namespace: ${users.length} user(s)`) + this.debouncedUserUpdate(secretInitialized, configMapInitialized) + break + } catch (error) { + console.error('Failed to process apl-users secret event:', error) + break + } + } + default: + break + } + }, + 'apl-users', + ) + console.info('Setting up apl-users secrets watcher done') + } catch (error) { + throw extractError('setting up apl-users secrets watcher', error) + } } } @@ -339,7 +415,7 @@ async function createKeycloakConnection(): Promise { try { // Use master realm for admin authentication const tokenUrl = `${basePath}/realms/master/protocol/openid-connect/token` - + const response = await fetch(tokenUrl, { method: 'POST', headers: { @@ -357,8 +433,8 @@ async function createKeycloakConnection(): Promise { throw new Error(`Token request failed: ${response.status} ${response.statusText}`) } - token = await response.json() as TokenEndpointResponse - + token = (await response.json()) as TokenEndpointResponse + return { token, basePath } as KeycloakConnection } catch (error) { throw extractError('creating Keycloak connection', error) @@ -490,10 +566,23 @@ async function keycloakRealmProviderConfigurer(api: KeycloakApi) { console.info('Creating client sub claim mapper') await api.protocols.adminRealmsRealmClientsClientUuidProtocolMappersModelsPost(keycloakRealm, client.id!, subMapper) } + if (!allClientClaimMappers.some((el) => el.name === 'name')) { + const nameMapper = createClientNameClaimMapper() + console.info('Creating client name claim mapper') + await api.protocols.adminRealmsRealmClientsClientUuidProtocolMappersModelsPost( + keycloakRealm, + client.id!, + nameMapper, + ) + } if (!allClientClaimMappers.some((claim) => claim.name === 'nickname')) { const nicknameMapper = createClientNicknameClaimMapper() console.info('Creating client nickname claim mapper') - await api.protocols.adminRealmsRealmClientsClientUuidProtocolMappersModelsPost(keycloakRealm, client.id!, nicknameMapper) + await api.protocols.adminRealmsRealmClientsClientUuidProtocolMappersModelsPost( + keycloakRealm, + client.id!, + nicknameMapper, + ) } // Needed for oauth2-proxy OIDC configuration @@ -512,12 +601,28 @@ async function keycloakRealmProviderConfigurer(api: KeycloakApi) { export async function manageUserProfile(api: KeycloakApi) { const currentUserProfile = (await api.users.adminRealmsRealmUsersProfileGet(keycloakRealm)).body - // set unmanaged attribute policy for use of nickname attribute and mapper - if (currentUserProfile.unmanagedAttributePolicy !== UnmanagedAttributePolicy.AdminEdit) { + const requiredAttributes = ['username', 'email', 'firstName', 'lastName'] + const existingNames = (currentUserProfile.attributes || []).map((a: any) => a.name) + const missingAttributes = requiredAttributes.filter((name) => !existingNames.includes(name)) + + if ( + currentUserProfile.unmanagedAttributePolicy !== UnmanagedAttributePolicy.AdminEdit || + missingAttributes.length > 0 + ) { + const attributes = currentUserProfile.attributes || [] + for (const name of missingAttributes) { + attributes.push({ + name, + displayName: `\${${name}}`, + permissions: { view: ['admin', 'user'] as any, edit: ['admin', 'user'] as any }, + }) + } await api.users.adminRealmsRealmUsersProfilePut(keycloakRealm, { + ...currentUserProfile, + attributes, unmanagedAttributePolicy: UnmanagedAttributePolicy.AdminEdit, }) - console.info('Setting unmanaged attribute policy to AdminEdit') + console.info(`User profile updated: ensured attributes [${requiredAttributes.join(', ')}] and AdminEdit policy`) } } @@ -665,7 +770,7 @@ export async function IDPManager(api: KeycloakApi, isExternalIdp: boolean) { if (isExternalIdp) await externalIDP(api) else { await internalIDP(api) - await manageUsers(api, env.USERS as Record[]) + await manageUsers(api, env.USERS) } } diff --git a/src/tasks/keycloak/config.ts b/src/tasks/keycloak/config.ts index c9a512f8..e79b49ce 100644 --- a/src/tasks/keycloak/config.ts +++ b/src/tasks/keycloak/config.ts @@ -60,6 +60,8 @@ export const idpMapperTpl = (name: string, alias: string, role: string, claim: s export const adminUserCfgTpl = (username: string, password: string): Record => ({ username, email: 'admin@oto.mi', + firstName: 'Admin', + lastName: 'User', emailVerified: true, enabled: true, realmRoles: ['platformAdmin'], @@ -241,6 +243,17 @@ export const clientSubClaimMapper = (): Record => ({ }, }) +export const clientNameClaimMapper = (): Record => ({ + name: 'name', + protocol: 'openid-connect', + protocolMapper: 'oidc-full-name-mapper', + config: { + 'access.token.claim': 'true', + 'id.token.claim': 'true', + 'introspection.token.claim': 'true', + 'userinfo.token.claim': 'true', + }, +}) export const clientNicknameClaimMapper = (): Record => ({ name: 'nickname', @@ -255,7 +268,7 @@ export const clientNicknameClaimMapper = (): Record => ({ 'lightweight.claim': 'true', 'user.attribute': 'nickname', 'userinfo.token.claim': 'true', - }, + }, }) export const clientAudClaimMapper = (): Record => ({ diff --git a/src/tasks/keycloak/realm-factory.ts b/src/tasks/keycloak/realm-factory.ts index b5c83049..e7da05df 100644 --- a/src/tasks/keycloak/realm-factory.ts +++ b/src/tasks/keycloak/realm-factory.ts @@ -16,6 +16,7 @@ import { adminUserCfgTpl, clientAudClaimMapper, clientEmailClaimMapper, + clientNameClaimMapper, clientNicknameClaimMapper, clientScopeCfgTpl, clientSubClaimMapper, @@ -122,6 +123,10 @@ export function createClientSubClaimMapper(): ProtocolMapperRepresentation { return subClaimMapper } +export function createClientNameClaimMapper(): ProtocolMapperRepresentation { + const nameClaimMapper = defaultsDeep(new ProtocolMapperRepresentation(), clientNameClaimMapper()) + return nameClaimMapper +} export function createClientNicknameClaimMapper(): ProtocolMapperRepresentation { const nicknameClaimMapper = defaultsDeep(new ProtocolMapperRepresentation(), clientNicknameClaimMapper())