diff --git a/src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/worker/apis/api_keys/README.md b/src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/worker/apis/api_keys/README.md new file mode 100644 index 0000000000000..e884ce9864c25 --- /dev/null +++ b/src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/worker/apis/api_keys/README.md @@ -0,0 +1,331 @@ +# API Keys API Service + +The API Keys service provides methods to manage Elasticsearch/Kibana API keys programmatically in Scout tests. + +## Usage + +The API Keys service is available through the `apiServices` fixture: + +```ts +import { test, expect } from '@kbn/scout'; + +test.describe('API Keys tests', () => { + test('create and use an API key', async ({ apiServices }) => { + // Create an API key + const { data: apiKey } = await apiServices.apiKeys.create({ + name: 'my-test-key', + expiration: '1d', + role_descriptors: { + my_role: { + cluster: ['monitor'], + indices: [ + { + names: ['logs-*'], + privileges: ['read'], + }, + ], + }, + }, + metadata: { + application: 'test', + environment: 'development', + }, + }); + + expect(apiKey.id).toBeDefined(); + expect(apiKey.encoded).toBeDefined(); + }); +}); +``` + +## API Reference + +### `create(params: CreateAPIKeyParams)` + +Create a new API key. + +**Parameters:** +- `name` (string): Name of the API key +- `expiration` (string, optional): Expiration time (e.g., '1d', '7d', '90d') +- `role_descriptors` (object, optional): Role descriptors defining privileges +- `metadata` (object, optional): Metadata to associate with the key + +**Returns:** `Promise>` + +**Example:** +```ts +const { data: apiKey } = await apiServices.apiKeys.create({ + name: 'my-api-key', + expiration: '7d', + metadata: { created_by: 'test-suite' }, +}); +``` + +--- + +### `update(params: UpdateAPIKeyParams)` + +Update an existing API key's role descriptors or metadata. + +**Parameters:** +- `id` (string): ID of the API key to update +- `role_descriptors` (object, optional): Updated role descriptors +- `metadata` (object, optional): Updated metadata + +**Returns:** `Promise>` + +**Example:** +```ts +const { data: result } = await apiServices.apiKeys.update({ + id: 'api-key-id', + metadata: { updated_at: Date.now() }, +}); +``` + +--- + +### `grant(params: GrantAPIKeyParams)` + +Grant an API key on behalf of another user. + +**Parameters:** +- `api_key`: API key creation parameters +- `grant_type`: Grant type ('password' or 'access_token') +- `username` (string, optional): Username for password grant +- `password` (string, optional): Password for password grant +- `access_token` (string, optional): Access token for token grant +- `run_as` (string, optional): User to run as + +**Returns:** `Promise>` + +**Example:** +```ts +const { data: apiKey } = await apiServices.apiKeys.grant({ + api_key: { + name: 'granted-key', + expiration: '1d', + }, + grant_type: 'password', + username: 'elastic', + password: 'changeme', + run_as: 'test_user', +}); +``` + +--- + +### `query(params?: QueryAPIKeyParams)` + +Query API keys with filtering and pagination. + +**Parameters:** +- `query` (object, optional): Elasticsearch query DSL +- `from` (number, optional): Starting position for pagination +- `size` (number, optional): Number of results to return +- `sort` (array, optional): Sort configuration +- `filters` (object, optional): Filters to apply + - `usernames` (string[]): Filter by usernames + - `type` ('rest' | 'managed' | 'cross_cluster'): Filter by type + - `expired` (boolean): Filter by expiration status + +**Returns:** `Promise>` + +**Example:** +```ts +const { data: results } = await apiServices.apiKeys.query({ + filters: { + type: 'rest', + expired: false, + }, + from: 0, + size: 100, +}); + +console.log(`Found ${results.total} API keys`); +results.api_keys.forEach(key => { + console.log(`- ${key.name} (ID: ${key.id})`); +}); +``` + +--- + +### `invalidate(params: InvalidateAPIKeyParams)` + +Invalidate (delete) one or more API keys. + +**Parameters:** +- `apiKeys` (array): Array of API keys to invalidate (each with `id` and `name`) +- `isAdmin` (boolean, optional): Whether to invalidate as admin + +**Returns:** `Promise>` + +**Example:** +```ts +const { data: result } = await apiServices.apiKeys.invalidate({ + apiKeys: [ + { id: 'key-id-1', name: 'key-1' }, + { id: 'key-id-2', name: 'key-2' }, + ], + isAdmin: true, +}); + +console.log(`Invalidated ${result.itemsInvalidated.length} keys`); +if (result.errors.length > 0) { + console.log(`Errors: ${result.errors.length}`); +} +``` + +--- + +## Cleanup Utilities + +The API Keys service provides cleanup utilities for managing API keys in tests: + +### `cleanup.deleteAll()` + +Delete all API keys in the cluster. + +**Returns:** `Promise` + +**Example:** +```ts +test.afterAll(async ({ apiServices }) => { + await apiServices.apiKeys.cleanup.deleteAll(); +}); +``` + +--- + +### `cleanup.deleteByName(namePattern: string)` + +Delete API keys by name pattern (supports wildcards). + +**Parameters:** +- `namePattern` (string): Name or pattern to match + +**Returns:** `Promise` + +**Example:** +```ts +test.afterAll(async ({ apiServices }) => { + // Delete all keys starting with 'test-' + await apiServices.apiKeys.cleanup.deleteByName('test-*'); +}); +``` + +--- + +### `cleanup.deleteByIds(ids: string[])` + +Delete API keys by their IDs. + +**Parameters:** +- `ids` (string[]): Array of API key IDs + +**Returns:** `Promise` + +**Example:** +```ts +test.afterAll(async ({ apiServices }) => { + await apiServices.apiKeys.cleanup.deleteByIds(['id1', 'id2', 'id3']); +}); +``` + +--- + +## Common Patterns + +### Test Setup and Teardown + +```ts +test.describe('My feature', () => { + let apiKeyId: string; + + test.beforeAll(async ({ apiServices }) => { + // Clean up any existing test keys + await apiServices.apiKeys.cleanup.deleteByName('test-*'); + + // Create a test key + const { data: apiKey } = await apiServices.apiKeys.create({ + name: 'test-key', + expiration: '1d', + }); + apiKeyId = apiKey.id; + }); + + test.afterAll(async ({ apiServices }) => { + // Clean up + await apiServices.apiKeys.cleanup.deleteByIds([apiKeyId]); + }); + + test('my test', async ({ apiServices }) => { + // Use the API key + }); +}); +``` + +### Creating Cross-Cluster API Keys + +```ts +const { data: apiKey } = await apiServices.apiKeys.create({ + name: 'cross-cluster-key', + expiration: '7d', + access: { + search: [ + { + names: ['logs-*', 'metrics-*'], + allow_restricted_indices: false, + }, + ], + replication: [ + { + names: ['*'], + }, + ], + }, +}); +``` + +### Querying with Filters + +```ts +// Get all active REST API keys +const { data: activeKeys } = await apiServices.apiKeys.query({ + filters: { + type: 'rest', + expired: false, + }, +}); + +// Get API keys for specific users +const { data: userKeys } = await apiServices.apiKeys.query({ + filters: { + usernames: ['user1', 'user2'], + }, +}); +``` + +## Error Handling + +All methods return an `ApiResponse` with status information. Handle errors appropriately: + +```ts +try { + const { data, status } = await apiServices.apiKeys.create({ + name: 'my-key', + }); + + if (status === 200 || status === 201) { + console.log('API key created successfully'); + } +} catch (error) { + console.error('Failed to create API key:', error); +} +``` + +## Related Documentation + +- [Elasticsearch API Keys Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-create-api-key.html) +- [Kibana Security API](https://www.elastic.co/guide/en/kibana/current/api-keys.html) +- [Scout API Services](../../README.md) + diff --git a/src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/worker/apis/api_keys/index.ts b/src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/worker/apis/api_keys/index.ts new file mode 100644 index 0000000000000..2158c9bb0eee7 --- /dev/null +++ b/src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/worker/apis/api_keys/index.ts @@ -0,0 +1,355 @@ +/* + * 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 { KbnClient, ScoutLogger } from '../../../../../../common'; +import { measurePerformanceAsync } from '../../../../../../common'; +import type { + ApiResponse, + ApiStatusResponse, + CreateAPIKeyParams, + CreateApiKeyResponse, + InvalidateAPIKeyParams, + InvalidateAPIKeyResult, + QueryAPIKeyParams, + QueryAPIKeyResult, + UpdateAPIKeyParams, + UpdateAPIKeyResult, +} from './types'; + +export type { + ApiResponse, + ApiStatusResponse, + CreateAPIKeyParams, + CreateApiKeyResponse, + UpdateAPIKeyParams, + UpdateAPIKeyResult, + QueryAPIKeyParams, + QueryAPIKeyResult, + ApiKey, + ApiKeyToInvalidate, + InvalidateAPIKeyParams, + InvalidateAPIKeyResult, +} from './types'; + +/** + * API service for managing API keys in Kibana + */ +export interface ApiKeysApiService { + /** + * Create a new API key + * @param params - API key creation parameters + * @returns Created API key details + * @example + * ```ts + * const apiKey = await apiServices.apiKeys.create({ + * name: 'my-api-key', + * expiration: '7d', + * role_descriptors: { + * my_role: { + * cluster: ['all'], + * indices: [{ names: ['*'], privileges: ['read'] }] + * } + * } + * }); + * ``` + */ + create: (params: CreateAPIKeyParams) => Promise>; + + /** + * Update an existing API key's role descriptors or metadata + * @param params - API key update parameters + * @returns Update result + * @example + * ```ts + * const result = await apiServices.apiKeys.update({ + * id: 'key-id', + * role_descriptors: { updated_role: { cluster: ['monitor'] } }, + * metadata: { updated: true } + * }); + * ``` + */ + update: (params: UpdateAPIKeyParams) => Promise>; + + /** + * Query API keys with filtering and pagination + * @param params - Query parameters (optional) + * @returns Query results containing matching API keys + * @example + * ```ts + * const results = await apiServices.apiKeys.query({ + * filters: { type: 'rest', expired: false }, + * from: 0, + * size: 100 + * }); + * ``` + */ + query: (params?: QueryAPIKeyParams) => Promise>; + + /** + * Invalidate (delete) one or more API keys + * @param params - Invalidation parameters + * @returns Invalidation result with successful and failed operations + * @example + * ```ts + * const result = await apiServices.apiKeys.invalidate({ + * apiKeys: [{ id: 'key-1', name: 'test-key' }], + * isAdmin: false + * }); + * ``` + */ + invalidate: (params: InvalidateAPIKeyParams) => Promise>; + + /** + * Cleanup utilities for managing API keys in tests + */ + cleanup: { + /** + * Delete all API keys in the cluster + * @returns Status of the operation + * @example + * ```ts + * await apiServices.apiKeys.cleanup.deleteAll(); + * ``` + */ + deleteAll: () => Promise; + + /** + * Delete API keys by name pattern + * @param namePattern - Name or pattern to match (supports wildcards) + * @returns Status of the operation + * @example + * ```ts + * await apiServices.apiKeys.cleanup.deleteByName('test-*'); + * ``` + */ + deleteByName: (namePattern: string) => Promise; + + /** + * Delete API keys by IDs + * @param ids - Array of API key IDs to delete + * @returns Status of the operation + * @example + * ```ts + * await apiServices.apiKeys.cleanup.deleteByIds(['id1', 'id2']); + * ``` + */ + deleteByIds: (ids: string[]) => Promise; + }; +} + +/** + * Get the API Keys API helper + * @param log - Scout logger instance + * @param kbnClient - Kibana client instance + * @returns API Keys service + */ +export const getApiKeysApiHelper = (log: ScoutLogger, kbnClient: KbnClient): ApiKeysApiService => { + const apiKeysUrl = '/internal/security/api_key'; + + /** + * Helper to query all API keys + * @param params - Optional query parameters + * @returns Array of all API key IDs and names + */ + const queryAllApiKeys = async ( + params?: QueryAPIKeyParams + ): Promise> => { + const response = await kbnClient.request({ + method: 'POST', + path: `${apiKeysUrl}/_query`, + retries: 3, + body: { + ...params, + from: params?.from || 0, + size: params?.size || 10000, // Get a large number to ensure we get all keys + }, + }); + + const queryResult = response.data as QueryAPIKeyResult; + return queryResult.apiKeys.map((key) => ({ id: key.id, name: key.name })); + }; + + return { + create: async (params) => { + return await measurePerformanceAsync(log, `apiKeysApi.create [${params.name}]`, async () => { + const response = await kbnClient.request({ + method: 'POST', + path: apiKeysUrl, + retries: 3, + body: params, + }); + return { data: response.data as CreateApiKeyResponse, status: response.status }; + }); + }, + + update: async (params) => { + return await measurePerformanceAsync(log, `apiKeysApi.update [${params.id}]`, async () => { + const response = await kbnClient.request({ + method: 'PUT', + path: apiKeysUrl, + retries: 3, + body: params, + }); + return { data: response.data as UpdateAPIKeyResult, status: response.status }; + }); + }, + + query: async (params) => { + return await measurePerformanceAsync(log, 'apiKeysApi.query', async () => { + const response = await kbnClient.request({ + method: 'POST', + path: `${apiKeysUrl}/_query`, + retries: 3, + body: params || {}, + }); + return { data: response.data as QueryAPIKeyResult, status: response.status }; + }); + }, + + invalidate: async (params) => { + return await measurePerformanceAsync( + log, + `apiKeysApi.invalidate [${params.apiKeys.length} keys]`, + async () => { + const response = await kbnClient.request({ + method: 'POST', + path: `${apiKeysUrl}/invalidate`, + retries: 3, + body: params, + }); + return { data: response.data as InvalidateAPIKeyResult, status: response.status }; + } + ); + }, + + cleanup: { + deleteAll: async () => { + return await measurePerformanceAsync(log, 'apiKeysApi.cleanup.deleteAll', async () => { + const allKeys = await queryAllApiKeys(); + + if (allKeys.length === 0) { + log.debug('No API keys to delete'); + return { status: 200 }; + } + + const response = await kbnClient.request({ + method: 'POST', + path: `${apiKeysUrl}/invalidate`, + retries: 3, + body: { + apiKeys: allKeys, + isAdmin: true, + }, + ignoreErrors: [404], + }); + + return { status: response.status }; + }); + }, + + deleteByName: async (namePattern) => { + return await measurePerformanceAsync( + log, + `apiKeysApi.cleanup.deleteByName [${namePattern}]`, + async () => { + // Query for API keys matching the name pattern + const queryResponse = await kbnClient.request({ + method: 'POST', + path: `${apiKeysUrl}/_query`, + retries: 3, + body: { + query: { + wildcard: { + name: namePattern, + }, + }, + size: 10000, + }, + }); + + const queryResult = queryResponse.data as QueryAPIKeyResult; + const keysToDelete = queryResult.apiKeys.map((key) => ({ + id: key.id, + name: key.name, + })); + + if (keysToDelete.length === 0) { + log.debug(`No API keys found matching pattern: ${namePattern}`); + return { status: 200 }; + } + + const response = await kbnClient.request({ + method: 'POST', + path: `${apiKeysUrl}/invalidate`, + retries: 3, + body: { + apiKeys: keysToDelete, + isAdmin: true, + }, + ignoreErrors: [404], + }); + + return { status: response.status }; + } + ); + }, + + deleteByIds: async (ids) => { + return await measurePerformanceAsync( + log, + `apiKeysApi.cleanup.deleteByIds [${ids.length} keys]`, + async () => { + if (ids.length === 0) { + return { status: 200 }; + } + + // First, we need to get the names of these keys + const queryResponse = await kbnClient.request({ + method: 'POST', + path: `${apiKeysUrl}/_query`, + retries: 3, + body: { + query: { + ids: { + values: ids, + }, + }, + size: ids.length, + }, + }); + + const queryResult = queryResponse.data as QueryAPIKeyResult; + const keysToDelete = queryResult.apiKeys.map((key) => ({ + id: key.id, + name: key.name, + })); + + if (keysToDelete.length === 0) { + log.debug('No API keys found with the provided IDs'); + return { status: 200 }; + } + + const response = await kbnClient.request({ + method: 'POST', + path: `${apiKeysUrl}/invalidate`, + retries: 3, + body: { + apiKeys: keysToDelete, + isAdmin: true, + }, + ignoreErrors: [404], + }); + + return { status: response.status }; + } + ); + }, + }, + }; +}; diff --git a/src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/worker/apis/api_keys/types.ts b/src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/worker/apis/api_keys/types.ts new file mode 100644 index 0000000000000..adabf82df00fb --- /dev/null +++ b/src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/worker/apis/api_keys/types.ts @@ -0,0 +1,133 @@ +/* + * 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 { + SecurityApiKey, + SecurityCreateApiKeyRequest, + SecurityCreateApiKeyResponse, + SecurityCreateCrossClusterApiKeyRequest, + SecurityCreateCrossClusterApiKeyResponse, +} from '@elastic/elasticsearch/lib/api/types'; + +export interface ApiResponse { + data: T; + status: number; +} + +export interface ApiStatusResponse { + status: number; +} + +export type ApiKey = SecurityApiKey; +export type CreateApiKeyResponse = SecurityCreateApiKeyResponse; +export type CreateCrossClusterApiKeyResponse = SecurityCreateCrossClusterApiKeyResponse; + +/** + * Union type for all API key creation parameters + */ +export type CreateAPIKeyParams = + | SecurityCreateApiKeyRequest + | SecurityCreateCrossClusterApiKeyRequest; + +/** + * Parameters for updating an API key + */ +export interface UpdateAPIKeyParams { + /** ID of the API key to update */ + id: string; + /** Updated role descriptors */ + role_descriptors?: Record; + /** Updated metadata */ + metadata?: Record; +} + +/** + * Result of updating an API key + */ +export interface UpdateAPIKeyResult { + /** Whether the API key was updated */ + updated: boolean; +} + +/** + * Parameters for querying API keys + */ +export interface QueryAPIKeyParams { + /** Query object using Elasticsearch query DSL */ + query?: Record; + /** Starting position for pagination */ + from?: number; + /** Number of results to return */ + size?: number; + /** Sort configuration */ + sort?: Array>; + /** Filters to apply */ + filters?: { + /** Filter by usernames */ + usernames?: string[]; + /** Filter by API key type */ + type?: 'rest' | 'managed' | 'cross_cluster'; + /** Filter by expiration status */ + expired?: boolean; + }; + /** Whether to filter API keys the user can access */ + with_limited_by?: boolean; + /** Whether to include profile uid in results */ + with_profile_uid?: boolean; + /** Typed keys for aggregations */ + typed_keys?: boolean; +} + +/** + * Result of querying API keys + */ +export interface QueryAPIKeyResult { + /** Total number of API keys matching the query */ + total: number; + /** Number of API keys in this response */ + count: number; + /** Array of API keys */ + apiKeys: SecurityApiKey[]; + /** Aggregations (if requested) */ + aggregations?: Record; +} + +/** + * API key to invalidate + */ +export interface ApiKeyToInvalidate { + /** ID of the API key */ + id: string; + /** Name of the API key */ + name: string; +} + +/** + * Parameters for invalidating API keys + */ +export interface InvalidateAPIKeyParams { + /** Array of API keys to invalidate */ + apiKeys: ApiKeyToInvalidate[]; + /** Whether to invalidate as admin (allows invalidating other users' keys) */ + isAdmin?: boolean; +} + +/** + * Result of invalidating API keys + */ +export interface InvalidateAPIKeyResult { + /** API keys that were successfully invalidated */ + itemsInvalidated: ApiKeyToInvalidate[]; + /** Errors encountered during invalidation */ + errors: Array<{ + id: string; + name: string; + error: string; + }>; +} diff --git a/src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/worker/apis/index.ts b/src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/worker/apis/index.ts index 0deaae4abe71e..22d804587bc64 100644 --- a/src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/worker/apis/index.ts +++ b/src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/worker/apis/index.ts @@ -10,6 +10,8 @@ import { coreWorkerFixtures } from '../core_fixtures'; import type { AlertingApiService } from './alerting'; import { getAlertingApiHelper } from './alerting'; +import type { ApiKeysApiService } from './api_keys'; +import { getApiKeysApiHelper } from './api_keys'; import type { CasesApiService } from './cases'; import { getCasesApiHelper } from './cases'; import type { CoreApiService } from './core'; @@ -21,6 +23,7 @@ import { getStreamsApiService } from './streams'; export interface ApiServicesFixture { alerting: AlertingApiService; + apiKeys: ApiKeysApiService; cases: CasesApiService; fleet: FleetApiService; streams: StreamsApiService; @@ -39,6 +42,7 @@ export const apiServicesFixture = coreWorkerFixtures.extend< async ({ kbnClient, log }, use) => { const services = { alerting: getAlertingApiHelper(log, kbnClient), + apiKeys: getApiKeysApiHelper(log, kbnClient), cases: getCasesApiHelper(log, kbnClient), fleet: getFleetApiHelper(log, kbnClient), streams: getStreamsApiService({ kbnClient, log }), diff --git a/x-pack/platform/packages/shared/security/api_key_management/src/components/api_key_created_callout.tsx b/x-pack/platform/packages/shared/security/api_key_management/src/components/api_key_created_callout.tsx index daea08b51bf96..ee86318cb6719 100644 --- a/x-pack/platform/packages/shared/security/api_key_management/src/components/api_key_created_callout.tsx +++ b/x-pack/platform/packages/shared/security/api_key_management/src/components/api_key_created_callout.tsx @@ -31,7 +31,7 @@ export const ApiKeyCreatedCallout: FunctionComponent values: { name: createdApiKey.name }, })} > -

