Skip to content
Draft
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
9 changes: 0 additions & 9 deletions src/operators/keycloak/keycloak.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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', () => {
Expand Down
125 changes: 115 additions & 10 deletions src/operators/keycloak/keycloak.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
createClient,
createClientAudClaimMapper,
createClientEmailClaimMapper,
createClientNameClaimMapper,
createClientNicknameClaimMapper,
createClientScopes,
createClientSubClaimMapper,
Expand Down Expand Up @@ -111,7 +112,7 @@ const env = {
REDIRECT_URIS: [] as string[],
TEAM_IDS: [] as string[],
WAIT_OPTIONS: {},
USERS: [],
USERS: [] as Record<string, any>[],
}

const kc = new k8s.KubeConfig()
Expand Down Expand Up @@ -185,6 +186,21 @@ async function runKeycloakUpdater() {
}

export default class MyOperator extends Operator {
private userUpdateTimer: ReturnType<typeof setTimeout> | 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
Expand All @@ -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
Expand Down Expand Up @@ -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') continue
if (!item.data?.email) continue

const decoded: Record<string, string> = {}
for (const [key, value] of Object.entries(item.data as Record<string, string>)) {
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)
}
}
}

Expand Down Expand Up @@ -339,7 +415,7 @@ async function createKeycloakConnection(): Promise<KeycloakConnection> {
try {
// Use master realm for admin authentication
const tokenUrl = `${basePath}/realms/master/protocol/openid-connect/token`

const response = await fetch(tokenUrl, {
method: 'POST',
headers: {
Expand All @@ -357,8 +433,8 @@ async function createKeycloakConnection(): Promise<KeycloakConnection> {
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)
Expand Down Expand Up @@ -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
Expand All @@ -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`)
}
}

Expand Down Expand Up @@ -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<string, any>[])
await manageUsers(api, env.USERS)
}
}

Expand Down
15 changes: 14 additions & 1 deletion src/tasks/keycloak/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ export const idpMapperTpl = (name: string, alias: string, role: string, claim: s
export const adminUserCfgTpl = (username: string, password: string): Record<string, unknown> => ({
username,
email: 'admin@oto.mi',
firstName: 'Admin',
lastName: 'User',
emailVerified: true,
enabled: true,
realmRoles: ['platformAdmin'],
Expand Down Expand Up @@ -241,6 +243,17 @@ export const clientSubClaimMapper = (): Record<string, unknown> => ({
},
})

export const clientNameClaimMapper = (): Record<string, unknown> => ({
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<string, unknown> => ({
name: 'nickname',
Expand All @@ -255,7 +268,7 @@ export const clientNicknameClaimMapper = (): Record<string, unknown> => ({
'lightweight.claim': 'true',
'user.attribute': 'nickname',
'userinfo.token.claim': 'true',
},
},
})

export const clientAudClaimMapper = (): Record<string, unknown> => ({
Expand Down
5 changes: 5 additions & 0 deletions src/tasks/keycloak/realm-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
adminUserCfgTpl,
clientAudClaimMapper,
clientEmailClaimMapper,
clientNameClaimMapper,
clientNicknameClaimMapper,
clientScopeCfgTpl,
clientSubClaimMapper,
Expand Down Expand Up @@ -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())
Expand Down
Loading