From 747e548d21c060edf66cb154737bc157229b6db4 Mon Sep 17 00:00:00 2001 From: Gloria Hornero Date: Tue, 12 Nov 2024 18:20:10 +0100 Subject: [PATCH 1/2] [Security Solution] Using API auth for API calls in Serverless Cypress tests (#190152) ### Context In our Cypress tests, we use API calls to set up the data needed to run the tests. Currently, we are using basic authentication for both ESS and serverless environments. However, for serverless, we should be using API key authentication, especially given that basic authentication will be deprecated soon. ### Challenges * Handling different authentication methods depending on whether the environment is serverless or ESS. * Allowing some tests to access or modify internal indexes. * Managing how the username is handled across different tests. ### Implentation To ensure the correct authentication is used based on the environment where the tests are executed, the `rootRequest` method is used to build the API request. Within this method, the appropriate authentication type is selected. All API calls will use an `admin` API key. The `admin` role is the least restrictive, which is appropriate for setting up data for tests rather than validating application behavior. This role minimizes the risk of issues during setup. A specific challenge arose when we needed to access or modify internal indexes, a capability restricted to the `system_indices_superuser` role for testing purposes. The issue stems from the API key generation method, which is tied to the user's role rather than the user itself. Since serverless currently lacks a role that permits access to internal indexes, we are, upon recommendation from the appex-qa team, using the Elasticsearch client directly with the `system_indices_superuser` role for these scenarios. For tests that assert the username, we made adjustments. Previously, the `system_indices_superuser` role was used universally, which is no longer the case for serverless. We now retrieve the username dynamically from user information instead of hardcoding the value. ### To be discussed When making modifications related to "username", it became apparent that we sometimes use "fullname" and, in other cases, "username," even though they seem intended to represent the same concept. Should we standardize on a single term across the solution? --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> (cherry picked from commit 81af529c8ca25465d7494a24fa15f594e1d8fb44) --- .../cypress/cypress.config.ts | 2 + .../cypress/cypress_ci.config.ts | 2 + .../cypress/cypress_ci_serverless.config.ts | 3 + .../cypress/cypress_serverless.config.ts | 3 + .../manage_lists.cy.ts | 12 +- .../cypress/e2e/explore/cases/creation.cy.ts | 13 +- .../timeline_templates/export.cy.ts | 12 +- .../e2e/investigations/timelines/export.cy.ts | 29 +++- .../investigations/timelines/notes_tab.cy.ts | 7 +- .../cypress/objects/exception.ts | 16 +- .../cypress/objects/timeline.ts | 14 +- .../cypress/support/es_client.ts | 156 ++++++++++++++++++ .../cypress/support/saml_auth.ts | 77 +++++++-- .../cypress/tasks/api_calls/common.ts | 56 +++---- .../cypress/tasks/api_calls/elasticsearch.ts | 70 ++------ .../cypress/tasks/api_calls/prebuilt_rules.ts | 28 +--- .../cypress/tasks/common.ts | 16 ++ .../cypress/tasks/privileges.ts | 10 +- 18 files changed, 353 insertions(+), 173 deletions(-) create mode 100644 x-pack/test/security_solution_cypress/cypress/support/es_client.ts diff --git a/x-pack/test/security_solution_cypress/cypress/cypress.config.ts b/x-pack/test/security_solution_cypress/cypress/cypress.config.ts index ef9a5b6130966..3b42b205e6030 100644 --- a/x-pack/test/security_solution_cypress/cypress/cypress.config.ts +++ b/x-pack/test/security_solution_cypress/cypress/cypress.config.ts @@ -7,6 +7,7 @@ import { defineCypressConfig } from '@kbn/cypress-config'; import { esArchiver } from './support/es_archiver'; +import { esClient } from './support/es_client'; export default defineCypressConfig({ chromeWebSecurity: false, @@ -31,6 +32,7 @@ export default defineCypressConfig({ experimentalCspAllowList: ['default-src', 'script-src', 'script-src-elem'], setupNodeEvents(on, config) { esArchiver(on, config); + esClient(on, config); on('before:browser:launch', (browser, launchOptions) => { if (browser.name === 'chrome' && browser.isHeadless) { launchOptions.args.push('--window-size=1920,1200'); diff --git a/x-pack/test/security_solution_cypress/cypress/cypress_ci.config.ts b/x-pack/test/security_solution_cypress/cypress/cypress_ci.config.ts index 3fae578ab1145..d621b844786dd 100644 --- a/x-pack/test/security_solution_cypress/cypress/cypress_ci.config.ts +++ b/x-pack/test/security_solution_cypress/cypress/cypress_ci.config.ts @@ -7,6 +7,7 @@ import { defineCypressConfig } from '@kbn/cypress-config'; import { esArchiver } from './support/es_archiver'; +import { esClient } from './support/es_client'; // eslint-disable-next-line import/no-default-export export default defineCypressConfig({ @@ -40,6 +41,7 @@ export default defineCypressConfig({ specPattern: './cypress/e2e/**/*.cy.ts', setupNodeEvents(on, config) { esArchiver(on, config); + esClient(on, config); on('before:browser:launch', (browser, launchOptions) => { if (browser.name === 'chrome' && browser.isHeadless) { launchOptions.args.push('--window-size=1920,1200'); diff --git a/x-pack/test/security_solution_cypress/cypress/cypress_ci_serverless.config.ts b/x-pack/test/security_solution_cypress/cypress/cypress_ci_serverless.config.ts index a5d59ca4dd4c6..2786ce7539092 100644 --- a/x-pack/test/security_solution_cypress/cypress/cypress_ci_serverless.config.ts +++ b/x-pack/test/security_solution_cypress/cypress/cypress_ci_serverless.config.ts @@ -8,6 +8,7 @@ import { defineCypressConfig } from '@kbn/cypress-config'; import { esArchiver } from './support/es_archiver'; import { samlAuthentication } from './support/saml_auth'; +import { esClient } from './support/es_client'; // eslint-disable-next-line import/no-default-export export default defineCypressConfig({ @@ -54,6 +55,8 @@ export default defineCypressConfig({ return launchOptions; }); samlAuthentication(on, config); + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; + esClient(on, config); // eslint-disable-next-line @typescript-eslint/no-var-requires require('@cypress/grep/src/plugin')(config); return config; diff --git a/x-pack/test/security_solution_cypress/cypress/cypress_serverless.config.ts b/x-pack/test/security_solution_cypress/cypress/cypress_serverless.config.ts index 5ca79319c8c32..dda12e04890c6 100644 --- a/x-pack/test/security_solution_cypress/cypress/cypress_serverless.config.ts +++ b/x-pack/test/security_solution_cypress/cypress/cypress_serverless.config.ts @@ -8,6 +8,7 @@ import { defineCypressConfig } from '@kbn/cypress-config'; import { esArchiver } from './support/es_archiver'; import { samlAuthentication } from './support/saml_auth'; +import { esClient } from './support/es_client'; // eslint-disable-next-line import/no-default-export export default defineCypressConfig({ @@ -33,6 +34,7 @@ export default defineCypressConfig({ experimentalMemoryManagement: true, setupNodeEvents(on, config) { esArchiver(on, config); + esClient(on, config); on('before:browser:launch', (browser, launchOptions) => { if (browser.name === 'chrome' && browser.isHeadless) { launchOptions.args.push('--window-size=1920,1200'); @@ -46,6 +48,7 @@ export default defineCypressConfig({ return launchOptions; }); samlAuthentication(on, config); + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; // eslint-disable-next-line @typescript-eslint/no-var-requires require('@cypress/grep/src/plugin')(config); return config; diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/exceptions/shared_exception_lists_management/shared_exception_list_page/manage_lists.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/exceptions/shared_exception_lists_management/shared_exception_list_page/manage_lists.cy.ts index d97481d5489bf..5682a89fd6990 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/exceptions/shared_exception_lists_management/shared_exception_list_page/manage_lists.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/exceptions/shared_exception_lists_management/shared_exception_list_page/manage_lists.cy.ts @@ -6,6 +6,7 @@ */ import { ExceptionListSchema } from '@kbn/securitysolution-io-ts-list-types'; +import { getUsername } from '../../../../../../tasks/common'; import { expectedExportedExceptionList, getExceptionList, @@ -97,11 +98,12 @@ describe( exportExceptionList(getExceptionList1().list_id); cy.wait('@export').then(({ response }) => { - cy.wrap(response?.body).should( - 'eql', - expectedExportedExceptionList(exceptionListResponse) - ); - + getUsername('admin').then((username) => { + cy.wrap(response?.body).should( + 'eql', + expectedExportedExceptionList(exceptionListResponse, username as string) + ); + }); cy.get(TOASTER).should( 'have.text', `Exception list "${EXCEPTION_LIST_NAME}" exported successfully` diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/explore/cases/creation.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/explore/cases/creation.cy.ts index c0895ec187365..05d2cddc8eaca 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/explore/cases/creation.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/explore/cases/creation.cy.ts @@ -51,18 +51,9 @@ import { import { visit, visitWithTimeRange } from '../../../tasks/navigation'; import { CASES_URL, OVERVIEW_URL } from '../../../urls/navigation'; -import { ELASTICSEARCH_USERNAME, IS_SERVERLESS } from '../../../env_var_names_constants'; import { deleteCases } from '../../../tasks/api_calls/cases'; import { login } from '../../../tasks/login'; - -const isServerless = Cypress.env(IS_SERVERLESS); -const getUsername = () => { - if (isServerless) { - return cy.task('getFullname'); - } else { - return cy.wrap(Cypress.env(ELASTICSEARCH_USERNAME)); - } -}; +import { getFullname } from '../../../tasks/common'; // Tracked by https://github.com/elastic/security-team/issues/7696 describe('Cases', { tags: ['@ess', '@serverless'] }, () => { @@ -120,7 +111,7 @@ describe('Cases', { tags: ['@ess', '@serverless'] }, () => { `${this.mycase.description} ${this.mycase.timeline.title}` ); - getUsername().then((username) => { + getFullname('platform_engineer').then((username) => { cy.get(CASE_DETAILS_USERNAMES).eq(REPORTER).should('contain', username); cy.get(CASE_DETAILS_USERNAMES).eq(PARTICIPANTS).should('contain', username); }); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timeline_templates/export.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timeline_templates/export.cy.ts index 7aff512667068..3c407a7506762 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timeline_templates/export.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timeline_templates/export.cy.ts @@ -13,6 +13,7 @@ import { expectedExportedTimelineTemplate } from '../../../objects/timeline'; import { TIMELINE_TEMPLATES_URL } from '../../../urls/navigation'; import { createTimelineTemplate, deleteTimelines } from '../../../tasks/api_calls/timelines'; import { searchByTitle } from '../../../tasks/table_pagination'; +import { getFullname } from '../../../tasks/common'; describe('Export timelines', { tags: ['@ess', '@serverless'] }, () => { beforeEach(() => { @@ -36,11 +37,12 @@ describe('Export timelines', { tags: ['@ess', '@serverless'] }, () => { cy.wait('@export').then(({ response }) => { cy.wrap(response?.statusCode).should('eql', 200); - - cy.wrap(response?.body).should( - 'eql', - expectedExportedTimelineTemplate(this.templateResponse) - ); + getFullname('admin').then((username) => { + cy.wrap(response?.body).should( + 'eql', + expectedExportedTimelineTemplate(this.templateResponse, username as string) + ); + }); }); }); }); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/export.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/export.cy.ts index e1e3a5cf2de32..826ca78228b61 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/export.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/export.cy.ts @@ -21,6 +21,7 @@ import { TIMELINE_CHECKBOX } from '../../../screens/timelines'; import { createTimeline } from '../../../tasks/api_calls/timelines'; import { expectedExportedTimeline } from '../../../objects/timeline'; import { closeToast } from '../../../tasks/common/toast'; +import { getFullname } from '../../../tasks/common'; describe('Export timelines', { tags: ['@ess', '@serverless'] }, () => { beforeEach(() => { @@ -51,7 +52,12 @@ describe('Export timelines', { tags: ['@ess', '@serverless'] }, () => { exportTimeline(this.timelineId1); cy.wait('@export').then(({ response }) => { cy.wrap(response?.statusCode).should('eql', 200); - cy.wrap(response?.body).should('eql', expectedExportedTimeline(this.timelineResponse1)); + getFullname('admin').then((username) => { + cy.wrap(response?.body).should( + 'eql', + expectedExportedTimeline(this.timelineResponse1, username as string) + ); + }); }); closeToast(); @@ -61,7 +67,13 @@ describe('Export timelines', { tags: ['@ess', '@serverless'] }, () => { exportSelectedTimelines(); cy.wait('@export').then(({ response }) => { cy.wrap(response?.statusCode).should('eql', 200); - cy.wrap(response?.body).should('eql', expectedExportedTimeline(this.timelineResponse1)); + + getFullname('admin').then((username) => { + cy.wrap(response?.body).should( + 'eql', + expectedExportedTimeline(this.timelineResponse1, username as string) + ); + }); }); closeToast(); @@ -81,8 +93,17 @@ describe('Export timelines', { tags: ['@ess', '@serverless'] }, () => { cy.wait('@export').then(({ response }) => { cy.wrap(response?.statusCode).should('eql', 200); const timelines = response?.body?.split('\n'); - assert.deepEqual(JSON.parse(timelines[0]), expectedExportedTimeline(this.timelineResponse2)); - assert.deepEqual(JSON.parse(timelines[1]), expectedExportedTimeline(this.timelineResponse1)); + + getFullname('admin').then((username) => { + assert.deepEqual( + JSON.parse(timelines[0]), + expectedExportedTimeline(this.timelineResponse2, username as string) + ); + assert.deepEqual( + JSON.parse(timelines[1]), + expectedExportedTimeline(this.timelineResponse1, username as string) + ); + }); }); }); }); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/notes_tab.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/notes_tab.cy.ts index 7bda7c4e4bfc3..7eead20dcb8c1 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/notes_tab.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/notes_tab.cy.ts @@ -23,6 +23,7 @@ import { createTimeline, deleteTimelines } from '../../../tasks/api_calls/timeli import { login } from '../../../tasks/login'; import { visitTimeline } from '../../../tasks/navigation'; import { addNotesToTimeline, goToNotesTab } from '../../../tasks/timeline'; +import { getFullname } from '../../../tasks/common'; const author = Cypress.env('ELASTICSEARCH_USERNAME'); const link = 'https://www.elastic.co/'; @@ -66,8 +67,10 @@ describe('Timeline notes tab', { tags: ['@ess', '@serverless'] }, () => { }); it('should render the right author', () => { - addNotesToTimeline(getTimelineNonValidQuery().notes); - cy.get(NOTES_AUTHOR).first().should('have.text', author); + getFullname('admin').then((username) => { + addNotesToTimeline(getTimelineNonValidQuery().notes); + cy.get(NOTES_AUTHOR).first().should('have.text', username); + }); }); // this test is failing on MKI only, the change was introduced by this EUI PR https://github.com/elastic/kibana/pull/195525 diff --git a/x-pack/test/security_solution_cypress/cypress/objects/exception.ts b/x-pack/test/security_solution_cypress/cypress/objects/exception.ts index 394130a257637..c4138052db304 100644 --- a/x-pack/test/security_solution_cypress/cypress/objects/exception.ts +++ b/x-pack/test/security_solution_cypress/cypress/objects/exception.ts @@ -6,7 +6,6 @@ */ import type { ExceptionListSchema } from '@kbn/securitysolution-io-ts-list-types'; -import { ELASTICSEARCH_USERNAME } from '../env_var_names_constants'; export interface Exception { field: string; @@ -59,18 +58,9 @@ export const getException = (): Exception => ({ }); export const expectedExportedExceptionList = ( - exceptionListResponse: Cypress.Response + exceptionListResponse: Cypress.Response, + username: string ): string => { const jsonRule = exceptionListResponse.body; - return `{"_version":"${jsonRule._version}","created_at":"${ - jsonRule.created_at - }","created_by":"${Cypress.env(ELASTICSEARCH_USERNAME)}","description":"${ - jsonRule.description - }","id":"${jsonRule.id}","immutable":false,"list_id":"${jsonRule.list_id}","name":"${ - jsonRule.name - }","namespace_type":"single","os_types":[],"tags":[],"tie_breaker_id":"${ - jsonRule.tie_breaker_id - }","type":"${jsonRule.type}","updated_at":"${jsonRule.updated_at}","updated_by":"${Cypress.env( - ELASTICSEARCH_USERNAME - )}","version":1}\n{"exported_exception_list_count":1,"exported_exception_list_item_count":0,"missing_exception_list_item_count":0,"missing_exception_list_items":[],"missing_exception_lists":[],"missing_exception_lists_count":0}\n`; + return `{"_version":"${jsonRule._version}","created_at":"${jsonRule.created_at}","created_by":"${username}","description":"${jsonRule.description}","id":"${jsonRule.id}","immutable":false,"list_id":"${jsonRule.list_id}","name":"${jsonRule.name}","namespace_type":"single","os_types":[],"tags":[],"tie_breaker_id":"${jsonRule.tie_breaker_id}","type":"${jsonRule.type}","updated_at":"${jsonRule.updated_at}","updated_by":"${username}","version":1}\n{"exported_exception_list_count":1,"exported_exception_list_item_count":0,"missing_exception_list_item_count":0,"missing_exception_list_items":[],"missing_exception_lists":[],"missing_exception_lists_count":0}\n`; }; diff --git a/x-pack/test/security_solution_cypress/cypress/objects/timeline.ts b/x-pack/test/security_solution_cypress/cypress/objects/timeline.ts index 1dcaae65a3392..1aea82de6612d 100644 --- a/x-pack/test/security_solution_cypress/cypress/objects/timeline.ts +++ b/x-pack/test/security_solution_cypress/cypress/objects/timeline.ts @@ -69,7 +69,8 @@ export const getTimelineNonValidQuery = (): CompleteTimeline => ({ }); export const expectedExportedTimelineTemplate = ( - templateResponse: Cypress.Response + templateResponse: Cypress.Response, + username: string ) => { const timelineTemplateBody = templateResponse.body.data.persistTimeline.timeline; @@ -102,9 +103,9 @@ export const expectedExportedTimelineTemplate = ( templateTimelineVersion: 1, timelineType: 'template', created: timelineTemplateBody.created, - createdBy: Cypress.env('ELASTICSEARCH_USERNAME'), + createdBy: username, updated: timelineTemplateBody.updated, - updatedBy: Cypress.env('ELASTICSEARCH_USERNAME'), + updatedBy: username, sort: [], eventNotes: [], globalNotes: [], @@ -114,7 +115,8 @@ export const expectedExportedTimelineTemplate = ( }; export const expectedExportedTimeline = ( - timelineResponse: Cypress.Response + timelineResponse: Cypress.Response, + username: string ) => { const timelineBody = timelineResponse.body.data.persistTimeline.timeline; @@ -140,9 +142,9 @@ export const expectedExportedTimeline = ( description: timelineBody.description, title: timelineBody.title, created: timelineBody.created, - createdBy: Cypress.env('ELASTICSEARCH_USERNAME'), + createdBy: username, updated: timelineBody.updated, - updatedBy: Cypress.env('ELASTICSEARCH_USERNAME'), + updatedBy: username, timelineType: 'default', sort: [], eventNotes: [], diff --git a/x-pack/test/security_solution_cypress/cypress/support/es_client.ts b/x-pack/test/security_solution_cypress/cypress/support/es_client.ts new file mode 100644 index 0000000000000..4b5e114ed33dd --- /dev/null +++ b/x-pack/test/security_solution_cypress/cypress/support/es_client.ts @@ -0,0 +1,156 @@ +/* + * 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 { systemIndicesSuperuser } from '@kbn/test'; +import { createEsClient } from '@kbn/security-solution-plugin/scripts/endpoint/common/stack_services'; + +export const esClient = ( + on: Cypress.PluginEvents, + config: Cypress.PluginConfigOptions +): Promise => { + const isServerless = config.env.IS_SERVERLESS; + const isCloudServerless = config.env.CLOUD_SERVERLESS; + + const user = { + username: config.env.ELASTICSEARCH_USERNAME, + password: config.env.ELASTICSEARCH_PASSWORD, + }; + + /* + system_indices_superuser is a user created for testing purposes (an operator one) that does not have restrictions, + that user is the one used on ESS and stateless environments to access internal indexes directly and does not exist on MKI environments. + */ + const authOverride = isServerless ? (isCloudServerless ? user : systemIndicesSuperuser) : user; + + const client = createEsClient({ + url: config.env.ELASTICSEARCH_URL, + username: authOverride?.username, + password: authOverride?.password, + }); + + on('task', { + putMapping: async (index: string) => { + await client.indices.putMapping({ + index, + body: { + dynamic: true, + }, + }); + return null; + }, + bulkInsert: async (body) => { + await client.bulk({ + refresh: true, + body, + }); + return null; + }, + refreshIndex: async (index: string) => { + try { + await client.indices.refresh({ index }); + return true; + } catch (error) { + return false; + } + }, + searchIndex: async (index) => { + try { + const response = await client.search({ + index, + body: { + query: { + match_all: {}, + }, + }, + }); + + return response.hits.hits.length; + } catch (error) { + return null; + } + }, + deleteDataStream: async (dataStreamName) => { + try { + await client.indices.deleteDataStream({ + name: dataStreamName, + }); + + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; + } + }, + deleteIndex: async (index) => { + try { + await client.indices.delete({ + index, + }); + + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; + } + }, + deleteDocuments: async (index: string) => { + await client.deleteByQuery({ + index, + body: { + query: { + match_all: {}, + }, + }, + conflicts: 'proceed', + scroll_size: 10000, + refresh: true, + }); + return null; + }, + createIndex: async ({ index: indexName, properties }) => { + const result = await client.indices.create({ + index: indexName, + body: { + mappings: { + properties, + }, + }, + }); + return result; + }, + createDocument: async ({ index: indexName, document }) => { + const result = await client.index({ + index: indexName, + body: document, + refresh: 'wait_for', + }); + + return result; + }, + deleteSecurityRulesFromKibana: async () => { + await client.deleteByQuery({ + index: '.kibana_*', + body: { + query: { + bool: { + filter: [ + { + match: { + type: 'security-rule', + }, + }, + ], + }, + }, + }, + conflicts: 'proceed', + refresh: true, + }); + return null; + }, + }); + + return Promise.resolve(); +}; diff --git a/x-pack/test/security_solution_cypress/cypress/support/saml_auth.ts b/x-pack/test/security_solution_cypress/cypress/support/saml_auth.ts index 1b5c2adcae455..1f95d373b2c17 100644 --- a/x-pack/test/security_solution_cypress/cypress/support/saml_auth.ts +++ b/x-pack/test/security_solution_cypress/cypress/support/saml_auth.ts @@ -11,6 +11,9 @@ import { SecurityRoleName } from '@kbn/security-solution-plugin/common/test'; import { HostOptions, SamlSessionManager } from '@kbn/test'; import { REPO_ROOT } from '@kbn/repo-info'; import { resolve } from 'path'; +import axios from 'axios'; +import fs from 'fs'; +import yaml from 'js-yaml'; import { DEFAULT_SERVERLESS_ROLE } from '../env_var_names_constants'; export const samlAuthentication = async ( @@ -31,31 +34,81 @@ export const samlAuthentication = async ( password: config.env.ELASTICSEARCH_PASSWORD, }; + const rolesPath = + '../../../../packages/kbn-es/src/serverless_resources/project_roles/security/roles.yml'; + // If config.env.PROXY_ORG is set, it means that proxy service is used to create projects. Define the proxy org filename to override the roles. const rolesFilename = config.env.PROXY_ORG ? `${config.env.PROXY_ORG}.json` : undefined; const cloudUsersFilePath = resolve(REPO_ROOT, '.ftr', rolesFilename ?? 'role_users.json'); + const INTERNAL_REQUEST_HEADERS = { + 'kbn-xsrf': 'cypress-creds', + 'x-elastic-internal-origin': 'security-solution', + }; + + const getYamlData = (filePath: string): any => { + const fileContents = fs.readFileSync(filePath, 'utf8'); + return yaml.load(fileContents); + }; + + const getRoleConfiguration = (role: string, filePath: string): any => { + const data = getYamlData(filePath); + if (data[role]) { + return data[role]; + } else { + throw new Error(`Role '${role}' not found in the YAML file.`); + } + }; + + const sessionManager = new SamlSessionManager({ + hostOptions, + log, + isCloud: config.env.CLOUD_SERVERLESS, + cloudUsersFilePath, + }); + on('task', { getSessionCookie: async (role: string | SecurityRoleName): Promise => { - const sessionManager = new SamlSessionManager({ - hostOptions, - log, - isCloud: config.env.CLOUD_SERVERLESS, - cloudUsersFilePath, - }); return sessionManager.getInteractiveUserSessionCookieWithRoleScope(role); }, + getApiKeyForRole: async (role: string | SecurityRoleName): Promise => { + const adminCookieHeader = await sessionManager.getApiCredentialsForRole('admin'); + + let roleDescriptor = {}; + + const roleConfig = getRoleConfiguration(role, rolesPath); + + roleDescriptor = { [role]: roleConfig }; + + const response = await axios.post( + `${kbnHost}/internal/security/api_key`, + { + name: 'myTestApiKey', + metadata: {}, + role_descriptors: roleDescriptor, + }, + { + headers: { + ...INTERNAL_REQUEST_HEADERS, + ...adminCookieHeader, + }, + } + ); + + const apiKey = response.data.encoded; + return apiKey; + }, getFullname: async ( role: string | SecurityRoleName = DEFAULT_SERVERLESS_ROLE ): Promise => { - const sessionManager = new SamlSessionManager({ - hostOptions, - log, - isCloud: config.env.CLOUD_SERVERLESS, - cloudUsersFilePath, - }); const { full_name: fullName } = await sessionManager.getUserData(role); return fullName; }, + getUsername: async ( + role: string | SecurityRoleName = DEFAULT_SERVERLESS_ROLE + ): Promise => { + const { username } = await sessionManager.getUserData(role); + return username; + }, }); }; diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/api_calls/common.ts b/x-pack/test/security_solution_cypress/cypress/tasks/api_calls/common.ts index 80d7c2c106815..480d97bf35979 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/api_calls/common.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/api_calls/common.ts @@ -14,7 +14,7 @@ import { deleteAllDocuments } from './elasticsearch'; import { getSpaceUrl } from '../space'; import { DEFAULT_ALERTS_INDEX_PATTERN } from './alerts'; -export const API_AUTH = Object.freeze({ +export const ESS_API_AUTH = Object.freeze({ user: Cypress.env(ELASTICSEARCH_USERNAME), pass: Cypress.env(ELASTICSEARCH_PASSWORD), }); @@ -28,17 +28,32 @@ export const API_HEADERS = Object.freeze({ export const INTERNAL_CLOUD_CONNECTORS = ['Elastic-Cloud-SMTP']; export const rootRequest = ({ - headers: optionHeaders, + headers: optionHeaders = {}, + role = 'admin', ...restOptions -}: Partial): Cypress.Chainable> => - cy.request({ - auth: API_AUTH, - headers: { - ...API_HEADERS, - ...(optionHeaders || {}), - }, - ...restOptions, - }); +}: Partial & { role?: string }): Cypress.Chainable> => { + if (Cypress.env('IS_SERVERLESS')) { + return cy.task('getApiKeyForRole', role).then((response) => { + return cy.request({ + headers: { + ...API_HEADERS, + ...optionHeaders, + Authorization: `ApiKey ${response}`, + }, + ...restOptions, + }); + }); + } else { + return cy.request({ + auth: ESS_API_AUTH, + headers: { + ...API_HEADERS, + ...optionHeaders, + }, + ...restOptions, + }); + } +}; // a helper function to wait for the root request to be successful // defaults to 5 second intervals for 3 attempts @@ -105,24 +120,7 @@ export const deleteConnectors = () => { }; export const deletePrebuiltRulesAssets = () => { - const kibanaIndexUrl = `${Cypress.env('ELASTICSEARCH_URL')}/.kibana_\*`; - rootRequest({ - method: 'POST', - url: `${kibanaIndexUrl}/_delete_by_query?conflicts=proceed&refresh`, - body: { - query: { - bool: { - filter: [ - { - match: { - type: 'security-rule', - }, - }, - ], - }, - }, - }, - }); + cy.task('deleteSecurityRulesFromKibana'); }; export const postDataView = (indexPattern: string, name?: string, id?: string) => { diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/api_calls/elasticsearch.ts b/x-pack/test/security_solution_cypress/cypress/tasks/api_calls/elasticsearch.ts index 6dc3622f72a03..b169a37154a5b 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/api_calls/elasticsearch.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/api_calls/elasticsearch.ts @@ -4,70 +4,37 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { rootRequest } from './common'; export const deleteIndex = (index: string) => { - rootRequest({ - method: 'DELETE', - url: `${Cypress.env('ELASTICSEARCH_URL')}/${index}`, - failOnStatusCode: false, - }); + cy.task('deleteIndex', index); }; export const deleteDataStream = (dataStreamName: string) => { - rootRequest({ - method: 'DELETE', - url: `${Cypress.env('ELASTICSEARCH_URL')}/_data_stream/${dataStreamName}`, - failOnStatusCode: false, - }); + cy.task('deleteDataStream', dataStreamName); }; export const deleteAllDocuments = (target: string) => { refreshIndex(target); - rootRequest({ - method: 'POST', - url: `${Cypress.env( - 'ELASTICSEARCH_URL' - )}/${target}/_delete_by_query?conflicts=proceed&scroll_size=10000&refresh`, - body: { - query: { - match_all: {}, - }, - }, - }); + cy.task('deleteDocuments', target); }; -export const createIndex = (indexName: string, properties: Record) => - rootRequest({ - method: 'PUT', - url: `${Cypress.env('ELASTICSEARCH_URL')}/${indexName}`, - body: { - mappings: { - properties, - }, - }, - }); +export const createIndex = (indexName: string, properties: Record) => { + cy.task('createIndex', { index: indexName, properties }); +}; -export const createDocument = (indexName: string, document: Record) => - rootRequest({ - method: 'POST', - url: `${Cypress.env('ELASTICSEARCH_URL')}/${indexName}/_doc?refresh=wait_for`, - body: document, - }); +export const createDocument = (indexName: string, document: Record) => { + cy.task('createDocument', { index: indexName, document }); +}; export const waitForNewDocumentToBeIndexed = (index: string, initialNumberOfDocuments: number) => { cy.waitUntil( () => - rootRequest<{ hits: { hits: unknown[] } }>({ - method: 'GET', - url: `${Cypress.env('ELASTICSEARCH_URL')}/${index}/_search`, - failOnStatusCode: false, - }).then((response) => { - if (response.status !== 200) { - return false; + cy.task('searchIndex', index).then((currentNumberOfDocuments) => { + if (typeof currentNumberOfDocuments === 'number') { + return currentNumberOfDocuments > initialNumberOfDocuments; } else { - return response.body.hits.hits.length > initialNumberOfDocuments; + return false; } }), { interval: 500, timeout: 12000 } @@ -77,15 +44,8 @@ export const waitForNewDocumentToBeIndexed = (index: string, initialNumberOfDocu export const refreshIndex = (index: string) => { cy.waitUntil( () => - rootRequest({ - method: 'POST', - url: `${Cypress.env('ELASTICSEARCH_URL')}/${index}/_refresh`, - failOnStatusCode: false, - }).then((response) => { - if (response.status !== 200) { - return false; - } - return true; + cy.task('refreshIndex', index).then((result) => { + return result === true; }), { interval: 500, timeout: 12000 } ); diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/api_calls/prebuilt_rules.ts b/x-pack/test/security_solution_cypress/cypress/tasks/api_calls/prebuilt_rules.ts index 420794de7e338..08de9decbddf1 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/api_calls/prebuilt_rules.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/api_calls/prebuilt_rules.ts @@ -122,7 +122,6 @@ export const createNewRuleAsset = ({ headers: { 'Content-Type': 'application/json', }, - failOnStatusCode: false, body: rule, }) .then((response) => response.status === 200); @@ -142,7 +141,6 @@ export const bulkCreateRuleAssets = ({ 'Bulk Install prebuilt rules', rules?.map((rule) => rule['security-rule'].rule_id).join(', ') ); - const url = `${Cypress.env('ELASTICSEARCH_URL')}/${index}/_bulk?refresh`; const bulkIndexRequestBody = rules.reduce((body, rule) => { const document = JSON.stringify(rule); @@ -165,29 +163,8 @@ export const bulkCreateRuleAssets = ({ return body.concat(indexRuleAsset, indexHistoricalRuleAsset); }, ''); - rootRequest({ - method: 'PUT', - url: `${Cypress.env('ELASTICSEARCH_URL')}/${index}/_mapping`, - body: { - dynamic: true, - }, - headers: { - 'Content-Type': 'application/json', - }, - }); - - cy.waitUntil( - () => { - return rootRequest({ - method: 'POST', - url, - headers: { 'Content-Type': 'application/json' }, - failOnStatusCode: false, - body: bulkIndexRequestBody, - }).then((response) => response.status === 200); - }, - { interval: 500, timeout: 12000 } - ); + cy.task('putMapping', index); + cy.task('bulkInsert', bulkIndexRequestBody); }; export const getRuleAssets = (index: string | undefined = '.kibana_security_solution') => { @@ -198,7 +175,6 @@ export const getRuleAssets = (index: string | undefined = '.kibana_security_solu headers: { 'Content-Type': 'application/json', }, - failOnStatusCode: false, body: { query: { term: { type: { value: 'security-rule' } }, diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/common.ts b/x-pack/test/security_solution_cypress/cypress/tasks/common.ts index ff0bbac6866cd..2e109805c4c0a 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/common.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/common.ts @@ -56,6 +56,22 @@ export const drop = (dropTarget: JQuery) => { .wait(300); }; +const getUserValue = (taskName: 'getFullname' | 'getUsername', role: string = 'admin') => { + if (Cypress.env('IS_SERVERLESS')) { + return cy.task(taskName, role); + } else { + return cy.wrap(Cypress.env('ELASTICSEARCH_USERNAME')); + } +}; + +export const getFullname = (role: string = 'admin') => { + return getUserValue('getFullname', role); +}; + +export const getUsername = (role: string = 'admin') => { + return getUserValue('getUsername', role); +}; + export const reload = () => { cy.reload(); cy.contains('a', 'Security'); diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/privileges.ts b/x-pack/test/security_solution_cypress/cypress/tasks/privileges.ts index bd7c9d9178198..7f2d0dea8b545 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/privileges.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/privileges.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { API_AUTH } from './api_calls/common'; +import { ESS_API_AUTH } from './api_calls/common'; interface User { username: string; @@ -193,7 +193,7 @@ export const createUsersAndRoles = (users: User[], roles: Role[]) => { body: role.privileges, headers: { 'kbn-xsrf': 'cypress-creds', 'x-elastic-internal-origin': 'security-solution' }, method: 'PUT', - auth: API_AUTH, + auth: ESS_API_AUTH, url: `/api/security/role/${role.name}`, }) .its('status') @@ -213,7 +213,7 @@ export const createUsersAndRoles = (users: User[], roles: Role[]) => { }, headers: { 'kbn-xsrf': 'cypress-creds', 'x-elastic-internal-origin': 'security-solution' }, method: 'POST', - auth: API_AUTH, + auth: ESS_API_AUTH, url: `/internal/security/users/${user.username}`, }) .its('status') @@ -227,7 +227,7 @@ export const deleteUsersAndRoles = (users: User[], roles: Role[]) => { cy.request({ headers: { 'kbn-xsrf': 'cypress-creds', 'x-elastic-internal-origin': 'security-solution' }, method: 'DELETE', - auth: API_AUTH, + auth: ESS_API_AUTH, url: `/internal/security/users/${user.username}`, failOnStatusCode: false, }) @@ -240,7 +240,7 @@ export const deleteUsersAndRoles = (users: User[], roles: Role[]) => { cy.request({ headers: { 'kbn-xsrf': 'cypress-creds', 'x-elastic-internal-origin': 'security-solution' }, method: 'DELETE', - auth: API_AUTH, + auth: ESS_API_AUTH, url: `/api/security/role/${role.name}`, failOnStatusCode: false, }) From 02e97e6ae76489550ab1505b74abf09ba469bf8e Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 12 Nov 2024 17:58:35 +0000 Subject: [PATCH 2/2] [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix' --- .../test/security_solution_cypress/cypress/support/saml_auth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/test/security_solution_cypress/cypress/support/saml_auth.ts b/x-pack/test/security_solution_cypress/cypress/support/saml_auth.ts index 1f95d373b2c17..ee68951e97a62 100644 --- a/x-pack/test/security_solution_cypress/cypress/support/saml_auth.ts +++ b/x-pack/test/security_solution_cypress/cypress/support/saml_auth.ts @@ -48,7 +48,7 @@ export const samlAuthentication = async ( const getYamlData = (filePath: string): any => { const fileContents = fs.readFileSync(filePath, 'utf8'); - return yaml.load(fileContents); + return yaml.safeLoad(fileContents); }; const getRoleConfiguration = (role: string, filePath: string): any => {