+

= ({ return ( - +

{ {canManageOwnApiKeys && !canManageApiKeys ? ( <> = ({ )} 0) { - await Promise.all( - existingKeys.api_keys.map(async (key) => { - await esClient.security.invalidateApiKey({ ids: [key.id] }); - }) - ); - } else { - logger.debug('No API keys to delete.'); - } -} diff --git a/x-pack/platform/test/functional/apps/api_keys/config.ts b/x-pack/platform/test/functional/apps/api_keys/config.ts deleted file mode 100644 index b35dc3ed669f5..0000000000000 --- a/x-pack/platform/test/functional/apps/api_keys/config.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * 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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { FtrConfigProviderContext } from '@kbn/test'; - -export default async function ({ readConfigFile }: FtrConfigProviderContext) { - const functionalConfig = await readConfigFile(require.resolve('../../config.base.ts')); - - return { - ...functionalConfig.getAll(), - testFiles: [require.resolve('.')], - }; -} diff --git a/x-pack/platform/test/functional/apps/api_keys/feature_controls/api_keys_security.ts b/x-pack/platform/test/functional/apps/api_keys/feature_controls/api_keys_security.ts deleted file mode 100644 index 880afd12ed354..0000000000000 --- a/x-pack/platform/test/functional/apps/api_keys/feature_controls/api_keys_security.ts +++ /dev/null @@ -1,73 +0,0 @@ -/* - * 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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import expect from '@kbn/expect'; -import type { FtrProviderContext } from '../../../ftr_provider_context'; - -export default function ({ getPageObjects, getService }: FtrProviderContext) { - const security = getService('security'); - const PageObjects = getPageObjects(['common', 'settings', 'security']); - const appsMenu = getService('appsMenu'); - const managementMenu = getService('managementMenu'); - - describe('security', function () { - this.tags('skipFIPS'); - before(async () => { - await PageObjects.common.navigateToApp('home'); - }); - - describe('global all privileges (aka kibana_admin)', () => { - before(async () => { - await security.testUser.setRoles(['kibana_admin']); - }); - after(async () => { - await security.testUser.restoreDefaults(); - }); - - it('should show the Stack Management nav link', async () => { - const links = await appsMenu.readLinks(); - expect(links.map((link) => link.text)).to.contain('Stack Management'); - }); - - it('should not render the "Security" section', async () => { - await PageObjects.common.navigateToApp('management'); - const sections = await managementMenu.getSections(); - - const sectionIds = sections.map((section) => section.sectionId); - expect(sectionIds).to.contain('data'); - expect(sectionIds).to.contain('insightsAndAlerting'); - expect(sectionIds).to.contain('kibana'); - - const dataSection = sections.find((section) => section.sectionId === 'data'); - expect(dataSection?.sectionLinks).to.eql(['data_quality', 'content_connectors']); - }); - }); - - describe('global dashboard read with manage_security', () => { - before(async () => { - await security.testUser.setRoles(['global_dashboard_read', 'manage_security']); - }); - after(async () => { - await security.testUser.restoreDefaults(); - }); - it('should show the Stack Management nav link', async () => { - const links = await appsMenu.readLinks(); - expect(links.map((link) => link.text)).to.contain('Stack Management'); - }); - - it('should render the "Security" section with API Keys', async () => { - await PageObjects.common.navigateToApp('management'); - const sections = await managementMenu.getSections(); - expect(sections).to.have.length(1); - expect(sections[0]).to.eql({ - sectionId: 'security', - sectionLinks: ['users', 'roles', 'api_keys', 'role_mappings'], - }); - }); - }); - }); -} diff --git a/x-pack/platform/test/functional/apps/api_keys/home_page.ts b/x-pack/platform/test/functional/apps/api_keys/home_page.ts deleted file mode 100644 index 2481d3620fbc4..0000000000000 --- a/x-pack/platform/test/functional/apps/api_keys/home_page.ts +++ /dev/null @@ -1,557 +0,0 @@ -/* - * 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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import expect from '@kbn/expect'; -import clearAllApiKeys from './api_keys_helpers'; -import type { FtrProviderContext } from '../../ftr_provider_context'; - -export default ({ getPageObjects, getService }: FtrProviderContext) => { - const es = getService('es'); - const pageObjects = getPageObjects(['common', 'apiKeys']); - const log = getService('log'); - const security = getService('security'); - const testSubjects = getService('testSubjects'); - const find = getService('find'); - const browser = getService('browser'); - const retry = getService('retry'); - - const testRoles: Record = { - viewer: { - cluster: ['all'], - indices: [ - { - names: ['*'], - privileges: ['all'], - allow_restricted_indices: false, - }, - { - names: ['*'], - privileges: ['monitor', 'read', 'view_index_metadata', 'read_cross_cluster'], - allow_restricted_indices: true, - }, - ], - run_as: ['*'], - }, - }; - - async function ensureApiKeysExist(apiKeysNames: string[]) { - await retry.try(async () => { - for (const apiKeyName of apiKeysNames) { - log.debug(`Checking if API key ("${apiKeyName}") exists.`); - await pageObjects.apiKeys.ensureApiKeyExists(apiKeyName); - log.debug(`API key ("${apiKeyName}") exists.`); - } - }); - } - - describe('Home page', function () { - before(async () => { - await clearAllApiKeys(es, log); - await security.testUser.setRoles(['kibana_admin']); - await pageObjects.common.navigateToApp('apiKeys'); - }); - - after(async () => { - await security.testUser.restoreDefaults(); - }); - - // https://www.elastic.co/guide/en/kibana/7.6/api-keys.html#api-keys-security-privileges - it('Hides management link if user is not authorized', async () => { - await testSubjects.missingOrFail('apiKeys'); - }); - - it('Loads the app', async () => { - await security.testUser.setRoles(['test_api_keys']); - log.debug('Checking for Create API key call to action'); - await find.existsByLinkText('Create API key'); - }); - - describe('creates API key', function () { - before(async () => { - await security.testUser.setRoles(['kibana_admin', 'test_api_keys']); - await pageObjects.common.navigateToApp('apiKeys'); - - // Delete any API keys created outside of these tests - await pageObjects.apiKeys.bulkDeleteApiKeys(); - }); - - afterEach(async () => { - await pageObjects.apiKeys.deleteAllApiKeyOneByOne(); - }); - - after(async () => { - await clearAllApiKeys(es, log); - }); - - it('when submitting form, close dialog and displays new api key', async () => { - const apiKeyName = 'Happy API Key'; - await pageObjects.apiKeys.clickOnPromptCreateApiKey(); - expect(await browser.getCurrentUrl()).to.contain('app/management/security/api_keys/create'); - expect(await pageObjects.apiKeys.getFlyoutTitleText()).to.be('Create API key'); - - await pageObjects.apiKeys.setApiKeyName(apiKeyName); - await pageObjects.apiKeys.clickSubmitButtonOnApiKeyFlyout(); - const newApiKeyCreation = await pageObjects.apiKeys.getNewApiKeyCreation(); - - expect(await browser.getCurrentUrl()).to.not.contain( - 'app/management/security/api_keys/flyout' - ); - expect(await browser.getCurrentUrl()).to.contain('app/management/security/api_keys'); - expect(await pageObjects.apiKeys.isApiKeyModalExists()).to.be(false); - expect(newApiKeyCreation).to.be(`Created API key '${apiKeyName}'`); - }); - - it('with optional expiration, redirects back and displays base64', async () => { - const apiKeyName = 'Happy expiration API key'; - await pageObjects.apiKeys.clickOnPromptCreateApiKey(); - expect(await browser.getCurrentUrl()).to.contain('app/management/security/api_keys/create'); - - await pageObjects.apiKeys.setApiKeyName(apiKeyName); - await pageObjects.apiKeys.toggleCustomExpiration(); - await pageObjects.apiKeys.setApiKeyCustomExpiration('12'); - await pageObjects.apiKeys.clickSubmitButtonOnApiKeyFlyout(); - const newApiKeyCreation = await pageObjects.apiKeys.getNewApiKeyCreation(); - - expect(await browser.getCurrentUrl()).to.not.contain( - 'app/management/security/api_keys/create' - ); - expect(await browser.getCurrentUrl()).to.contain('app/management/security/api_keys'); - expect(await pageObjects.apiKeys.isApiKeyModalExists()).to.be(false); - expect(newApiKeyCreation).to.be(`Created API key '${apiKeyName}'`); - }); - }); - - describe('Update API key', function () { - before(async () => { - await security.testUser.setRoles(['kibana_admin', 'test_api_keys']); - await pageObjects.common.navigateToApp('apiKeys'); - - // Delete any API keys created outside these tests - await pageObjects.apiKeys.bulkDeleteApiKeys(); - }); - - afterEach(async () => { - await pageObjects.apiKeys.deleteAllApiKeyOneByOne(); - }); - - after(async () => { - await clearAllApiKeys(es, log); - }); - - it('should create a new API key, click the name of the new row, fill out and submit form, and display success message', async () => { - // Create a key to updated - const apiKeyName = 'Happy API Key to Update'; - - await es.security.grantApiKey({ - api_key: { - name: apiKeyName, - expiration: '1d', - }, - grant_type: 'password', - run_as: 'test_user', - username: 'elastic', - password: 'changeme', - }); - - await browser.refresh(); - - log.debug('API key created, moving on to update'); - - // Update newly created API Key - await pageObjects.apiKeys.clickExistingApiKeyToOpenFlyout(apiKeyName); - - expect(await browser.getCurrentUrl()).to.contain('app/management/security/api_keys'); - - await pageObjects.apiKeys.waitForSubmitButtonOnApiKeyFlyoutEnabled(); - - expect(await pageObjects.apiKeys.getFlyoutTitleText()).to.be('Update API key'); - - // Verify name input box is not present - expect(await pageObjects.apiKeys.isApiKeyNamePresent()).to.be(false); - - // Status should be displayed - const apiKeyStatus = await pageObjects.apiKeys.getFlyoutApiKeyStatus(); - expect(await apiKeyStatus).to.be('Expires in a day'); - - // Verify metadata is editable - const apiKeyMetadataSwitch = await pageObjects.apiKeys.getMetadataSwitch(); - expect(await apiKeyMetadataSwitch.isEnabled()).to.be(true); - - // Verify restrict privileges is editable - const apiKeyRestrictPrivilegesSwitch = - await pageObjects.apiKeys.getRestrictPrivilegesSwitch(); - expect(await apiKeyRestrictPrivilegesSwitch.isEnabled()).to.be(true); - - // Toggle restrict privileges so the code editor shows up - await apiKeyRestrictPrivilegesSwitch.click(); - - // Toggle metadata switch so the code editor shows up - await apiKeyMetadataSwitch.click(); - - // wait for monaco editor model to be updated - await pageObjects.common.sleep(300); - - // Check default value of restrict privileges and set value - const restrictPrivilegesCodeEditorValue = - await pageObjects.apiKeys.getCodeEditorValueByIndex(0); - expect(restrictPrivilegesCodeEditorValue).to.be('{}'); - - // Check default value of metadata and set value - const metadataCodeEditorValue = await pageObjects.apiKeys.getCodeEditorValueByIndex(1); - expect(metadataCodeEditorValue).to.be('{}'); - - await pageObjects.apiKeys.setCodeEditorValueByIndex(0, JSON.stringify(testRoles)); - - await pageObjects.apiKeys.setCodeEditorValueByIndex(1, '{"name":"metadataTest"}'); - - // Submit values to update API key - await pageObjects.apiKeys.clickSubmitButtonOnApiKeyFlyout(); - - // Get success message - const updatedApiKeyToastText = await pageObjects.apiKeys.getApiKeyUpdateSuccessToast(); - expect(updatedApiKeyToastText).to.be(`Updated API key '${apiKeyName}'`); - - expect(await browser.getCurrentUrl()).to.contain('app/management/security/api_keys'); - expect(await pageObjects.apiKeys.isApiKeyModalExists()).to.be(false); - }); - }); - - describe('Readonly API key', function () { - this.tags('skipFIPS'); - before(async () => { - await security.role.create('read_security_role', { - elasticsearch: { - cluster: ['read_security'], - }, - kibana: [ - { - feature: { - infrastructure: ['read'], - }, - spaces: ['*'], - }, - ], - }); - - await security.testUser.setRoles(['kibana_admin', 'test_api_keys']); - await pageObjects.common.navigateToApp('apiKeys'); - - // Delete any API keys created outside these tests - await pageObjects.apiKeys.bulkDeleteApiKeys(); - }); - - afterEach(async () => { - await pageObjects.apiKeys.deleteAllApiKeyOneByOne(); - }); - - after(async () => { - await clearAllApiKeys(es, log); - }); - - it('should see readonly form elements', async () => { - // Create a key to updated - const apiKeyName = 'Happy API Key to View'; - - await es.security.grantApiKey({ - api_key: { - name: apiKeyName, - expiration: '1d', - metadata: { name: 'metadatatest' }, - role_descriptors: { ...testRoles }, - }, - grant_type: 'password', - run_as: 'test_user', - username: 'elastic', - password: 'changeme', - }); - - await browser.refresh(); - - log.debug('API key created, moving on to view'); - - // Set testUsers roles to have the `read_security` cluster privilege - await security.testUser.setRoles(['read_security_role']); - - // View newly created API Key - await pageObjects.apiKeys.clickExistingApiKeyToOpenFlyout(apiKeyName); - expect(await browser.getCurrentUrl()).to.contain('app/management/security/api_keys'); - expect(await pageObjects.apiKeys.getFlyoutTitleText()).to.be('API key details'); - - // Verify name input box is not present - expect(await pageObjects.apiKeys.isApiKeyNamePresent()).to.be(false); - - // Status should be displayed - const apiKeyStatus = await pageObjects.apiKeys.getFlyoutApiKeyStatus(); - expect(await apiKeyStatus).to.be('Expires in a day'); - - const apiKeyMetadataSwitch = await pageObjects.apiKeys.getMetadataSwitch(); - const apiKeyRestrictPrivilegesSwitch = - await pageObjects.apiKeys.getRestrictPrivilegesSwitch(); - - // Verify metadata and restrict privileges switches are now disabled - expect(await apiKeyMetadataSwitch.isEnabled()).to.be(false); - expect(await apiKeyRestrictPrivilegesSwitch.isEnabled()).to.be(false); - - // Close flyout with cancel - await pageObjects.apiKeys.clickCancelButtonOnApiKeyFlyout(); - - // Undo `read_security_role` - await security.testUser.setRoles(['kibana_admin', 'test_api_keys']); - }); - - it('should show the `API key details` flyout if the expiration date is passed', async () => { - const apiKeyName = 'expired-key'; - - await es.security.grantApiKey({ - api_key: { - name: apiKeyName, - expiration: '1ms', - }, - grant_type: 'password', - run_as: 'test_user', - username: 'elastic', - password: 'changeme', - }); - - await browser.refresh(); - - log.debug('API key created, moving on to view'); - - await pageObjects.apiKeys.clickExistingApiKeyToOpenFlyout(apiKeyName); - - expect(await browser.getCurrentUrl()).to.contain('app/management/security/api_keys'); - expect(await pageObjects.apiKeys.getFlyoutTitleText()).to.be('API key details'); - - // Verify name input box is not present - expect(await pageObjects.apiKeys.isApiKeyNamePresent()).to.be(false); - - // Status should be displayed - const apiKeyStatus = await pageObjects.apiKeys.getFlyoutApiKeyStatus(); - expect(await apiKeyStatus).to.be('Expired'); - - const apiKeyMetadataSwitch = await pageObjects.apiKeys.getMetadataSwitch(); - const apiKeyRestrictPrivilegesSwitch = - await pageObjects.apiKeys.getRestrictPrivilegesSwitch(); - - // Verify metadata and restrict privileges switches are now disabled - expect(await apiKeyMetadataSwitch.isEnabled()).to.be(false); - expect(await apiKeyRestrictPrivilegesSwitch.isEnabled()).to.be(false); - - await pageObjects.apiKeys.clickCancelButtonOnApiKeyFlyout(); - }); - - it('should show the `API key details flyout` if the API key does not belong to the user', async () => { - const apiKeyName = 'other-key'; - - await es.security.grantApiKey({ - api_key: { - name: apiKeyName, - }, - grant_type: 'password', - run_as: 'elastic', - username: 'elastic', - password: 'changeme', - }); - - await browser.refresh(); - - log.debug('API key created, moving on to view'); - - await pageObjects.apiKeys.clickExistingApiKeyToOpenFlyout(apiKeyName); - - expect(await browser.getCurrentUrl()).to.contain('app/management/security/api_keys'); - expect(await pageObjects.apiKeys.getFlyoutTitleText()).to.be('API key details'); - - // Verify name input box is not present - expect(await pageObjects.apiKeys.isApiKeyNamePresent()).to.be(false); - - // Status should be displayed - const apiKeyStatus = await pageObjects.apiKeys.getFlyoutApiKeyStatus(); - expect(await apiKeyStatus).to.be('Active'); - - const apiKeyMetadataSwitch = await pageObjects.apiKeys.getMetadataSwitch(); - const apiKeyRestrictPrivilegesSwitch = - await pageObjects.apiKeys.getRestrictPrivilegesSwitch(); - - // Verify metadata and restrict privileges switches are now disabled - expect(await apiKeyMetadataSwitch.isEnabled()).to.be(false); - expect(await apiKeyRestrictPrivilegesSwitch.isEnabled()).to.be(false); - - await pageObjects.apiKeys.clickCancelButtonOnApiKeyFlyout(); - }); - }); - - describe('deletes API key(s)', function () { - before(async () => { - await security.testUser.setRoles(['kibana_admin', 'test_api_keys']); - await pageObjects.common.navigateToApp('apiKeys'); - }); - - beforeEach(async () => { - await pageObjects.apiKeys.clickOnPromptCreateApiKey(); - await pageObjects.apiKeys.setApiKeyName('api key 1'); - await pageObjects.apiKeys.clickSubmitButtonOnApiKeyFlyout(); - await ensureApiKeysExist(['api key 1']); - }); - - it('one by one', async () => { - await pageObjects.apiKeys.deleteAllApiKeyOneByOne(); - expect(await pageObjects.apiKeys.getApiKeysFirstPromptTitle()).to.be( - 'Create your first API key' - ); - }); - - it('by bulk', async () => { - await pageObjects.apiKeys.clickOnTableCreateApiKey(); - await pageObjects.apiKeys.setApiKeyName('api key 2'); - await pageObjects.apiKeys.clickSubmitButtonOnApiKeyFlyout(); - - // Make sure all API keys we want to delete are created and rendered. - await ensureApiKeysExist(['api key 1', 'api key 2']); - - await pageObjects.apiKeys.bulkDeleteApiKeys(); - expect(await pageObjects.apiKeys.getApiKeysFirstPromptTitle()).to.be( - 'Create your first API key' - ); - }); - }); - - describe('querying API keys', function () { - before(async () => { - await clearAllApiKeys(es, log); - await security.testUser.setRoles(['kibana_admin', 'test_api_keys']); - - await es.transport.request({ - method: 'POST', - path: '/_security/cross_cluster/api_key', - body: { - name: 'test_cross_cluster', - expiration: '1d', - access: { - search: [ - { - names: ['*'], - }, - ], - replication: [ - { - names: ['*'], - }, - ], - }, - }, - }); - - await es.security.createApiKey({ - name: 'my api key', - expiration: '1d', - role_descriptors: { - role_1: {}, - }, - metadata: { - managed: true, - }, - }); - - await es.security.createApiKey({ - name: 'Alerting: Managed', - expiration: '1d', - role_descriptors: { - role_1: {}, - }, - }); - - await es.security.createApiKey({ - name: 'test_api_key', - expiration: '1s', - role_descriptors: { - role_1: {}, - }, - }); - - await es.security.grantApiKey({ - api_key: { - name: 'test_user_api_key', - expiration: '1d', - }, - grant_type: 'password', - run_as: 'test_user', - username: 'elastic', - password: 'changeme', - }); - }); - - after(async () => { - await security.testUser.restoreDefaults(); - await clearAllApiKeys(es, log); - }); - - beforeEach(async () => { - await pageObjects.common.navigateToApp('apiKeys'); - }); - - it('active/expired filter buttons work as expected', async () => { - await pageObjects.apiKeys.clickExpiryFilters('active'); - await ensureApiKeysExist(['my api key', 'Alerting: Managed', 'test_cross_cluster']); - expect(await pageObjects.apiKeys.doesApiKeyExist('test_api_key')).to.be(false); - - await pageObjects.apiKeys.clickExpiryFilters('expired'); - await ensureApiKeysExist(['test_api_key']); - expect(await pageObjects.apiKeys.doesApiKeyExist('my api key')).to.be(false); - - // reset filter buttons - await pageObjects.apiKeys.clickExpiryFilters('expired'); - }); - - it('api key type filter buttons work as expected', async () => { - await pageObjects.apiKeys.clickTypeFilters('personal'); - - await ensureApiKeysExist(['test_api_key']); - - await pageObjects.apiKeys.clickTypeFilters('cross_cluster'); - - await ensureApiKeysExist(['test_cross_cluster']); - - await pageObjects.apiKeys.clickTypeFilters('managed'); - - await ensureApiKeysExist(['my api key', 'Alerting: Managed']); - - // reset filters by simulate clicking the managed filter button again - await pageObjects.apiKeys.clickTypeFilters('managed'); - }); - - it('username filter buttons work as expected', async () => { - await pageObjects.apiKeys.clickUserNameDropdown(); - expect( - await testSubjects.exists('userProfileSelectableOption-system_indices_superuser') - ).to.be(true); - expect(await testSubjects.exists('userProfileSelectableOption-test_user')).to.be(true); - - await testSubjects.click('userProfileSelectableOption-test_user'); - - await ensureApiKeysExist(['test_user_api_key']); - await testSubjects.click('userProfileSelectableOption-test_user'); - - await testSubjects.click('userProfileSelectableOption-system_indices_superuser'); - - await ensureApiKeysExist(['my api key', 'Alerting: Managed', 'test_cross_cluster']); - }); - - it('search bar works as expected', async () => { - await pageObjects.apiKeys.setSearchBarValue('test_user_api_key'); - - await ensureApiKeysExist(['test_user_api_key']); - - await pageObjects.apiKeys.setSearchBarValue('"my api key"'); - await ensureApiKeysExist(['my api key']); - - await pageObjects.apiKeys.setSearchBarValue('"api"'); - await ensureApiKeysExist(['my api key']); - }); - }); - }); -}; diff --git a/x-pack/platform/test/functional/apps/api_keys/index.ts b/x-pack/platform/test/functional/apps/api_keys/index.ts deleted file mode 100644 index 513f9a710a8d6..0000000000000 --- a/x-pack/platform/test/functional/apps/api_keys/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* - * 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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { FtrProviderContext } from '../../ftr_provider_context'; - -export default ({ loadTestFile }: FtrProviderContext) => { - describe('API Keys app', function () { - loadTestFile(require.resolve('./home_page')); - loadTestFile(require.resolve('./feature_controls')); - }); -}; diff --git a/x-pack/platform/test/functional/apps/api_keys/scout/ui/fixtures/index.ts b/x-pack/platform/test/functional/apps/api_keys/scout/ui/fixtures/index.ts new file mode 100644 index 0000000000000..bd1d7aaa85fab --- /dev/null +++ b/x-pack/platform/test/functional/apps/api_keys/scout/ui/fixtures/index.ts @@ -0,0 +1,30 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { test as base } from '@kbn/scout'; +import type { ScoutPage, ScoutTestFixtures, ScoutWorkerFixtures } from '@kbn/scout'; +import { extendPageObjects, type ApiKeysPageObjects } from './page_objects'; + +export interface ApiKeysTestFixtures extends ScoutTestFixtures { + pageObjects: ApiKeysPageObjects; +} + +export const test = base.extend({ + pageObjects: async ( + { + pageObjects, + page, + }: { + pageObjects: ApiKeysPageObjects; + page: ScoutPage; + }, + use: (pageObjects: ApiKeysPageObjects) => Promise + ) => { + const extendedPageObjects = extendPageObjects(pageObjects, page); + await use(extendedPageObjects); + }, +}); diff --git a/x-pack/platform/test/functional/apps/api_keys/scout/ui/fixtures/page_objects/api_keys_page.ts b/x-pack/platform/test/functional/apps/api_keys/scout/ui/fixtures/page_objects/api_keys_page.ts new file mode 100644 index 0000000000000..5c773fda3738e --- /dev/null +++ b/x-pack/platform/test/functional/apps/api_keys/scout/ui/fixtures/page_objects/api_keys_page.ts @@ -0,0 +1,394 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ScoutPage } from '@kbn/scout'; +export class ApiKeysPage { + // Element locators + readonly noApiKeysHeader; + readonly apiKeyAdminDescriptionCallOut; + readonly goToConsoleButton; + readonly apiKeysPermissionDeniedMessage; + readonly apiKeysCreatePromptButton; + readonly apiKeysCreateTableButton; + readonly apiKeyNameInput; + readonly apiKeyCustomExpirationInput; + readonly apiKeyCustomExpirationSwitch; + readonly formFlyoutSubmitButton; + readonly formFlyoutCancelButton; + readonly checkboxSelectAll; + readonly bulkInvalidateActionButton; + readonly confirmModalConfirmButton; + readonly apiKeysMetadataSwitch; + readonly apiKeysRoleDescriptorsSwitch; + readonly apiKeyStatus; + readonly updateApiKeySuccessToast; + readonly activeFilterButton; + readonly expiredFilterButton; + readonly personalFilterButton; + readonly managedFilterButton; + readonly crossClusterFilterButton; + readonly ownerFilterButton; + readonly apiKeysSearchBar; + readonly apiKeysTable; + + constructor(private readonly page: ScoutPage) { + // Initialize all locators in the constructor + this.noApiKeysHeader = this.page.testSubj.locator('noApiKeysHeader'); + this.apiKeyAdminDescriptionCallOut = this.page.testSubj.locator( + 'apiKeyAdminDescriptionCallOut' + ); + this.goToConsoleButton = this.page.testSubj.locator('goToConsoleButton'); + this.apiKeysPermissionDeniedMessage = this.page.testSubj.locator( + 'apiKeysPermissionDeniedMessage' + ); + this.apiKeysCreatePromptButton = this.page.testSubj.locator('apiKeysCreatePromptButton'); + this.apiKeysCreateTableButton = this.page.testSubj.locator('apiKeysCreateTableButton'); + this.apiKeyNameInput = this.page.testSubj.locator('apiKeyNameInput'); + this.apiKeyCustomExpirationInput = this.page.testSubj.locator('apiKeyCustomExpirationInput'); + this.apiKeyCustomExpirationSwitch = this.page.testSubj.locator('apiKeyCustomExpirationSwitch'); + this.formFlyoutSubmitButton = this.page.testSubj.locator('formFlyoutSubmitButton'); + this.formFlyoutCancelButton = this.page.testSubj.locator('formFlyoutCancelButton'); + this.checkboxSelectAll = this.page.testSubj.locator('checkboxSelectAll'); + this.bulkInvalidateActionButton = this.page.testSubj.locator('bulkInvalidateActionButton'); + this.confirmModalConfirmButton = this.page.testSubj.locator('confirmModalConfirmButton'); + this.apiKeysMetadataSwitch = this.page.testSubj.locator('apiKeysMetadataSwitch'); + this.apiKeysRoleDescriptorsSwitch = this.page.testSubj.locator('apiKeysRoleDescriptorsSwitch'); + this.apiKeyStatus = this.page.testSubj + .locator('apiKeyFlyout') + .locator('[data-test-subj="apiKeyStatus"]'); + this.updateApiKeySuccessToast = this.page.testSubj.locator('updateApiKeySuccessToast'); + this.activeFilterButton = this.page.testSubj.locator('activeFilterButton'); + this.expiredFilterButton = this.page.testSubj.locator('expiredFilterButton'); + this.personalFilterButton = this.page.testSubj.locator('personalFilterButton'); + this.managedFilterButton = this.page.testSubj.locator('managedFilterButton'); + this.crossClusterFilterButton = this.page.testSubj.locator('crossClusterFilterButton'); + this.ownerFilterButton = this.page.testSubj.locator('ownerFilterButton'); + this.apiKeysSearchBar = this.page.testSubj.locator('apiKeysSearchBar'); + this.apiKeysTable = this.page.testSubj.locator('apiKeysTable'); + } + + /** + * Navigate to the API Keys management page + */ + async goto() { + await this.page.gotoApp('management/security/api_keys'); + } + + /** + * Get the text from the "no API keys" header + */ + async noAPIKeysHeading() { + return await this.noApiKeysHeader.innerText(); + } + + /** + * Get the API key admin description text + */ + async getApiKeyAdminDesc() { + return await this.apiKeyAdminDescriptionCallOut.innerText(); + } + + /** + * Get the "Go to Console" button element + */ + async getGoToConsoleButton() { + return this.goToConsoleButton; + } + + /** + * Get the permission denied message text + */ + async getApiKeysPermissionDeniedMessage() { + return await this.apiKeysPermissionDeniedMessage.innerText(); + } + + /** + * Click the "Create API key" button on the empty prompt page + */ + async clickOnPromptCreateApiKey() { + await this.apiKeysCreatePromptButton.click(); + } + + /** + * Click the "Create API key" button on the table view + */ + async clickOnTableCreateApiKey() { + await this.apiKeysCreateTableButton.click(); + } + + /** + * Set the API key name in the input field + */ + async setApiKeyName(apiKeyName: string) { + await this.apiKeyNameInput.fill(apiKeyName); + } + + /** + * Get the API key name input element + */ + async getApiKeyName() { + return this.apiKeyNameInput; + } + + /** + * Check if the API key name input is present + */ + async isApiKeyNamePresent() { + return await this.apiKeyNameInput.isVisible({ timeout: 500 }).catch(() => false); + } + + /** + * Set a custom expiration time for the API key + */ + async setApiKeyCustomExpiration(expirationTime: string) { + await this.apiKeyCustomExpirationInput.fill(expirationTime); + } + + /** + * Toggle the custom expiration switch + */ + async toggleCustomExpiration() { + await this.apiKeyCustomExpirationSwitch.click(); + } + + /** + * Click the submit button on the API key flyout + */ + async clickSubmitButtonOnApiKeyFlyout() { + await this.formFlyoutSubmitButton.click(); + } + + /** + * Click the cancel button on the API key flyout + */ + async clickCancelButtonOnApiKeyFlyout() { + await this.formFlyoutCancelButton.click(); + } + + /** + * Check if the API key modal/flyout exists + */ + async isApiKeyModalExists() { + return await this.page + .locator('.euiFlyoutHeader') + .isVisible() + .catch(() => false); + } + + /** + * Get the new API key creation message + */ + async getNewApiKeyCreation() { + await this.page.testSubj + .locator('apiKeyCreatedCalloutSuccessDescription') + .waitFor({ state: 'visible', timeout: 10000 }); + const euiCallOutHeader = this.page.locator('.euiCallOutHeader__title'); + return await euiCallOutHeader.innerText(); + } + + /** + * Check if we're on the prompt page (no API keys exist) + */ + async isPromptPage() { + return await this.apiKeysCreatePromptButton.isVisible().catch(() => false); + } + + /** + * Get the title text from the first prompt + */ + async getApiKeysFirstPromptTitle() { + const titlePromptElem = this.page.locator('.euiEmptyPrompt .euiTitle'); + return await titlePromptElem.innerText(); + } + + /** + * Delete a specific API key by name + */ + async deleteApiKeyByName(apiKeyName: string) { + await this.page.testSubj.locator(`apiKeysTableDeleteAction-${apiKeyName}`).click(); + await this.confirmModalConfirmButton.click(); + await this.page.testSubj.locator(`apiKeyRowName-${apiKeyName}`).waitFor({ state: 'detached' }); + } + + /** + * Delete all API keys one by one + */ + async deleteAllApiKeyOneByOne() { + const deleteButtons = this.page.testSubj.locator('^apiKeysTableDeleteAction'); + const count = await deleteButtons.count(); + + if (count > 0) { + for (let i = 0; i < count; i++) { + const currentCount = await deleteButtons.count(); + await deleteButtons.first().click(); + await this.confirmModalConfirmButton.click(); + + // Wait for the delete button count to decrease by polling + const startTime = Date.now(); + const timeout = 30000; // 30 second timeout + while ((await deleteButtons.count()) >= currentCount) { + if (Date.now() - startTime > timeout) { + throw new Error( + `Timeout waiting for API key to be deleted. Expected count to be less than ${currentCount}` + ); + } + await this.page.waitForTimeout(100); + } + } + } + } + + /** + * Bulk delete all API keys using the select all checkbox + */ + async bulkDeleteApiKeys() { + const hasApiKeysToDelete = await this.checkboxSelectAll.isVisible().catch(() => false); + + if (hasApiKeysToDelete) { + await this.checkboxSelectAll.click(); + await this.bulkInvalidateActionButton.click(); + await this.confirmModalConfirmButton.click(); + } + } + + /** + * Click on an existing API key row to open the flyout + */ + async clickExistingApiKeyToOpenFlyout(apiKeyName: string) { + await this.page.testSubj.locator(`apiKeyRowName-${apiKeyName}`).click(); + } + + /** + * Ensure that an API key exists (will fail if not found) + */ + async ensureApiKeyExists(apiKeyName: string) { + await this.page.testSubj.locator(`apiKeyRowName-${apiKeyName}`).waitFor({ state: 'visible' }); + } + + /** + * Ensure that an API key does not exist (will fail if found) + */ + async ensureApiKeyDoesNotExist(apiKeyName: string) { + await this.page.testSubj.locator(`apiKeyRowName-${apiKeyName}`).waitFor({ state: 'detached' }); + } + + /** + * Check if an API key exists + */ + async doesApiKeyExist(apiKeyName: string) { + return await this.page.testSubj + .locator(`apiKeyRowName-${apiKeyName}`) + .isVisible() + .catch(() => false); + } + + /** + * Get the metadata switch element + */ + async getMetadataSwitch() { + return this.apiKeysMetadataSwitch; + } + + /** + * Get the code editor value by index (0-based) + */ + async getCodeEditorValueByIndex(index: number) { + // Monaco editor interaction - we need to get the text content from the editor + const editors = this.page.locator('.monaco-editor'); + const editor = editors.nth(index); + await editor.waitFor({ state: 'visible' }); + + // Get the text from the editor's model + const textContent = await editor.locator('.view-lines').innerText(); + return textContent; + } + + /** + * Set the code editor value by index (0-based) + */ + async setCodeEditorValueByIndex(index: number, data: string) { + // Monaco editor interaction - we need to set the value + const editors = this.page.locator('.monaco-editor'); + const editor = editors.nth(index); + await editor.waitFor({ state: 'visible' }); + + // Focus the editor and select all, then type the new value + await editor.click(); + await this.page.waitForTimeout(100); + // Use keyboard shortcut to select all content (Cmd+A on Mac, Ctrl+A on others) + await this.page.keyboard.press('ControlOrMeta+A'); + await this.page.keyboard.press('Delete'); + // Type the new value (this will replace the selected content) + await this.page.keyboard.type(data); + } + + /** + * Get the restrict privileges switch element + */ + async getRestrictPrivilegesSwitch() { + return this.apiKeysRoleDescriptorsSwitch; + } + + /** + * Get the flyout title text + */ + async getFlyoutTitleText() { + const header = this.page.locator('.euiFlyoutHeader'); + return await header.innerText(); + } + + /** + * Get the API key status text from the flyout + */ + async getFlyoutApiKeyStatus() { + return await this.apiKeyStatus.innerText(); + } + + /** + * Get the API key update success toast + */ + async getApiKeyUpdateSuccessToast() { + await this.updateApiKeySuccessToast.waitFor({ state: 'visible' }); + return this.updateApiKeySuccessToast; + } + + /** + * Click on expiry filter buttons (active or expired) + */ + async clickExpiryFilters(type: 'active' | 'expired') { + const button = type === 'active' ? this.activeFilterButton : this.expiredFilterButton; + await button.click(); + } + + /** + * Click on type filter buttons (personal, managed, or cross_cluster) + */ + async clickTypeFilters(type: 'personal' | 'managed' | 'cross_cluster') { + const buttonMap = { + personal: this.personalFilterButton, + managed: this.managedFilterButton, + cross_cluster: this.crossClusterFilterButton, + }; + + await buttonMap[type].click(); + } + + /** + * Click the username dropdown filter button + */ + async clickUserNameDropdown() { + await this.ownerFilterButton.click(); + } + + /** + * Set the search bar value + */ + async setSearchBarValue(query: string) { + await this.apiKeysSearchBar.clear(); + await this.apiKeysSearchBar.fill(query); + } +} diff --git a/x-pack/platform/test/functional/apps/api_keys/scout/ui/fixtures/page_objects/index.ts b/x-pack/platform/test/functional/apps/api_keys/scout/ui/fixtures/page_objects/index.ts new file mode 100644 index 0000000000000..5c2add19d3cb4 --- /dev/null +++ b/x-pack/platform/test/functional/apps/api_keys/scout/ui/fixtures/page_objects/index.ts @@ -0,0 +1,21 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { PageObjects, ScoutPage } from '@kbn/scout'; +import { createLazyPageObject } from '@kbn/scout'; +import { ApiKeysPage } from './api_keys_page'; + +export interface ApiKeysPageObjects extends PageObjects { + apiKeys: ApiKeysPage; +} + +export function extendPageObjects(pageObjects: PageObjects, page: ScoutPage): ApiKeysPageObjects { + return { + ...pageObjects, + apiKeys: createLazyPageObject(ApiKeysPage, page), + }; +} diff --git a/x-pack/platform/test/functional/apps/api_keys/feature_controls/index.ts b/x-pack/platform/test/functional/apps/api_keys/scout/ui/playwright.config.ts similarity index 50% rename from x-pack/platform/test/functional/apps/api_keys/feature_controls/index.ts rename to x-pack/platform/test/functional/apps/api_keys/scout/ui/playwright.config.ts index dd834d1b339d0..75a7694d12043 100644 --- a/x-pack/platform/test/functional/apps/api_keys/feature_controls/index.ts +++ b/x-pack/platform/test/functional/apps/api_keys/scout/ui/playwright.config.ts @@ -5,10 +5,8 @@ * 2.0. */ -import type { FtrProviderContext } from '../../../ftr_provider_context'; +import { createPlaywrightConfig } from '@kbn/scout'; -export default function ({ loadTestFile }: FtrProviderContext) { - describe('feature controls', function () { - loadTestFile(require.resolve('./api_keys_security')); - }); -} +export default createPlaywrightConfig({ + testDir: './tests', +}); diff --git a/x-pack/platform/test/functional/apps/api_keys/scout/ui/tests/api_keys_security.spec.ts b/x-pack/platform/test/functional/apps/api_keys/scout/ui/tests/api_keys_security.spec.ts new file mode 100644 index 0000000000000..b398651d377b3 --- /dev/null +++ b/x-pack/platform/test/functional/apps/api_keys/scout/ui/tests/api_keys_security.spec.ts @@ -0,0 +1,151 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { expect, test, tags } from '@kbn/scout'; + +// Helper function to get management menu sections from the UI +async function getManagementSections(page: any) { + const sectionsElements = await page.locator('.kbnSolutionNav .euiSideNavItem--root').all(); + + const sections = []; + + for (const el of sectionsElements) { + const sectionButton = el.locator('.euiSideNavItemButton').first(); + const sectionId = await sectionButton.getAttribute('data-test-subj'); + + const sectionLinkElements = await el.locator('.euiSideNavItem > a.euiSideNavItemButton').all(); + + const sectionLinks = []; + for (const linkEl of sectionLinkElements) { + const testSubj = await linkEl.getAttribute('data-test-subj'); + if (testSubj) { + sectionLinks.push(testSubj); + } + } + + sections.push({ sectionId, sectionLinks }); + } + + return sections; +} + +// Helper function to check if apps menu contains a specific link text +async function checkAppsMenuContains(page: any, linkText: string): Promise { + // Open collapsible nav if it's not already open + const navButton = page.locator('[data-test-subj="toggleNavButton"]'); + const isVisible = await navButton.isVisible().catch(() => false); + + if (isVisible) { + const navPanel = page.locator('[data-test-subj="collapsibleNav"]'); + const isPanelVisible = await navPanel.isVisible().catch(() => false); + + if (!isPanelVisible) { + await navButton.click(); + await navPanel.waitFor({ state: 'visible', timeout: 5000 }); + } + } + + // Look for the link text in the navigation + const links = await page.locator('[data-test-subj="collapsibleNav"] a').all(); + + for (const link of links) { + const text = await link.innerText(); + if (text.includes(linkText)) { + return true; + } + } + + return false; +} + +test.describe('security', { tag: tags.ESS_ONLY }, () => { + test.describe('global all privileges (aka kibana_admin)', () => { + test.beforeEach(async ({ browserAuth, page }) => { + const random = `does-not-exist-${Math.random().toString(36).substring(2, 15)}`; + await browserAuth.loginWithCustomRole({ + kibana: [ + { + base: ['all'], + feature: {}, + spaces: ['default', random], + }, + ], + elasticsearch: { + cluster: [], + }, + }); + await page.gotoApp('management'); + await page.testSubj.locator('managementHome').waitFor({ state: 'attached' }); + }); + + test('should show the Stack Management nav link', async ({ page }) => { + // Check if apps menu contains 'Stack Management' + const containsStackManagement = await checkAppsMenuContains(page, 'Stack Management'); + expect(containsStackManagement).toBe(true); + }); + + test('should not render the "Security" section', async ({ page }) => { + // Get management menu sections + const sections = await getManagementSections(page); + + // Extract section IDs + const sectionIds = sections.map((section) => section.sectionId); + + // Verify section IDs contain expected sections + expect(sectionIds).toContain('data'); + expect(sectionIds).toContain('insightsAndAlerting'); + expect(sectionIds).toContain('kibana'); + + // Find data section and verify its links + const dataSection = sections.find((section) => section.sectionId === 'data'); + expect(dataSection?.sectionLinks).toEqual(['data_quality', 'content_connectors']); + }); + }); + + test.describe('global dashboard read with manage_security', () => { + test.beforeEach(async ({ browserAuth, page }) => { + const random = `does-not-exist-${Math.random().toString(36).substring(2, 15)}`; + await browserAuth.loginWithCustomRole({ + kibana: [ + { + base: [], + feature: { + dashboard: ['read'], + }, + spaces: ['default', random], + }, + ], + elasticsearch: { + cluster: ['manage_security'], + }, + }); + + await page.gotoApp('management'); + await page.testSubj.locator('managementHome').waitFor({ state: 'attached' }); + }); + + test('should show the Stack Management nav link for manage_security', async ({ page }) => { + // Check if apps menu contains 'Stack Management' + const containsStackManagement = await checkAppsMenuContains(page, 'Stack Management'); + expect(containsStackManagement).toBe(true); + }); + + test('should render the "Security" section with API Keys', async ({ page }) => { + // Get management menu sections + const sections = await getManagementSections(page); + + // Verify sections array has length 1 + expect(sections).toHaveLength(1); + + // Verify first section has expected properties + expect(sections[0]).toEqual({ + sectionId: 'security', + sectionLinks: ['users', 'roles', 'api_keys', 'role_mappings'], + }); + }); + }); +}); diff --git a/x-pack/platform/test/functional/apps/api_keys/scout/ui/tests/home_page.spec.ts b/x-pack/platform/test/functional/apps/api_keys/scout/ui/tests/home_page.spec.ts new file mode 100644 index 0000000000000..15ac488a2d50c --- /dev/null +++ b/x-pack/platform/test/functional/apps/api_keys/scout/ui/tests/home_page.spec.ts @@ -0,0 +1,656 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { BrowserAuthFixture } from '@kbn/scout'; +import { expect, tags } from '@kbn/scout'; +import { test } from '../fixtures'; + +const testRoles: Record = { + viewer: { + cluster: ['all'], + indices: [ + { + names: ['*'], + privileges: ['all'], + allow_restricted_indices: false, + }, + { + names: ['*'], + privileges: ['monitor', 'read', 'view_index_metadata', 'read_cross_cluster'], + allow_restricted_indices: true, + }, + ], + run_as: ['*'], + }, +}; + +function loginAsApiKeysAdmin(browserAuth: BrowserAuthFixture): Promise { + const time = `does-not-exist-${Math.random().toString(36).substring(2, 15)}`; + return browserAuth.loginWithCustomRole({ + elasticsearch: { + cluster: ['manage_security', 'manage_api_key'], + }, + kibana: [ + { + base: [], + feature: { + advancedSettings: ['read'], + }, + spaces: ['default', time], + }, + ], + }); +} + +function loginAsApiKeysReadOnlyUser(browserAuth: BrowserAuthFixture): Promise { + const time = `does-not-exist-${Math.random().toString(36).substring(2, 15)}`; + + return browserAuth.loginWithCustomRole({ + elasticsearch: { + cluster: ['read_security'], + }, + kibana: [ + { + base: [], + feature: { + advancedSettings: ['read'], + }, + spaces: ['default', time], + }, + ], + }); +} + +test.describe('Home page', { tag: tags.ESS_ONLY }, () => { + test.beforeAll(async ({ apiServices }) => { + // Clean up any existing API keys + await apiServices.apiKeys.cleanup.deleteAll(); + }); + + test('Hides management link if user is not authorized', async ({ + page, + browserAuth, + pageObjects, + }) => { + // The API Keys test subject should not be visible for editor without proper permissions + // Login as admin and navigate to API Keys + await browserAuth.loginAsPrivilegedUser(); + await pageObjects.apiKeys.goto(); + const apiKeysSubject = page.testSubj.locator('apiKeys'); + const isVisible = await apiKeysSubject.isVisible(); + expect(isVisible).toBe(false); + }); + + test('Loads the app', async ({ browserAuth, page, pageObjects }) => { + await loginAsApiKeysAdmin(browserAuth); + await pageObjects.apiKeys.goto(); + + // Verify the create API key link is present + const createLink = page.getByText('Create API key'); + await expect(createLink).toBeVisible(); + }); + + test.describe('creates API key', () => { + test.beforeEach(async ({ apiServices }) => { + // Delete any API keys created outside of these tests + await apiServices.apiKeys.cleanup.deleteAll(); + }); + + test('when submitting form, close dialog and displays new api key', async ({ + page, + pageObjects, + browserAuth, + }) => { + await loginAsApiKeysAdmin(browserAuth); + await pageObjects.apiKeys.goto(); + + const apiKeyName = 'Happy API Key'; + + // Click create API key button + await pageObjects.apiKeys.clickOnPromptCreateApiKey(); + + // Verify URL contains create path + expect(page.url()).toContain('app/management/security/api_keys/create'); + + // Verify flyout title + const flyoutTitle = await pageObjects.apiKeys.getFlyoutTitleText(); + expect(flyoutTitle).toContain('Create API key'); + + // Set API key name + await pageObjects.apiKeys.setApiKeyName(apiKeyName); + + // Click submit + await pageObjects.apiKeys.clickSubmitButtonOnApiKeyFlyout(); + + // Get creation message + const creationMessage = await pageObjects.apiKeys.getNewApiKeyCreation(); + + // Verify URL does not contain flyout path + expect(page.url()).not.toContain('app/management/security/api_keys/flyout'); + + // Verify URL contains api_keys path + expect(page.url()).toContain('app/management/security/api_keys'); + + // Verify modal does not exist + const modalExists = await pageObjects.apiKeys.isApiKeyModalExists(); + expect(modalExists).toBe(false); + + // Verify creation message + expect(creationMessage).toBe(`Created API key '${apiKeyName}'`); + }); + + test('with optional expiration, redirects back and displays base64', async ({ + page, + pageObjects, + browserAuth, + }) => { + await loginAsApiKeysAdmin(browserAuth); + await pageObjects.apiKeys.goto(); + + const apiKeyName = 'Happy expiration API key'; + + // Click create API key button + await pageObjects.apiKeys.clickOnPromptCreateApiKey(); + + // Verify URL contains create path + expect(page.url()).toContain('app/management/security/api_keys/create'); + + // Set API key name + await pageObjects.apiKeys.setApiKeyName(apiKeyName); + + // Toggle custom expiration + await pageObjects.apiKeys.toggleCustomExpiration(); + + // Set expiration to 12 days + await pageObjects.apiKeys.setApiKeyCustomExpiration('12'); + + // Click submit + await pageObjects.apiKeys.clickSubmitButtonOnApiKeyFlyout(); + + // Wait for modal to close + await page.waitForTimeout(1000); + + // Get creation message + const creationMessage = await pageObjects.apiKeys.getNewApiKeyCreation(); + + // Verify URL does not contain create path + expect(page.url()).not.toContain('app/management/security/api_keys/create'); + + // Verify URL contains api_keys path + expect(page.url()).toContain('app/management/security/api_keys'); + + // Verify modal does not exist + const modalExists = await pageObjects.apiKeys.isApiKeyModalExists(); + expect(modalExists).toBe(false); + + // Verify creation message + expect(creationMessage).toBe(`Created API key '${apiKeyName}'`); + }); + }); + + test.describe('Update API key', () => { + test.beforeAll(async ({ apiServices }) => { + // Delete any API keys created outside these tests + await apiServices.apiKeys.cleanup.deleteAll(); + }); + + test('should create a new API key, click the name of the new row, fill out and submit form, and display success message', async ({ + page, + pageObjects, + browserAuth, + }) => { + await loginAsApiKeysAdmin(browserAuth); + await pageObjects.apiKeys.goto(); + const apiKeyName = 'Happy API Key to Update'; + + await pageObjects.apiKeys.clickOnPromptCreateApiKey(); + await pageObjects.apiKeys.setApiKeyName(apiKeyName); + await pageObjects.apiKeys.clickSubmitButtonOnApiKeyFlyout(); + await pageObjects.apiKeys.ensureApiKeyExists(apiKeyName); + + await pageObjects.apiKeys.clickExistingApiKeyToOpenFlyout(apiKeyName); + + // Wait for submit button to be visible + await expect(pageObjects.apiKeys.formFlyoutSubmitButton).toBeEnabled({ timeout: 10000 }); + + // Verify flyout title + const flyoutTitle = await pageObjects.apiKeys.getFlyoutTitleText(); + expect(flyoutTitle).toContain('Update API key'); + + // Verify API key name input is not present (name cannot be changed) + const namePresent = await pageObjects.apiKeys.isApiKeyNamePresent(); + expect(namePresent).toBe(false); + + // Verify status + expect(await pageObjects.apiKeys.getFlyoutApiKeyStatus()).toBe('Active'); + + // Get metadata switch and verify it is enabled + const metadataSwitch = await pageObjects.apiKeys.getMetadataSwitch(); + const metadataEnabled = await metadataSwitch.isEnabled(); + expect(metadataEnabled).toBe(true); + + // Get restrict privileges switch and verify it is enabled + const restrictPrivilegesSwitch = await pageObjects.apiKeys.getRestrictPrivilegesSwitch(); + const restrictPrivilegesEnabled = await restrictPrivilegesSwitch.isEnabled(); + expect(restrictPrivilegesEnabled).toBe(true); + + // Click restrict privileges switch to show code editor + await restrictPrivilegesSwitch.click(); + + // Click metadata switch to show code editor + await metadataSwitch.click(); + + // Get and verify restrict privileges code editor value + const restrictPrivilegesValue = await pageObjects.apiKeys.getCodeEditorValueByIndex(0); + expect(restrictPrivilegesValue.trim()).toBe('{}'); + + // Get and verify metadata code editor value + const metadataValue = await pageObjects.apiKeys.getCodeEditorValueByIndex(1); + expect(metadataValue.trim()).toBe('{}'); + + // Set restrict privileges code editor value + await pageObjects.apiKeys.setCodeEditorValueByIndex(0, JSON.stringify(testRoles)); + + // Set metadata code editor value + await pageObjects.apiKeys.setCodeEditorValueByIndex(1, '{"name":"metadataTest"}'); + + // Click submit button + await pageObjects.apiKeys.clickSubmitButtonOnApiKeyFlyout(); + + // Get success toast text + const toast = await pageObjects.apiKeys.getApiKeyUpdateSuccessToast(); + await expect(toast).toContainText(`Updated API key '${apiKeyName}'`); + + // Verify URL contains api_keys path + expect(page.url()).toContain('app/management/security/api_keys'); + + // Verify modal does not exist + const modalExists = await pageObjects.apiKeys.isApiKeyModalExists(); + expect(modalExists).toBe(false); + }); + }); + + test.describe('Readonly API key', () => { + test.beforeAll(async ({ apiServices }) => { + // Delete any API keys created outside these tests + await apiServices.apiKeys.cleanup.deleteAll(); + }); + + test('should see readonly form elements', async ({ + page, + pageObjects, + apiServices, + browserAuth, + }) => { + const apiKeyName = 'Happy API Key to View'; + + // Create API key with metadata and role descriptors + const { data: apiKey } = await apiServices.apiKeys.create({ + name: apiKeyName, + expiration: '1d', + metadata: { name: 'metadatatest' }, + role_descriptors: testRoles, + }); + + expect(apiKey.id).toBeDefined(); + + // Set user roles to read_security_role + await loginAsApiKeysReadOnlyUser(browserAuth); + await pageObjects.apiKeys.goto(); + + // Click on API key to open flyout + await pageObjects.apiKeys.clickExistingApiKeyToOpenFlyout(apiKeyName); + + // Verify URL + expect(page.url()).toContain('app/management/security/api_keys'); + + // Verify flyout title + const flyoutTitle = await pageObjects.apiKeys.getFlyoutTitleText(); + expect(flyoutTitle).toContain('API key details'); + + // Verify name input is not present + const namePresent = await pageObjects.apiKeys.isApiKeyNamePresent(); + expect(namePresent).toBe(false); + + // Verify status + expect(await pageObjects.apiKeys.getFlyoutApiKeyStatus()).toBe('Expires in a day'); + + // Get metadata switch and verify it is disabled + const metadataSwitch = await pageObjects.apiKeys.getMetadataSwitch(); + const metadataEnabled = await metadataSwitch.isEnabled(); + expect(metadataEnabled).toBe(false); + + // Get restrict privileges switch and verify it is disabled + const restrictPrivilegesSwitch = await pageObjects.apiKeys.getRestrictPrivilegesSwitch(); + const restrictPrivilegesEnabled = await restrictPrivilegesSwitch.isEnabled(); + expect(restrictPrivilegesEnabled).toBe(false); + + // Click cancel button + await pageObjects.apiKeys.clickCancelButtonOnApiKeyFlyout(); + }); + + test('should show the `API key details` flyout if the expiration date is passed', async ({ + page, + pageObjects, + apiServices, + browserAuth, + }) => { + await loginAsApiKeysReadOnlyUser(browserAuth); + await pageObjects.apiKeys.goto(); + + const apiKeyName = 'expired-key'; + + // Create API key with very short expiration + const { data: apiKey } = await apiServices.apiKeys.create({ + name: apiKeyName, + expiration: '1ms', + }); + + expect(apiKey.id).toBeDefined(); + + // Wait a bit to ensure expiration + await page.reload(); + + // Click on API key to open flyout + await pageObjects.apiKeys.clickExistingApiKeyToOpenFlyout(apiKeyName); + + // Verify URL + expect(page.url()).toContain('app/management/security/api_keys'); + + // Verify flyout title + const flyoutTitle = await pageObjects.apiKeys.getFlyoutTitleText(); + expect(flyoutTitle).toContain('API key details'); + + // Verify name input is not present + const namePresent = await pageObjects.apiKeys.isApiKeyNamePresent(); + expect(namePresent).toBe(false); + + // Verify status shows expired + expect(await pageObjects.apiKeys.getFlyoutApiKeyStatus()).toBe('Expired'); + + // Get metadata switch and verify it is disabled + const metadataSwitch = await pageObjects.apiKeys.getMetadataSwitch(); + const metadataEnabled = await metadataSwitch.isEnabled(); + expect(metadataEnabled).toBe(false); + + // Get restrict privileges switch and verify it is disabled + const restrictPrivilegesSwitch = await pageObjects.apiKeys.getRestrictPrivilegesSwitch(); + const restrictPrivilegesEnabled = await restrictPrivilegesSwitch.isEnabled(); + expect(restrictPrivilegesEnabled).toBe(false); + + // Click cancel button + await pageObjects.apiKeys.clickCancelButtonOnApiKeyFlyout(); + }); + + test('should show the `API key details flyout` if the API key does not belong to the user', async ({ + page, + pageObjects, + apiServices, + browserAuth, + }) => { + await loginAsApiKeysReadOnlyUser(browserAuth); + await pageObjects.apiKeys.goto(); + const apiKeyName = 'other-key'; + + // Create API key for different user (elastic) + const { data: apiKey } = await apiServices.apiKeys.create({ + name: apiKeyName, + }); + + expect(apiKey.id).toBeDefined(); + + // Refresh browser + await page.reload(); + await expect(pageObjects.apiKeys.apiKeysTable).toBeVisible({ timeout: 10000 }); + + // Click on API key to open flyout + await pageObjects.apiKeys.clickExistingApiKeyToOpenFlyout(apiKeyName); + + // Verify URL + expect(page.url()).toContain('app/management/security/api_keys'); + + // Verify flyout title + const flyoutTitle = await pageObjects.apiKeys.getFlyoutTitleText(); + expect(flyoutTitle).toContain('API key details'); + + // Verify name input is not present + const namePresent = await pageObjects.apiKeys.isApiKeyNamePresent(); + expect(namePresent).toBe(false); + + // Verify status shows active + expect(await pageObjects.apiKeys.getFlyoutApiKeyStatus()).toBe('Active'); + + // Get metadata switch and verify it is disabled + const metadataSwitch = await pageObjects.apiKeys.getMetadataSwitch(); + const metadataEnabled = await metadataSwitch.isEnabled(); + expect(metadataEnabled).toBe(false); + + // Get restrict privileges switch and verify it is disabled + const restrictPrivilegesSwitch = await pageObjects.apiKeys.getRestrictPrivilegesSwitch(); + const restrictPrivilegesEnabled = await restrictPrivilegesSwitch.isEnabled(); + expect(restrictPrivilegesEnabled).toBe(false); + + // Click cancel button + await pageObjects.apiKeys.clickCancelButtonOnApiKeyFlyout(); + }); + }); + + test.describe('deletes API key(s)', () => { + test.beforeEach(async ({ page, pageObjects, browserAuth, apiServices }) => { + await apiServices.apiKeys.cleanup.deleteAll(); + await loginAsApiKeysAdmin(browserAuth); + await pageObjects.apiKeys.goto(); + + await apiServices.apiKeys.create({ + name: 'api key 1', + }); + + // Create API key + await page.reload(); + await pageObjects.apiKeys.ensureApiKeyExists('api key 1'); + }); + + test('one by one', async ({ page, pageObjects, browserAuth }) => { + // Delete all API keys one by one + await pageObjects.apiKeys.ensureApiKeyExists('api key 1'); + await pageObjects.apiKeys.deleteAllApiKeyOneByOne(); + + // Verify we're back to the empty prompt + const promptTitle = await pageObjects.apiKeys.getApiKeysFirstPromptTitle(); + expect(promptTitle).toBe('Create your first API key'); + }); + + test('by bulk', async ({ page, pageObjects, browserAuth }) => { + // Create second API key + await pageObjects.apiKeys.clickOnTableCreateApiKey(); + await pageObjects.apiKeys.setApiKeyName('api key 2'); + await pageObjects.apiKeys.clickSubmitButtonOnApiKeyFlyout(); + + // Ensure both keys exist + await pageObjects.apiKeys.ensureApiKeyExists('api key 1'); + await pageObjects.apiKeys.ensureApiKeyExists('api key 2'); + + // Bulk delete all keys + await pageObjects.apiKeys.bulkDeleteApiKeys(); + + await page.waitForSelector('.euiEmptyPrompt .euiTitle', { state: 'visible' }); + const promptTitle = await pageObjects.apiKeys.getApiKeysFirstPromptTitle(); + expect(promptTitle).toBe('Create your first API key'); + }); + }); + + test.describe('querying API keys', () => { + test.beforeAll(async ({ apiServices, esClient }) => { + // Clear all existing API keys + await apiServices.apiKeys.cleanup.deleteAll(); + + // Create cross-cluster API key + await esClient.transport.request({ + method: 'POST', + path: '/_security/cross_cluster/api_key', + body: { + name: 'test_cross_cluster', + expiration: '1d', + access: { + search: [{ names: ['*'] }], + replication: [{ names: ['*'] }], + }, + }, + }); + + // Create managed API key + await apiServices.apiKeys.create({ + name: 'my api key', + expiration: '1d', + metadata: { managed: true }, + }); + + // Create another managed key + await apiServices.apiKeys.create({ + name: 'Alerting: Managed', + expiration: '1d', + }); + + // Create API key that will expire quickly + await apiServices.apiKeys.create({ + name: 'test_api_key', + expiration: '1s', + }); + + // Wait for the key to expire + await new Promise((resolve) => setTimeout(resolve, 2000)); + }); + + test.afterAll(async ({ apiServices }) => { + // Restore defaults and clear API keys + await apiServices.apiKeys.cleanup.deleteAll(); + }); + + test.beforeEach(async ({ page, pageObjects, browserAuth, apiServices }) => { + // Navigate to API Keys app before each test + await loginAsApiKeysAdmin(browserAuth); + await pageObjects.apiKeys.goto(); + + const { data: apiKeys } = await apiServices.apiKeys.query({ + query: { + wildcard: { + name: 'custom role api key', + }, + }, + }); + + if (apiKeys.apiKeys.length === 0) { + // Creating with pageObjects because this needs to be owned by the test user, not the default `elastic` user + await pageObjects.apiKeys.clickOnTableCreateApiKey(); + await pageObjects.apiKeys.setApiKeyName('custom role api key'); + await pageObjects.apiKeys.clickSubmitButtonOnApiKeyFlyout(); + await pageObjects.apiKeys.ensureApiKeyExists('custom role api key'); + } + }); + + test('active/expired filter buttons work as expected', async ({ page, pageObjects }) => { + // Click active filter + await pageObjects.apiKeys.clickExpiryFilters('active'); + + // Ensure active keys exist + await pageObjects.apiKeys.ensureApiKeyExists('my api key'); + await pageObjects.apiKeys.ensureApiKeyExists('Alerting: Managed'); + await pageObjects.apiKeys.ensureApiKeyExists('test_cross_cluster'); + + // Verify expired key does not exist + await pageObjects.apiKeys.ensureApiKeyDoesNotExist('test_api_key'); + + // Click expired filter + await pageObjects.apiKeys.clickExpiryFilters('expired'); + + // Ensure expired key exists + await pageObjects.apiKeys.ensureApiKeyExists('test_api_key'); + + // Verify active key does not exist + await pageObjects.apiKeys.ensureApiKeyDoesNotExist('my api key'); + + // Reset filter by clicking expired again + await pageObjects.apiKeys.clickExpiryFilters('expired'); + }); + + test('api key type filter buttons work as expected', async ({ page, pageObjects }) => { + // Click personal filter + await pageObjects.apiKeys.clickTypeFilters('personal'); + + // Ensure personal key exists + await pageObjects.apiKeys.ensureApiKeyExists('custom role api key'); + + // Click cross_cluster filter + await pageObjects.apiKeys.clickTypeFilters('cross_cluster'); + + // Ensure cross-cluster key exists + await pageObjects.apiKeys.ensureApiKeyExists('test_cross_cluster'); + + // Click managed filter + await pageObjects.apiKeys.clickTypeFilters('managed'); + + // Ensure managed keys exist + await pageObjects.apiKeys.ensureApiKeyExists('my api key'); + await pageObjects.apiKeys.ensureApiKeyExists('Alerting: Managed'); + + // Reset filters by clicking managed again + await pageObjects.apiKeys.clickTypeFilters('managed'); + }); + + test('username filter buttons work as expected', async ({ page, pageObjects, samlAuth }) => { + // Click username dropdown + await pageObjects.apiKeys.clickUserNameDropdown(); + + // Verify user options exist + const customUserExists = await page.testSubj.isVisible( + `userProfileSelectableOption-elastic_${samlAuth.customRoleName}`, + { timeout: 1000 } + ); + expect(customUserExists).toBe(true); + + const testUserExists = await page.testSubj.isVisible('userProfileSelectableOption-elastic'); + expect(testUserExists).toBe(true); + + // Click custom role option + await page.testSubj.click(`userProfileSelectableOption-elastic_${samlAuth.customRoleName}`); + + // Ensure custom role API key exists + await pageObjects.apiKeys.ensureApiKeyExists('custom role api key'); + + // Deselect custom role + await page.testSubj.click(`userProfileSelectableOption-elastic`); + + // Click elastic option + await page.testSubj.click(`userProfileSelectableOption-elastic_${samlAuth.customRoleName}`); + + // Ensure elastic user keys exist + await pageObjects.apiKeys.ensureApiKeyExists('my api key'); + await pageObjects.apiKeys.ensureApiKeyExists('Alerting: Managed'); + await pageObjects.apiKeys.ensureApiKeyExists('test_cross_cluster'); + }); + + test('search bar works as expected', async ({ page, pageObjects }) => { + // Search for custom role api key + await pageObjects.apiKeys.setSearchBarValue('custom role api key'); + + // Ensure API key exists + await pageObjects.apiKeys.ensureApiKeyExists('custom role api key'); + + // Search with exact match quotes + await pageObjects.apiKeys.setSearchBarValue('"custom role api key"'); + + // Ensure API key exists + await pageObjects.apiKeys.ensureApiKeyExists('my api key'); + + // Search with partial match + await pageObjects.apiKeys.setSearchBarValue('"api"'); + + // Ensure API key exists + await pageObjects.apiKeys.ensureApiKeyExists('my api key'); + }); + }); +}); diff --git a/x-pack/platform/test/tsconfig.json b/x-pack/platform/test/tsconfig.json index 14d8ee5a3da53..2d1920dbe166a 100644 --- a/x-pack/platform/test/tsconfig.json +++ b/x-pack/platform/test/tsconfig.json @@ -173,6 +173,7 @@ "@kbn/reporting-test-routes", "@kbn/alerting-types", "@kbn/visualizations-common", + "@kbn/scout", "@kbn/es-types", "@kbn/connector-schemas", ]