From f00ac7a8a21463e6bb4a2784c3a3884f36c62900 Mon Sep 17 00:00:00 2001 From: Dzmitry Lemechko Date: Fri, 11 Oct 2024 17:11:23 +0200 Subject: [PATCH] [FTR] support custom native roles in serverless tests (#194677) ## Summary This PR updates FTR services to support authentication with custom native role. Few notes: - for compatibility with MKI we reserve **"customRole"** as a custom role name used in tests - test user is **automatically assigned** to this role, but before login in browser/ generating cookie header or API key in each test suite **role privileges must me updated according test scenario** How to test: I added a new test file for Search project: `x-pack/test_serverless/functional/test_suites/search/custom_role_access.ts` It can be run locally with: ``` node scripts/functional_tests --config=x-pack/test_serverless/functional/test_suites/search/config.ts --grep "With custom role" ``` FTR UI test example: ```ts // First set privileges for custom role await samlAuth.setCustomRole({ elasticsearch: { indices: [{ names: ['logstash-*'], privileges: ['read', 'view_index_metadata'] }], }, kibana: [ { feature: { discover: ['read'], }, spaces: ['*'], }, ], }); }); // Then you can login in browser as a user with newly defined privileges await pageObjects.svlCommonPage.loginWithCustomRole(); ``` FTR api_integration test example: ```ts // First set privileges for custom role await samlAuth.setCustomRole({ elasticsearch: { indices: [{ names: ['logstash-*'], privileges: ['read', 'view_index_metadata'] }], }, kibana: [ { feature: { discover: ['read'], }, spaces: ['*'], }, ], }); }); // Then you can generate an API key with newly defined privileges const roleAuthc = await samlAuth.createM2mApiKeyWithRoleScope('customRole'); // Don't forget to invalidate the API key in the end await samlAuth.invalidateM2mApiKeyWithRoleScope(roleAuthc); ``` --- .../index.ts | 1 + .../services/saml_auth/get_auth_provider.ts | 4 +- .../services/saml_auth/index.ts | 6 +- .../services/saml_auth/saml_auth_provider.ts | 124 +++++++++++++----- .../saml_auth/serverless/auth_provider.ts | 27 +++- .../saml_auth/stateful/auth_provider.ts | 23 +++- .../default_configs/serverless.config.base.ts | 8 ++ x-pack/test_serverless/README.md | 53 ++++++++ .../api_integration/config.base.ts | 8 ++ .../common/platform_security/authorization.ts | 3 +- .../test_serverless/functional/config.base.ts | 8 ++ .../page_objects/svl_common_page.ts | 8 ++ .../test_suites/search/custom_role_access.ts | 88 +++++++++++++ .../functional/test_suites/search/index.ts | 2 +- 14 files changed, 321 insertions(+), 42 deletions(-) create mode 100644 x-pack/test_serverless/functional/test_suites/search/custom_role_access.ts diff --git a/packages/kbn-ftr-common-functional-services/index.ts b/packages/kbn-ftr-common-functional-services/index.ts index 769c1ecb66e07..a975c175c5837 100644 --- a/packages/kbn-ftr-common-functional-services/index.ts +++ b/packages/kbn-ftr-common-functional-services/index.ts @@ -30,6 +30,7 @@ export type { InternalRequestHeader, RoleCredentials, CookieCredentials, + KibanaRoleDescriptors, } from './services/saml_auth'; import { SamlAuthProvider } from './services/saml_auth/saml_auth_provider'; diff --git a/packages/kbn-ftr-common-functional-services/services/saml_auth/get_auth_provider.ts b/packages/kbn-ftr-common-functional-services/services/saml_auth/get_auth_provider.ts index f924e5b40d72a..eb7ababe7d2c3 100644 --- a/packages/kbn-ftr-common-functional-services/services/saml_auth/get_auth_provider.ts +++ b/packages/kbn-ftr-common-functional-services/services/saml_auth/get_auth_provider.ts @@ -12,8 +12,10 @@ import { ServerlessAuthProvider } from './serverless/auth_provider'; import { StatefulAuthProvider } from './stateful/auth_provider'; export interface AuthProvider { - getSupportedRoleDescriptors(): Record; + getSupportedRoleDescriptors(): Map; getDefaultRole(): string; + isCustomRoleEnabled(): boolean; + getCustomRole(): string; getRolesDefinitionPath(): string; getCommonRequestHeader(): { [key: string]: string }; getInternalRequestHeader(): { [key: string]: string }; diff --git a/packages/kbn-ftr-common-functional-services/services/saml_auth/index.ts b/packages/kbn-ftr-common-functional-services/services/saml_auth/index.ts index f379a3dc761ed..6caf70183998e 100644 --- a/packages/kbn-ftr-common-functional-services/services/saml_auth/index.ts +++ b/packages/kbn-ftr-common-functional-services/services/saml_auth/index.ts @@ -8,5 +8,9 @@ */ export { SamlAuthProvider } from './saml_auth_provider'; -export type { RoleCredentials, CookieCredentials } from './saml_auth_provider'; +export type { + RoleCredentials, + CookieCredentials, + KibanaRoleDescriptors, +} from './saml_auth_provider'; export type { InternalRequestHeader } from './default_request_headers'; diff --git a/packages/kbn-ftr-common-functional-services/services/saml_auth/saml_auth_provider.ts b/packages/kbn-ftr-common-functional-services/services/saml_auth/saml_auth_provider.ts index 1ee239ac5448e..5723dca7b339b 100644 --- a/packages/kbn-ftr-common-functional-services/services/saml_auth/saml_auth_provider.ts +++ b/packages/kbn-ftr-common-functional-services/services/saml_auth/saml_auth_provider.ts @@ -27,6 +27,19 @@ export interface CookieCredentials { [header: string]: string; } +export interface KibanaRoleDescriptors { + kibana: any; + elasticsearch?: any; +} + +const throwIfRoleNotSet = (role: string, customRole: string, roleDescriptors: Map) => { + if (role === customRole && !roleDescriptors.has(customRole)) { + throw new Error( + `Set privileges for '${customRole}' using 'samlAuth.setCustomRole' before authentication.` + ); + } +}; + export function SamlAuthProvider({ getService }: FtrProviderContext) { const config = getService('config'); const log = getService('log'); @@ -35,9 +48,8 @@ export function SamlAuthProvider({ getService }: FtrProviderContext) { const authRoleProvider = getAuthProvider({ config }); const supportedRoleDescriptors = authRoleProvider.getSupportedRoleDescriptors(); - const supportedRoles = Object.keys(supportedRoleDescriptors); - - const customRolesFileName: string | undefined = process.env.ROLES_FILENAME_OVERRIDE; + const supportedRoles = Array.from(supportedRoleDescriptors.keys()); + const customRolesFileName = process.env.ROLES_FILENAME_OVERRIDE; const cloudUsersFilePath = resolve(REPO_ROOT, '.ftr', customRolesFileName ?? 'role_users.json'); // Sharing the instance within FTR config run means cookies are persistent for each role between tests. @@ -61,14 +73,36 @@ export function SamlAuthProvider({ getService }: FtrProviderContext) { const DEFAULT_ROLE = authRoleProvider.getDefaultRole(); const COMMON_REQUEST_HEADERS = authRoleProvider.getCommonRequestHeader(); const INTERNAL_REQUEST_HEADERS = authRoleProvider.getInternalRequestHeader(); + const CUSTOM_ROLE = authRoleProvider.getCustomRole(); + const isCustomRoleEnabled = authRoleProvider.isCustomRoleEnabled(); + + const getAdminCredentials = async () => { + return await sessionManager.getApiCredentialsForRole('admin'); + }; + + const createApiKeyPayload = (role: string, roleDescriptors: any) => { + return { + name: `myTestApiKey_${role}`, + metadata: {}, + ...(role === CUSTOM_ROLE + ? { kibana_role_descriptors: roleDescriptors } + : { role_descriptors: roleDescriptors }), + }; + }; return { async getInteractiveUserSessionCookieWithRoleScope(role: string) { + // Custom role has no descriptors by default, check if it was added before authentication + throwIfRoleNotSet(role, CUSTOM_ROLE, supportedRoleDescriptors); return sessionManager.getInteractiveUserSessionCookieWithRoleScope(role); }, + async getM2MApiCookieCredentialsWithRoleScope(role: string): Promise { + // Custom role has no descriptors by default, check if it was added before authentication + throwIfRoleNotSet(role, CUSTOM_ROLE, supportedRoleDescriptors); return sessionManager.getApiCredentialsForRole(role); }, + async getEmail(role: string) { return sessionManager.getEmail(role); }, @@ -76,40 +110,41 @@ export function SamlAuthProvider({ getService }: FtrProviderContext) { async getUserData(role: string) { return sessionManager.getUserData(role); }, + async createM2mApiKeyWithDefaultRoleScope() { - log.debug(`Creating api key for default role: [${this.DEFAULT_ROLE}]`); - return this.createM2mApiKeyWithRoleScope(this.DEFAULT_ROLE); + log.debug(`Creating API key for default role: [${DEFAULT_ROLE}]`); + return this.createM2mApiKeyWithRoleScope(DEFAULT_ROLE); }, + async createM2mApiKeyWithRoleScope(role: string): Promise { // Get admin credentials in order to create the API key - const adminCookieHeader = await this.getM2MApiCookieCredentialsWithRoleScope('admin'); - - // Get the role descrtiptor for the role + const adminCookieHeader = await getAdminCredentials(); let roleDescriptors = {}; + if (role !== 'admin') { - const roleDescriptor = supportedRoleDescriptors[role]; + if (role === CUSTOM_ROLE && !isCustomRoleEnabled) { + throw new Error(`Custom roles are not supported for the current deployment`); + } + const roleDescriptor = supportedRoleDescriptors.get(role); if (!roleDescriptor) { - throw new Error(`Cannot create API key for non-existent role "${role}"`); + throw new Error( + role === CUSTOM_ROLE + ? `Before creating API key for '${CUSTOM_ROLE}', use 'samlAuth.setCustomRole' to set the role privileges` + : `Cannot create API key for non-existent role "${role}"` + ); } log.debug( - `Creating api key for ${role} role with the following privileges ${JSON.stringify( - roleDescriptor - )}` + `Creating API key for ${role} with privileges: ${JSON.stringify(roleDescriptor)}` ); - roleDescriptors = { - [role]: roleDescriptor, - }; + roleDescriptors = { [role]: roleDescriptor }; } + const payload = createApiKeyPayload(role, roleDescriptors); const response = await supertestWithoutAuth .post('/internal/security/api_key') .set(INTERNAL_REQUEST_HEADERS) .set(adminCookieHeader) - .send({ - name: 'myTestApiKey', - metadata: {}, - role_descriptors: roleDescriptors, - }); + .send(payload); if (response.status !== 200) { throw new Error( @@ -120,31 +155,50 @@ export function SamlAuthProvider({ getService }: FtrProviderContext) { const apiKey = response.body; const apiKeyHeader = { Authorization: 'ApiKey ' + apiKey.encoded }; - log.debug(`Created api key for role: [${role}]`); + log.debug(`Created API key for role: [${role}]`); return { apiKey, apiKeyHeader }; }, + async invalidateM2mApiKeyWithRoleScope(roleCredentials: RoleCredentials) { // Get admin credentials in order to invalidate the API key - const adminCookieHeader = await this.getM2MApiCookieCredentialsWithRoleScope('admin'); - - const requestBody = { - apiKeys: [ - { - id: roleCredentials.apiKey.id, - name: roleCredentials.apiKey.name, - }, - ], - isAdmin: true, - }; + const adminCookieHeader = await getAdminCredentials(); const { status } = await supertestWithoutAuth .post('/internal/security/api_key/invalidate') .set(INTERNAL_REQUEST_HEADERS) .set(adminCookieHeader) - .send(requestBody); + .send({ + apiKeys: [{ id: roleCredentials.apiKey.id, name: roleCredentials.apiKey.name }], + isAdmin: true, + }); expect(status).to.be(200); }, + + async setCustomRole(descriptors: KibanaRoleDescriptors) { + if (!isCustomRoleEnabled) { + throw new Error(`Custom roles are not supported for the current deployment`); + } + log.debug(`Updating role ${CUSTOM_ROLE}`); + const adminCookieHeader = await getAdminCredentials(); + + const customRoleDescriptors = { + kibana: descriptors.kibana, + elasticsearch: descriptors.elasticsearch ?? [], + }; + + const { status } = await supertestWithoutAuth + .put(`/api/security/role/${CUSTOM_ROLE}`) + .set(INTERNAL_REQUEST_HEADERS) + .set(adminCookieHeader) + .send(customRoleDescriptors); + + expect(status).to.be(204); + + // Update descriptors for custome role, it will be used to create API key + supportedRoleDescriptors.set(CUSTOM_ROLE, customRoleDescriptors); + }, + getCommonRequestHeader() { return COMMON_REQUEST_HEADERS; }, @@ -152,6 +206,8 @@ export function SamlAuthProvider({ getService }: FtrProviderContext) { getInternalRequestHeader(): InternalRequestHeader { return INTERNAL_REQUEST_HEADERS; }, + DEFAULT_ROLE, + CUSTOM_ROLE, }; } diff --git a/packages/kbn-ftr-common-functional-services/services/saml_auth/serverless/auth_provider.ts b/packages/kbn-ftr-common-functional-services/services/saml_auth/serverless/auth_provider.ts index 35314050f82ca..25038a3cfa17b 100644 --- a/packages/kbn-ftr-common-functional-services/services/saml_auth/serverless/auth_provider.ts +++ b/packages/kbn-ftr-common-functional-services/services/saml_auth/serverless/auth_provider.ts @@ -24,6 +24,8 @@ const projectDefaultRoles = new Map([ ['oblt', 'editor'], ]); +const projectTypesWithCustomRolesEnabled = ['es', 'security']; + const getDefaultServerlessRole = (projectType: string) => { if (projectDefaultRoles.has(projectType)) { return projectDefaultRoles.get(projectType)!; @@ -50,18 +52,39 @@ export class ServerlessAuthProvider implements AuthProvider { this.rolesDefinitionPath = resolve(SERVERLESS_ROLES_ROOT_PATH, this.projectType, 'roles.yml'); } - getSupportedRoleDescriptors(): Record { - return readRolesDescriptorsFromResource(this.rolesDefinitionPath) as Record; + getSupportedRoleDescriptors() { + const roleDescriptors = new Map( + Object.entries( + readRolesDescriptorsFromResource(this.rolesDefinitionPath) as Record + ) + ); + // Adding custom role to the map without privileges, so it can be later updated and used in the tests + if (this.isCustomRoleEnabled()) { + roleDescriptors.set(this.getCustomRole(), null); + } + return roleDescriptors; } + getDefaultRole(): string { return getDefaultServerlessRole(this.projectType); } + + isCustomRoleEnabled() { + return projectTypesWithCustomRolesEnabled.includes(this.projectType); + } + + getCustomRole() { + return 'customRole'; + } + getRolesDefinitionPath(): string { return this.rolesDefinitionPath; } + getCommonRequestHeader() { return COMMON_REQUEST_HEADERS; } + getInternalRequestHeader() { return getServerlessInternalRequestHeaders(); } diff --git a/packages/kbn-ftr-common-functional-services/services/saml_auth/stateful/auth_provider.ts b/packages/kbn-ftr-common-functional-services/services/saml_auth/stateful/auth_provider.ts index 2f9dfc512d872..f4a8b5a8abff1 100644 --- a/packages/kbn-ftr-common-functional-services/services/saml_auth/stateful/auth_provider.ts +++ b/packages/kbn-ftr-common-functional-services/services/saml_auth/stateful/auth_provider.ts @@ -18,12 +18,31 @@ import { export class StatefulAuthProvider implements AuthProvider { private readonly rolesDefinitionPath = resolve(REPO_ROOT, STATEFUL_ROLES_ROOT_PATH, 'roles.yml'); - getSupportedRoleDescriptors(): Record { - return readRolesDescriptorsFromResource(this.rolesDefinitionPath) as Record; + + getSupportedRoleDescriptors() { + const roleDescriptors = new Map( + Object.entries( + readRolesDescriptorsFromResource(this.rolesDefinitionPath) as Record + ) + ); + // no privileges set by default + roleDescriptors.set(this.getCustomRole(), null); + + return roleDescriptors; } + getDefaultRole() { return 'editor'; } + + isCustomRoleEnabled() { + return true; + } + + getCustomRole() { + return 'customRole'; + } + getRolesDefinitionPath() { return this.rolesDefinitionPath; } diff --git a/x-pack/test/api_integration/deployment_agnostic/default_configs/serverless.config.base.ts b/x-pack/test/api_integration/deployment_agnostic/default_configs/serverless.config.base.ts index 1c49433d742af..f73af3a6d4bf7 100644 --- a/x-pack/test/api_integration/deployment_agnostic/default_configs/serverless.config.base.ts +++ b/x-pack/test/api_integration/deployment_agnostic/default_configs/serverless.config.base.ts @@ -100,6 +100,10 @@ export function createServerlessTestConfig { - describe('disabled', () => { + // skipped, see https://github.com/elastic/kibana/issues/194933 + describe.skip('disabled', () => { // Skipped due to change in QA environment for role management and spaces // TODO: revisit once the change is rolled out to all environments it.skip('get all privileges', async () => { diff --git a/x-pack/test_serverless/functional/config.base.ts b/x-pack/test_serverless/functional/config.base.ts index 963be692d58ea..b0cd556fe1d36 100644 --- a/x-pack/test_serverless/functional/config.base.ts +++ b/x-pack/test_serverless/functional/config.base.ts @@ -24,6 +24,10 @@ export function createTestConfig(options: CreateTestConfigOptions) { ...svlSharedConfig.get('esTestCluster'), serverArgs: [ ...svlSharedConfig.get('esTestCluster.serverArgs'), + // custom native roles are enabled only for search and security projects + ...(options.serverlessProject !== 'oblt' + ? ['xpack.security.authc.native_roles.enabled=true'] + : []), ...(options.esServerArgs ?? []), ], }, @@ -32,6 +36,10 @@ export function createTestConfig(options: CreateTestConfigOptions) { serverArgs: [ ...svlSharedConfig.get('kbnTestServer.serverArgs'), `--serverless=${options.serverlessProject}`, + // custom native roles are enabled only for search and security projects + ...(options.serverlessProject !== 'oblt' + ? ['--xpack.security.roleManagementEnabled=true'] + : []), ...(options.kbnServerArgs ?? []), ], }, 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 d132651e0badc..5533128c2d19e 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 @@ -141,6 +141,14 @@ export function SvlCommonPageProvider({ getService, getPageObjects }: FtrProvide await this.loginWithRole(svlUserManager.DEFAULT_ROLE); }, + /** + * + * Login to Kibana using SAML authentication with custom role + */ + async loginWithCustomRole() { + await this.loginWithRole(svlUserManager.CUSTOM_ROLE); + }, + async navigateToLoginForm() { const url = deployment.getHostPort() + '/login'; await browser.get(url); diff --git a/x-pack/test_serverless/functional/test_suites/search/custom_role_access.ts b/x-pack/test_serverless/functional/test_suites/search/custom_role_access.ts new file mode 100644 index 0000000000000..6e28289d4fb00 --- /dev/null +++ b/x-pack/test_serverless/functional/test_suites/search/custom_role_access.ts @@ -0,0 +1,88 @@ +/* + * 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'; +import { RoleCredentials } from '../../../shared/services'; + +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const pageObjects = getPageObjects(['svlCommonPage', 'timePicker', 'common', 'header']); + const samlAuth = getService('samlAuth'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + const testSubjects = getService('testSubjects'); + let roleAuthc: RoleCredentials; + + describe('With custom role', function () { + // skipping on MKI while we are working on a solution + this.tags(['skipMKI']); + before(async () => { + await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional'); + await kibanaServer.importExport.load('test/functional/fixtures/kbn_archiver/discover'); + await kibanaServer.uiSettings.update({ + defaultIndex: 'logstash-*', + }); + await samlAuth.setCustomRole({ + elasticsearch: { + indices: [{ names: ['logstash-*'], privileges: ['read', 'view_index_metadata'] }], + }, + kibana: [ + { + feature: { + discover: ['read'], + }, + spaces: ['*'], + }, + ], + }); + // login with custom role + await pageObjects.svlCommonPage.loginWithCustomRole(); + await pageObjects.svlCommonPage.assertUserAvatarExists(); + }); + + after(async () => { + await esArchiver.unload('test/functional/fixtures/es_archiver/logstash_functional'); + await kibanaServer.importExport.unload('test/functional/fixtures/kbn_archiver/discover'); + await kibanaServer.uiSettings.replace({}); + await kibanaServer.savedObjects.cleanStandardList(); + if (roleAuthc) { + await samlAuth.invalidateM2mApiKeyWithRoleScope(roleAuthc); + } + }); + + it('should have limited navigation menu', async () => { + await pageObjects.svlCommonPage.assertUserAvatarExists(); + // discover navigation link is present + await testSubjects.existOrFail('~nav-item-search_project_nav.kibana.discover'); + // dashboard and index_management navigation links are hidden + await testSubjects.missingOrFail('~nav-item-search_project_nav.kibana.dashboard'); + await testSubjects.missingOrFail( + 'nav-item-search_project_nav.content.management:index_management' + ); + }); + + it('should access Discover app', async () => { + await pageObjects.common.navigateToApp('discover'); + await pageObjects.timePicker.setDefaultAbsoluteRange(); + await pageObjects.header.waitUntilLoadingHasFinished(); + expect(await testSubjects.exists('unifiedHistogramChart')).to.be(true); + expect(await testSubjects.exists('discoverQueryHits')).to.be(true); + }); + + it('should access console with API key', async () => { + roleAuthc = await samlAuth.createM2mApiKeyWithRoleScope('customRole'); + const { body } = await supertestWithoutAuth + .get('/api/console/api_server') + .set(roleAuthc.apiKeyHeader) + .set(samlAuth.getInternalRequestHeader()) + .set({ 'kbn-xsrf': 'true' }) + .expect(200); + expect(body.es).to.be.ok(); + }); + }); +} diff --git a/x-pack/test_serverless/functional/test_suites/search/index.ts b/x-pack/test_serverless/functional/test_suites/search/index.ts index 250f99d13a3a1..b2be587e51ea5 100644 --- a/x-pack/test_serverless/functional/test_suites/search/index.ts +++ b/x-pack/test_serverless/functional/test_suites/search/index.ts @@ -23,7 +23,7 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./rules/rule_details')); loadTestFile(require.resolve('./console_notebooks')); loadTestFile(require.resolve('./search_playground/playground_overview')); - loadTestFile(require.resolve('./ml')); + loadTestFile(require.resolve('./custom_role_access')); }); }