From d75103eacb92b134b9235ccc373bbe82d0b1feff Mon Sep 17 00:00:00 2001 From: Dzmitry Lemechko Date: Mon, 4 Dec 2023 21:09:05 +0200 Subject: [PATCH] [FTR] add service to test user roles on serverless (#170417) ## Summary ### This PR enables user roles testing in FTR We use SAML authentication to get session cookie for user with the specific role. The cookie is cached on FTR service side so we only make SAML auth one time per user within FTR config run. For Kibana CI service relies on changes coming in #170852 In order to run FTR tests locally against existing MKI project: - add `.ftr/role_users.json` in Kibana root dir ``` { "viewer": { "email": "...", "password": "..." }, "developer": { "email": "...", "password": "..." } } ``` - set Cloud hostname (!not project hostname!) with TEST_CLOUD_HOST_NAME, e.g. `export TEST_CLOUD_HOST_NAME=console.qa.cld.elstc.co` ### How to use: - functional tests: ``` const svlCommonPage = getPageObject('svlCommonPage'); before(async () => { // login with Viewer role await svlCommonPage.loginWithRole('viewer'); // you are logged in in browser and on project home page, start the test }); it('has project header', async () => { await svlCommonPage.assertProjectHeaderExists(); }); ``` - API integration tests: ``` const svlUserManager = getService('svlUserManager'); const supertestWithoutAuth = getService('supertestWithoutAuth'); let credentials: { Cookie: string }; before(async () => { // get auth header for Viewer role credentials = await svlUserManager.getApiCredentialsForRole('viewer'); }); it('returns full status payload for authenticated request', async () => { const { body } = await supertestWithoutAuth .get('/api/status') .set(credentials) .set('kbn-xsrf', 'kibana'); expect(body.name).to.be.a('string'); expect(body.uuid).to.be.a('string'); expect(body.version.number).to.be.a('string'); }); ``` Flaky-test-runner: #1 https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/4081 #2 https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/4114 --------- Co-authored-by: Robert Oskamp Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Aleh Zasypkin --- .gitignore | 3 + packages/kbn-es/src/utils/docker.test.ts | 4 +- packages/kbn-es/src/utils/docker.ts | 4 +- test/functional/services/common/browser.ts | 46 +++- .../common/platform_security/index.ts | 1 + .../platform_security/request_as_viewer.ts | 33 +++ .../page_objects/svl_common_page.ts | 52 +++- .../common/platform_security/index.ts | 1 + .../platform_security/viewer_role_login.ts | 32 +++ .../test_serverless/shared/services/index.ts | 2 + .../shared/services/supertest.ts | 16 +- .../shared/services/user_manager/saml_auth.ts | 234 ++++++++++++++++++ .../services/user_manager/svl_user_manager.ts | 168 +++++++++++++ 13 files changed, 578 insertions(+), 18 deletions(-) create mode 100644 x-pack/test_serverless/api_integration/test_suites/common/platform_security/request_as_viewer.ts create mode 100644 x-pack/test_serverless/functional/test_suites/common/platform_security/viewer_role_login.ts create mode 100644 x-pack/test_serverless/shared/services/user_manager/saml_auth.ts create mode 100644 x-pack/test_serverless/shared/services/user_manager/svl_user_manager.ts diff --git a/.gitignore b/.gitignore index 3a5dda1378c2e7..2739b9a50172ce 100644 --- a/.gitignore +++ b/.gitignore @@ -141,4 +141,7 @@ fleet-server.yml **/.journeys/ x-pack/test/security_api_integration/plugins/audit_log/audit.log +# ignore FTR temp directory +.ftr +role_users.json diff --git a/packages/kbn-es/src/utils/docker.test.ts b/packages/kbn-es/src/utils/docker.test.ts index b574447a20508b..d877e5bdf8261c 100644 --- a/packages/kbn-es/src/utils/docker.test.ts +++ b/packages/kbn-es/src/utils/docker.test.ts @@ -462,9 +462,9 @@ describe('resolveEsArgs()', () => { "--env", "xpack.security.authc.realms.saml.mock-idp.attributes.groups=http://saml.elastic-cloud.com/attributes/roles", "--env", - "xpack.security.authc.realms.saml.mock-idp.attributes.name=http://saml.elastic-cloud.com/attributes/email", + "xpack.security.authc.realms.saml.mock-idp.attributes.name=http://saml.elastic-cloud.com/attributes/name", "--env", - "xpack.security.authc.realms.saml.mock-idp.attributes.mail=http://saml.elastic-cloud.com/attributes/name", + "xpack.security.authc.realms.saml.mock-idp.attributes.mail=http://saml.elastic-cloud.com/attributes/email", ] `); }); diff --git a/packages/kbn-es/src/utils/docker.ts b/packages/kbn-es/src/utils/docker.ts index 73e5e1fc772884..31c00beb7a71f1 100644 --- a/packages/kbn-es/src/utils/docker.ts +++ b/packages/kbn-es/src/utils/docker.ts @@ -508,11 +508,11 @@ export function resolveEsArgs( ); esArgs.set( `xpack.security.authc.realms.saml.${MOCK_IDP_REALM_NAME}.attributes.name`, - MOCK_IDP_ATTRIBUTE_EMAIL + MOCK_IDP_ATTRIBUTE_NAME ); esArgs.set( `xpack.security.authc.realms.saml.${MOCK_IDP_REALM_NAME}.attributes.mail`, - MOCK_IDP_ATTRIBUTE_NAME + MOCK_IDP_ATTRIBUTE_EMAIL ); } diff --git a/test/functional/services/common/browser.ts b/test/functional/services/common/browser.ts index 4172e7087ea36f..cac8eec71e7f4d 100644 --- a/test/functional/services/common/browser.ts +++ b/test/functional/services/common/browser.ts @@ -6,22 +6,22 @@ * Side Public License, v 1. */ -import Url from 'url'; -import { setTimeout as setTimeoutAsync } from 'timers/promises'; +import { modifyUrl } from '@kbn/std'; import { cloneDeepWith, isString } from 'lodash'; import { Key, Origin, type WebDriver } from 'selenium-webdriver'; import { Driver as ChromiumWebDriver } from 'selenium-webdriver/chrome'; -import { modifyUrl } from '@kbn/std'; +import { setTimeout as setTimeoutAsync } from 'timers/promises'; +import Url from 'url'; -import sharp from 'sharp'; import { NoSuchSessionError } from 'selenium-webdriver/lib/error'; +import sharp from 'sharp'; +import { FtrService, type FtrProviderContext } from '../../ftr_provider_context'; import { WebElementWrapper } from '../lib/web_element_wrapper'; -import { type FtrProviderContext, FtrService } from '../../ftr_provider_context'; import { Browsers } from '../remote/browsers'; import { + NETWORK_PROFILES, type NetworkOptions, type NetworkProfile, - NETWORK_PROFILES, } from '../remote/network_profiles'; export type Browser = BrowserService; @@ -246,6 +246,28 @@ class BrowserService extends FtrService { return await this.driver.get(url); } + /** + * Deletes all the cookies of the current browsing context. + * https://www.selenium.dev/documentation/webdriver/interactions/cookies/#delete-all-cookies + * + * @return {Promise} + */ + public async deleteAllCookies() { + await this.driver.manage().deleteAllCookies(); + } + + /** + * Adds a cookie to the current browsing context. You need to be on the domain that the cookie will be valid for. + * https://www.selenium.dev/documentation/webdriver/interactions/cookies/#add-cookie + * + * @param {string} name + * @param {string} value + * @return {Promise} + */ + public async setCookie(name: string, value: string) { + await this.driver.manage().addCookie({ name, value }); + } + /** * Retrieves the cookie with the given name. Returns null if there is no such cookie. The cookie will be returned as * a JSON object as described by the WebDriver wire protocol. @@ -258,6 +280,18 @@ class BrowserService extends FtrService { return await this.driver.manage().getCookie(cookieName); } + /** + * Returns a ‘successful serialized cookie data’ for current browsing context. + * If browser is no longer available it returns error. + * https://www.selenium.dev/documentation/webdriver/interactions/cookies/#get-all-cookies + * + * @param {string} cookieName + * @return {Promise} + */ + public async getCookies() { + return await this.driver.manage().getCookies(); + } + /** * Pauses the execution in the browser, similar to setting a breakpoint for debugging. * @return {Promise} diff --git a/x-pack/test_serverless/api_integration/test_suites/common/platform_security/index.ts b/x-pack/test_serverless/api_integration/test_suites/common/platform_security/index.ts index 8d5970aa843acf..86d4ad05cfc359 100644 --- a/x-pack/test_serverless/api_integration/test_suites/common/platform_security/index.ts +++ b/x-pack/test_serverless/api_integration/test_suites/common/platform_security/index.ts @@ -22,6 +22,7 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./role_mappings')); loadTestFile(require.resolve('./sessions')); loadTestFile(require.resolve('./users')); + loadTestFile(require.resolve('./request_as_viewer')); loadTestFile(require.resolve('./user_profiles')); loadTestFile(require.resolve('./views')); }); diff --git a/x-pack/test_serverless/api_integration/test_suites/common/platform_security/request_as_viewer.ts b/x-pack/test_serverless/api_integration/test_suites/common/platform_security/request_as_viewer.ts new file mode 100644 index 00000000000000..e04a98b4ff7e72 --- /dev/null +++ b/x-pack/test_serverless/api_integration/test_suites/common/platform_security/request_as_viewer.ts @@ -0,0 +1,33 @@ +/* + * 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 ({ getService }: FtrProviderContext) { + describe('security/request as viewer', () => { + const svlUserManager = getService('svlUserManager'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + let credentials: { Cookie: string }; + + before(async () => { + // get auth header for Viewer role + credentials = await svlUserManager.getApiCredentialsForRole('viewer'); + }); + + it('returns full status payload for authenticated request', async () => { + const { body } = await supertestWithoutAuth + .get('/api/status') + .set(credentials) + .set('kbn-xsrf', 'kibana'); + + expect(body.name).to.be.a('string'); + expect(body.uuid).to.be.a('string'); + expect(body.version.number).to.be.a('string'); + }); + }); +} diff --git a/x-pack/test_serverless/functional/page_objects/svl_common_page.ts b/x-pack/test_serverless/functional/page_objects/svl_common_page.ts index 7762bf92d046ac..3709efe36ea1db 100644 --- a/x-pack/test_serverless/functional/page_objects/svl_common_page.ts +++ b/x-pack/test_serverless/functional/page_objects/svl_common_page.ts @@ -15,6 +15,9 @@ export function SvlCommonPageProvider({ getService, getPageObjects }: FtrProvide const deployment = getService('deployment'); const log = getService('log'); const browser = getService('browser'); + const svlUserManager = getService('svlUserManager'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const svlCommonApi = getService('svlCommonApi'); const delay = (ms: number) => new Promise((resolve) => { @@ -22,13 +25,54 @@ export function SvlCommonPageProvider({ getService, getPageObjects }: FtrProvide }); return { + async loginWithRole(role: string) { + await retry.waitForWithTimeout( + `Logging in by setting browser cookie for '${role}' role`, + 30_000, + async () => { + log.debug(`Delete all the cookies in the current browser context`); + await browser.deleteAllCookies(); + log.debug(`Setting the cookie for '${role}' role`); + const sidCookie = await svlUserManager.getSessionCookieForRole(role); + // Loading bootstrap.js in order to be on the domain that the cookie will be set for. + await browser.get(deployment.getHostPort() + '/bootstrap.js'); + await browser.setCookie('sid', sidCookie); + // Cookie should be already set in the browsing context, navigating to the Home page + await browser.get(deployment.getHostPort()); + // Verifying that we are logged in + if (await testSubjects.exists('userMenuButton', { timeout: 10_000 })) { + log.debug('userMenuButton found, login passed'); + } else { + throw new Error(`Failed to login with cookie for '${role}' role`); + } + + // Validating that the new cookie in the browser is set for the correct user + const browserCookies = await browser.getCookies(); + if (browserCookies.length === 0) { + throw new Error(`The cookie is missing in browser context`); + } + const { body } = await supertestWithoutAuth + .get('/internal/security/me') + .set(svlCommonApi.getInternalRequestHeader()) + .set('Cookie', `sid=${browserCookies[0].value}`); + + const userData = await svlUserManager.getUserData(role); + // email returned from API call must match the email for the specified role + if (body.email === userData.email) { + log.debug(`The new cookie is properly set for '${role}' role`); + return true; + } else { + throw new Error( + `Cookie is not set properly, expected email is '${userData.email}', but found '${body.email}'` + ); + } + } + ); + }, + async navigateToLoginForm() { const url = deployment.getHostPort() + '/login'; await browser.get(url); - // ensure welcome screen won't be shown. This is relevant for environments which don't allow - // to use the yml setting, e.g. cloud - await browser.setLocalStorageItem('home:welcome:show', 'false'); - log.debug('Waiting for Login Form to appear.'); await retry.waitForWithTimeout('login form', 10_000, async () => { return await pageObjects.security.isLoginFormVisible(); diff --git a/x-pack/test_serverless/functional/test_suites/common/platform_security/index.ts b/x-pack/test_serverless/functional/test_suites/common/platform_security/index.ts index bbb66a98418682..7427384fc73bf1 100644 --- a/x-pack/test_serverless/functional/test_suites/common/platform_security/index.ts +++ b/x-pack/test_serverless/functional/test_suites/common/platform_security/index.ts @@ -9,6 +9,7 @@ import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('Serverless Common UI - Platform Security', function () { + loadTestFile(require.resolve('./viewer_role_login')); loadTestFile(require.resolve('./api_keys')); loadTestFile(require.resolve('./navigation/avatar_menu')); loadTestFile(require.resolve('./user_profiles/user_profiles')); diff --git a/x-pack/test_serverless/functional/test_suites/common/platform_security/viewer_role_login.ts b/x-pack/test_serverless/functional/test_suites/common/platform_security/viewer_role_login.ts new file mode 100644 index 00000000000000..767abe540c2f50 --- /dev/null +++ b/x-pack/test_serverless/functional/test_suites/common/platform_security/viewer_role_login.ts @@ -0,0 +1,32 @@ +/* + * 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 { FtrProviderContext } from '../../../ftr_provider_context'; + +const VIEWER_ROLE = 'viewer'; + +export default function ({ getPageObject, getService }: FtrProviderContext) { + describe(`Login as ${VIEWER_ROLE}`, function () { + const svlCommonPage = getPageObject('svlCommonPage'); + const testSubjects = getService('testSubjects'); + const svlUserManager = getService('svlUserManager'); + + before(async () => { + await svlCommonPage.loginWithRole(VIEWER_ROLE); + }); + + it('should be able to see correct profile', async () => { + await svlCommonPage.assertProjectHeaderExists(); + await svlCommonPage.assertUserAvatarExists(); + await svlCommonPage.clickUserAvatar(); + await svlCommonPage.assertUserMenuExists(); + const actualFullname = await testSubjects.getVisibleText('contextMenuPanelTitle'); + const userData = await svlUserManager.getUserData(VIEWER_ROLE); + expect(actualFullname).to.be(userData.fullname); + }); + }); +} diff --git a/x-pack/test_serverless/shared/services/index.ts b/x-pack/test_serverless/shared/services/index.ts index a3168734b28eeb..3803ca84490e50 100644 --- a/x-pack/test_serverless/shared/services/index.ts +++ b/x-pack/test_serverless/shared/services/index.ts @@ -8,10 +8,12 @@ import { SvlReportingServiceProvider } from './svl_reporting'; import { SupertestProvider, SupertestWithoutAuthProvider } from './supertest'; import { SvlCommonApiServiceProvider } from './svl_common_api'; +import { SvlUserManagerProvider } from './user_manager/svl_user_manager'; export const services = { supertest: SupertestProvider, supertestWithoutAuth: SupertestWithoutAuthProvider, svlCommonApi: SvlCommonApiServiceProvider, svlReportingApi: SvlReportingServiceProvider, + svlUserManager: SvlUserManagerProvider, }; diff --git a/x-pack/test_serverless/shared/services/supertest.ts b/x-pack/test_serverless/shared/services/supertest.ts index 1a07e540184894..dec306dcb8f284 100644 --- a/x-pack/test_serverless/shared/services/supertest.ts +++ b/x-pack/test_serverless/shared/services/supertest.ts @@ -9,21 +9,29 @@ import { format as formatUrl } from 'url'; import supertest from 'supertest'; import { FtrProviderContext } from '../../functional/ftr_provider_context'; +/** + * Returns supertest.SuperTest instance that will not persist cookie between API requests. + */ export function SupertestProvider({ getService }: FtrProviderContext) { const config = getService('config'); const kbnUrl = formatUrl(config.get('servers.kibana')); - const ca = config.get('servers.kibana').certificateAuthorities; - return supertest.agent(kbnUrl, { ca }); + return supertest(kbnUrl); } +/** + * Returns supertest.SuperTest instance that will not persist cookie between API requests. + * If you need to pass certificate, do the following: + * await supertestWithoutAuth + * .get('/abc') + * .ca(CA_CERT) + */ export function SupertestWithoutAuthProvider({ getService }: FtrProviderContext) { const config = getService('config'); const kbnUrl = formatUrl({ ...config.get('servers.kibana'), auth: false, }); - const ca = config.get('servers.kibana').certificateAuthorities; - return supertest.agent(kbnUrl, { ca }); + return supertest(kbnUrl); } diff --git a/x-pack/test_serverless/shared/services/user_manager/saml_auth.ts b/x-pack/test_serverless/shared/services/user_manager/saml_auth.ts new file mode 100644 index 00000000000000..ac69ec402fa7c5 --- /dev/null +++ b/x-pack/test_serverless/shared/services/user_manager/saml_auth.ts @@ -0,0 +1,234 @@ +/* + * 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 { createSAMLResponse as createMockedSAMLResponse } from '@kbn/mock-idp-plugin/common'; +import { ToolingLog } from '@kbn/tooling-log'; +import axios, { AxiosResponse } from 'axios'; +import * as cheerio from 'cheerio'; +import { parse as parseCookie } from 'tough-cookie'; +import Url from 'url'; +import { Session } from './svl_user_manager'; + +export interface CloudSamlSessionParams { + email: string; + password: string; + kbnHost: string; + kbnVersion: string; + log: ToolingLog; +} + +export interface LocalSamlSessionParams { + username: string; + email: string; + fullname: string; + role: string; + kbnHost: string; + log: ToolingLog; +} + +export interface CreateSamlSessionParams { + hostname: string; + email: string; + password: string; + log: ToolingLog; +} + +const cleanException = (url: string, ex: any) => { + if (ex.isAxiosError) { + ex.url = url; + if (ex.response?.data) { + if (ex.response.data?.message) { + ex.response_message = ex.response.data.message; + } else { + ex.data = ex.response.data; + } + } + ex.config = { REDACTED: 'REDACTED' }; + ex.request = { REDACTED: 'REDACTED' }; + ex.response = { REDACTED: 'REDACTED' }; + } +}; + +const getSessionCookie = (cookieString: string) => { + return parseCookie(cookieString); +}; + +const getCloudHostName = () => { + const hostname = process.env.TEST_CLOUD_HOST_NAME; + if (!hostname) { + throw new Error('SAML Authentication requires TEST_CLOUD_HOST_NAME env variable to be set'); + } + + return hostname; +}; + +const getCloudUrl = (hostname: string, pathname: string) => { + return Url.format({ + protocol: 'https', + hostname, + pathname, + }); +}; + +const createCloudSession = async (params: CreateSamlSessionParams) => { + const { hostname, email, password, log } = params; + const cloudLoginUrl = getCloudUrl(hostname, '/api/v1/users/_login'); + let sessionResponse: AxiosResponse; + try { + sessionResponse = await axios.request({ + url: cloudLoginUrl, + method: 'post', + data: { + email, + password, + }, + headers: { + accept: 'application/json', + 'content-type': 'application/json', + }, + validateStatus: () => true, + maxRedirects: 0, + }); + } catch (ex) { + log.error('Failed to create the new cloud session'); + cleanException(cloudLoginUrl, ex); + throw ex; + } + + const firstName = sessionResponse?.data?.user?.data?.first_name ?? ''; + const lastName = sessionResponse?.data?.user?.data?.last_name ?? ''; + const firstLastNames = `${firstName} ${lastName}`.trim(); + const fullname = firstLastNames.length > 0 ? firstLastNames : email; + const token = sessionResponse?.data?.token as string; + if (!token) { + log.error( + `Failed to create cloud session, token is missing in response data: ${JSON.stringify( + sessionResponse?.data + )}` + ); + throw new Error(`Unable to create Cloud session, token is missing.`); + } + return { token, fullname }; +}; + +const createSAMLRequest = async (kbnUrl: string, kbnVersion: string, log: ToolingLog) => { + let samlResponse: AxiosResponse; + const url = kbnUrl + '/internal/security/login'; + try { + samlResponse = await axios.request({ + url, + method: 'post', + data: { + providerType: 'saml', + providerName: 'cloud-saml-kibana', + currentURL: kbnUrl + '/login?next=%2F"', + }, + headers: { + 'kbn-version': kbnVersion, + 'x-elastic-internal-origin': 'Kibana', + 'content-type': 'application/json', + }, + validateStatus: () => true, + maxRedirects: 0, + }); + } catch (ex) { + log.error('Failed to create SAML request'); + cleanException(url, ex); + throw ex; + } + + const cookie = getSessionCookie(samlResponse.headers['set-cookie']![0]); + if (!cookie) { + throw new Error(`Failed to parse cookie from SAML response headers`); + } + + const location = samlResponse?.data?.location as string; + if (!location) { + throw new Error( + `Failed to get location from SAML response data: ${JSON.stringify(samlResponse.data)}` + ); + } + return { location, sid: cookie.value }; +}; + +const createSAMLResponse = async (url: string, ecSession: string) => { + const samlResponse = await axios.get(url, { + headers: { + Cookie: `ec_session=${ecSession}`, + }, + }); + const $ = cheerio.load(samlResponse.data); + const value = $('input').attr('value') ?? ''; + if (value.length === 0) { + throw new Error('Failed to parse SAML response value'); + } + return value; +}; + +const finishSAMLHandshake = async ({ + kbnHost, + samlResponse, + sid, + log, +}: { + kbnHost: string; + samlResponse: string; + sid?: string; + log: ToolingLog; +}) => { + const encodedResponse = encodeURIComponent(samlResponse); + const url = kbnHost + '/api/security/saml/callback'; + let authResponse: AxiosResponse; + + try { + authResponse = await axios.request({ + url, + method: 'post', + data: `SAMLResponse=${encodedResponse}`, + headers: { + 'content-type': 'application/x-www-form-urlencoded', + ...(sid ? { Cookie: `sid=${sid}` } : {}), + }, + validateStatus: () => true, + maxRedirects: 0, + }); + } catch (ex) { + log.error('Failed to call SAML callback'); + cleanException(url, ex); + throw ex; + } + + const cookie = getSessionCookie(authResponse!.headers['set-cookie']![0]); + if (!cookie) { + throw new Error(`Failed to get cookie from SAML callback response headers`); + } + + return cookie; +}; + +export const createCloudSAMLSession = async (params: CloudSamlSessionParams) => { + const { email, password, kbnHost, kbnVersion, log } = params; + const hostname = getCloudHostName(); + const { token, fullname } = await createCloudSession({ hostname, email, password, log }); + const { location, sid } = await createSAMLRequest(kbnHost, kbnVersion, log); + const samlResponse = await createSAMLResponse(location, token); + const cookie = await finishSAMLHandshake({ kbnHost, samlResponse, sid, log }); + return new Session(cookie, email, fullname); +}; + +export const createLocalSAMLSession = async (params: LocalSamlSessionParams) => { + const { username, email, fullname, role, kbnHost, log } = params; + const samlResponse = await createMockedSAMLResponse({ + kibanaUrl: kbnHost + '/api/security/saml/callback', + username, + fullname, + email, + roles: [role], + }); + const cookie = await finishSAMLHandshake({ kbnHost, samlResponse, log }); + return new Session(cookie, email, fullname); +}; diff --git a/x-pack/test_serverless/shared/services/user_manager/svl_user_manager.ts b/x-pack/test_serverless/shared/services/user_manager/svl_user_manager.ts new file mode 100644 index 00000000000000..fd9560e7a7a80b --- /dev/null +++ b/x-pack/test_serverless/shared/services/user_manager/svl_user_manager.ts @@ -0,0 +1,168 @@ +/* + * 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 { REPO_ROOT } from '@kbn/repo-info'; +import * as fs from 'fs'; +import { load as loadYaml } from 'js-yaml'; +import { resolve } from 'path'; +import { Cookie } from 'tough-cookie'; +import Url from 'url'; +import { FtrProviderContext } from '../../../functional/ftr_provider_context'; +import { createCloudSAMLSession, createLocalSAMLSession } from './saml_auth'; + +export interface User { + readonly email: string; + readonly password: string; +} + +export type Role = string; + +export class Session { + readonly cookie; + readonly email; + readonly fullname; + constructor(cookie: Cookie, email: string, fullname: string) { + this.cookie = cookie; + this.email = email; + this.fullname = fullname; + } + + getCookieValue() { + return this.cookie.value; + } +} + +export function SvlUserManagerProvider({ getService }: FtrProviderContext) { + const kibanaServer = getService('kibanaServer'); + const config = getService('config'); + const log = getService('log'); + const isServerless = config.get('serverless'); + const isCloud = !!process.env.TEST_CLOUD; + const cloudRoleUsersFilePath = resolve(REPO_ROOT, '.ftr', 'role_users.json'); + const rolesDefinitionFilePath = resolve( + REPO_ROOT, + 'packages/kbn-es/src/serverless_resources/roles.yml' + ); + const roles: string[] = Object.keys(loadYaml(fs.readFileSync(rolesDefinitionFilePath, 'utf8'))); + const roleToUserMap: Map = new Map(); + + if (!isServerless) { + throw new Error(`'svlUserManager' service can't be used in non-serverless FTR context`); + } + + if (isCloud) { + // QAF should prepare the '.ftr/role_users.json' file for MKI pipelines + if (!fs.existsSync(cloudRoleUsersFilePath)) { + throw new Error( + `svlUserManager service requires user roles to be defined in ${cloudRoleUsersFilePath}` + ); + } + + const data = fs.readFileSync(cloudRoleUsersFilePath, 'utf8'); + if (data.length === 0) { + throw new Error(`'${cloudRoleUsersFilePath}' is empty: no roles are defined`); + } + for (const [roleName, user] of Object.entries(JSON.parse(data)) as Array<[string, User]>) { + roleToUserMap.set(roleName, user); + } + } + // to be re-used within FTR config run + const sessionCache = new Map(); + + const getCloudUserByRole = (role: string) => { + if (!roles.includes(role)) { + log.warning(`Role '${role}' is not listed in 'kbn-es/src/serverless_resources/roles.yml'`); + } + if (roleToUserMap.has(role)) { + return roleToUserMap.get(role)!; + } else { + throw new Error(`User with '${role}' role is not defined`); + } + }; + + const getSessionByRole = async (role: string) => { + if (sessionCache.has(role)) { + return sessionCache.get(role)!; + } + + const kbnHost = Url.format({ + protocol: config.get('servers.kibana.protocol'), + hostname: config.get('servers.kibana.hostname'), + port: isCloud ? undefined : config.get('servers.kibana.port'), + }); + let session: Session; + + if (isCloud) { + log.debug(`new SAML authentication with '${role}' role`); + const kbnVersion = await kibanaServer.version.get(); + session = await createCloudSAMLSession({ + ...getCloudUserByRole(role), + kbnHost, + kbnVersion, + log, + }); + } else { + log.debug(`new fake SAML authentication with '${role}' role`); + session = await createLocalSAMLSession({ + username: `elastic_${role}`, + email: `elastic_${role}@elastic.co`, + fullname: `test ${role}`, + role, + kbnHost, + log, + }); + } + + sessionCache.set(role, session); + return session; + }; + + return { + /* + * Returns auth header to do API calls with 'supertestWithoutAuth' service + * + * @example Create API call as a user with viewer role + * + * ```ts + * const credentials = await svlUserManager.getApiCredentialsForRole('viewer'); + * const response = await supertestWithoutAuth + * .get('/api/status') + * .set(credentials) + * .set('kbn-xsrf', 'kibana'); + * ``` + */ + async getApiCredentialsForRole(role: string) { + const session = await getSessionByRole(role); + return { Cookie: `sid=${session.getCookieValue()}` }; + }, + + /** + * Returns sid cookie that can be added to browser context for authentication + * + * @example Set cookie in browser context to login with specific role + * + * ```ts + * const sidCookie = await svlUserManager.getSessionCookieForRole(role); + * Loading bootstrap.js in order to be on the domain that the cookie will be set for. + * await browser.get(deployment.getHostPort() + '/bootstrap.js'); + * await browser.setCookie('sid', sidCookie); + * ``` + */ + async getSessionCookieForRole(role: string) { + const session = await getSessionByRole(role); + return session.getCookieValue(); + }, + + /** + * Returns SAML user email and full name + */ + async getUserData(role: string) { + const { email, fullname } = await getSessionByRole(role); + return { email, fullname }; + }, + }; +}