diff --git a/src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/test/browser_auth/index.ts b/src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/test/browser_auth/index.ts index bc47daed244af..befcf870448c7 100644 --- a/src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/test/browser_auth/index.ts +++ b/src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/test/browser_auth/index.ts @@ -41,6 +41,25 @@ export interface BrowserAuthFixture { * @returns A Promise that resolves once the cookie in browser is set. */ loginWithCustomRole: (role: KibanaRole) => Promise; + /** + * Logs in as a SAML user whose privileges match the named Elasticsearch role + * (e.g. `'kibana_admin'`, `'superuser'`, `'monitoring_user'`). + * + * Fetches the role descriptor from Elasticsearch and provisions it into the + * worker's custom role slot — works on both local and Cloud environments. + * No entry in `roles.yml` is required. + * + * @param roleName - The name of the ES role to look up and log in as. + * @returns A Promise that resolves once the cookie in browser is set. + * + * @example + * test('kibana_admin cannot see CCR link', async ({ browserAuth, page }) => { + * await browserAuth.loginWithBuiltinRole('kibana_admin'); + * await page.goto('/app/management'); + * await expect(page.locator('[data-test-subj="cross_cluster_replication"]')).toBeHidden(); + * }); + */ + loginWithBuiltinRole: (roleName: string) => Promise; } /** @@ -76,6 +95,11 @@ export const browserAuthFixture = coreWorkerFixtures.extend<{ browserAuth: Brows return loginAs(samlAuth.customRoleName); }; + const loginWithBuiltinRole = async (roleName: string) => { + await samlAuth.setBuiltinRole(roleName); + return loginAs(samlAuth.customRoleName); + }; + const loginAsAdmin = () => loginAs('admin'); const loginAsViewer = () => loginAs('viewer'); const loginAsPrivilegedUser = () => @@ -90,6 +114,7 @@ export const browserAuthFixture = coreWorkerFixtures.extend<{ browserAuth: Brows loginAsPrivilegedUser, loginAs, loginWithCustomRole, + loginWithBuiltinRole, }); }, }); diff --git a/src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/worker/api_key.ts b/src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/worker/api_key.ts index 0646638e7787a..33aebd5920d29 100644 --- a/src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/worker/api_key.ts +++ b/src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/worker/api_key.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { coreWorkerFixtures } from './core_fixtures'; +import { coreWorkerFixtures } from './saml_auth'; import type { ApiClientFixture } from './api_client'; import type { DefaultRolesFixture } from './default_roles'; import type { ElasticsearchRoleDescriptor, KibanaRole } from '../../../../common'; @@ -64,6 +64,18 @@ export interface RequestAuthFixture { * Elasticsearch projects, `editor` for all other deployments and project types. */ getApiKeyForPrivilegedUser: () => Promise; + /** + * Fetches the descriptor of the named ES role and creates an API key scoped + * to those privileges. Works for built-in ES roles (e.g. `'kibana_admin'`, + * `'superuser'`) without requiring an entry in `roles.yml`. + * + * The descriptor is embedded inline in the API key — no separate role is + * created in Elasticsearch. + * + * @example + * const { apiKeyHeader } = await requestAuth.getApiKeyForBuiltinRole('kibana_admin'); + */ + getApiKeyForBuiltinRole: (roleName: string) => Promise; } export const requestAuthFixture = coreWorkerFixtures.extend< @@ -204,12 +216,20 @@ export const requestAuthFixture = coreWorkerFixtures.extend< ); }; + const getApiKeyForBuiltinRole = async (roleName: string): Promise => { + const descriptor = await samlAuth.setBuiltinRole(roleName); + return createApiKeyWithAdminCredentials(samlAuth.customRoleName, { + [samlAuth.customRoleName]: descriptor, + }); + }; + await use({ getApiKey, getApiKeyForCustomRole, getApiKeyForAdmin, getApiKeyForViewer, getApiKeyForPrivilegedUser, + getApiKeyForBuiltinRole, }); // Invalidate all API Keys after tests diff --git a/src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/worker/core_fixtures.ts b/src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/worker/core_fixtures.ts index a53a6dd9fd369..5b310a8923b83 100644 --- a/src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/worker/core_fixtures.ts +++ b/src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/worker/core_fixtures.ts @@ -9,23 +9,14 @@ import { test as base } from '@playwright/test'; import type { KbnClient } from '@kbn/kbn-client'; -import type { SamlSessionManager } from '@kbn/test-saml-auth'; import type { Client } from '@elastic/elasticsearch'; -import type { - KibanaUrl, - ElasticsearchRoleDescriptor, - KibanaRole, -} from '../../../../common/services'; +import type { KibanaUrl } from '../../../../common/services'; import { createKbnUrl, getEsClient, getKbnClient, - createSamlSessionManager, createScoutConfig, ScoutLogger, - createElasticsearchCustomRole, - createCustomRole, - isElasticsearchRole, } from '../../../../common/services'; import type { ScoutTestOptions } from '../../../types'; import type { ScoutTestConfig } from '.'; @@ -47,89 +38,12 @@ export interface RoleSessionCredentials { cookieHeader: CookieHeader; } -/** - * UI settings returns 400 when a key is locked by `uiSettings.globalOverrides`. - * Collect message + Error.cause chain so we match reliably across clients/wrappers. - */ -function formatUnknownError(err: unknown): string { - if (err == null) { - return ''; - } - if (typeof err === 'string') { - return err; - } - const parts: string[] = []; - let current: unknown = err; - for (let depth = 0; depth < 8 && current != null; depth++) { - if (current instanceof Error) { - parts.push(current.message); - current = current.cause; - } else { - try { - parts.push(JSON.stringify(current)); - } catch { - parts.push(String(current)); - } - break; - } - } - return parts.join('\n'); -} - -function isGlobalUiSettingOverrideConflict(err: unknown): boolean { - const text = formatUnknownError(err); - return ( - text.includes('because it is overridden') || - (text.includes('hideAnnouncements') && text.includes('overridden')) - ); -} - -export interface SamlAuth { - session: SamlSessionManager; - customRoleName: string; - setCustomRole(role: KibanaRole | ElasticsearchRoleDescriptor): Promise; - - /** - * Generates a SAML session cookie for an interactive user with the specified role. - * - * This method is ideal for testing internal APIs that are typically accessed via the UI. - * It authenticates as an interactive user and returns session credentials including cookie - * headers that can be used in API requests. - * - * @param role - Either a built-in Kibana role name (e.g., 'admin', 'editor', 'viewer') or - * a custom role descriptor with specific permissions (Kibana or Elasticsearch) - * @returns Promise resolving to credentials with cookieValue and cookieHeader properties - * - * @example - * // Using a built-in role - * const { cookieHeader } = await samlAuth.asInteractiveUser('admin'); - * const response = await apiClient.get('internal/endpoint', { - * headers: { ...cookieHeader } - * }); - * - * @example - * // Using a custom role descriptor - * const customRole = { - * kibana: [{ base: ['read'], spaces: ['*'] }], - * elasticsearch: { indices: [{ names: ['logs-*'], privileges: ['read'] }] } - * }; - * const { cookieHeader } = await samlAuth.asInteractiveUser(customRole); - * const response = await apiClient.get('internal/endpoint', { - * headers: { ...cookieHeader } - * }); - */ - asInteractiveUser( - role: string | KibanaRole | ElasticsearchRoleDescriptor - ): Promise; -} - -export interface CoreWorkerFixtures { +export interface BaseWorkerFixtures { log: ScoutLogger; config: ScoutTestConfig; kbnUrl: KibanaUrl; esClient: Client; kbnClient: KbnClient; - samlAuth: SamlAuth; /** * `true` when the target Elasticsearch cluster is a SNAPSHOT build. SNAPSHOT * builds bundle test-only modules (e.g. the `shard_delay` aggregation) that @@ -151,8 +65,11 @@ export interface CoreWorkerFixtures { * scoped resources for each Playwright worker, ensuring that tests have consistent * and isolated access to critical services such as logging, configuration, and * clients for interacting with Kibana and Elasticsearch. + * + * Note: `samlAuth` is added by the `samlAuthFixture` in `./saml_auth/index.ts`, which + * extends this base. The combined fixture (with samlAuth) is what `worker/index.ts` exports. */ -export const coreWorkerFixtures = base.extend<{}, CoreWorkerFixtures>({ +export const coreWorkerFixtures = base.extend<{}, BaseWorkerFixtures>({ // Provides a scoped logger instance for each worker to use in fixtures and tests. // For parallel workers logger context is matching worker index+1, e.g. '[scout-worker-1]', '[scout-worker-2]', etc. log: [ @@ -248,119 +165,4 @@ export const coreWorkerFixtures = base.extend<{}, CoreWorkerFixtures>({ }, { scope: 'worker' }, ], - - /** - * Creates a SAML session manager, that handles authentication tasks for tests involving - * SAML-based authentication. Exposes a method to set a custom role for the session. - * - * Note: In order to speedup execution of tests, we cache the session cookies for each role - * after first call. Custom roles are persisted for the worker lifetime and cleaned up when - * the worker completes. - */ - samlAuth: [ - async ({ log, config, esClient, kbnClient }, use, workerInfo) => { - /** - * When running tests against Cloud, ensure the `.ftr/role_users.json` file is populated with the required roles - * and credentials. Each worker uses a unique custom role named `custom_role_worker_`. - * If running tests in parallel, make sure the file contains enough entries to accommodate all workers. - * The file should be structured as follows: - * { - * "custom_role_worker_1": { "username": ..., "password": ... }, - * "custom_role_worker_2": { "username": ..., "password": ... }, - */ - const customRoleName = `custom_role_worker_${workerInfo.parallelIndex + 1}`; - const session = createSamlSessionManager(config, log, customRoleName); - let customRoleHash = ''; - let isCustomRoleCreated = false; - - const isCustomRoleSet = (roleHash: string) => roleHash === customRoleHash; - - const setCustomRole = async (role: KibanaRole | ElasticsearchRoleDescriptor) => { - const newRoleHash = JSON.stringify(role); - - if (isCustomRoleSet(newRoleHash)) { - log.debug( - `Custom role '${customRoleName}' with provided privileges already exists, reusing it` - ); - return; - } - - log.debug( - isCustomRoleCreated - ? `Overriding existing custom role '${customRoleName}'` - : `Creating custom role '${customRoleName}'` - ); - - isCustomRoleCreated = true; - - if (isElasticsearchRole(role)) { - await createElasticsearchCustomRole(esClient, customRoleName, role); - log.debug(`Created Elasticsearch custom role: ${customRoleName}`); - } else { - await createCustomRole(kbnClient, customRoleName, role); - log.debug(`Created Kibana custom role: ${customRoleName}`); - } - - customRoleHash = newRoleHash; - }; - - const asInteractiveUser = async ( - role: string | KibanaRole | ElasticsearchRoleDescriptor - ): Promise => { - let roleName: string; - - if (typeof role === 'string') { - // Built-in role name - roleName = role; - } else { - // Custom role descriptor - create/update the role first - await setCustomRole(role); - roleName = customRoleName; - } - - const cookieValue = await session.getInteractiveUserSessionCookieWithRoleScope(roleName); - const cookieHeader = { Cookie: `sid=${cookieValue}` }; - return { cookieValue, cookieHeader }; - }; - - // Hide the announcements (including the sidenav tour) in advance to prevent - // it from interfering with test flows. Default Scout server config_sets do not set - // globalOverrides (ECH/MKI parity); a plugin-specific config_set may add - // `--uiSettings.globalOverrides.hideAnnouncements`, in which case this POST returns 400. - try { - await kbnClient.uiSettings.updateGlobal({ hideAnnouncements: true }); - } catch (err: unknown) { - if (isGlobalUiSettingOverrideConflict(err)) { - const detail = formatUnknownError(err); - log.debug( - `Skipping hideAnnouncements update — already enforced by server-level override: ${detail}` - ); - } else { - throw err; - } - } - - await use({ - session, - customRoleName, - setCustomRole, - asInteractiveUser, - }); - - // Delete custom role when worker completes (if it was created) - if (isCustomRoleCreated) { - log.debug(`Deleting custom role ${customRoleName}`); - try { - await esClient.security.deleteRole({ name: customRoleName }); - log.debug(`Custom role '${customRoleName}' deleted`); - customRoleHash = ''; - } catch (error: any) { - log.error( - `Failed to delete custom role '${customRoleName}' during worker cleanup: ${error.message}` - ); - } - } - }, - { scope: 'worker' }, - ], }); diff --git a/src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/worker/index.ts b/src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/worker/index.ts index 10f6fbedd41dc..693a06f197e80 100644 --- a/src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/worker/index.ts +++ b/src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/worker/index.ts @@ -7,16 +7,12 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export { coreWorkerFixtures } from './core_fixtures'; -export type { - ScoutLogger, - ScoutTestConfig, - KibanaUrl, - EsClient, - KbnClient, - SamlAuth, - CoreWorkerFixtures, -} from './core_fixtures'; +// The samlAuthFixture extends the base coreWorkerFixtures with `samlAuth`. +// Re-export it as `coreWorkerFixtures` so all downstream consumers get +// the full fixture set (including samlAuth) without any import-path changes. +export { samlAuthFixture as coreWorkerFixtures } from './saml_auth'; +export type { ScoutLogger, ScoutTestConfig, KibanaUrl, EsClient, KbnClient } from './core_fixtures'; +export type { SamlAuth, CoreWorkerFixtures } from './saml_auth'; export { esArchiverFixture } from './es_archiver'; export type { EsArchiverFixture } from './es_archiver'; diff --git a/src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/worker/saml_auth/index.ts b/src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/worker/saml_auth/index.ts new file mode 100644 index 0000000000000..8c60b362dbafe --- /dev/null +++ b/src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/worker/saml_auth/index.ts @@ -0,0 +1,173 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { SamlSessionManager } from '@kbn/test-saml-auth'; +import { createSamlSessionManager } from '../../../../../common/services'; +import type { ElasticsearchRoleDescriptor, KibanaRole } from '../../../../../common/services'; +import type { RoleSessionCredentials, BaseWorkerFixtures } from '../core_fixtures'; +import { coreWorkerFixtures } from '../core_fixtures'; +import { SamlAuthManager } from './saml_auth_manager'; + +/** + * UI settings returns 400 when a key is locked by `uiSettings.globalOverrides`. + * Collect message + Error.cause chain so we match reliably across clients/wrappers. + */ +function formatUnknownError(err: unknown): string { + if (err == null) { + return ''; + } + if (typeof err === 'string') { + return err; + } + const parts: string[] = []; + let current: unknown = err; + for (let depth = 0; depth < 8 && current != null; depth++) { + if (current instanceof Error) { + parts.push(current.message); + current = current.cause; + } else { + try { + parts.push(JSON.stringify(current)); + } catch { + parts.push(String(current)); + } + break; + } + } + return parts.join('\n'); +} + +function isGlobalUiSettingOverrideConflict(err: unknown): boolean { + const text = formatUnknownError(err); + return ( + text.includes('because it is overridden') || + (text.includes('hideAnnouncements') && text.includes('overridden')) + ); +} + +export interface SamlAuth { + session: SamlSessionManager; + customRoleName: string; + setCustomRole(role: KibanaRole | ElasticsearchRoleDescriptor): Promise; + /** + * Fetches the live descriptor of any named ES role and provisions it as the + * worker's custom role slot. Works for built-in ES roles (e.g. `kibana_admin`, + * `superuser`) and any other role present in Elasticsearch. + * + * Works on both local and Cloud because it delegates entirely to `setCustomRole`, + * which already supports Cloud. + * + * @param roleName - The name of the role to look up in Elasticsearch. + */ + setBuiltinRole(roleName: string): Promise; + /** + * Generates a SAML session cookie for an interactive user with the specified role. + * + * This method is ideal for testing internal APIs that are typically accessed via the UI. + * It authenticates as an interactive user and returns session credentials including cookie + * headers that can be used in API requests. + * + * @param role - Either a built-in Kibana role name (e.g., 'admin', 'editor', 'viewer') or + * a custom role descriptor with specific permissions (Kibana or Elasticsearch) + * @returns Promise resolving to credentials with cookieValue and cookieHeader properties + * + * @example + * // Using a built-in role + * const { cookieHeader } = await samlAuth.asInteractiveUser('admin'); + * const response = await apiClient.get('internal/endpoint', { + * headers: { ...cookieHeader } + * }); + * + * @example + * // Using a custom role descriptor + * const customRole = { + * kibana: [{ base: ['read'], spaces: ['*'] }], + * elasticsearch: { indices: [{ names: ['logs-*'], privileges: ['read'] }] } + * }; + * const { cookieHeader } = await samlAuth.asInteractiveUser(customRole); + * const response = await apiClient.get('internal/endpoint', { + * headers: { ...cookieHeader } + * }); + */ + asInteractiveUser( + role: string | KibanaRole | ElasticsearchRoleDescriptor + ): Promise; +} + +/** + * Full worker fixture set: base fixtures + samlAuth. + * Use this type when you need to reference the complete worker fixture surface. + */ +export interface CoreWorkerFixtures extends BaseWorkerFixtures { + samlAuth: SamlAuth; +} + +export const samlAuthFixture = coreWorkerFixtures.extend<{}, { samlAuth: SamlAuth }>({ + /** + * Creates a SAML session manager, that handles authentication tasks for tests involving + * SAML-based authentication. Exposes methods to set a custom role or a built-in ES role. + * + * Note: In order to speedup execution of tests, we cache the session cookies for each role + * after first call. Custom roles are persisted for the worker lifetime and cleaned up when + * the worker completes. + */ + samlAuth: [ + async ({ log, config, esClient, kbnClient }, use, workerInfo) => { + /** + * When running tests against Cloud, ensure the `.ftr/role_users.json` file is populated with the required roles + * and credentials. Each worker uses a unique custom role named `custom_role_worker_`. + * If running tests in parallel, make sure the file contains enough entries to accommodate all workers. + * The file should be structured as follows: + * { + * "custom_role_worker_1": { "username": ..., "password": ... }, + * "custom_role_worker_2": { "username": ..., "password": ... }, + */ + const customRoleName = `custom_role_worker_${workerInfo.parallelIndex + 1}`; + const session = createSamlSessionManager(config, log, customRoleName); + const manager = new SamlAuthManager(session, customRoleName, esClient, kbnClient, log); + + // Hide the announcements (including the sidenav tour) in advance to prevent + // it from interfering with test flows. Default Scout server config_sets do not set + // globalOverrides (ECH/MKI parity); a plugin-specific config_set may add + // `--uiSettings.globalOverrides.hideAnnouncements`, in which case this POST returns 400. + try { + await kbnClient.uiSettings.updateGlobal({ hideAnnouncements: true }); + } catch (err: unknown) { + if (isGlobalUiSettingOverrideConflict(err)) { + const detail = formatUnknownError(err); + log.debug( + `Skipping hideAnnouncements update — already enforced by server-level override: ${detail}` + ); + } else { + throw err; + } + } + + // Expose a plain object (not a class instance) so that consumers that + // spread `samlAuth` (e.g. `{ ...samlAuth, extraMethod }`) get all methods + // as own enumerable properties rather than losing prototype methods. + await use({ + session: manager.session, + customRoleName: manager.customRoleName, + setCustomRole: (role) => manager.setCustomRole(role), + setBuiltinRole: (roleName) => manager.setBuiltinRole(roleName), + asInteractiveUser: (role) => manager.asInteractiveUser(role), + }); + await manager.cleanup(); + }, + { scope: 'worker' }, + ], +}); + +/** + * Re-exported alias so worker-level fixtures that need samlAuth can import + * `coreWorkerFixtures` from `./saml_auth` (the extended version) rather than + * from `./core_fixtures` (the base version without samlAuth). + */ +export { samlAuthFixture as coreWorkerFixtures }; diff --git a/src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/worker/saml_auth/saml_auth_manager.ts b/src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/worker/saml_auth/saml_auth_manager.ts new file mode 100644 index 0000000000000..d496754dc63e3 --- /dev/null +++ b/src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/worker/saml_auth/saml_auth_manager.ts @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { SamlSessionManager } from '@kbn/test-saml-auth'; +import type { Client as EsClient } from '@elastic/elasticsearch'; +import type { KbnClient } from '@kbn/kbn-client'; +import type { ElasticsearchRoleDescriptor, KibanaRole } from '../../../../../common/services'; +import { + createElasticsearchCustomRole, + createCustomRole, + isElasticsearchRole, +} from '../../../../../common/services'; +import type { ScoutLogger } from '../../../../../common/services/logger'; +import type { RoleSessionCredentials } from '../core_fixtures'; + +export class SamlAuthManager { + private customRoleHash = ''; + private isCustomRoleCreated = false; + + constructor( + public readonly session: SamlSessionManager, + public readonly customRoleName: string, + private readonly esClient: EsClient, + private readonly kbnClient: KbnClient, + private readonly log: ScoutLogger + ) {} + + async setCustomRole(role: KibanaRole | ElasticsearchRoleDescriptor): Promise { + const newRoleHash = JSON.stringify(role); + + if (newRoleHash === this.customRoleHash) { + this.log.debug( + `Custom role '${this.customRoleName}' with provided privileges already exists, reusing it` + ); + return; + } + + this.log.debug( + this.isCustomRoleCreated + ? `Overriding existing custom role '${this.customRoleName}'` + : `Creating custom role '${this.customRoleName}'` + ); + + this.isCustomRoleCreated = true; + + if (isElasticsearchRole(role)) { + await createElasticsearchCustomRole(this.esClient, this.customRoleName, role); + this.log.debug(`Created Elasticsearch custom role: ${this.customRoleName}`); + } else { + await createCustomRole(this.kbnClient, this.customRoleName, role); + this.log.debug(`Created Kibana custom role: ${this.customRoleName}`); + } + + this.customRoleHash = newRoleHash; + } + + /** + * Fetches the live descriptor of any named ES role and provisions it as the + * worker's custom role slot. Works for both built-in ES roles (e.g. `kibana_admin`, + * `superuser`) and any other role present in Elasticsearch. + * + * @param roleName - The name of the role to look up in Elasticsearch. + */ + async setBuiltinRole(roleName: string): Promise { + const response = await this.esClient.security.getRole({ name: roleName }); + const roleData = response[roleName]; + if (!roleData) { + throw new Error(`Role '${roleName}' not found in Elasticsearch`); + } + // Strip fields that are not valid privilege fields: + // - `metadata` / `transient_metadata`: ES internal bookkeeping + // - `description`: human-readable label accepted by the role API but rejected + // by the API key role_descriptors endpoint + const { + metadata: _metadata, + transient_metadata: _transient, + description: _description, + ...descriptor + } = roleData; + await this.setCustomRole(descriptor as ElasticsearchRoleDescriptor); + return descriptor as ElasticsearchRoleDescriptor; + } + + async asInteractiveUser( + role: string | KibanaRole | ElasticsearchRoleDescriptor + ): Promise { + let roleName: string; + + if (typeof role === 'string') { + roleName = role; + } else { + await this.setCustomRole(role); + roleName = this.customRoleName; + } + + const cookieValue = await this.session.getInteractiveUserSessionCookieWithRoleScope(roleName); + return { cookieValue, cookieHeader: { Cookie: `sid=${cookieValue}` } }; + } + + async cleanup(): Promise { + if (!this.isCustomRoleCreated) return; + + this.log.debug(`Deleting custom role ${this.customRoleName}`); + try { + await this.esClient.security.deleteRole({ name: this.customRoleName }); + this.log.debug(`Custom role '${this.customRoleName}' deleted`); + this.customRoleHash = ''; + } catch (error: any) { + this.log.error( + `Failed to delete custom role '${this.customRoleName}' during worker cleanup: ${error.message}` + ); + } + } +} diff --git a/src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/auth/saml_login.spec.ts b/src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/auth/saml_login.spec.ts index 290dbd6e33516..b87ab6ddfe11c 100644 --- a/src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/auth/saml_login.spec.ts +++ b/src/platform/packages/shared/kbn-scout/test/scout/api/parallel_tests/auth/saml_login.spec.ts @@ -24,5 +24,25 @@ apiTest.describe( expect(adminApiCredentials.apiKey.id).toBeDefined(); expect(adminApiCredentials.apiKey.name).toBeDefined(); }); + + apiTest( + `setBuiltinRole should provision the custom role slot and return the descriptor`, + async ({ samlAuth }) => { + const descriptor = await samlAuth.setBuiltinRole('kibana_admin'); + expect(descriptor).toBeDefined(); + const credentials = await samlAuth.asInteractiveUser(samlAuth.customRoleName); + expect(credentials.cookieValue).toBeDefined(); + } + ); + + apiTest( + `getApiKeyForBuiltinRole should create an API key scoped to a built-in ES role`, + async ({ requestAuth }) => { + const { apiKey, apiKeyHeader } = await requestAuth.getApiKeyForBuiltinRole('kibana_admin'); + expect(apiKey.id).toBeDefined(); + expect(apiKey.name).toBeDefined(); + expect(apiKeyHeader.Authorization).toMatch(/^ApiKey /); + } + ); } );