From d086529ac7021253476f6ec520042f822ce44943 Mon Sep 17 00:00:00 2001 From: Ferruh <63190600+ferruhcihan@users.noreply.github.com> Date: Mon, 14 Oct 2024 13:52:12 +0200 Subject: [PATCH] feat: user management (#118) * chore: release branch [ci skip] (#116) * feat: update readme and launch&tasks.json files for keycloak operator * feat: rm unnecessary lines from launch.json and package.json * feat: add createTeamUser in the keycloak operator * feat: update groups * feat: update create user * feat: update harbor operator * feat: update create user * feat: add delete keycloak users function * test: delete keycloak users * feat: update create keycloak user * feat: update keycloak user without credentials * test: update user * feat: update keycloak createUpdateUser * feat: update keycloak.ts * test: users * feat: keycloak userCreateUpdate * feat: update user creation * feat: update keycloak manageUsers flow * feat: update keycloak.ts and add keycloak.test.ts * feat: update manageUsers * feat: update existing otomi-admin user groups * fix: update platformAdminUser variable name --------- Co-authored-by: Ani Argjiri --- .cspell.json | 1 + .vscode/launch.json | 34 +---- .vscode/tasks.json | 11 ++ README.md | 2 +- package.json | 4 - src/operator/harbor.ts | 8 +- src/operator/keycloak.test.ts | 76 +++++++++++ src/operator/keycloak.ts | 204 +++++++++++++++++++++------- src/tasks/keycloak/config.ts | 29 +++- src/tasks/keycloak/realm-factory.ts | 58 ++++++-- src/validators.ts | 6 +- 11 files changed, 331 insertions(+), 102 deletions(-) create mode 100644 src/operator/keycloak.test.ts diff --git a/.cspell.json b/.cspell.json index 92a14a7..fcdd29d 100644 --- a/.cspell.json +++ b/.cspell.json @@ -10,6 +10,7 @@ "camelcase", "creds", "gitea", + "keycloak", "kubernetes", "oidc", "openid", diff --git a/.vscode/launch.json b/.vscode/launch.json index 2800ec1..4b56314 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -113,41 +113,17 @@ { "type": "node", "request": "launch", - "name": "Debug Harbor task", - "runtimeExecutable": "npm", - "runtimeArgs": ["run-script", "tasks:harbor-dev"], - "cwd": "${workspaceRoot}", - "console": "integratedTerminal", - "envFile": "${workspaceFolder}/.env" - // "env": { - // "NODE_EXTRA_CA_CERTS": "${workspaceFolder}/.env.ca" - // } - }, - { - "type": "node", - "request": "launch", - "name": "Debug Keycloak task", - "runtimeExecutable": "npm", - "runtimeArgs": ["run-script", "tasks:keycloak-dev"], - "cwd": "${workspaceRoot}", - "console": "integratedTerminal", - "envFile": "${workspaceFolder}/.env", - "env": { - "NODE_EXTRA_CA_CERTS": "${workspaceFolder}/.env.ca" - } - }, - { - "type": "node", - "request": "launch", - "name": "Debug Keycloak operator", + "name": "Debug keycloak operator", "runtimeExecutable": "npm", "runtimeArgs": ["run-script", "operator:keycloak-dev"], "cwd": "${workspaceRoot}", "console": "integratedTerminal", "envFile": "${workspaceFolder}/.env", "env": { - "NODE_EXTRA_CA_CERTS": "${workspaceFolder}/.env.ca" - } + "NODE_EXTRA_CA_CERTS": "${workspaceFolder}/.env.ca", + "KUBECONFIG": "/path/to/your/kubeconfig.yaml", + }, + "preLaunchTask": "port-forward-keycloak" }, { "type": "node", diff --git a/.vscode/tasks.json b/.vscode/tasks.json index a53e416..7ea8100 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -22,6 +22,17 @@ "reveal": "always", "panel": "new" } + }, + { + "label": "port-forward-keycloak", + "type": "shell", + "command": "export KUBECONFIG=/path/to/your/kubeconfig.yaml && kubectl -n keycloak port-forward svc/keycloak-operator 8084:80", + "problemMatcher": [], + "isBackground": true, + "presentation": { + "reveal": "always", + "panel": "new" + } } ] } diff --git a/README.md b/README.md index e29060a..ddca3d4 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Then start a proxy to the api you wish to target: - gitea: `k -n gitea port-forward svc/gitea-http 8082:3000 &` - harbor: `k -n harbor port-forward svc/harbor-core 8083:80 &` -- keycloak: `k -n keycloak port-forward svc/keycloak-http 8084:80 &` +- keycloak: `k -n keycloak port-forward svc/keycloak-operator 8084:80 &` Or start them all with `bin/start-proxies.sh` diff --git a/package.json b/package.json index b538324..168af89 100644 --- a/package.json +++ b/package.json @@ -122,10 +122,6 @@ "tasks:copy-certs-dev": "ts-node-dev ./src/tasks/otomi/copy-certs.ts", "tasks:copy-certs": "node dist/tasks/otomi/copy-certs.js", "tasks:copy-certs-argo": "node dist/tasks/otomi/copy-certs-argo.js", - "tasks:harbor-dev": "NODE_TLS_REJECT_UNAUTHORIZED=0 ts-node-dev ./src/tasks/harbor/harbor.ts", - "tasks:harbor": "node dist/tasks/harbor/harbor.js", - "tasks:keycloak-dev": "NODE_TLS_REJECT_UNAUTHORIZED=0 ts-node-dev ./src/tasks/keycloak/keycloak.ts", - "tasks:keycloak": "node dist/tasks/keycloak/keycloak.js", "tasks:keycloak-users-dev": "NODE_TLS_REJECT_UNAUTHORIZED=0 ts-node-dev ./src/tasks/keycloak/users.ts", "tasks:otomi-chart-dev": "NODE_TLS_REJECT_UNAUTHORIZED=0 ts-node-dev ./src/tasks/otomi/otomi-chart.ts", "tasks:otomi-chart": "node dist/tasks/otomi/otomi-chart.js", diff --git a/src/operator/harbor.ts b/src/operator/harbor.ts index c9b07b9..bfbe16b 100644 --- a/src/operator/harbor.ts +++ b/src/operator/harbor.ts @@ -257,7 +257,7 @@ async function setupHarbor() { const config: any = { auth_mode: 'oidc_auth', - oidc_admin_group: 'admin', + oidc_admin_group: 'platform-admin', oidc_client_id: 'otomi', oidc_client_secret: env.oidcClientSecret, oidc_endpoint: env.oidcEndpoint, @@ -309,7 +309,7 @@ async function getBearerToken(): Promise { // unauthenticated, so remove and recreate secret await k8sApi.deleteNamespacedSecret(systemSecretName, systemNamespace) // now, the next call might throw IF: - // - authMode oidc was already turned on and an otomi admin accidentally removed the secret + // - authMode oidc was already turned on and an platform admin accidentally removed the secret // but that is very unlikely, an unresolvable problem and needs a manual db fix robotSecret = await createSystemRobotSecret() } @@ -366,7 +366,7 @@ async function processNamespace(namespace: string) { const projAdminMember: ProjectMember = { roleId: HarborRole.admin, memberGroup: { - groupName: 'team-admin', + groupName: 'all-teams-admin', groupType: HarborGroupType.http, }, } @@ -377,7 +377,7 @@ async function processNamespace(namespace: string) { ) await doApiCall( errors, - `Associating "project-admin" role for "team-admin" with harbor project "${projectName}"`, + `Associating "project-admin" role for "all-teams-admin" with harbor project "${projectName}"`, () => memberApi.createProjectMember(projectId, undefined, undefined, projAdminMember), ) diff --git a/src/operator/keycloak.test.ts b/src/operator/keycloak.test.ts new file mode 100644 index 0000000..2d17260 --- /dev/null +++ b/src/operator/keycloak.test.ts @@ -0,0 +1,76 @@ +import { expect } from 'chai' +import sinon from 'sinon' +import { addUserGroups, removeUserGroups } from './keycloak' + +describe('Keycloak User Group Management', () => { + let api: any + let existingUser: any + let keycloakRealm: string + + beforeEach(() => { + keycloakRealm = 'otomi' + existingUser = { id: 'user-id' } + api = { + users: { + realmUsersIdGroupsGet: sinon.stub(), + realmUsersIdGroupsGroupIdDelete: sinon.stub(), + realmUsersIdGroupsGroupIdPut: sinon.stub(), + }, + groups: { + realmGroupsGet: sinon.stub(), + }, + } + }) + + afterEach(() => { + sinon.restore() + }) + + describe('removeUserGroups', () => { + it('should remove user from groups not in teamGroups', async () => { + const existingUserGroups = [ + { name: 'group1', id: 'group1-id' }, + { name: 'group2', id: 'group2-id' }, + ] + api.users.realmUsersIdGroupsGet.resolves({ body: existingUserGroups }) + + await removeUserGroups(api, existingUser, ['group1']) + + expect(api.users.realmUsersIdGroupsGroupIdDelete.calledWith(keycloakRealm, 'user-id', 'group2-id')).to.be.true + expect(api.users.realmUsersIdGroupsGroupIdDelete.calledWith(keycloakRealm, 'user-id', 'group1-id')).to.be.false + }) + + it('should handle errors gracefully', async () => { + api.users.realmUsersIdGroupsGet.rejects(new Error('API Error')) + + await removeUserGroups(api, existingUser, ['group1']) + + expect(api.users.realmUsersIdGroupsGroupIdDelete.called).to.be.false + }) + }) + + describe('addUserGroups', () => { + it('should add user to groups in teamGroups if not already present', async () => { + const currentKeycloakGroups = [ + { name: 'group1', id: 'group1-id' }, + { name: 'group2', id: 'group2-id' }, + ] + const existingUserGroups = [{ name: 'group1', id: 'group1-id' }] + api.groups.realmGroupsGet.resolves({ body: currentKeycloakGroups }) + api.users.realmUsersIdGroupsGet.resolves({ body: existingUserGroups }) + + await addUserGroups(api, existingUser, ['group1', 'group2']) + + expect(api.users.realmUsersIdGroupsGroupIdPut.calledWith(keycloakRealm, 'user-id', 'group2-id')).to.be.true + expect(api.users.realmUsersIdGroupsGroupIdPut.calledWith(keycloakRealm, 'user-id', 'group1-id')).to.be.false + }) + + it('should handle errors gracefully', async () => { + api.groups.realmGroupsGet.rejects(new Error('API Error')) + + await addUserGroups(api, existingUser, ['group1']) + + expect(api.users.realmUsersIdGroupsGroupIdPut.called).to.be.false + }) + }) +}) diff --git a/src/operator/keycloak.ts b/src/operator/keycloak.ts index 8ec00f5..16f3fbd 100644 --- a/src/operator/keycloak.ts +++ b/src/operator/keycloak.ts @@ -23,7 +23,7 @@ import { UserRepresentation, UsersApi, } from '@linode/keycloak-client-node' -import { forEach } from 'lodash' +import { forEach, omit } from 'lodash' import { custom, Issuer, TokenSet } from 'openid-client' import { keycloakRealm } from '../tasks/keycloak/config' import { @@ -36,9 +36,10 @@ import { createIdProvider, createLoginThemeConfig, createRealm, + createTeamUser, mapTeamsToRoles, } from '../tasks/keycloak/realm-factory' -import { doApiCall, waitTillAvailable } from '../utils' +import { doApiCall } from '../utils' import { cleanEnv, KEYCLOAK_TOKEN_OFFLINE_MAX_TTL_ENABLED, @@ -63,6 +64,7 @@ interface KeycloakApi { protocols: ProtocolMappersApi realms: RealmsAdminApi users: UsersApi + groups: GroupsApi } // Create realm roles @@ -79,8 +81,9 @@ const env = { IDP_OIDC_URL: '', IDP_CLIENT_ID: '', IDP_CLIENT_SECRET: '', - IDP_GROUP_OTOMI_ADMIN: '', IDP_GROUP_TEAM_ADMIN: '', + IDP_GROUP_ALL_TEAMS_ADMIN: '', + IDP_GROUP_PLATFORM_ADMIN: '', IDP_GROUP_MAPPINGS_TEAMS: {} || undefined, IDP_SUB_CLAIM_MAPPER: '', IDP_USERNAME_CLAIM_MAPPER: '', @@ -96,6 +99,7 @@ const env = { REDIRECT_URIS: [] as string[], TEAM_IDS: [] as string[], WAIT_OPTIONS: {}, + USERS: [], } const kc = new KubeConfig() @@ -115,8 +119,8 @@ async function runKeycloakUpdater(key: string) { !env.IDP_OIDC_URL || !env.IDP_CLIENT_ID || !env.IDP_CLIENT_SECRET || - !env.IDP_GROUP_OTOMI_ADMIN || - !env.IDP_GROUP_TEAM_ADMIN || + !env.IDP_GROUP_PLATFORM_ADMIN || + !env.IDP_GROUP_ALL_TEAMS_ADMIN || !env.IDP_GROUP_MAPPINGS_TEAMS || !env.IDP_SUB_CLAIM_MAPPER || !env.IDP_USERNAME_CLAIM_MAPPER @@ -138,44 +142,37 @@ async function runKeycloakUpdater(key: string) { console.info('Missing required keycloak variables for Keycloak setup/reconfiguration') return } + + async function retryOperation(operation: () => Promise, operationName: string) { + try { + await operation() + } catch (error) { + console.debug(`Error could not ${operationName}`, error) + console.debug('Retrying in 30 seconds') + await new Promise((resolve) => setTimeout(resolve, 30000)) + console.log(`Retrying to ${operationName}`) + await runKeycloakUpdater(operationName) + } + } + switch (key) { case 'addTeam': - try { - await keycloakTeamAdded() - break - } catch (error) { - console.debug('Error could not add team', error) - console.debug('Retrying in 30 seconds') - await new Promise((resolve) => setTimeout(resolve, 30000)) - console.log('Retrying to add team') - await runKeycloakUpdater('addTeam') - } + await retryOperation(keycloakTeamAdded, 'add team') break case 'removeTeam': - try { - await keycloakTeamDeleted() - break - } catch (error) { - console.debug('Error could not delete team', error) - console.debug('Retrying in 30 seconds') - await new Promise((resolve) => setTimeout(resolve, 30000)) - console.log('Retrying to delete team') - await runKeycloakUpdater('removeTeam') - } + await retryOperation(keycloakTeamDeleted, 'delete team') + break + case 'manageUsers': + await retryOperation(() => manageUsers(env.USERS), 'update users') break case 'updateConfig': - try { - await keycloakConfigMapChanges().then(async () => { - await runKeycloakUpdater('addTeam') - }) - break - } catch (error) { - console.debug('Error could not update configMap', error) - console.debug('Retrying in 30 seconds') - await new Promise((resolve) => setTimeout(resolve, 30000)) - console.log('Retrying to update configMap') - await runKeycloakUpdater('updateConfig') - } + await retryOperation(async () => { + await keycloakConfigMapChanges() + await runKeycloakUpdater('addTeam') + if (!JSON.parse(env.FEAT_EXTERNAL_IDP)) { + await runKeycloakUpdater('manageUsers') + } + }, 'update configMap') break default: break @@ -206,6 +203,7 @@ 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()) await runKeycloakUpdater('updateConfig').then(() => { console.log('Updated Config') }) @@ -250,7 +248,8 @@ export default class MyOperator extends Operator { if (env.FEAT_EXTERNAL_IDP === 'true') { env.IDP_ALIAS = data!.IDP_ALIAS env.IDP_OIDC_URL = data!.IDP_OIDC_URL - env.IDP_GROUP_OTOMI_ADMIN = data!.IDP_GROUP_OTOMI_ADMIN + env.IDP_GROUP_PLATFORM_ADMIN = data!.IDP_GROUP_PLATFORM_ADMIN + env.IDP_GROUP_ALL_TEAMS_ADMIN = data!.IDP_GROUP_ALL_TEAMS_ADMIN env.IDP_GROUP_TEAM_ADMIN = data!.IDP_GROUP_TEAM_ADMIN env.IDP_GROUP_MAPPINGS_TEAMS = Object.keys(data!.IDP_GROUP_MAPPINGS_TEAMS).length === 0 @@ -358,7 +357,6 @@ async function keycloakTeamDeleted() { } async function createKeycloakConnection(): Promise { - await waitTillAvailable(env.KEYCLOAK_HOSTNAME_URL, undefined, env.WAIT_OPTIONS) const keycloakAddress = env.KEYCLOAK_HOSTNAME_URL const basePath = `${keycloakAddress}/admin/realms` let token: TokenSet @@ -396,6 +394,7 @@ function setupKeycloakApi(connection: KeycloakConnection) { protocols: new ProtocolMappersApi(basePath), realms: new RealmsAdminApi(basePath), users: new UsersApi(basePath), + groups: new GroupsApi(basePath), } // eslint-disable-next-line no-return-assign,no-param-reassign forEach(api, (a) => (a.accessToken = String(token.access_token))) @@ -446,7 +445,8 @@ async function keycloakRealmProviderConfigurer(api: KeycloakApi) { env.TEAM_IDS, env.IDP_GROUP_MAPPINGS_TEAMS, env.IDP_GROUP_TEAM_ADMIN, - env.IDP_GROUP_OTOMI_ADMIN, + env.IDP_GROUP_ALL_TEAMS_ADMIN, + env.IDP_GROUP_PLATFORM_ADMIN, env.KEYCLOAK_REALM, ) const existingRealmRoles = ((await doApiCall(errors, `Getting all roles from realm ${keycloakRealm}`, async () => @@ -516,7 +516,8 @@ async function externalIDP(api: KeycloakApi) { const idpMappers = createIdpMappers( env.IDP_ALIAS, env.IDP_GROUP_MAPPINGS_TEAMS, - env.IDP_GROUP_OTOMI_ADMIN, + env.IDP_GROUP_PLATFORM_ADMIN, + env.IDP_GROUP_ALL_TEAMS_ADMIN, env.IDP_GROUP_TEAM_ADMIN, env.IDP_USERNAME_CLAIM_MAPPER, env.IDP_SUB_CLAIM_MAPPER, @@ -608,7 +609,7 @@ async function internalIdp(api: KeycloakApi, connection: KeycloakConnection) { // set realm roles const roles: Array = [] const existingRole = updatedExistingRealmRoles.find( - (el) => el.name === (groupName === 'otomi-admin' ? 'admin' : groupName), + (el) => el.name === (groupName === 'otomi-admin' ? 'platform-admin' : groupName), ) as RoleRepresentation roles.push(existingRole) await doApiCall(errors, `Creating role mapping for group ${groupName}`, async () => @@ -630,10 +631,10 @@ async function internalIdp(api: KeycloakApi, connection: KeycloakConnection) { if (!existingClientRoleMapping) { // let team members see other users const accessRoles: Array = [userViewerRole] - // both otomi-admin and team-admin role will get access to manage users - // so the otomi-admin can login to the 'otomi' realm just like team-admin and see the same - if (groupName === 'team-admin') accessRoles.push(userManagementRole) - if (groupName === 'otomi-admin') accessRoles.push(realmManagementRole) + // both platform-admin and all-teams-admin role will get access to manage users + // so the platform-admin can login to the 'otomi' realm just like all-teams-admin and see the same + if (groupName === 'all-teams-admin') accessRoles.push(userManagementRole) + if (groupName === 'platform-admin') accessRoles.push(realmManagementRole) await doApiCall( errors, `Creating access roles [${accessRoles.map((r) => r.name).join(',')}] mapping for group ${groupName}`, @@ -654,12 +655,14 @@ async function internalIdp(api: KeycloakApi, connection: KeycloakConnection) { const existingUsersByAdminEmail = (await doApiCall([], `Getting users`, () => api.users.realmUsersGet(keycloakRealm, false, userConf.email), )) as UserRepresentation[] - const existingUser: UserRepresentation = existingUsersByAdminEmail?.[0] + const existingPlatformAdminUser: UserRepresentation = existingUsersByAdminEmail?.[0] + try { - if (existingUser) { + if (existingPlatformAdminUser) { await doApiCall(errors, `Updating user ${env.KEYCLOAK_ADMIN}`, async () => - api.users.realmUsersIdPut(keycloakRealm, existingUser.id as string, userConf), + api.users.realmUsersIdPut(keycloakRealm, existingPlatformAdminUser.id as string, userConf), ) + await addUserGroups(api, existingPlatformAdminUser, ['platform-admin']) } else { await doApiCall(errors, `Creating user ${env.KEYCLOAK_ADMIN}`, () => api.users.realmUsersPost(keycloakRealm, userConf), @@ -700,3 +703,106 @@ async function manageGroups(connection: KeycloakConnection) { console.error('Error in manageGroups: ', error) } } + +export async function removeUserGroups( + api: { users: UsersApi; groups: GroupsApi }, + existingUser: UserRepresentation, + teamGroups: string[], +): Promise { + try { + const { body: existingUserGroups } = await api.users.realmUsersIdGroupsGet(keycloakRealm, existingUser.id as string) + + await Promise.all( + existingUserGroups.map(async (group) => { + if (!teamGroups.includes(group.name)) { + await api.users.realmUsersIdGroupsGroupIdDelete(keycloakRealm, existingUser.id as string, group.id as string) + } + }), + ) + } catch (error) { + console.error('Error removing user groups:', error) + } +} + +export async function addUserGroups( + api: { users: UsersApi; groups: GroupsApi }, + existingUser: UserRepresentation, + teamGroups: string[], +): Promise { + try { + const { body: currentKeycloakGroups } = await api.groups.realmGroupsGet(keycloakRealm) + const { body: existingUserGroups } = await api.users.realmUsersIdGroupsGet(keycloakRealm, existingUser.id as string) + + await Promise.all( + teamGroups.map(async (teamGroup) => { + const existingGroup = existingUserGroups.find((el) => el.name === teamGroup) + + if (!existingGroup) { + const groupId = currentKeycloakGroups.find((el) => el.name === teamGroup)?.id + if (groupId) { + await api.users.realmUsersIdGroupsGroupIdPut(keycloakRealm, existingUser.id as string, groupId as string) + } + } + }), + ) + } catch (error) { + console.error('Error adding user groups:', error) + } +} + +async function createUpdateUser(api: any, user: any) { + const { email, firstName, lastName, groups, initialPassword } = user + const userConf = createTeamUser(email, firstName, lastName, groups, initialPassword) + const existingUsersByUserEmail = (await doApiCall([], `Getting users`, () => + api.users.realmUsersGet(keycloakRealm, false, `${email}`), + )) as UserRepresentation[] + const existingUser: UserRepresentation = existingUsersByUserEmail?.[0] + + try { + if (existingUser) { + console.debug(`User with email ${email} already exists, updating user`) + const updatedUserConf = existingUser.requiredActions?.includes('UPDATE_PASSWORD') + ? userConf + : omit(userConf, ['credentials']) + await doApiCall(errors, `Updating user ${email}`, async () => + api.users.realmUsersIdPut(keycloakRealm, existingUser.id as string, updatedUserConf), + ) + await removeUserGroups(api, existingUser, groups) + await addUserGroups(api, existingUser, groups) + } else { + await doApiCall(errors, `Creating user ${email}`, () => api.users.realmUsersPost(keycloakRealm, userConf)) + } + } catch (error) { + console.error('Error in internalIDP: ', error) + } +} + +async function deleteUsers(api: any, users: any[]) { + try { + const { body: keycloakUsers } = await api.users.realmUsersGet(keycloakRealm) + const filteredUsers = keycloakUsers.filter((user) => user.username !== 'otomi-admin') + const usersToDelete = filteredUsers.filter((user) => !users.some((u) => u.email === user.email)) + + await Promise.all( + usersToDelete.map(async (user) => { + try { + await api.users.realmUsersIdDelete(keycloakRealm, user.id) + console.debug(`Deleted user ${user.email}`) + } catch (error) { + console.error(`Error deleting user ${user.email}:`, error) + } + }), + ) + } catch (error) { + console.error('Error fetching users from Keycloak:', error) + } +} + +async function manageUsers(users: any[]) { + const connection = await createKeycloakConnection() + const api = setupKeycloakApi(connection) + // Create/Update users in realm 'otomi' + await Promise.all(users.map((user) => createUpdateUser(api, user))) + // Delete users not in users list + await deleteUsers(api, users) +} diff --git a/src/tasks/keycloak/config.ts b/src/tasks/keycloak/config.ts index 6b8ae3b..2724a18 100644 --- a/src/tasks/keycloak/config.ts +++ b/src/tasks/keycloak/config.ts @@ -58,8 +58,8 @@ export const adminUserCfgTpl = (username: string, password: string): Record => ({ + username: email, + enabled: true, + email, + emailVerified: true, + firstName, + lastName, + realmRoles: ['teamMember'], + groups, + credentials: [ + { + type: 'password', + value: `${initialPassword}`, + temporary: true, + }, + ], + requiredActions: [], +}) + export const realmCfgTpl = (realm: string): Record => ({ id: realm, realm, diff --git a/src/tasks/keycloak/realm-factory.ts b/src/tasks/keycloak/realm-factory.ts index c86472d..07f0df3 100644 --- a/src/tasks/keycloak/realm-factory.ts +++ b/src/tasks/keycloak/realm-factory.ts @@ -24,6 +24,7 @@ import { protocolMappersList, realmCfgTpl, roleTpl, + teamUserCfgTpl, } from './config' export function createClient(redirectUris: string[], webOrigins: string, secret: string): ClientRepresentation { @@ -35,7 +36,9 @@ export function createClient(redirectUris: string[], webOrigins: string, secret: } export function createGroups(teamIds: string[]): Array { - const groupNames: string[] = teamIds.map((id) => `team-${id}`).concat(['otomi-admin', 'team-admin']) + const groupNames: string[] = teamIds + .map((id) => `team-${id}`) + .concat(['platform-admin', 'all-teams-admin', 'team-admin']) const groups = groupNames.map((name) => defaultsDeep(new GroupRepresentation(), { name })) return groups } @@ -43,18 +46,31 @@ export function createGroups(teamIds: string[]): Array { export function createIdpMappers( idpAlias: string, teams: {} | undefined, - adminGroupMapping: string, + platformAdminGroupMapping: string, + allTeamsAdminGroupMapping: string, teamAdminGroupMapping: string, userClaimMapper: string, idpSubClaimMapper: string, ): Array { - // admin idp mapper case - const admin = idpMapperTpl('otomi-admin group to role', idpAlias, 'admin', adminGroupMapping) - const adminMapper = defaultsDeep(new IdentityProviderMapperRepresentation(), admin) + // platform admin idp mapper case + const platformAdmin = idpMapperTpl( + 'platform-admin group to role', + idpAlias, + 'platform-admin', + platformAdminGroupMapping, + ) + const platformAdminMapper = defaultsDeep(new IdentityProviderMapperRepresentation(), platformAdmin) + // all teams admin idp mapper case + const allTeamsAdmin = idpMapperTpl( + 'all-teams-admin group to role', + idpAlias, + 'all-teams-admin', + allTeamsAdminGroupMapping, + ) + const allTeamsAdminMapper = defaultsDeep(new IdentityProviderMapperRepresentation(), allTeamsAdmin) // team admin idp mapper case const teamAdmin = idpMapperTpl('team-admin group to role', idpAlias, 'team-admin', teamAdminGroupMapping) const teamAdminMapper = defaultsDeep(new IdentityProviderMapperRepresentation(), teamAdmin) - // default idp mappers case const defaultIdps = defaultsIdpMapperTpl(idpAlias, userClaimMapper, idpSubClaimMapper) @@ -67,7 +83,11 @@ export function createIdpMappers( const teamMapper = idpMapperTpl(`${team.name} group to role`, idpAlias, team.name, team.groupMapping) return defaultsDeep(new IdentityProviderMapperRepresentation(), teamMapper) }) - return teamMappers.concat(defaultMapper).concat(adminMapper).concat(teamAdminMapper) + return teamMappers + .concat(defaultMapper) + .concat(platformAdminMapper) + .concat(allTeamsAdminMapper) + .concat(teamAdminMapper) } export async function createIdProvider( @@ -99,6 +119,19 @@ export function createAdminUser(username: string, password: string): UserReprese const userRepresentation = defaultsDeep(new UserRepresentation(), adminUserCfgTpl(username, password)) return userRepresentation } +export function createTeamUser( + email: string, + firstName: string, + lastName: string, + groups: string[], + initialPassword: string, +): UserRepresentation { + const userRepresentation = defaultsDeep( + new UserRepresentation(), + teamUserCfgTpl(email, firstName, lastName, groups, initialPassword), + ) + return userRepresentation +} export function createRealm(realm: string): RealmRepresentation { const realmRepresentation = defaultsDeep(new RealmRepresentation(), realmCfgTpl(realm)) @@ -117,7 +150,8 @@ export function mapTeamsToRoles( teamIds: string[], idpGroupMappings: {} | undefined, idpGroupTeamAdmin: string, - groupOtomiAdmin: string, + idpGroupAllTeamsAdmin: string, + idpGroupPlatformAdmin: string, realm: string, ): Array { // eslint-disable-next-line no-param-reassign @@ -131,10 +165,12 @@ export function mapTeamsToRoles( }, {}) // create static admin teams const teamAdmin = Object.create({ name: 'team-admin', groupMapping: idpGroupTeamAdmin }) as TeamMapping - const adminTeams = [teamAdmin] + const allTeamsAdmin = Object.create({ name: 'all-teams-admin', groupMapping: idpGroupAllTeamsAdmin }) as TeamMapping + const adminTeams = [teamAdmin, allTeamsAdmin] + const otomiAdmin = Object.create({ - name: 'admin', - groupMapping: groupOtomiAdmin, + name: 'platform-admin', + groupMapping: idpGroupPlatformAdmin, }) as TeamMapping adminTeams.push(otomiAdmin) // iterate through all the teams and map groups diff --git a/src/validators.ts b/src/validators.ts index f163fc6..1d49211 100644 --- a/src/validators.ts +++ b/src/validators.ts @@ -33,7 +33,8 @@ export const IDP_GROUP_MAPPINGS_TEAMS = json({ default: undefined, }) export const IDP_GROUP_TEAM_ADMIN = str({ desc: 'APL team-admin group name' }) -export const IDP_GROUP_OTOMI_ADMIN = str({ desc: 'APL admin group name', default: undefined }) +export const IDP_GROUP_ALL_TEAMS_ADMIN = str({ desc: 'APL all-teams-admin group name' }) +export const IDP_GROUP_PLATFORM_ADMIN = str({ desc: 'APL platform admin group name', default: undefined }) export const IDP_OIDC_URL = str({ desc: "The IDP's OIDC enpoints url", default: undefined }) export const IDP_USERNAME_CLAIM_MAPPER = str({ desc: "The IDP's OIDC claim to username mapper string", @@ -97,7 +98,8 @@ if (!feat.FEAT_EXTERNAL_IDP) { ;[ IDP_ALIAS, IDP_GROUP_TEAM_ADMIN, - IDP_GROUP_OTOMI_ADMIN, + IDP_GROUP_ALL_TEAMS_ADMIN, + IDP_GROUP_PLATFORM_ADMIN, IDP_OIDC_URL, IDP_USERNAME_CLAIM_MAPPER, IDP_SUB_CLAIM_MAPPER,