From a09dce58ff38fd02be2b26b269dc1f92ba1905fb Mon Sep 17 00:00:00 2001 From: Jessica He Date: Fri, 21 Aug 2026 08:52:18 -0400 Subject: [PATCH] feat(auth): add LDAP support helper Signed-off-by: Jessica He Co-authored-by: Cursor --- docs/changelog.md | 13 +- package.json | 8 +- src/deployment/keycloak/deployment.ts | 280 +++++++++++++++++++++++ src/deployment/keycloak/index.ts | 13 +- src/deployment/keycloak/types.ts | 39 ++++ src/deployment/openldap/config/seed.ldif | 102 +++++++++ src/deployment/openldap/constants.ts | 83 +++++++ src/deployment/openldap/deployment.ts | 256 +++++++++++++++++++++ src/deployment/openldap/index.ts | 14 ++ src/deployment/openldap/types.ts | 35 +++ src/playwright/helpers/common.ts | 17 +- 11 files changed, 854 insertions(+), 6 deletions(-) create mode 100644 src/deployment/openldap/config/seed.ldif create mode 100644 src/deployment/openldap/constants.ts create mode 100644 src/deployment/openldap/deployment.ts create mode 100644 src/deployment/openldap/index.ts create mode 100644 src/deployment/openldap/types.ts diff --git a/docs/changelog.md b/docs/changelog.md index 5710e8d..d9f4f1b 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,7 +2,18 @@ All notable changes to this project will be documented in this file. -## [2.1.12] - Current +## [2.1.13] - Current + +### Added + +- **OpenLDAP helper (`./openldap`)**: Orchestrator-style `OpenLDAPHelper` deploys Bitnami legacy OpenLDAP into a test namespace with seeded users/groups (`dc=rhdh,dc=test`). Call from `test.runOnce` — not globalSetup. +- **Keycloak LDAP federation APIs**: `createLdapUserFederation`, mappers, sync, `addClientProtocolMapper`, and `configureLdapRealm` for a separate LDAP-fed realm (OIDC claim `ldap_uuid`). + +### Changed + +- **`loginAsKeycloakUser`**: Clicks "Sign in using Keycloak" when the community auth provider is present, otherwise falls back to the built-in OIDC "Sign In" button. + +## [2.1.12] ### Fixed diff --git a/package.json b/package.json index d88922a..70ec53f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@red-hat-developer-hub/e2e-test-utils", - "version": "2.1.12", + "version": "2.1.13", "description": "Test utilities for RHDH E2E tests", "license": "Apache-2.0", "repository": { @@ -52,6 +52,10 @@ "./orchestrator": { "types": "./dist/deployment/orchestrator/index.d.ts", "default": "./dist/deployment/orchestrator/index.js" + }, + "./openldap": { + "types": "./dist/deployment/openldap/index.d.ts", + "default": "./dist/deployment/openldap/index.js" } }, "publishConfig": { @@ -62,7 +66,7 @@ "tsconfig.base.json" ], "scripts": { - "build": "yarn clean && tsc -p tsconfig.build.json && cp -r src/deployment/rhdh/config dist/deployment/rhdh/ && cp -r src/deployment/keycloak/config dist/deployment/keycloak/ && cp src/deployment/orchestrator/install-orchestrator.sh dist/deployment/orchestrator/", + "build": "yarn clean && tsc -p tsconfig.build.json && cp -r src/deployment/rhdh/config dist/deployment/rhdh/ && cp -r src/deployment/keycloak/config dist/deployment/keycloak/ && cp -r src/deployment/openldap/config dist/deployment/openldap/ && cp src/deployment/orchestrator/install-orchestrator.sh dist/deployment/orchestrator/", "prepare": "husky", "check": "yarn typecheck && yarn lint:check && yarn prettier:check", "clean": "rm -rf dist", diff --git a/src/deployment/keycloak/deployment.ts b/src/deployment/keycloak/deployment.ts index cb9b8f7..a106e2c 100644 --- a/src/deployment/keycloak/deployment.ts +++ b/src/deployment/keycloak/deployment.ts @@ -19,6 +19,10 @@ import type { KeycloakGroupConfig, KeycloakRealmConfig, KeycloakConnectionConfig, + KeycloakLdapFederationConfig, + KeycloakLdapMapperConfig, + KeycloakProtocolMapperConfig, + KeycloakLdapRealmOptions, } from "./types.js"; export class KeycloakHelper { @@ -424,6 +428,250 @@ export class KeycloakHelper { } } + /** + * Create (or return existing) LDAP user federation component on a realm. + * Requires admin credentials via connect()/deploy(). + */ + async createLdapUserFederation( + realm: string, + config: KeycloakLdapFederationConfig, + ): Promise { + await this._ensureAdminClient(); + this._adminClient!.setConfig({ realmName: realm }); + + const name = config.name ?? "openldap"; + const existing = await this.getLdapUserFederation(realm, name); + if (existing?.id) { + this._log(`LDAP federation ${name} already exists (${existing.id})`); + await this.updateLdapUserFederation(existing.id, config, realm); + return existing.id; + } + + const realmRep = await this._adminClient!.realms.findOne({ realm }); + if (!realmRep?.id) { + throw new Error(`Realm ${realm} not found`); + } + + const { id } = await this._adminClient!.components.create({ + realm, + name, + providerId: "ldap", + providerType: "org.keycloak.storage.UserStorageProvider", + parentId: realmRep.id, + config: this._ldapFederationConfigToComponent(config), + }); + this._log(`Created LDAP federation: ${name} (${id})`); + return id; + } + + async getLdapUserFederation( + realm: string, + name?: string, + ): Promise<{ id: string; name?: string } | undefined> { + await this._ensureAdminClient(); + this._adminClient!.setConfig({ realmName: realm }); + + const components = await this._adminClient!.components.find({ + realm, + type: "org.keycloak.storage.UserStorageProvider", + }); + const match = name + ? components.find((c) => c.name === name && c.providerId === "ldap") + : components.find((c) => c.providerId === "ldap"); + return match?.id ? { id: match.id, name: match.name } : undefined; + } + + async updateLdapUserFederation( + id: string, + config: KeycloakLdapFederationConfig, + realm: string, + ): Promise { + await this._ensureAdminClient(); + this._adminClient!.setConfig({ realmName: realm }); + + const current = await this._adminClient!.components.findOne({ id, realm }); + if (!current) { + throw new Error(`LDAP federation component ${id} not found`); + } + + await this._adminClient!.components.update( + { id, realm }, + { + ...current, + config: { + ...current.config, + ...this._ldapFederationConfigToComponent(config), + }, + }, + ); + this._log(`Updated LDAP federation: ${id}`); + } + + async deleteLdapUserFederation(id: string, realm: string): Promise { + await this._ensureAdminClient(); + await this._adminClient!.components.del({ id, realm }); + this._log(`Deleted LDAP federation: ${id}`); + } + + async createLdapMapper( + realm: string, + parentId: string, + mapper: KeycloakLdapMapperConfig, + ): Promise { + await this._ensureAdminClient(); + this._adminClient!.setConfig({ realmName: realm }); + + const existing = await this.listLdapMappers(realm, parentId); + const found = existing.find((m) => m.name === mapper.name); + if (found?.id) { + this._log(`LDAP mapper ${mapper.name} already exists`); + return found.id; + } + + const configEntries: Record = {}; + for (const [key, value] of Object.entries(mapper.config)) { + configEntries[key] = [value]; + } + + const { id } = await this._adminClient!.components.create({ + realm, + name: mapper.name, + parentId, + providerId: mapper.providerId, + providerType: "org.keycloak.storage.ldap.mappers.LDAPStorageMapper", + config: configEntries, + }); + this._log(`Created LDAP mapper: ${mapper.name}`); + return id; + } + + async listLdapMappers( + realm: string, + parentId: string, + ): Promise> { + await this._ensureAdminClient(); + return this._adminClient!.components.find({ + realm, + parent: parentId, + type: "org.keycloak.storage.ldap.mappers.LDAPStorageMapper", + }); + } + + async syncLdapUsers( + federationId: string, + action: "triggerFullSync" | "triggerChangedUsersSync" = "triggerFullSync", + ): Promise { + await this._ensureAdminClient(); + await this._adminClient!.userStorageProvider.sync({ + id: federationId, + action, + }); + this._log(`Triggered LDAP sync (${action}) for ${federationId}`); + } + + /** + * Add an OIDC protocol mapper on a client (e.g. ldap_uuid user attribute → claim). + */ + async addClientProtocolMapper( + realm: string, + clientId: string, + mapper: KeycloakProtocolMapperConfig, + ): Promise { + await this._ensureAdminClient(); + this._adminClient!.setConfig({ realmName: realm }); + + const clients = await this._adminClient!.clients.find({ clientId }); + if (clients.length === 0 || !clients[0].id) { + throw new Error(`Client ${clientId} not found in realm ${realm}`); + } + const id = clients[0].id; + + const existing = await this._adminClient!.clients.listProtocolMappers({ + id, + realm, + }); + if (existing.some((m) => m.name === mapper.name)) { + this._log(`Protocol mapper ${mapper.name} already exists on ${clientId}`); + return; + } + + await this._adminClient!.clients.addProtocolMapper( + { id, realm }, + { + name: mapper.name, + protocol: mapper.protocol ?? "openid-connect", + protocolMapper: mapper.protocolMapper, + config: mapper.config, + }, + ); + this._log(`Added protocol mapper ${mapper.name} on client ${clientId}`); + } + + /** + * One-shot: separate LDAP-fed realm + client + OpenLDAP federation + ldap_uuid claim. + * Connect as Keycloak admin first (username/password), not the RHDH service account. + */ + async configureLdapRealm(options: KeycloakLdapRealmOptions): Promise { + await this._ensureAdminClient(); + + const realmName = options.realm; + await this.createRealm({ realm: realmName, enabled: true }); + + const clientConfig: KeycloakClientConfig = { + ...DEFAULT_RHDH_CLIENT, + clientId: "rhdh-ldap-client", + clientSecret: "rhdh-ldap-client-secret", + name: "RHDH LDAP Client", + ...options.client, + }; + await this.createClient(realmName, clientConfig); + await this._assignServiceAccountRoles(realmName, clientConfig.clientId); + + const federationId = await this.createLdapUserFederation( + realmName, + options.ldap, + ); + + // Map LDAP entryUUID into a Keycloak user attribute for the token claim. + /* eslint-disable @typescript-eslint/naming-convention -- Keycloak Admin API mapper config keys */ + const ldapUuidMapperConfig = { + "user.model.attribute": "ldap_uuid", + "ldap.attribute": options.ldap.uuidLdapAttribute ?? "entryUUID", + "read.only": "true", + "always.read.value.from.ldap": "true", + "is.binary.attribute": "false", + }; + const claim = options.ldapUuidClaim ?? "ldap_uuid"; + const ldapUuidClaimConfig = { + "user.attribute": "ldap_uuid", + "claim.name": claim, + "jsonType.label": "String", + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true", + }; + /* eslint-enable @typescript-eslint/naming-convention */ + + await this.createLdapMapper(realmName, federationId, { + name: "ldap-uuid", + providerId: "user-attribute-ldap-mapper", + config: ldapUuidMapperConfig, + }); + + await this.addClientProtocolMapper(realmName, clientConfig.clientId, { + name: "ldap-uuid-claim", + protocolMapper: "oidc-usermodel-attribute-mapper", + config: ldapUuidClaimConfig, + }); + + await this.syncLdapUsers(federationId); + + this.realm = realmName; + this.clientId = clientConfig.clientId; + this.clientSecret = clientConfig.clientSecret; + this._log(`Configured LDAP realm ${realmName}`); + } + /** * Teardown Keycloak deployment */ @@ -616,6 +864,38 @@ spec: } } + private _ldapFederationConfigToComponent( + config: KeycloakLdapFederationConfig, + ): Record { + const bool = (v: boolean | undefined, fallback: boolean) => + String(v ?? fallback); + return { + enabled: ["true"], + priority: ["0"], + vendor: [config.vendor ?? "other"], + connectionUrl: [config.connectionUrl], + bindDn: [config.bindDn], + bindCredential: [config.bindCredential], + usersDn: [config.usersDn], + usernameLDAPAttribute: [config.usernameLdapAttribute ?? "uid"], + rdnLDAPAttribute: [config.rdnLdapAttribute ?? "uid"], + uuidLDAPAttribute: [config.uuidLdapAttribute ?? "entryUUID"], + userObjectClasses: [ + config.userObjectClasses ?? "inetOrgPerson, organizationalPerson", + ], + editMode: [config.editMode ?? "READ_ONLY"], + importEnabled: [bool(config.importEnabled, true)], + pagination: [bool(config.pagination, true)], + searchScope: [config.searchScope ?? "1"], + trustEmail: [bool(config.trustEmail, true)], + authType: ["simple"], + syncRegistrations: ["false"], + useTruststoreSpi: ["ldapsOnly"], + connectionPooling: ["true"], + cachePolicy: ["DEFAULT"], + }; + } + private _isConflictError(error: unknown): boolean { const err = error as { response?: { status?: number }; status?: number }; return err.response?.status === 409 || err.status === 409; diff --git a/src/deployment/keycloak/index.ts b/src/deployment/keycloak/index.ts index 1419c42..e51f3ae 100644 --- a/src/deployment/keycloak/index.ts +++ b/src/deployment/keycloak/index.ts @@ -1,2 +1,13 @@ export { KeycloakHelper } from "./deployment.js"; -export type { KeycloakUserConfig, KeycloakGroupConfig } from "./types.js"; +export { + DEFAULT_GROUPS, + DEFAULT_KEYCLOAK_CONFIG, + DEFAULT_USERS, +} from "./constants.js"; +export type { + KeycloakUserConfig, + KeycloakGroupConfig, + KeycloakLdapFederationConfig, + KeycloakLdapRealmOptions, + KeycloakClientConfig, +} from "./types.js"; diff --git a/src/deployment/keycloak/types.ts b/src/deployment/keycloak/types.ts index 2ea5f3d..d95c9b9 100644 --- a/src/deployment/keycloak/types.ts +++ b/src/deployment/keycloak/types.ts @@ -62,3 +62,42 @@ export type KeycloakConnectionConfig = { username?: string; password?: string; }; + +export type KeycloakLdapFederationConfig = { + name?: string; + connectionUrl: string; + bindDn: string; + bindCredential: string; + usersDn: string; + usernameLdapAttribute?: string; + rdnLdapAttribute?: string; + uuidLdapAttribute?: string; + userObjectClasses?: string; + vendor?: string; + editMode?: string; + importEnabled?: boolean; + pagination?: boolean; + searchScope?: string; + trustEmail?: boolean; +}; + +export type KeycloakLdapMapperConfig = { + name: string; + providerId: string; + config: Record; +}; + +export type KeycloakProtocolMapperConfig = { + name: string; + protocol?: string; + protocolMapper: string; + config: Record; +}; + +export type KeycloakLdapRealmOptions = { + realm: string; + client?: Partial; + ldap: KeycloakLdapFederationConfig; + /** Protocol mapper claim name for LDAP UUID (default ldap_uuid). */ + ldapUuidClaim?: string; +}; diff --git a/src/deployment/openldap/config/seed.ldif b/src/deployment/openldap/config/seed.ldif new file mode 100644 index 0000000..b64e598 --- /dev/null +++ b/src/deployment/openldap/config/seed.ldif @@ -0,0 +1,102 @@ +# Bootstrap tree for RHDH LDAP e2e (OpenLDAP / Bitnami). +# Loaded via LDAP_CUSTOM_LDIF_DIR=/ldifs — must include root + OUs. + +dn: dc=rhdh,dc=test +objectClass: dcObject +objectClass: organization +o: RHDH Test +dc: rhdh + +dn: ou=users,dc=rhdh,dc=test +objectClass: organizationalUnit +ou: users + +dn: ou=groups,dc=rhdh,dc=test +objectClass: organizationalUnit +ou: groups + +dn: uid=user1,ou=users,dc=rhdh,dc=test +objectClass: inetOrgPerson +objectClass: organizationalPerson +objectClass: person +objectClass: top +uid: user1 +cn: User 1 +sn: One +givenName: User +mail: user1@rhdh.test +userPassword: user1pass + +dn: uid=user2,ou=users,dc=rhdh,dc=test +objectClass: inetOrgPerson +objectClass: organizationalPerson +objectClass: person +objectClass: top +uid: user2 +cn: User 2 +sn: Two +givenName: User +mail: user2@rhdh.test +userPassword: user1pass + +dn: uid=user3,ou=users,dc=rhdh,dc=test +objectClass: inetOrgPerson +objectClass: organizationalPerson +objectClass: person +objectClass: top +uid: user3 +cn: User 3 +sn: Three +givenName: User +mail: user3@rhdh.test +userPassword: user1pass + +dn: uid=rhdh-admin,ou=users,dc=rhdh,dc=test +objectClass: inetOrgPerson +objectClass: organizationalPerson +objectClass: person +objectClass: top +uid: rhdh-admin +cn: RHDH Admin +sn: Admin +givenName: RHDH +mail: rhdh-admin@rhdh.test +userPassword: user1pass + +dn: cn=Admins,ou=groups,dc=rhdh,dc=test +objectClass: groupOfNames +objectClass: top +cn: Admins +member: uid=rhdh-admin,ou=users,dc=rhdh,dc=test + +dn: cn=All_Users,ou=groups,dc=rhdh,dc=test +objectClass: groupOfNames +objectClass: top +cn: All_Users +member: uid=user1,ou=users,dc=rhdh,dc=test +member: uid=user2,ou=users,dc=rhdh,dc=test + +dn: cn=SubAdmins,ou=groups,dc=rhdh,dc=test +objectClass: groupOfNames +objectClass: top +cn: SubAdmins +member: uid=rhdh-admin,ou=users,dc=rhdh,dc=test + +# Nested groups: create leaves before parents so member DNs resolve. +dn: cn=testSubSubGroup,ou=groups,dc=rhdh,dc=test +objectClass: groupOfNames +objectClass: top +cn: testSubSubGroup +member: uid=user3,ou=users,dc=rhdh,dc=test + +dn: cn=testSubGroup,ou=groups,dc=rhdh,dc=test +objectClass: groupOfNames +objectClass: top +cn: testSubGroup +member: cn=testSubSubGroup,ou=groups,dc=rhdh,dc=test + +dn: cn=testGroup,ou=groups,dc=rhdh,dc=test +objectClass: groupOfNames +objectClass: top +cn: testGroup +member: cn=testSubGroup,ou=groups,dc=rhdh,dc=test diff --git a/src/deployment/openldap/constants.ts b/src/deployment/openldap/constants.ts new file mode 100644 index 0000000..88387c4 --- /dev/null +++ b/src/deployment/openldap/constants.ts @@ -0,0 +1,83 @@ +import path from "path"; +import type { OpenLDAPDeploymentOptions } from "./types.js"; + +// Navigate from dist/deployment/openldap/ to package root +const PACKAGE_ROOT = path.resolve(import.meta.dirname, "../../.."); + +/** Default password shared by admin bind and seed user1 (matches ldap.spec pattern). */ +export const DEFAULT_OPENLDAP_PASSWORD = "user1pass"; + +export const DEFAULT_OPENLDAP_CONFIG = { + releaseName: "openldap", + adminUser: "admin", + adminPassword: DEFAULT_OPENLDAP_PASSWORD, + baseDn: "dc=rhdh,dc=test", + usersOu: "users", + groupsOu: "groups", + port: 1389, + // Bitnami public Helm chart for OpenLDAP was removed; use the Bitnami legacy image. + imageRepository: "bitnamilegacy/openldap", + imageTag: "2.6.10-debian-12-r4", +}; + +export const DEFAULT_CONFIG_PATHS = { + seedLdifFile: path.join( + PACKAGE_ROOT, + "dist/deployment/openldap/config/seed.ldif", + ), +}; + +export const DEFAULT_USERS = [ + { + uid: "user1", + cn: "User 1", + sn: "One", + mail: "user1@rhdh.test", + password: DEFAULT_OPENLDAP_PASSWORD, + }, + { + uid: "user2", + cn: "User 2", + sn: "Two", + mail: "user2@rhdh.test", + password: DEFAULT_OPENLDAP_PASSWORD, + }, + { + uid: "user3", + cn: "User 3", + sn: "Three", + mail: "user3@rhdh.test", + password: DEFAULT_OPENLDAP_PASSWORD, + }, + { + uid: "rhdh-admin", + cn: "RHDH Admin", + sn: "Admin", + mail: "rhdh-admin@rhdh.test", + password: DEFAULT_OPENLDAP_PASSWORD, + }, +] as const; + +export function buildBindDn( + options: Pick = {}, +): string { + const adminUser = options.adminUser ?? DEFAULT_OPENLDAP_CONFIG.adminUser; + const baseDn = options.baseDn ?? DEFAULT_OPENLDAP_CONFIG.baseDn; + return `cn=${adminUser},${baseDn}`; +} + +export function buildUsersDn( + options: Pick = {}, +): string { + const usersOu = options.usersOu ?? DEFAULT_OPENLDAP_CONFIG.usersOu; + const baseDn = options.baseDn ?? DEFAULT_OPENLDAP_CONFIG.baseDn; + return `ou=${usersOu},${baseDn}`; +} + +export function buildGroupsDn( + options: Pick = {}, +): string { + const groupsOu = options.groupsOu ?? DEFAULT_OPENLDAP_CONFIG.groupsOu; + const baseDn = options.baseDn ?? DEFAULT_OPENLDAP_CONFIG.baseDn; + return `ou=${groupsOu},${baseDn}`; +} diff --git a/src/deployment/openldap/deployment.ts b/src/deployment/openldap/deployment.ts new file mode 100644 index 0000000..95aae5f --- /dev/null +++ b/src/deployment/openldap/deployment.ts @@ -0,0 +1,256 @@ +import * as fs from "fs"; +import { KubernetesClientHelper } from "../../utils/kubernetes-client.js"; +import { $, runQuietUnlessFailure } from "../../utils/bash.js"; +import { + DEFAULT_OPENLDAP_CONFIG, + DEFAULT_CONFIG_PATHS, + buildBindDn, + buildUsersDn, + buildGroupsDn, +} from "./constants.js"; +import type { + OpenLDAPDeploymentOptions, + OpenLDAPDeploymentConfig, + OpenLDAPBindConfig, +} from "./types.js"; + +/** + * Orchestrator-style OpenLDAP helper (Bitnami legacy image). + * Call from test.runOnce — not globalSetup. Deploys into the Playwright project namespace. + */ +export class OpenLDAPHelper { + public k8sClient = new KubernetesClientHelper(); + public deploymentConfig: OpenLDAPDeploymentConfig; + public ldapUrl = ""; + + constructor(options: OpenLDAPDeploymentOptions = {}) { + this.deploymentConfig = this._buildDeploymentConfig(options); + } + + /** + * Deploy OpenLDAP into the given namespace (creates namespace if needed). + */ + async deploy(namespace: string): Promise { + this.deploymentConfig.namespace = namespace; + this._log(`Starting OpenLDAP deployment in ${namespace}...`); + + await this.k8sClient.createNamespaceIfNotExists(namespace); + await this._applyManifests(); + await this.waitUntilReady(); + this.ldapUrl = this.getServiceUrl(); + this._log(`OpenLDAP ready at ${this.ldapUrl}`); + } + + /** + * True if the OpenLDAP service already exists in the configured namespace. + */ + async isRunning(): Promise { + const { namespace, releaseName } = this.deploymentConfig; + if (!namespace) { + return false; + } + try { + const result = + await $`kubectl get svc ${releaseName} -n ${namespace} -o name`.nothrow(); + return result.exitCode === 0; + } catch { + return false; + } + } + + /** Cluster-internal LDAP URL (Keycloak federation / RHDH ldapOrg). */ + getServiceUrl(): string { + const { releaseName, namespace, port } = this.deploymentConfig; + if (!namespace) { + throw new Error( + "OpenLDAP namespace is not set — call deploy(namespace) first", + ); + } + return `ldap://${releaseName}.${namespace}.svc.cluster.local:${port}`; + } + + getBindConfig(): OpenLDAPBindConfig { + const { adminPassword, baseDn, adminUser, usersOu, groupsOu } = + this.deploymentConfig; + return { + bindDn: buildBindDn({ adminUser, baseDn }), + bindSecret: adminPassword, + usersDn: buildUsersDn({ usersOu, baseDn }), + groupsDn: buildGroupsDn({ groupsOu, baseDn }), + baseDn, + }; + } + + /** Export LDAP_* env vars for RHDH secrets / app-config substitution. */ + exportEnv(): void { + const bind = this.getBindConfig(); + process.env.LDAP_TARGET_URL = this.getServiceUrl(); + process.env.LDAP_BIND_DN = bind.bindDn; + process.env.LDAP_BIND_SECRET = bind.bindSecret; + process.env.LDAP_USERS_DN = bind.usersDn; + process.env.LDAP_GROUPS_DN = bind.groupsDn; + } + + async waitUntilReady(timeout = 300): Promise { + const { namespace, releaseName } = this.deploymentConfig; + this._log("Waiting for OpenLDAP pods..."); + const labelSelector = `app.kubernetes.io/name=openldap,app.kubernetes.io/instance=${releaseName}`; + await this.k8sClient.waitForPodsWithFailureDetection( + namespace, + labelSelector, + timeout, + ); + } + + async teardown(): Promise { + const { namespace, releaseName } = this.deploymentConfig; + this._log(`Tearing down OpenLDAP ${releaseName} in ${namespace}...`); + await $`kubectl delete deployment,svc,configmap -l app.kubernetes.io/instance=${releaseName} -n ${namespace} --ignore-not-found=true`.nothrow(); + } + + private _buildDeploymentConfig( + options: OpenLDAPDeploymentOptions, + ): OpenLDAPDeploymentConfig { + return { + namespace: "", + releaseName: options.releaseName ?? DEFAULT_OPENLDAP_CONFIG.releaseName, + adminUser: options.adminUser ?? DEFAULT_OPENLDAP_CONFIG.adminUser, + adminPassword: + options.adminPassword ?? DEFAULT_OPENLDAP_CONFIG.adminPassword, + baseDn: options.baseDn ?? DEFAULT_OPENLDAP_CONFIG.baseDn, + usersOu: options.usersOu ?? DEFAULT_OPENLDAP_CONFIG.usersOu, + groupsOu: options.groupsOu ?? DEFAULT_OPENLDAP_CONFIG.groupsOu, + port: options.port ?? DEFAULT_OPENLDAP_CONFIG.port, + imageRepository: + options.imageRepository ?? DEFAULT_OPENLDAP_CONFIG.imageRepository, + imageTag: options.imageTag ?? DEFAULT_OPENLDAP_CONFIG.imageTag, + seedLdifFile: options.seedLdifFile ?? DEFAULT_CONFIG_PATHS.seedLdifFile, + }; + } + + private async _applyManifests(): Promise { + const cfg = this.deploymentConfig; + if (!fs.existsSync(cfg.seedLdifFile)) { + throw new Error(`OpenLDAP seed LDIF not found: ${cfg.seedLdifFile}`); + } + + const seedContent = fs.readFileSync(cfg.seedLdifFile, "utf-8"); + const manifest = ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: ${cfg.releaseName}-seed + namespace: ${cfg.namespace} + labels: + app.kubernetes.io/name: openldap + app.kubernetes.io/instance: ${cfg.releaseName} +data: + seed.ldif: | +${seedContent + .split("\n") + .map((line) => ` ${line}`) + .join("\n")} +--- +apiVersion: v1 +kind: Service +metadata: + name: ${cfg.releaseName} + namespace: ${cfg.namespace} + labels: + app.kubernetes.io/name: openldap + app.kubernetes.io/instance: ${cfg.releaseName} +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: openldap + app.kubernetes.io/instance: ${cfg.releaseName} + ports: + - name: ldap + port: ${cfg.port} + targetPort: ldap +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ${cfg.releaseName} + namespace: ${cfg.namespace} + labels: + app.kubernetes.io/name: openldap + app.kubernetes.io/instance: ${cfg.releaseName} +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: openldap + app.kubernetes.io/instance: ${cfg.releaseName} + template: + metadata: + labels: + app.kubernetes.io/name: openldap + app.kubernetes.io/instance: ${cfg.releaseName} + spec: + containers: + - name: openldap + image: ${cfg.imageRepository}:${cfg.imageTag} + imagePullPolicy: IfNotPresent + ports: + - name: ldap + containerPort: ${cfg.port} + # Bitnami slapd/slappasswd carry setcap CAP_NET_BIND_SERVICE; OpenShift + # restricted-v2 drops ALL capabilities unless NET_BIND_SERVICE is added + # explicitly (otherwise: "slappasswd: Operation not permitted"). + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + add: ["NET_BIND_SERVICE"] + runAsNonRoot: true + env: + - name: LDAP_ROOT + value: "${cfg.baseDn}" + - name: LDAP_ADMIN_USERNAME + value: "${cfg.adminUser}" + - name: LDAP_ADMIN_PASSWORD + value: "${cfg.adminPassword}" + - name: LDAP_PORT_NUMBER + value: "${cfg.port}" + - name: LDAP_CUSTOM_LDIF_DIR + value: /ldifs + - name: LDAP_ALLOW_ANON_BINDING + value: "no" + volumeMounts: + - name: seed + mountPath: /ldifs + readOnly: true + readinessProbe: + tcpSocket: + port: ldap + initialDelaySeconds: 10 + periodSeconds: 5 + failureThreshold: 12 + livenessProbe: + tcpSocket: + port: ldap + initialDelaySeconds: 30 + periodSeconds: 10 + failureThreshold: 6 + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + volumes: + - name: seed + configMap: + name: ${cfg.releaseName}-seed +`; + + await runQuietUnlessFailure`echo ${manifest} | kubectl apply -f -`; + } + + private _log(message: string): void { + console.log(`[OpenLDAP] ${message}`); + } +} diff --git a/src/deployment/openldap/index.ts b/src/deployment/openldap/index.ts new file mode 100644 index 0000000..1574086 --- /dev/null +++ b/src/deployment/openldap/index.ts @@ -0,0 +1,14 @@ +export { OpenLDAPHelper } from "./deployment.js"; +export { + DEFAULT_OPENLDAP_CONFIG, + DEFAULT_OPENLDAP_PASSWORD, + DEFAULT_USERS as DEFAULT_OPENLDAP_USERS, + buildBindDn, + buildUsersDn, + buildGroupsDn, +} from "./constants.js"; +export type { + OpenLDAPDeploymentOptions, + OpenLDAPDeploymentConfig, + OpenLDAPBindConfig, +} from "./types.js"; diff --git a/src/deployment/openldap/types.ts b/src/deployment/openldap/types.ts new file mode 100644 index 0000000..78d7fb1 --- /dev/null +++ b/src/deployment/openldap/types.ts @@ -0,0 +1,35 @@ +export type OpenLDAPDeploymentOptions = { + releaseName?: string; + adminUser?: string; + adminPassword?: string; + baseDn?: string; + usersOu?: string; + groupsOu?: string; + port?: number; + imageRepository?: string; + imageTag?: string; + valuesFile?: string; + seedLdifFile?: string; +}; + +export type OpenLDAPDeploymentConfig = { + namespace: string; + releaseName: string; + adminUser: string; + adminPassword: string; + baseDn: string; + usersOu: string; + groupsOu: string; + port: number; + imageRepository: string; + imageTag: string; + seedLdifFile: string; +}; + +export type OpenLDAPBindConfig = { + bindDn: string; + bindSecret: string; + usersDn: string; + groupsDn: string; + baseDn: string; +}; diff --git a/src/playwright/helpers/common.ts b/src/playwright/helpers/common.ts index 08fc30c..8bc8d46 100644 --- a/src/playwright/helpers/common.ts +++ b/src/playwright/helpers/common.ts @@ -206,17 +206,30 @@ export class LoginHelper { await popup.locator("#kc-login").click(); } + /** + * Sign in via Keycloak popup. Supports both OIDC ("Sign In") and the + * community keycloak provider ("Sign in using Keycloak"). + */ async loginAsKeycloakUser( userid: string = DEFAULT_USERS[0].username, password: string = DEFAULT_USERS[0].password, ) { await this.page.goto("/"); await this.uiHelper.waitForLoad(240000); + const popupPromise = this.page.waitForEvent("popup"); - await this.uiHelper.clickButton("Sign In"); + const keycloakProviderBtn = this.page.getByRole("button", { + name: /sign in using keycloak/i, + }); + if (await keycloakProviderBtn.isVisible().catch(() => false)) { + await keycloakProviderBtn.click(); + } else { + await this.uiHelper.clickButton("Sign In"); + } + const popup = await popupPromise; await this.logintoKeycloak(popup, userid, password); - await this.page.waitForSelector("nav a", { timeout: 10_000 }); + await this.page.waitForSelector("nav a", { timeout: 30_000 }); } async loginAsGithubUser(