Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,25 @@ export interface BrowserAuthFixture {
* @returns A Promise that resolves once the cookie in browser is set.
*/
loginWithCustomRole: (role: KibanaRole) => Promise<void>;
/**
* 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<void>;
}

/**
Expand Down Expand Up @@ -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 = () =>
Expand All @@ -90,6 +114,7 @@ export const browserAuthFixture = coreWorkerFixtures.extend<{ browserAuth: Brows
loginAsPrivilegedUser,
loginAs,
loginWithCustomRole,
loginWithBuiltinRole,
});
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -64,6 +64,18 @@ export interface RequestAuthFixture {
* Elasticsearch projects, `editor` for all other deployments and project types.
*/
getApiKeyForPrivilegedUser: () => Promise<RoleApiCredentials>;
/**
* 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<RoleApiCredentials>;
}

export const requestAuthFixture = coreWorkerFixtures.extend<
Expand Down Expand Up @@ -204,12 +216,20 @@ export const requestAuthFixture = coreWorkerFixtures.extend<
);
};

const getApiKeyForBuiltinRole = async (roleName: string): Promise<RoleApiCredentials> => {
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 '.';
Expand All @@ -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<void>;

/**
* 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<RoleSessionCredentials>;
}

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
Expand All @@ -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: [
Expand Down Expand Up @@ -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_<index>`.
* 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<RoleSessionCredentials> => {
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' },
],
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading
Loading