From 70a2b116509dba9051f391f97ac37bf370b02664 Mon Sep 17 00:00:00 2001 From: sylvain senechal Date: Thu, 21 May 2026 16:09:23 +0200 Subject: [PATCH] move keycloak hook seeder from cli-testing to zenko Issue: ZENKO-5283 --- tests/functional/ctst/common/hooks.ts | 1 - .../ctst/common/hooksKeycloakSetup.ts | 29 ++ tests/functional/ctst/steps/utils/keycloak.ts | 407 ++++++++++++++++++ tests/functional/package.json | 1 + tests/functional/yarn.lock | 14 + 5 files changed, 451 insertions(+), 1 deletion(-) create mode 100644 tests/functional/ctst/common/hooksKeycloakSetup.ts create mode 100644 tests/functional/ctst/steps/utils/keycloak.ts diff --git a/tests/functional/ctst/common/hooks.ts b/tests/functional/ctst/common/hooks.ts index 9f195b56c..2359019b5 100644 --- a/tests/functional/ctst/common/hooks.ts +++ b/tests/functional/ctst/common/hooks.ts @@ -21,7 +21,6 @@ import { import { createKubeCustomObjectClient, waitForZenkoToStabilize } from 'steps/utils/kubernetes'; import { startDLQConsumer, stopDLQConsumer } from 'steps/utils/kafka'; -import 'cli-testing/hooks/KeycloakSetup'; import 'cli-testing/hooks/Logger'; // HTTPS should not cause any error for CTST diff --git a/tests/functional/ctst/common/hooksKeycloakSetup.ts b/tests/functional/ctst/common/hooksKeycloakSetup.ts new file mode 100644 index 000000000..8d3846d28 --- /dev/null +++ b/tests/functional/ctst/common/hooksKeycloakSetup.ts @@ -0,0 +1,29 @@ +import { BeforeAll } from '@cucumber/cucumber'; +import * as Werelogs from 'werelogs'; +import { WorkCoordination } from 'cli-testing'; +import Keycloak from 'steps/utils/keycloak'; + +BeforeAll({ name: 'keycloak setup' }, async function (this) { + const logger = new Werelogs.Logger('KeycloakSetup').newRequestLogger(); + + if (process.env.SEED_KEYCLOAK_DEFAULT_ROLES !== 'true') { + logger.info('Skipping Keycloak setup'); + return; + } + + await WorkCoordination.runOnceAcrossWorkers( + { lockName: 'keycloak-seed', logger }, + async () => { + logger.info('Starting Keycloak setup'); + const keycloakSeeder = new Keycloak({ + host: `https://${process.env.KEYCLOAK_HOST || this.parameters.KeycloakHost}`, + realm: `${process.env.KEYCLOAK_REALM || this.parameters.KeycloakRealm}`, + username: process.env.KEYCLOAK_ADMIN_USERNAME || 'admin', + password: process.env.KEYCLOAK_ADMIN_PASSWORD || 'password', + clientId: process.env.KEYCLOAK_ADMIN_CLIENT_ID || 'admin-cli', + }, logger); + await keycloakSeeder.seedKeycloakWithDefaultRoles(); + logger.info('Keycloak setup completed successfully'); + }, + ); +}); diff --git a/tests/functional/ctst/steps/utils/keycloak.ts b/tests/functional/ctst/steps/utils/keycloak.ts new file mode 100644 index 000000000..e42417cfe --- /dev/null +++ b/tests/functional/ctst/steps/utils/keycloak.ts @@ -0,0 +1,407 @@ +import KcAdminClient from '@keycloak/keycloak-admin-client'; +import RoleRepresentation from '@keycloak/keycloak-admin-client/lib/defs/roleRepresentation'; +import * as Werelogs from 'werelogs'; + +const DEFAULT_ACCOUNT_NAME = 'AccountTest'; + +/** + * Keycloak configuration utility using the official Keycloak Admin Client. + * Handles authentication and setup of groups, roles, and users in Keycloak. + * Supports default roles from typical Scality products. + */ +export default class Keycloak { + private readonly kcAdminClient: KcAdminClient; + private readonly realm: string; + private readonly host: string; + private readonly username: string; + private readonly password: string; + private readonly clientId: string; + + // Configuration from environment variables with defaults + private readonly config = { + account: process.env.ACCOUNT || DEFAULT_ACCOUNT_NAME, + storageManager: process.env.STORAGE_MANAGER || 'storage_manager', + storageAccountOwner: process.env.STORAGE_ACCOUNT_OWNER || 'storage_account_owner', + dataConsumer: process.env.DATA_CONSUMER || 'data_consumer', + dataAccessor: process.env.DATA_ACCESSOR || 'data_accessor', + }; + + private readonly passwordConfig = [ + { + type: 'password', + value: '123', + temporary: false, + }, + ]; + + private readonly logger: Werelogs.RequestLogger; + + constructor( + parameters: { + host: string; + realm: string; + username: string; + password: string; + clientId: string; + }, + logger?: Werelogs.RequestLogger, + ) { + this.logger = logger || new Werelogs.Logger('Keycloak').newRequestLogger(); + this.realm = parameters.realm; + this.host = parameters.host; + this.username = parameters.username; + this.password = parameters.password; + this.clientId = parameters.clientId; + + logger?.debug('Keycloak parameters', { + host: this.host, + realm: this.realm, + username: this.username, + password: this.password, + clientId: this.clientId, + }); + + // Initialize Keycloak Admin Client + this.kcAdminClient = new KcAdminClient({ + baseUrl: `${this.host}/auth`, + realmName: 'master', // Auth realm for authentication + }); + } + + /** + * Authenticate with Keycloak using the admin client + */ + private async authenticate(): Promise { + this.logger.info('Authenticating with Keycloak...'); + + try { + await this.kcAdminClient.auth({ + username: this.username, + password: this.password, + grantType: 'password', + clientId: this.clientId, + }); + + // should set the realm to the one we are using + this.kcAdminClient.setConfig({ realmName: this.realm }); + + this.logger.info('Successfully authenticated with Keycloak'); + } catch (error) { + this.logger.error('Failed to authenticate with Keycloak', { error }); + throw error; + } + } + + /** + * Check if a group exists in Keycloak + */ + private async groupExists(groupName: string): Promise { + try { + const groups = await this.kcAdminClient.groups.find({ + realm: this.realm, + search: groupName, + }); + return groups.some(group => group.name === groupName); + } catch (error) { + this.logger.error(`Failed to check if group exists: ${groupName}`, { error }); + return false; // Assume it doesn't exist if we can't check + } + } + + /** + * Check if a role exists in Keycloak + */ + private async roleExists(roleName: string): Promise { + try { + // Uses roles.find() + filter instead of findOneByName to avoid + // URL encoding issues with special characters (e.g. '::') in role names. + const roles = await this.kcAdminClient.roles.find({ + realm: this.realm, + }); + return roles.some(role => role.name === roleName); + } catch (error) { + this.logger.error(`Failed to check if role exists: ${roleName}`, { error }); + return false; // Assume it doesn't exist if we can't check + } + } + + /** + * Check if a user exists in Keycloak + */ + private async userExists(username: string): Promise { + try { + const users = await this.kcAdminClient.users.find({ + realm: this.realm, + username, + exact: true, + }); + return users && users.length > 0; + } catch (error) { + this.logger.error(`Failed to check if user exists: ${username}`, { error }); + return false; // Assume it doesn't exist if we can't check + } + } + + /** + * Create a group in Keycloak (noop if already exists) + */ + private async createGroup(groupName: string): Promise { + if (await this.groupExists(groupName)) { + this.logger.debug(`Group already exists, skipping creation: ${groupName}`); + return; + } + + try { + await this.kcAdminClient.groups.create({ + realm: this.realm, + name: groupName, + }); + this.logger.info(`Successfully created group: ${groupName}`); + } catch (error) { + this.logger.error(`Failed to create group: ${groupName}`, { error }); + throw error; + } + } + + /** + * Create a role in Keycloak (noop if already exists) + */ + private async createRole(roleName: string): Promise { + if (await this.roleExists(roleName)) { + this.logger.debug(`Role already exists, skipping creation: ${roleName}`); + return; + } + + try { + await this.kcAdminClient.roles.create({ + realm: this.realm, + name: roleName, + }); + this.logger.info(`Successfully created role: ${roleName}`); + } catch (error) { + this.logger.error(`Failed to create role: ${roleName}`, { error }); + throw error; + } + } + + /** + * Create a user in Keycloak + */ + private async createUser(username: string, groups?: string[], realmRoles?: string[]): Promise { + const userData = { + realm: this.realm, + username, + firstName: username, + lastName: username, + email: `${username}@scality.com`, + enabled: true, + credentials: this.passwordConfig, + ...(groups && { groups }), + ...(realmRoles && { realmRoles }), + }; + + if (await this.userExists(username)) { + this.logger.debug(`User already exists, skipping creation: ${username}`); + return; + } + + try { + await this.kcAdminClient.users.create(userData); + this.logger.info(`Successfully created user: ${username}`); + } catch (error) { + this.logger.error(`Failed to create user: ${username}`, { error }); + throw error; + } + } + + /** + * Get user ID by username + */ + private async getUserId(username: string): Promise { + try { + const users = await this.kcAdminClient.users.find({ + realm: this.realm, + username, + exact: true, + }); + + if (!users || users.length === 0) { + throw new Error(`User not found: ${username}`); + } + + return users[0].id!; + } catch (error) { + this.logger.error(`Failed to get user ID for: ${username}`, { error }); + throw error; + } + } + + /** + * Get role by name + */ + private async getRole(roleName: string): Promise { + try { + // Uses roles.find() + filter instead of findOneByName to avoid + // URL encoding issues with special characters (e.g. '::') in role names. + const roles = await this.kcAdminClient.roles.find({ + realm: this.realm, + }); + const role = roles.find(r => r.name === roleName); + + if (!role) { + throw new Error(`Role not found: ${roleName}`); + } + + return role; + } catch (error) { + this.logger.error(`Failed to get role: ${roleName}`, { error }); + throw error; + } + } + + /** + * Assign role to user + */ + private async assignRoleToUser(userId: string, role: RoleRepresentation): Promise { + try { + await this.kcAdminClient.users.addRealmRoleMappings({ + realm: this.realm, + id: userId, + roles: [ + { + id: role.id!, + name: role.name!, + }, + ], + }); + this.logger.info(`Successfully assigned role ${role.name} to user ${userId}`); + } catch (error) { + this.logger.error(`Failed to assign role ${role.name} to user ${userId}`, { error }); + throw error; + } + } + + /** + * Setup groups in Keycloak + */ + private async setupGroups(): Promise { + this.logger.info('Setting up Keycloak groups...'); + + const groups = [ + `${this.config.account}::StorageAccountOwner`, + `${this.config.account}::DataConsumer`, + `${this.config.account}::DataAccessor`, + ]; + + for (const group of groups) { + await this.createGroup(group); + } + + this.logger.info('Successfully set up all Keycloak groups'); + } + + /** + * Setup roles in Keycloak + */ + private async setupRoles(): Promise { + this.logger.info('Setting up Keycloak roles...'); + + const roles = [ + `${this.config.account}::StorageAccountOwner`, + `${this.config.account}::DataConsumer`, + `${this.config.account}::DataAccessor`, + ]; + + for (const role of roles) { + await this.createRole(role); + } + + this.logger.info('Successfully set up all Keycloak roles'); + } + + /** + * Setup users in Keycloak + */ + private async setupUsers(): Promise { + this.logger.info('Setting up Keycloak users...'); + + // Create storage manager with realm role + await this.createUser(this.config.storageManager, undefined, ['StorageManager']); + + // Create users with groups + await this.createUser(this.config.storageAccountOwner, [`${this.config.account}::StorageAccountOwner`]); + await this.createUser(this.config.dataConsumer, [`${this.config.account}::DataConsumer`]); + await this.createUser(this.config.dataAccessor, [`${this.config.account}::DataAccessor`]); + + this.logger.info('Successfully set up all Keycloak users'); + } + + /** + * Attach roles to users + */ + private async attachRoles(): Promise { + this.logger.info('Attaching roles to users...'); + + // Attach StorageManager role to storage manager + try { + const storageManagerId = await this.getUserId(this.config.storageManager); + const storageManagerRole = await this.getRole('StorageManager'); + await this.assignRoleToUser(storageManagerId, storageManagerRole); + } catch (error) { + this.logger.warn('Failed to attach StorageManager role - role may not exist yet', { + error, + }); + } + + // Attach account-specific roles to users + const userRoleMappings = [ + { + username: this.config.storageAccountOwner, + roleName: `${this.config.account}::StorageAccountOwner`, + }, + { + username: this.config.dataConsumer, + roleName: `${this.config.account}::DataConsumer`, + }, + { + username: this.config.dataAccessor, + roleName: `${this.config.account}::DataAccessor`, + }, + ]; + + for (const mapping of userRoleMappings) { + try { + const userId = await this.getUserId(mapping.username); + const role = await this.getRole(mapping.roleName); + await this.assignRoleToUser(userId, role); + } catch (error) { + this.logger.error(`Failed to attach role ${mapping.roleName} to user ${mapping.username}`, { + error, + }); + throw error; + } + } + + this.logger.info('Successfully attached all roles to users'); + } + + /** + * Full Keycloak seeding process + */ + public async seedKeycloakWithDefaultRoles(): Promise { + this.logger.info('Starting Keycloak seeding process...'); + + try { + await this.authenticate(); + await this.setupGroups(); + await this.setupRoles(); + await this.setupUsers(); + await this.attachRoles(); + + this.logger.info('Keycloak seeding completed successfully'); + } catch (error) { + this.logger.error('Keycloak seeding failed', { error }); + throw error; + } + } +} diff --git a/tests/functional/package.json b/tests/functional/package.json index 9b6fcdb33..411889308 100644 --- a/tests/functional/package.json +++ b/tests/functional/package.json @@ -13,6 +13,7 @@ "@smithy/node-http-handler": "^4.0.0", "@azure/storage-blob": "^12.12.0", "@google-cloud/storage": "^7.12.1", + "@keycloak/keycloak-admin-client": "^26.6.2", "arsenal": "scality/Arsenal#8.1.38", "assert": "^2.1.0", "async": "2.1.2", diff --git a/tests/functional/yarn.lock b/tests/functional/yarn.lock index 7d191cc69..47c13b94f 100644 --- a/tests/functional/yarn.lock +++ b/tests/functional/yarn.lock @@ -1265,6 +1265,20 @@ camelize-ts "^3.0.0" url-template "^3.1.1" +"@keycloak/keycloak-admin-client@^26.6.2": + version "26.6.2" + resolved "https://registry.yarnpkg.com/@keycloak/keycloak-admin-client/-/keycloak-admin-client-26.6.2.tgz#3c47278fe332945823efd0d18fe221bebb609c4b" + integrity sha512-CEqQ/fzJPkWOTM0LaUeH5KAKlCQGkqlydhpnxFM9Vx1rLyjr9AIqrENPNzt0owYDPIqrl6OYaFZJBAoH8woDpA== + dependencies: + "@microsoft/kiota-abstractions" "^1.0.0-preview.86" + "@microsoft/kiota-http-fetchlibrary" "^1.0.0-preview.80" + "@microsoft/kiota-serialization-form" "^1.0.0-preview.74" + "@microsoft/kiota-serialization-json" "^1.0.0-preview.86" + "@microsoft/kiota-serialization-multipart" "^1.0.0-preview.67" + "@microsoft/kiota-serialization-text" "^1.0.0-preview.79" + camelize-ts "^3.0.0" + url-template "^3.1.1" + "@kubernetes/client-node@^1.4.0": version "1.4.0" resolved "https://registry.yarnpkg.com/@kubernetes/client-node/-/client-node-1.4.0.tgz#35b72d31f662befd9a2869e172b3153a3443e368"